@zeix/cause-effect 0.17.3 → 0.18.1
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 +169 -227
- package/.cursorrules +41 -35
- package/.github/copilot-instructions.md +176 -116
- package/ARCHITECTURE.md +276 -0
- package/CHANGELOG.md +29 -0
- package/CLAUDE.md +201 -143
- package/GUIDE.md +298 -0
- package/README.md +246 -193
- package/REQUIREMENTS.md +100 -0
- package/bench/reactivity.bench.ts +577 -0
- package/context7.json +4 -0
- package/examples/events-sensor.ts +187 -0
- package/examples/selector-sensor.ts +173 -0
- package/index.dev.js +1390 -1008
- package/index.js +1 -1
- package/index.ts +60 -74
- package/package.json +5 -2
- package/skills/changelog-keeper/SKILL.md +59 -0
- package/skills/changelog-keeper/agents/openai.yaml +4 -0
- package/src/errors.ts +118 -74
- package/src/graph.ts +612 -0
- package/src/nodes/collection.ts +512 -0
- package/src/nodes/effect.ts +149 -0
- package/src/nodes/list.ts +589 -0
- package/src/nodes/memo.ts +148 -0
- package/src/nodes/sensor.ts +149 -0
- package/src/nodes/state.ts +135 -0
- package/src/nodes/store.ts +378 -0
- package/src/nodes/task.ts +174 -0
- package/src/signal.ts +112 -66
- package/src/util.ts +26 -57
- package/test/batch.test.ts +96 -62
- package/test/benchmark.test.ts +473 -487
- package/test/collection.test.ts +456 -707
- package/test/effect.test.ts +293 -696
- package/test/list.test.ts +335 -592
- package/test/memo.test.ts +574 -0
- package/test/regression.test.ts +156 -0
- package/test/scope.test.ts +191 -0
- package/test/sensor.test.ts +454 -0
- package/test/signal.test.ts +220 -213
- package/test/state.test.ts +217 -265
- package/test/store.test.ts +346 -446
- package/test/task.test.ts +529 -0
- package/test/untrack.test.ts +167 -0
- package/types/index.d.ts +13 -15
- package/types/src/errors.d.ts +73 -17
- package/types/src/graph.d.ts +218 -0
- package/types/src/nodes/collection.d.ts +69 -0
- package/types/src/nodes/effect.d.ts +48 -0
- package/types/src/nodes/list.d.ts +66 -0
- package/types/src/nodes/memo.d.ts +63 -0
- package/types/src/nodes/sensor.d.ts +81 -0
- package/types/src/nodes/state.d.ts +78 -0
- package/types/src/nodes/store.d.ts +51 -0
- package/types/src/nodes/task.d.ts +79 -0
- package/types/src/signal.d.ts +43 -29
- package/types/src/util.d.ts +9 -16
- package/archive/benchmark.ts +0 -683
- package/archive/collection.ts +0 -253
- package/archive/composite.ts +0 -85
- package/archive/computed.ts +0 -195
- package/archive/list.ts +0 -483
- package/archive/memo.ts +0 -139
- package/archive/state.ts +0 -90
- package/archive/store.ts +0 -298
- package/archive/task.ts +0 -189
- package/src/classes/collection.ts +0 -245
- package/src/classes/computed.ts +0 -349
- package/src/classes/list.ts +0 -343
- package/src/classes/ref.ts +0 -70
- package/src/classes/state.ts +0 -102
- package/src/classes/store.ts +0 -262
- package/src/diff.ts +0 -138
- package/src/effect.ts +0 -93
- package/src/match.ts +0 -45
- package/src/resolve.ts +0 -49
- package/src/system.ts +0 -257
- package/test/computed.test.ts +0 -1108
- package/test/diff.test.ts +0 -955
- package/test/match.test.ts +0 -388
- package/test/ref.test.ts +0 -353
- package/test/resolve.test.ts +0 -154
- package/types/src/classes/collection.d.ts +0 -45
- package/types/src/classes/computed.d.ts +0 -94
- package/types/src/classes/list.d.ts +0 -43
- package/types/src/classes/ref.d.ts +0 -35
- package/types/src/classes/state.d.ts +0 -49
- package/types/src/classes/store.d.ts +0 -52
- package/types/src/diff.d.ts +0 -28
- package/types/src/effect.d.ts +0 -15
- package/types/src/match.d.ts +0 -21
- package/types/src/resolve.d.ts +0 -29
- package/types/src/system.d.ts +0 -78
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.18.1
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Memo `watched(invalidate)` option**: `createMemo(fn, { watched })` accepts a lazy lifecycle callback that receives an `invalidate` function. Calling `invalidate()` marks the memo dirty and triggers re-evaluation. The callback is invoked on first sink attachment and cleaned up when the last sink detaches. This enables patterns like DOM observation where a memo re-derives its value in response to external events (e.g., MutationObserver) without needing a separate Sensor.
|
|
8
|
+
- **Task `watched(invalidate)` option**: Same pattern as Memo. Calling `invalidate()` aborts any in-flight computation and triggers re-execution.
|
|
9
|
+
- **`CollectionChanges<T>` type**: New typed interface for collection mutations with `add?: T[]`, `change?: T[]`, `remove?: T[]` arrays. Replaces the untyped `DiffResult` records previously used by `CollectionCallback`.
|
|
10
|
+
- **`SensorOptions<T>` type**: Dedicated options type for `createSensor`, extending `SignalOptions<T>` with optional `value`.
|
|
11
|
+
- **`CollectionChanges` export** from public API (`index.ts`).
|
|
12
|
+
- **`SensorOptions` export** from public API (`index.ts`).
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- **`createSensor` parameter renamed**: `start` → `watched` for consistency with Store/List lifecycle terminology.
|
|
17
|
+
- **`createSensor` options type**: `ComputedOptions<T>` → `SensorOptions<T>`. This decouples Sensor options from `ComputedOptions`, which now carries the `watched(invalidate)` field for Memo/Task.
|
|
18
|
+
- **`createCollection` parameter renamed**: `start` → `watched` for consistency.
|
|
19
|
+
- **`CollectionCallback` is now generic**: `CollectionCallback` → `CollectionCallback<T>`. The `applyChanges` parameter accepts `CollectionChanges<T>` instead of `DiffResult`.
|
|
20
|
+
- **`CollectionOptions.createItem` signature**: `(key: string, value: T) => Signal<T>` → `(value: T) => Signal<T>`. Key generation is now handled internally.
|
|
21
|
+
- **`KeyConfig<T>` return type relaxed**: Key functions may now return `string | undefined`. Returning `undefined` falls back to synthetic key generation.
|
|
22
|
+
|
|
23
|
+
### Removed
|
|
24
|
+
|
|
25
|
+
- **`DiffResult` removed from public API**: No longer re-exported from `index.ts`. The type remains available from `src/nodes/list.ts` for internal use but is superseded by `CollectionChanges<T>` for collection mutations.
|
|
26
|
+
|
|
27
|
+
## 0.18.0
|
|
28
|
+
|
|
29
|
+
Baseline release. Factory function API (`createState`, `createMemo`, `createTask`, `createEffect`, `createStore`, `createList`, `createCollection`, `createSensor`) with linked-list graph engine.
|
package/CLAUDE.md
CHANGED
|
@@ -14,21 +14,34 @@ Cause & Effect is a reactive state management library that implements the signal
|
|
|
14
14
|
|
|
15
15
|
Think of signals as **observable cells** in a spreadsheet:
|
|
16
16
|
- **State signals** are input cells for primitive values and objects
|
|
17
|
-
- **
|
|
18
|
-
- **
|
|
17
|
+
- **Sensor signals** are read-only cells that track external input (mouse position, resize, mutable objects) and update lazily
|
|
18
|
+
- **Memo signals** are formula cells that automatically recalculate when dependencies change
|
|
19
|
+
- **Task signals** are async formula cells with abort semantics and pending state
|
|
19
20
|
- **Store signals** are structured tables where individual columns (properties) are reactive
|
|
20
21
|
- **List signals** are tables with stable row IDs that survive sorting and reordering
|
|
21
|
-
- **Collection signals** are
|
|
22
|
+
- **Collection signals** are externally-fed reactive tables with a watched lifecycle (WebSocket, SSE), or derived tables from Lists/Collections via `.deriveCollection()`
|
|
22
23
|
- **Effects** are event handlers that trigger side effects when cells change
|
|
23
24
|
|
|
24
25
|
## Architectural Deep Dive
|
|
25
26
|
|
|
26
|
-
### The
|
|
27
|
-
The core of reactivity lies in the
|
|
28
|
-
- Each
|
|
29
|
-
- When a signal's `.get()` method is called during effect/
|
|
30
|
-
- When a signal changes, it
|
|
31
|
-
-
|
|
27
|
+
### The Graph System
|
|
28
|
+
The core of reactivity lies in the linked graph system (`src/graph.ts`):
|
|
29
|
+
- Each source node maintains a linked list of `Edge` entries pointing to sink nodes
|
|
30
|
+
- When a signal's `.get()` method is called during effect/memo/task execution, it automatically links the source to the active sink via `link()`
|
|
31
|
+
- When a signal changes, it calls `propagate()` on all linked sinks, which flags them dirty
|
|
32
|
+
- `flush()` processes queued effects after propagation
|
|
33
|
+
- `batch()` defers flushing until the outermost batch completes
|
|
34
|
+
- `trimSources()` removes stale edges after recomputation
|
|
35
|
+
|
|
36
|
+
### Node Types in the Graph
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
StateNode<T> — source-only with equality + guard (State, Sensor)
|
|
40
|
+
MemoNode<T> — source + sink (Memo, Store, List, Collection)
|
|
41
|
+
TaskNode<T> — source + sink + async (Task)
|
|
42
|
+
EffectNode — sink + owner (Effect)
|
|
43
|
+
Scope — owner-only (createScope)
|
|
44
|
+
```
|
|
32
45
|
|
|
33
46
|
### Signal Hierarchy and Type System
|
|
34
47
|
|
|
@@ -38,18 +51,33 @@ interface Signal<T extends {}> {
|
|
|
38
51
|
get(): T
|
|
39
52
|
}
|
|
40
53
|
|
|
41
|
-
//
|
|
42
|
-
|
|
54
|
+
// State adds mutation methods
|
|
55
|
+
type State<T extends {}> = {
|
|
56
|
+
get(): T
|
|
43
57
|
set(value: T): void
|
|
44
58
|
update(fn: (current: T) => T): void
|
|
45
59
|
}
|
|
46
60
|
|
|
47
|
-
//
|
|
48
|
-
|
|
61
|
+
// Memo is read-only computed
|
|
62
|
+
type Memo<T extends {}> = {
|
|
63
|
+
get(): T
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Task adds async control
|
|
67
|
+
type Task<T extends {}> = {
|
|
68
|
+
get(): T
|
|
69
|
+
isPending(): boolean
|
|
70
|
+
abort(): void
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Collection interface
|
|
74
|
+
type Collection<T extends {}> = {
|
|
49
75
|
get(): T[]
|
|
50
76
|
at(index: number): Signal<T> | undefined
|
|
51
77
|
byKey(key: string): Signal<T> | undefined
|
|
52
|
-
|
|
78
|
+
keys(): IterableIterator<string>
|
|
79
|
+
deriveCollection<R extends {}>(callback: (sourceValue: T) => R): Collection<R>
|
|
80
|
+
deriveCollection<R extends {}>(callback: (sourceValue: T, abort: AbortSignal) => Promise<R>): Collection<R>
|
|
53
81
|
}
|
|
54
82
|
```
|
|
55
83
|
|
|
@@ -57,80 +85,88 @@ The generic constraint `T extends {}` is crucial - it excludes `null` and `undef
|
|
|
57
85
|
|
|
58
86
|
### Collection Architecture
|
|
59
87
|
|
|
60
|
-
Collections
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
-
|
|
64
|
-
-
|
|
88
|
+
**Collections** (`createCollection`): Externally-driven collections with watched lifecycle
|
|
89
|
+
- Created via `createCollection(watched, options?)` — mirrors `createSensor(watched, options?)`
|
|
90
|
+
- The `watched` callback receives an `applyChanges(diffResult)` function for granular add/change/remove operations
|
|
91
|
+
- `options.value` provides initial items (default `[]`), `options.keyConfig` configures key generation
|
|
92
|
+
- Lazy activation: `watched` callback invoked on first effect access, cleanup when unwatched
|
|
93
|
+
|
|
94
|
+
**Derived Collections** (`deriveCollection`): Transformed from Lists or other Collections
|
|
95
|
+
- Created via `list.deriveCollection(callback)` or `collection.deriveCollection(callback)`
|
|
96
|
+
- Also available as `deriveCollection(source, callback)` (internal helper, exported for advanced use)
|
|
97
|
+
- Item-level memoization: sync callbacks use `createMemo`, async callbacks use `createTask`
|
|
98
|
+
- Structural changes tracked via an internal `MemoNode` that reads `source.keys()`
|
|
65
99
|
- Chainable for data pipelines
|
|
66
100
|
|
|
67
|
-
**ElementCollection**: DOM element collections with MutationObserver
|
|
68
|
-
- Uses Ref signals for elements that change externally
|
|
69
|
-
- Watches attributes and childList mutations
|
|
70
|
-
- Stable keys for persistent element identity
|
|
71
|
-
|
|
72
|
-
Key patterns:
|
|
73
|
-
- Collections return arrays of values via `.get()`
|
|
74
|
-
- Individual items accessed as signals via `.at()` and `.byKey()`
|
|
75
|
-
- All collections support `.deriveCollection()` for chaining
|
|
76
|
-
|
|
77
101
|
### Store and List Architecture
|
|
78
102
|
|
|
79
103
|
**Store signals** (`createStore`): Transform objects into reactive data structures
|
|
80
|
-
- Each property becomes its own signal via Proxy
|
|
81
|
-
-
|
|
82
|
-
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
-
|
|
104
|
+
- Each property becomes its own State signal (primitives) or nested Store (objects) via Proxy
|
|
105
|
+
- Uses an internal `MemoNode` for structural reactivity (add/remove properties)
|
|
106
|
+
- `diff()` computes granular changes when calling `set()`
|
|
107
|
+
- Dynamic property addition/removal with `add()`/`remove()`
|
|
108
|
+
|
|
109
|
+
**List signals** (`createList`): Arrays with stable keys and reactive items
|
|
110
|
+
- Each item becomes a `State` signal in a `Map<string, State<T>>`
|
|
111
|
+
- Uses an internal `MemoNode` for structural reactivity
|
|
112
|
+
- Configurable key generation: auto-increment, string prefix, or function
|
|
87
113
|
- Provides `byKey()`, `keyAt()`, `indexOfKey()` for key-based access
|
|
114
|
+
- `deriveCollection()` creates derived Collections
|
|
88
115
|
|
|
89
116
|
### Computed Signal Memoization Strategy
|
|
90
117
|
|
|
91
118
|
Computed signals implement smart memoization:
|
|
92
|
-
- **Dependency Tracking**: Automatically tracks which signals are accessed during computation
|
|
93
|
-
- **Stale Detection**:
|
|
94
|
-
- **Async Support**:
|
|
119
|
+
- **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
|
|
121
|
+
- **Async Support**: `createTask` handles Promise-based computations with automatic AbortController cancellation
|
|
95
122
|
- **Error Handling**: Preserves error states and prevents cascade failures
|
|
96
123
|
- **Reducer Capabilities**: Access to previous value enables state accumulation and transitions
|
|
97
124
|
|
|
98
125
|
## Resource Management with Watch Callbacks
|
|
99
126
|
|
|
100
|
-
|
|
127
|
+
Sensor, Collection, Memo (with `watched` option), and Task (with `watched` option) use a **watched callback** pattern for lazy resource management. Resources are allocated only when a signal is first accessed by an effect and automatically cleaned up when no effects are watching:
|
|
101
128
|
|
|
102
129
|
```typescript
|
|
103
|
-
//
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
},
|
|
109
|
-
unwatched: () => {
|
|
110
|
-
console.log('Cleaning up API client...')
|
|
111
|
-
client.disconnect()
|
|
112
|
-
}
|
|
113
|
-
})
|
|
114
|
-
|
|
115
|
-
// Resource is only created when effect runs
|
|
116
|
-
const cleanup = createEffect(() => {
|
|
117
|
-
console.log('API URL:', config.get().apiUrl) // Triggers watched callback
|
|
130
|
+
// Sensor: track external input with state updates
|
|
131
|
+
const mousePos = createSensor<{ x: number; y: number }>((set) => {
|
|
132
|
+
const handler = (e: MouseEvent) => set({ x: e.clientX, y: e.clientY })
|
|
133
|
+
window.addEventListener('mousemove', handler)
|
|
134
|
+
return () => window.removeEventListener('mousemove', handler)
|
|
118
135
|
})
|
|
119
136
|
|
|
120
|
-
|
|
137
|
+
// Sensor: observe a mutable external object (SKIP_EQUALITY)
|
|
138
|
+
const element = createSensor<HTMLElement>((set) => {
|
|
139
|
+
const node = document.getElementById('status')!
|
|
140
|
+
set(node)
|
|
141
|
+
const observer = new MutationObserver(() => set(node))
|
|
142
|
+
observer.observe(node, { attributes: true })
|
|
143
|
+
return () => observer.disconnect() // cleanup when unwatched
|
|
144
|
+
}, { value: node, equals: SKIP_EQUALITY })
|
|
145
|
+
|
|
146
|
+
// Collection: receive keyed data from external source
|
|
147
|
+
const feed = createCollection<{ id: string; text: string }>((applyChanges) => {
|
|
148
|
+
const es = new EventSource('/feed')
|
|
149
|
+
es.onmessage = (e) => applyChanges(JSON.parse(e.data))
|
|
150
|
+
return () => es.close()
|
|
151
|
+
}, { keyConfig: item => item.id })
|
|
121
152
|
```
|
|
122
153
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
154
|
+
Store and List signals support an optional `watched` callback in their options:
|
|
155
|
+
|
|
156
|
+
```typescript
|
|
157
|
+
const user = createStore({ name: 'Alice', email: 'alice@example.com' }, {
|
|
158
|
+
watched: () => {
|
|
159
|
+
console.log('Store is now being watched')
|
|
160
|
+
const ws = new WebSocket('/updates')
|
|
161
|
+
return () => ws.close() // cleanup returned as Cleanup
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
```
|
|
129
165
|
|
|
130
166
|
**Watch Lifecycle**:
|
|
131
|
-
1. First effect accesses signal →
|
|
132
|
-
|
|
133
|
-
|
|
167
|
+
1. First effect accesses signal → watched callback executed
|
|
168
|
+
2. Last effect stops watching → returned cleanup function executed
|
|
169
|
+
3. New effect accesses signal → watched callback executed again
|
|
134
170
|
|
|
135
171
|
This pattern enables **lazy resource allocation** - resources are only consumed when actually needed and automatically freed when no longer used.
|
|
136
172
|
|
|
@@ -138,126 +174,146 @@ This pattern enables **lazy resource allocation** - resources are only consumed
|
|
|
138
174
|
|
|
139
175
|
### When to Use Each Signal Type
|
|
140
176
|
|
|
141
|
-
**State
|
|
177
|
+
**State (`createState`)**:
|
|
142
178
|
- Primitive values (numbers, strings, booleans)
|
|
143
179
|
- Objects that you replace entirely rather than mutating properties
|
|
144
180
|
- Simple toggles and flags
|
|
145
|
-
- Values with straightforward update patterns
|
|
146
181
|
|
|
147
182
|
```typescript
|
|
148
|
-
const count =
|
|
149
|
-
const theme =
|
|
183
|
+
const count = createState(0)
|
|
184
|
+
const theme = createState<'light' | 'dark'>('light')
|
|
150
185
|
```
|
|
151
186
|
|
|
152
|
-
**
|
|
153
|
-
- External
|
|
154
|
-
-
|
|
155
|
-
-
|
|
187
|
+
**Sensor (`createSensor`)**:
|
|
188
|
+
- External input streams (mouse position, window size, media queries)
|
|
189
|
+
- External mutable objects observed via MutationObserver, IntersectionObserver, etc.
|
|
190
|
+
- Returns a read-only signal that activates lazily and tears down when unwatched
|
|
191
|
+
- Starts undefined until first `set()` unless `options.value` is provided
|
|
156
192
|
|
|
157
193
|
```typescript
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
|
|
194
|
+
// Tracking external values (default equality)
|
|
195
|
+
const windowSize = createSensor<{ w: number; h: number }>((set) => {
|
|
196
|
+
const update = () => set({ w: innerWidth, h: innerHeight })
|
|
197
|
+
update()
|
|
198
|
+
window.addEventListener('resize', update)
|
|
199
|
+
return () => window.removeEventListener('resize', update)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
// Observing a mutable object (SKIP_EQUALITY)
|
|
203
|
+
const el = createSensor<HTMLElement>((set) => {
|
|
204
|
+
const node = document.getElementById('box')!
|
|
205
|
+
set(node)
|
|
206
|
+
const obs = new MutationObserver(() => set(node))
|
|
207
|
+
obs.observe(node, { attributes: true })
|
|
208
|
+
return () => obs.disconnect()
|
|
209
|
+
}, { value: node, equals: SKIP_EQUALITY })
|
|
161
210
|
```
|
|
162
211
|
|
|
163
|
-
**Store
|
|
212
|
+
**Store (`createStore`)**:
|
|
164
213
|
- Objects where individual properties change independently
|
|
214
|
+
- Proxy-based: access properties directly as signals
|
|
165
215
|
|
|
166
216
|
```typescript
|
|
167
217
|
const user = createStore({ name: 'Alice', email: 'alice@example.com' })
|
|
168
218
|
user.name.set('Bob') // Only name subscribers react
|
|
169
219
|
```
|
|
170
220
|
|
|
171
|
-
**List
|
|
221
|
+
**List (`createList`)**:
|
|
222
|
+
- Arrays with stable keys and reactive items
|
|
223
|
+
|
|
172
224
|
```typescript
|
|
173
|
-
const todoList =
|
|
225
|
+
const todoList = createList([
|
|
174
226
|
{ id: 'task1', text: 'Learn signals' }
|
|
175
|
-
], todo => todo.id)
|
|
227
|
+
], { keyConfig: todo => todo.id })
|
|
176
228
|
const firstTodo = todoList.byKey('task1') // Access by stable key
|
|
177
229
|
```
|
|
178
230
|
|
|
179
|
-
**Collection
|
|
231
|
+
**Collection (`createCollection`)**:
|
|
232
|
+
- Externally-driven keyed collections (WebSocket streams, SSE, external data feeds)
|
|
233
|
+
- Mirrors `createSensor(watched, options?)` — watched callback pattern with lazy lifecycle
|
|
234
|
+
- Same `Collection` interface — `.get()`, `.byKey()`, `.keys()`, `.deriveCollection()`
|
|
235
|
+
|
|
236
|
+
```typescript
|
|
237
|
+
const feed = createCollection<{ id: string; text: string }>((applyChanges) => {
|
|
238
|
+
const ws = new WebSocket('/feed')
|
|
239
|
+
ws.onmessage = (e) => applyChanges(JSON.parse(e.data))
|
|
240
|
+
return () => ws.close()
|
|
241
|
+
}, { keyConfig: item => item.id })
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
**Derived Collection** (`.deriveCollection()`):
|
|
245
|
+
- Read-only derived transformations of Lists or other Collections
|
|
246
|
+
- Created via `.deriveCollection()` method on List or Collection
|
|
247
|
+
|
|
180
248
|
```typescript
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
const response = await fetch(`/
|
|
186
|
-
return { ...
|
|
249
|
+
const doubled = numbers.deriveCollection((value: number) => value * 2)
|
|
250
|
+
|
|
251
|
+
// Async transformation
|
|
252
|
+
const enriched = users.deriveCollection(async (user, abort) => {
|
|
253
|
+
const response = await fetch(`/api/${user.id}`, { signal: abort })
|
|
254
|
+
return { ...user, details: await response.json() }
|
|
187
255
|
})
|
|
188
256
|
|
|
189
257
|
// Chain collections for data pipelines
|
|
190
|
-
const
|
|
258
|
+
const processed = todoList
|
|
191
259
|
.deriveCollection(todo => ({ ...todo, urgent: todo.priority > 8 }))
|
|
192
260
|
.deriveCollection(todo => todo.urgent ? `URGENT: ${todo.text}` : todo.text)
|
|
193
|
-
|
|
194
|
-
// Collections maintain stable references through List changes
|
|
195
|
-
const firstTodoDetail = todoDetails.byKey('task1') // Computed signal
|
|
196
|
-
todoList.sort() // Reorders list but collection signals remain stable
|
|
197
261
|
```
|
|
198
262
|
|
|
199
|
-
**
|
|
200
|
-
-
|
|
201
|
-
-
|
|
202
|
-
-
|
|
203
|
-
- Cross-cutting concerns that multiple components need
|
|
263
|
+
**Memo (`createMemo`)**:
|
|
264
|
+
- Synchronous derived computations with memoization
|
|
265
|
+
- Reducer pattern with previous value access
|
|
266
|
+
- Optional `watched(invalidate)` callback for lazy external invalidation (e.g., MutationObserver)
|
|
204
267
|
|
|
205
268
|
```typescript
|
|
206
|
-
const
|
|
207
|
-
return heavyComputation(data1.get(), data2.get()) // Memoized
|
|
208
|
-
})
|
|
209
|
-
|
|
210
|
-
const userData = new Task(async (prev, abort) => {
|
|
211
|
-
const id = userId.get()
|
|
212
|
-
if (!id) return prev // Keep previous data if no ID
|
|
213
|
-
const response = await fetch(`/users/${id}`, { signal: abort })
|
|
214
|
-
return response.json()
|
|
215
|
-
})
|
|
269
|
+
const doubled = createMemo(() => count.get() * 2)
|
|
216
270
|
|
|
217
271
|
// Reducer pattern for state machines
|
|
218
|
-
const gameState =
|
|
272
|
+
const gameState = createMemo(prev => {
|
|
219
273
|
const action = playerAction.get()
|
|
220
274
|
switch (prev) {
|
|
221
|
-
case 'menu':
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
return action === 'pause' ? 'paused' : action === 'gameover' ? 'ended' : 'playing'
|
|
225
|
-
case 'paused':
|
|
226
|
-
return action === 'resume' ? 'playing' : action === 'quit' ? 'menu' : 'paused'
|
|
227
|
-
case 'ended':
|
|
228
|
-
return action === 'restart' ? 'playing' : action === 'menu' ? 'menu' : 'ended'
|
|
229
|
-
default:
|
|
230
|
-
return 'menu'
|
|
275
|
+
case 'menu': return action === 'start' ? 'playing' : 'menu'
|
|
276
|
+
case 'playing': return action === 'pause' ? 'paused' : 'playing'
|
|
277
|
+
default: return prev
|
|
231
278
|
}
|
|
232
|
-
}, 'menu')
|
|
279
|
+
}, { value: 'menu' })
|
|
280
|
+
|
|
281
|
+
// Accumulating values
|
|
282
|
+
const runningTotal = createMemo(prev => prev + currentValue.get(), { value: 0 })
|
|
283
|
+
```
|
|
233
284
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
285
|
+
**Task (`createTask`)**:
|
|
286
|
+
- Async computations with automatic cancellation
|
|
287
|
+
- Optional `watched(invalidate)` callback for lazy external invalidation
|
|
288
|
+
|
|
289
|
+
```typescript
|
|
290
|
+
const userData = createTask(async (prev, abort) => {
|
|
291
|
+
const id = userId.get()
|
|
292
|
+
if (!id) return prev
|
|
293
|
+
const response = await fetch(`/users/${id}`, { signal: abort })
|
|
294
|
+
return response.json()
|
|
295
|
+
})
|
|
239
296
|
```
|
|
240
297
|
|
|
241
298
|
### Error Handling Strategies
|
|
242
299
|
|
|
243
300
|
The library provides several layers of error handling:
|
|
244
301
|
|
|
245
|
-
1. **Input Validation**:
|
|
302
|
+
1. **Input Validation**: `validateSignalValue()` and `validateCallback()` with custom error classes
|
|
246
303
|
2. **Async Cancellation**: AbortSignal integration prevents stale async operations
|
|
247
|
-
3. **Error Propagation**:
|
|
248
|
-
4. **Helper
|
|
304
|
+
3. **Error Propagation**: Memo and Task preserve error states and throw on `.get()`
|
|
305
|
+
4. **Match Helper**: `match()` for ergonomic signal value extraction inside effects
|
|
249
306
|
|
|
250
307
|
```typescript
|
|
251
|
-
|
|
252
|
-
const apiData = new Task(async (prev, abort) => {
|
|
308
|
+
const apiData = createTask(async (prev, abort) => {
|
|
253
309
|
const response = await fetch('/api/data', { signal: abort })
|
|
254
310
|
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
255
311
|
return response.json()
|
|
256
312
|
})
|
|
257
313
|
|
|
258
314
|
createEffect(() => {
|
|
259
|
-
match(
|
|
260
|
-
ok: (
|
|
315
|
+
match([apiData], {
|
|
316
|
+
ok: ([data]) => updateUI(data),
|
|
261
317
|
nil: () => showLoading(),
|
|
262
318
|
err: errors => showError(errors[0].message)
|
|
263
319
|
})
|
|
@@ -266,9 +322,9 @@ createEffect(() => {
|
|
|
266
322
|
|
|
267
323
|
### Performance Optimization
|
|
268
324
|
|
|
269
|
-
**Batching**: Use `
|
|
325
|
+
**Batching**: Use `batch()` for multiple updates
|
|
270
326
|
```typescript
|
|
271
|
-
|
|
327
|
+
batch(() => {
|
|
272
328
|
user.name.set('Alice')
|
|
273
329
|
user.email.set('alice@example.com')
|
|
274
330
|
}) // Single effect trigger
|
|
@@ -277,17 +333,19 @@ batchSignalWrites(() => {
|
|
|
277
333
|
**Granular Dependencies**: Structure computed signals to minimize dependencies
|
|
278
334
|
```typescript
|
|
279
335
|
// Bad: depends on entire user object
|
|
280
|
-
const display =
|
|
281
|
-
// Good: only depends on specific properties
|
|
282
|
-
const display =
|
|
336
|
+
const display = createMemo(() => user.get().name + user.get().email)
|
|
337
|
+
// Good: only depends on specific properties
|
|
338
|
+
const display = createMemo(() => user.name.get() + user.email.get())
|
|
283
339
|
```
|
|
284
340
|
|
|
285
341
|
## Common Pitfalls
|
|
286
342
|
|
|
287
343
|
1. **Infinite Loops**: Don't update signals within their own computed callbacks
|
|
288
|
-
2. **Memory Leaks**: Clean up effects when components unmount
|
|
344
|
+
2. **Memory Leaks**: Clean up effects when components unmount
|
|
289
345
|
3. **Over-reactivity**: Structure data to minimize unnecessary updates
|
|
290
346
|
4. **Async Race Conditions**: Trust automatic cancellation with AbortSignal
|
|
347
|
+
5. **Circular Dependencies**: The graph detects and throws `CircularDependencyError`
|
|
348
|
+
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.
|
|
291
349
|
|
|
292
350
|
## Advanced Patterns
|
|
293
351
|
|
|
@@ -299,8 +357,8 @@ type Events = {
|
|
|
299
357
|
}
|
|
300
358
|
|
|
301
359
|
const eventBus = createStore<Events>({
|
|
302
|
-
userLogin:
|
|
303
|
-
userLogout:
|
|
360
|
+
userLogin: undefined as unknown as Events['userLogin'],
|
|
361
|
+
userLogout: undefined as unknown as Events['userLogout'],
|
|
304
362
|
})
|
|
305
363
|
|
|
306
364
|
const emit = <K extends keyof Events>(event: K, data: Events[K]) => {
|
|
@@ -310,13 +368,13 @@ const emit = <K extends keyof Events>(event: K, data: Events[K]) => {
|
|
|
310
368
|
const on = <K extends keyof Events>(event: K, callback: (data: Events[K]) => void) =>
|
|
311
369
|
createEffect(() => {
|
|
312
370
|
const data = eventBus[event].get()
|
|
313
|
-
if (data
|
|
371
|
+
if (data != null) callback(data)
|
|
314
372
|
})
|
|
315
373
|
```
|
|
316
374
|
|
|
317
375
|
### Data Processing Pipelines
|
|
318
376
|
```typescript
|
|
319
|
-
const rawData =
|
|
377
|
+
const rawData = createList([{ id: 1, value: 10 }], { keyConfig: item => String(item.id) })
|
|
320
378
|
const processed = rawData
|
|
321
379
|
.deriveCollection(item => ({ ...item, doubled: item.value * 2 }))
|
|
322
380
|
.deriveCollection(item => ({ ...item, formatted: `$${item.doubled}` }))
|
|
@@ -324,9 +382,9 @@ const processed = rawData
|
|
|
324
382
|
|
|
325
383
|
### Stable List Keys
|
|
326
384
|
```typescript
|
|
327
|
-
const playlist =
|
|
385
|
+
const playlist = createList([
|
|
328
386
|
{ id: 'track1', title: 'Song A' }
|
|
329
|
-
], track => track.id)
|
|
387
|
+
], { keyConfig: track => track.id })
|
|
330
388
|
|
|
331
389
|
const firstTrack = playlist.byKey('track1') // Persists through sorting
|
|
332
390
|
playlist.sort((a, b) => a.title.localeCompare(b.title))
|