@zeix/cause-effect 0.18.1 → 0.18.3
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 +12 -4
- package/.github/copilot-instructions.md +10 -2
- package/ARCHITECTURE.md +44 -10
- package/CHANGELOG.md +24 -0
- package/CLAUDE.md +49 -1
- package/GUIDE.md +23 -1
- package/README.md +46 -1
- package/REQUIREMENTS.md +4 -3
- package/index.dev.js +170 -71
- package/index.js +1 -1
- package/index.ts +3 -1
- package/package.json +1 -1
- package/src/errors.ts +13 -0
- package/src/graph.ts +17 -3
- package/src/nodes/collection.ts +43 -33
- package/src/nodes/effect.ts +3 -3
- package/src/nodes/list.ts +16 -12
- package/src/nodes/slot.ts +134 -0
- package/src/nodes/store.ts +16 -12
- package/src/signal.ts +2 -0
- package/test/effect.test.ts +17 -0
- package/test/list.test.ts +192 -0
- package/test/signal.test.ts +2 -0
- package/test/slot.test.ts +118 -0
- package/types/index.d.ts +3 -2
- package/types/src/errors.d.ts +9 -1
- package/types/src/graph.d.ts +3 -1
- package/types/src/nodes/effect.d.ts +2 -2
- package/types/src/nodes/slot.d.ts +53 -0
package/.ai-context.md
CHANGED
|
@@ -19,7 +19,7 @@ The reactive engine is a linked graph of source and sink nodes connected by `Edg
|
|
|
19
19
|
### Node Types
|
|
20
20
|
```
|
|
21
21
|
StateNode<T> — source-only with equality + guard (State, Sensor)
|
|
22
|
-
MemoNode<T> — source + sink (Memo, Store, List, Collection)
|
|
22
|
+
MemoNode<T> — source + sink (Memo, Slot, Store, List, Collection)
|
|
23
23
|
TaskNode<T> — source + sink + async (Task)
|
|
24
24
|
EffectNode — sink + owner (Effect)
|
|
25
25
|
Scope — owner-only (createScope)
|
|
@@ -33,6 +33,7 @@ Scope — owner-only (createScope)
|
|
|
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
|
|
36
|
+
- **Slot** (`createSlot`): Stable delegation signal with swappable backing signal, designed for integration layers (property descriptors, custom elements)
|
|
36
37
|
- **Effect** (`createEffect`): Side effect handlers that react to signal changes
|
|
37
38
|
|
|
38
39
|
### Key Principles
|
|
@@ -56,7 +57,8 @@ cause-effect/
|
|
|
56
57
|
│ │ ├── effect.ts # createEffect, match — side effects
|
|
57
58
|
│ │ ├── store.ts # createStore — reactive object stores
|
|
58
59
|
│ │ ├── list.ts # createList — reactive arrays with stable keys
|
|
59
|
-
│ │
|
|
60
|
+
│ │ ├── collection.ts # createCollection — externally-driven and derived collections
|
|
61
|
+
│ │ └── slot.ts # createSlot — stable delegation with swappable backing signal
|
|
60
62
|
│ ├── util.ts # Utility functions and type checks
|
|
61
63
|
│ └── ...
|
|
62
64
|
├── index.ts # Entry point / main export file
|
|
@@ -135,6 +137,12 @@ const feed = createCollection<{ id: string; text: string }>((applyChanges) => {
|
|
|
135
137
|
ws.onmessage = (e) => applyChanges(JSON.parse(e.data))
|
|
136
138
|
return () => ws.close()
|
|
137
139
|
}, { keyConfig: item => item.id })
|
|
140
|
+
|
|
141
|
+
// Slot for stable property delegation
|
|
142
|
+
const local = createState('default')
|
|
143
|
+
const slot = createSlot(local)
|
|
144
|
+
Object.defineProperty(element, 'label', slot)
|
|
145
|
+
slot.replace(createMemo(() => parentState.get())) // swap backing signal
|
|
138
146
|
```
|
|
139
147
|
|
|
140
148
|
### Reactivity
|
|
@@ -195,8 +203,8 @@ const processed = items.deriveCollection(item => item.toUpperCase())
|
|
|
195
203
|
- Pure function annotations: `/*#__PURE__*/` for tree-shaking
|
|
196
204
|
|
|
197
205
|
### Naming Conventions
|
|
198
|
-
- Factory functions: `create*` prefix (createState, createMemo, createEffect, createStore, createList, createCollection, createSensor)
|
|
199
|
-
- Type predicates: `is*` prefix (isState, isMemo, isTask, isStore, isList, isCollection, isSensor)
|
|
206
|
+
- Factory functions: `create*` prefix (createState, createMemo, createEffect, createStore, createList, createCollection, createSensor, createSlot)
|
|
207
|
+
- Type predicates: `is*` prefix (isState, isMemo, isTask, isStore, isList, isCollection, isSensor, isSlot)
|
|
200
208
|
- Type constants: `TYPE_*` format (TYPE_STATE, TYPE_STORE, TYPE_SENSOR, TYPE_COLLECTION)
|
|
201
209
|
- Callback types: `*Callback` suffix (MemoCallback, TaskCallback, EffectCallback, SensorCallback, CollectionCallback, DeriveCollectionCallback)
|
|
202
210
|
|
|
@@ -20,6 +20,7 @@ Cause & Effect is a reactive state management library for JavaScript/TypeScript
|
|
|
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
|
|
23
|
+
- **Slot** (`createSlot`): Stable delegation signal with swappable backing signal, designed for integration layers (property descriptors, custom elements)
|
|
23
24
|
- **Effect** (`createEffect`): Side effects that react to signal changes
|
|
24
25
|
|
|
25
26
|
## Key Files Structure
|
|
@@ -34,6 +35,7 @@ Cause & Effect is a reactive state management library for JavaScript/TypeScript
|
|
|
34
35
|
- `src/nodes/store.ts` - createStore, isStore, Store type, diff, isEqual
|
|
35
36
|
- `src/nodes/list.ts` - createList, isList, List type
|
|
36
37
|
- `src/nodes/collection.ts` - createCollection, isCollection, Collection type, deriveCollection (internal)
|
|
38
|
+
- `src/nodes/slot.ts` - createSlot, isSlot, Slot type
|
|
37
39
|
- `src/util.ts` - Utility functions and type checks
|
|
38
40
|
- `index.ts` - Entry point / main export file
|
|
39
41
|
|
|
@@ -47,8 +49,8 @@ Cause & Effect is a reactive state management library for JavaScript/TypeScript
|
|
|
47
49
|
- JSDoc comments for all public APIs
|
|
48
50
|
|
|
49
51
|
### Naming Conventions
|
|
50
|
-
- Factory functions: `create*` (e.g., `createState`, `createMemo`, `createEffect`, `createStore`, `createList`, `createCollection`, `createSensor`)
|
|
51
|
-
- Type predicates: `is*` (e.g., `isState`, `isMemo`, `isStore`, `isList`, `isCollection`, `isSensor`)
|
|
52
|
+
- Factory functions: `create*` (e.g., `createState`, `createMemo`, `createEffect`, `createStore`, `createList`, `createCollection`, `createSensor`, `createSlot`)
|
|
53
|
+
- Type predicates: `is*` (e.g., `isState`, `isMemo`, `isStore`, `isList`, `isCollection`, `isSensor`, `isSlot`)
|
|
52
54
|
- Type constants: `TYPE_*` for internal type tags
|
|
53
55
|
- Callback types: `*Callback` suffix (MemoCallback, TaskCallback, EffectCallback, SensorCallback, CollectionCallback, DeriveCollectionCallback)
|
|
54
56
|
- Private variables: use descriptive names, no underscore prefix
|
|
@@ -148,6 +150,12 @@ const feed = createCollection<{ id: string; text: string }>((applyChanges) => {
|
|
|
148
150
|
ws.onmessage = (e) => applyChanges(JSON.parse(e.data))
|
|
149
151
|
return () => ws.close()
|
|
150
152
|
}, { keyConfig: item => item.id })
|
|
153
|
+
|
|
154
|
+
// Slot for stable property delegation
|
|
155
|
+
const local = createState('default')
|
|
156
|
+
const slot = createSlot(local)
|
|
157
|
+
Object.defineProperty(element, 'label', slot)
|
|
158
|
+
slot.replace(createMemo(() => parentState.get())) // swap backing signal
|
|
151
159
|
```
|
|
152
160
|
|
|
153
161
|
### Reactivity
|
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
|
|
|
@@ -168,7 +178,7 @@ Creates an ownership scope without an effect. The scope becomes `activeOwner` du
|
|
|
168
178
|
|
|
169
179
|
### State (`src/nodes/state.ts`)
|
|
170
180
|
|
|
171
|
-
**Graph node**: `
|
|
181
|
+
**Graph node**: `MemoNode<T>` (source + sink, single delegated dependency)
|
|
172
182
|
|
|
173
183
|
A mutable value container. The simplest signal type — `get()` links and returns the value, `set()` validates, calls `setState()`, which propagates changes to dependents.
|
|
174
184
|
|
|
@@ -221,6 +231,18 @@ A side-effecting computation that runs immediately and re-runs when dependencies
|
|
|
221
231
|
|
|
222
232
|
Effects double as owners: they have a `cleanup` field and become `activeOwner` during execution. Child effects and scopes created during execution are automatically disposed when the parent effect re-runs or is disposed.
|
|
223
233
|
|
|
234
|
+
### Slot (`src/nodes/slot.ts`)
|
|
235
|
+
|
|
236
|
+
**Graph node**: `MemoNode<T>` (source + sink)
|
|
237
|
+
|
|
238
|
+
A stable reactive source that delegates reads and writes to a swappable backing signal. Designed for integration layers (e.g. custom element systems) where a property position must switch its backing signal — from a local `State` to a parent-controlled `Memo`, for example — without breaking existing subscribers.
|
|
239
|
+
|
|
240
|
+
The slot object doubles as a property descriptor: its `get`, `set`, `configurable`, and `enumerable` fields can be passed directly to `Object.defineProperty()`. Control methods (`replace()`, `current()`) live on the same object but are ignored by the property definition; integration code should retain the slot reference for later `replace()` calls.
|
|
241
|
+
|
|
242
|
+
**Graph behavior**: Sinks link to the slot (stable across replacements). The slot links upstream to exactly one delegated signal at a time. On `replace(nextSignal)`, the slot updates its internal reference, flags sinks dirty via `propagate()`, and flushes. Re-running sinks call `slot.get()`, which triggers `refresh()` — dependency tracking (`link` + `trimSources`) re-subscribes to the new backing signal and drops stale edges to the old one. Setter calls forward to the delegated signal when writable; `ReadonlySignalError` is thrown otherwise.
|
|
243
|
+
|
|
244
|
+
Options mirror State: optional `guard` and `equals`. Type-level replacement follows `replace<U extends T>(next)` — narrowing is allowed, widening is not.
|
|
245
|
+
|
|
224
246
|
### Store (`src/nodes/store.ts`)
|
|
225
247
|
|
|
226
248
|
**Graph node**: `MemoNode<T>` (source + sink, used for structural reactivity)
|
|
@@ -229,7 +251,7 @@ A reactive object where each property is its own signal. Properties are automati
|
|
|
229
251
|
|
|
230
252
|
**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
253
|
|
|
232
|
-
**Two-path access
|
|
254
|
+
**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
255
|
|
|
234
256
|
**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
257
|
|
|
@@ -241,11 +263,11 @@ A reactive object where each property is its own signal. Properties are automati
|
|
|
241
263
|
|
|
242
264
|
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
265
|
|
|
244
|
-
**Structural reactivity**: Uses the same `MemoNode` + `
|
|
266
|
+
**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
267
|
|
|
246
268
|
**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
269
|
|
|
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)
|
|
270
|
+
**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
271
|
|
|
250
272
|
### Collection (`src/nodes/collection.ts`)
|
|
251
273
|
|
|
@@ -259,9 +281,9 @@ An externally-driven reactive collection with a watched lifecycle, mirroring `cr
|
|
|
259
281
|
|
|
260
282
|
**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
283
|
|
|
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
|
|
284
|
+
**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
285
|
|
|
264
|
-
**Two-path access
|
|
286
|
+
**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
287
|
|
|
266
288
|
#### `deriveCollection(source, callback)` — internally derived
|
|
267
289
|
|
|
@@ -271,6 +293,18 @@ An internal factory (not exported from the public API) that creates a read-only
|
|
|
271
293
|
|
|
272
294
|
**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
295
|
|
|
274
|
-
**
|
|
296
|
+
**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.
|
|
297
|
+
|
|
298
|
+
**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.
|
|
299
|
+
|
|
300
|
+
**Three-path `ensureFresh()`**: Access to the derived collection's value follows three distinct paths depending on the node's edge state:
|
|
301
|
+
|
|
302
|
+
| Path | Condition | Behavior |
|
|
303
|
+
|------|-----------|---------|
|
|
304
|
+
| 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. |
|
|
305
|
+
| 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. |
|
|
306
|
+
| 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. |
|
|
307
|
+
|
|
308
|
+
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
309
|
|
|
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.
|
|
310
|
+
**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,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.18.3
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Slot signal (`createSlot`, `isSlot`)**: A stable reactive source that delegates reads and writes to a swappable backing signal. Designed for integration layers (e.g. custom element systems) where a property position must switch its backing signal — from a local writable `State` to a parent-controlled `Memo` — without breaking existing subscribers. The slot object doubles as a property descriptor for `Object.defineProperty()`. `replace(nextSignal)` swaps the backing signal and invalidates downstream subscribers; `current()` returns the currently delegated signal. Options mirror State: optional `guard` and `equals`.
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **`match()` now preserves tuple types**: The `ok` handler correctly receives per-position types (e.g., `[number, string]`) instead of a widened union (e.g., `(number | string)[]`). The `signals` parameter and `MatchHandlers` type now use `readonly [...T]` to preserve tuple inference.
|
|
12
|
+
|
|
13
|
+
## 0.18.2
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **`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.
|
|
18
|
+
- **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.
|
|
19
|
+
- **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.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- **`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.
|
|
24
|
+
- **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.
|
|
25
|
+
- **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.
|
|
26
|
+
|
|
3
27
|
## 0.18.1
|
|
4
28
|
|
|
5
29
|
### Added
|
package/CLAUDE.md
CHANGED
|
@@ -20,6 +20,7 @@ Think of signals as **observable cells** in a spreadsheet:
|
|
|
20
20
|
- **Store signals** are structured tables where individual columns (properties) are reactive
|
|
21
21
|
- **List signals** are tables with stable row IDs that survive sorting and reordering
|
|
22
22
|
- **Collection signals** are externally-fed reactive tables with a watched lifecycle (WebSocket, SSE), or derived tables from Lists/Collections via `.deriveCollection()`
|
|
23
|
+
- **Slot signals** are stable cell references that delegate to a swappable backing cell — used by integration layers to swap a property's backing signal without breaking subscribers
|
|
23
24
|
- **Effects** are event handlers that trigger side effects when cells change
|
|
24
25
|
|
|
25
26
|
## Architectural Deep Dive
|
|
@@ -37,7 +38,7 @@ The core of reactivity lies in the linked graph system (`src/graph.ts`):
|
|
|
37
38
|
|
|
38
39
|
```
|
|
39
40
|
StateNode<T> — source-only with equality + guard (State, Sensor)
|
|
40
|
-
MemoNode<T> — source + sink (Memo, Store, List, Collection)
|
|
41
|
+
MemoNode<T> — source + sink (Memo, Slot, Store, List, Collection)
|
|
41
42
|
TaskNode<T> — source + sink + async (Task)
|
|
42
43
|
EffectNode — sink + owner (Effect)
|
|
43
44
|
Scope — owner-only (createScope)
|
|
@@ -70,6 +71,14 @@ type Task<T extends {}> = {
|
|
|
70
71
|
abort(): void
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
// Slot delegates to a swappable backing signal
|
|
75
|
+
type Slot<T extends {}> = {
|
|
76
|
+
get(): T
|
|
77
|
+
set(next: T): void
|
|
78
|
+
replace<U extends T>(next: Signal<U>): void
|
|
79
|
+
current(): Signal<T>
|
|
80
|
+
}
|
|
81
|
+
|
|
73
82
|
// Collection interface
|
|
74
83
|
type Collection<T extends {}> = {
|
|
75
84
|
get(): T[]
|
|
@@ -168,6 +177,8 @@ const user = createStore({ name: 'Alice', email: 'alice@example.com' }, {
|
|
|
168
177
|
2. Last effect stops watching → returned cleanup function executed
|
|
169
178
|
3. New effect accesses signal → watched callback executed again
|
|
170
179
|
|
|
180
|
+
**Watched propagation through `deriveCollection()`**: When an effect reads a derived collection, the `watched` callback on the source List, Store, or Collection activates automatically — even through multiple levels of `.deriveCollection()` chaining. The reactive graph establishes edges from effect → derived collection → source, and `watched` fires when the first edge reaches the source. Mutations (add, remove, sort) on the source do **not** tear down and restart `watched` — the watcher remains stable as long as at least one downstream effect is subscribed. When the last effect disposes, cleanup cascades upstream through all intermediate nodes.
|
|
181
|
+
|
|
171
182
|
This pattern enables **lazy resource allocation** - resources are only consumed when actually needed and automatically freed when no longer used.
|
|
172
183
|
|
|
173
184
|
## Advanced Patterns and Best Practices
|
|
@@ -295,6 +306,21 @@ const userData = createTask(async (prev, abort) => {
|
|
|
295
306
|
})
|
|
296
307
|
```
|
|
297
308
|
|
|
309
|
+
**Slot (`createSlot`)**:
|
|
310
|
+
- Property positions that must swap their backing signal without breaking subscribers
|
|
311
|
+
- Integration layers (custom elements, component systems) using `Object.defineProperty()`
|
|
312
|
+
- The slot object is a valid property descriptor (`get`, `set`, `configurable`, `enumerable`)
|
|
313
|
+
|
|
314
|
+
```typescript
|
|
315
|
+
const local = createState('default')
|
|
316
|
+
const slot = createSlot(local)
|
|
317
|
+
Object.defineProperty(element, 'label', slot)
|
|
318
|
+
|
|
319
|
+
// Swap backing signal — subscribers re-run automatically
|
|
320
|
+
const parentLabel = createMemo(() => parentState.get())
|
|
321
|
+
slot.replace(parentLabel)
|
|
322
|
+
```
|
|
323
|
+
|
|
298
324
|
### Error Handling Strategies
|
|
299
325
|
|
|
300
326
|
The library provides several layers of error handling:
|
|
@@ -346,6 +372,28 @@ const display = createMemo(() => user.name.get() + user.email.get())
|
|
|
346
372
|
4. **Async Race Conditions**: Trust automatic cancellation with AbortSignal
|
|
347
373
|
5. **Circular Dependencies**: The graph detects and throws `CircularDependencyError`
|
|
348
374
|
6. **Untracked `byKey()`/`at()` access**: On Store, List, and Collection, `byKey()`, `at()`, `keyAt()`, and `indexOfKey()` do **not** create graph edges. They are direct lookups that bypass structural tracking. An effect using only `collection.byKey('x')?.get()` will react to value changes of key `'x'`, but will **not** re-run if key `'x'` is added or removed. Use `get()`, `keys()`, or `length` to track structural changes.
|
|
375
|
+
7. **Conditional reads delay `watched` activation**: Dependencies are tracked dynamically based on which `.get()` calls actually execute during each effect run. If a signal read is inside a branch that doesn't execute (e.g., inside the `ok` branch of `match()` while a Task is still pending), no edge is created and `watched` does not activate until that branch runs. **Fix:** read the signal eagerly before conditional logic:
|
|
376
|
+
|
|
377
|
+
```typescript
|
|
378
|
+
// Good: watched activates immediately, errors/nil in derived are also caught
|
|
379
|
+
createEffect(() => {
|
|
380
|
+
match([task, derived], {
|
|
381
|
+
ok: ([result, values]) => renderList(values, result),
|
|
382
|
+
nil: () => showLoading(),
|
|
383
|
+
})
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
// Bad: watched only activates after task resolves
|
|
387
|
+
createEffect(() => {
|
|
388
|
+
match([task], {
|
|
389
|
+
ok: ([result]) => {
|
|
390
|
+
const values = derived.get() // only tracked in this branch
|
|
391
|
+
renderList(values, result)
|
|
392
|
+
},
|
|
393
|
+
nil: () => showLoading(),
|
|
394
|
+
})
|
|
395
|
+
})
|
|
396
|
+
```
|
|
349
397
|
|
|
350
398
|
## Advanced Patterns
|
|
351
399
|
|
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
|
|
|
@@ -296,3 +296,25 @@ const windowSize = createSensor((set) => {
|
|
|
296
296
|
```
|
|
297
297
|
|
|
298
298
|
The start callback runs lazily — only when an effect first reads the sensor. When no effects are watching, the cleanup runs automatically. When an effect reads it again, the start callback runs again. No manual setup/teardown.
|
|
299
|
+
|
|
300
|
+
### Slot: stable property delegation
|
|
301
|
+
|
|
302
|
+
If you are building a component system, you often need to expose signals as object properties via `Object.defineProperty()`. The challenge arises when a property must switch its backing signal — for example, from a local writable `State` to a parent-controlled read-only `Memo` — without breaking existing subscribers.
|
|
303
|
+
|
|
304
|
+
`createSlot()` solves this by providing a stable reactive source that delegates to a swappable backing signal. The slot object itself is a valid property descriptor:
|
|
305
|
+
|
|
306
|
+
```ts
|
|
307
|
+
import { createState, createMemo, createSlot, createEffect } from '@zeix/cause-effect'
|
|
308
|
+
|
|
309
|
+
const local = createState('default')
|
|
310
|
+
const slot = createSlot(local)
|
|
311
|
+
Object.defineProperty(element, 'label', slot)
|
|
312
|
+
|
|
313
|
+
createEffect(() => console.log(element.label)) // logs: "default"
|
|
314
|
+
|
|
315
|
+
// Parent provides a derived value — swap without breaking the effect
|
|
316
|
+
const parentLabel = createMemo(() => `Parent: ${parentState.get()}`)
|
|
317
|
+
slot.replace(parentLabel) // effect re-runs with new value
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
Setter calls forward to the current backing signal when it is writable. If the backing signal is read-only (e.g. a Memo), setting throws `ReadonlySignalError`. The `replace()` and `current()` methods are on the slot object but not on the installed property — keep the slot reference for later control.
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Cause & Effect
|
|
2
2
|
|
|
3
|
-
Version 0.18.
|
|
3
|
+
Version 0.18.3
|
|
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
|
|
|
@@ -27,6 +27,7 @@ Every signal type participates in the same dependency graph with the same propag
|
|
|
27
27
|
| **Store** | Reactive object (keyed properties, proxy-based) | `createStore()` |
|
|
28
28
|
| **List** | Reactive array (keyed items, stable identity) | `createList()` |
|
|
29
29
|
| **Collection** | Reactive collection (external source or derived, item-level memoization) | `createCollection()` |
|
|
30
|
+
| **Slot** | Stable delegation for integration layers (swappable backing signal) | `createSlot()` |
|
|
30
31
|
| **Effect** | Side-effect sink (terminal) | `createEffect()` |
|
|
31
32
|
|
|
32
33
|
## Design Principles
|
|
@@ -322,6 +323,33 @@ const processed = users
|
|
|
322
323
|
.deriveCollection(user => user.active ? `Active: ${user.name}` : `Inactive: ${user.name}`)
|
|
323
324
|
```
|
|
324
325
|
|
|
326
|
+
### Slot
|
|
327
|
+
|
|
328
|
+
A stable reactive source that delegates to a swappable backing signal. Designed for integration layers (e.g. custom element systems) where a property must switch its backing signal without breaking subscribers. The slot object doubles as a property descriptor for `Object.defineProperty()`:
|
|
329
|
+
|
|
330
|
+
```js
|
|
331
|
+
import { createState, createMemo, createSlot, createEffect } from '@zeix/cause-effect'
|
|
332
|
+
|
|
333
|
+
const local = createState(1)
|
|
334
|
+
const slot = createSlot(local)
|
|
335
|
+
|
|
336
|
+
// Use as a property descriptor
|
|
337
|
+
const target = {}
|
|
338
|
+
Object.defineProperty(target, 'value', slot)
|
|
339
|
+
|
|
340
|
+
createEffect(() => console.log(target.value)) // logs: 1
|
|
341
|
+
|
|
342
|
+
// Swap the backing signal — subscribers re-run automatically
|
|
343
|
+
const derived = createMemo(() => 42)
|
|
344
|
+
slot.replace(derived) // logs: 42
|
|
345
|
+
|
|
346
|
+
// Write through to the current backing signal
|
|
347
|
+
slot.replace(local)
|
|
348
|
+
target.value = 10 // sets local to 10
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
`replace()` and `current()` are available on the slot object but are not installed on the property — keep the slot reference for later control. Setting via the property forwards to the delegated signal; throws `ReadonlySignalError` if the current backing signal is read-only.
|
|
352
|
+
|
|
325
353
|
### Effect
|
|
326
354
|
|
|
327
355
|
A side-effect sink that runs whenever the signals it reads change. Effects are terminal — they consume values but produce none. The returned function disposes the effect:
|
|
@@ -418,6 +446,10 @@ Does the data come from *outside* the reactive system?
|
|
|
418
446
|
└─ Is it derived / read-only transformation of a `List` or `Collection`?
|
|
419
447
|
└─ Yes → `.deriveCollection()`
|
|
420
448
|
(memoized + supports async mapping + chaining)
|
|
449
|
+
|
|
450
|
+
Do you need a *stable property position* that can swap its backing signal?
|
|
451
|
+
└─ Yes → `createSlot(existingSignal)`
|
|
452
|
+
(integration layers, custom elements, property descriptors)
|
|
421
453
|
```
|
|
422
454
|
|
|
423
455
|
## Advanced Usage
|
|
@@ -517,6 +549,19 @@ const user = createStore({ name: 'Alice' }, {
|
|
|
517
549
|
})
|
|
518
550
|
```
|
|
519
551
|
|
|
552
|
+
**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.
|
|
553
|
+
|
|
554
|
+
**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:
|
|
555
|
+
|
|
556
|
+
```js
|
|
557
|
+
createEffect(() => {
|
|
558
|
+
match([task, derived], { // derived is always tracked
|
|
559
|
+
ok: ([result, values]) => renderList(values, result),
|
|
560
|
+
nil: () => showLoading(),
|
|
561
|
+
})
|
|
562
|
+
})
|
|
563
|
+
```
|
|
564
|
+
|
|
520
565
|
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
566
|
|
|
522
567
|
```js
|
package/REQUIREMENTS.md
CHANGED
|
@@ -32,7 +32,7 @@ All signals enforce `T extends {}`, excluding `null` and `undefined` at the type
|
|
|
32
32
|
Every signal type participates in the same dependency graph with the same propagation, batching, and cleanup semantics. Composite signals (Store, List, Collection) and async signals (Task) are first-class citizens, not afterthoughts. The goal is that all state which is derivable can be derived.
|
|
33
33
|
|
|
34
34
|
### Minimal Surface, Maximum Coverage
|
|
35
|
-
The library ships
|
|
35
|
+
The library ships 9 signal types — each justified by a distinct role in the graph and a distinct data structure it manages:
|
|
36
36
|
|
|
37
37
|
| Type | Role | Data Structure |
|
|
38
38
|
|------|------|----------------|
|
|
@@ -41,6 +41,7 @@ The library ships 8 signal types — each justified by a distinct role in the gr
|
|
|
41
41
|
| **Memo** | Synchronous derivation | Single value (memoized) |
|
|
42
42
|
| **Task** | Asynchronous derivation | Single value (memoized, cancellable) |
|
|
43
43
|
| **Effect** | Side-effect sink | None (terminal) |
|
|
44
|
+
| **Slot** | Stable delegation (integration layer) | Single value (swappable backing signal) |
|
|
44
45
|
| **Store** | Reactive object | Keyed properties (proxy-based) |
|
|
45
46
|
| **List** | Reactive array | Keyed items (stable identity) |
|
|
46
47
|
| **Collection** | Reactive collection (external source or derived) | Keyed items (lazy lifecycle, item-level memoization) |
|
|
@@ -63,7 +64,7 @@ The library uses no browser-specific APIs in its core. Environment-specific beha
|
|
|
63
64
|
| Usage | Target |
|
|
64
65
|
|-------|--------|
|
|
65
66
|
| Core signals only (State, Memo, Task, Effect) | Below 5 kB gzipped |
|
|
66
|
-
| Full library (all
|
|
67
|
+
| Full library (all 9 signal types + utilities) | Below 10 kB gzipped |
|
|
67
68
|
|
|
68
69
|
The library must remain tree-shakable: importing only what you use should not pull in unrelated signal types.
|
|
69
70
|
|
|
@@ -79,7 +80,7 @@ The following are explicitly out of scope and will not be added to the library:
|
|
|
79
80
|
- **Persistence**: No serialization, no local storage, no database integration. State enters and leaves the graph through signals; how it is stored is not this library's concern.
|
|
80
81
|
- **Framework-specific bindings**: No React hooks, no Vue composables, no Angular decorators. Consuming libraries build their own integrations.
|
|
81
82
|
- **DevTools protocol**: Debugging is straightforward by design — attaching an effect to any signal reveals its current value and update behavior. A dedicated debugging protocol adds complexity without proportional value.
|
|
82
|
-
- **Additional signal types**: The
|
|
83
|
+
- **Additional signal types**: The 9 signal types are considered complete. New types would only be considered if major Web Platform changes shift the optimal way to achieve the library's existing goals.
|
|
83
84
|
|
|
84
85
|
## Stability
|
|
85
86
|
|