@zeix/cause-effect 0.18.0 → 0.18.2

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/.ai-context.md CHANGED
@@ -28,8 +28,8 @@ Scope — owner-only (createScope)
28
28
  ### Signal Types
29
29
  - **State** (`createState`): Mutable signals for primitive values and objects
30
30
  - **Sensor** (`createSensor`): Read-only signals for external input streams with automatic state updates. Use `SKIP_EQUALITY` for mutable object observation.
31
- - **Memo** (`createMemo`): Synchronous derived computations with memoization and reducer capabilities
32
- - **Task** (`createTask`): Async derived computations with automatic abort/cancellation
31
+ - **Memo** (`createMemo`): Synchronous derived computations with memoization, reducer capabilities, and optional `watched(invalidate)` for external invalidation
32
+ - **Task** (`createTask`): Async derived computations with automatic abort/cancellation and optional `watched(invalidate)` for external invalidation
33
33
  - **Store** (`createStore`): Mutable object signals with individually reactive properties via Proxy
34
34
  - **List** (`createList`): Mutable array signals with stable keys and reactive items
35
35
  - **Collection** (`createCollection`): Reactive collections — either externally-driven with watched lifecycle, or derived from List/Collection with item-level memoization
@@ -223,7 +223,7 @@ const processed = items.deriveCollection(item => item.toUpperCase())
223
223
 
224
224
  ## Resource Management
225
225
 
226
- **Sensor and Collection** use a start callback that returns a Cleanup function:
226
+ **Sensor and Collection** use a watched callback that returns a Cleanup function:
227
227
  ```typescript
228
228
  const sensor = createSensor<T>((set) => {
229
229
  // setup input tracking, call set(value) to update
@@ -236,6 +236,17 @@ const feed = createCollection<T>((applyChanges) => {
236
236
  }, { keyConfig: item => item.id })
237
237
  ```
238
238
 
239
+ **Memo and Task** use an optional `watched` callback in options that receives an `invalidate` function:
240
+ ```typescript
241
+ const derived = createMemo(() => element.get().textContent ?? '', {
242
+ watched: (invalidate) => {
243
+ const obs = new MutationObserver(() => invalidate())
244
+ obs.observe(element.get(), { childList: true })
245
+ return () => obs.disconnect()
246
+ }
247
+ })
248
+ ```
249
+
239
250
  **Store and List** use an optional `watched` callback in options:
240
251
  ```typescript
241
252
  const store = createStore(initialValue, {
@@ -15,8 +15,8 @@ Cause & Effect is a reactive state management library for JavaScript/TypeScript
15
15
  ### Signal Types (all in `src/nodes/`)
16
16
  - **State** (`createState`): Mutable signals for values (`get`, `set`, `update`)
17
17
  - **Sensor** (`createSensor`): Read-only signals for external input with automatic state updates. Use `SKIP_EQUALITY` for mutable object observation.
18
- - **Memo** (`createMemo`): Synchronous derived computations with memoization and reducer capabilities
19
- - **Task** (`createTask`): Async derived computations with automatic AbortController cancellation
18
+ - **Memo** (`createMemo`): Synchronous derived computations with memoization, reducer capabilities, and optional `watched(invalidate)` for external invalidation
19
+ - **Task** (`createTask`): Async derived computations with automatic AbortController cancellation and optional `watched(invalidate)` for external invalidation
20
20
  - **Store** (`createStore`): Proxy-based reactive objects with per-property State/Store signals
21
21
  - **List** (`createList`): Reactive arrays with stable keys and per-item State signals
22
22
  - **Collection** (`createCollection`): Reactive collections — either externally-driven with watched lifecycle, or derived from List/Collection with item-level memoization
@@ -72,7 +72,8 @@ Cause & Effect is a reactive state management library for JavaScript/TypeScript
72
72
  - All signals have `.get()` for value access
73
73
  - Mutable signals (State) have `.set(value)` and `.update(fn)`
74
74
  - Store properties are automatically reactive signals via Proxy
75
- - Sensor/Collection use a start callback returning Cleanup (lazy activation)
75
+ - Sensor/Collection use a watched callback returning Cleanup (lazy activation)
76
+ - Memo/Task use optional `watched(invalidate)` callback in options for external invalidation
76
77
  - Store/List use optional `watched` callback in options returning Cleanup
77
78
  - Effects return a dispose function (Cleanup)
78
79
 
@@ -191,18 +192,27 @@ const count = createState(0, {
191
192
  ## Resource Management
192
193
 
193
194
  ```typescript
