@zeix/cause-effect 0.17.2 → 0.18.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.
Files changed (94) hide show
  1. package/.ai-context.md +163 -226
  2. package/.cursorrules +41 -35
  3. package/.github/copilot-instructions.md +166 -116
  4. package/.zed/settings.json +3 -0
  5. package/ARCHITECTURE.md +274 -0
  6. package/CLAUDE.md +197 -202
  7. package/COLLECTION_REFACTORING.md +161 -0
  8. package/GUIDE.md +298 -0
  9. package/README.md +241 -220
  10. package/REQUIREMENTS.md +100 -0
  11. package/bench/reactivity.bench.ts +577 -0
  12. package/index.dev.js +1326 -1174
  13. package/index.js +1 -1
  14. package/index.ts +58 -85
  15. package/package.json +9 -6
  16. package/src/errors.ts +118 -70
  17. package/src/graph.ts +601 -0
  18. package/src/nodes/collection.ts +474 -0
  19. package/src/nodes/effect.ts +149 -0
  20. package/src/nodes/list.ts +588 -0
  21. package/src/nodes/memo.ts +120 -0
  22. package/src/nodes/sensor.ts +139 -0
  23. package/src/nodes/state.ts +135 -0
  24. package/src/nodes/store.ts +383 -0
  25. package/src/nodes/task.ts +146 -0
  26. package/src/signal.ts +112 -64
  27. package/src/util.ts +26 -57
  28. package/test/batch.test.ts +96 -69
  29. package/test/benchmark.test.ts +473 -485
  30. package/test/collection.test.ts +455 -955
  31. package/test/effect.test.ts +293 -696
  32. package/test/list.test.ts +332 -857
  33. package/test/memo.test.ts +380 -0
  34. package/test/regression.test.ts +156 -0
  35. package/test/scope.test.ts +191 -0
  36. package/test/sensor.test.ts +454 -0
  37. package/test/signal.test.ts +220 -213
  38. package/test/state.test.ts +217 -271
  39. package/test/store.test.ts +346 -898
  40. package/test/task.test.ts +395 -0
  41. package/test/untrack.test.ts +167 -0
  42. package/test/util/dependency-graph.ts +2 -2
  43. package/tsconfig.build.json +11 -0
  44. package/tsconfig.json +5 -7
  45. package/types/index.d.ts +13 -15
  46. package/types/src/errors.d.ts +73 -19
  47. package/types/src/graph.d.ts +208 -0
  48. package/types/src/nodes/collection.d.ts +64 -0
  49. package/types/src/nodes/effect.d.ts +48 -0
  50. package/types/src/nodes/list.d.ts +65 -0
  51. package/types/src/nodes/memo.d.ts +57 -0
  52. package/types/src/nodes/sensor.d.ts +75 -0
  53. package/types/src/nodes/state.d.ts +78 -0
  54. package/types/src/nodes/store.d.ts +51 -0
  55. package/types/src/nodes/task.d.ts +73 -0
  56. package/types/src/signal.d.ts +43 -28
  57. package/types/src/util.d.ts +9 -16
  58. package/archive/benchmark.ts +0 -688
  59. package/archive/collection.ts +0 -310
  60. package/archive/computed.ts +0 -198
  61. package/archive/list.ts +0 -544
  62. package/archive/memo.ts +0 -140
  63. package/archive/state.ts +0 -90
  64. package/archive/store.ts +0 -357
  65. package/archive/task.ts +0 -191
  66. package/src/classes/collection.ts +0 -298
  67. package/src/classes/composite.ts +0 -171
  68. package/src/classes/computed.ts +0 -392
  69. package/src/classes/list.ts +0 -310
  70. package/src/classes/ref.ts +0 -96
  71. package/src/classes/state.ts +0 -131
  72. package/src/classes/store.ts +0 -227
  73. package/src/diff.ts +0 -138
  74. package/src/effect.ts +0 -96
  75. package/src/match.ts +0 -45
  76. package/src/resolve.ts +0 -49
  77. package/src/system.ts +0 -275
  78. package/test/computed.test.ts +0 -1126
  79. package/test/diff.test.ts +0 -955
  80. package/test/match.test.ts +0 -388
  81. package/test/ref.test.ts +0 -381
  82. package/test/resolve.test.ts +0 -154
  83. package/types/src/classes/collection.d.ts +0 -47
  84. package/types/src/classes/composite.d.ts +0 -15
  85. package/types/src/classes/computed.d.ts +0 -114
  86. package/types/src/classes/list.d.ts +0 -41
  87. package/types/src/classes/ref.d.ts +0 -48
  88. package/types/src/classes/state.d.ts +0 -61
  89. package/types/src/classes/store.d.ts +0 -51
  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 -81
@@ -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
+ })