@zeix/cause-effect 0.17.3 → 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 (89) hide show
  1. package/.ai-context.md +163 -232
  2. package/.cursorrules +41 -35
  3. package/.github/copilot-instructions.md +166 -116
  4. package/ARCHITECTURE.md +274 -0
  5. package/CLAUDE.md +199 -143
  6. package/COLLECTION_REFACTORING.md +161 -0
  7. package/GUIDE.md +298 -0
  8. package/README.md +232 -197
  9. package/REQUIREMENTS.md +100 -0
  10. package/bench/reactivity.bench.ts +577 -0
  11. package/index.dev.js +1325 -997
  12. package/index.js +1 -1
  13. package/index.ts +58 -74
  14. package/package.json +4 -1
  15. package/src/errors.ts +118 -74
  16. package/src/graph.ts +601 -0
  17. package/src/nodes/collection.ts +474 -0
  18. package/src/nodes/effect.ts +149 -0
  19. package/src/nodes/list.ts +588 -0
  20. package/src/nodes/memo.ts +120 -0
  21. package/src/nodes/sensor.ts +139 -0
  22. package/src/nodes/state.ts +135 -0
  23. package/src/nodes/store.ts +383 -0
  24. package/src/nodes/task.ts +146 -0
  25. package/src/signal.ts +112 -66
  26. package/src/util.ts +26 -57
  27. package/test/batch.test.ts +96 -62
  28. package/test/benchmark.test.ts +473 -487
  29. package/test/collection.test.ts +466 -706
  30. package/test/effect.test.ts +293 -696
  31. package/test/list.test.ts +335 -592
  32. package/test/memo.test.ts +380 -0
  33. package/test/regression.test.ts +156 -0
  34. package/test/scope.test.ts +191 -0
  35. package/test/sensor.test.ts +454 -0
  36. package/test/signal.test.ts +220 -213
  37. package/test/state.test.ts +217 -265
  38. package/test/store.test.ts +346 -446
  39. package/test/task.test.ts +395 -0
  40. package/test/untrack.test.ts +167 -0
  41. package/types/index.d.ts +13 -15
  42. package/types/src/errors.d.ts +73 -17
  43. package/types/src/graph.d.ts +208 -0
  44. package/types/src/nodes/collection.d.ts +64 -0
  45. package/types/src/nodes/effect.d.ts +48 -0
  46. package/types/src/nodes/list.d.ts +65 -0
  47. package/types/src/nodes/memo.d.ts +57 -0
  48. package/types/src/nodes/sensor.d.ts +75 -0
  49. package/types/src/nodes/state.d.ts +78 -0
  50. package/types/src/nodes/store.d.ts +51 -0
  51. package/types/src/nodes/task.d.ts +73 -0
  52. package/types/src/signal.d.ts +43 -29
  53. package/types/src/util.d.ts +9 -16
  54. package/archive/benchmark.ts +0 -683
  55. package/archive/collection.ts +0 -253
  56. package/archive/composite.ts +0 -85
  57. package/archive/computed.ts +0 -195
  58. package/archive/list.ts +0 -483
  59. package/archive/memo.ts +0 -139
  60. package/archive/state.ts +0 -90
  61. package/archive/store.ts +0 -298
  62. package/archive/task.ts +0 -189
  63. package/src/classes/collection.ts +0 -245
  64. package/src/classes/computed.ts +0 -349
  65. package/src/classes/list.ts +0 -343
  66. package/src/classes/ref.ts +0 -70
  67. package/src/classes/state.ts +0 -102
  68. package/src/classes/store.ts +0 -262
  69. package/src/diff.ts +0 -138
  70. package/src/effect.ts +0 -93
  71. package/src/match.ts +0 -45
  72. package/src/resolve.ts +0 -49
  73. package/src/system.ts +0 -257
  74. package/test/computed.test.ts +0 -1108
  75. package/test/diff.test.ts +0 -955
  76. package/test/match.test.ts +0 -388
  77. package/test/ref.test.ts +0 -353
  78. package/test/resolve.test.ts +0 -154
  79. package/types/src/classes/collection.d.ts +0 -45
  80. package/types/src/classes/computed.d.ts +0 -94
  81. package/types/src/classes/list.d.ts +0 -43
  82. package/types/src/classes/ref.d.ts +0 -35
  83. package/types/src/classes/state.d.ts +0 -49
  84. package/types/src/classes/store.d.ts +0 -52
  85. package/types/src/diff.d.ts +0 -28
  86. package/types/src/effect.d.ts +0 -15
  87. package/types/src/match.d.ts +0 -21
  88. package/types/src/resolve.d.ts +0 -29
  89. package/types/src/system.d.ts +0 -78
@@ -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
+ })