@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
|
@@ -2,70 +2,84 @@
|
|
|
2
2
|
|
|
3
3
|
## Project Overview
|
|
4
4
|
|
|
5
|
-
Cause & Effect is a reactive state management library for JavaScript/TypeScript that provides signals-based reactivity. The library is built
|
|
5
|
+
Cause & Effect is a reactive state management library for JavaScript/TypeScript that provides signals-based reactivity. The library is built on a linked graph of source and sink nodes (`src/graph.ts`) with functional factory functions for all signal types.
|
|
6
6
|
|
|
7
7
|
## Core Architecture
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
- **
|
|
11
|
-
- **
|
|
12
|
-
- **
|
|
13
|
-
- **
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
- **
|
|
9
|
+
### Graph Engine (`src/graph.ts`)
|
|
10
|
+
- **Nodes**: StateNode (source + equality), MemoNode (source + sink), TaskNode (source + sink + async), EffectNode (sink + owner)
|
|
11
|
+
- **Edges**: Doubly-linked list connecting sources to sinks
|
|
12
|
+
- **Operations**: `link()` creates edges, `propagate()` flags sinks dirty, `flush()` runs queued effects, `batch()` defers flushing
|
|
13
|
+
- **Flags**: FLAG_CLEAN, FLAG_CHECK, FLAG_DIRTY, FLAG_RUNNING for efficient dirty checking
|
|
14
|
+
|
|
15
|
+
### Signal Types (all in `src/nodes/`)
|
|
16
|
+
- **State** (`createState`): Mutable signals for values (`get`, `set`, `update`)
|
|
17
|
+
- **Sensor** (`createSensor`): Read-only signals for external input with automatic state updates. Use `SKIP_EQUALITY` for mutable object observation.
|
|
18
|
+
- **Memo** (`createMemo`): Synchronous derived computations with memoization, reducer capabilities, and optional `watched(invalidate)` for external invalidation
|
|
19
|
+
- **Task** (`createTask`): Async derived computations with automatic AbortController cancellation and optional `watched(invalidate)` for external invalidation
|
|
20
|
+
- **Store** (`createStore`): Proxy-based reactive objects with per-property State/Store signals
|
|
21
|
+
- **List** (`createList`): Reactive arrays with stable keys and per-item State signals
|
|
22
|
+
- **Collection** (`createCollection`): Reactive collections — either externally-driven with watched lifecycle, or derived from List/Collection with item-level memoization
|
|
23
|
+
- **Effect** (`createEffect`): Side effects that react to signal changes
|
|
17
24
|
|
|
18
25
|
## Key Files Structure
|
|
19
26
|
|
|
20
|
-
- `src/
|
|
21
|
-
- `src/
|
|
22
|
-
- `src/
|
|
23
|
-
- `src/
|
|
24
|
-
- `src/
|
|
25
|
-
- `src/
|
|
26
|
-
- `src/
|
|
27
|
-
- `src/
|
|
28
|
-
- `src/
|
|
29
|
-
- `src/
|
|
30
|
-
- `
|
|
27
|
+
- `src/graph.ts` - Core reactive engine (nodes, edges, link, propagate, flush, batch)
|
|
28
|
+
- `src/errors.ts` - Error classes and validation functions
|
|
29
|
+
- `src/nodes/state.ts` - createState, isState, State type
|
|
30
|
+
- `src/nodes/sensor.ts` - createSensor, isSensor, SensorCallback type
|
|
31
|
+
- `src/nodes/memo.ts` - createMemo, isMemo, Memo type
|
|
32
|
+
- `src/nodes/task.ts` - createTask, isTask, Task type
|
|
33
|
+
- `src/nodes/effect.ts` - createEffect, match, MatchHandlers type
|
|
34
|
+
- `src/nodes/store.ts` - createStore, isStore, Store type, diff, isEqual
|
|
35
|
+
- `src/nodes/list.ts` - createList, isList, List type
|
|
36
|
+
- `src/nodes/collection.ts` - createCollection, isCollection, Collection type, deriveCollection (internal)
|
|
37
|
+
- `src/util.ts` - Utility functions and type checks
|
|
38
|
+
- `index.ts` - Entry point / main export file
|
|
31
39
|
|
|
32
40
|
## Coding Conventions
|
|
33
41
|
|
|
34
42
|
### TypeScript Style
|
|
35
43
|
- Use `const` for immutable values, prefer immutability
|
|
36
44
|
- Generic constraints: `T extends {}` to exclude nullish values
|
|
37
|
-
- Function overloads for complex type inference
|
|
45
|
+
- Function overloads for complex type inference (e.g., `createCollection`, `deriveCollection`)
|
|
38
46
|
- Pure functions marked with `/*#__PURE__*/` for tree-shaking
|
|
39
47
|
- JSDoc comments for all public APIs
|
|
40
48
|
|
|
41
49
|
### Naming Conventions
|
|
42
|
-
- Factory functions: `create*` (e.g., `createEffect`, `createStore`)
|
|
43
|
-
- Type predicates: `is*` (e.g., `
|
|
44
|
-
-
|
|
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
|
+
- Type constants: `TYPE_*` for internal type tags
|
|
53
|
+
- Callback types: `*Callback` suffix (MemoCallback, TaskCallback, EffectCallback, SensorCallback, CollectionCallback, DeriveCollectionCallback)
|
|
45
54
|
- Private variables: use descriptive names, no underscore prefix
|
|
46
55
|
|
|
47
56
|
### Error Handling
|
|
48
|
-
-
|
|
49
|
-
-
|
|
50
|
-
-
|
|
51
|
-
-
|
|
57
|
+
- Error classes defined in `src/errors.ts`: CircularDependencyError, NullishSignalValueError, InvalidSignalValueError, InvalidCallbackError, RequiredOwnerError, UnsetSignalValueError
|
|
58
|
+
- `validateSignalValue()` and `validateCallback()` for input validation at public API boundaries
|
|
59
|
+
- Optional `guard` function in SignalOptions for runtime type checking
|
|
60
|
+
- AbortSignal for cancellation in async Tasks
|
|
52
61
|
|
|
53
62
|
### Performance Patterns
|
|
54
|
-
-
|
|
55
|
-
-
|
|
56
|
-
-
|
|
57
|
-
-
|
|
58
|
-
-
|
|
63
|
+
- Linked-list edges for O(1) link/unlink
|
|
64
|
+
- Flag-based dirty checking avoids unnecessary recomputation
|
|
65
|
+
- `batch()` defers `flush()` to minimize effect re-runs
|
|
66
|
+
- Lazy evaluation: Memos only recompute when accessed and dirty
|
|
67
|
+
- `trimSources()` removes stale edges after recomputation
|
|
68
|
+
- `unlink()` calls `source.stop()` when last sink disconnects (auto-cleanup)
|
|
59
69
|
|
|
60
70
|
### API Design Principles
|
|
61
|
-
- All signals
|
|
62
|
-
-
|
|
63
|
-
-
|
|
64
|
-
-
|
|
65
|
-
-
|
|
71
|
+
- All signals created via `create*()` factory functions (no class constructors)
|
|
72
|
+
- All signals have `.get()` for value access
|
|
73
|
+
- Mutable signals (State) have `.set(value)` and `.update(fn)`
|
|
74
|
+
- Store properties are automatically reactive signals via Proxy
|
|
75
|
+
- Sensor/Collection use a watched callback returning Cleanup (lazy activation)
|
|
76
|
+
- Memo/Task use optional `watched(invalidate)` callback in options for external invalidation
|
|
77
|
+
- Store/List use optional `watched` callback in options returning Cleanup
|
|
78
|
+
- Effects return a dispose function (Cleanup)
|
|
66
79
|
|
|
67
80
|
### Testing Patterns
|
|
68
|
-
- Use Bun test runner
|
|
81
|
+
- Use Bun test runner (`bun test`)
|
|
82
|
+
- Test files: `test/*.next.test.ts`
|
|
69
83
|
- Test reactivity chains and dependency tracking
|
|
70
84
|
- Test async cancellation behavior
|
|
71
85
|
- Test error conditions and edge cases
|
|
@@ -74,114 +88,160 @@ Cause & Effect is a reactive state management library for JavaScript/TypeScript
|
|
|
74
88
|
|
|
75
89
|
### Creating Signals
|
|
76
90
|
```typescript
|
|
77
|
-
// State for
|
|
78
|
-
const count =
|
|
79
|
-
const name =
|
|
80
|
-
|
|
91
|
+
// State for values
|
|
92
|
+
const count = createState(42)
|
|
93
|
+
const name = createState('Alice')
|
|
94
|
+
|
|
95
|
+
// Sensor for external input
|
|
96
|
+
const mouse = createSensor<{ x: number; y: number }>((set) => {
|
|
97
|
+
const h = (e: MouseEvent) => set({ x: e.clientX, y: e.clientY })
|
|
98
|
+
window.addEventListener('mousemove', h)
|
|
99
|
+
return () => window.removeEventListener('mousemove', h)
|
|
100
|
+
})
|
|
81
101
|
|
|
82
|
-
//
|
|
83
|
-
const
|
|
84
|
-
const
|
|
102
|
+
// Sensor for mutable object observation (SKIP_EQUALITY)
|
|
103
|
+
const element = createSensor<HTMLElement>((set) => {
|
|
104
|
+
const node = document.getElementById('box')!
|
|
105
|
+
set(node)
|
|
106
|
+
const obs = new MutationObserver(() => set(node))
|
|
107
|
+
obs.observe(node, { attributes: true })
|
|
108
|
+
return () => obs.disconnect()
|
|
109
|
+
}, { value: node, equals: SKIP_EQUALITY })
|
|
85
110
|
|
|
86
|
-
// Store for objects
|
|
111
|
+
// Store for reactive objects
|
|
87
112
|
const user = createStore({ name: 'Alice', age: 30 })
|
|
88
113
|
|
|
89
|
-
// List with stable keys
|
|
90
|
-
const items =
|
|
91
|
-
const users =
|
|
114
|
+
// List with stable keys
|
|
115
|
+
const items = createList(['apple', 'banana'], { keyConfig: 'fruit' })
|
|
116
|
+
const users = createList(
|
|
117
|
+
[{ id: 'alice', name: 'Alice' }],
|
|
118
|
+
{ keyConfig: u => u.id }
|
|
119
|
+
)
|
|
92
120
|
|
|
93
|
-
//
|
|
94
|
-
const doubled =
|
|
121
|
+
// Memo for synchronous derived values
|
|
122
|
+
const doubled = createMemo(() => count.get() * 2)
|
|
95
123
|
|
|
96
|
-
//
|
|
97
|
-
const counter =
|
|
124
|
+
// Memo with reducer capabilities
|
|
125
|
+
const counter = createMemo(prev => {
|
|
98
126
|
const action = actions.get()
|
|
99
127
|
return action === 'increment' ? prev + 1 : prev - 1
|
|
100
|
-
},
|
|
128
|
+
}, { value: 0 })
|
|
129
|
+
|
|
130
|
+
// Task for async derived values
|
|
131
|
+
const userData = createTask(async (prev, abort) => {
|
|
132
|
+
const id = userId.get()
|
|
133
|
+
if (!id) return prev
|
|
134
|
+
const response = await fetch(`/users/${id}`, { signal: abort })
|
|
135
|
+
return response.json()
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
// Collection for derived transformations
|
|
139
|
+
const doubled = numbers.deriveCollection((n: number) => n * 2)
|
|
140
|
+
const enriched = users.deriveCollection(async (user, abort) => {
|
|
141
|
+
const res = await fetch(`/api/${user.id}`, { signal: abort })
|
|
142
|
+
return { ...user, details: await res.json() }
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
// Collection for externally-driven data
|
|
146
|
+
const feed = createCollection<{ id: string; text: string }>((applyChanges) => {
|
|
147
|
+
const ws = new WebSocket('/feed')
|
|
148
|
+
ws.onmessage = (e) => applyChanges(JSON.parse(e.data))
|
|
149
|
+
return () => ws.close()
|
|
150
|
+
}, { keyConfig: item => item.id })
|
|
101
151
|
```
|
|
102
152
|
|
|
103
153
|
### Reactivity
|
|
104
154
|
```typescript
|
|
105
|
-
// Effects run when dependencies change
|
|
106
|
-
createEffect(() => {
|
|
155
|
+
// Effects run when dependencies change, return Cleanup
|
|
156
|
+
const dispose = createEffect(() => {
|
|
107
157
|
console.log(`Count is ${count.get()}`)
|
|
108
158
|
})
|
|
109
159
|
|
|
110
|
-
//
|
|
111
|
-
createEffect(
|
|
112
|
-
const
|
|
113
|
-
return
|
|
160
|
+
// Effects can return cleanup functions
|
|
161
|
+
createEffect(() => {
|
|
162
|
+
const timer = setInterval(() => console.log(count.get()), 1000)
|
|
163
|
+
return () => clearInterval(timer)
|
|
114
164
|
})
|
|
115
165
|
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
166
|
+
// match() for ergonomic signal value handling
|
|
167
|
+
createEffect(() => {
|
|
168
|
+
match([userData], {
|
|
169
|
+
ok: ([data]) => updateUI(data),
|
|
170
|
+
nil: () => showLoading(),
|
|
171
|
+
err: errors => showError(errors[0].message)
|
|
172
|
+
})
|
|
121
173
|
})
|
|
122
174
|
```
|
|
123
175
|
|
|
124
176
|
### Type Safety
|
|
125
177
|
```typescript
|
|
126
|
-
//
|
|
178
|
+
// Generic constraints exclude nullish
|
|
127
179
|
function createSignal<T extends {}>(value: T): Signal<T>
|
|
128
180
|
|
|
129
181
|
// Type predicates for runtime checks
|
|
130
|
-
|
|
182
|
+
if (isState(value)) value.set(newValue)
|
|
183
|
+
if (isMemo(value)) console.log(value.get())
|
|
184
|
+
if (isStore(value)) value.name.set('Bob')
|
|
185
|
+
|
|
186
|
+
// Guards for runtime type validation
|
|
187
|
+
const count = createState(0, {
|
|
188
|
+
guard: (v): v is number => typeof v === 'number'
|
|
189
|
+
})
|
|
131
190
|
```
|
|
132
191
|
|
|
133
|
-
##
|
|
134
|
-
- Uses Bun as build tool and runtime
|
|
135
|
-
- TypeScript compilation with declaration files
|
|
136
|
-
- Minified production build
|
|
137
|
-
- ES modules only (`"type": "module"`)
|
|
192
|
+
## Resource Management
|
|
138
193
|
|
|
139
|
-
|
|
194
|
+
```typescript
|
|
195
|
+
// Sensor: lazy external input tracking (watched callback with set)
|
|
196
|
+
const sensor = createSensor<T>((set) => {
|
|
197
|
+
// setup — call set(value) to update
|
|
198
|
+
return () => { /* cleanup — called when last effect stops watching */ }
|
|
199
|
+
})
|
|
140
200
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
201
|
+
// Collection: lazy external data source (watched callback with applyChanges)
|
|
202
|
+
const feed = createCollection<T>((applyChanges) => {
|
|
203
|
+
// setup — call applyChanges(diffResult) on changes
|
|
204
|
+
return () => { /* cleanup */ }
|
|
205
|
+
}, { keyConfig: item => item.id })
|
|
206
|
+
|
|
207
|
+
// Memo/Task: optional watched callback with invalidate
|
|
208
|
+
const derived = createMemo(() => element.get().textContent ?? '', {
|
|
209
|
+
watched: (invalidate) => {
|
|
210
|
+
const obs = new MutationObserver(() => invalidate())
|
|
211
|
+
obs.observe(element.get(), { childList: true })
|
|
212
|
+
return () => obs.disconnect()
|
|
213
|
+
}
|
|
214
|
+
})
|
|
147
215
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
// Creates keys: 'fruit0', 'fruit1'
|
|
156
|
-
|
|
157
|
-
// Function-based keys
|
|
158
|
-
const users = new List([
|
|
159
|
-
{ id: 'user1', name: 'Alice' },
|
|
160
|
-
{ id: 'user2', name: 'Bob' }
|
|
161
|
-
], user => user.id) // Uses user.id as stable key
|
|
162
|
-
|
|
163
|
-
// Collections derived from lists
|
|
164
|
-
const userProfiles = new DerivedCollection(users, user => ({
|
|
165
|
-
...user,
|
|
166
|
-
displayName: `${user.name} (ID: ${user.id})`
|
|
167
|
-
}))
|
|
168
|
-
|
|
169
|
-
// Chained collections for data pipelines
|
|
170
|
-
const activeUserSummaries = users
|
|
171
|
-
.deriveCollection(user => ({ ...user, active: user.lastLogin > Date.now() - 86400000 }))
|
|
172
|
-
.deriveCollection(user => user.active ? `Active: ${user.name}` : null)
|
|
173
|
-
.filter(Boolean)
|
|
174
|
-
```
|
|
216
|
+
// Store/List: optional watched callback
|
|
217
|
+
const store = createStore(initialValue, {
|
|
218
|
+
watched: () => {
|
|
219
|
+
// setup
|
|
220
|
+
return () => { /* cleanup */ }
|
|
221
|
+
}
|
|
222
|
+
})
|
|
175
223
|
|
|
176
|
-
|
|
224
|
+
// Scope for hierarchical cleanup
|
|
225
|
+
const dispose = createScope(() => {
|
|
226
|
+
const state = createState(0)
|
|
227
|
+
createEffect(() => console.log(state.get()))
|
|
228
|
+
return () => console.log('scope disposed')
|
|
229
|
+
})
|
|
230
|
+
dispose() // cleans up effect and runs the returned cleanup
|
|
231
|
+
```
|
|
177
232
|
|
|
178
|
-
|
|
233
|
+
## Build System
|
|
234
|
+
- Uses Bun as build tool and runtime
|
|
235
|
+
- TypeScript compilation with declaration files
|
|
236
|
+
- ES modules only (`"type": "module"`)
|
|
237
|
+
- Biome for code formatting and linting
|
|
179
238
|
|
|
180
239
|
## When suggesting code:
|
|
181
|
-
1.
|
|
182
|
-
2.
|
|
183
|
-
3.
|
|
184
|
-
4.
|
|
185
|
-
5.
|
|
186
|
-
6.
|
|
187
|
-
7.
|
|
240
|
+
1. Use `create*()` factory functions, not class constructors
|
|
241
|
+
2. Follow the established patterns for signal creation and usage
|
|
242
|
+
3. Use proper TypeScript types and generics with `T extends {}`
|
|
243
|
+
4. Include JSDoc for public APIs
|
|
244
|
+
5. Consider performance implications (batching, granular dependencies)
|
|
245
|
+
6. Handle errors with the existing error classes and validation functions
|
|
246
|
+
7. Support async operations with AbortSignal when relevant
|
|
247
|
+
8. Use function overloads when callback signatures have sync/async variants
|
package/ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
# Cause & Effect v0.18 - Signal Graph Architecture
|
|
2
|
+
|
|
3
|
+
This document describes the reactive signal graph engine implemented in `src/graph.ts` and the node types built on top of it in `src/nodes/`.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
The engine maintains a directed acyclic graph (DAG) of signal nodes connected by edges. Nodes come in two roles: **sources** produce values, **sinks** consume them. Some nodes (Memo, Task, Store, List, Collection) are both source and sink. Edges are created and destroyed automatically as computations run, ensuring the graph always reflects the true dependency structure.
|
|
8
|
+
|
|
9
|
+
The design optimizes for three properties:
|
|
10
|
+
|
|
11
|
+
1. **Minimal work**: Only dirty nodes recompute; unchanged values stop propagation.
|
|
12
|
+
2. **Minimal memory**: Edges are stored as doubly-linked lists embedded in nodes, avoiding separate data structures.
|
|
13
|
+
3. **Correctness**: Dynamic dependency tracking means the graph never has stale edges.
|
|
14
|
+
|
|
15
|
+
## Core Data Structures
|
|
16
|
+
|
|
17
|
+
### Edges
|
|
18
|
+
|
|
19
|
+
An `Edge` connects a source to a sink. Each edge participates in two linked lists simultaneously:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
type Edge = {
|
|
23
|
+
source: SourceNode // the node being depended on
|
|
24
|
+
sink: SinkNode // the node that depends on source
|
|
25
|
+
nextSource: Edge | null // next edge in the sink's source list
|
|
26
|
+
prevSink: Edge | null // previous edge in the source's sink list
|
|
27
|
+
nextSink: Edge | null // next edge in the source's sink list
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Each source maintains a singly-linked list of its sinks (`sinks` → `sinksTail`), threaded through `nextSink`/`prevSink`. Each sink maintains a singly-linked list of its sources (`sources` → `sourcesTail`), threaded through `nextSource`. The `prevSink` pointer enables O(1) removal from the source's sink list.
|
|
32
|
+
|
|
33
|
+
### Node Field Mixins
|
|
34
|
+
|
|
35
|
+
Nodes are composed from field groups rather than using class inheritance:
|
|
36
|
+
|
|
37
|
+
| Mixin | Fields | Purpose |
|
|
38
|
+
|-------|--------|---------|
|
|
39
|
+
| `SourceFields<T>` | `value`, `sinks`, `sinksTail`, `stop?` | Holds a value and tracks dependents |
|
|
40
|
+
| `OptionsFields<T>` | `equals`, `guard?` | Equality check and type validation |
|
|
41
|
+
| `SinkFields` | `fn`, `flags`, `sources`, `sourcesTail` | Holds a computation and tracks dependencies |
|
|
42
|
+
| `OwnerFields` | `cleanup` | Manages disposal of child effects/scopes |
|
|
43
|
+
| `AsyncFields` | `controller`, `error` | AbortController for async cancellation |
|
|
44
|
+
|
|
45
|
+
### Concrete Node Types
|
|
46
|
+
|
|
47
|
+
| Node | Composed From | Role |
|
|
48
|
+
|------|---------------|------|
|
|
49
|
+
| `StateNode<T>` | SourceFields + OptionsFields | Source only |
|
|
50
|
+
| `MemoNode<T>` | SourceFields + OptionsFields + SinkFields + `error` | Source + Sink |
|
|
51
|
+
| `TaskNode<T>` | SourceFields + OptionsFields + SinkFields + AsyncFields | Source + Sink |
|
|
52
|
+
| `EffectNode` | SinkFields + OwnerFields | Sink only |
|
|
53
|
+
| `Scope` | OwnerFields | Owner only (not in graph) |
|
|
54
|
+
|
|
55
|
+
## Automatic Dependency Tracking
|
|
56
|
+
|
|
57
|
+
### The `activeSink` Protocol
|
|
58
|
+
|
|
59
|
+
A module-level variable `activeSink` points to the sink node currently executing its computation. When a signal's `.get()` method is called, it checks `activeSink` and, if non-null, calls `link(source, activeSink)` to establish an edge.
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
signal.get()
|
|
63
|
+
└─ if (activeSink) link(thisNode, activeSink)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Before a sink recomputes, the engine sets `activeSink = node`, ensuring all `.get()` calls during execution are captured. After execution, `activeSink` is restored.
|
|
67
|
+
|
|
68
|
+
### Edge Creation: `link(source, sink)`
|
|
69
|
+
|
|
70
|
+
`link()` creates a new edge from source to sink, appending it to both the source's sink list and the sink's source list. It includes three fast-path optimizations:
|
|
71
|
+
|
|
72
|
+
1. **Same source as last**: If `sink.sourcesTail.source === source`, the edge already exists — skip.
|
|
73
|
+
2. **Edge reuse during recomputation**: When `FLAG_RUNNING` is set, `link()` checks if the next existing edge in the sink's source list already points to this source. If so, it advances the `sourcesTail` pointer instead of creating a new edge. This handles the common case where dependencies are the same across recomputations.
|
|
74
|
+
3. **Duplicate sink check**: If the source's last sink edge already points to this sink, skip creating a duplicate.
|
|
75
|
+
|
|
76
|
+
### Edge Removal: `trimSources(node)` and `unlink(edge)`
|
|
77
|
+
|
|
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
|
+
|
|
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.
|
|
81
|
+
|
|
82
|
+
### Dependency Tracking Opt-Out: `untrack(fn)`
|
|
83
|
+
|
|
84
|
+
`untrack()` temporarily sets `activeSink = null`, executing `fn` without creating any edges. This prevents dependency pollution when an effect creates subcomponents with their own internal signals.
|
|
85
|
+
|
|
86
|
+
## Change Propagation
|
|
87
|
+
|
|
88
|
+
### Flag-Based Dirty Tracking
|
|
89
|
+
|
|
90
|
+
Each sink node has a `flags` field with four states:
|
|
91
|
+
|
|
92
|
+
| Flag | Value | Meaning |
|
|
93
|
+
|------|-------|---------|
|
|
94
|
+
| `FLAG_CLEAN` | 0 | Value is up to date |
|
|
95
|
+
| `FLAG_CHECK` | 1 | A transitive dependency may have changed — verify before recomputing |
|
|
96
|
+
| `FLAG_DIRTY` | 2 | A direct dependency changed — recomputation required |
|
|
97
|
+
| `FLAG_RUNNING` | 4 | Currently executing (used for circular dependency detection and edge reuse) |
|
|
98
|
+
|
|
99
|
+
### The `propagate(node)` Function
|
|
100
|
+
|
|
101
|
+
When a source value changes, `propagate()` walks its sink list:
|
|
102
|
+
|
|
103
|
+
- **Memo/Task sinks** (have `sinks` field): Flagged `DIRTY`. Their own sinks are recursively flagged `CHECK`. If the node has an in-flight `AbortController`, it is aborted immediately.
|
|
104
|
+
- **Effect sinks** (no `sinks` field): Flagged `DIRTY` and pushed onto the `queuedEffects` array for later execution.
|
|
105
|
+
|
|
106
|
+
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.
|
|
107
|
+
|
|
108
|
+
### The `refresh(node)` Function
|
|
109
|
+
|
|
110
|
+
`refresh()` is called when a sink's value is read (pull-based evaluation). It handles two cases:
|
|
111
|
+
|
|
112
|
+
1. **FLAG_CHECK**: Walk the node's source list. For each source that is itself a sink (Memo/Task), recursively `refresh()` it. If at any point the node gets upgraded to `DIRTY`, stop checking.
|
|
113
|
+
2. **FLAG_DIRTY**: Recompute the node by calling `recomputeMemo()`, `recomputeTask()`, or `runEffect()` depending on the node type.
|
|
114
|
+
|
|
115
|
+
If `FLAG_RUNNING` is encountered, a `CircularDependencyError` is thrown.
|
|
116
|
+
|
|
117
|
+
### The `setState(node, next)` Function
|
|
118
|
+
|
|
119
|
+
`setState()` is the entry point for value changes on `StateNode`-based signals (State, Sensor). It:
|
|
120
|
+
|
|
121
|
+
1. Checks equality — if unchanged, returns immediately.
|
|
122
|
+
2. Updates `node.value`.
|
|
123
|
+
3. Walks the sink list, calling `propagate()` on each dependent.
|
|
124
|
+
4. If not inside a `batch()`, calls `flush()` to execute queued effects.
|
|
125
|
+
|
|
126
|
+
## Effect Scheduling
|
|
127
|
+
|
|
128
|
+
### Batching
|
|
129
|
+
|
|
130
|
+
`batch(fn)` increments a `batchDepth` counter before executing `fn` and decrements it after. Effects are only flushed when `batchDepth` returns to 0. Batches nest — only the outermost batch triggers a flush.
|
|
131
|
+
|
|
132
|
+
### The `flush()` Function
|
|
133
|
+
|
|
134
|
+
`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.
|
|
135
|
+
|
|
136
|
+
### Effect Lifecycle
|
|
137
|
+
|
|
138
|
+
When an effect runs:
|
|
139
|
+
|
|
140
|
+
1. `runCleanup(node)` disposes previous cleanup callbacks.
|
|
141
|
+
2. `activeSink` and `activeOwner` are set to the effect node.
|
|
142
|
+
3. The effect function executes; `.get()` calls create edges.
|
|
143
|
+
4. If the function returns a cleanup function, it is registered via `registerCleanup()`.
|
|
144
|
+
5. `activeSink` and `activeOwner` are restored.
|
|
145
|
+
6. `trimSources()` removes stale edges.
|
|
146
|
+
|
|
147
|
+
## Ownership and Cleanup
|
|
148
|
+
|
|
149
|
+
### The `activeOwner` Protocol
|
|
150
|
+
|
|
151
|
+
`activeOwner` points to the current owner node (an `EffectNode` or `Scope`). When `createEffect()` is called, the new effect's dispose function is registered on `activeOwner`. This creates a tree of ownership: disposing a parent disposes all children.
|
|
152
|
+
|
|
153
|
+
### Cleanup Storage
|
|
154
|
+
|
|
155
|
+
Cleanup functions are stored on the `cleanup` field of owner nodes. The field is polymorphic for efficiency:
|
|
156
|
+
|
|
157
|
+
- `null` — no cleanups registered.
|
|
158
|
+
- A single function — one cleanup registered.
|
|
159
|
+
- An array of functions — multiple cleanups registered.
|
|
160
|
+
|
|
161
|
+
`registerCleanup()` promotes from `null` → function → array as needed. `runCleanup()` executes all registered cleanups and resets the field to `null`.
|
|
162
|
+
|
|
163
|
+
### `createScope(fn)`
|
|
164
|
+
|
|
165
|
+
Creates an ownership scope without an effect. The scope becomes `activeOwner` during `fn` execution. Returns a `dispose` function. If the scope is created inside another owner, its disposal is automatically registered on the parent.
|
|
166
|
+
|
|
167
|
+
## Signal Types
|
|
168
|
+
|
|
169
|
+
### State (`src/nodes/state.ts`)
|
|
170
|
+
|
|
171
|
+
**Graph node**: `StateNode<T>` (source only)
|
|
172
|
+
|
|
173
|
+
A mutable value container. The simplest signal type — `get()` links and returns the value, `set()` validates, calls `setState()`, which propagates changes to dependents.
|
|
174
|
+
|
|
175
|
+
`update(fn)` is sugar for `set(fn(get()))` with validation.
|
|
176
|
+
|
|
177
|
+
### Sensor (`src/nodes/sensor.ts`)
|
|
178
|
+
|
|
179
|
+
**Graph node**: `StateNode<T>` (source only)
|
|
180
|
+
|
|
181
|
+
A read-only signal that tracks external input. The `watched` callback receives a `set` function that updates the node's value via `setState()`. Sensors cover two patterns:
|
|
182
|
+
|
|
183
|
+
1. **Tracking external values** (default): Receives replacement values from events (mouse position, resize events). Equality checking (`===` by default) prevents unnecessary propagation.
|
|
184
|
+
2. **Observing mutable objects** (with `SKIP_EQUALITY`): Holds a stable reference to a mutable object (DOM element, Map, Set). `set(sameRef)` with `equals: SKIP_EQUALITY` always propagates, notifying consumers that the object's internals have changed.
|
|
185
|
+
|
|
186
|
+
The value starts undefined unless `options.value` is provided. Reading a sensor before its `watched` callback has called `set()` (and without `options.value`) throws `UnsetSignalValueError`.
|
|
187
|
+
|
|
188
|
+
**Lazy lifecycle**: The `watched` callback is invoked on first sink attachment. The returned cleanup is stored as `node.stop` and called when the last sink detaches (via `unlink()`).
|
|
189
|
+
|
|
190
|
+
### Memo (`src/nodes/memo.ts`)
|
|
191
|
+
|
|
192
|
+
**Graph node**: `MemoNode<T>` (source + sink)
|
|
193
|
+
|
|
194
|
+
A synchronous derived computation. The `fn` receives the previous value (or `undefined` on first run) and returns a new value. Dependencies are tracked automatically during execution.
|
|
195
|
+
|
|
196
|
+
Memos use lazy evaluation — they only recompute when read (`get()` calls `refresh()`). If the recomputed value is equal to the previous (per the `equals` function), downstream sinks are not flagged dirty, stopping propagation. This is the key mechanism for avoiding unnecessary work.
|
|
197
|
+
|
|
198
|
+
The `error` field preserves thrown errors: if `fn` throws, the error is stored and re-thrown on subsequent `get()` calls until the memo successfully recomputes.
|
|
199
|
+
|
|
200
|
+
**Reducer pattern**: The `prev` parameter enables state accumulation across recomputations without writable state.
|
|
201
|
+
|
|
202
|
+
**Watched lifecycle**: An optional `watched` callback in options provides lazy external invalidation. The callback receives an `invalidate` function and is invoked on first sink attachment. Calling `invalidate()` marks the node `FLAG_DIRTY`, propagates to sinks, and flushes — triggering re-evaluation of the memo's `fn` without changing any tracked dependency. The returned cleanup is stored as `node.stop` and called when the last sink detaches. This enables patterns like DOM observation (MutationObserver) where the memo re-derives its value in response to external events.
|
|
203
|
+
|
|
204
|
+
### Task (`src/nodes/task.ts`)
|
|
205
|
+
|
|
206
|
+
**Graph node**: `TaskNode<T>` (source + sink)
|
|
207
|
+
|
|
208
|
+
An asynchronous derived computation. Like Memo but `fn` returns a `Promise` and receives an `AbortSignal`. When dependencies change while a task is in flight, the `AbortController` is aborted and a new computation starts.
|
|
209
|
+
|
|
210
|
+
During dependency tracking, only the synchronous preamble of `fn` is tracked (before the first `await`). The promise resolution triggers propagation and flush asynchronously.
|
|
211
|
+
|
|
212
|
+
`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).
|
|
213
|
+
|
|
214
|
+
**Watched lifecycle**: Same pattern as Memo — an optional `watched` callback receives `invalidate` and is invoked on first sink attachment. Calling `invalidate()` marks the node dirty and triggers re-execution, which aborts any in-flight computation via the existing `AbortController` mechanism before starting a new one.
|
|
215
|
+
|
|
216
|
+
### Effect (`src/nodes/effect.ts`)
|
|
217
|
+
|
|
218
|
+
**Graph node**: `EffectNode` (sink only)
|
|
219
|
+
|
|
220
|
+
A side-effecting computation that runs immediately and re-runs when dependencies change. Effects are terminal — they have no value and no sinks. They are pushed onto the `queuedEffects` array during propagation and executed during `flush()`.
|
|
221
|
+
|
|
222
|
+
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
|
+
|
|
224
|
+
### Store (`src/nodes/store.ts`)
|
|
225
|
+
|
|
226
|
+
**Graph node**: `MemoNode<T>` (source + sink, used for structural reactivity)
|
|
227
|
+
|
|
228
|
+
A reactive object where each property is its own signal. Properties are automatically wrapped: primitives become `State`, nested objects become `Store`, arrays become `List`. A `Proxy` provides direct property access (`store.name` returns the `State` signal for that property).
|
|
229
|
+
|
|
230
|
+
**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
|
+
|
|
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.
|
|
233
|
+
|
|
234
|
+
**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
|
+
|
|
236
|
+
**Watched lifecycle**: An optional `watched` callback in options provides lazy resource allocation, following the same pattern as Sensor — activated on first sink, cleaned up when the last sink detaches.
|
|
237
|
+
|
|
238
|
+
### List (`src/nodes/list.ts`)
|
|
239
|
+
|
|
240
|
+
**Graph node**: `MemoNode<T[]>` (source + sink, used for structural reactivity)
|
|
241
|
+
|
|
242
|
+
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
|
+
|
|
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.
|
|
245
|
+
|
|
246
|
+
**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
|
+
|
|
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()`.
|
|
249
|
+
|
|
250
|
+
### Collection (`src/nodes/collection.ts`)
|
|
251
|
+
|
|
252
|
+
Collection implements two creation patterns that share the same `Collection<T>` interface:
|
|
253
|
+
|
|
254
|
+
#### `createCollection(watched, options?)` — externally driven
|
|
255
|
+
|
|
256
|
+
**Graph node**: `MemoNode<T[]>` (source + sink, tracks item values)
|
|
257
|
+
|
|
258
|
+
An externally-driven reactive collection with a watched lifecycle, mirroring `createSensor(watched, options?)`. The `watched` callback receives an `applyChanges(diffResult)` function for granular add/change/remove operations. Initial items are provided via `options.value` (default `[]`).
|
|
259
|
+
|
|
260
|
+
**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
|
+
|
|
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.
|
|
263
|
+
|
|
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.
|
|
265
|
+
|
|
266
|
+
#### `deriveCollection(source, callback)` — internally derived
|
|
267
|
+
|
|
268
|
+
**Graph node**: `MemoNode<T[]>` (source + sink, tracks item values)
|
|
269
|
+
|
|
270
|
+
An internal factory (not exported from the public API) that creates a read-only derived transformation of a List or another Collection. Exposed to users via the `.deriveCollection(callback)` method on List and Collection. Each source item is individually memoized: sync callbacks create `Memo` signals, async callbacks create `Task` signals.
|
|
271
|
+
|
|
272
|
+
**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
|
+
|
|
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.
|
|
275
|
+
|
|
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.
|