@zeix/cause-effect 0.18.3 → 0.18.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +8 -8
- package/CHANGELOG.md +22 -0
- package/CLAUDE.md +36 -397
- package/OWNERSHIP_BUG.md +95 -0
- package/README.md +1 -1
- package/index.dev.js +18 -10
- package/index.js +1 -1
- package/index.ts +2 -1
- package/package.json +1 -1
- package/src/graph.ts +26 -4
- package/src/nodes/memo.ts +1 -3
- package/src/nodes/task.ts +1 -3
- package/test/effect.test.ts +204 -0
- package/test/unown.test.ts +179 -0
- package/types/index.d.ts +2 -2
- package/types/src/graph.d.ts +12 -1
- package/.ai-context.md +0 -281
- package/examples/events-sensor.ts +0 -187
- package/examples/selector-sensor.ts +0 -173
package/ARCHITECTURE.md
CHANGED
|
@@ -106,14 +106,14 @@ The first four flags (`CLEAN`/`CHECK`/`DIRTY`/`RUNNING`) are used by the core gr
|
|
|
106
106
|
|
|
107
107
|
`FLAG_RELINK` is used exclusively by composite signal types (Store, List, Collection, deriveCollection) that manage their own child signals. When a structural mutation adds or removes child signals, the node is flagged `FLAG_DIRTY | FLAG_RELINK`. On the next `get()`, the composite signal's fast path reads the flag: if `FLAG_RELINK` is set, it forces a tracked `refresh()` after rebuilding the value so that `recomputeMemo()` can call `link()` for new child signals and `trimSources()` for removed ones. This avoids the previous approach of nulling `node.sources`/`node.sourcesTail`, which orphaned edges in upstream sink lists. `FLAG_RELINK` is always cleared by `recomputeMemo()`, which assigns `node.flags = FLAG_RUNNING` (clearing all bits) at the start of recomputation.
|
|
108
108
|
|
|
109
|
-
### The `propagate(node)` Function
|
|
109
|
+
### The `propagate(node, newFlag?)` Function
|
|
110
110
|
|
|
111
|
-
When a source value changes, `propagate()` walks its sink list
|
|
111
|
+
When a source value changes, `propagate()` walks its sink list. The `newFlag` parameter defaults to `FLAG_DIRTY` but callers may pass `FLAG_CHECK` for speculative invalidation (e.g., watched callbacks where the source value may not have actually changed).
|
|
112
112
|
|
|
113
|
-
- **Memo/Task sinks** (have `sinks` field): Flagged `DIRTY
|
|
114
|
-
- **Effect sinks** (no `sinks` field): Flagged `
|
|
113
|
+
- **Memo/Task sinks** (have `sinks` field): Flagged with `newFlag` (typically `DIRTY`). Their own sinks are recursively flagged `CHECK`. If the node has an in-flight `AbortController`, it is aborted immediately. Short-circuits if the node already carries an equal or higher flag.
|
|
114
|
+
- **Effect sinks** (no `sinks` field): Flagged with `newFlag` and pushed onto the `queuedEffects` array. An effect is only enqueued once — subsequent propagations escalate the flag (e.g., `CHECK` → `DIRTY`) without re-enqueuing. The flag is assigned (not OR'd) to clear `FLAG_RUNNING`, preserving the existing pattern where a state update inside a running effect triggers a re-run.
|
|
115
115
|
|
|
116
|
-
The two-level flagging (`DIRTY` for direct dependents, `CHECK` for transitive) avoids unnecessary recomputation. A `CHECK` node only recomputes if, upon inspection during `refresh()`, one of its sources turns out to have actually changed.
|
|
116
|
+
The two-level flagging (`DIRTY` for direct dependents, `CHECK` for transitive) avoids unnecessary recomputation. A `CHECK` node only recomputes if, upon inspection during `refresh()`, one of its sources turns out to have actually changed. This applies equally to memo, task, and effect nodes.
|
|
117
117
|
|
|
118
118
|
### The `refresh(node)` Function
|
|
119
119
|
|
|
@@ -141,7 +141,7 @@ If `FLAG_RUNNING` is encountered, a `CircularDependencyError` is thrown.
|
|
|
141
141
|
|
|
142
142
|
### The `flush()` Function
|
|
143
143
|
|
|
144
|
-
`flush()` iterates over `queuedEffects`, calling `refresh()` on each effect that is still `DIRTY`. A `flushing` guard prevents re-entrant flushes. Effects that were enqueued during the flush (due to async resolution or nested state changes) are processed in the same pass, since `flush()` reads the array length dynamically.
|
|
144
|
+
`flush()` iterates over `queuedEffects`, calling `refresh()` on each effect that is still `DIRTY` or `CHECK`. A `flushing` guard prevents re-entrant flushes. Effects that were enqueued during the flush (due to async resolution or nested state changes) are processed in the same pass, since `flush()` reads the array length dynamically. Effects with only `FLAG_CHECK` enter `refresh()`, which walks their sources — if no source value actually changed, the effect is cleaned without running.
|
|
145
145
|
|
|
146
146
|
### Effect Lifecycle
|
|
147
147
|
|
|
@@ -209,7 +209,7 @@ The `error` field preserves thrown errors: if `fn` throws, the error is stored a
|
|
|
209
209
|
|
|
210
210
|
**Reducer pattern**: The `prev` parameter enables state accumulation across recomputations without writable state.
|
|
211
211
|
|
|
212
|
-
**Watched lifecycle**: An optional `watched` callback in options provides lazy external invalidation. The callback receives an `invalidate` function and is invoked on first sink attachment. Calling `invalidate()`
|
|
212
|
+
**Watched lifecycle**: An optional `watched` callback in options provides lazy external invalidation. The callback receives an `invalidate` function and is invoked on first sink attachment. Calling `invalidate()` calls `propagate(node)` on the memo itself, which marks it `FLAG_DIRTY` and propagates `FLAG_CHECK` to downstream sinks, then flushes. During flush, downstream effects verify the memo via `refresh()` — if the memo's `equals` function determines the recomputed value is unchanged, the effect is cleaned without running. The returned cleanup is stored as `node.stop` and called when the last sink detaches. This enables patterns like DOM observation (MutationObserver) where a memo re-derives its value in response to external events, with the `equals` check respected at every level of the graph.
|
|
213
213
|
|
|
214
214
|
### Task (`src/nodes/task.ts`)
|
|
215
215
|
|
|
@@ -221,7 +221,7 @@ During dependency tracking, only the synchronous preamble of `fn` is tracked (be
|
|
|
221
221
|
|
|
222
222
|
`isPending()` returns `true` while a computation is in flight. `abort()` cancels the current computation manually. Errors are preserved like Memo, but old values are retained on errors (the last successful result remains accessible).
|
|
223
223
|
|
|
224
|
-
**Watched lifecycle**: Same pattern as Memo — an optional `watched` callback receives `invalidate` and is invoked on first sink attachment. Calling `invalidate()`
|
|
224
|
+
**Watched lifecycle**: Same pattern as Memo — an optional `watched` callback receives `invalidate` and is invoked on first sink attachment. Calling `invalidate()` calls `propagate(node)` on the task itself, which marks it dirty, aborts any in-flight computation eagerly via the `AbortController`, and propagates `FLAG_CHECK` to downstream sinks. Effects only re-run if the task's resolved value actually changes.
|
|
225
225
|
|
|
226
226
|
### Effect (`src/nodes/effect.ts`)
|
|
227
227
|
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.18.5
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **`unown(fn)` — escape hatch for DOM-owned component lifecycles**: Runs a callback with `activeOwner` set to `null`, preventing any `createScope` or `createEffect` calls inside from being registered as children of the current active owner. Use this in `connectedCallback` (or any external lifecycle hook) when a component manages its own cleanup independently via `disconnectedCallback` rather than through the reactive ownership tree.
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **Scope disposal bug when `connectedCallback` fires inside a re-runnable effect**: Previously, calling `createScope` inside a reactive effect (e.g. a list sync effect) registered the scope's `dispose` on that effect's cleanup list. When the effect re-ran — for example, because a `MutationObserver` fired — it called `runCleanup`, disposing all child scopes including those belonging to already-connected custom elements. This silently removed event listeners and reactive subscriptions from components that were still live in the DOM. Wrapping the `connectedCallback` body in `unown(() => createScope(...))` detaches the scope from the effect's ownership, so effect re-runs no longer dispose it.
|
|
12
|
+
|
|
13
|
+
## 0.18.4
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **Watched `invalidate()` now respects `equals` at every graph level**: Previously, calling `invalidate()` from a Memo or Task `watched` callback propagated `FLAG_DIRTY` directly to effect sinks, causing unconditional re-runs even when the recomputed value was unchanged. Now `invalidate()` delegates to `propagate(node)`, which marks the node itself `FLAG_DIRTY` and propagates `FLAG_CHECK` to downstream sinks. During flush, effects verify their sources via `refresh()` — if the memo's `equals` function determines the value is unchanged, the effect is cleaned without running. This eliminates unnecessary effect executions for watched memos with custom equality or stable return values.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- **`propagate()` supports `FLAG_CHECK` for effect nodes**: The effect branch of `propagate()` now respects the `newFlag` parameter instead of unconditionally setting `FLAG_DIRTY`. Effects are enqueued only on first notification; subsequent propagations escalate the flag (e.g., `CHECK` → `DIRTY`) without re-enqueuing.
|
|
22
|
+
- **`flush()` processes `FLAG_CHECK` effects**: The flush loop now calls `refresh()` on effects with either `FLAG_DIRTY` or `FLAG_CHECK`, enabling the check-sources-first path for effects.
|
|
23
|
+
- **Task `invalidate()` aborts eagerly**: Task watched callbacks now abort in-flight computations immediately during `propagate()` rather than deferring to `recomputeTask()`, consistent with the normal dependency-change path.
|
|
24
|
+
|
|
3
25
|
## 0.18.3
|
|
4
26
|
|
|
5
27
|
### Added
|
package/CLAUDE.md
CHANGED
|
@@ -1,381 +1,41 @@
|
|
|
1
1
|
# Claude Context for Cause & Effect
|
|
2
2
|
|
|
3
|
-
##
|
|
4
|
-
|
|
5
|
-
Cause & Effect is a reactive state management library that implements the signals pattern for JavaScript and TypeScript applications. The library is designed around the principle of **explicit reactivity** - where dependencies are automatically tracked but relationships remain clear and predictable.
|
|
6
|
-
|
|
7
|
-
### Core Philosophy
|
|
8
|
-
- **Granular Reactivity**: Changes propagate only to dependent computations, minimizing unnecessary work
|
|
9
|
-
- **Explicit Dependencies**: Dependencies are tracked through `.get()` calls, no hidden subscriptions
|
|
10
|
-
- **Type Safety First**: Comprehensive TypeScript support with strict generic constraints (`T extends {}`)
|
|
11
|
-
- **Performance Conscious**: Minimal overhead through efficient dependency tracking and batching
|
|
12
|
-
|
|
13
|
-
## Mental Model for Understanding the System
|
|
3
|
+
## Mental Model
|
|
14
4
|
|
|
15
5
|
Think of signals as **observable cells** in a spreadsheet:
|
|
16
|
-
- **State signals** are input cells for primitive values and objects
|
|
17
|
-
- **Sensor signals** are read-only cells that track external input (mouse position, resize, mutable objects) and update lazily
|
|
18
|
-
- **Memo signals** are formula cells that automatically recalculate when dependencies change
|
|
19
|
-
- **Task signals** are async formula cells with abort semantics and pending state
|
|
20
|
-
- **Store signals** are structured tables where individual columns (properties) are reactive
|
|
21
|
-
- **List signals** are tables with stable row IDs that survive sorting and reordering
|
|
22
|
-
- **Collection signals** are externally-fed reactive tables with a watched lifecycle (WebSocket, SSE), or derived tables from Lists/Collections via `.deriveCollection()`
|
|
23
|
-
- **Slot signals** are stable cell references that delegate to a swappable backing cell — used by integration layers to swap a property's backing signal without breaking subscribers
|
|
24
|
-
- **Effects** are event handlers that trigger side effects when cells change
|
|
25
|
-
|
|
26
|
-
## Architectural Deep Dive
|
|
27
6
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
-
|
|
31
|
-
-
|
|
32
|
-
-
|
|
33
|
-
-
|
|
34
|
-
-
|
|
35
|
-
-
|
|
7
|
+
- **State** — input cell you write to
|
|
8
|
+
- **Sensor** — read-only cell driven by an external source (mouse, resize, MutationObserver); activates lazily
|
|
9
|
+
- **Memo** — formula cell; recomputes synchronously when dependencies change
|
|
10
|
+
- **Task** — async formula cell; auto-cancels in-flight work when dependencies change
|
|
11
|
+
- **Store** — structured row where each column is its own reactive cell
|
|
12
|
+
- **List** — table with stable row IDs that survive sorting and reordering
|
|
13
|
+
- **Collection** — externally-fed table (WebSocket, SSE) or a derived table via `.deriveCollection()`
|
|
14
|
+
- **Slot** — a stable cell reference that delegates to a swappable backing cell
|
|
15
|
+
- **Effect** — a sink that runs side effects; the only node that never has downstream dependents
|
|
36
16
|
|
|
37
|
-
|
|
17
|
+
## Internal Node Shapes
|
|
38
18
|
|
|
39
19
|
```
|
|
40
|
-
StateNode<T> — source
|
|
41
|
-
MemoNode<T> — source + sink (Memo, Slot, Store, List, Collection)
|
|
42
|
-
TaskNode<T> — source + sink +
|
|
20
|
+
StateNode<T> — source only (State, Sensor)
|
|
21
|
+
MemoNode<T> — source + sink (Memo, Slot, Store, List, Collection internals)
|
|
22
|
+
TaskNode<T> — source + sink + AbortController (Task)
|
|
43
23
|
EffectNode — sink + owner (Effect)
|
|
44
|
-
Scope — owner
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
### Signal Hierarchy and Type System
|
|
48
|
-
|
|
49
|
-
```typescript
|
|
50
|
-
// Base Signal interface - all signals implement this
|
|
51
|
-
interface Signal<T extends {}> {
|
|
52
|
-
get(): T
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// State adds mutation methods
|
|
56
|
-
type State<T extends {}> = {
|
|
57
|
-
get(): T
|
|
58
|
-
set(value: T): void
|
|
59
|
-
update(fn: (current: T) => T): void
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// Memo is read-only computed
|
|
63
|
-
type Memo<T extends {}> = {
|
|
64
|
-
get(): T
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// Task adds async control
|
|
68
|
-
type Task<T extends {}> = {
|
|
69
|
-
get(): T
|
|
70
|
-
isPending(): boolean
|
|
71
|
-
abort(): void
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// Slot delegates to a swappable backing signal
|
|
75
|
-
type Slot<T extends {}> = {
|
|
76
|
-
get(): T
|
|
77
|
-
set(next: T): void
|
|
78
|
-
replace<U extends T>(next: Signal<U>): void
|
|
79
|
-
current(): Signal<T>
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Collection interface
|
|
83
|
-
type Collection<T extends {}> = {
|
|
84
|
-
get(): T[]
|
|
85
|
-
at(index: number): Signal<T> | undefined
|
|
86
|
-
byKey(key: string): Signal<T> | undefined
|
|
87
|
-
keys(): IterableIterator<string>
|
|
88
|
-
deriveCollection<R extends {}>(callback: (sourceValue: T) => R): Collection<R>
|
|
89
|
-
deriveCollection<R extends {}>(callback: (sourceValue: T, abort: AbortSignal) => Promise<R>): Collection<R>
|
|
90
|
-
}
|
|
91
|
-
```
|
|
92
|
-
|
|
93
|
-
The generic constraint `T extends {}` is crucial - it excludes `null` and `undefined` at the type level, preventing common runtime errors and making the API more predictable.
|
|
94
|
-
|
|
95
|
-
### Collection Architecture
|
|
96
|
-
|
|
97
|
-
**Collections** (`createCollection`): Externally-driven collections with watched lifecycle
|
|
98
|
-
- Created via `createCollection(watched, options?)` — mirrors `createSensor(watched, options?)`
|
|
99
|
-
- The `watched` callback receives an `applyChanges(diffResult)` function for granular add/change/remove operations
|
|
100
|
-
- `options.value` provides initial items (default `[]`), `options.keyConfig` configures key generation
|
|
101
|
-
- Lazy activation: `watched` callback invoked on first effect access, cleanup when unwatched
|
|
102
|
-
|
|
103
|
-
**Derived Collections** (`deriveCollection`): Transformed from Lists or other Collections
|
|
104
|
-
- Created via `list.deriveCollection(callback)` or `collection.deriveCollection(callback)`
|
|
105
|
-
- Also available as `deriveCollection(source, callback)` (internal helper, exported for advanced use)
|
|
106
|
-
- Item-level memoization: sync callbacks use `createMemo`, async callbacks use `createTask`
|
|
107
|
-
- Structural changes tracked via an internal `MemoNode` that reads `source.keys()`
|
|
108
|
-
- Chainable for data pipelines
|
|
109
|
-
|
|
110
|
-
### Store and List Architecture
|
|
111
|
-
|
|
112
|
-
**Store signals** (`createStore`): Transform objects into reactive data structures
|
|
113
|
-
- Each property becomes its own State signal (primitives) or nested Store (objects) via Proxy
|
|
114
|
-
- Uses an internal `MemoNode` for structural reactivity (add/remove properties)
|
|
115
|
-
- `diff()` computes granular changes when calling `set()`
|
|
116
|
-
- Dynamic property addition/removal with `add()`/`remove()`
|
|
117
|
-
|
|
118
|
-
**List signals** (`createList`): Arrays with stable keys and reactive items
|
|
119
|
-
- Each item becomes a `State` signal in a `Map<string, State<T>>`
|
|
120
|
-
- Uses an internal `MemoNode` for structural reactivity
|
|
121
|
-
- Configurable key generation: auto-increment, string prefix, or function
|
|
122
|
-
- Provides `byKey()`, `keyAt()`, `indexOfKey()` for key-based access
|
|
123
|
-
- `deriveCollection()` creates derived Collections
|
|
124
|
-
|
|
125
|
-
### Computed Signal Memoization Strategy
|
|
126
|
-
|
|
127
|
-
Computed signals implement smart memoization:
|
|
128
|
-
- **Dependency Tracking**: Automatically tracks which signals are accessed during computation via `link()`
|
|
129
|
-
- **Stale Detection**: Flag-based dirty checking (CLEAN, CHECK, DIRTY) — only recalculates when dependencies actually change
|
|
130
|
-
- **Async Support**: `createTask` handles Promise-based computations with automatic AbortController cancellation
|
|
131
|
-
- **Error Handling**: Preserves error states and prevents cascade failures
|
|
132
|
-
- **Reducer Capabilities**: Access to previous value enables state accumulation and transitions
|
|
133
|
-
|
|
134
|
-
## Resource Management with Watch Callbacks
|
|
135
|
-
|
|
136
|
-
Sensor, Collection, Memo (with `watched` option), and Task (with `watched` option) use a **watched callback** pattern for lazy resource management. Resources are allocated only when a signal is first accessed by an effect and automatically cleaned up when no effects are watching:
|
|
137
|
-
|
|
138
|
-
```typescript
|
|
139
|
-
// Sensor: track external input with state updates
|
|
140
|
-
const mousePos = createSensor<{ x: number; y: number }>((set) => {
|
|
141
|
-
const handler = (e: MouseEvent) => set({ x: e.clientX, y: e.clientY })
|
|
142
|
-
window.addEventListener('mousemove', handler)
|
|
143
|
-
return () => window.removeEventListener('mousemove', handler)
|
|
144
|
-
})
|
|
145
|
-
|
|
146
|
-
// Sensor: observe a mutable external object (SKIP_EQUALITY)
|
|
147
|
-
const element = createSensor<HTMLElement>((set) => {
|
|
148
|
-
const node = document.getElementById('status')!
|
|
149
|
-
set(node)
|
|
150
|
-
const observer = new MutationObserver(() => set(node))
|
|
151
|
-
observer.observe(node, { attributes: true })
|
|
152
|
-
return () => observer.disconnect() // cleanup when unwatched
|
|
153
|
-
}, { value: node, equals: SKIP_EQUALITY })
|
|
154
|
-
|
|
155
|
-
// Collection: receive keyed data from external source
|
|
156
|
-
const feed = createCollection<{ id: string; text: string }>((applyChanges) => {
|
|
157
|
-
const es = new EventSource('/feed')
|
|
158
|
-
es.onmessage = (e) => applyChanges(JSON.parse(e.data))
|
|
159
|
-
return () => es.close()
|
|
160
|
-
}, { keyConfig: item => item.id })
|
|
161
|
-
```
|
|
162
|
-
|
|
163
|
-
Store and List signals support an optional `watched` callback in their options:
|
|
164
|
-
|
|
165
|
-
```typescript
|
|
166
|
-
const user = createStore({ name: 'Alice', email: 'alice@example.com' }, {
|
|
167
|
-
watched: () => {
|
|
168
|
-
console.log('Store is now being watched')
|
|
169
|
-
const ws = new WebSocket('/updates')
|
|
170
|
-
return () => ws.close() // cleanup returned as Cleanup
|
|
171
|
-
}
|
|
172
|
-
})
|
|
173
|
-
```
|
|
174
|
-
|
|
175
|
-
**Watch Lifecycle**:
|
|
176
|
-
1. First effect accesses signal → watched callback executed
|
|
177
|
-
2. Last effect stops watching → returned cleanup function executed
|
|
178
|
-
3. New effect accesses signal → watched callback executed again
|
|
179
|
-
|
|
180
|
-
**Watched propagation through `deriveCollection()`**: When an effect reads a derived collection, the `watched` callback on the source List, Store, or Collection activates automatically — even through multiple levels of `.deriveCollection()` chaining. The reactive graph establishes edges from effect → derived collection → source, and `watched` fires when the first edge reaches the source. Mutations (add, remove, sort) on the source do **not** tear down and restart `watched` — the watcher remains stable as long as at least one downstream effect is subscribed. When the last effect disposes, cleanup cascades upstream through all intermediate nodes.
|
|
181
|
-
|
|
182
|
-
This pattern enables **lazy resource allocation** - resources are only consumed when actually needed and automatically freed when no longer used.
|
|
183
|
-
|
|
184
|
-
## Advanced Patterns and Best Practices
|
|
185
|
-
|
|
186
|
-
### When to Use Each Signal Type
|
|
187
|
-
|
|
188
|
-
**State (`createState`)**:
|
|
189
|
-
- Primitive values (numbers, strings, booleans)
|
|
190
|
-
- Objects that you replace entirely rather than mutating properties
|
|
191
|
-
- Simple toggles and flags
|
|
192
|
-
|
|
193
|
-
```typescript
|
|
194
|
-
const count = createState(0)
|
|
195
|
-
const theme = createState<'light' | 'dark'>('light')
|
|
196
|
-
```
|
|
197
|
-
|
|
198
|
-
**Sensor (`createSensor`)**:
|
|
199
|
-
- External input streams (mouse position, window size, media queries)
|
|
200
|
-
- External mutable objects observed via MutationObserver, IntersectionObserver, etc.
|
|
201
|
-
- Returns a read-only signal that activates lazily and tears down when unwatched
|
|
202
|
-
- Starts undefined until first `set()` unless `options.value` is provided
|
|
203
|
-
|
|
204
|
-
```typescript
|
|
205
|
-
// Tracking external values (default equality)
|
|
206
|
-
const windowSize = createSensor<{ w: number; h: number }>((set) => {
|
|
207
|
-
const update = () => set({ w: innerWidth, h: innerHeight })
|
|
208
|
-
update()
|
|
209
|
-
window.addEventListener('resize', update)
|
|
210
|
-
return () => window.removeEventListener('resize', update)
|
|
211
|
-
})
|
|
212
|
-
|
|
213
|
-
// Observing a mutable object (SKIP_EQUALITY)
|
|
214
|
-
const el = createSensor<HTMLElement>((set) => {
|
|
215
|
-
const node = document.getElementById('box')!
|
|
216
|
-
set(node)
|
|
217
|
-
const obs = new MutationObserver(() => set(node))
|
|
218
|
-
obs.observe(node, { attributes: true })
|
|
219
|
-
return () => obs.disconnect()
|
|
220
|
-
}, { value: node, equals: SKIP_EQUALITY })
|
|
221
|
-
```
|
|
222
|
-
|
|
223
|
-
**Store (`createStore`)**:
|
|
224
|
-
- Objects where individual properties change independently
|
|
225
|
-
- Proxy-based: access properties directly as signals
|
|
226
|
-
|
|
227
|
-
```typescript
|
|
228
|
-
const user = createStore({ name: 'Alice', email: 'alice@example.com' })
|
|
229
|
-
user.name.set('Bob') // Only name subscribers react
|
|
230
|
-
```
|
|
231
|
-
|
|
232
|
-
**List (`createList`)**:
|
|
233
|
-
- Arrays with stable keys and reactive items
|
|
234
|
-
|
|
235
|
-
```typescript
|
|
236
|
-
const todoList = createList([
|
|
237
|
-
{ id: 'task1', text: 'Learn signals' }
|
|
238
|
-
], { keyConfig: todo => todo.id })
|
|
239
|
-
const firstTodo = todoList.byKey('task1') // Access by stable key
|
|
240
|
-
```
|
|
241
|
-
|
|
242
|
-
**Collection (`createCollection`)**:
|
|
243
|
-
- Externally-driven keyed collections (WebSocket streams, SSE, external data feeds)
|
|
244
|
-
- Mirrors `createSensor(watched, options?)` — watched callback pattern with lazy lifecycle
|
|
245
|
-
- Same `Collection` interface — `.get()`, `.byKey()`, `.keys()`, `.deriveCollection()`
|
|
246
|
-
|
|
247
|
-
```typescript
|
|
248
|
-
const feed = createCollection<{ id: string; text: string }>((applyChanges) => {
|
|
249
|
-
const ws = new WebSocket('/feed')
|
|
250
|
-
ws.onmessage = (e) => applyChanges(JSON.parse(e.data))
|
|
251
|
-
return () => ws.close()
|
|
252
|
-
}, { keyConfig: item => item.id })
|
|
253
|
-
```
|
|
254
|
-
|
|
255
|
-
**Derived Collection** (`.deriveCollection()`):
|
|
256
|
-
- Read-only derived transformations of Lists or other Collections
|
|
257
|
-
- Created via `.deriveCollection()` method on List or Collection
|
|
258
|
-
|
|
259
|
-
```typescript
|
|
260
|
-
const doubled = numbers.deriveCollection((value: number) => value * 2)
|
|
261
|
-
|
|
262
|
-
// Async transformation
|
|
263
|
-
const enriched = users.deriveCollection(async (user, abort) => {
|
|
264
|
-
const response = await fetch(`/api/${user.id}`, { signal: abort })
|
|
265
|
-
return { ...user, details: await response.json() }
|
|
266
|
-
})
|
|
267
|
-
|
|
268
|
-
// Chain collections for data pipelines
|
|
269
|
-
const processed = todoList
|
|
270
|
-
.deriveCollection(todo => ({ ...todo, urgent: todo.priority > 8 }))
|
|
271
|
-
.deriveCollection(todo => todo.urgent ? `URGENT: ${todo.text}` : todo.text)
|
|
272
|
-
```
|
|
273
|
-
|
|
274
|
-
**Memo (`createMemo`)**:
|
|
275
|
-
- Synchronous derived computations with memoization
|
|
276
|
-
- Reducer pattern with previous value access
|
|
277
|
-
- Optional `watched(invalidate)` callback for lazy external invalidation (e.g., MutationObserver)
|
|
278
|
-
|
|
279
|
-
```typescript
|
|
280
|
-
const doubled = createMemo(() => count.get() * 2)
|
|
281
|
-
|
|
282
|
-
// Reducer pattern for state machines
|
|
283
|
-
const gameState = createMemo(prev => {
|
|
284
|
-
const action = playerAction.get()
|
|
285
|
-
switch (prev) {
|
|
286
|
-
case 'menu': return action === 'start' ? 'playing' : 'menu'
|
|
287
|
-
case 'playing': return action === 'pause' ? 'paused' : 'playing'
|
|
288
|
-
default: return prev
|
|
289
|
-
}
|
|
290
|
-
}, { value: 'menu' })
|
|
291
|
-
|
|
292
|
-
// Accumulating values
|
|
293
|
-
const runningTotal = createMemo(prev => prev + currentValue.get(), { value: 0 })
|
|
294
|
-
```
|
|
295
|
-
|
|
296
|
-
**Task (`createTask`)**:
|
|
297
|
-
- Async computations with automatic cancellation
|
|
298
|
-
- Optional `watched(invalidate)` callback for lazy external invalidation
|
|
299
|
-
|
|
300
|
-
```typescript
|
|
301
|
-
const userData = createTask(async (prev, abort) => {
|
|
302
|
-
const id = userId.get()
|
|
303
|
-
if (!id) return prev
|
|
304
|
-
const response = await fetch(`/users/${id}`, { signal: abort })
|
|
305
|
-
return response.json()
|
|
306
|
-
})
|
|
307
|
-
```
|
|
308
|
-
|
|
309
|
-
**Slot (`createSlot`)**:
|
|
310
|
-
- Property positions that must swap their backing signal without breaking subscribers
|
|
311
|
-
- Integration layers (custom elements, component systems) using `Object.defineProperty()`
|
|
312
|
-
- The slot object is a valid property descriptor (`get`, `set`, `configurable`, `enumerable`)
|
|
313
|
-
|
|
314
|
-
```typescript
|
|
315
|
-
const local = createState('default')
|
|
316
|
-
const slot = createSlot(local)
|
|
317
|
-
Object.defineProperty(element, 'label', slot)
|
|
318
|
-
|
|
319
|
-
// Swap backing signal — subscribers re-run automatically
|
|
320
|
-
const parentLabel = createMemo(() => parentState.get())
|
|
321
|
-
slot.replace(parentLabel)
|
|
322
|
-
```
|
|
323
|
-
|
|
324
|
-
### Error Handling Strategies
|
|
325
|
-
|
|
326
|
-
The library provides several layers of error handling:
|
|
327
|
-
|
|
328
|
-
1. **Input Validation**: `validateSignalValue()` and `validateCallback()` with custom error classes
|
|
329
|
-
2. **Async Cancellation**: AbortSignal integration prevents stale async operations
|
|
330
|
-
3. **Error Propagation**: Memo and Task preserve error states and throw on `.get()`
|
|
331
|
-
4. **Match Helper**: `match()` for ergonomic signal value extraction inside effects
|
|
332
|
-
|
|
333
|
-
```typescript
|
|
334
|
-
const apiData = createTask(async (prev, abort) => {
|
|
335
|
-
const response = await fetch('/api/data', { signal: abort })
|
|
336
|
-
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
337
|
-
return response.json()
|
|
338
|
-
})
|
|
339
|
-
|
|
340
|
-
createEffect(() => {
|
|
341
|
-
match([apiData], {
|
|
342
|
-
ok: ([data]) => updateUI(data),
|
|
343
|
-
nil: () => showLoading(),
|
|
344
|
-
err: errors => showError(errors[0].message)
|
|
345
|
-
})
|
|
346
|
-
})
|
|
24
|
+
Scope — owner only (createScope)
|
|
347
25
|
```
|
|
348
26
|
|
|
349
|
-
|
|
27
|
+
`activeOwner` tracks the current owner for cleanup registration. `activeSink` tracks the current sink for dependency tracking. These are separate: `untrack()` nulls `activeSink` (stops dependency edges), `unown()` nulls `activeOwner` (stops scope registration). You can read signals without tracking them, or create scopes without parenting them, independently.
|
|
350
28
|
|
|
351
|
-
|
|
352
|
-
```typescript
|
|
353
|
-
batch(() => {
|
|
354
|
-
user.name.set('Alice')
|
|
355
|
-
user.email.set('alice@example.com')
|
|
356
|
-
}) // Single effect trigger
|
|
357
|
-
```
|
|
29
|
+
## Non-Obvious Behaviors
|
|
358
30
|
|
|
359
|
-
|
|
360
|
-
```typescript
|
|
361
|
-
// Bad: depends on entire user object
|
|
362
|
-
const display = createMemo(() => user.get().name + user.get().email)
|
|
363
|
-
// Good: only depends on specific properties
|
|
364
|
-
const display = createMemo(() => user.name.get() + user.email.get())
|
|
365
|
-
```
|
|
31
|
+
**`T extends {}` excludes `null` and `undefined` at the type level.** Every signal generic uses this constraint. Signals cannot hold nullish values — use a wrapper type or a union with a sentinel if you need to represent absence.
|
|
366
32
|
|
|
367
|
-
|
|
33
|
+
**`byKey()`, `at()`, `keyAt()`, and `indexOfKey()` do NOT create graph edges.** They are direct lookups. An effect that only calls `collection.byKey('x')?.get()` will react to value changes of key `'x'` but will *not* re-run if that key is added or removed. To track structural changes, read `get()`, `keys()`, or `length`.
|
|
368
34
|
|
|
369
|
-
|
|
370
|
-
2. **Memory Leaks**: Clean up effects when components unmount
|
|
371
|
-
3. **Over-reactivity**: Structure data to minimize unnecessary updates
|
|
372
|
-
4. **Async Race Conditions**: Trust automatic cancellation with AbortSignal
|
|
373
|
-
5. **Circular Dependencies**: The graph detects and throws `CircularDependencyError`
|
|
374
|
-
6. **Untracked `byKey()`/`at()` access**: On Store, List, and Collection, `byKey()`, `at()`, `keyAt()`, and `indexOfKey()` do **not** create graph edges. They are direct lookups that bypass structural tracking. An effect using only `collection.byKey('x')?.get()` will react to value changes of key `'x'`, but will **not** re-run if key `'x'` is added or removed. Use `get()`, `keys()`, or `length` to track structural changes.
|
|
375
|
-
7. **Conditional reads delay `watched` activation**: Dependencies are tracked dynamically based on which `.get()` calls actually execute during each effect run. If a signal read is inside a branch that doesn't execute (e.g., inside the `ok` branch of `match()` while a Task is still pending), no edge is created and `watched` does not activate until that branch runs. **Fix:** read the signal eagerly before conditional logic:
|
|
35
|
+
**Conditional reads delay `watched` activation.** Dependencies are tracked only for `.get()` calls that actually execute during a given effect run. If a signal read is inside a branch that doesn't execute (e.g. the `ok` arm of `match()` while a Task is still pending), no edge is created and `watched` does not fire. Read signals eagerly before conditional logic when lifecycle activation matters:
|
|
376
36
|
|
|
377
37
|
```typescript
|
|
378
|
-
// Good:
|
|
38
|
+
// Good: both signals are always tracked; watched activates immediately
|
|
379
39
|
createEffect(() => {
|
|
380
40
|
match([task, derived], {
|
|
381
41
|
ok: ([result, values]) => renderList(values, result),
|
|
@@ -383,11 +43,11 @@ createEffect(() => {
|
|
|
383
43
|
})
|
|
384
44
|
})
|
|
385
45
|
|
|
386
|
-
// Bad:
|
|
46
|
+
// Bad: derived is only tracked after task resolves
|
|
387
47
|
createEffect(() => {
|
|
388
48
|
match([task], {
|
|
389
49
|
ok: ([result]) => {
|
|
390
|
-
const values = derived.get()
|
|
50
|
+
const values = derived.get()
|
|
391
51
|
renderList(values, result)
|
|
392
52
|
},
|
|
393
53
|
nil: () => showLoading(),
|
|
@@ -395,45 +55,24 @@ createEffect(() => {
|
|
|
395
55
|
})
|
|
396
56
|
```
|
|
397
57
|
|
|
398
|
-
|
|
58
|
+
**`equals` is respected at every level of the graph, not just at the source.** When a Memo recomputes to the same value (per its `equals` function), downstream Memos and Effects receive `FLAG_CHECK` and are cleaned without running. Effects only re-execute if at least one upstream node has actually changed value. This means a custom `equals` on an intermediate Memo can suppress entire subtrees of recomputation.
|
|
399
59
|
|
|
400
|
-
|
|
401
|
-
```typescript
|
|
402
|
-
type Events = {
|
|
403
|
-
userLogin: { userId: number; timestamp: number }
|
|
404
|
-
userLogout: { userId: number }
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
const eventBus = createStore<Events>({
|
|
408
|
-
userLogin: undefined as unknown as Events['userLogin'],
|
|
409
|
-
userLogout: undefined as unknown as Events['userLogout'],
|
|
410
|
-
})
|
|
60
|
+
**`SKIP_EQUALITY` forces propagation on every update.** Use it with `createSensor` when observing a mutable object where the reference stays the same but internal state changes (e.g. a DOM element passed through a MutationObserver). Without it, `setState` would see `old === new` and suppress propagation entirely.
|
|
411
61
|
|
|
412
|
-
|
|
413
|
-
eventBus[event].set(data)
|
|
414
|
-
}
|
|
62
|
+
**`watched` propagates through `.deriveCollection()` chains without restarting on mutations.** When an effect reads a derived collection, the `watched` callback on the source List/Collection activates automatically — even through multiple levels of chaining. Structural mutations (add, remove, sort) on the source do *not* tear down and restart `watched`; the watcher stays active as long as any downstream effect is subscribed. Cleanup cascades upstream only when the last subscriber disposes.
|
|
415
63
|
|
|
416
|
-
|
|
417
|
-
createEffect(() => {
|
|
418
|
-
const data = eventBus[event].get()
|
|
419
|
-
if (data != null) callback(data)
|
|
420
|
-
})
|
|
421
|
-
```
|
|
64
|
+
**Memo and Task callbacks receive the previous value as their first argument.** This enables reducer and accumulator patterns without external state:
|
|
422
65
|
|
|
423
|
-
### Data Processing Pipelines
|
|
424
66
|
```typescript
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
67
|
+
const runningTotal = createMemo((prev = 0) => prev + tick.get())
|
|
68
|
+
|
|
69
|
+
const gameState = createMemo((prev = 'menu') => {
|
|
70
|
+
const action = playerAction.get()
|
|
71
|
+
if (prev === 'menu' && action === 'start') return 'playing'
|
|
72
|
+
return prev
|
|
73
|
+
})
|
|
429
74
|
```
|
|
430
75
|
|
|
431
|
-
|
|
432
|
-
```typescript
|
|
433
|
-
const playlist = createList([
|
|
434
|
-
{ id: 'track1', title: 'Song A' }
|
|
435
|
-
], { keyConfig: track => track.id })
|
|
76
|
+
**`Slot` is a valid property descriptor.** The object returned by `createSlot()` has `get`, `set`, `configurable`, and `enumerable` — it can be passed directly to `Object.defineProperty()`. `slot.replace(nextSignal)` swaps the backing signal and invalidates all existing subscribers without them needing to re-subscribe.
|
|
436
77
|
|
|
437
|
-
|
|
438
|
-
playlist.sort((a, b) => a.title.localeCompare(b.title))
|
|
439
|
-
```
|
|
78
|
+
**`unown()` is the correct fix for DOM-owned component lifecycles.** When a custom element's `connectedCallback` fires inside a re-runnable reactive effect, any `createScope` call inside it would register its `dispose` on the effect's cleanup list — causing the component's effects and listeners to be torn down the next time the parent effect re-runs. Wrapping the `connectedCallback` body in `unown(() => createScope(...))` detaches the scope from the effect's ownership entirely, leaving `disconnectedCallback` as the sole lifecycle authority.
|
package/OWNERSHIP_BUG.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Ownership Bug: Component Scope Disposed by Parent Effect
|
|
2
|
+
|
|
3
|
+
## Symptom
|
|
4
|
+
|
|
5
|
+
In `module-todo`, `form-checkbox` elements wired via `checkboxes: pass(...)` lose their
|
|
6
|
+
reactive effects after the initial render — `setProperty('checked')` stops updating
|
|
7
|
+
`input.checked`, and the `on('change')` event listener is silently removed. Reading
|
|
8
|
+
`fc.checked` (a pull) still works correctly, but reactive push is gone.
|
|
9
|
+
|
|
10
|
+
## Root Cause
|
|
11
|
+
|
|
12
|
+
`createScope` registers its `dispose` on `prevOwner` — the `activeOwner` at the time the
|
|
13
|
+
scope is created. This is the right behavior for *hierarchical component trees* where a
|
|
14
|
+
parent component logically owns its children. But custom elements have a different ownership
|
|
15
|
+
model: **the DOM owns them**, via `connectedCallback` / `disconnectedCallback`.
|
|
16
|
+
|
|
17
|
+
The problem arises when a custom element's `connectedCallback` fires *inside* a
|
|
18
|
+
re-runnable reactive effect:
|
|
19
|
+
|
|
20
|
+
1. `module-todo`'s list sync effect runs inside `flush()` with `activeOwner = listSyncEffect`.
|
|
21
|
+
2. `list.append(li)` connects the `<li>`, which connects the `<form-checkbox>` inside it.
|
|
22
|
+
3. `form-checkbox.connectedCallback()` calls `runEffects(ui, setup(ui))`, which calls
|
|
23
|
+
`createScope`. `prevOwner = listSyncEffect`, so `dispose` is **registered on
|
|
24
|
+
`listSyncEffect`**.
|
|
25
|
+
4. Later, the `items = all('li[data-key]')` MutationObserver fires (the DOM mutation from
|
|
26
|
+
step 2 is detected) and re-queues `listSyncEffect`.
|
|
27
|
+
5. `runEffect(listSyncEffect)` calls `runCleanup(listSyncEffect)`, which calls all
|
|
28
|
+
registered cleanups — including `form-checkbox`'s `dispose`.
|
|
29
|
+
6. `dispose()` runs `runCleanup(fc1Scope)`, which removes the `on('change')` event
|
|
30
|
+
listener and trims the `setProperty` effect's reactive subscriptions.
|
|
31
|
+
7. The `<form-checkbox>` elements are still in the DOM, but their effects are permanently
|
|
32
|
+
gone. `connectedCallback` does not re-fire on already-connected elements.
|
|
33
|
+
|
|
34
|
+
The same problem recurs whenever `listSyncEffect` re-runs for any reason (e.g. a new todo
|
|
35
|
+
is added), disposing the scopes of all existing `<form-checkbox>` elements.
|
|
36
|
+
|
|
37
|
+
## Why `unown` Is the Correct Fix
|
|
38
|
+
|
|
39
|
+
`createScope`'s "register on `prevOwner`" semantics model one ownership relationship:
|
|
40
|
+
*parent reactive scope owns child*. Custom elements model a different one: *the DOM owns
|
|
41
|
+
the component*. `disconnectedCallback` is the authoritative cleanup trigger, not the
|
|
42
|
+
reactive graph.
|
|
43
|
+
|
|
44
|
+
`unown` is the explicit handshake that says "this scope is DOM-owned". It prevents
|
|
45
|
+
`createScope` from registering `dispose` on whatever reactive effect happens to be running
|
|
46
|
+
when `connectedCallback` fires, while leaving `this.#cleanup` + `disconnectedCallback` as
|
|
47
|
+
the sole lifecycle authority.
|
|
48
|
+
|
|
49
|
+
A `createScope`-only approach (without `unown`) has two failure modes:
|
|
50
|
+
|
|
51
|
+
| Scenario | Problem |
|
|
52
|
+
|---|---|
|
|
53
|
+
| Connects in static DOM (`activeOwner = null`) | `dispose` is discarded; effects never cleaned up on disconnect — memory leak |
|
|
54
|
+
| Connects inside a re-runnable effect | Same disposal bug as described above |
|
|
55
|
+
|
|
56
|
+
Per-item scopes (manually tracking a `Map<key, Cleanup>`) could also fix the disposal
|
|
57
|
+
problem but require significant restructuring of the list sync effect and still need
|
|
58
|
+
`unown` to prevent re-registration on each effect re-run.
|
|
59
|
+
|
|
60
|
+
## Required Changes
|
|
61
|
+
|
|
62
|
+
### `@zeix/cause-effect`
|
|
63
|
+
|
|
64
|
+
**`src/graph.ts`** — Add `unown` next to `untrack`:
|
|
65
|
+
|
|
66
|
+
```typescript
|
|
67
|
+
/**
|
|
68
|
+
* Runs a callback without any active owner.
|
|
69
|
+
* Any scopes or effects created inside the callback will not be registered as
|
|
70
|
+
* children of the current active owner (e.g. a re-runnable effect). Use this
|
|
71
|
+
* when a component or resource manages its own lifecycle independently of the
|
|
72
|
+
* reactive graph.
|
|
73
|
+
*
|
|
74
|
+
* @since 0.18.5
|
|
75
|
+
*/
|
|
76
|
+
function unown<T>(fn: () => T): T {
|
|
77
|
+
const prev = activeOwner
|
|
78
|
+
activeOwner = null
|
|
79
|
+
try {
|
|
80
|
+
return fn()
|
|
81
|
+
} finally {
|
|
82
|
+
activeOwner = prev
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Export it from the internal graph exports and from **`index.ts`**:
|
|
88
|
+
|
|
89
|
+
```typescript
|
|
90
|
+
export {
|
|
91
|
+
// ...existing exports...
|
|
92
|
+
unown,
|
|
93
|
+
untrack,
|
|
94
|
+
} from './src/graph'
|
|
95
|
+
```
|