@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
@@ -0,0 +1,191 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { createEffect, createScope, createState } from '../index.ts'
3
+
4
+ /* === Tests === */
5
+
6
+ describe('createScope', () => {
7
+ test('should return a dispose function', () => {
8
+ const dispose = createScope(() => {})
9
+ expect(typeof dispose).toBe('function')
10
+ })
11
+
12
+ test('should run the callback immediately', () => {
13
+ let ran = false
14
+ createScope(() => {
15
+ ran = true
16
+ })
17
+ expect(ran).toBe(true)
18
+ })
19
+
20
+ test('should call returned cleanup on dispose', () => {
21
+ let cleaned = false
22
+ const dispose = createScope(() => {
23
+ return () => {
24
+ cleaned = true
25
+ }
26
+ })
27
+ expect(cleaned).toBe(false)
28
+ dispose()
29
+ expect(cleaned).toBe(true)
30
+ })
31
+
32
+ test('should dispose child effects', () => {
33
+ const source = createState(0)
34
+ let count = 0
35
+ const dispose = createScope(() => {
36
+ createEffect((): undefined => {
37
+ source.get()
38
+ count++
39
+ })
40
+ })
41
+ expect(count).toBe(1)
42
+ source.set(1)
43
+ expect(count).toBe(2)
44
+ dispose()
45
+ source.set(2)
46
+ expect(count).toBe(2) // effect should no longer run
47
+ })
48
+
49
+ test('should dispose multiple child effects', () => {
50
+ const a = createState(0)
51
+ const b = createState(0)
52
+ let countA = 0
53
+ let countB = 0
54
+ const dispose = createScope(() => {
55
+ createEffect((): undefined => {
56
+ a.get()
57
+ countA++
58
+ })
59
+ createEffect((): undefined => {
60
+ b.get()
61
+ countB++
62
+ })
63
+ })
64
+ expect(countA).toBe(1)
65
+ expect(countB).toBe(1)
66
+ dispose()
67
+ a.set(1)
68
+ b.set(1)
69
+ expect(countA).toBe(1)
70
+ expect(countB).toBe(1)
71
+ })
72
+
73
+ test('should call returned cleanup and dispose child effects', () => {
74
+ const source = createState(0)
75
+ let effectCount = 0
76
+ let cleaned = false
77
+ const dispose = createScope(() => {
78
+ createEffect((): undefined => {
79
+ source.get()
80
+ effectCount++
81
+ })
82
+ return () => {
83
+ cleaned = true
84
+ }
85
+ })
86
+ expect(effectCount).toBe(1)
87
+ expect(cleaned).toBe(false)
88
+ dispose()
89
+ expect(cleaned).toBe(true)
90
+ source.set(1)
91
+ expect(effectCount).toBe(1)
92
+ })
93
+
94
+ test('should handle nested scopes independently', () => {
95
+ const source = createState(0)
96
+ let outerCount = 0
97
+ let innerCount = 0
98
+ let innerDispose!: () => void
99
+ const outerDispose = createScope(() => {
100
+ createEffect((): undefined => {
101
+ source.get()
102
+ outerCount++
103
+ })
104
+ innerDispose = createScope(() => {
105
+ createEffect((): undefined => {
106
+ source.get()
107
+ innerCount++
108
+ })
109
+ })
110
+ })
111
+ expect(outerCount).toBe(1)
112
+ expect(innerCount).toBe(1)
113
+ source.set(1)
114
+ expect(outerCount).toBe(2)
115
+ expect(innerCount).toBe(2)
116
+
117
+ // disposing inner scope should not affect outer
118
+ innerDispose()
119
+ source.set(2)
120
+ expect(outerCount).toBe(3)
121
+ expect(innerCount).toBe(2)
122
+
123
+ // disposing outer scope should have no further effect
124
+ outerDispose()
125
+ source.set(3)
126
+ expect(outerCount).toBe(3)
127
+ expect(innerCount).toBe(2)
128
+ })
129
+
130
+ test('should dispose nested scopes when parent is disposed', () => {
131
+ const source = createState(0)
132
+ let innerCount = 0
133
+ const outerDispose = createScope(() => {
134
+ createScope(() => {
135
+ createEffect((): undefined => {
136
+ source.get()
137
+ innerCount++
138
+ })
139
+ })
140
+ })
141
+ expect(innerCount).toBe(1)
142
+ source.set(1)
143
+ expect(innerCount).toBe(2)
144
+
145
+ // disposing outer should also dispose inner
146
+ outerDispose()
147
+ source.set(2)
148
+ expect(innerCount).toBe(2)
149
+ })
150
+
151
+ test('should call nested cleanup functions on parent dispose', () => {
152
+ let outerCleaned = false
153
+ let innerCleaned = false
154
+ const dispose = createScope(() => {
155
+ createScope(() => {
156
+ return () => {
157
+ innerCleaned = true
158
+ }
159
+ })
160
+ return () => {
161
+ outerCleaned = true
162
+ }
163
+ })
164
+ expect(outerCleaned).toBe(false)
165
+ expect(innerCleaned).toBe(false)
166
+ dispose()
167
+ expect(outerCleaned).toBe(true)
168
+ expect(innerCleaned).toBe(true)
169
+ })
170
+
171
+ test('should be safe to call dispose multiple times', () => {
172
+ let cleanCount = 0
173
+ const dispose = createScope(() => {
174
+ return () => {
175
+ cleanCount++
176
+ }
177
+ })
178
+ dispose()
179
+ expect(cleanCount).toBe(1)
180
+ dispose()
181
+ // cleanup should only run once since it's nulled after first run
182
+ expect(cleanCount).toBe(1)
183
+ })
184
+
185
+ test('should handle scope with no cleanup return', () => {
186
+ const dispose = createScope(() => {
187
+ // no return
188
+ })
189
+ expect(() => dispose()).not.toThrow()
190
+ })
191
+ })
@@ -0,0 +1,454 @@
1
+ import { describe, expect, mock, test } from 'bun:test'
2
+ import {
3
+ createEffect,
4
+ createMemo,
5
+ createSensor,
6
+ isMemo,
7
+ isSensor,
8
+ SKIP_EQUALITY,
9
+ UnsetSignalValueError,
10
+ } from '../index.ts'
11
+
12
+ /* === Utility Functions === */
13
+
14
+ const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
15
+
16
+ /* === Tests === */
17
+
18
+ describe('Sensor', () => {
19
+ describe('createSensor', () => {
20
+ test('should have Symbol.toStringTag of "Sensor"', () => {
21
+ const sensor = createSensor<number>(() => () => {})
22
+ expect(sensor[Symbol.toStringTag]).toBe('Sensor')
23
+ })
24
+
25
+ test('should throw UnsetSignalValueError when read outside an effect', () => {
26
+ const sensor = createSensor<number>(set => {
27
+ set(42)
28
+ return () => {}
29
+ })
30
+ expect(() => sensor.get()).toThrow(UnsetSignalValueError)
31
+ })
32
+
33
+ test('should activate and return value when read inside an effect', () => {
34
+ let started = false
35
+ const sensor = createSensor<number>(set => {
36
+ started = true
37
+ set(42)
38
+ return () => {}
39
+ })
40
+
41
+ expect(started).toBe(false)
42
+
43
+ let received: number | undefined
44
+ createEffect(() => {
45
+ received = sensor.get()
46
+ })
47
+
48
+ expect(started).toBe(true)
49
+ expect(received).toBe(42)
50
+ })
51
+ })
52
+
53
+ describe('isSensor', () => {
54
+ test('should identify sensor signals', () => {
55
+ expect(isSensor(createSensor<number>(() => () => {}))).toBe(true)
56
+ })
57
+
58
+ test('should return false for non-sensor values', () => {
59
+ expect(isSensor(42)).toBe(false)
60
+ expect(isSensor(null)).toBe(false)
61
+ expect(isSensor({})).toBe(false)
62
+ expect(isMemo(createSensor<number>(() => () => {}))).toBe(false)
63
+ })
64
+ })
65
+
66
+ describe('start/link ordering', () => {
67
+ test('synchronous set() inside start should be visible to activating effect', () => {
68
+ // Start fires before link: synchronous set() updates node.value
69
+ // without propagation (no sinks yet). The activating effect reads
70
+ // the updated value directly after link completes.
71
+ const sensor = createSensor<number>(
72
+ set => {
73
+ set(10)
74
+ return () => {}
75
+ },
76
+ { value: 0 },
77
+ )
78
+
79
+ const doubled = createMemo(() => sensor.get() * 2)
80
+
81
+ let result = 0
82
+ createEffect(() => {
83
+ result = doubled.get()
84
+ })
85
+
86
+ // The memo should see 10 (from start's set), not 0 (initial value)
87
+ expect(result).toBe(20)
88
+ })
89
+ })
90
+
91
+ describe('set callback', () => {
92
+ test('should update value and trigger effects', () => {
93
+ let setFn!: (v: number) => void
94
+ const sensor = createSensor<number>(set => {
95
+ setFn = set
96
+ set(0)
97
+ return () => {}
98
+ })
99
+
100
+ let effectCount = 0
101
+ let received = 0
102
+ createEffect(() => {
103
+ received = sensor.get()
104
+ effectCount++
105
+ })
106
+
107
+ expect(received).toBe(0)
108
+ expect(effectCount).toBe(1)
109
+
110
+ setFn(10)
111
+ expect(received).toBe(10)
112
+ expect(effectCount).toBe(2)
113
+ })
114
+
115
+ test('should notify multiple effects', () => {
116
+ let setFn!: (v: string) => void
117
+ const sensor = createSensor<string>(set => {
118
+ setFn = set
119
+ set('initial')
120
+ return () => {}
121
+ })
122
+
123
+ const mock1 = mock(() => {})
124
+ const mock2 = mock(() => {})
125
+
126
+ createEffect(() => {
127
+ sensor.get()
128
+ mock1()
129
+ })
130
+ createEffect(() => {
131
+ sensor.get()
132
+ mock2()
133
+ })
134
+
135
+ expect(mock1).toHaveBeenCalledTimes(1)
136
+ expect(mock2).toHaveBeenCalledTimes(1)
137
+
138
+ setFn('updated')
139
+ expect(mock1).toHaveBeenCalledTimes(2)
140
+ expect(mock2).toHaveBeenCalledTimes(2)
141
+ })
142
+ })
143
+
144
+ describe('Lazy Activation', () => {
145
+ test('should only call start when first effect subscribes', async () => {
146
+ let counter = 0
147
+ let intervalId: Timer | undefined
148
+
149
+ const sensor = createSensor<number>(set => {
150
+ set(0)
151
+ intervalId = setInterval(() => {
152
+ counter++
153
+ set(counter)
154
+ }, 10)
155
+ return () => {
156
+ clearInterval(intervalId)
157
+ intervalId = undefined
158
+ }
159
+ })
160
+
161
+ expect(counter).toBe(0)
162
+ await wait(50)
163
+ expect(counter).toBe(0)
164
+ expect(intervalId).toBeUndefined()
165
+
166
+ const dispose = createEffect(() => {
167
+ sensor.get()
168
+ })
169
+
170
+ await wait(50)
171
+ expect(counter).toBeGreaterThan(0)
172
+ expect(intervalId).toBeDefined()
173
+
174
+ dispose()
175
+ const counterAfterStop = counter
176
+
177
+ await wait(50)
178
+ expect(counter).toBe(counterAfterStop)
179
+ expect(intervalId).toBeUndefined()
180
+ })
181
+
182
+ test('should call start only once for multiple subscribers', () => {
183
+ let startCount = 0
184
+ let cleanupCount = 0
185
+
186
+ const sensor = createSensor<number>(set => {
187
+ startCount++
188
+ set(1)
189
+ return () => {
190
+ cleanupCount++
191
+ }
192
+ })
193
+
194
+ const dispose1 = createEffect(() => {
195
+ sensor.get()
196
+ })
197
+ expect(startCount).toBe(1)
198
+
199
+ const dispose2 = createEffect(() => {
200
+ sensor.get()
201
+ })
202
+ expect(startCount).toBe(1)
203
+
204
+ dispose1()
205
+ expect(cleanupCount).toBe(0) // still has subscriber
206
+
207
+ dispose2()
208
+ expect(cleanupCount).toBe(1)
209
+ })
210
+
211
+ test('should reactivate after all subscribers leave and new one arrives', () => {
212
+ let startCount = 0
213
+ let cleanupCount = 0
214
+
215
+ const sensor = createSensor<number>(set => {
216
+ startCount++
217
+ set(startCount)
218
+ return () => {
219
+ cleanupCount++
220
+ }
221
+ })
222
+
223
+ const dispose1 = createEffect(() => {
224
+ sensor.get()
225
+ })
226
+ expect(startCount).toBe(1)
227
+
228
+ dispose1()
229
+ expect(cleanupCount).toBe(1)
230
+
231
+ let received = 0
232
+ const dispose2 = createEffect(() => {
233
+ received = sensor.get()
234
+ })
235
+ expect(startCount).toBe(2)
236
+ expect(received).toBe(2)
237
+
238
+ dispose2()
239
+ expect(cleanupCount).toBe(2)
240
+ })
241
+ })
242
+
243
+ describe('options.equals', () => {
244
+ test('should skip update when value is equal by default', () => {
245
+ let setFn!: (v: number) => void
246
+ const sensor = createSensor<number>(set => {
247
+ setFn = set
248
+ set(5)
249
+ return () => {}
250
+ })
251
+
252
+ let effectCount = 0
253
+ createEffect(() => {
254
+ sensor.get()
255
+ effectCount++
256
+ })
257
+ expect(effectCount).toBe(1)
258
+
259
+ setFn(5) // same value
260
+ expect(effectCount).toBe(1)
261
+
262
+ setFn(6)
263
+ expect(effectCount).toBe(2)
264
+ })
265
+
266
+ test('should use custom equality function', () => {
267
+ let setFn!: (v: { x: number }) => void
268
+ const sensor = createSensor<{ x: number }>(
269
+ set => {
270
+ setFn = set
271
+ set({ x: 1 })
272
+ return () => {}
273
+ },
274
+ { equals: (a, b) => a?.x === b?.x },
275
+ )
276
+
277
+ let effectCount = 0
278
+ createEffect(() => {
279
+ sensor.get()
280
+ effectCount++
281
+ })
282
+ expect(effectCount).toBe(1)
283
+
284
+ setFn({ x: 1 }) // structurally equal
285
+ expect(effectCount).toBe(1)
286
+
287
+ setFn({ x: 2 })
288
+ expect(effectCount).toBe(2)
289
+ })
290
+ })
291
+
292
+ describe('options.guard', () => {
293
+ test('should validate values from set callback', () => {
294
+ let setFn!: (v: number) => void
295
+ const sensor = createSensor<number>(
296
+ set => {
297
+ setFn = set
298
+ set(1)
299
+ return () => {}
300
+ },
301
+ { guard: (v): v is number => typeof v === 'number' && v > 0 },
302
+ )
303
+
304
+ createEffect(() => {
305
+ sensor.get()
306
+ })
307
+
308
+ expect(() => setFn(5)).not.toThrow()
309
+ expect(() => setFn(-1)).toThrow()
310
+ expect(() => setFn(0)).toThrow()
311
+ })
312
+ })
313
+
314
+ describe('options.value', () => {
315
+ test('should use initial value before activation', () => {
316
+ const sensor = createSensor<number>(() => () => {}, { value: 99 })
317
+
318
+ let received: number | undefined
319
+ createEffect(() => {
320
+ received = sensor.get()
321
+ })
322
+ expect(received).toBe(99)
323
+ })
324
+ })
325
+
326
+ describe('SKIP_EQUALITY', () => {
327
+ test('should always return false', () => {
328
+ expect(SKIP_EQUALITY()).toBe(false)
329
+ expect(SKIP_EQUALITY(1, 1)).toBe(false)
330
+ })
331
+
332
+ test('should return the reference value from get()', () => {
333
+ const obj = { name: 'test' }
334
+ const sensor = createSensor<typeof obj>(
335
+ set => {
336
+ set(obj)
337
+ return () => {}
338
+ },
339
+ { value: obj, equals: SKIP_EQUALITY },
340
+ )
341
+
342
+ let received: typeof obj | undefined
343
+ const dispose = createEffect(() => {
344
+ received = sensor.get()
345
+ })
346
+ expect(received).toBe(obj)
347
+ dispose()
348
+ })
349
+
350
+ test('should re-run effects when set is called with same reference', () => {
351
+ const obj = { status: 'offline' }
352
+ let setFn!: (next: typeof obj) => void
353
+
354
+ const sensor = createSensor<typeof obj>(
355
+ set => {
356
+ setFn = set
357
+ set(obj)
358
+ return () => {}
359
+ },
360
+ { equals: SKIP_EQUALITY },
361
+ )
362
+
363
+ let effectCount = 0
364
+ let lastStatus = ''
365
+ createEffect(() => {
366
+ lastStatus = sensor.get().status
367
+ effectCount++
368
+ })
369
+
370
+ expect(effectCount).toBe(1)
371
+ expect(lastStatus).toBe('offline')
372
+
373
+ obj.status = 'online'
374
+ expect(effectCount).toBe(1) // no set yet
375
+
376
+ setFn(obj) // same reference, but SKIP_EQUALITY ensures propagation
377
+ expect(effectCount).toBe(2)
378
+ expect(lastStatus).toBe('online')
379
+ })
380
+
381
+ test('should trigger multiple effect runs on multiple set calls', () => {
382
+ const obj = { size: 100 }
383
+ let setFn!: (next: typeof obj) => void
384
+
385
+ const sensor = createSensor<typeof obj>(
386
+ set => {
387
+ setFn = set
388
+ set(obj)
389
+ return () => {}
390
+ },
391
+ { equals: SKIP_EQUALITY },
392
+ )
393
+
394
+ const callback = mock(() => {})
395
+ createEffect(() => {
396
+ sensor.get()
397
+ callback()
398
+ })
399
+
400
+ expect(callback).toHaveBeenCalledTimes(1)
401
+
402
+ setFn(obj)
403
+ expect(callback).toHaveBeenCalledTimes(2)
404
+
405
+ setFn(obj)
406
+ expect(callback).toHaveBeenCalledTimes(3)
407
+ })
408
+
409
+ test('should validate values passed through set()', () => {
410
+ let setFn!: (next: unknown) => void
411
+
412
+ const sensor = createSensor<{ x: number }>(
413
+ set => {
414
+ setFn = set as (next: unknown) => void
415
+ set({ x: 1 })
416
+ return () => {}
417
+ },
418
+ { equals: SKIP_EQUALITY },
419
+ )
420
+
421
+ createEffect(() => {
422
+ sensor.get()
423
+ })
424
+
425
+ expect(() => {
426
+ // biome-ignore lint/suspicious/noExplicitAny: testing invalid input
427
+ setFn(null as any)
428
+ }).toThrow('[Sensor] Signal value cannot be null or undefined')
429
+ })
430
+ })
431
+
432
+ describe('Input Validation', () => {
433
+ test('should throw InvalidCallbackError for non-function start', () => {
434
+ expect(() => {
435
+ // @ts-expect-error - Testing invalid input
436
+ createSensor(null)
437
+ }).toThrow('[Sensor] Callback null is invalid')
438
+ })
439
+
440
+ test('should throw InvalidCallbackError for async start callback', () => {
441
+ expect(() => {
442
+ // @ts-expect-error - Testing invalid input
443
+ createSensor(async () => () => {})
444
+ }).toThrow()
445
+ })
446
+
447
+ test('should throw NullishSignalValueError for null initial value', () => {
448
+ expect(() => {
449
+ // @ts-expect-error - Testing invalid input
450
+ createSensor<number>(() => () => {}, { value: null })
451
+ }).toThrow('[Sensor] Signal value cannot be null or undefined')
452
+ })
453
+ })
454
+ })