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