@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.
Files changed (94) hide show
  1. package/.ai-context.md +169 -227
  2. package/.cursorrules +41 -35
  3. package/.github/copilot-instructions.md +176 -116
  4. package/ARCHITECTURE.md +276 -0
  5. package/CHANGELOG.md +29 -0
  6. package/CLAUDE.md +201 -143
  7. package/GUIDE.md +298 -0
  8. package/README.md +246 -193
  9. package/REQUIREMENTS.md +100 -0
  10. package/bench/reactivity.bench.ts +577 -0
  11. package/context7.json +4 -0
  12. package/examples/events-sensor.ts +187 -0
  13. package/examples/selector-sensor.ts +173 -0
  14. package/index.dev.js +1390 -1008
  15. package/index.js +1 -1
  16. package/index.ts +60 -74
  17. package/package.json +5 -2
  18. package/skills/changelog-keeper/SKILL.md +59 -0
  19. package/skills/changelog-keeper/agents/openai.yaml +4 -0
  20. package/src/errors.ts +118 -74
  21. package/src/graph.ts +612 -0
  22. package/src/nodes/collection.ts +512 -0
  23. package/src/nodes/effect.ts +149 -0
  24. package/src/nodes/list.ts +589 -0
  25. package/src/nodes/memo.ts +148 -0
  26. package/src/nodes/sensor.ts +149 -0
  27. package/src/nodes/state.ts +135 -0
  28. package/src/nodes/store.ts +378 -0
  29. package/src/nodes/task.ts +174 -0
  30. package/src/signal.ts +112 -66
  31. package/src/util.ts +26 -57
  32. package/test/batch.test.ts +96 -62
  33. package/test/benchmark.test.ts +473 -487
  34. package/test/collection.test.ts +456 -707
  35. package/test/effect.test.ts +293 -696
  36. package/test/list.test.ts +335 -592
  37. package/test/memo.test.ts +574 -0
  38. package/test/regression.test.ts +156 -0
  39. package/test/scope.test.ts +191 -0
  40. package/test/sensor.test.ts +454 -0
  41. package/test/signal.test.ts +220 -213
  42. package/test/state.test.ts +217 -265
  43. package/test/store.test.ts +346 -446
  44. package/test/task.test.ts +529 -0
  45. package/test/untrack.test.ts +167 -0
  46. package/types/index.d.ts +13 -15
  47. package/types/src/errors.d.ts +73 -17
  48. package/types/src/graph.d.ts +218 -0
  49. package/types/src/nodes/collection.d.ts +69 -0
  50. package/types/src/nodes/effect.d.ts +48 -0
  51. package/types/src/nodes/list.d.ts +66 -0
  52. package/types/src/nodes/memo.d.ts +63 -0
  53. package/types/src/nodes/sensor.d.ts +81 -0
  54. package/types/src/nodes/state.d.ts +78 -0
  55. package/types/src/nodes/store.d.ts +51 -0
  56. package/types/src/nodes/task.d.ts +79 -0
  57. package/types/src/signal.d.ts +43 -29
  58. package/types/src/util.d.ts +9 -16
  59. package/archive/benchmark.ts +0 -683
  60. package/archive/collection.ts +0 -253
  61. package/archive/composite.ts +0 -85
  62. package/archive/computed.ts +0 -195
  63. package/archive/list.ts +0 -483
  64. package/archive/memo.ts +0 -139
  65. package/archive/state.ts +0 -90
  66. package/archive/store.ts +0 -298
  67. package/archive/task.ts +0 -189
  68. package/src/classes/collection.ts +0 -245
  69. package/src/classes/computed.ts +0 -349
  70. package/src/classes/list.ts +0 -343
  71. package/src/classes/ref.ts +0 -70
  72. package/src/classes/state.ts +0 -102
  73. package/src/classes/store.ts +0 -262
  74. package/src/diff.ts +0 -138
  75. package/src/effect.ts +0 -93
  76. package/src/match.ts +0 -45
  77. package/src/resolve.ts +0 -49
  78. package/src/system.ts +0 -257
  79. package/test/computed.test.ts +0 -1108
  80. package/test/diff.test.ts +0 -955
  81. package/test/match.test.ts +0 -388
  82. package/test/ref.test.ts +0 -353
  83. package/test/resolve.test.ts +0 -154
  84. package/types/src/classes/collection.d.ts +0 -45
  85. package/types/src/classes/computed.d.ts +0 -94
  86. package/types/src/classes/list.d.ts +0 -43
  87. package/types/src/classes/ref.d.ts +0 -35
  88. package/types/src/classes/state.d.ts +0 -49
  89. package/types/src/classes/store.d.ts +0 -52
  90. package/types/src/diff.d.ts +0 -28
  91. package/types/src/effect.d.ts +0 -15
  92. package/types/src/match.d.ts +0 -21
  93. package/types/src/resolve.d.ts +0 -29
  94. 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 with modern JavaScript/TypeScript and follows functional programming principles with a focus on performance and type safety.
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
- - **Signals**: Base reactive primitives with `.get()` method
10
- - **State**: Mutable signals for primitive values (`new State()`)
11
- - **Ref**: Signal wrappers for external objects that change outside reactive system (`new Ref()`)
12
- - **Computed**: Derived read-only signals with memoization, reducer capabilities and async support (`new Memo()`, `new Task()`)
13
- - **Store**: Mutable signals for objects with reactive properties (`createStore()`)
14
- - **List**: Mutable signals for arrays with stable keys and reactive entries (`new List()`)
15
- - **Collection**: Interface for reactive collections (implemented by `DerivedCollection`)
16
- - **Effects**: Side effect handlers that react to signal changes (`createEffect()`)
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/classes/state.ts` - Mutable state signals
21
- - `src/classes/ref.ts` - Signal wrappers for external objects (DOM, Map, Set, etc.)
22
- - `src/classes/store.ts` - Object stores with reactive properties
23
- - `src/classes/list.ts` - Array stores with stable keys and reactive items
24
- - `src/classes/collection.ts` - Collection interface and DerivedCollection implementation
25
- - `src/classes/computed.ts` - Computed/derived signals
26
- - `src/signal.ts` - Base signal types and utilities
27
- - `src/effect.ts` - Effect system
28
- - `src/system.ts` - Core reactivity system (watchers, batching)
29
- - `src/util.ts` - Utility functions and constants
30
- - `index.ts` - Main export file
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., `isSignal`, `isState`)
44
- - Constants: `TYPE_*` for type tags, `UPPER_CASE` for values
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
- - Custom error classes in `src/errors.ts`
49
- - Validate inputs and throw descriptive errors
50
- - Use `UNSET` symbol for uninitialized/pending states
51
- - Support AbortSignal for cancellation in async operations
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
- - Use `Set<Watcher>` for efficient subscription management
55
- - Batch updates to minimize effect re-runs
56
- - Memoization in computed signals
57
- - Shallow equality checks with `isEqual()` utility
58
- - Tree-shaking friendly with pure function annotations
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 have `.get()` method for value access
62
- - Mutable signals have `.set(value)` and `.update(fn)` methods
63
- - Store properties are automatically reactive signals
64
- - Effects receive AbortSignal for async cancellation
65
- - Helper functions like `resolve()`, `match()`, `diff()` for ergonomics
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 primitives
78
- const count = new State(42)
79
- const name = new State('Alice')
80
- const actions = new State<'increment' | 'decrement'>('increment')
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
- // Ref for external objects
83
- const elementRef = new Ref(document.getElementById('status'))
84
- const cacheRef = new Ref(new Map())
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 for arrays
90
- const items = new List(['apple', 'banana', 'cherry'])
91
- const users = new List([{ id: 'alice', name: 'Alice' }], user => user.id)
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
- // Computed for derived values
94
- const doubled = new Memo(() => count.get() * 2)
121
+ // Memo for synchronous derived values
122
+ const doubled = createMemo(() => count.get() * 2)
95
123
 
96
- // Computed with reducer capabilities
97
- const counter = new Memo(prev => {
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
- }, 0) // Initial value
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
- // Async effects with cancellation
111
- createEffect(async (abort) => {
112
- const response = await fetch('/api', { signal: abort })
113
- return response.json()
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
- // Async computed with old value access
117
- const userData = new Task(async (prev, abort) => {
118
- if (!userId.get()) return prev // Keep previous data if no user
119
- const response = await fetch(`/users/${userId.get()}`, { signal: abort })
120
- return response.json()
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
- // Use proper generic constraints
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
- function isSignal<T extends {}>(value: unknown): value is Signal<T>
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
- ## Build System
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
- ## Store Methods and Stable Keys
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
- ### List Methods
142
- - `byKey(key)` - Access signals by stable key instead of index
143
- - `keyAt(index)` - Get stable key at specific position
144
- - `indexOfKey(key)` - Get position of stable key in current order
145
- - `splice(start, deleteCount, ...items)` - Array-like splicing with stable key preservation
146
- - `sort(compareFn)` - Sort items while maintaining stable key associations
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
- ### Stable Keys Usage
149
- ```typescript
150
- // Default auto-incrementing keys
151
- const items = new List(['a', 'b', 'c'])
152
-
153
- // String prefix keys
154
- const items = new List(['apple', 'banana'], 'fruit')
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
- ## Resource Management
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
- All signals support `watched` and `unwatched` callbacks in signal configuration (optional second parameter) for lazy resource allocation. Resources are only created when signals are accessed by effects and automatically cleaned up when no longer watched.
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. Follow the established patterns for signal creation and usage
182
- 2. Use proper TypeScript types and generics
183
- 3. Include JSDoc for public APIs
184
- 4. Consider performance implications
185
- 5. Handle errors appropriately
186
- 6. Support async operations with AbortSignal when relevant
187
- 7. Use the established utility functions (`UNSET`, `isEqual`, etc.)
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
@@ -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.