@zeix/cause-effect 0.18.4 → 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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
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
+
3
13
  ## 0.18.4
4
14
 
5
15
  ### Fixed
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. The `equals` check is respected at every graph level: when a memo recomputes to the same value, downstream memos *and* effects are cleaned without running.
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). Calling `invalidate()` marks the memo dirty and propagates `FLAG_CHECK` to sinks — effects only re-run if the memo's `equals` check determines the value actually changed.
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. Calling `invalidate()` eagerly aborts any in-flight computation and propagates `FLAG_CHECK` to sinks.
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
+ ```
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Cause & Effect
2
2
 
3
- Version 0.18.4
3
+ Version 0.18.5
4
4
 
5
5
  **Cause & Effect** is a reactive state management primitives library for TypeScript. It provides the foundational building blocks for managing complex, dynamic, composite, and asynchronous state — correctly and performantly — in a unified signal graph.
6
6
 
package/index.dev.js CHANGED
@@ -399,6 +399,15 @@ function createScope(fn) {
399
399
  activeOwner = prevOwner;
400
400
  }
401
401
  }
402
+ function unown(fn) {
403
+ const prev = activeOwner;
404
+ activeOwner = null;
405
+ try {
406
+ return fn();
407
+ } finally {
408
+ activeOwner = prev;
409
+ }
410
+ }
402
411
  // src/nodes/state.ts
403
412
  function createState(value, options) {
404
413
  validateSignalValue(TYPE_STATE, value, options?.guard);
@@ -1604,6 +1613,7 @@ function isSlot(value) {
1604
1613
  export {
1605
1614
  valueString,
1606
1615
  untrack,
1616
+ unown,
1607
1617
  match,
1608
1618
  isTask,
1609
1619
  isStore,