@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
package/GUIDE.md ADDED
@@ -0,0 +1,298 @@
1
+ # Guide for Framework Developers
2
+
3
+ If you've used React, Vue, or Angular, you already understand the core idea behind Cause & Effect: state changes should automatically propagate to derived values and side effects. This guide maps what you know to how this library works, explains where the mental model diverges, and introduces capabilities that go beyond what most reactive libraries provide.
4
+
5
+ ## The Familiar Core
6
+
7
+ The three building blocks map directly to what you already use:
8
+
9
+ | Concept | React | Vue | Angular | Cause & Effect |
10
+ |---------|-------|-----|---------|----------------|
11
+ | Mutable state | `useState` | `ref()` | `signal()` | `createState()` |
12
+ | Derived value | `useMemo` | `computed()` | `computed()` | `createMemo()` |
13
+ | Side effect | `useEffect` | `watchEffect()` | `effect()` | `createEffect()` |
14
+
15
+ Here is how they work together:
16
+
17
+ ```ts
18
+ import { createState, createMemo, createEffect } from '@zeix/cause-effect'
19
+
20
+ const count = createState(0)
21
+ const doubled = createMemo(() => count.get() * 2)
22
+
23
+ createEffect(() => {
24
+ console.log(`${count.get()} doubled is ${doubled.get()}`)
25
+ })
26
+
27
+ count.set(5) // logs: "5 doubled is 10"
28
+ ```
29
+
30
+ If you've written a `computed` in Vue or a `useMemo` in React, this should feel immediately familiar. The difference is that there is no component, no template, no JSX — just reactive primitives composing directly.
31
+
32
+ ## What Works Differently
33
+
34
+ ### Dependencies are tracked, not declared
35
+
36
+ In React, you declare dependencies manually:
37
+
38
+ ```ts
39
+ // React
40
+ useEffect(() => {
41
+ console.log(count)
42
+ }, [count]) // ← you must list dependencies
43
+ ```
44
+
45
+ In Cause & Effect, calling `.get()` *is* the dependency declaration. If you read a signal inside an effect or memo, it becomes a dependency automatically. If you don't read it, it doesn't.
46
+
47
+ ```ts
48
+ // Cause & Effect
49
+ createEffect(() => {
50
+ console.log(count.get()) // ← this IS the dependency
51
+ })
52
+ ```
53
+
54
+ There are no dependency arrays to maintain, no lint rules to enforce them, and no stale closure bugs from forgetting a dependency. Vue and Angular developers will find this familiar — it works like `watchEffect()` and Angular's `effect()`.
55
+
56
+ ### Effects run synchronously
57
+
58
+ In React, effects run after the browser paints. In Vue, reactive updates are batched until the next microtask. In Cause & Effect, effects run synchronously right after a state change:
59
+
60
+ ```ts
61
+ const name = createState('Alice')
62
+
63
+ createEffect(() => {
64
+ console.log(name.get()) // runs immediately with "Alice"
65
+ })
66
+
67
+ name.set('Bob') // runs the effect again, right here, synchronously
68
+ ```
69
+
70
+ When you need to update multiple signals without triggering intermediate effects, wrap updates in `batch()`:
71
+
72
+ ```ts
73
+ import { batch } from '@zeix/cause-effect'
74
+
75
+ batch(() => {
76
+ firstName.set('Bob')
77
+ lastName.set('Smith')
78
+ }) // effect runs once, after both updates
79
+ ```
80
+
81
+ ### Non-nullable signals
82
+
83
+ All signals enforce `T extends {}` — `null` and `undefined` are excluded at the type level. This means you can trust that `.get()` always returns a real value without null checks.
84
+
85
+ ```ts
86
+ const count = createState(0)
87
+ count.get() // type is number, guaranteed non-null
88
+
89
+ // This won't compile:
90
+ // const maybeUser = createState<User | null>(null)
91
+ ```
92
+
93
+ This is a deliberate design decision. In frameworks, nullable state leads to defensive checks scattered across templates and hooks. Here, the type system prevents it.
94
+
95
+ **What to do instead:**
96
+
97
+ - For async results: use `createTask()` — a Task without reactive dependencies works like a Promise that resolves into the graph. Use `match()` to handle the pending state.
98
+ - For external input that starts undefined: use `createSensor()` with its lazy start callback.
99
+ - For optional state: use a discriminated union, an empty string, an empty array, `0`, or `false` — whatever the zero value for your type is:
100
+
101
+ ```ts
102
+ type AuthState = { status: 'anonymous' } | { status: 'authenticated', user: User }
103
+ const auth = createState<AuthState>({ status: 'anonymous' })
104
+ ```
105
+
106
+ ### Scopes replace the component tree
107
+
108
+ In React, Vue, and Angular, reactivity is tied to components. Effects clean up when components unmount. Components form a tree that manages lifetimes.
109
+
110
+ Cause & Effect has no components — but it has `createScope()`, which serves the same structural purpose. A scope captures child effects, manages their cleanup, and can be nested inside other scopes or effects:
111
+
112
+ ```ts
113
+ import { createState, createEffect, createScope } from '@zeix/cause-effect'
114
+
115
+ const dispose = createScope(() => {
116
+ const count = createState(0)
117
+
118
+ createEffect(() => {
119
+ console.log(count.get())
120
+ })
121
+
122
+ return () => console.log('scope disposed')
123
+ })
124
+
125
+ // Later: dispose everything created inside
126
+ dispose()
127
+ ```
128
+
129
+ Think of scopes as **components without rendering**. They are the building block for breaking the signal graph into smaller, manageable pieces — often driven by what needs to be looped or dynamically created. A UI framework built on this library would typically create a scope per component.
130
+
131
+ **Automatic vs. manual cleanup:**
132
+
133
+ - Inside a scope or parent effect, child effects are disposed automatically when the parent is disposed.
134
+ - Outside any owner, you must call the cleanup function returned by `createEffect()` yourself.
135
+
136
+ ```ts
137
+ // Automatic: effect is disposed when the scope is disposed
138
+ const dispose = createScope(() => {
139
+ createEffect(() => console.log(count.get()))
140
+ })
141
+ dispose() // cleans up the effect
142
+
143
+ // Manual: no parent scope, you manage the lifetime
144
+ const cleanup = createEffect(() => console.log(count.get()))
145
+ cleanup() // you must call this yourself
146
+ ```
147
+
148
+ ### Explicit equality, not reference identity
149
+
150
+ By default, signals use `===` for equality. But unlike frameworks where this is buried in internals, you can override it per signal:
151
+
152
+ ```ts
153
+ const point = createState({ x: 0, y: 0 }, {
154
+ equals: (a, b) => a.x === b.x && a.y === b.y
155
+ })
156
+
157
+ point.set({ x: 0, y: 0 }) // no update — values are equal
158
+ ```
159
+
160
+ ## Beyond the Basics
161
+
162
+ The primitives above cover what most reactive libraries provide. The following signal types address patterns that frameworks handle with ad-hoc solutions or external libraries.
163
+
164
+ ### Task: async derivations with cancellation
165
+
166
+ In React, async data fetching requires `useEffect` + cleanup + state management (or a library like React Query). In Angular, you'd use RxJS with `switchMap`. In Cause & Effect, `createTask()` is a signal that happens to be async:
167
+
168
+ ```ts
169
+ import { createState, createTask, createEffect, match } from '@zeix/cause-effect'
170
+
171
+ const userId = createState(1)
172
+
173
+ const user = createTask(async (prev, abort) => {
174
+ const res = await fetch(`/api/users/${userId.get()}`, { signal: abort })
175
+ return res.json()
176
+ })
177
+
178
+ userId.set(2) // cancels the in-flight request, starts a new one
179
+ ```
180
+
181
+ The `abort` signal is managed automatically — when dependencies change, the previous computation is cancelled. No cleanup functions to write, no race conditions to handle.
182
+
183
+ Use `match()` inside effects to handle all states declaratively:
184
+
185
+ ```ts
186
+ createEffect(() => {
187
+ match([user], {
188
+ ok: ([data]) => console.log('User:', data),
189
+ nil: () => console.log('Loading...'),
190
+ err: (errors) => console.error(errors[0])
191
+ })
192
+ })
193
+ ```
194
+
195
+ ### Store: per-property reactivity
196
+
197
+ In React, updating one property of an object re-renders everything that reads the object. In Vue, `reactive()` gives you per-property tracking — `createStore()` works the same way:
198
+
199
+ ```ts
200
+ import { createStore, createEffect } from '@zeix/cause-effect'
201
+
202
+ const user = createStore({ name: 'Alice', age: 30, email: 'alice@example.com' })
203
+
204
+ // This effect only re-runs when name changes
205
+ createEffect(() => {
206
+ console.log(user.name.get())
207
+ })
208
+
209
+ user.age.set(31) // does NOT trigger the effect above
210
+ user.name.set('Bob') // triggers it
211
+ ```
212
+
213
+ Each property becomes its own signal. Nested objects become nested stores. This is more granular than `createState({ ... })`, which would treat the whole object as a single value.
214
+
215
+ ### List: reactive arrays with stable keys
216
+
217
+ Frameworks use `key` props (React), `:key` bindings (Vue), or `track` expressions (Angular) to maintain item identity during re-renders. In Cause & Effect, `createList()` bakes stable keys into the data structure itself:
218
+
219
+ ```ts
220
+ import { createList, createEffect } from '@zeix/cause-effect'
221
+
222
+ const todos = createList([
223
+ { id: 't1', text: 'Learn signals', done: false },
224
+ { id: 't2', text: 'Build app', done: false }
225
+ ], { keyConfig: todo => todo.id })
226
+
227
+ // Get a stable reference to a specific item
228
+ const first = todos.byKey('t1')
229
+
230
+ todos.sort((a, b) => a.text.localeCompare(b.text))
231
+ // first still points to "Learn signals", regardless of position
232
+
233
+ // Update a single item without replacing the array
234
+ first?.set({ id: 't1', text: 'Learn signals', done: true })
235
+ ```
236
+
237
+ Each item is its own signal. Sorting reorders keys without destroying signals or their downstream dependencies. Adding and removing items is granular — unaffected items and their effects don't re-run.
238
+
239
+ ### Collection: derived arrays with item-level memoization
240
+
241
+ Collections provide reactive transformations over arrays with automatic per-item memoization. They come in two forms: **derived collections** (transformations of Lists or other Collections) and **externally-driven collections** (fed by external sources like WebSockets or Server-Sent Events).
242
+
243
+ **Derived collections** are created via `.deriveCollection()` on a List or Collection:
244
+
245
+ ```ts
246
+ const display = todos.deriveCollection(todo => ({
247
+ label: todo.done ? `[x] ${todo.text}` : `[ ] ${todo.text}`
248
+ }))
249
+
250
+ // Async transformations with automatic cancellation
251
+ const enriched = todos.deriveCollection(async (todo, abort) => {
252
+ const res = await fetch(`/api/details/${todo.id}`, { signal: abort })
253
+ return { ...todo, details: await res.json() }
254
+ })
255
+
256
+ // Chain collections for data pipelines
257
+ const pipeline = todos
258
+ .deriveCollection(todo => ({ ...todo, urgent: todo.priority > 8 }))
259
+ .deriveCollection(todo => todo.urgent ? `URGENT: ${todo.text}` : todo.text)
260
+ ```
261
+
262
+ When one item changes, only its derived signal recomputes. Structural changes (additions, removals) are tracked separately from value changes.
263
+
264
+ **Externally-driven collections** are created with `createCollection()` and a start callback for keyed data arriving from external sources:
265
+
266
+ ```ts
267
+ import { createCollection, createEffect } from '@zeix/cause-effect'
268
+
269
+ const messages = createCollection((applyChanges) => {
270
+ const ws = new WebSocket('/messages')
271
+ ws.onmessage = (e) => applyChanges({ changed: true, add: JSON.parse(e.data) })
272
+ return () => ws.close()
273
+ }, { keyConfig: msg => msg.id })
274
+
275
+ // Same Collection interface — .get(), .byKey(), .deriveCollection()
276
+ createEffect(() => {
277
+ console.log('Messages:', messages.get().length)
278
+ })
279
+ ```
280
+
281
+ The WebSocket connects when the first effect reads the collection and disconnects when no effects are watching. Incoming data is applied as granular add/change/remove operations, not wholesale array replacement.
282
+
283
+ ### Sensor: lazy external input
284
+
285
+ Frameworks typically manage event listeners inside component lifecycle hooks (`useEffect`, `onMounted`, `ngOnInit`). In Cause & Effect, `createSensor()` encapsulates external input with automatic resource management:
286
+
287
+ ```ts
288
+ import { createSensor, createEffect } from '@zeix/cause-effect'
289
+
290
+ const windowSize = createSensor((set) => {
291
+ const update = () => set({ w: innerWidth, h: innerHeight })
292
+ update()
293
+ window.addEventListener('resize', update)
294
+ return () => window.removeEventListener('resize', update)
295
+ })
296
+ ```
297
+
298
+ The start callback runs lazily — only when an effect first reads the sensor. When no effects are watching, the cleanup runs automatically. When an effect reads it again, the start callback runs again. No manual setup/teardown.