194
- // Sensor: lazy external input tracking
195
+ // Sensor: lazy external input tracking (watched callback with set)
195
196
  const sensor = createSensor<T>((set) => {
196
197
  // setup — call set(value) to update
197
198
  return () => { /* cleanup — called when last effect stops watching */ }
198
199
  })
199
200
 
200
- // Collection: lazy external data source
201
+ // Collection: lazy external data source (watched callback with applyChanges)
201
202
  const feed = createCollection<T>((applyChanges) => {
202
203
  // setup — call applyChanges(diffResult) on changes
203
204
  return () => { /* cleanup */ }
204
205
  }, { keyConfig: item => item.id })
205
206
 
207
+ // Memo/Task: optional watched callback with invalidate
208
+ const derived = createMemo(() => element.get().textContent ?? '', {
209
+ watched: (invalidate) => {
210
+ const obs = new MutationObserver(() => invalidate())
211
+ obs.observe(element.get(), { childList: true })
212
+ return () => obs.disconnect()
213
+ }
214
+ })
215
+
206
216
  // Store/List: optional watched callback
207
217
  const store = createStore(initialValue, {
208
218
  watched: () => {
package/ARCHITECTURE.md CHANGED
@@ -77,7 +77,12 @@ Before a sink recomputes, the engine sets `activeSink = node`, ensuring all `.ge
77
77
 
78
78
  After a sink finishes recomputing, `trimSources()` removes any edges beyond `sourcesTail` — these are dependencies from the previous execution that were not accessed this time. This is how the graph adapts to conditional dependencies.
79
79
 
80
- `unlink()` removes an edge from the source's sink list. If the source's sink list becomes empty and the source has a `stop` callback, that callback is invoked — this is how lazy resources (Sensor, Collection, watched Store/List) are deallocated when no longer observed.
80
+ `unlink()` removes an edge from the source's sink list. If the source's sink list becomes empty:
81
+
82
+ 1. **Watched cleanup**: If the source has a `stop` callback, it is invoked — this is how lazy resources (Sensor, Collection, watched Store/List) are deallocated when no longer observed.
83
+ 2. **Cascading cleanup**: If the source is also a sink (a MemoNode or TaskNode — identified by having a `sources` field), its own sources are trimmed via `trimSources()`. This recursively unlinks the node from its upstream dependencies, allowing their `stop` callbacks to fire if they also become unobserved.
84
+
85
+ The cascade is critical for intermediate nodes like `deriveCollection`'s internal MemoNode: when the last effect unsubscribes from the derived collection, `unlink()` removes the effect→derived edge, which triggers cascade cleanup of the derived→List edge, which in turn fires the List's `stop` (the `watched` cleanup). Without the cascade, the List would retain a stale sink reference and never clean up its watcher. Recursion depth is bounded by graph depth since the graph is a DAG.
81
86
 
82
87
  ### Dependency Tracking Opt-Out: `untrack(fn)`
83
88
 
@@ -87,7 +92,7 @@ After a sink finishes recomputing, `trimSources()` removes any edges beyond `sou
87
92
 
88
93
  ### Flag-Based Dirty Tracking
89
94
 
90
- Each sink node has a `flags` field with four states:
95
+ Each sink node has a `flags` field using a bitmap with five flags:
91
96
 
92
97
  | Flag | Value | Meaning |
93
98
  |------|-------|---------|
@@ -95,6 +100,11 @@ Each sink node has a `flags` field with four states:
95
100
  | `FLAG_CHECK` | 1 | A transitive dependency may have changed — verify before recomputing |
96
101
  | `FLAG_DIRTY` | 2 | A direct dependency changed — recomputation required |
97
102
  | `FLAG_RUNNING` | 4 | Currently executing (used for circular dependency detection and edge reuse) |
103
+ | `FLAG_RELINK` | 8 | Structural change requires edge re-establishment on next read |
104
+
105
+ The first four flags (`CLEAN`/`CHECK`/`DIRTY`/`RUNNING`) are used by the core graph engine in `propagate()` and `refresh()`. They are tested with bitmask operations that ignore higher bits, so `FLAG_RELINK` is invisible to the propagation and refresh machinery.
106
+
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.
98
108
 
99
109
  ### The `propagate(node)` Function
100
110
 
@@ -178,14 +188,14 @@ A mutable value container. The simplest signal type — `get()` links and return
178
188
 
179
189
  **Graph node**: `StateNode<T>` (source only)
180
190
 
181
- A read-only signal that tracks external input. The `start` callback receives a `set` function that updates the node's value via `setState()`. Sensors cover two patterns:
191
+ A read-only signal that tracks external input. The `watched` callback receives a `set` function that updates the node's value via `setState()`. Sensors cover two patterns:
182
192
 
183
- 1. **Tracking external values** (default): Receives replacement values from events (mouse position, resize events). Equality checking (`Object.is` by default) prevents unnecessary propagation.
193
+ 1. **Tracking external values** (default): Receives replacement values from events (mouse position, resize events). Equality checking (`===` by default) prevents unnecessary propagation.
184
194
  2. **Observing mutable objects** (with `SKIP_EQUALITY`): Holds a stable reference to a mutable object (DOM element, Map, Set). `set(sameRef)` with `equals: SKIP_EQUALITY` always propagates, notifying consumers that the object's internals have changed.
185
195
 
186
- The value starts undefined unless `options.value` is provided. Reading a sensor before its `start` callback has called `set()` (and without `options.value`) throws `UnsetSignalValueError`.
196
+ The value starts undefined unless `options.value` is provided. Reading a sensor before its `watched` callback has called `set()` (and without `options.value`) throws `UnsetSignalValueError`.
187
197
 
188
- **Lazy lifecycle**: The `start` callback is invoked on first sink attachment. The returned cleanup is stored as `node.stop` and called when the last sink detaches (via `unlink()`).
198
+ **Lazy lifecycle**: The `watched` callback is invoked on first sink attachment. The returned cleanup is stored as `node.stop` and called when the last sink detaches (via `unlink()`).
189
199
 
190
200
  ### Memo (`src/nodes/memo.ts`)
191
201
 
@@ -199,6 +209,8 @@ The `error` field preserves thrown errors: if `fn` throws, the error is stored a
199
209
 
200
210
  **Reducer pattern**: The `prev` parameter enables state accumulation across recomputations without writable state.
201
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.
213
+
202
214
  ### Task (`src/nodes/task.ts`)
203
215
 
204
216
  **Graph node**: `TaskNode<T>` (source + sink)
@@ -209,6 +221,8 @@ During dependency tracking, only the synchronous preamble of `fn` is tracked (be
209
221
 
210
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).
211
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.
225
+
212
226
  ### Effect (`src/nodes/effect.ts`)
213
227
 
214
228
  **Graph node**: `EffectNode` (sink only)
@@ -225,7 +239,7 @@ A reactive object where each property is its own signal. Properties are automati
225
239
 
226
240
  **Structural reactivity**: The internal `MemoNode` tracks edges from child signals to the store node. When consumers call `store.get()`, the node acts as both a source (to the consumer) and a sink (of its child signals). Changes to any child signal propagate through the store to its consumers.
227
241
 
228
- **Two-path access**: On first `get()`, `refresh()` executes `buildValue()` with `activeSink = storeNode`, establishing edges from each child signal to the store. Subsequent reads use a fast path: `untrack(buildValue)` rebuilds the value without re-establishing edges. Structural mutations (`add`/`remove`) call `invalidateEdges()` (nulling `node.sources`) to force re-establishment on the next read.
242
+ **Two-path access with `FLAG_RELINK`**: On first `get()`, `refresh()` executes `buildValue()` with `activeSink = storeNode`, establishing edges from each child signal to the store. Subsequent reads use a fast path: `untrack(buildValue)` rebuilds the value without re-establishing edges. Structural mutations (`add`/`remove`/`set` with additions or removals) set `FLAG_DIRTY | FLAG_RELINK` on the node. The next `get()` detects `FLAG_RELINK` and forces a tracked `refresh()` after rebuilding the value, so `recomputeMemo()` links new child signals and trims removed ones without orphaning edges.
229
243
 
230
244
  **Diff-based updates**: `store.set(newObj)` diffs the new object against the current state, applying only the granular changes to child signals. This preserves identity of unchanged child signals and their downstream edges.
231
245
 
@@ -237,38 +251,48 @@ A reactive object where each property is its own signal. Properties are automati
237
251
 
238
252
  A reactive array with stable keys and per-item reactivity. Each item becomes a `State<T>` signal, keyed by a configurable key generation strategy (auto-increment, string prefix, or custom function).
239
253
 
240
- **Structural reactivity**: Uses the same `MemoNode` + `invalidateEdges` + two-path access pattern as Store. The `buildValue()` function reads all child signals in key order, establishing edges on the refresh path.
254
+ **Structural reactivity**: Uses the same `MemoNode` + `FLAG_RELINK` + two-path access pattern as Store. The `buildValue()` function reads all child signals in key order, establishing edges on the refresh path.
241
255
 
242
256
  **Stable keys**: Keys survive sorting and reordering. `byKey(key)` returns a stable `State<T>` reference regardless of the item's current index. `sort()` reorders the keys array without destroying signals.
243
257
 
244
- **Diff-based updates**: `list.set(newArray)` uses `diffArrays()` to compute granular additions, changes, and removals. Changed items update their existing `State` signals; structural changes (add/remove) trigger `invalidateEdges()`.
258
+ **Diff-based updates**: `list.set(newArray)` uses `diffArrays()` to compute granular additions, changes, and removals. Changed items update their existing `State` signals; structural changes (add/remove) set `FLAG_DIRTY | FLAG_RELINK`.
245
259
 
246
260
  ### Collection (`src/nodes/collection.ts`)
247
261
 
248
262
  Collection implements two creation patterns that share the same `Collection<T>` interface:
249
263
 
250
- #### `createCollection(start, options?)` — externally driven
264
+ #### `createCollection(watched, options?)` — externally driven
251
265
 
252
266
  **Graph node**: `MemoNode<T[]>` (source + sink, tracks item values)
253
267
 
254
- An externally-driven reactive collection with a watched lifecycle, mirroring `createSensor(start, options?)`. The `start` callback receives an `applyChanges(diffResult)` function for granular add/change/remove operations. Initial items are provided via `options.value` (default `[]`).
268
+ An externally-driven reactive collection with a watched lifecycle, mirroring `createSensor(watched, options?)`. The `watched` callback receives an `applyChanges(diffResult)` function for granular add/change/remove operations. Initial items are provided via `options.value` (default `[]`).
255
269
 
256
- **Lazy lifecycle**: Like Sensor, the `start` callback is invoked on first sink attachment. The returned cleanup is stored as `node.stop` and called when the last sink detaches (via `unlink()`). The `startWatching()` guard ensures `start` fires before `link()` so synchronous mutations inside `start` update `node.value` before the activating effect reads it.
270
+ **Lazy lifecycle**: Like Sensor, the `watched` callback is invoked on first sink attachment. The returned cleanup is stored as `node.stop` and called when the last sink detaches (via `unlink()`). The `startWatching()` guard ensures `watched` fires before `link()` so synchronous mutations inside `watched` update `node.value` before the activating effect reads it.
257
271
 
258
- **External mutation via `applyChanges`**: Additions create new item signals (via configurable `createItem` factory, default `createState`). Changes update existing `State` signals. Removals delete signals and keys. Structural changes null out `node.sources` to force edge re-establishment. The node uses `equals: () => false` since structural changes are managed externally rather than detected by diffing.
272
+ **External mutation via `applyChanges`**: Additions create new item signals (via configurable `createItem` factory, default `createState`). Changes update existing `State` signals. Removals delete signals and keys. Structural changes set `FLAG_DIRTY | FLAG_RELINK` to trigger edge re-establishment on the next read. The node uses `equals: () => false` since structural changes are managed externally rather than detected by diffing.
259
273
 
260
- **Two-path access**: Same pattern as Store/List — first `get()` uses `refresh()` to establish edges from child signals to the collection node; subsequent reads use `untrack(buildValue)` to avoid re-linking.
274
+ **Two-path access with `FLAG_RELINK`**: Same pattern as Store/List — first `get()` uses `refresh()` to establish edges from child signals to the collection node; subsequent reads use `untrack(buildValue)` to avoid re-linking. When `FLAG_RELINK` is set, the next `get()` forces a tracked `refresh()` after rebuilding to link new child signals and trim removed ones.
261
275
 
262
276
  #### `deriveCollection(source, callback)` — internally derived
263
277
 
264
- **Graph node**: `MemoNode<string[]>` (source + sink, tracks keys not values)
278
+ **Graph node**: `MemoNode<T[]>` (source + sink, tracks item values)
265
279
 
266
280
  An internal factory (not exported from the public API) that creates a read-only derived transformation of a List or another Collection. Exposed to users via the `.deriveCollection(callback)` method on List and Collection. Each source item is individually memoized: sync callbacks create `Memo` signals, async callbacks create `Task` signals.
267
281
 
268
- **Two-level reactivity**: The derived collection's `MemoNode` tracks structural changes only its `fn` (`syncKeys`) reads `source.keys()` to detect additions and removals. Value-level changes flow through the individual per-item `Memo`/`Task` signals, which independently track their source item.
282
+ **Consistent with Store/List/createCollection**: The `MemoNode.value` is a `T[]` (cached computed values), and keys are tracked in a separate local `string[]` variable. The `equals` function uses shallow reference equality on array elements to prevent unnecessary downstream propagation when re-evaluation produces the same item references. The node starts `FLAG_DIRTY` to ensure the first `refresh()` establishes edges.
283
+
284
+ **Initialization**: Source keys are read via `untrack(() => source.keys())` to populate the signals map for direct access (`at()`, `byKey()`, `keyAt()`, `[Symbol.iterator]()`) without triggering premature `watched` activation on the upstream source. The node stays `FLAG_DIRTY` so the first `refresh()` with a real subscriber establishes proper graph edges.
285
+
286
+ **Non-destructive `syncKeys()` with `FLAG_RELINK`**: Like Store/List/createCollection, `deriveCollection`'s `syncKeys()` sets `FLAG_RELINK` on the node when keys change, without touching the edge lists. This avoids orphaning edges in upstream sink lists, which would prevent the cascading cleanup in `unlink()` from reaching the source List's `watched` lifecycle. All four composite signal types now use the same `FLAG_RELINK` mechanism for structural edge invalidation.
287
+
288
+ **Three-path `ensureFresh()`**: Access to the derived collection's value follows three distinct paths depending on the node's edge state:
269
289
 
270
- **Key differences from Store/List**: The `MemoNode.value` is a `string[]` (the keys array), not the collection's actual values. The `equals` function is a shallow string array comparison (`keysEqual`). The node starts `FLAG_DIRTY` (unlike Store/List which start clean after initialization) to ensure the first `refresh()` establishes the edge from source to collection.
290
+ | Path | Condition | Behavior |
291
+ |------|-----------|---------|
292
+ | Fast path | `node.sources` exists | `untrack(buildValue)` rebuilds without re-linking. If `FLAG_RELINK` is set, forces a tracked `refresh()` to link new child signals and trim deleted ones. |
293
+ | First subscriber | no `node.sources`, but `node.sinks` | `refresh()` via `recomputeMemo()` establishes all graph edges (source → derived, child signals → derived) in a single tracked pass. This is where `watched` activation propagates upstream. |
294
+ | No subscriber | neither sources nor sinks | `untrack(buildValue)` computes the value without establishing edges. Keeps `FLAG_DIRTY` so the first real subscriber triggers the "first subscriber" path. Used during chained `deriveCollection` initialization. |
271
295
 
272
- **No `invalidateEdges`**: Unlike Store/List, the derived collection never needs to re-establish its source edge because it has exactly one source (the parent List or Collection) that never changes identity. Structural changes (adding/removing per-item signals) happen inside `syncKeys` without affecting the source edge.
296
+ The first-subscriber path is the key to `watched` lifecycle propagation: when an effect first reads a derived collection, `recomputeMemo()` sets `activeSink = derivedNode`, then `buildValue()` calls `source.keys()`, which triggers `subscribe()` on the upstream List with a non-null `activeSink`. This creates the List→derived edge and activates the List's `watched` callback. When the effect later disposes, the cascading cleanup in `unlink()` traverses effect→derived→List, firing the List's `stop` cleanup.
273
297
 
274
- **Chaining**: `.deriveCollection()` creates a new derived collection from an existing one, forming a pipeline. Each level in the chain has its own `MemoNode` for structural tracking and its own set of per-item derived signals.
298
+ **Chaining**: `.deriveCollection()` creates a new derived collection from an existing one, forming a pipeline. Each level in the chain has its own `MemoNode` for value caching and its own set of per-item derived signals. The "no subscriber" path in `ensureFresh()` ensures intermediate levels don't prematurely activate upstream `watched` callbacks during construction — activation cascades through the entire chain only when the terminal effect subscribes.
package/CHANGELOG.md ADDED
@@ -0,0 +1,43 @@
1
+ # Changelog
2
+
3
+ ## 0.18.2
4
+
5
+ ### Fixed
6
+
7
+ - **`watched` propagation through `deriveCollection()` chains**: When an effect reads a derived collection, the `watched` callback on the source List, Store, or Collection now activates correctly — even through multiple levels of `.deriveCollection()` chaining. Previously, `deriveCollection` did not propagate sink subscriptions back to the source's `watched` lifecycle.
8
+ - **Stable `watched` lifecycle during mutations**: Adding, removing, or sorting items on a List (or Store/Collection) consumed through `deriveCollection()` no longer tears down and restarts the `watched` callback. The watcher remains active as long as at least one downstream effect is subscribed.
9
+ - **Cleanup cascade on disposal**: When the last effect unsubscribes from a derived collection chain, cleanup now propagates upstream through all intermediate nodes to the source, correctly invoking the `watched` cleanup function.
10
+
11
+ ### Changed
12
+
13
+ - **`FLAG_RELINK` replaces source-nulling in composite signals**: Store, List, Collection, and deriveCollection no longer null out `node.sources`/`node.sourcesTail` on structural mutations. Instead, a new `FLAG_RELINK` bitmap flag triggers a tracked `refresh()` on the next `.get()` call, re-establishing edges cleanly via `link()`/`trimSources()` without orphaning them.
14
+ - **Cascading `trimSources()` in `unlink()`**: When a MemoNode loses all sinks, its own sources are now trimmed recursively, ensuring upstream `watched` cleanup propagates correctly through intermediate nodes.
15
+ - **Three-path `ensureFresh()` in `deriveCollection`**: The internal freshness check now distinguishes between fast path (has sources, clean), first subscriber (has sinks but no sources yet), and no subscriber (untracked build). This prevents premature `watched` activation during initialization.
16
+
17
+ ## 0.18.1
18
+
19
+ ### Added
20
+
21
+ - **Memo `watched(invalidate)` option**: `createMemo(fn, { watched })` accepts a lazy lifecycle callback that receives an `invalidate` function. Calling `invalidate()` marks the memo dirty and triggers re-evaluation. The callback is invoked on first sink attachment and cleaned up when the last sink detaches. This enables patterns like DOM observation where a memo re-derives its value in response to external events (e.g., MutationObserver) without needing a separate Sensor.
22
+ - **Task `watched(invalidate)` option**: Same pattern as Memo. Calling `invalidate()` aborts any in-flight computation and triggers re-execution.
23
+ - **`CollectionChanges<T>` type**: New typed interface for collection mutations with `add?: T[]`, `change?: T[]`, `remove?: T[]` arrays. Replaces the untyped `DiffResult` records previously used by `CollectionCallback`.
24
+ - **`SensorOptions<T>` type**: Dedicated options type for `createSensor`, extending `SignalOptions<T>` with optional `value`.
25
+ - **`CollectionChanges` export** from public API (`index.ts`).
26
+ - **`SensorOptions` export** from public API (`index.ts`).
27
+
28
+ ### Changed
29
+
30
+ - **`createSensor` parameter renamed**: `start` → `watched` for consistency with Store/List lifecycle terminology.
31
+ - **`createSensor` options type**: `ComputedOptions<T>` → `SensorOptions<T>`. This decouples Sensor options from `ComputedOptions`, which now carries the `watched(invalidate)` field for Memo/Task.
32
+ - **`createCollection` parameter renamed**: `start` → `watched` for consistency.
33
+ - **`CollectionCallback` is now generic**: `CollectionCallback` → `CollectionCallback<T>`. The `applyChanges` parameter accepts `CollectionChanges<T>` instead of `DiffResult`.
34
+ - **`CollectionOptions.createItem` signature**: `(key: string, value: T) => Signal<T>` → `(value: T) => Signal<T>`. Key generation is now handled internally.
35
+ - **`KeyConfig<T>` return type relaxed**: Key functions may now return `string | undefined`. Returning `undefined` falls back to synthetic key generation.
36
+
37
+ ### Removed
38
+
39
+ - **`DiffResult` removed from public API**: No longer re-exported from `index.ts`. The type remains available from `src/nodes/list.ts` for internal use but is superseded by `CollectionChanges<T>` for collection mutations.
40
+
41
+ ## 0.18.0
42
+
43
+ Baseline release. Factory function API (`createState`, `createMemo`, `createTask`, `createEffect`, `createStore`, `createList`, `createCollection`, `createSensor`) with linked-list graph engine.
package/CLAUDE.md CHANGED
@@ -86,10 +86,10 @@ The generic constraint `T extends {}` is crucial - it excludes `null` and `undef
86
86
  ### Collection Architecture
87
87
 
88
88
  **Collections** (`createCollection`): Externally-driven collections with watched lifecycle
89
- - Created via `createCollection(start, options?)` — mirrors `createSensor(start, options?)`
90
- - The `start` callback receives an `applyChanges(diffResult)` function for granular add/change/remove operations
89
+ - Created via `createCollection(watched, options?)` — mirrors `createSensor(watched, options?)`
90
+ - The `watched` callback receives an `applyChanges(diffResult)` function for granular add/change/remove operations
91
91
  - `options.value` provides initial items (default `[]`), `options.keyConfig` configures key generation
92
- - Lazy activation: `start` callback invoked on first effect access, cleanup when unwatched
92
+ - Lazy activation: `watched` callback invoked on first effect access, cleanup when unwatched
93
93
 
94
94
  **Derived Collections** (`deriveCollection`): Transformed from Lists or other Collections
95
95
  - Created via `list.deriveCollection(callback)` or `collection.deriveCollection(callback)`
@@ -124,7 +124,7 @@ Computed signals implement smart memoization:
124
124
 
125
125
  ## Resource Management with Watch Callbacks
126
126
 
127
- Sensor and Collection signals use a **start 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:
127
+ 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:
128
128
 
129
129
  ```typescript
130
130
  // Sensor: track external input with state updates
@@ -164,9 +164,11 @@ const user = createStore({ name: 'Alice', email: 'alice@example.com' }, {
164
164
  ```
165
165
 
166
166
  **Watch Lifecycle**:
167
- 1. First effect accesses signal → start/watched callback executed
167
+ 1. First effect accesses signal → watched callback executed
168
168
  2. Last effect stops watching → returned cleanup function executed
169
- 3. New effect accesses signal → start/watched callback executed again
169
+ 3. New effect accesses signal → watched callback executed again
170
+
171
+ **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.
170
172
 
171
173
  This pattern enables **lazy resource allocation** - resources are only consumed when actually needed and automatically freed when no longer used.
172
174
 
@@ -230,7 +232,7 @@ const firstTodo = todoList.byKey('task1') // Access by stable key
230
232
 
231
233
  **Collection (`createCollection`)**:
232
234
  - Externally-driven keyed collections (WebSocket streams, SSE, external data feeds)
233
- - Mirrors `createSensor(start, options?)` — start callback pattern with watched lifecycle
235
+ - Mirrors `createSensor(watched, options?)` — watched callback pattern with lazy lifecycle
234
236
  - Same `Collection` interface — `.get()`, `.byKey()`, `.keys()`, `.deriveCollection()`
235
237
 
236
238
  ```typescript
@@ -263,6 +265,7 @@ const processed = todoList
263
265
  **Memo (`createMemo`)**:
264
266
  - Synchronous derived computations with memoization
265
267
  - Reducer pattern with previous value access
268
+ - Optional `watched(invalidate)` callback for lazy external invalidation (e.g., MutationObserver)
266
269
 
267
270
  ```typescript
268
271
  const doubled = createMemo(() => count.get() * 2)
@@ -283,6 +286,7 @@ const runningTotal = createMemo(prev => prev + currentValue.get(), { value: 0 })
283
286
 
284
287
  **Task (`createTask`)**:
285
288
  - Async computations with automatic cancellation
289
+ - Optional `watched(invalidate)` callback for lazy external invalidation
286
290
 
287
291
  ```typescript
288
292
  const userData = createTask(async (prev, abort) => {
@@ -344,6 +348,28 @@ const display = createMemo(() => user.name.get() + user.email.get())
344
348
  4. **Async Race Conditions**: Trust automatic cancellation with AbortSignal
345
349
  5. **Circular Dependencies**: The graph detects and throws `CircularDependencyError`
346
350
  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.
351
+ 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:
352
+
353
+ ```typescript
354
+ // Good: watched activates immediately, errors/nil in derived are also caught
355
+ createEffect(() => {
356
+ match([task, derived], {
357
+ ok: ([result, values]) => renderList(values, result),
358
+ nil: () => showLoading(),
359
+ })
360
+ })
361
+
362
+ // Bad: watched only activates after task resolves
363
+ createEffect(() => {
364
+ match([task], {
365
+ ok: ([result]) => {
366
+ const values = derived.get() // only tracked in this branch
367
+ renderList(values, result)
368
+ },
369
+ nil: () => showLoading(),
370
+ })
371
+ })
372
+ ```
347
373
 
348
374
  ## Advanced Patterns
349
375
 
package/GUIDE.md CHANGED
@@ -268,7 +268,7 @@ import { createCollection, createEffect } from '@zeix/cause-effect'
268
268
 
269
269
  const messages = createCollection((applyChanges) => {
270
270
  const ws = new WebSocket('/messages')
271
- ws.onmessage = (e) => applyChanges({ changed: true, add: JSON.parse(e.data) })
271
+ ws.onmessage = (e) => applyChanges({ add: JSON.parse(e.data) })
272
272
  return () => ws.close()
273
273
  }, { keyConfig: msg => msg.id })
274
274
 
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Cause & Effect
2
2
 
3
- Version 0.18.0
3
+ Version 0.18.2
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
 
@@ -266,7 +266,7 @@ Lists have `.keys()`, `.add()`, and `.remove()` methods like stores. Additionall
266
266
 
267
267
  ### Collection
268
268
 
269
- A reactive collection with item-level memoization. Collections can be externally-driven (via a start callback) or derived from a List or another Collection.
269
+ A reactive collection with item-level memoization. Collections can be externally-driven (via a watched callback) or derived from a List or another Collection.
270
270
 
271
271
  **Externally-driven collections** receive data from external sources (WebSocket, Server-Sent Events, etc.) via `applyChanges()`:
272
272
 
@@ -277,7 +277,7 @@ const items = createCollection((applyChanges) => {
277
277
  const ws = new WebSocket('/items')
278
278
  ws.onmessage = (e) => {
279
279
  const { add, change, remove } = JSON.parse(e.data)
280
- applyChanges({ changed: true, add, change, remove })
280
+ applyChanges({ add, change, remove })
281
281
  }
282
282
  return () => ws.close()
283
283
  }, { keyConfig: item => item.id })
@@ -285,7 +285,7 @@ const items = createCollection((applyChanges) => {
285
285
  createEffect(() => console.log('Items:', items.get()))
286
286
  ```
287
287
 
288
- The start callback activates lazily when the collection is first accessed by an effect and cleans up when no effects are watching. Options include `value` for initial items (default `[]`) and `keyConfig` for key generation.
288
+ The watched callback activates lazily when the collection is first accessed by an effect and cleans up when no effects are watching. Options include `value` for initial items (default `[]`) and `keyConfig` for key generation.
289
289
 
290
290
  **Derived collections** transform Lists or other Collections via `.deriveCollection()`:
291
291
 
@@ -476,7 +476,7 @@ dispose() // Cleans up the effect and runs the returned cleanup
476
476
 
477
477
  ### Resource Management with Watch Callbacks
478
478
 
479
- Sensor and Collection signals use a **start callback** for lazy resource management. The callback runs when the signal is first accessed by an effect and the returned cleanup function runs when no effects are watching:
479
+ Sensor and Collection signals use a **watched callback** for lazy resource management. The callback runs when the signal is first accessed by an effect and the returned cleanup function runs when no effects are watching:
480
480
 
481
481
  ```js
482
482
  import { createSensor, createCollection, createEffect } from '@zeix/cause-effect'
@@ -517,11 +517,42 @@ const user = createStore({ name: 'Alice' }, {
517
517
  })
518
518
  ```
519
519
 
520
+ **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 chaining. Mutations on the source do not tear down the watcher. When the last effect disposes, cleanup cascades upstream through all intermediate nodes.
521
+
522
+ **Tip — conditional reads delay activation**: Dependencies are tracked based on which `.get()` calls actually execute. If a signal read is inside a branch that doesn't run yet (e.g., inside `match()`'s `ok` branch while a Task is pending), `watched` won't activate until that branch executes. Read signals eagerly before conditional logic to ensure immediate activation:
523
+
524
+ ```js
525
+ createEffect(() => {
526
+ match([task, derived], { // derived is always tracked
527
+ ok: ([result, values]) => renderList(values, result),
528
+ nil: () => showLoading(),
529
+ })
530
+ })
531
+ ```
532
+
533
+ Memo and Task signals also support a `watched` option, but their callback receives an `invalidate` function that marks the signal dirty and triggers recomputation:
534
+
535
+ ```js
536
+ const changes = createMemo((prev) => {
537
+ const next = new Set(parent.querySelectorAll(selector))
538
+ // ... diff prev vs next ...
539
+ return { current: next, added, removed }
540
+ }, {
541
+ value: { current: new Set(), added: [], removed: [] },
542
+ watched: (invalidate) => {
543
+ const observer = new MutationObserver(() => invalidate())
544
+ observer.observe(parent, { childList: true, subtree: true })
545
+ return () => observer.disconnect()
546
+ }
547
+ })
548
+ ```
549
+
520
550
  This pattern is ideal for:
521
551
  - Event listeners that should only be active when data is being watched
522
552
  - Network connections that can be lazily established
523
553
  - Expensive computations that should pause when not needed
524
554
  - External subscriptions (WebSocket, Server-Sent Events, etc.)
555
+ - Computed signals that need to react to external events (DOM mutations, timers)
525
556
 
526
557
  ## Contributing & License
527
558
 
package/context7.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "url": "https://context7.com/zeixcom/cause-effect",
3
+ "public_key": "pk_opatBUM6blxfIrNVkGw84"
4
+ }