@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 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`. Their own sinks are recursively flagged `CHECK`. If the node has an in-flight `AbortController`, it is aborted immediately.
114
- - **Effect sinks** (no `sinks` field): Flagged `DIRTY` and pushed onto the `queuedEffects` array for later execution.
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()` marks the node `FLAG_DIRTY`, propagates to sinks, and flushes triggering re-evaluation of the memo's `fn` without changing any tracked dependency. The returned cleanup is stored as `node.stop` and called when the last sink detaches. This enables patterns like DOM observation (MutationObserver) where the memo re-derives its value in response to external events.
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()` marks the node dirty and triggers re-execution, which aborts any in-flight computation via the existing `AbortController` mechanism before starting a new one.
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
- ## Library Overview and Philosophy
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
- ### The Graph System
29
- The core of reactivity lies in the linked graph system (`src/graph.ts`):
30
- - Each source node maintains a linked list of `Edge` entries pointing to sink nodes
31
- - When a signal's `.get()` method is called during effect/memo/task execution, it automatically links the source to the active sink via `link()`
32
- - When a signal changes, it calls `propagate()` on all linked sinks, which flags them dirty
33
- - `flush()` processes queued effects after propagation
34
- - `batch()` defers flushing until the outermost batch completes
35
- - `trimSources()` removes stale edges after recomputation
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
- ### Node Types in the Graph
17
+ ## Internal Node Shapes
38
18
 
39
19
  ```
40
- StateNode<T> — source-only with equality + guard (State, Sensor)
41
- MemoNode<T> — source + sink (Memo, Slot, Store, List, Collection)
42
- TaskNode<T> — source + sink + async (Task)
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-only (createScope)
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
- ### Performance Optimization
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
- **Batching**: Use `batch()` for multiple updates
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
- **Granular Dependencies**: Structure computed signals to minimize dependencies
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
- ## Common Pitfalls
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
- 1. **Infinite Loops**: Don't update signals within their own computed callbacks
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: watched activates immediately, errors/nil in derived are also caught
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: watched only activates after task resolves
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() // only tracked in this branch
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
- ## Advanced Patterns
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
- ### Event Bus with Type Safety
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
- const emit = <K extends keyof Events>(event: K, data: Events[K]) => {
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
- const on = <K extends keyof Events>(event: K, callback: (data: Events[K]) => void) =>
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 rawData = createList([{ id: 1, value: 10 }], { keyConfig: item => String(item.id) })
426
- const processed = rawData
427
- .deriveCollection(item => ({ ...item, doubled: item.value * 2 }))
428
- .deriveCollection(item => ({ ...item, formatted: `$${item.doubled}` }))
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
- ### Stable List Keys
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
- const firstTrack = playlist.byKey('track1') // Persists through sorting
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.
@@ -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
+ ```