@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 +14 -3
- package/.github/copilot-instructions.md +15 -5
- package/ARCHITECTURE.md +43 -19
- package/CHANGELOG.md +43 -0
- package/CLAUDE.md +33 -7
- package/GUIDE.md +1 -1
- package/README.md +36 -5
- package/context7.json +4 -0
- package/examples/events-sensor.ts +187 -0
- package/examples/selector-sensor.ts +173 -0
- package/index.dev.js +276 -222
- package/index.js +1 -1
- package/index.ts +4 -2
- package/package.json +2 -2
- package/skills/changelog-keeper/SKILL.md +59 -0
- package/skills/changelog-keeper/agents/openai.yaml +4 -0
- package/src/graph.ts +28 -5
- package/src/nodes/collection.ts +185 -133
- package/src/nodes/list.ts +121 -116
- package/src/nodes/memo.ts +31 -3
- package/src/nodes/sensor.ts +27 -17
- package/src/nodes/state.ts +2 -2
- package/src/nodes/store.ts +71 -72
- package/src/nodes/task.ts +31 -3
- package/test/collection.test.ts +40 -51
- package/test/list.test.ts +192 -0
- package/test/memo.test.ts +194 -0
- package/test/task.test.ts +134 -0
- package/types/index.d.ts +5 -5
- package/types/src/graph.d.ts +12 -2
- package/types/src/nodes/collection.d.ts +12 -7
- package/types/src/nodes/list.d.ts +12 -11
- package/types/src/nodes/memo.d.ts +6 -0
- package/types/src/nodes/sensor.d.ts +15 -9
- package/types/src/nodes/store.d.ts +4 -4
- package/types/src/nodes/task.d.ts +6 -0
- package/COLLECTION_REFACTORING.md +0 -161
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
|
@@ -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 `
|
|
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 (
|
|
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 `
|
|
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 `
|
|
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
|
|
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` + `
|
|
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)
|
|
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(
|
|
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(
|
|
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 `
|
|
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
|
|
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
|
|
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<
|
|
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
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
90
|
-
- The `
|
|
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: `
|
|
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
|
|
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 →
|
|
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 →
|
|
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(
|
|
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({
|
|
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
|
|
|
@@ -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
|
|
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({
|
|
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
|
|
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 **
|
|
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