@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
@@ -1,1126 +0,0 @@
1
- import { describe, expect, test } from 'bun:test'
2
- import {
3
- createEffect,
4
- isComputed,
5
- isState,
6
- Memo,
7
- match,
8
- resolve,
9
- State,
10
- Task,
11
- UNSET,
12
- } from '../index.ts'
13
- import { HOOK_WATCH } from '../src/system'
14
-
15
- /* === Utility Functions === */
16
-
17
- const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
18
- const increment = (n: number) => (Number.isFinite(n) ? n + 1 : UNSET)
19
-
20
- /* === Tests === */
21
-
22
- describe('Computed', () => {
23
- test('should identify computed signals with isComputed()', () => {
24
- const count = new State(42)
25
- const doubled = new Memo(() => count.get() * 2)
26
- expect(isComputed(doubled)).toBe(true)
27
- expect(isState(doubled)).toBe(false)
28
- })
29
-
30
- test('should compute a function', () => {
31
- const derived = new Memo(() => 1 + 2)
32
- expect(derived.get()).toBe(3)
33
- })
34
-
35
- test('should compute function dependent on a signal', () => {
36
- const cause = new State(42)
37
- const derived = new Memo(() => cause.get() + 1)
38
- expect(derived.get()).toBe(43)
39
- })
40
-
41
- test('should compute function dependent on an updated signal', () => {
42
- const cause = new State(42)
43
- const derived = new Memo(() => cause.get() + 1)
44
- cause.set(24)
45
- expect(derived.get()).toBe(25)
46
- })
47
-
48
- test('should compute function dependent on an async signal', async () => {
49
- const status = new State('pending')
50
- const promised = new Task(async () => {
51
- await wait(100)
52
- status.set('success')
53
- return 42
54
- })
55
- const derived = new Memo(() => increment(promised.get()))
56
- expect(derived.get()).toBe(UNSET)
57
- expect(status.get()).toBe('pending')
58
- await wait(110)
59
- expect(derived.get()).toBe(43)
60
- expect(status.get()).toBe('success')
61
- })
62
-
63
- test('should handle errors from an async signal gracefully', async () => {
64
- const status = new State('pending')
65
- const error = new State('')
66
- const promised = new Task(async () => {
67
- await wait(100)
68
- status.set('error')
69
- error.set('error occurred')
70
- return 0
71
- })
72
- const derived = new Memo(() => increment(promised.get()))
73
- expect(derived.get()).toBe(UNSET)
74
- expect(status.get()).toBe('pending')
75
- await wait(110)
76
- expect(error.get()).toBe('error occurred')
77
- expect(status.get()).toBe('error')
78
- })
79
-
80
- test('should compute task signals in parallel without waterfalls', async () => {
81
- const a = new Task(async () => {
82
- await wait(100)
83
- return 10
84
- })
85
- const b = new Task(async () => {
86
- await wait(100)
87
- return 20
88
- })
89
- const c = new Memo(() => {
90
- const aValue = a.get()
91
- const bValue = b.get()
92
- return aValue === UNSET || bValue === UNSET
93
- ? UNSET
94
- : aValue + bValue
95
- })
96
- expect(c.get()).toBe(UNSET)
97
- await wait(110)
98
- expect(c.get()).toBe(30)
99
- })
100
-
101
- test('should compute function dependent on a chain of computed states dependent on a signal', () => {
102
- const x = new State(42)
103
- const a = new Memo(() => x.get() + 1)
104
- const b = new Memo(() => a.get() * 2)
105
- const c = new Memo(() => b.get() + 1)
106
- expect(c.get()).toBe(87)
107
- })
108
-
109
- test('should compute function dependent on a chain of computed states dependent on an updated signal', () => {
110
- const x = new State(42)
111
- const a = new Memo(() => x.get() + 1)
112
- const b = new Memo(() => a.get() * 2)
113
- const c = new Memo(() => b.get() + 1)
114
- x.set(24)
115
- expect(c.get()).toBe(51)
116
- })
117
-
118
- test('should drop X->B->X updates', () => {
119
- let count = 0
120
- const x = new State(2)
121
- const a = new Memo(() => x.get() - 1)
122
- const b = new Memo(() => x.get() + a.get())
123
- const c = new Memo(() => {
124
- count++
125
- return `c: ${b.get()}`
126
- })
127
- expect(c.get()).toBe('c: 3')
128
- expect(count).toBe(1)
129
- x.set(4)
130
- expect(c.get()).toBe('c: 7')
131
- expect(count).toBe(2)
132
- })
133
-
134
- test('should only update every signal once (diamond graph)', () => {
135
- let count = 0
136
- const x = new State('a')
137
- const a = new Memo(() => x.get())
138
- const b = new Memo(() => x.get())
139
- const c = new Memo(() => {
140
- count++
141
- return `${a.get()} ${b.get()}`
142
- })
143
- expect(c.get()).toBe('a a')
144
- expect(count).toBe(1)
145
- x.set('aa')
146
- // flush()
147
- expect(c.get()).toBe('aa aa')
148
- expect(count).toBe(2)
149
- })
150
-
151
- test('should only update every signal once (diamond graph + tail)', () => {
152
- let count = 0
153
- const x = new State('a')
154
- const a = new Memo(() => x.get())
155
- const b = new Memo(() => x.get())
156
- const c = new Memo(() => `${a.get()} ${b.get()}`)
157
- const d = new Memo(() => {
158
- count++
159
- return c.get()
160
- })
161
- expect(d.get()).toBe('a a')
162
- expect(count).toBe(1)
163
- x.set('aa')
164
- expect(d.get()).toBe('aa aa')
165
- expect(count).toBe(2)
166
- })
167
-
168
- test('should update multiple times after multiple state changes', () => {
169
- const a = new State(3)
170
- const b = new State(4)
171
- let count = 0
172
- const sum = new Memo(() => {
173
- count++
174
- return a.get() + b.get()
175
- })
176
- expect(sum.get()).toBe(7)
177
- a.set(6)
178
- expect(sum.get()).toBe(10)
179
- b.set(8)
180
- expect(sum.get()).toBe(14)
181
- expect(count).toBe(3)
182
- })
183
-
184
- /*
185
- * Note for the next two tests:
186
- *
187
- * Due to the lazy evaluation strategy, unchanged computed signals may propagate
188
- * change notifications one additional time before stabilizing. This is a
189
- * one-time performance cost that allows for efficient memoization and
190
- * error handling in most cases.
191
- */
192
- test('should bail out if result is the same', () => {
193
- let count = 0
194
- const x = new State('a')
195
- const a = new Memo(() => {
196
- x.get()
197
- return 'foo'
198
- })
199
- const b = new Memo(() => {
200
- count++
201
- return a.get()
202
- })
203
- expect(b.get()).toBe('foo')
204
- expect(count).toBe(1)
205
- x.set('aa')
206
- x.set('aaa')
207
- x.set('aaaa')
208
- expect(b.get()).toBe('foo')
209
- expect(count).toBe(2)
210
- })
211
-
212
- test('should block if result remains unchanged', () => {
213
- let count = 0
214
- const x = new State(42)
215
- const a = new Memo(() => x.get() % 2)
216
- const b = new Memo(() => (a.get() ? 'odd' : 'even'))
217
- const c = new Memo(() => {
218
- count++
219
- return `c: ${b.get()}`
220
- })
221
- expect(c.get()).toBe('c: even')
222
- expect(count).toBe(1)
223
- x.set(44)
224
- x.set(46)
225
- x.set(48)
226
- expect(c.get()).toBe('c: even')
227
- expect(count).toBe(2)
228
- })
229
-
230
- test('should detect and throw error for circular dependencies', () => {
231
- const a = new State(1)
232
- const b = new Memo(() => c.get() + 1)
233
- const c = new Memo(() => b.get() + a.get())
234
- expect(() => {
235
- b.get() // This should trigger the circular dependency
236
- }).toThrow('Circular dependency detected in memo')
237
- expect(a.get()).toBe(1)
238
- })
239
-
240
- test('should propagate error if an error occurred', () => {
241
- let okCount = 0
242
- let errCount = 0
243
- const x = new State(0)
244
- const a = new Memo(() => {
245
- if (x.get() === 1) throw new Error('Calculation error')
246
- return 1
247
- })
248
-
249
- // Replace matcher with try/catch in a computed
250
- const b = new Memo(() => {
251
- try {
252
- a.get() // just check if it works
253
- return `c: success`
254
- } catch (_error) {
255
- errCount++
256
- return `c: recovered`
257
- }
258
- })
259
- const c = new Memo(() => {
260
- okCount++
261
- return b.get()
262
- })
263
-
264
- expect(a.get()).toBe(1)
265
- expect(c.get()).toBe('c: success')
266
- expect(okCount).toBe(1)
267
- try {
268
- x.set(1)
269
- expect(a.get()).toBe(1)
270
- expect(true).toBe(false) // This line should not be reached
271
- } catch (error) {
272
- expect(error.message).toBe('Calculation error')
273
- } finally {
274
- expect(c.get()).toBe('c: recovered')
275
- expect(okCount).toBe(2)
276
- expect(errCount).toBe(1)
277
- }
278
- })
279
-
280
- test('should create an effect that reacts on async computed changes', async () => {
281
- const cause = new State(42)
282
- const derived = new Task(async () => {
283
- await wait(100)
284
- return cause.get() + 1
285
- })
286
- let okCount = 0
287
- let nilCount = 0
288
- let result: number = 0
289
- createEffect(() => {
290
- const resolved = resolve({ derived })
291
- match(resolved, {
292
- ok: ({ derived: v }) => {
293
- result = v
294
- okCount++
295
- },
296
- nil: () => {
297
- nilCount++
298
- },
299
- })
300
- })
301
- cause.set(43)
302
- expect(okCount).toBe(0)
303
- expect(nilCount).toBe(1)
304
- expect(result).toBe(0)
305
-
306
- await wait(110)
307
- expect(okCount).toBe(1) // not +1 because initial state never made it here
308
- expect(nilCount).toBe(1)
309
- expect(result).toBe(44)
310
- })
311
-
312
- test('should handle complex computed signal with error and async dependencies', async () => {
313
- const toggleState = new State(true)
314
- const errorProne = new Memo(() => {
315
- if (toggleState.get()) throw new Error('Intentional error')
316
- return 42
317
- })
318
- const asyncValue = new Task(async () => {
319
- await wait(50)
320
- return 10
321
- })
322
- let okCount = 0
323
- let nilCount = 0
324
- let errCount = 0
325
- // let _result: number = 0
326
-
327
- const complexComputed = new Memo(() => {
328
- try {
329
- const x = errorProne.get()
330
- const y = asyncValue.get()
331
- if (y === UNSET) {
332
- // not ready yet
333
- nilCount++
334
- return 0
335
- } else {
336
- // happy path
337
- okCount++
338
- return x + y
339
- }
340
- } catch (_error) {
341
- // error path
342
- errCount++
343
- return -1
344
- }
345
- })
346
-
347
- for (let i = 0; i < 10; i++) {
348
- toggleState.set(!!(i % 2))
349
- await wait(10)
350
- complexComputed.get()
351
- }
352
-
353
- // Adjusted expectations to be more flexible
354
- expect(nilCount + okCount + errCount).toBe(10)
355
- expect(okCount).toBeGreaterThan(0)
356
- expect(errCount).toBeGreaterThan(0)
357
- })
358
-
359
- test('should handle signal changes during async computation', async () => {
360
- const source = new State(1)
361
- let computationCount = 0
362
- const derived = new Task(async (_, abort) => {
363
- computationCount++
364
- expect(abort?.aborted).toBe(false)
365
- await wait(100)
366
- return source.get()
367
- })
368
-
369
- // Start first computation
370
- expect(derived.get()).toBe(UNSET)
371
- expect(computationCount).toBe(1)
372
-
373
- // Change source before first computation completes
374
- source.set(2)
375
- await wait(210)
376
- expect(derived.get()).toBe(2)
377
- expect(computationCount).toBe(1)
378
- })
379
-
380
- test('should handle multiple rapid changes during async computation', async () => {
381
- const source = new State(1)
382
- let computationCount = 0
383
- const derived = new Task(async (_, abort) => {
384
- computationCount++
385
- expect(abort?.aborted).toBe(false)
386
- await wait(100)
387
- return source.get()
388
- })
389
-
390
- // Start first computation
391
- expect(derived.get()).toBe(UNSET)
392
- expect(computationCount).toBe(1)
393
-
394
- // Make multiple rapid changes
395
- source.set(2)
396
- source.set(3)
397
- source.set(4)
398
- await wait(210)
399
-
400
- // Should have computed twice (initial + final change)
401
- expect(derived.get()).toBe(4)
402
- expect(computationCount).toBe(1)
403
- })
404
-
405
- test('should handle errors in aborted computations', async () => {
406
- const source = new State(1)
407
- const derived = new Task(async () => {
408
- await wait(100)
409
- const value = source.get()
410
- if (value === 2) throw new Error('Intentional error')
411
- return value
412
- })
413
-
414
- // Start first computation
415
- expect(derived.get()).toBe(UNSET)
416
-
417
- // Change to error state before first computation completes
418
- source.set(2)
419
- await wait(110)
420
- expect(() => derived.get()).toThrow('Intentional error')
421
-
422
- // Change to normal state before second computation completes
423
- source.set(3)
424
- await wait(100)
425
- expect(derived.get()).toBe(3)
426
- })
427
-
428
- describe('Input Validation', () => {
429
- test('should throw InvalidCallbackError when callback is not a function', () => {
430
- expect(() => {
431
- // @ts-expect-error - Testing invalid input
432
- new Memo(null)
433
- }).toThrow('Invalid Memo callback null')
434
-
435
- expect(() => {
436
- // @ts-expect-error - Testing invalid input
437
- new Memo(undefined)
438
- }).toThrow('Invalid Memo callback undefined')
439
-
440
- expect(() => {
441
- // @ts-expect-error - Testing invalid input
442
- new Memo(42)
443
- }).toThrow('Invalid Memo callback 42')
444
-
445
- expect(() => {
446
- // @ts-expect-error - Testing invalid input
447
- new Memo('not a function')
448
- }).toThrow('Invalid Memo callback "not a function"')
449
-
450
- expect(() => {
451
- // @ts-expect-error - Testing invalid input
452
- new Memo({ not: 'a function' })
453
- }).toThrow('Invalid Memo callback {"not":"a function"}')
454
-
455
- expect(() => {
456
- // @ts-expect-error - Testing invalid input
457
- new Memo((_a: unknown, _b: unknown, _c: unknown) => 42)
458
- }).toThrow('Invalid Memo callback (_a, _b, _c) => 42')
459
-
460
- expect(() => {
461
- // @ts-expect-error - Testing invalid input
462
- new Memo(async (_a: unknown, _b: unknown) => 42)
463
- }).toThrow('Invalid Memo callback async (_a, _b) => 42')
464
-
465
- expect(() => {
466
- // @ts-expect-error - Testing invalid input
467
- new Task((_a: unknown) => 42)
468
- }).toThrow('Invalid Task callback (_a) => 42')
469
- })
470
-
471
- test('should throw NullishSignalValueError when initialValue is null', () => {
472
- expect(() => {
473
- // @ts-expect-error - Testing invalid input
474
- new Memo(() => 42, null)
475
- }).toThrow('Nullish signal values are not allowed in Memo')
476
- })
477
-
478
- test('should throw specific error types for invalid inputs', () => {
479
- try {
480
- // @ts-expect-error - Testing invalid input
481
- new Memo(null)
482
- expect(true).toBe(false) // Should not reach here
483
- } catch (error) {
484
- expect(error).toBeInstanceOf(TypeError)
485
- expect(error.name).toBe('InvalidCallbackError')
486
- expect(error.message).toBe('Invalid Memo callback null')
487
- }
488
-
489
- try {
490
- // @ts-expect-error - Testing invalid input
491
- new Memo(() => 42, null)
492
- expect(true).toBe(false) // Should not reach here
493
- } catch (error) {
494
- expect(error).toBeInstanceOf(TypeError)
495
- expect(error.name).toBe('NullishSignalValueError')
496
- expect(error.message).toBe(
497
- 'Nullish signal values are not allowed in Memo',
498
- )
499
- }
500
- })
501
-
502
- test('should allow valid callbacks and non-nullish initialValues', () => {
503
- // These should not throw
504
- expect(() => {
505
- new Memo(() => 42)
506
- }).not.toThrow()
507
-
508
- expect(() => {
509
- new Memo(() => 42, 0)
510
- }).not.toThrow()
511
-
512
- expect(() => {
513
- new Memo(() => 'foo', '')
514
- }).not.toThrow()
515
-
516
- expect(() => {
517
- new Memo(() => true, false)
518
- }).not.toThrow()
519
-
520
- expect(() => {
521
- new Task(async () => ({ id: 42, name: 'John' }), UNSET)
522
- }).not.toThrow()
523
- })
524
- })
525
-
526
- describe('Initial Value and Old Value', () => {
527
- test('should use initialValue when provided', () => {
528
- const computed = new Memo((oldValue: number) => oldValue + 1, 10)
529
- expect(computed.get()).toBe(11)
530
- })
531
-
532
- test('should pass current value as oldValue to callback', () => {
533
- const state = new State(5)
534
- let receivedOldValue: number | undefined
535
- const computed = new Memo((oldValue: number) => {
536
- receivedOldValue = oldValue
537
- return state.get() * 2
538
- }, 0)
539
-
540
- expect(computed.get()).toBe(10)
541
- expect(receivedOldValue).toBe(0)
542
-
543
- state.set(3)
544
- expect(computed.get()).toBe(6)
545
- expect(receivedOldValue).toBe(10)
546
- })
547
-
548
- test('should work as reducer function with oldValue', () => {
549
- const increment = new State(0)
550
- const sum = new Memo((oldValue: number) => {
551
- const inc = increment.get()
552
- return inc === 0 ? oldValue : oldValue + inc
553
- }, 0)
554
-
555
- expect(sum.get()).toBe(0)
556
-
557
- increment.set(5)
558
- expect(sum.get()).toBe(5)
559
-
560
- increment.set(3)
561
- expect(sum.get()).toBe(8)
562
-
563
- increment.set(2)
564
- expect(sum.get()).toBe(10)
565
- })
566
-
567
- test('should handle array accumulation with oldValue', () => {
568
- const item = new State('')
569
- const items = new Memo((oldValue: string[]) => {
570
- const newItem = item.get()
571
- return newItem === '' ? oldValue : [...oldValue, newItem]
572
- }, [] as string[])
573
-
574
- expect(items.get()).toEqual([])
575
-
576
- item.set('first')
577
- expect(items.get()).toEqual(['first'])
578
-
579
- item.set('second')
580
- expect(items.get()).toEqual(['first', 'second'])
581
-
582
- item.set('third')
583
- expect(items.get()).toEqual(['first', 'second', 'third'])
584
- })
585
-
586
- test('should handle counter with oldValue and multiple dependencies', () => {
587
- const reset = new State(false)
588
- const add = new State(0)
589
- const counter = new Memo((oldValue: number) => {
590
- if (reset.get()) return 0
591
- const increment = add.get()
592
- return increment === 0 ? oldValue : oldValue + increment
593
- }, 0)
594
-
595
- expect(counter.get()).toBe(0)
596
-
597
- add.set(5)
598
- expect(counter.get()).toBe(5)
599
-
600
- add.set(3)
601
- expect(counter.get()).toBe(8)
602
-
603
- reset.set(true)
604
- expect(counter.get()).toBe(0)
605
-
606
- reset.set(false)
607
- add.set(2)
608
- expect(counter.get()).toBe(2)
609
- })
610
-
611
- test('should pass UNSET as oldValue when no initialValue provided', () => {
612
- let receivedOldValue: number | undefined
613
- const state = new State(42)
614
- const computed = new Memo((oldValue: number) => {
615
- receivedOldValue = oldValue
616
- return state.get()
617
- })
618
-
619
- expect(computed.get()).toBe(42)
620
- expect(receivedOldValue).toBe(UNSET)
621
- })
622
-
623
- test('should work with async computation and oldValue', async () => {
624
- let receivedOldValue: number | undefined
625
-
626
- const asyncComputed = new Task(async (oldValue: number) => {
627
- receivedOldValue = oldValue
628
- await wait(50)
629
- return oldValue + 5
630
- }, 10)
631
-
632
- // Initially returns initialValue before async computation completes
633
- expect(asyncComputed.get()).toBe(10)
634
-
635
- // Wait for async computation to complete
636
- await wait(60)
637
- expect(asyncComputed.get()).toBe(15) // 10 + 5
638
- expect(receivedOldValue).toBe(10)
639
- })
640
-
641
- test('should handle object updates with oldValue', () => {
642
- const key = new State('')
643
- const value = new State('')
644
- const obj = new Memo(
645
- (oldValue: Record<string, string>) => {
646
- const k = key.get()
647
- const v = value.get()
648
- if (k === '' || v === '') return oldValue
649
- return { ...oldValue, [k]: v }
650
- },
651
- {} as Record<string, string>,
652
- )
653
-
654
- expect(obj.get()).toEqual({})
655
-
656
- key.set('name')
657
- value.set('Alice')
658
- expect(obj.get()).toEqual({ name: 'Alice' })
659
-
660
- key.set('age')
661
- value.set('30')
662
- expect(obj.get()).toEqual({ name: 'Alice', age: '30' })
663
- })
664
-
665
- test('should handle async computation with AbortSignal and oldValue', async () => {
666
- const source = new State(1)
667
- let computationCount = 0
668
- const receivedOldValues: number[] = []
669
-
670
- const asyncComputed = new Task(
671
- async (oldValue: number, abort: AbortSignal) => {
672
- computationCount++
673
- receivedOldValues.push(oldValue)
674
-
675
- // Simulate async work
676
- await wait(100)
677
-
678
- // Check if computation was aborted
679
- if (abort.aborted) {
680
- return oldValue
681
- }
682
-
683
- return source.get() + oldValue
684
- },
685
- 0,
686
- )
687
-
688
- // Initial computation
689
- expect(asyncComputed.get()).toBe(0) // Returns initialValue immediately
690
-
691
- // Change source before first computation completes
692
- source.set(2)
693
-
694
- // Wait for computation to complete
695
- await wait(110)
696
-
697
- // Should have the result from the computation that wasn't aborted
698
- expect(asyncComputed.get()).toBe(2) // 2 + 0 (initialValue was used as oldValue)
699
- expect(computationCount).toBe(1) // Only one computation completed
700
- expect(receivedOldValues).toEqual([0])
701
- })
702
-
703
- test('should work with error handling and oldValue', () => {
704
- const shouldError = new State(false)
705
- const counter = new State(1)
706
-
707
- const computed = new Memo((oldValue: number) => {
708
- if (shouldError.get()) {
709
- throw new Error('Computation failed')
710
- }
711
- // Handle UNSET case by treating it as 0
712
- const safeOldValue = oldValue === UNSET ? 0 : oldValue
713
- return safeOldValue + counter.get()
714
- }, 10)
715
-
716
- expect(computed.get()).toBe(11) // 10 + 1
717
-
718
- counter.set(5)
719
- expect(computed.get()).toBe(16) // 11 + 5
720
-
721
- // Trigger error
722
- shouldError.set(true)
723
- expect(() => computed.get()).toThrow('Computation failed')
724
-
725
- // Recover from error
726
- shouldError.set(false)
727
- counter.set(2)
728
-
729
- // After error, oldValue should be UNSET, so we treat it as 0 and get 0 + 2 = 2
730
- expect(computed.get()).toBe(2)
731
- })
732
-
733
- test('should work with complex state transitions using oldValue', () => {
734
- const action = new State<
735
- 'increment' | 'decrement' | 'reset' | 'multiply'
736
- >('increment')
737
- const amount = new State(1)
738
-
739
- const calculator = new Memo((oldValue: number) => {
740
- const act = action.get()
741
- const amt = amount.get()
742
-
743
- switch (act) {
744
- case 'increment':
745
- return oldValue + amt
746
- case 'decrement':
747
- return oldValue - amt
748
- case 'multiply':
749
- return oldValue * amt
750
- case 'reset':
751
- return 0
752
- default:
753
- return oldValue
754
- }
755
- }, 0)
756
-
757
- expect(calculator.get()).toBe(1) // 0 + 1
758
-
759
- amount.set(5)
760
- expect(calculator.get()).toBe(6) // 1 + 5
761
-
762
- action.set('multiply')
763
- amount.set(2)
764
- expect(calculator.get()).toBe(12) // 6 * 2
765
-
766
- action.set('decrement')
767
- amount.set(3)
768
- expect(calculator.get()).toBe(9) // 12 - 3
769
-
770
- action.set('reset')
771
- expect(calculator.get()).toBe(0)
772
- })
773
-
774
- test('should handle edge cases with initialValue and oldValue', () => {
775
- // Test with null/undefined-like values
776
- const nullishComputed = new Memo((oldValue: string) => {
777
- return `${oldValue} updated`
778
- }, '')
779
-
780
- expect(nullishComputed.get()).toBe(' updated')
781
-
782
- // Test with complex object initialValue
783
- interface StateObject {
784
- count: number
785
- items: string[]
786
- meta: { created: Date }
787
- }
788
-
789
- const now = new Date()
790
- const objectComputed = new Memo(
791
- (oldValue: StateObject) => ({
792
- ...oldValue,
793
- count: oldValue.count + 1,
794
- items: [...oldValue.items, `item${oldValue.count + 1}`],
795
- }),
796
- {
797
- count: 0,
798
- items: [] as string[],
799
- meta: { created: now },
800
- },
801
- )
802
-
803
- const result = objectComputed.get()
804
- expect(result.count).toBe(1)
805
- expect(result.items).toEqual(['item1'])
806
- expect(result.meta.created).toBe(now)
807
- })
808
-
809
- test('should preserve initialValue type consistency', () => {
810
- // Test that oldValue type is consistent with initialValue
811
- const stringComputed = new Memo((oldValue: string) => {
812
- expect(typeof oldValue).toBe('string')
813
- return oldValue.toUpperCase()
814
- }, 'hello')
815
-
816
- expect(stringComputed.get()).toBe('HELLO')
817
-
818
- const numberComputed = new Memo((oldValue: number) => {
819
- expect(typeof oldValue).toBe('number')
820
- expect(Number.isFinite(oldValue)).toBe(true)
821
- return oldValue * 2
822
- }, 5)
823
-
824
- expect(numberComputed.get()).toBe(10)
825
- })
826
-
827
- test('should work with chained computed using oldValue', () => {
828
- const source = new State(1)
829
-
830
- const first = new Memo(
831
- (oldValue: number) => oldValue + source.get(),
832
- 10,
833
- )
834
-
835
- const second = new Memo(
836
- (oldValue: number) => oldValue + first.get(),
837
- 20,
838
- )
839
-
840
- expect(first.get()).toBe(11) // 10 + 1
841
- expect(second.get()).toBe(31) // 20 + 11
842
-
843
- source.set(5)
844
- expect(first.get()).toBe(16) // 11 + 5
845
- expect(second.get()).toBe(47) // 31 + 16
846
- })
847
-
848
- test('should handle frequent updates with oldValue correctly', () => {
849
- const trigger = new State(0)
850
- let computationCount = 0
851
-
852
- const accumulator = new Memo((oldValue: number) => {
853
- computationCount++
854
- return oldValue + trigger.get()
855
- }, 100)
856
-
857
- expect(accumulator.get()).toBe(100) // 100 + 0
858
- expect(computationCount).toBe(1)
859
-
860
- // Make rapid changes
861
- for (let i = 1; i <= 5; i++) {
862
- trigger.set(i)
863
- accumulator.get() // Force evaluation
864
- }
865
-
866
- expect(computationCount).toBe(6) // Initial + 5 updates
867
- expect(accumulator.get()).toBe(115) // Final accumulated value
868
- })
869
- })
870
-
871
- describe('HOOK_WATCH - Lazy Resource Management', () => {
872
- test('Memo - should manage external resources lazily', async () => {
873
- const source = new State(1)
874
- let counter = 0
875
- let intervalId: Timer | undefined
876
-
877
- // Create memo that depends on source
878
- const computed = new Memo((oldValue: number) => {
879
- return source.get() * 2 + (oldValue || 0)
880
- }, 0)
881
-
882
- // Add HOOK_WATCH callback that starts interval
883
- const cleanupHookCallback = computed.on(HOOK_WATCH, () => {
884
- intervalId = setInterval(() => {
885
- counter++
886
- }, 10) // Fast interval for testing
887
-
888
- // Return cleanup function
889
- return () => {
890
- if (intervalId) {
891
- clearInterval(intervalId)
892
- intervalId = undefined
893
- }
894
- }
895
- })
896
-
897
- // Counter should not be running yet
898
- expect(counter).toBe(0)
899
- await wait(50)
900
- expect(counter).toBe(0)
901
- expect(intervalId).toBeUndefined()
902
-
903
- // Effect subscribes to computed, triggering HOOK_WATCH
904
- const effectCleanup = createEffect(() => {
905
- computed.get()
906
- })
907
-
908
- // Counter should now be running
909
- await wait(50)
910
- expect(counter).toBeGreaterThan(0)
911
- expect(intervalId).toBeDefined()
912
-
913
- // Stop effect, should cleanup resources
914
- effectCleanup()
915
- const counterAfterStop = counter
916
-
917
- // Counter should stop incrementing
918
- await wait(50)
919
- expect(counter).toBe(counterAfterStop)
920
- expect(intervalId).toBeUndefined()
921
-
922
- // Cleanup
923
- cleanupHookCallback()
924
- })
925
-
926
- test('Task - should manage external resources lazily', async () => {
927
- const source = new State('initial')
928
- let counter = 0
929
- let intervalId: Timer | undefined
930
-
931
- // Create task that depends on source
932
- const computed = new Task(
933
- async (oldValue: string, abort: AbortSignal) => {
934
- const value = source.get()
935
- await wait(10) // Simulate async work
936
-
937
- if (abort.aborted) throw new Error('Aborted')
938
-
939
- return `${value}-processed-${oldValue || 'none'}`
940
- },
941
- 'default',
942
- )
943
-
944
- // Add HOOK_WATCH callback
945
- const cleanupHookCallback = computed.on(HOOK_WATCH, () => {
946
- intervalId = setInterval(() => {
947
- counter++
948
- }, 10)
949
-
950
- return () => {
951
- if (intervalId) {
952
- clearInterval(intervalId)
953
- intervalId = undefined
954
- }
955
- }
956
- })
957
-
958
- // Counter should not be running yet
959
- expect(counter).toBe(0)
960
- await wait(50)
961
- expect(counter).toBe(0)
962
- expect(intervalId).toBeUndefined()
963
-
964
- // Effect subscribes to computed
965
- const effectCleanup = createEffect(() => {
966
- computed.get()
967
- })
968
-
969
- // Wait for async computation and counter to start
970
- await wait(100)
971
- expect(counter).toBeGreaterThan(0)
972
- expect(intervalId).toBeDefined()
973
-
974
- // Stop effect
975
- effectCleanup()
976
- const counterAfterStop = counter
977
-
978
- // Counter should stop incrementing
979
- await wait(50)
980
- expect(counter).toBe(counterAfterStop)
981
- expect(intervalId).toBeUndefined()
982
-
983
- // Cleanup
984
- cleanupHookCallback()
985
- })
986
-
987
- test('Memo - multiple watchers should share resources', async () => {
988
- const source = new State(10)
989
- let subscriptionCount = 0
990
-
991
- const computed = new Memo((oldValue: number) => {
992
- return source.get() + (oldValue || 0)
993
- }, 0)
994
-
995
- // HOOK_WATCH should only be called once for multiple watchers
996
- const cleanupHookCallback = computed.on(HOOK_WATCH, () => {
997
- subscriptionCount++
998
- return () => {
999
- subscriptionCount--
1000
- }
1001
- })
1002
-
1003
- expect(subscriptionCount).toBe(0)
1004
-
1005
- // Create multiple effects
1006
- const effect1 = createEffect(() => {
1007
- computed.get()
1008
- })
1009
- const effect2 = createEffect(() => {
1010
- computed.get()
1011
- })
1012
-
1013
- // Should only increment once
1014
- expect(subscriptionCount).toBe(1)
1015
-
1016
- // Stop first effect
1017
- effect1()
1018
- expect(subscriptionCount).toBe(1) // Still active due to second watcher
1019
-
1020
- // Stop second effect
1021
- effect2()
1022
- expect(subscriptionCount).toBe(0) // Now cleaned up
1023
-
1024
- // Cleanup
1025
- cleanupHookCallback()
1026
- })
1027
-
1028
- test('Task - should handle abort signals in external resources', async () => {
1029
- const source = new State('test')
1030
- const abortedControllers: AbortController[] = []
1031
-
1032
- const computed = new Task(
1033
- async (oldValue: string, abort: AbortSignal) => {
1034
- await wait(20)
1035
- if (abort.aborted) throw new Error('Aborted')
1036
- return `${source.get()}-${oldValue || 'initial'}`
1037
- },
1038
- 'default',
1039
- )
1040
-
1041
- // HOOK_WATCH that creates external resources with abort handling
1042
- const cleanupHookCallback = computed.on(HOOK_WATCH, () => {
1043
- const controller = new AbortController()
1044
-
1045
- // Simulate external async operation (catch rejections to avoid unhandled errors)
1046
- new Promise(resolve => {
1047
- const timeout = setTimeout(() => {
1048
- if (controller.signal.aborted) {
1049
- resolve('External operation aborted')
1050
- } else {
1051
- resolve('External operation completed')
1052
- }
1053
- }, 50)
1054
-
1055
- controller.signal.addEventListener('abort', () => {
1056
- clearTimeout(timeout)
1057
- resolve('External operation aborted')
1058
- })
1059
- }).catch(() => {
1060
- // Ignore promise rejections in test
1061
- })
1062
-
1063
- return () => {
1064
- controller.abort()
1065
- abortedControllers.push(controller)
1066
- }
1067
- })
1068
-
1069
- const effect1 = createEffect(() => {
1070
- computed.get()
1071
- })
1072
-
1073
- // Change source to trigger recomputation
1074
- source.set('updated')
1075
-
1076
- // Stop effect to trigger cleanup
1077
- effect1()
1078
-
1079
- // Wait for cleanup to complete
1080
- await wait(100)
1081
-
1082
- // Should have aborted external controllers
1083
- expect(abortedControllers.length).toBeGreaterThan(0)
1084
- expect(abortedControllers[0].signal.aborted).toBe(true)
1085
-
1086
- // Cleanup
1087
- cleanupHookCallback()
1088
- })
1089
-
1090
- test('Exception handling in computed HOOK_WATCH callbacks', async () => {
1091
- const source = new State(1)
1092
- const computed = new Memo(() => source.get() * 2)
1093
-
1094
- let successfulCallbackCalled = false
1095
- let throwingCallbackCalled = false
1096
-
1097
- // Add throwing callback
1098
- const cleanup1 = computed.on(HOOK_WATCH, () => {
1099
- throwingCallbackCalled = true
1100
- throw new Error('Test error in computed HOOK_WATCH')
1101
- })
1102
-
1103
- // Add successful callback
1104
- const cleanup2 = computed.on(HOOK_WATCH, () => {
1105
- successfulCallbackCalled = true
1106
- return () => {
1107
- // cleanup
1108
- }
1109
- })
1110
-
1111
- // Trigger callbacks - should throw due to exception in callback
1112
- expect(() => computed.get()).toThrow(
1113
- 'Test error in computed HOOK_WATCH',
1114
- )
1115
-
1116
- // Throwing callback should have been called
1117
- expect(throwingCallbackCalled).toBe(true)
1118
- // Successful callback should also have been called (resilient collection)
1119
- expect(successfulCallbackCalled).toBe(true)
1120
-
1121
- // Cleanup
1122
- cleanup1()
1123
- cleanup2()
1124
- })
1125
- })
1126
- })