@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.
@@ -1,5 +1,13 @@
1
1
  import { describe, expect, mock, test } from 'bun:test'
2
- import { computed, effect, state, UNSET } from '../'
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((): undefined => {
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
- signals: [a, b],
36
- ok: (aValue, bValue): undefined => {
37
- result = aValue + bValue
38
- count++
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((): undefined => {
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
- signals: [b],
74
- ok: (): undefined => {
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(error.message).toBe('Value too high')
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
- signals: [a],
110
- ok: (aValue: number): undefined => {
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(aValue).toBe(42)
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((): undefined => {
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(expect.any(Error))
152
-
153
- // Check the error message
154
- const error = (mockConsoleError as ReturnType<typeof mock>).mock
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((): undefined => {
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
- signals: [count],
186
- ok: (): undefined => {
187
- okCount++
188
- // This effect updates the signal it depends on, creating a circular dependency
189
- count.update(v => ++v)
190
- },
191
- err: (e): undefined => {
192
- errCount++
193
- expect(e).toBeInstanceOf(Error)
194
- expect(e.message).toBe('Circular dependency in effect detected')
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
+ })