@zeix/cause-effect 0.18.2 → 0.18.4
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 +21 -9
- package/CHANGELOG.md +22 -0
- package/CLAUDE.md +28 -4
- package/GUIDE.md +22 -0
- package/README.md +33 -1
- package/REQUIREMENTS.md +4 -3
- package/index.dev.js +178 -81
- 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 +8 -4
- package/src/nodes/collection.ts +0 -4
- package/src/nodes/effect.ts +3 -3
- package/src/nodes/memo.ts +1 -3
- package/src/nodes/slot.ts +134 -0
- package/src/nodes/task.ts +1 -3
- package/src/signal.ts +2 -0
- package/test/effect.test.ts +221 -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 +4 -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
|
@@ -106,14 +106,14 @@ The first four flags (`CLEAN`/`CHECK`/`DIRTY`/`RUNNING`) are used by the core gr
|
|
|
106
106
|
|
|
107
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.
|
|
108
108
|
|
|
109
|
-
### The `propagate(node)` Function
|
|
109
|
+
### The `propagate(node, newFlag?)` Function
|
|
110
110
|
|
|
111
|
-
When a source value changes, `propagate()` walks its sink list
|
|
111
|
+
When a source value changes, `propagate()` walks its sink list. The `newFlag` parameter defaults to `FLAG_DIRTY` but callers may pass `FLAG_CHECK` for speculative invalidation (e.g., watched callbacks where the source value may not have actually changed).
|
|
112
112
|
|
|
113
|
-
- **Memo/Task sinks** (have `sinks` field): Flagged `DIRTY
|
|
114
|
-
- **Effect sinks** (no `sinks` field): Flagged `
|
|
113
|
+
- **Memo/Task sinks** (have `sinks` field): Flagged with `newFlag` (typically `DIRTY`). Their own sinks are recursively flagged `CHECK`. If the node has an in-flight `AbortController`, it is aborted immediately. Short-circuits if the node already carries an equal or higher flag.
|
|
114
|
+
- **Effect sinks** (no `sinks` field): Flagged with `newFlag` and pushed onto the `queuedEffects` array. An effect is only enqueued once — subsequent propagations escalate the flag (e.g., `CHECK` → `DIRTY`) without re-enqueuing. The flag is assigned (not OR'd) to clear `FLAG_RUNNING`, preserving the existing pattern where a state update inside a running effect triggers a re-run.
|
|
115
115
|
|
|
116
|
-
The two-level flagging (`DIRTY` for direct dependents, `CHECK` for transitive) avoids unnecessary recomputation. A `CHECK` node only recomputes if, upon inspection during `refresh()`, one of its sources turns out to have actually changed.
|
|
116
|
+
The two-level flagging (`DIRTY` for direct dependents, `CHECK` for transitive) avoids unnecessary recomputation. A `CHECK` node only recomputes if, upon inspection during `refresh()`, one of its sources turns out to have actually changed. This applies equally to memo, task, and effect nodes.
|
|
117
117
|
|
|
118
118
|
### The `refresh(node)` Function
|
|
119
119
|
|
|
@@ -141,7 +141,7 @@ If `FLAG_RUNNING` is encountered, a `CircularDependencyError` is thrown.
|
|
|
141
141
|
|
|
142
142
|
### The `flush()` Function
|
|
143
143
|
|
|
144
|
-
`flush()` iterates over `queuedEffects`, calling `refresh()` on each effect that is still `DIRTY`. A `flushing` guard prevents re-entrant flushes. Effects that were enqueued during the flush (due to async resolution or nested state changes) are processed in the same pass, since `flush()` reads the array length dynamically.
|
|
144
|
+
`flush()` iterates over `queuedEffects`, calling `refresh()` on each effect that is still `DIRTY` or `CHECK`. A `flushing` guard prevents re-entrant flushes. Effects that were enqueued during the flush (due to async resolution or nested state changes) are processed in the same pass, since `flush()` reads the array length dynamically. Effects with only `FLAG_CHECK` enter `refresh()`, which walks their sources — if no source value actually changed, the effect is cleaned without running.
|
|
145
145
|
|
|
146
146
|
### Effect Lifecycle
|
|
147
147
|
|
|
@@ -178,7 +178,7 @@ Creates an ownership scope without an effect. The scope becomes `activeOwner` du
|
|
|
178
178
|
|
|
179
179
|
### State (`src/nodes/state.ts`)
|
|
180
180
|
|
|
181
|
-
**Graph node**: `
|
|
181
|
+
**Graph node**: `MemoNode<T>` (source + sink, single delegated dependency)
|
|
182
182
|
|
|
183
183
|
A mutable value container. The simplest signal type — `get()` links and returns the value, `set()` validates, calls `setState()`, which propagates changes to dependents.
|
|
184
184
|
|
|
@@ -209,7 +209,7 @@ The `error` field preserves thrown errors: if `fn` throws, the error is stored a
|
|
|
209
209
|
|
|
210
210
|
**Reducer pattern**: The `prev` parameter enables state accumulation across recomputations without writable state.
|
|
211
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()`
|
|
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()` calls `propagate(node)` on the memo itself, which marks it `FLAG_DIRTY` and propagates `FLAG_CHECK` to downstream sinks, then flushes. During flush, downstream effects verify the memo via `refresh()` — if the memo's `equals` function determines the recomputed value is unchanged, the effect is cleaned without running. The returned cleanup is stored as `node.stop` and called when the last sink detaches. This enables patterns like DOM observation (MutationObserver) where a memo re-derives its value in response to external events, with the `equals` check respected at every level of the graph.
|
|
213
213
|
|
|
214
214
|
### Task (`src/nodes/task.ts`)
|
|
215
215
|
|
|
@@ -221,7 +221,7 @@ During dependency tracking, only the synchronous preamble of `fn` is tracked (be
|
|
|
221
221
|
|
|
222
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).
|
|
223
223
|
|
|
224
|
-
**Watched lifecycle**: Same pattern as Memo — an optional `watched` callback receives `invalidate` and is invoked on first sink attachment. Calling `invalidate()`
|
|
224
|
+
**Watched lifecycle**: Same pattern as Memo — an optional `watched` callback receives `invalidate` and is invoked on first sink attachment. Calling `invalidate()` calls `propagate(node)` on the task itself, which marks it dirty, aborts any in-flight computation eagerly via the `AbortController`, and propagates `FLAG_CHECK` to downstream sinks. Effects only re-run if the task's resolved value actually changes.
|
|
225
225
|
|
|
226
226
|
### Effect (`src/nodes/effect.ts`)
|
|
227
227
|
|
|
@@ -231,6 +231,18 @@ A side-effecting computation that runs immediately and re-runs when dependencies
|
|
|
231
231
|
|
|
232
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.
|
|
233
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
|
+
|
|
234
246
|
### Store (`src/nodes/store.ts`)
|
|
235
247
|
|
|
236
248
|
**Graph node**: `MemoNode<T>` (source + sink, used for structural reactivity)
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.18.4
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **Watched `invalidate()` now respects `equals` at every graph level**: Previously, calling `invalidate()` from a Memo or Task `watched` callback propagated `FLAG_DIRTY` directly to effect sinks, causing unconditional re-runs even when the recomputed value was unchanged. Now `invalidate()` delegates to `propagate(node)`, which marks the node itself `FLAG_DIRTY` and propagates `FLAG_CHECK` to downstream sinks. During flush, effects verify their sources via `refresh()` — if the memo's `equals` function determines the value is unchanged, the effect is cleaned without running. This eliminates unnecessary effect executions for watched memos with custom equality or stable return values.
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- **`propagate()` supports `FLAG_CHECK` for effect nodes**: The effect branch of `propagate()` now respects the `newFlag` parameter instead of unconditionally setting `FLAG_DIRTY`. Effects are enqueued only on first notification; subsequent propagations escalate the flag (e.g., `CHECK` → `DIRTY`) without re-enqueuing.
|
|
12
|
+
- **`flush()` processes `FLAG_CHECK` effects**: The flush loop now calls `refresh()` on effects with either `FLAG_DIRTY` or `FLAG_CHECK`, enabling the check-sources-first path for effects.
|
|
13
|
+
- **Task `invalidate()` aborts eagerly**: Task watched callbacks now abort in-flight computations immediately during `propagate()` rather than deferring to `recomputeTask()`, consistent with the normal dependency-change path.
|
|
14
|
+
|
|
15
|
+
## 0.18.3
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **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`.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- **`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.
|
|
24
|
+
|
|
3
25
|
## 0.18.2
|
|
4
26
|
|
|
5
27
|
### Fixed
|
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[]
|
|
@@ -117,7 +126,7 @@ The generic constraint `T extends {}` is crucial - it excludes `null` and `undef
|
|
|
117
126
|
|
|
118
127
|
Computed signals implement smart memoization:
|
|
119
128
|
- **Dependency Tracking**: Automatically tracks which signals are accessed during computation via `link()`
|
|
120
|
-
- **Stale Detection**: Flag-based dirty checking (CLEAN, CHECK, DIRTY) — only recalculates when dependencies actually change
|
|
129
|
+
- **Stale Detection**: Flag-based dirty checking (CLEAN, CHECK, DIRTY) — only recalculates when dependencies actually change. The `equals` check is respected at every graph level: when a memo recomputes to the same value, downstream memos *and* effects are cleaned without running.
|
|
121
130
|
- **Async Support**: `createTask` handles Promise-based computations with automatic AbortController cancellation
|
|
122
131
|
- **Error Handling**: Preserves error states and prevents cascade failures
|
|
123
132
|
- **Reducer Capabilities**: Access to previous value enables state accumulation and transitions
|
|
@@ -265,7 +274,7 @@ const processed = todoList
|
|
|
265
274
|
**Memo (`createMemo`)**:
|
|
266
275
|
- Synchronous derived computations with memoization
|
|
267
276
|
- Reducer pattern with previous value access
|
|
268
|
-
- Optional `watched(invalidate)` callback for lazy external invalidation (e.g., MutationObserver)
|
|
277
|
+
- Optional `watched(invalidate)` callback for lazy external invalidation (e.g., MutationObserver). Calling `invalidate()` marks the memo dirty and propagates `FLAG_CHECK` to sinks — effects only re-run if the memo's `equals` check determines the value actually changed.
|
|
269
278
|
|
|
270
279
|
```typescript
|
|
271
280
|
const doubled = createMemo(() => count.get() * 2)
|
|
@@ -286,7 +295,7 @@ const runningTotal = createMemo(prev => prev + currentValue.get(), { value: 0 })
|
|
|
286
295
|
|
|
287
296
|
**Task (`createTask`)**:
|
|
288
297
|
- Async computations with automatic cancellation
|
|
289
|
-
- Optional `watched(invalidate)` callback for lazy external invalidation
|
|
298
|
+
- Optional `watched(invalidate)` callback for lazy external invalidation. Calling `invalidate()` eagerly aborts any in-flight computation and propagates `FLAG_CHECK` to sinks.
|
|
290
299
|
|
|
291
300
|
```typescript
|
|
292
301
|
const userData = createTask(async (prev, abort) => {
|
|
@@ -297,6 +306,21 @@ const userData = createTask(async (prev, abort) => {
|
|
|
297
306
|
})
|
|
298
307
|
```
|
|
299
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
|
+
|
|
300
324
|
### Error Handling Strategies
|
|
301
325
|
|
|
302
326
|
The library provides several layers of error handling:
|
package/GUIDE.md
CHANGED
|
@@ -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.4
|
|
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
|
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
|
|