@zeix/cause-effect 0.18.4 → 1.0.0
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/ARCHITECTURE.md +1 -1
- package/CHANGELOG.md +18 -0
- package/CLAUDE.md +36 -397
- package/README.md +1 -1
- package/bench/reactivity.bench.ts +18 -7
- package/biome.json +1 -1
- package/index.dev.js +21 -5
- package/index.js +1 -1
- package/index.ts +2 -1
- package/package.json +3 -4
- package/skills/cause-effect-dev/SKILL.md +114 -0
- package/skills/cause-effect-dev/agents/openai.yaml +4 -0
- package/src/graph.ts +26 -4
- package/src/nodes/collection.ts +4 -2
- package/src/nodes/list.ts +5 -2
- package/test/benchmark.test.ts +25 -11
- package/test/collection.test.ts +6 -3
- package/test/effect.test.ts +2 -1
- package/test/list.test.ts +8 -4
- package/test/regression.test.ts +4 -2
- package/test/store.test.ts +8 -4
- package/test/unown.test.ts +179 -0
- package/test/util/dependency-graph.ts +12 -6
- package/tsconfig.json +5 -1
- package/types/index.d.ts +2 -2
- package/types/src/graph.d.ts +13 -3
- package/.ai-context.md +0 -281
- package/examples/events-sensor.ts +0 -187
- package/examples/selector-sensor.ts +0 -173
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
createEffect,
|
|
4
|
+
createScope,
|
|
5
|
+
createState,
|
|
6
|
+
unown,
|
|
7
|
+
} from '../index.ts'
|
|
8
|
+
|
|
9
|
+
describe('unown', () => {
|
|
10
|
+
|
|
11
|
+
test('should return the value of the callback', () => {
|
|
12
|
+
const result = unown(() => 42)
|
|
13
|
+
expect(result).toBe(42)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('should run the callback immediately and synchronously', () => {
|
|
17
|
+
let ran = false
|
|
18
|
+
unown(() => { ran = true })
|
|
19
|
+
expect(ran).toBe(true)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('scope created inside unown is not registered on the enclosing scope', () => {
|
|
23
|
+
let innerCleanupRan = false
|
|
24
|
+
const outerDispose = createScope(() => {
|
|
25
|
+
unown(() => {
|
|
26
|
+
createScope(() => {
|
|
27
|
+
return () => { innerCleanupRan = true }
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
outerDispose()
|
|
32
|
+
expect(innerCleanupRan).toBe(false)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('scope created inside unown is not registered on the enclosing effect', () => {
|
|
36
|
+
const trigger = createState(0)
|
|
37
|
+
let innerCleanupRuns = 0
|
|
38
|
+
|
|
39
|
+
const outerDispose = createScope(() => {
|
|
40
|
+
createEffect((): undefined => {
|
|
41
|
+
trigger.get()
|
|
42
|
+
unown(() => {
|
|
43
|
+
createScope(() => {
|
|
44
|
+
return () => { innerCleanupRuns++ }
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
expect(innerCleanupRuns).toBe(0)
|
|
51
|
+
trigger.set(1)
|
|
52
|
+
expect(innerCleanupRuns).toBe(0)
|
|
53
|
+
trigger.set(2)
|
|
54
|
+
expect(innerCleanupRuns).toBe(0)
|
|
55
|
+
|
|
56
|
+
outerDispose()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('effects inside an unowned scope survive effect re-runs (ownership bug regression)', () => {
|
|
60
|
+
const listChange = createState(0)
|
|
61
|
+
let componentEffectRuns = 0
|
|
62
|
+
let componentCleanupRuns = 0
|
|
63
|
+
|
|
64
|
+
const connectComponent = () => unown(() =>
|
|
65
|
+
createScope(() => {
|
|
66
|
+
createEffect((): undefined => {
|
|
67
|
+
componentEffectRuns++
|
|
68
|
+
})
|
|
69
|
+
return () => { componentCleanupRuns++ }
|
|
70
|
+
})
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
const outerDispose = createScope(() => {
|
|
74
|
+
createEffect((): undefined => {
|
|
75
|
+
listChange.get()
|
|
76
|
+
if (listChange.get() === 0) connectComponent()
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
expect(componentEffectRuns).toBe(1)
|
|
81
|
+
expect(componentCleanupRuns).toBe(0)
|
|
82
|
+
|
|
83
|
+
listChange.set(1)
|
|
84
|
+
expect(componentEffectRuns).toBe(1)
|
|
85
|
+
expect(componentCleanupRuns).toBe(0)
|
|
86
|
+
|
|
87
|
+
outerDispose()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('effects inside an unowned scope still run reactively', () => {
|
|
91
|
+
const source = createState('a')
|
|
92
|
+
let effectRuns = 0
|
|
93
|
+
|
|
94
|
+
const dispose = unown(() =>
|
|
95
|
+
createScope(() => {
|
|
96
|
+
createEffect((): undefined => {
|
|
97
|
+
source.get()
|
|
98
|
+
effectRuns++
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
expect(effectRuns).toBe(1)
|
|
104
|
+
source.set('b')
|
|
105
|
+
expect(effectRuns).toBe(2)
|
|
106
|
+
|
|
107
|
+
dispose()
|
|
108
|
+
source.set('c')
|
|
109
|
+
expect(effectRuns).toBe(2)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('dispose returned from an unowned scope still works', () => {
|
|
113
|
+
let cleanupRan = false
|
|
114
|
+
const dispose = unown(() =>
|
|
115
|
+
createScope(() => {
|
|
116
|
+
return () => { cleanupRan = true }
|
|
117
|
+
})
|
|
118
|
+
)
|
|
119
|
+
expect(cleanupRan).toBe(false)
|
|
120
|
+
dispose()
|
|
121
|
+
expect(cleanupRan).toBe(true)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('nested unown calls work correctly', () => {
|
|
125
|
+
let innerCleanupRan = false
|
|
126
|
+
const outerDispose = createScope(() => {
|
|
127
|
+
unown(() => {
|
|
128
|
+
unown(() => {
|
|
129
|
+
createScope(() => {
|
|
130
|
+
return () => { innerCleanupRan = true }
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
outerDispose()
|
|
136
|
+
expect(innerCleanupRan).toBe(false)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('restores the active owner after the callback completes', () => {
|
|
140
|
+
let postCleanupRan = false
|
|
141
|
+
const outerDispose = createScope(() => {
|
|
142
|
+
unown(() => { /* some unowned work */ })
|
|
143
|
+
createScope(() => {
|
|
144
|
+
return () => { postCleanupRan = true }
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
outerDispose()
|
|
148
|
+
expect(postCleanupRan).toBe(true)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test('restores the active owner even if the callback throws', () => {
|
|
152
|
+
let postCleanupRan = false
|
|
153
|
+
const outerDispose = createScope(() => {
|
|
154
|
+
try {
|
|
155
|
+
unown(() => { throw new Error('boom') })
|
|
156
|
+
} catch {
|
|
157
|
+
// swallow
|
|
158
|
+
}
|
|
159
|
+
createScope(() => {
|
|
160
|
+
return () => { postCleanupRan = true }
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
outerDispose()
|
|
164
|
+
expect(postCleanupRan).toBe(true)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test('works correctly when called outside any scope or effect', () => {
|
|
168
|
+
let ran = false
|
|
169
|
+
const dispose = unown(() => {
|
|
170
|
+
ran = true
|
|
171
|
+
return createScope(() => {
|
|
172
|
+
return () => {}
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
expect(ran).toBe(true)
|
|
176
|
+
expect(typeof dispose).toBe('function')
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
})
|
|
@@ -53,7 +53,8 @@ export function runGraph(
|
|
|
53
53
|
): number {
|
|
54
54
|
const rand = new Random('seed')
|
|
55
55
|
const { sources, layers } = graph
|
|
56
|
-
|
|
56
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
57
|
+
const leaves = layers[layers.length - 1]!
|
|
57
58
|
const skipCount = Math.round(leaves.length * (1 - readFraction))
|
|
58
59
|
const readLeaves = removeElems(leaves, skipCount, rand)
|
|
59
60
|
const frameworkName = framework.name.toLowerCase()
|
|
@@ -65,7 +66,8 @@ export function runGraph(
|
|
|
65
66
|
for (let i = 0; i < iterations; i++) {
|
|
66
67
|
framework.withBatch(() => {
|
|
67
68
|
const sourceDex = i % sources.length
|
|
68
|
-
|
|
69
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
70
|
+
sources[sourceDex]!.write(i + sourceDex)
|
|
69
71
|
})
|
|
70
72
|
|
|
71
73
|
for (const leaf of readLeaves) {
|
|
@@ -87,7 +89,8 @@ export function runGraph(
|
|
|
87
89
|
} */
|
|
88
90
|
|
|
89
91
|
const sourceDex = i % sources.length
|
|
90
|
-
|
|
92
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
93
|
+
sources[sourceDex]!.write(i + sourceDex)
|
|
91
94
|
|
|
92
95
|
for (const leaf of readLeaves) {
|
|
93
96
|
leaf.read()
|
|
@@ -153,7 +156,8 @@ function makeRow(
|
|
|
153
156
|
return sources.map((_, myDex) => {
|
|
154
157
|
const mySources: Computed<number>[] = []
|
|
155
158
|
for (let sourceDex = 0; sourceDex < nSources; sourceDex++) {
|
|
156
|
-
|
|
159
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
160
|
+
mySources.push(sources[(myDex + sourceDex) % sources.length]!)
|
|
157
161
|
}
|
|
158
162
|
|
|
159
163
|
const staticNode = random.float() < staticFraction
|
|
@@ -170,7 +174,8 @@ function makeRow(
|
|
|
170
174
|
})
|
|
171
175
|
} else {
|
|
172
176
|
// dynamic node, drops one of the sources depending on the value of the first element
|
|
173
|
-
|
|
177
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
178
|
+
const first = mySources[0]!
|
|
174
179
|
const tail = mySources.slice(1)
|
|
175
180
|
const node = framework.computed(() => {
|
|
176
181
|
counter.count++
|
|
@@ -180,7 +185,8 @@ function makeRow(
|
|
|
180
185
|
|
|
181
186
|
for (let i = 0; i < tail.length; i++) {
|
|
182
187
|
if (shouldDrop && i === dropDex) continue
|
|
183
|
-
|
|
188
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
189
|
+
sum += tail[i]!.read()
|
|
184
190
|
}
|
|
185
191
|
|
|
186
192
|
return sum
|
package/tsconfig.json
CHANGED
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
// Best practices
|
|
20
20
|
"strict": true,
|
|
21
21
|
"skipLibCheck": true,
|
|
22
|
+
"noUncheckedIndexedAccess": true,
|
|
23
|
+
"exactOptionalPropertyTypes": true,
|
|
24
|
+
"useUnknownInCatchVariables": true,
|
|
25
|
+
"noUncheckedSideEffectImports": true,
|
|
22
26
|
"noFallthroughCasesInSwitch": true,
|
|
23
27
|
|
|
24
28
|
// Some stricter flags (disabled by default)
|
|
@@ -27,5 +31,5 @@
|
|
|
27
31
|
"noPropertyAccessFromIndexSignature": false,
|
|
28
32
|
},
|
|
29
33
|
"include": ["./**/*.ts"],
|
|
30
|
-
"exclude": ["node_modules", "types"],
|
|
34
|
+
"exclude": ["node_modules", "types", "index.js"],
|
|
31
35
|
}
|
package/types/index.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @name Cause & Effect
|
|
3
|
-
* @version 0.
|
|
3
|
+
* @version 1.0.0
|
|
4
4
|
* @author Esther Brunner
|
|
5
5
|
*/
|
|
6
6
|
export { CircularDependencyError, type Guard, InvalidCallbackError, InvalidSignalValueError, NullishSignalValueError, ReadonlySignalError, RequiredOwnerError, UnsetSignalValueError, } from './src/errors';
|
|
7
|
-
export { batch, type Cleanup, type ComputedOptions, createScope, type EffectCallback, type MaybeCleanup, type MemoCallback, type Signal, type SignalOptions, SKIP_EQUALITY, type TaskCallback, untrack, } from './src/graph';
|
|
7
|
+
export { batch, type Cleanup, type ComputedOptions, createScope, type EffectCallback, type MaybeCleanup, type MemoCallback, type Signal, type SignalOptions, SKIP_EQUALITY, type TaskCallback, unown, untrack, } from './src/graph';
|
|
8
8
|
export { type Collection, type CollectionCallback, type CollectionChanges, type CollectionOptions, createCollection, type DeriveCollectionCallback, isCollection, } from './src/nodes/collection';
|
|
9
9
|
export { createEffect, type MatchHandlers, type MaybePromise, match, } from './src/nodes/effect';
|
|
10
10
|
export { createList, isEqual, isList, type KeyConfig, type List, type ListOptions, } from './src/nodes/list';
|
package/types/src/graph.d.ts
CHANGED
|
@@ -3,11 +3,11 @@ type SourceFields<T extends {}> = {
|
|
|
3
3
|
value: T;
|
|
4
4
|
sinks: Edge | null;
|
|
5
5
|
sinksTail: Edge | null;
|
|
6
|
-
stop?: Cleanup;
|
|
6
|
+
stop?: Cleanup | undefined;
|
|
7
7
|
};
|
|
8
8
|
type OptionsFields<T extends {}> = {
|
|
9
9
|
equals: (a: T, b: T) => boolean;
|
|
10
|
-
guard?: Guard<T
|
|
10
|
+
guard?: Guard<T> | undefined;
|
|
11
11
|
};
|
|
12
12
|
type SinkFields = {
|
|
13
13
|
fn: unknown;
|
|
@@ -218,4 +218,14 @@ declare function untrack<T>(fn: () => T): T;
|
|
|
218
218
|
* ```
|
|
219
219
|
*/
|
|
220
220
|
declare function createScope(fn: () => MaybeCleanup): Cleanup;
|
|
221
|
-
|
|
221
|
+
/**
|
|
222
|
+
* Runs a callback without any active owner.
|
|
223
|
+
* Any scopes or effects created inside the callback will not be registered as
|
|
224
|
+
* children of the current active owner (e.g. a re-runnable effect). Use this
|
|
225
|
+
* when a component or resource manages its own lifecycle independently of the
|
|
226
|
+
* reactive graph.
|
|
227
|
+
*
|
|
228
|
+
* @since 0.18.5
|
|
229
|
+
*/
|
|
230
|
+
declare function unown<T>(fn: () => T): T;
|
|
231
|
+
export { type Cleanup, type ComputedOptions, type EffectCallback, type EffectNode, type MaybeCleanup, type MemoCallback, type MemoNode, type Scope, type Signal, type SignalOptions, type SinkNode, type StateNode, type TaskCallback, type TaskNode, activeOwner, activeSink, batch, batchDepth, createScope, DEFAULT_EQUALITY, SKIP_EQUALITY, FLAG_CHECK, FLAG_CLEAN, FLAG_DIRTY, FLAG_RELINK, flush, link, propagate, refresh, registerCleanup, runCleanup, runEffect, setState, trimSources, TYPE_COLLECTION, TYPE_LIST, TYPE_MEMO, TYPE_SENSOR, TYPE_STATE, TYPE_SLOT, TYPE_STORE, TYPE_TASK, unlink, unown, untrack, };
|
package/.ai-context.md
DELETED
|
@@ -1,281 +0,0 @@
|
|
|
1
|
-
# AI Context for Cause & Effect
|
|
2
|
-
|
|
3
|
-
## What is Cause & Effect?
|
|
4
|
-
|
|
5
|
-
Cause & Effect is a modern reactive state management library for JavaScript/TypeScript that implements the signals pattern. It provides fine-grained reactivity with automatic dependency tracking, making it easy to build responsive applications with predictable state updates.
|
|
6
|
-
|
|
7
|
-
## Core Architecture
|
|
8
|
-
|
|
9
|
-
### Graph-Based Reactivity (`src/graph.ts`)
|
|
10
|
-
|
|
11
|
-
The reactive engine is a linked graph of source and sink nodes connected by `Edge` entries:
|
|
12
|
-
- **Sources** (StateNode) maintain a linked list of sink edges
|
|
13
|
-
- **Sinks** (MemoNode, TaskNode, EffectNode) maintain a linked list of source edges
|
|
14
|
-
- `link()` creates edges between sources and sinks during `.get()` calls
|
|
15
|
-
- `propagate()` flags sinks as dirty when sources change
|
|
16
|
-
- `flush()` processes queued effects after propagation
|
|
17
|
-
- `trimSources()` removes stale edges after recomputation
|
|
18
|
-
|
|
19
|
-
### Node Types
|
|
20
|
-
```
|
|
21
|
-
StateNode<T> — source-only with equality + guard (State, Sensor)
|
|
22
|
-
MemoNode<T> — source + sink (Memo, Slot, Store, List, Collection)
|
|
23
|
-
TaskNode<T> — source + sink + async (Task)
|
|
24
|
-
EffectNode — sink + owner (Effect)
|
|
25
|
-
Scope — owner-only (createScope)
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
### Signal Types
|
|
29
|
-
- **State** (`createState`): Mutable signals for primitive values and objects
|
|
30
|
-
- **Sensor** (`createSensor`): Read-only signals for external input streams with automatic state updates. Use `SKIP_EQUALITY` for mutable object observation.
|
|
31
|
-
- **Memo** (`createMemo`): Synchronous derived computations with memoization, reducer capabilities, and optional `watched(invalidate)` for external invalidation
|
|
32
|
-
- **Task** (`createTask`): Async derived computations with automatic abort/cancellation and optional `watched(invalidate)` for external invalidation
|
|
33
|
-
- **Store** (`createStore`): Mutable object signals with individually reactive properties via Proxy
|
|
34
|
-
- **List** (`createList`): Mutable array signals with stable keys and reactive items
|
|
35
|
-
- **Collection** (`createCollection`): Reactive collections — either externally-driven with watched lifecycle, or derived from List/Collection with item-level memoization
|
|
36
|
-
- **Slot** (`createSlot`): Stable delegation signal with swappable backing signal, designed for integration layers (property descriptors, custom elements)
|
|
37
|
-
- **Effect** (`createEffect`): Side effect handlers that react to signal changes
|
|
38
|
-
|
|
39
|
-
### Key Principles
|
|
40
|
-
1. **Functional API**: All signals created via `create*()` factory functions (no classes)
|
|
41
|
-
2. **Type Safety**: Full TypeScript support with strict type constraints (`T extends {}`)
|
|
42
|
-
3. **Performance**: Flag-based dirty checking, linked-list edge traversal, batched flushing
|
|
43
|
-
4. **Async Support**: Built-in cancellation with AbortSignal in Tasks
|
|
44
|
-
5. **Tree-shaking**: Optimized for minimal bundle size with `/*#__PURE__*/` annotations
|
|
45
|
-
|
|
46
|
-
## Project Structure
|
|
47
|
-
|
|
48
|
-
```
|
|
49
|
-
cause-effect/
|
|
50
|
-
├── src/
|
|
51
|
-
│ ├── graph.ts # Core reactive engine (nodes, edges, link, propagate, flush, batch)
|
|
52
|
-
│ ├── nodes/
|
|
53
|
-
│ │ ├── state.ts # createState — mutable state signals
|
|
54
|
-
│ │ ├── sensor.ts # createSensor — external input tracking (also covers mutable object observation)
|
|
55
|
-
│ │ ├── memo.ts # createMemo — synchronous derived computations
|
|
56
|
-
│ │ ├── task.ts # createTask — async derived computations
|
|
57
|
-
│ │ ├── effect.ts # createEffect, match — side effects
|
|
58
|
-
│ │ ├── store.ts # createStore — reactive object stores
|
|
59
|
-
│ │ ├── list.ts # createList — reactive arrays with stable keys
|
|
60
|
-
│ │ ├── collection.ts # createCollection — externally-driven and derived collections
|
|
61
|
-
│ │ └── slot.ts # createSlot — stable delegation with swappable backing signal
|
|
62
|
-
│ ├── util.ts # Utility functions and type checks
|
|
63
|
-
│ └── ...
|
|
64
|
-
├── index.ts # Entry point / main export file
|
|
65
|
-
├── test/
|
|
66
|
-
│ ├── state.test.ts
|
|
67
|
-
│ ├── memo.test.ts
|
|
68
|
-
│ ├── task.test.ts
|
|
69
|
-
│ ├── effect.test.ts
|
|
70
|
-
│ ├── store.test.ts
|
|
71
|
-
│ ├── list.test.ts
|
|
72
|
-
│ └── collection.test.ts
|
|
73
|
-
└── package.json
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
## API Patterns
|
|
77
|
-
|
|
78
|
-
### Signal Creation
|
|
79
|
-
```typescript
|
|
80
|
-
// State for primitives and objects
|
|
81
|
-
const count = createState(42)
|
|
82
|
-
const name = createState('Alice')
|
|
83
|
-
|
|
84
|
-
// Sensor for external input
|
|
85
|
-
const mousePos = createSensor<{ x: number; y: number }>((set) => {
|
|
86
|
-
const handler = (e: MouseEvent) => set({ x: e.clientX, y: e.clientY })
|
|
87
|
-
window.addEventListener('mousemove', handler)
|
|
88
|
-
return () => window.removeEventListener('mousemove', handler)
|
|
89
|
-
})
|
|
90
|
-
|
|
91
|
-
// Sensor for mutable object observation (SKIP_EQUALITY)
|
|
92
|
-
const element = createSensor<HTMLElement>((set) => {
|
|
93
|
-
const node = document.getElementById('box')!
|
|
94
|
-
set(node)
|
|
95
|
-
const obs = new MutationObserver(() => set(node))
|
|
96
|
-
obs.observe(node, { attributes: true })
|
|
97
|
-
return () => obs.disconnect()
|
|
98
|
-
}, { value: node, equals: SKIP_EQUALITY })
|
|
99
|
-
|
|
100
|
-
// Store for objects with reactive properties
|
|
101
|
-
const user = createStore({ name: 'Alice', age: 30 })
|
|
102
|
-
|
|
103
|
-
// List with stable keys for arrays
|
|
104
|
-
const items = createList(['apple', 'banana', 'cherry'])
|
|
105
|
-
const users = createList(
|
|
106
|
-
[{ id: 'alice', name: 'Alice' }],
|
|
107
|
-
{ keyConfig: user => user.id }
|
|
108
|
-
)
|
|
109
|
-
|
|
110
|
-
// Memo for synchronous derived values
|
|
111
|
-
const doubled = createMemo(() => count.get() * 2)
|
|
112
|
-
|
|
113
|
-
// Memo with reducer capabilities (access to previous value)
|
|
114
|
-
const counter = createMemo(prev => {
|
|
115
|
-
const action = actions.get()
|
|
116
|
-
return action === 'increment' ? prev + 1 : prev - 1
|
|
117
|
-
}, { value: 0 })
|
|
118
|
-
|
|
119
|
-
// Task for async derived values with cancellation
|
|
120
|
-
const userData = createTask(async (prev, abort) => {
|
|
121
|
-
const id = userId.get()
|
|
122
|
-
if (!id) return prev
|
|
123
|
-
const response = await fetch(`/users/${id}`, { signal: abort })
|
|
124
|
-
return response.json()
|
|
125
|
-
})
|
|
126
|
-
|
|
127
|
-
// Collection for derived transformations
|
|
128
|
-
const doubled = numbers.deriveCollection((value: number) => value * 2)
|
|
129
|
-
const enriched = users.deriveCollection(async (user, abort) => {
|
|
130
|
-
const res = await fetch(`/api/${user.id}`, { signal: abort })
|
|
131
|
-
return { ...user, details: await res.json() }
|
|
132
|
-
})
|
|
133
|
-
|
|
134
|
-
// Collection for externally-driven data
|
|
135
|
-
const feed = createCollection<{ id: string; text: string }>((applyChanges) => {
|
|
136
|
-
const ws = new WebSocket('/feed')
|
|
137
|
-
ws.onmessage = (e) => applyChanges(JSON.parse(e.data))
|
|
138
|
-
return () => ws.close()
|
|
139
|
-
}, { keyConfig: item => item.id })
|
|
140
|
-
|
|
141
|
-
// Slot for stable property delegation
|
|
142
|
-
const local = createState('default')
|
|
143
|
-
const slot = createSlot(local)
|
|
144
|
-
Object.defineProperty(element, 'label', slot)
|
|
145
|
-
slot.replace(createMemo(() => parentState.get())) // swap backing signal
|
|
146
|
-
```
|
|
147
|
-
|
|
148
|
-
### Reactivity
|
|
149
|
-
```typescript
|
|
150
|
-
// Effects run when dependencies change
|
|
151
|
-
const dispose = createEffect(() => {
|
|
152
|
-
console.log(`Count: ${count.get()}`)
|
|
153
|
-
})
|
|
154
|
-
|
|
155
|
-
// Effects can return cleanup functions
|
|
156
|
-
createEffect(() => {
|
|
157
|
-
const timer = setInterval(() => console.log(count.get()), 1000)
|
|
158
|
-
return () => clearInterval(timer)
|
|
159
|
-
})
|
|
160
|
-
|
|
161
|
-
// match() for ergonomic signal value extraction inside effects
|
|
162
|
-
createEffect(() => {
|
|
163
|
-
match([userData], {
|
|
164
|
-
ok: ([data]) => updateUI(data),
|
|
165
|
-
nil: () => showLoading(),
|
|
166
|
-
err: errors => showError(errors[0].message)
|
|
167
|
-
})
|
|
168
|
-
})
|
|
169
|
-
```
|
|
170
|
-
|
|
171
|
-
### Value Access Patterns
|
|
172
|
-
```typescript
|
|
173
|
-
// All signals use .get() for value access
|
|
174
|
-
const value = signal.get()
|
|
175
|
-
|
|
176
|
-
// State has .set() and .update()
|
|
177
|
-
state.set(newValue)
|
|
178
|
-
state.update(current => current + 1)
|
|
179
|
-
|
|
180
|
-
// Store properties are individually reactive via Proxy
|
|
181
|
-
user.name.set('Bob') // Only name watchers trigger
|
|
182
|
-
user.age.update(age => age + 1) // Only age watchers trigger
|
|
183
|
-
|
|
184
|
-
// List with stable keys
|
|
185
|
-
const items = createList(['apple', 'banana'], { keyConfig: 'fruit' })
|
|
186
|
-
const appleSignal = items.byKey('fruit0')
|
|
187
|
-
const firstKey = items.keyAt(0)
|
|
188
|
-
const appleIndex = items.indexOfKey('fruit0')
|
|
189
|
-
items.splice(1, 0, 'cherry')
|
|
190
|
-
items.sort()
|
|
191
|
-
|
|
192
|
-
// Collections via deriveCollection
|
|
193
|
-
const processed = items.deriveCollection(item => item.toUpperCase())
|
|
194
|
-
```
|
|
195
|
-
|
|
196
|
-
## Coding Conventions
|
|
197
|
-
|
|
198
|
-
### TypeScript Style
|
|
199
|
-
- Generic constraints: `T extends {}` to exclude null/undefined
|
|
200
|
-
- Use `const` for immutable values
|
|
201
|
-
- Function overloads for complex type scenarios (e.g., `createCollection`, `deriveCollection`)
|
|
202
|
-
- JSDoc comments on all public APIs
|
|
203
|
-
- Pure function annotations: `/*#__PURE__*/` for tree-shaking
|
|
204
|
-
|
|
205
|
-
### Naming Conventions
|
|
206
|
-
- Factory functions: `create*` prefix (createState, createMemo, createEffect, createStore, createList, createCollection, createSensor, createSlot)
|
|
207
|
-
- Type predicates: `is*` prefix (isState, isMemo, isTask, isStore, isList, isCollection, isSensor, isSlot)
|
|
208
|
-
- Type constants: `TYPE_*` format (TYPE_STATE, TYPE_STORE, TYPE_SENSOR, TYPE_COLLECTION)
|
|
209
|
-
- Callback types: `*Callback` suffix (MemoCallback, TaskCallback, EffectCallback, SensorCallback, CollectionCallback, DeriveCollectionCallback)
|
|
210
|
-
|
|
211
|
-
### Error Handling
|
|
212
|
-
- Custom error classes in `src/errors.ts`: CircularDependencyError, NullishSignalValueError, InvalidSignalValueError, InvalidCallbackError, RequiredOwnerError, UnsetSignalValueError
|
|
213
|
-
- Descriptive error messages with `[TypeName]` prefix
|
|
214
|
-
- Input validation via `validateSignalValue()` and `validateCallback()`
|
|
215
|
-
- Optional type guards via `guard` option in SignalOptions
|
|
216
|
-
|
|
217
|
-
## Performance Considerations
|
|
218
|
-
|
|
219
|
-
### Optimization Patterns
|
|
220
|
-
- Linked-list edges for O(1) link/unlink operations
|
|
221
|
-
- Flag-based dirty checking: FLAG_CLEAN, FLAG_CHECK, FLAG_DIRTY, FLAG_RUNNING
|
|
222
|
-
- Batched updates via `batch()` to minimize effect re-runs
|
|
223
|
-
- Lazy evaluation: Memos only recompute when accessed and dirty
|
|
224
|
-
- Automatic abort of in-flight Tasks when sources change
|
|
225
|
-
|
|
226
|
-
### Memory Management
|
|
227
|
-
- `trimSources()` removes stale edges after each recomputation
|
|
228
|
-
- `unlink()` calls `source.stop()` when last sink disconnects (auto-cleanup for Sensor/Collection/Store/List)
|
|
229
|
-
- AbortSignal integration for canceling async operations
|
|
230
|
-
- `createScope()` for hierarchical cleanup of nested effects
|
|
231
|
-
|
|
232
|
-
## Resource Management
|
|
233
|
-
|
|
234
|
-
**Sensor and Collection** use a watched callback that returns a Cleanup function:
|
|
235
|
-
```typescript
|
|
236
|
-
const sensor = createSensor<T>((set) => {
|
|
237
|
-
// setup input tracking, call set(value) to update
|
|
238
|
-
return () => { /* cleanup */ }
|
|
239
|
-
})
|
|
240
|
-
|
|
241
|
-
const feed = createCollection<T>((applyChanges) => {
|
|
242
|
-
// setup external data source, call applyChanges(diffResult) on changes
|
|
243
|
-
return () => { /* cleanup */ }
|
|
244
|
-
}, { keyConfig: item => item.id })
|
|
245
|
-
```
|
|
246
|
-
|
|
247
|
-
**Memo and Task** use an optional `watched` callback in options that receives an `invalidate` function:
|
|
248
|
-
```typescript
|
|
249
|
-
const derived = createMemo(() => element.get().textContent ?? '', {
|
|
250
|
-
watched: (invalidate) => {
|
|
251
|
-
const obs = new MutationObserver(() => invalidate())
|
|
252
|
-
obs.observe(element.get(), { childList: true })
|
|
253
|
-
return () => obs.disconnect()
|
|
254
|
-
}
|
|
255
|
-
})
|
|
256
|
-
```
|
|
257
|
-
|
|
258
|
-
**Store and List** use an optional `watched` callback in options:
|
|
259
|
-
```typescript
|
|
260
|
-
const store = createStore(initialValue, {
|
|
261
|
-
watched: () => {
|
|
262
|
-
// setup resources
|
|
263
|
-
return () => { /* cleanup */ }
|
|
264
|
-
}
|
|
265
|
-
})
|
|
266
|
-
```
|
|
267
|
-
|
|
268
|
-
Resources activate on first sink link and cleanup when last sink unlinks.
|
|
269
|
-
|
|
270
|
-
## Build and Development
|
|
271
|
-
|
|
272
|
-
### Tools
|
|
273
|
-
- **Runtime**: Bun (also works with Node.js)
|
|
274
|
-
- **Build**: Bun build with TypeScript compilation
|
|
275
|
-
- **Testing**: Bun test runner (`bun test`)
|
|
276
|
-
- **Linting**: Biome for code formatting and linting
|
|
277
|
-
|
|
278
|
-
### Package Configuration
|
|
279
|
-
- ES modules only (`"type": "module"`)
|
|
280
|
-
- TypeScript declarations generated automatically
|
|
281
|
-
- Tree-shaking friendly with proper sideEffects configuration
|