@zeix/cause-effect 0.18.1 → 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/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
 
@@ -229,7 +239,7 @@ A reactive object where each property is its own signal. Properties are automati
229
239
 
230
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.
231
241
 
232
- **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.
233
243
 
234
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.
235
245
 
@@ -241,11 +251,11 @@ A reactive object where each property is its own signal. Properties are automati
241
251
 
242
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).
243
253
 
244
- **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.
245
255
 
246
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.
247
257
 
248
- **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`.
249
259
 
250
260
  ### Collection (`src/nodes/collection.ts`)
251
261
 
@@ -259,9 +269,9 @@ An externally-driven reactive collection with a watched lifecycle, mirroring `cr
259
269
 
260
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.
261
271
 
262
- **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.
263
273
 
264
- **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.
265
275
 
266
276
  #### `deriveCollection(source, callback)` — internally derived
267
277
 
@@ -271,6 +281,18 @@ An internal factory (not exported from the public API) that creates a read-only
271
281
 
272
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.
273
283
 
274
- **Two-path access with structural fallback**: Same pattern as Store/List first `get()` uses `refresh()` to establish edges; subsequent reads use `untrack(buildValue)`. A `syncKeys()` step inside `buildValue` syncs the signals map with `source.keys()`. If keys changed, `syncKeys` nulls `node.sources` to force edge re-establishment via `refresh()`, ensuring new child signals are properly linked.
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:
289
+
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. |
295
+
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.
275
297
 
276
- **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.
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 CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
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
+
3
17
  ## 0.18.1
4
18
 
5
19
  ### Added
