@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,13 +1,12 @@
1
1
  import { describe, expect, mock, test } from 'bun:test'
2
2
  import {
3
3
  createEffect,
4
- isAbortError,
5
- Memo,
4
+ createMemo,
5
+ createScope,
6
+ createState,
7
+ createTask,
6
8
  match,
7
- resolve,
8
- State,
9
- Task,
10
- UNSET,
9
+ RequiredOwnerError,
11
10
  } from '../index.ts'
12
11
 
13
12
  /* === Utility Functions === */
@@ -16,797 +15,395 @@ const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
16
15
 
17
16
  /* === Tests === */
18
17
 
19
- describe('Effect', () => {
20
- test('should be triggered after a state change', () => {
21
- const cause = new State('foo')
22
- let count = 0
18
+ describe('createEffect', () => {
19
+ test('should run immediately on creation', () => {
20
+ let ran = false
23
21
  createEffect(() => {
24
- cause.get()
25
- count++
22
+ ran = true
26
23
  })
27
- expect(count).toBe(1)
28
- cause.set('bar')
29
- expect(count).toBe(2)
24
+ expect(ran).toBe(true)
30
25
  })
31
26
 
32
- test('should be triggered after computed async signals resolve without waterfalls', async () => {
33
- const a = new Task(async () => {
34
- await wait(100)
35
- return 10
36
- })
37
- const b = new Task(async () => {
38
- await wait(100)
39
- return 20
40
- })
41
- let result = 0
27
+ test('should re-run when a tracked dependency changes', () => {
28
+ const source = createState('foo')
42
29
  let count = 0
43
30
  createEffect(() => {
44
- const resolved = resolve({ a, b })
45
- match(resolved, {
46
- ok: ({ a: aValue, b: bValue }) => {
47
- result = aValue + bValue
48
- count++
49
- },
50
- })
31
+ source.get()
32
+ count++
51
33
  })
52
- expect(result).toBe(0)
53
- expect(count).toBe(0)
54
- await wait(110)
55
- expect(result).toBe(30)
56
34
  expect(count).toBe(1)
35
+ source.set('bar')
36
+ expect(count).toBe(2)
57
37
  })
58
38
 
59
- test('should be triggered repeatedly after repeated state change', async () => {
60
- const cause = new State(0)
39
+ test('should re-run on each state change', () => {
40
+ const source = createState(0)
61
41
  let result = 0
62
- let count = 0
63
42
  createEffect(() => {
64
- result = cause.get()
65
- count++
43
+ result = source.get()
66
44
  })
67
- for (let i = 0; i < 10; i++) {
68
- cause.set(i)
45
+ for (let i = 1; i <= 5; i++) {
46
+ source.set(i)
69
47
  expect(result).toBe(i)
70
- expect(count).toBe(i + 1) // + 1 for effect initialization
71
48
  }
72
49
  })
73
50
 
74
- test('should handle errors in effects with resolve handlers', () => {
75
- const a = new State(1)
76
- const b = new Memo(() => {
77
- const v = a.get()
78
- if (v > 5) throw new Error('Value too high')
79
- return v * 2
80
- })
81
- let normalCallCount = 0
82
- let errorCallCount = 0
51
+ test('should handle state updates inside effects', () => {
52
+ const count = createState(0)
53
+ let effectCount = 0
83
54
  createEffect(() => {
84
- const resolved = resolve({ b })
85
- match(resolved, {
86
- ok: () => {
87
- normalCallCount++
88
- },
89
- err: errors => {
90
- errorCallCount++
91
- expect(errors[0].message).toBe('Value too high')
92
- },
93
- })
55
+ effectCount++
56
+ if (count.get() === 0) count.set(1)
94
57
  })
58
+ expect(count.get()).toBe(1)
59
+ expect(effectCount).toBe(2)
60
+ })
95
61
 
96
- // Normal case
97
- a.set(2)
98
- expect(normalCallCount).toBe(2)
99
- expect(errorCallCount).toBe(0)
62
+ describe('Cleanup', () => {
63
+ test('should call cleanup before next run', () => {
64
+ const source = createState(0)
65
+ let cleanupCount = 0
66
+ let effectCount = 0
100
67
 
101
- // Error case
102
- a.set(6)
103
- expect(normalCallCount).toBe(2)
104
- expect(errorCallCount).toBe(1)
68
+ createEffect(() => {
69
+ source.get()
70
+ effectCount++
71
+ return () => {
72
+ cleanupCount++
73
+ }
74
+ })
105
75
 
106
- // Back to normal
107
- a.set(3)
108
- expect(normalCallCount).toBe(3)
109
- expect(errorCallCount).toBe(1)
110
- })
76
+ expect(effectCount).toBe(1)
77
+ expect(cleanupCount).toBe(0)
111
78
 
112
- test('should handle errors in effects with resolve result', () => {
113
- const a = new State(1)
114
- const b = new Memo(() => {
115
- const v = a.get()
116
- if (v > 5) throw new Error('Value too high')
117
- return v * 2
118
- })
119
- let normalCallCount = 0
120
- let errorCallCount = 0
121
- createEffect(() => {
122
- const result = resolve({ b })
123
- if (result.ok) {
124
- normalCallCount++
125
- } else if (result.errors) {
126
- errorCallCount++
127
- expect(result.errors[0].message).toBe('Value too high')
128
- }
129
- })
79
+ source.set(1)
80
+ expect(effectCount).toBe(2)
81
+ expect(cleanupCount).toBe(1)
130
82
 
131
- // Normal case
132
- a.set(2)
133
- expect(normalCallCount).toBe(2)
134
- expect(errorCallCount).toBe(0)
83
+ source.set(2)
84
+ expect(effectCount).toBe(3)
85
+ expect(cleanupCount).toBe(2)
86
+ })
135
87
 
136
- // Error case
137
- a.set(6)
138
- expect(normalCallCount).toBe(2)
139
- expect(errorCallCount).toBe(1)
88
+ test('should call cleanup on disposal', () => {
89
+ const source = createState(0)
90
+ let cleanupCalled = false
140
91
 
141
- // Back to normal
142
- a.set(3)
143
- expect(normalCallCount).toBe(3)
144
- expect(errorCallCount).toBe(1)
145
- })
92
+ const dispose = createEffect(() => {
93
+ source.get()
94
+ return () => {
95
+ cleanupCalled = true
96
+ }
97
+ })
146
98
 
147
- test('should handle UNSET values in effects with resolve handlers', async () => {
148
- const a = new Task(async () => {
149
- await wait(100)
150
- return 42
99
+ expect(cleanupCalled).toBe(false)
100
+ dispose()
101
+ expect(cleanupCalled).toBe(true)
151
102
  })
152
- let normalCallCount = 0
153
- let nilCount = 0
154
- createEffect(() => {
155
- const resolved = resolve({ a })
156
- match(resolved, {
157
- ok: values => {
158
- normalCallCount++
159
- expect(values.a).toBe(42)
160
- },
161
- nil: () => {
162
- nilCount++
163
- },
103
+
104
+ test('should stop reacting after disposal', () => {
105
+ const source = createState(42)
106
+ let received = 0
107
+
108
+ const dispose = createEffect(() => {
109
+ received = source.get()
164
110
  })
165
- })
166
111
 
167
- expect(normalCallCount).toBe(0)
168
- expect(nilCount).toBe(1)
169
- expect(a.get()).toBe(UNSET)
170
- await wait(110)
171
- expect(normalCallCount).toBeGreaterThan(0)
172
- expect(nilCount).toBe(1)
173
- expect(a.get()).toBe(42)
174
- })
112
+ source.set(43)
113
+ expect(received).toBe(43)
175
114
 
176
- test('should handle UNSET values in effects with resolve result', async () => {
177
- const a = new Task(async () => {
178
- await wait(100)
179
- return 42
180
- })
181
- let normalCallCount = 0
182
- let nilCount = 0
183
- createEffect(() => {
184
- const result = resolve({ a })
185
- if (result.ok) {
186
- normalCallCount++
187
- expect(result.values.a).toBe(42)
188
- } else if (result.pending) {
189
- nilCount++
190
- }
115
+ dispose()
116
+ source.set(44)
117
+ expect(received).toBe(43)
191
118
  })
192
-
193
- expect(normalCallCount).toBe(0)
194
- expect(nilCount).toBe(1)
195
- expect(a.get()).toBe(UNSET)
196
- await wait(110)
197
- expect(normalCallCount).toBeGreaterThan(0)
198
- expect(nilCount).toBe(1)
199
- expect(a.get()).toBe(42)
200
119
  })
201
120
 
202
- test('should log error to console when error is not handled', () => {
203
- // Mock console.error
204
- const originalConsoleError = console.error
205
- const mockConsoleError = mock(() => {})
206
- console.error = mockConsoleError
121
+ describe('Owner Registration', () => {
122
+ test('should dispose nested effects when parent scope is disposed', () => {
123
+ const source = createState(0)
124
+ let innerRuns = 0
207
125
 
208
- try {
209
- const a = new State(1)
210
- const b = new Memo(() => {
211
- const v = a.get()
212
- if (v > 5) throw new Error('Value too high')
213
- return v * 2
126
+ const dispose = createScope(() => {
127
+ createEffect(() => {
128
+ source.get()
129
+ innerRuns++
130
+ })
214
131
  })
215
132
 
216
- // Create an effect without explicit error handling
217
- createEffect(() => {
218
- b.get()
219
- })
133
+ expect(innerRuns).toBe(1)
134
+ source.set(1)
135
+ expect(innerRuns).toBe(2)
220
136
 
221
- // This should trigger the error
222
- a.set(6)
137
+ dispose()
138
+ source.set(2)
139
+ expect(innerRuns).toBe(2) // no longer reacting
140
+ })
141
+ })
223
142
 
224
- // Check if console.error was called with the error message
225
- expect(mockConsoleError).toHaveBeenCalledWith(
226
- 'Error in effect callback:',
227
- expect.any(Error),
143
+ describe('Input Validation', () => {
144
+ test('should throw InvalidCallbackError for non-function', () => {
145
+ // @ts-expect-error - Testing invalid input
146
+ expect(() => createEffect(null)).toThrow(
147
+ '[Effect] Callback null is invalid',
228
148
  )
229
- } finally {
230
- // Restore the original console.error
231
- console.error = originalConsoleError
232
- }
149
+ // @ts-expect-error - Testing invalid input
150
+ expect(() => createEffect(42)).toThrow(
151
+ '[Effect] Callback 42 is invalid',
152
+ )
153
+ })
233
154
  })
155
+ })
234
156
 
235
- test('should clean up subscriptions when disposed', () => {
236
- const count = new State(42)
237
- let received = 0
157
+ describe('match', () => {
158
+ test('should call ok handler when all signals have values', () => {
159
+ const a = createState(1)
160
+ const b = createState(2)
161
+ let result = 0
162
+ createEffect(() =>
163
+ match([a, b], {
164
+ ok: ([aVal, bVal]) => {
165
+ result = aVal + bVal
166
+ },
167
+ }),
168
+ )
169
+ expect(result).toBe(3)
170
+ })
238
171
 
239
- const cleanup = createEffect(() => {
240
- received = count.get()
172
+ test('should call nil handler when signals are unset', async () => {
173
+ const task = createTask(async () => {
174
+ await wait(50)
175
+ return 42
241
176
  })
177
+ let okCount = 0
178
+ let nilCount = 0
179
+ createEffect(() =>
180
+ match([task], {
181
+ ok: ([value]) => {
182
+ okCount++
183
+ expect(value).toBe(42)
184
+ },
185
+ nil: () => {
186
+ nilCount++
187
+ },
188
+ }),
189
+ )
242
190
 
243
- count.set(43)
244
- expect(received).toBe(43)
245
-
246
- cleanup()
247
- count.set(44)
248
- expect(received).toBe(43) // Should not update after dispose
191
+ expect(okCount).toBe(0)
192
+ expect(nilCount).toBe(1)
193
+ await wait(60)
194
+ expect(okCount).toBeGreaterThan(0)
195
+ expect(nilCount).toBe(1)
249
196
  })
250
197
 
251
- test('should detect and throw error for circular dependencies in effects', () => {
198
+ test('should call err handler when signals throw', () => {
199
+ const a = createState(1)
200
+ const b = createMemo(() => {
201
+ if (a.get() > 5) throw new Error('Too high')
202
+ return a.get() * 2
203
+ })
252
204
  let okCount = 0
253
205
  let errCount = 0
254
- const count = new State(0)
255
-
256
- createEffect(() => {
257
- const resolved = resolve({ count })
258
- match(resolved, {
206
+ createEffect(() =>
207
+ match([b], {
259
208
  ok: () => {
260
209
  okCount++
261
- // This effect updates the signal it depends on, creating a circular dependency
262
- count.update(v => ++v)
263
210
  },
264
211
  err: errors => {
265
212
  errCount++
266
- expect(errors[0]).toBeInstanceOf(Error)
267
- expect(errors[0].message).toBe(
268
- 'Circular dependency detected in effect',
269
- )
213
+ expect(errors[0].message).toBe('Too high')
270
214
  },
271
- })
272
- })
215
+ }),
216
+ )
273
217
 
274
- // Verify that the count was changed only once due to the circular dependency error
275
- expect(count.get()).toBe(1)
276
218
  expect(okCount).toBe(1)
219
+ a.set(6)
277
220
  expect(errCount).toBe(1)
278
- })
279
- })
280
-
281
- describe('Effect - Async with AbortSignal', () => {
282
- test('should pass AbortSignal to async effect callback', async () => {
283
- let abortSignalReceived = false
284
- let effectCompleted = false
285
-
286
- createEffect(async (abort: AbortSignal) => {
287
- expect(abort).toBeInstanceOf(AbortSignal)
288
- expect(abort.aborted).toBe(false)
289
- abortSignalReceived = true
290
-
291
- await wait(50)
292
- effectCompleted = true
293
- return () => {}
294
- })
295
-
296
- expect(abortSignalReceived).toBe(true)
297
- await wait(60)
298
- expect(effectCompleted).toBe(true)
299
- })
300
-
301
- test('should abort async operations when signal changes', async () => {
302
- const testSignal = new State(1)
303
- let operationAborted = false
304
- let operationCompleted = false
305
- let abortReason: DOMException | undefined
306
-
307
- createEffect(async abort => {
308
- const result = resolve({ testSignal })
309
- if (!result.ok) return
310
-
311
- abort.addEventListener('abort', () => {
312
- operationAborted = true
313
- abortReason = abort.reason
314
- })
315
-
316
- try {
317
- await wait(100)
318
- operationCompleted = true
319
- } catch (error) {
320
- if (
321
- error instanceof DOMException &&
322
- error.name === 'AbortError'
323
- ) {
324
- operationAborted = true
325
- }
326
- }
327
- })
328
-
329
- // Change signal quickly to trigger abort
330
- await wait(20)
331
- testSignal.set(2)
332
-
333
- await wait(50)
334
- expect(operationAborted).toBe(true)
335
- expect(operationCompleted).toBe(false)
336
- expect(abortReason instanceof DOMException).toBe(true)
337
- expect((abortReason as DOMException).name).toBe('AbortError')
338
- })
339
-
340
- test('should abort async operations on effect cleanup', async () => {
341
- let operationAborted = false
342
- let abortReason: DOMException | undefined
343
-
344
- const cleanup = createEffect(async abort => {
345
- abort.addEventListener('abort', () => {
346
- operationAborted = true
347
- abortReason = abort.reason
348
- })
349
-
350
- await wait(100)
351
- })
352
221
 
353
- await wait(20)
354
- cleanup()
355
-
356
- await wait(30)
357
- expect(operationAborted).toBe(true)
358
- expect(abortReason instanceof DOMException).toBe(true)
359
- expect((abortReason as DOMException).name).toBe('AbortError')
222
+ a.set(3)
223
+ expect(okCount).toBe(2)
224
+ expect(errCount).toBe(1)
360
225
  })
361
226
 
362
- test('should handle AbortError gracefully without logging to console', async () => {
227
+ test('should fall back to console.error when err handler is not provided', () => {
363
228
  const originalConsoleError = console.error
364
229
  const mockConsoleError = mock(() => {})
365
230
  console.error = mockConsoleError
366
231
 
367
232
  try {
368
- const testSignal = new State(1)
369
-
370
- createEffect(async abort => {
371
- const result = resolve({ testSignal })
372
- if (!result.ok) return
373
-
374
- try {
375
- await new Promise((resolve, reject) => {
376
- const timeout = setTimeout(resolve, 100)
377
- abort.addEventListener('abort', () => {
378
- clearTimeout(timeout)
379
- reject(new DOMException('Aborted', 'AbortError'))
380
- })
381
- })
382
- } catch (error) {
383
- if (
384
- error instanceof DOMException &&
385
- error.name === 'AbortError'
386
- ) {
387
- // This is expected, should not be logged
388
- return
389
- } else {
390
- throw error
391
- }
392
- }
233
+ const a = createState(1)
234
+ const b = createMemo(() => {
235
+ if (a.get() > 5) throw new Error('Too high')
236
+ return a.get() * 2
393
237
  })
394
238
 
395
- await wait(20)
396
- testSignal.set(2)
397
- await wait(50)
398
-
399
- // Should not have logged the AbortError
400
- expect(mockConsoleError).not.toHaveBeenCalledWith(
401
- 'Effect callback error:',
402
- expect.any(DOMException),
239
+ createEffect(() =>
240
+ match([b], {
241
+ ok: () => {},
242
+ }),
403
243
  )
244
+
245
+ a.set(6)
246
+ expect(mockConsoleError).toHaveBeenCalled()
404
247
  } finally {
405
248
  console.error = originalConsoleError
406
249
  }
407
250
  })
408
251
 
409
- test('should handle async effects that return cleanup functions', async () => {
410
- let asyncEffectCompleted = false
411
- let cleanupRegistered = false
412
- const testSignal = new State('initial')
413
-
414
- const cleanup = createEffect(async () => {
415
- const result = resolve({ testSignal })
416
- if (!result.ok) return
417
-
418
- await wait(30)
419
- asyncEffectCompleted = true
420
- return () => {
421
- cleanupRegistered = true
422
- }
423
- })
424
-
425
- // Wait for async effect to complete
426
- await wait(50)
427
- expect(asyncEffectCompleted).toBe(true)
428
-
429
- cleanup()
430
- expect(cleanupRegistered).toBe(true)
431
- expect(cleanup).toBeInstanceOf(Function)
432
- })
433
-
434
- test('should handle rapid signal changes with concurrent async operations', async () => {
435
- const testSignal = new State(0)
436
- let completedOperations = 0
437
- let abortedOperations = 0
438
-
439
- createEffect(async abort => {
440
- const result = resolve({ testSignal })
441
- if (!result.ok) return
442
-
443
- try {
444
- await wait(30)
445
- if (!abort.aborted) {
446
- completedOperations++
447
- }
448
- } catch (error) {
449
- if (
450
- error instanceof DOMException &&
451
- error.name === 'AbortError'
452
- ) {
453
- abortedOperations++
454
- }
455
- }
456
- })
457
-
458
- // Rapidly change signal multiple times
459
- testSignal.set(1)
460
- await wait(5)
461
- testSignal.set(2)
462
- await wait(5)
463
- testSignal.set(3)
464
- await wait(5)
465
- testSignal.set(4)
466
-
467
- // Wait for all operations to complete or abort
468
- await wait(60)
469
-
470
- // Only the last operation should complete
471
- expect(completedOperations).toBe(1)
472
- expect(abortedOperations).toBe(0) // AbortError is handled gracefully, not thrown
252
+ test('should throw RequiredOwnerError when called outside an owner', () => {
253
+ expect(() => match([], { ok: () => {} })).toThrow(RequiredOwnerError)
473
254
  })
474
255
 
475
- test('should handle async errors that are not AbortError', async () => {
476
- const originalConsoleError = console.error
477
- const mockConsoleError = mock(() => {})
478
- console.error = mockConsoleError
479
-
480
- try {
481
- const testSignal = new State(1)
482
-
483
- const errorThrower = new Memo(() => {
484
- const value = testSignal.get()
485
- if (value > 5) throw new Error('Value too high')
486
- return value
487
- })
488
-
489
- createEffect(async () => {
490
- const result = resolve({ errorThrower })
491
- if (result.ok) {
492
- // Normal operation
493
- } else if (result.errors) {
494
- // Handle errors from resolve
495
- expect(result.errors[0].message).toBe('Value too high')
496
- return
497
- }
498
-
499
- // Simulate an async error that's not an AbortError
500
- if (result.ok && result.values.errorThrower > 3) {
501
- throw new Error('Async processing error')
502
- }
503
- })
504
-
505
- testSignal.set(4) // This will cause an async error
256
+ test('should resolve multiple async tasks without waterfalls', async () => {
257
+ const a = createTask(async () => {
506
258
  await wait(20)
507
-
508
- // Should have logged the async error
509
- expect(mockConsoleError).toHaveBeenCalledWith(
510
- 'Error in async effect callback:',
511
- expect.any(Error),
512
- )
513
- } finally {
514
- console.error = originalConsoleError
515
- }
516
- })
517
-
518
- test('should handle promise-based async effects', async () => {
519
- let promiseResolved = false
520
- let effectValue = ''
521
- const testSignal = new State('test-value')
522
-
523
- createEffect(async abort => {
524
- const result = resolve({ testSignal })
525
- if (!result.ok) return
526
-
527
- // Simulate async work that respects abort signal
528
- await new Promise<void>((resolve, reject) => {
529
- const timeout = setTimeout(() => {
530
- effectValue = result.values.testSignal
531
- promiseResolved = true
532
- resolve()
533
- }, 40)
534
-
535
- abort.addEventListener('abort', () => {
536
- clearTimeout(timeout)
537
- reject(new DOMException('Aborted', 'AbortError'))
538
- })
539
- })
540
-
541
- return () => {
542
- // Cleanup function
543
- }
259
+ return 10
544
260
  })
545
-
546
- await wait(60)
547
- expect(promiseResolved).toBe(true)
548
- expect(effectValue).toBe('test-value')
549
- })
550
-
551
- test('should not create AbortController for sync functions', () => {
552
- const testSignal = new State('test')
553
- let syncCallCount = 0
554
-
555
- // Mock AbortController constructor to detect if it's called
556
- const originalAbortController = globalThis.AbortController
557
- let abortControllerCreated = false
558
-
559
- globalThis.AbortController = class extends originalAbortController {
560
- constructor() {
561
- super()
562
- abortControllerCreated = true
563
- }
564
- }
565
-
566
- try {
567
- createEffect(() => {
568
- const result = resolve({ testSignal })
569
- if (result.ok) {
570
- syncCallCount++
571
- }
572
- })
573
-
574
- testSignal.set('changed')
575
- expect(syncCallCount).toBe(2)
576
- expect(abortControllerCreated).toBe(false)
577
- } finally {
578
- globalThis.AbortController = originalAbortController
579
- }
580
- })
581
-
582
- test('should handle concurrent async operations with abort', async () => {
583
- const testSignal = new State(1)
584
- let operation1Completed = false
585
- let operation1Aborted = false
586
-
587
- createEffect(async abort => {
588
- const result = resolve({ testSignal })
589
- if (!result.ok) return
590
-
591
- try {
592
- // Create a promise that can be aborted
593
- await new Promise<void>((resolve, reject) => {
594
- const timeout = setTimeout(() => {
595
- operation1Completed = true
596
- resolve()
597
- }, 80)
598
-
599
- abort.addEventListener('abort', () => {
600
- operation1Aborted = true
601
- clearTimeout(timeout)
602
- reject(new DOMException('Aborted', 'AbortError'))
603
- })
604
- })
605
- } catch (error) {
606
- if (
607
- error instanceof DOMException &&
608
- error.name === 'AbortError'
609
- ) {
610
- // Expected when aborted
611
- return
612
- }
613
- throw error
614
- }
261
+ const b = createTask(async () => {
262
+ await wait(20)
263
+ return 20
615
264
  })
616
-
617
- // Start first operation
618
- await wait(20)
619
-
620
- // Trigger second operation before first completes
621
- testSignal.set(2)
622
-
623
- // Wait a bit for abort to take effect
265
+ let result = 0
266
+ let nilCount = 0
267
+ createEffect(() =>
268
+ match([a, b], {
269
+ ok: ([aVal, bVal]) => {
270
+ result = aVal + bVal
271
+ },
272
+ nil: () => {
273
+ nilCount++
274
+ },
275
+ }),
276
+ )
277
+ expect(result).toBe(0)
278
+ expect(nilCount).toBe(1)
624
279
  await wait(30)
625
-
626
- expect(operation1Aborted).toBe(true)
627
- expect(operation1Completed).toBe(false)
280
+ expect(result).toBe(30)
628
281
  })
629
- })
630
282
 
631
- describe('Effect + Resolve Integration', () => {
632
- test('should work with resolve discriminated union', () => {
633
- const a = new State(10)
634
- const b = new State('hello')
635
- let effectRan = false
283
+ describe('Async Handlers', () => {
284
+ test('should not register cleanup from stale async handler after disposal', async () => {
285
+ let cleanupRegistered = false
286
+
287
+ const dispose = createEffect(() =>
288
+ match([], {
289
+ ok: async () => {
290
+ await wait(50)
291
+ return () => {
292
+ cleanupRegistered = true
293
+ }
294
+ },
295
+ }),
296
+ )
636
297
 
637
- createEffect(() => {
638
- const result = resolve({ a, b })
298
+ await wait(10)
299
+ dispose()
300
+ await wait(60)
639
301
 
640
- if (result.ok) {
641
- effectRan = true
642
- expect(result.values.a).toBe(10)
643
- expect(result.values.b).toBe('hello')
644
- }
302
+ expect(cleanupRegistered).toBe(false)
645
303
  })
646
304
 
647
- expect(effectRan).toBe(true)
648
- })
649
-
650
- test('should work with match function', () => {
651
- const a = new State(42)
652
- let matchedValue = 0
305
+ test('should register and run cleanup from completed async handler', async () => {
306
+ let cleanupCalled = false
307
+
308
+ const dispose = createEffect(() =>
309
+ match([], {
310
+ ok: async () => {
311
+ await wait(10)
312
+ return () => {
313
+ cleanupCalled = true
314
+ }
315
+ },
316
+ }),
317
+ )
653
318
 
654
- createEffect(() => {
655
- const result = resolve({ a })
656
- match(result, {
657
- ok: values => {
658
- matchedValue = values.a
659
- },
660
- })
319
+ await wait(20)
320
+ dispose()
321
+ expect(cleanupCalled).toBe(true)
661
322
  })
662
323
 
663
- expect(matchedValue).toBe(42)
664
- })
665
- })
666
-
667
- describe('Effect - Race Conditions and Consistency', () => {
668
- test('should handle race conditions between abort and cleanup properly', async () => {
669
- // This test explores potential race conditions in effect cleanup
670
- const testSignal = new State(0)
671
- let cleanupCallCount = 0
672
- let abortCallCount = 0
673
- let operationCount = 0
674
-
675
- createEffect(async abort => {
676
- testSignal.get()
677
- ++operationCount
678
-
679
- abort.addEventListener('abort', () => {
680
- abortCallCount++
681
- })
324
+ test('should route async errors to err handler', async () => {
325
+ const originalConsoleError = console.error
326
+ const mockConsoleError = mock(() => {})
327
+ console.error = mockConsoleError
682
328
 
683
329
  try {
684
- await wait(50)
685
- // This cleanup should only be registered if the operation wasn't aborted
686
- return () => {
687
- cleanupCallCount++
688
- }
689
- } catch (error) {
690
- if (!isAbortError(error)) throw error
330
+ const source = createState(1)
331
+
332
+ createEffect(() =>
333
+ match([source], {
334
+ ok: async ([value]) => {
335
+ await wait(10)
336
+ if (value > 3) throw new Error('Async error')
337
+ },
338
+ }),
339
+ )
340
+
341
+ source.set(4)
342
+ await wait(20)
343
+
344
+ expect(mockConsoleError).toHaveBeenCalled()
345
+ } finally {
346
+ console.error = originalConsoleError
691
347
  }
692
348
  })
693
349
 
694
- // Rapid signal changes to test race conditions
695
- testSignal.set(1)
696
- await wait(10)
697
- testSignal.set(2)
698
- await wait(10)
699
- testSignal.set(3)
700
- await wait(100) // Let all operations complete
701
-
702
- // Without proper abort handling, we might get multiple cleanups
703
- expect(cleanupCallCount).toBeLessThanOrEqual(1) // Should be at most 1
704
- expect(operationCount).toBeGreaterThan(1) // Should have multiple operations
705
- expect(abortCallCount).toBeGreaterThan(0) // Should have some aborts
706
- })
707
-
708
- test('should demonstrate difference in abort handling between computed and effect', async () => {
709
- // This test shows why computed needs an abort listener but effect might not
710
- const source = new State(1)
711
- let computedRetries = 0
712
- let effectRuns = 0
713
-
714
- // Computed with abort listener (current implementation)
715
- const comp = new Task(async () => {
716
- computedRetries++
717
- await wait(30)
718
- return source.get() * 2
719
- })
720
-
721
- // Effect without abort listener (current implementation)
722
- createEffect(async () => {
723
- effectRuns++
724
- // Must access the source to make effect reactive
725
- source.get()
726
- await wait(30)
727
- resolve({ comp })
728
- // Effect doesn't need to return a value immediately
729
- })
730
-
731
- // Change source rapidly
732
- source.set(2)
733
- await wait(10)
734
- source.set(3)
735
- await wait(50)
736
-
737
- // Computed should retry efficiently due to abort listener
738
- // Effect should handle the changes naturally through dependency tracking
739
- expect(computedRetries).toBeGreaterThan(0)
740
- expect(effectRuns).toBeGreaterThan(0)
741
- })
350
+ test('should discard stale async cleanup when effect re-runs', async () => {
351
+ const source = createState(1)
352
+ let staleCleanupCalled = false
353
+ let freshCleanupCalled = false
354
+
355
+ const dispose = createEffect(() =>
356
+ match([source], {
357
+ ok: async ([value]) => {
358
+ if (value === 1) {
359
+ await wait(80)
360
+ return () => {
361
+ staleCleanupCalled = true
362
+ }
363
+ }
364
+ await wait(10)
365
+ return () => {
366
+ freshCleanupCalled = true
367
+ }
368
+ },
369
+ }),
370
+ )
742
371
 
743
- test('should prevent stale cleanup registration with generation counter approach', async () => {
744
- // This test verifies that the currentController check prevents stale cleanups
745
- const testSignal = new State(0)
746
- let cleanupCallCount = 0
747
- let effectRunCount = 0
748
- let staleCleanupAttempts = 0
372
+ await wait(20)
373
+ source.set(2)
374
+ await wait(100)
749
375
 
750
- createEffect(async () => {
751
- effectRunCount++
752
- const currentRun = effectRunCount
753
- testSignal.get() // Make reactive
376
+ expect(staleCleanupCalled).toBe(false)
754
377
 
755
- try {
756
- await wait(60)
757
- // This cleanup should only be registered for the latest run
758
- return () => {
759
- cleanupCallCount++
760
- if (currentRun !== effectRunCount) {
761
- staleCleanupAttempts++
762
- }
763
- }
764
- } catch (error) {
765
- if (!isAbortError(error)) throw error
766
- return undefined
767
- }
378
+ dispose()
379
+ expect(freshCleanupCalled).toBe(true)
768
380
  })
769
381
 
770
- // Trigger multiple rapid changes
771
- testSignal.set(1)
772
- await wait(20)
773
- testSignal.set(2)
774
- await wait(20)
775
- testSignal.set(3)
776
- await wait(80) // Let final operation complete
777
-
778
- // Should have multiple runs but only one cleanup (from the last successful run)
779
- expect(effectRunCount).toBeGreaterThan(1)
780
- expect(cleanupCallCount).toBeLessThanOrEqual(1)
781
- expect(staleCleanupAttempts).toBe(0) // No stale cleanups should be registered
782
- })
382
+ test('should call async cleanup before re-running', async () => {
383
+ const source = createState(0)
384
+ let cleanupCount = 0
385
+ let okCount = 0
386
+
387
+ createEffect(() =>
388
+ match([source], {
389
+ ok: async () => {
390
+ okCount++
391
+ await wait(10)
392
+ return () => {
393
+ cleanupCount++
394
+ }
395
+ },
396
+ }),
397
+ )
783
398
 
784
- test('should demonstrate why computed needs immediate retry via abort listener', async () => {
785
- // This test shows the performance benefit of immediate retry in computed
786
- const source = new State(1)
787
- let computeAttempts = 0
788
- let finalValue: number = 0
399
+ await wait(20)
400
+ expect(okCount).toBe(1)
401
+ expect(cleanupCount).toBe(0)
789
402
 
790
- const comp = new Task(async () => {
791
- computeAttempts++
792
- await wait(30)
793
- return source.get() * 2
403
+ source.set(1)
404
+ expect(cleanupCount).toBe(1)
405
+ await wait(20)
406
+ expect(okCount).toBe(2)
794
407
  })
795
-
796
- // Start computation
797
- expect(comp.get()).toBe(UNSET)
798
-
799
- // Change source during computation - this should trigger immediate retry
800
- await wait(10)
801
- source.set(5)
802
-
803
- // Wait for computation to complete
804
- await wait(50)
805
- finalValue = comp.get()
806
-
807
- // The abort listener allows immediate retry, so we should get the latest value
808
- expect(finalValue).toBe(10) // 5 * 2
809
- // Note: The number of attempts can vary due to timing, but should get correct result
810
- expect(computeAttempts).toBeGreaterThanOrEqual(1)
811
408
  })
812
409
  })