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