@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 +31 -9
- package/CHANGELOG.md +14 -0
- package/CLAUDE.md +24 -0
- package/GUIDE.md +1 -1
- package/README.md +14 -1
- package/package.json +1 -1
- package/src/graph.ts +15 -3
- package/src/nodes/collection.ts +43 -29
- package/src/nodes/list.ts +16 -12
- package/src/nodes/store.ts +16 -12
- package/test/list.test.ts +192 -0
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
|
|
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
|
|
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
|
|
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` + `
|
|
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)
|
|
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
|
|
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
|
|
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
|
-
**
|
|
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({
|
|
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.
|
|
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
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
|
|
248
|
-
source.stop
|
|
249
|
-
|
|
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,
|
package/src/nodes/collection.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
|
155
|
-
//
|
|
156
|
-
// to establish
|
|
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
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
}
|
package/src/nodes/store.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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', () => {
|