@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 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
- │ │ └── collection.ts # createCollection — externally-driven and derived collections
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 and the source has a `stop` callback, that callback is invoked — this is how lazy resources (Sensor, Collection, watched Store/List) are deallocated when no longer observed.
80
+ `unlink()` removes an edge from the source's sink list. If the source's sink list becomes empty:
81
+
82
+ 1. **Watched cleanup**: If the source has a `stop` callback, it is invoked — this is how lazy resources (Sensor, Collection, watched Store/List) are deallocated when no longer observed.
83
+ 2. **Cascading cleanup**: If the source is also a sink (a MemoNode or TaskNode — identified by having a `sources` field), its own sources are trimmed via `trimSources()`. This recursively unlinks the node from its upstream dependencies, allowing their `stop` callbacks to fire if they also become unobserved.
84
+
85
+ The cascade is critical for intermediate nodes like `deriveCollection`'s internal MemoNode: when the last effect unsubscribes from the derived collection, `unlink()` removes the effect→derived edge, which triggers cascade cleanup of the derived→List edge, which in turn fires the List's `stop` (the `watched` cleanup). Without the cascade, the List would retain a stale sink reference and never clean up its watcher. Recursion depth is bounded by graph depth since the graph is a DAG.
81
86
 
82
87
  ### Dependency Tracking Opt-Out: `untrack(fn)`
83
88
 
@@ -87,7 +92,7 @@ After a sink finishes recomputing, `trimSources()` removes any edges beyond `sou
87
92
 
88
93
  ### Flag-Based Dirty Tracking
89
94
 
90
- Each sink node has a `flags` field with four states:
95
+ Each sink node has a `flags` field using a bitmap with five flags:
91
96
 
92
97
  | Flag | Value | Meaning |
93
98
  |------|-------|---------|
@@ -95,6 +100,11 @@ Each sink node has a `flags` field with four states:
95
100
  | `FLAG_CHECK` | 1 | A transitive dependency may have changed — verify before recomputing |
96
101
  | `FLAG_DIRTY` | 2 | A direct dependency changed — recomputation required |
97
102
  | `FLAG_RUNNING` | 4 | Currently executing (used for circular dependency detection and edge reuse) |
103
+ | `FLAG_RELINK` | 8 | Structural change requires edge re-establishment on next read |
104
+
105
+ The first four flags (`CLEAN`/`CHECK`/`DIRTY`/`RUNNING`) are used by the core graph engine in `propagate()` and `refresh()`. They are tested with bitmask operations that ignore higher bits, so `FLAG_RELINK` is invisible to the propagation and refresh machinery.
106
+
107
+ `FLAG_RELINK` is used exclusively by composite signal types (Store, List, Collection, deriveCollection) that manage their own child signals. When a structural mutation adds or removes child signals, the node is flagged `FLAG_DIRTY | FLAG_RELINK`. On the next `get()`, the composite signal's fast path reads the flag: if `FLAG_RELINK` is set, it forces a tracked `refresh()` after rebuilding the value so that `recomputeMemo()` can call `link()` for new child signals and `trimSources()` for removed ones. This avoids the previous approach of nulling `node.sources`/`node.sourcesTail`, which orphaned edges in upstream sink lists. `FLAG_RELINK` is always cleared by `recomputeMemo()`, which assigns `node.flags = FLAG_RUNNING` (clearing all bits) at the start of recomputation.
98
108
 
99
109
  ### The `propagate(node)` Function
100
110
 
@@ -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**: `StateNode<T>` (source only)
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**: On first `get()`, `refresh()` executes `buildValue()` with `activeSink = storeNode`, establishing edges from each child signal to the store. Subsequent reads use a fast path: `untrack(buildValue)` rebuilds the value without re-establishing edges. Structural mutations (`add`/`remove`) call `invalidateEdges()` (nulling `node.sources`) to force re-establishment on the next read.
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` + `invalidateEdges` + two-path access pattern as Store. The `buildValue()` function reads all child signals in key order, establishing edges on the refresh path.
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) trigger `invalidateEdges()`.
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 null out `node.sources` to force edge re-establishment. The node uses `equals: () => false` since structural changes are managed externally rather than detected by diffing.
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**: 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.
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
- **Two-path access with structural fallback**: Same pattern as Store/List first `get()` uses `refresh()` to establish edges; subsequent reads use `untrack(buildValue)`. A `syncKeys()` step inside `buildValue` syncs the signals map with `source.keys()`. If keys changed, `syncKeys` nulls `node.sources` to force edge re-establishment via `refresh()`, ensuring new child signals are properly linked.
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({ changed: true, add: JSON.parse(e.data) })
271
+ ws.onmessage = (e) => applyChanges({ add: JSON.parse(e.data) })
272
272
  return () => ws.close()
273
273
  }, { keyConfig: msg => msg.id })
274
274
 
@@ -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.1
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 8 signal types — each justified by a distinct role in the graph and a distinct data structure it manages:
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 8 signal types + utilities) | Below 10 kB gzipped |
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 8 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
+ - **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