package/CLAUDE.md CHANGED
@@ -168,6 +168,8 @@ const user = createStore({ name: 'Alice', email: 'alice@example.com' }, {
168
168
  2. Last effect stops watching → returned cleanup function executed
169
169
  3. New effect accesses signal → watched callback executed again
170
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.
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
 
173
175
  ## Advanced Patterns and Best Practices
@@ -346,6 +348,28 @@ const display = createMemo(() => user.name.get() + user.email.get())
346
348
  4. **Async Race Conditions**: Trust automatic cancellation with AbortSignal
347
349
  5. **Circular Dependencies**: The graph detects and throws `CircularDependencyError`
348
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
+ ```
349
373
 
350
374
  ## Advanced Patterns
351
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.1
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
 
@@ -517,6 +517,19 @@ 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
+
520
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:
521
534
 
522
535
  ```js
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeix/cause-effect",
3
- "version": "0.18.1",
3
+ "version": "0.18.2",
4
4
  "author": "Esther Brunner",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/src/graph.ts CHANGED
@@ -161,6 +161,7 @@ const FLAG_CLEAN = 0
161
161
  const FLAG_CHECK = 1 << 0
162
162
  const FLAG_DIRTY = 1 << 1
163
163
  const FLAG_RUNNING = 1 << 2
164
+ const FLAG_RELINK = 1 << 3
164
165
 
165
166
  /* === Module State === */
166
167
 
@@ -244,9 +245,19 @@ function unlink(edge: Edge): Edge | null {
244
245
  if (prevSink) prevSink.nextSink = nextSink
245
246
  else source.sinks = nextSink
246
247
 
247
- if (!source.sinks && source.stop) {
248
- source.stop()
249
- source.stop = undefined
248
+ if (!source.sinks) {
249
+ if (source.stop) {
250
+ source.stop()
251
+ source.stop = undefined
252
+ }
253
+
254
+ // Cascade: if the source is also a sink (e.g. MemoNode, derived collection),
255
+ // trim its own sources so upstream watched callbacks can clean up
256
+ if ('sources' in source && source.sources) {
257
+ const sinkNode = source as SinkNode
258
+ sinkNode.sourcesTail = null
259
+ trimSources(sinkNode)
260
+ }
250
261
  }
251
262
 
252
263
  return nextSource
@@ -591,6 +602,7 @@ export {
591
602
  SKIP_EQUALITY,
592
603
  FLAG_CLEAN,
593
604
  FLAG_DIRTY,
605
+ FLAG_RELINK,
594
606
  flush,
595
607
  link,
596
608
  propagate,
@@ -9,6 +9,7 @@ import {
9
9
  type Cleanup,
10
10
  FLAG_CLEAN,
11
11
  FLAG_DIRTY,
12
+ FLAG_RELINK,
12
13
  link,
13
14
  type MemoNode,
14
15
  propagate,
@@ -129,13 +130,10 @@ function deriveCollection<T extends {}, U extends {}>(
129
130
  signals.set(key, signal as Memo<T>)
130
131
  }
131
132
 
132
- // Sync signals map with source keys, reading source.keys()
133
- // to establish a graph edge from source → this node.
133
+ // Sync signals map with the given keys.
134
134
  // Intentionally side-effectful: mutates the private signals map and keys
135
- // array. The side effects are idempotent and scoped to private state.
136
- function syncKeys(): void {
137
- const nextKeys = Array.from(source.keys())
138
-
135
+ // array. Sets FLAG_RELINK on the node if keys changed.
136
+ function syncKeys(nextKeys: string[]): void {
139
137
  if (!keysEqual(keys, nextKeys)) {
140
138
  const a = new Set(keys)
141
139
  const b = new Set(nextKeys)
@@ -143,19 +141,15 @@ function deriveCollection<T extends {}, U extends {}>(
143
141
  for (const key of keys) if (!b.has(key)) signals.delete(key)
144
142
  for (const key of nextKeys) if (!a.has(key)) addSignal(key)
145
143
  keys = nextKeys
146
-
147
- // Force re-establishment of edges on next refresh() so new
148
- // child signals are properly linked to this node
149
- node.sources = null
150
- node.sourcesTail = null
144
+ node.flags |= FLAG_RELINK
151
145
  }
152
146
  }
153
147
 
154
- // Build current value from child signals; syncKeys runs first to
155
- // ensure the signals map is up to date and — during refresh() —
156
- // to establish the graph edge from source → this node.
148
+ // Build current value from child signals.
149
+ // Reads source.keys() to sync the signals map and — during refresh() —
150
+ // to establish a graph edge from source → this node.
157
151
  function buildValue(): T[] {
158
- syncKeys()
152
+ syncKeys(Array.from(source.keys()))
159
153
  const result: T[] = []
160
154
  for (const key of keys) {
161
155
  try {
@@ -196,26 +190,40 @@ function deriveCollection<T extends {}, U extends {}>(
196
190
  if (node.sources) {
197
191
  if (node.flags) {
198
192
  node.value = untrack(buildValue)
199
- node.flags = FLAG_CLEAN
200
- // syncKeys may have nulled sources if keys changed
201
- // re-run with refresh() to establish edges to new child signals
202
- if (!node.sources) {
193
+ if (node.flags & FLAG_RELINK) {
194
+ // Keys changed new child signals need graph edges.
195
+ // Tracked recompute so link() adds new edges and
196
+ // trimSources() removes stale ones without orphaning.
203
197
  node.flags = FLAG_DIRTY
204
198
  refresh(node as unknown as SinkNode)
205
199
  if (node.error) throw node.error
200
+ } else {
201
+ node.flags = FLAG_CLEAN
206
202
  }
207
203
  }
208
- } else {
204
+ } else if (node.sinks) {
205
+ // First access with a downstream subscriber — use refresh()
206
+ // to establish graph edges via recomputeMemo
209
207
  refresh(node as unknown as SinkNode)
210
208
  if (node.error) throw node.error
209
+ } else {
210
+ // No subscribers yet (e.g., chained deriveCollection init) —
211
+ // compute value without establishing graph edges to prevent
212
+ // premature watched activation on upstream sources.
213
+ // Keep FLAG_DIRTY so the first refresh() with a real subscriber
214
+ // will establish proper graph edges.
215
+ node.value = untrack(buildValue)
211
216
  }
212
217
  }
213
218
 
214
- // Initialize signals for current source keys
215
- const initialKeys = Array.from(source.keys())
219
+ // Initialize signals for current source keys — untrack to prevent
220
+ // triggering watched callbacks on upstream sources during construction.
221
+ // The first refresh() (triggered by an effect) will establish proper
222
+ // graph edges; this just populates the signals map for direct access.
223
+ const initialKeys = Array.from(untrack(() => source.keys()))
216
224
  for (const key of initialKeys) addSignal(key)
217
225
  keys = initialKeys
218
- // Keep FLAG_DIRTY so the first refresh() establishes edges
226
+ // Keep FLAG_DIRTY so the first refresh() establishes edges.
219
227
 
220
228
  const collection: Collection<T> = {
221
229
  [Symbol.toStringTag]: TYPE_COLLECTION,
@@ -392,12 +400,8 @@ function createCollection<T extends {}>(
392
400
  }
393
401
  }
394
402
 
395
- if (structural) {
396
- node.sources = null
397
- node.sourcesTail = null
398
- }
399
403
  // Mark DIRTY so next get() rebuilds; propagate to sinks
400
- node.flags = FLAG_DIRTY
404
+ node.flags = FLAG_DIRTY | (structural ? FLAG_RELINK : 0)
401
405
  for (let e = node.sinks; e; e = e.nextSink)
402
406
  propagate(e.sink)
403
407
  })
@@ -431,8 +435,18 @@ function createCollection<T extends {}>(
431
435
  subscribe()
432
436
  if (node.sources) {
433
437
  if (node.flags) {
438
+ const relink = node.flags & FLAG_RELINK
434
439
  node.value = untrack(buildValue)
435
- node.flags = FLAG_CLEAN
440
+ if (relink) {
441
+ // Structural mutation added/removed child signals —
442
+ // tracked recompute so link() adds new edges and
443
+ // trimSources() removes stale ones without orphaning.
444
+ node.flags = FLAG_DIRTY
445
+ refresh(node as unknown as SinkNode)
446
+ if (node.error) throw node.error
447
+ } else {
448
+ node.flags = FLAG_CLEAN
449
+ }
436
450
  }
437
451
  } else {
438
452
  refresh(node as unknown as SinkNode)
package/src/nodes/list.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  type Cleanup,
11
11
  FLAG_CLEAN,
12
12
  FLAG_DIRTY,
13
+ FLAG_RELINK,
13
14
  flush,
14
15
  link,
15
16
  type MemoNode,
@@ -263,7 +264,7 @@ function createList<T extends {}>(
263
264
  // Structural tracking node — not a general-purpose Memo.
264
265
  // On first get(): refresh() establishes edges from child signals.
265
266
  // On subsequent get(): untrack(buildValue) rebuilds without re-linking.
266
- // Mutation methods (add/remove/set/splice) null out sources to force re-establishment.
267
+ // Mutation methods set FLAG_RELINK to force re-establishment on next read.
267
268
  const node: MemoNode<T[]> = {
268
269
  fn: buildValue,
269
270
  value,
@@ -325,10 +326,7 @@ function createList<T extends {}>(
325
326
  structural = true
326
327
  }
327
328
 
328
- if (structural) {
329
- node.sources = null
330
- node.sourcesTail = null
331
- }
329
+ if (structural) node.flags |= FLAG_RELINK
332
330
 
333
331
  return changes.changed
334
332
  }
@@ -380,8 +378,18 @@ function createList<T extends {}>(
380
378
  if (node.sources) {
381
379
  // Fast path: edges already established, rebuild value directly
382
380
  if (node.flags) {
381
+ const relink = node.flags & FLAG_RELINK
383
382
  node.value = untrack(buildValue)
384
- node.flags = FLAG_CLEAN
383
+ if (relink) {
384
+ // Structural mutation added/removed child signals —
385
+ // tracked recompute so link() adds new edges and
386
+ // trimSources() removes stale ones without orphaning.
387
+ node.flags = FLAG_DIRTY
388
+ refresh(node as unknown as SinkNode)
389
+ if (node.error) throw node.error
390
+ } else {
391
+ node.flags = FLAG_CLEAN
392
+ }
385
393
  }
386
394
  } else {
387
395
  // First access: use refresh() to establish child → list edges
@@ -441,9 +449,7 @@ function createList<T extends {}>(
441
449
  if (!keys.includes(key)) keys.push(key)
442
450
  validateSignalValue(`${TYPE_LIST} item for key "${key}"`, value)
443
451
  signals.set(key, createState(value))
444
- node.sources = null
445
- node.sourcesTail = null
446
- node.flags |= FLAG_DIRTY
452
+ node.flags |= FLAG_DIRTY | FLAG_RELINK
447
453
  for (let e = node.sinks; e; e = e.nextSink) propagate(e.sink)
448
454
  if (batchDepth === 0) flush()
449
455
  return key
@@ -459,9 +465,7 @@ function createList<T extends {}>(
459
465
  ? keyOrIndex
460
466
  : keys.indexOf(key)
461
467
  if (index >= 0) keys.splice(index, 1)
462
- node.sources = null
463
- node.sourcesTail = null
464
- node.flags |= FLAG_DIRTY
468
+ node.flags |= FLAG_DIRTY | FLAG_RELINK
465
469
  for (let e = node.sinks; e; e = e.nextSink) propagate(e.sink)
466
470
  if (batchDepth === 0) flush()
467
471
  }
@@ -6,6 +6,7 @@ import {
6
6
  type Cleanup,
7
7
  FLAG_CLEAN,
8
8
  FLAG_DIRTY,
9
+ FLAG_RELINK,
9
10
  flush,
10
11
  link,
11
12
  type MemoNode,
@@ -168,7 +169,7 @@ function createStore<T extends UnknownRecord>(
168
169
  // Structural tracking node — not a general-purpose Memo.
169
170
  // On first get(): refresh() establishes edges from child signals.
170
171
  // On subsequent get(): untrack(buildValue) rebuilds without re-linking.
171
- // Mutation methods (add/remove/set) null out sources to force re-establishment.
172
+ // Mutation methods set FLAG_RELINK to force re-establishment on next read.
172
173
  const node: MemoNode<T> = {
173
174
  fn: buildValue,
174
175
  value,
@@ -214,10 +215,7 @@ function createStore<T extends UnknownRecord>(
214
215
  structural = true
215
216
  }
216
217
 
217
- if (structural) {
218
- node.sources = null
219
- node.sourcesTail = null
220
- }
218
+ if (structural) node.flags |= FLAG_RELINK
221
219
 
222
220
  return changes.changed
223
221
  }
@@ -280,8 +278,18 @@ function createStore<T extends UnknownRecord>(
280
278
  // from child signals using untrack to avoid creating spurious
281
279
  // edges to the current effect/memo consumer
282
280
  if (node.flags) {
281
+ const relink = node.flags & FLAG_RELINK
283
282
  node.value = untrack(buildValue)
284
- node.flags = FLAG_CLEAN
283
+ if (relink) {
284
+ // Structural mutation added/removed child signals —
285
+ // tracked recompute so link() adds new edges and
286
+ // trimSources() removes stale ones without orphaning.
287
+ node.flags = FLAG_DIRTY
288
+ refresh(node as unknown as SinkNode)
289
+ if (node.error) throw node.error
290
+ } else {
291
+ node.flags = FLAG_CLEAN
292
+ }
285
293
  }
286
294
  } else {
287
295
  // First access: use refresh() to establish child → store edges
@@ -311,9 +319,7 @@ function createStore<T extends UnknownRecord>(
311
319
  if (signals.has(key))
312
320
  throw new DuplicateKeyError(TYPE_STORE, key, value)
313
321
  addSignal(key, value)
314
- node.sources = null
315
- node.sourcesTail = null
316
- node.flags |= FLAG_DIRTY
322
+ node.flags |= FLAG_DIRTY | FLAG_RELINK
317
323
  for (let e = node.sinks; e; e = e.nextSink) propagate(e.sink)
318
324
  if (batchDepth === 0) flush()
319
325
  return key
@@ -322,9 +328,7 @@ function createStore<T extends UnknownRecord>(
322
328
  remove(key: string) {
323
329
  const ok = signals.delete(key)
324
330
  if (ok) {
325
- node.sources = null
326
- node.sourcesTail = null
327
- node.flags |= FLAG_DIRTY
331
+ node.flags |= FLAG_DIRTY | FLAG_RELINK
328
332
  for (let e = node.sinks; e; e = e.nextSink) propagate(e.sink)
329
333
  if (batchDepth === 0) flush()
330
334
  }
package/test/list.test.ts CHANGED
@@ -3,10 +3,18 @@ import {
3
3
  createEffect,
4
4
  createList,
5
5
  createMemo,
6
+ createScope,
7
+ createState,
8
+ createTask,
6
9
  isList,
7
10
  isMemo,
11
+ match,
8
12
  } from '../index.ts'
9
13
 
14
+ /* === Utility Functions === */
15
+
16
+ const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
17
+
10
18
  describe('List', () => {
11
19
  describe('createList', () => {
12
20
  test('should return initial values from get()', () => {
@@ -437,6 +445,190 @@ describe('List', () => {
437
445
  expect(watchedCalled).toBe(true)
438
446
  dispose()
439
447
  })
448
+
449
+ test('should activate watched via sync deriveCollection', () => {
450
+ let watchedCalled = false
451
+ let unwatchedCalled = false
452
+ const list = createList([1, 2, 3], {
453
+ watched: () => {
454
+ watchedCalled = true
455
+ return () => {
456
+ unwatchedCalled = true
457
+ }
458
+ },
459
+ })
460
+
461
+ const derived = list.deriveCollection((v: number) => v * 2)
462
+
463
+ expect(watchedCalled).toBe(false)
464
+
465
+ const dispose = createEffect(() => {
466
+ derived.get()
467
+ })
468
+
469
+ expect(watchedCalled).toBe(true)
470
+ expect(unwatchedCalled).toBe(false)
471
+
472
+ dispose()
473
+ expect(unwatchedCalled).toBe(true)
474
+ })
475
+
476
+ test('should activate watched via async deriveCollection', async () => {
477
+ let watchedCalled = false
478
+ let unwatchedCalled = false
479
+ const list = createList([1, 2, 3], {
480
+ watched: () => {
481
+ watchedCalled = true
482
+ return () => {
483
+ unwatchedCalled = true
484
+ }
485
+ },
486
+ })
487
+
488
+ const derived = list.deriveCollection(
489
+ async (v: number, _abort: AbortSignal) => v * 2,
490
+ )
491
+
492
+ expect(watchedCalled).toBe(false)
493
+
494
+ const dispose = createEffect(() => {
495
+ derived.get()
496
+ })
497
+
498
+ expect(watchedCalled).toBe(true)
499
+
500
+ await wait(10)
501
+
502
+ expect(unwatchedCalled).toBe(false)
503
+
504
+ dispose()
505
+ expect(unwatchedCalled).toBe(true)
506
+ })
507
+
508
+ test('should not tear down watched during list mutation via deriveCollection', () => {
509
+ let activations = 0
510
+ let deactivations = 0
511
+ const list = createList([1, 2], {
512
+ watched: () => {
513
+ activations++
514
+ return () => {
515
+ deactivations++
516
+ }
517
+ },
518
+ })
519
+
520
+ const derived = list.deriveCollection((v: number) => v * 2)
521
+
522
+ let result: number[] = []
523
+ const dispose = createEffect(() => {
524
+ result = derived.get()
525
+ })
526
+
527
+ expect(activations).toBe(1)
528
+ expect(deactivations).toBe(0)
529
+ expect(result).toEqual([2, 4])
530
+
531
+ // Add item — should NOT tear down and restart watched
532
+ list.add(3)
533
+ expect(result).toEqual([2, 4, 6])
534
+ expect(activations).toBe(1)
535
+ expect(deactivations).toBe(0)
536
+
537
+ // Remove item — should NOT tear down and restart watched
538
+ list.remove(0)
539
+ expect(activations).toBe(1)
540
+ expect(deactivations).toBe(0)
541
+
542
+ dispose()
543
+ expect(deactivations).toBe(1)
544
+ })
545
+
546
+ test('should delay watched activation for conditional reads', () => {
547
+ let watchedCalled = false
548
+ const list = createList([1, 2], {
549
+ watched: () => {
550
+ watchedCalled = true
551
+ return () => {}
552
+ },
553
+ })
554
+
555
+ const show = createState(false)
556
+
557
+ const dispose = createScope(() => {
558
+ createEffect(() => {
559
+ if (show.get()) {
560
+ list.get()
561
+ }
562
+ })
563
+ })
564
+
565
+ // Conditional read — list not accessed, watched should not fire
566
+ expect(watchedCalled).toBe(false)
567
+
568
+ // Flip condition — list is now accessed
569
+ show.set(true)
570
+ expect(watchedCalled).toBe(true)
571
+
572
+ dispose()
573
+ })
574
+
575
+ test('should activate watched via chained deriveCollection', () => {
576
+ let watchedCalled = false
577
+ const list = createList([1, 2, 3], {
578
+ watched: () => {
579
+ watchedCalled = true
580
+ return () => {}
581
+ },
582
+ })
583
+
584
+ const doubled = list.deriveCollection((v: number) => v * 2)
585
+ const quadrupled = doubled.deriveCollection((v: number) => v * 2)
586
+
587
+ expect(watchedCalled).toBe(false)
588
+
589
+ const dispose = createEffect(() => {
590
+ quadrupled.get()
591
+ })
592
+
593
+ expect(watchedCalled).toBe(true)
594
+ dispose()
595
+ })
596
+
597
+ test('should activate watched via deriveCollection read inside match()', async () => {
598
+ let watchedCalled = false
599
+ const list = createList([1, 2], {
600
+ watched: () => {
601
+ watchedCalled = true
602
+ return () => {}
603
+ },
604
+ })
605
+
606
+ const derived = list.deriveCollection((v: number) => v * 10)
607
+
608
+ const task = createTask(async () => {
609
+ await wait(10)
610
+ return 'done'
611
+ })
612
+
613
+ const dispose = createScope(() => {
614
+ createEffect(() => {
615
+ // Read derived BEFORE match to ensure subscription
616
+ const values = derived.get()
617
+ match([task], {
618
+ ok: () => {
619
+ void values
620
+ },
621
+ nil: () => {},
622
+ })
623
+ })
624
+ })
625
+
626
+ // watched should activate synchronously even though task is pending
627
+ expect(watchedCalled).toBe(true)
628
+
629
+ await wait(50)
630
+ dispose()
631
+ })
440
632
  })
441
633
 
442
634
  describe('Input Validation', () => {