@zeix/cause-effect 0.17.1 → 0.17.3
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/.ai-context.md +13 -0
- package/.github/copilot-instructions.md +4 -0
- package/.zed/settings.json +3 -0
- package/CLAUDE.md +41 -7
- package/README.md +48 -25
- package/archive/benchmark.ts +0 -5
- package/archive/collection.ts +6 -65
- package/archive/composite.ts +85 -0
- package/archive/computed.ts +18 -20
- package/archive/list.ts +7 -75
- package/archive/memo.ts +15 -15
- package/archive/state.ts +2 -1
- package/archive/store.ts +8 -78
- package/archive/task.ts +20 -25
- package/index.dev.js +508 -526
- package/index.js +1 -1
- package/index.ts +9 -11
- package/package.json +6 -6
- package/src/classes/collection.ts +70 -107
- package/src/classes/computed.ts +165 -149
- package/src/classes/list.ts +145 -107
- package/src/classes/ref.ts +19 -17
- package/src/classes/state.ts +21 -17
- package/src/classes/store.ts +125 -73
- package/src/diff.ts +2 -1
- package/src/effect.ts +17 -10
- package/src/errors.ts +14 -1
- package/src/resolve.ts +1 -1
- package/src/signal.ts +3 -2
- package/src/system.ts +159 -61
- package/src/util.ts +0 -6
- package/test/batch.test.ts +4 -11
- package/test/benchmark.test.ts +4 -2
- package/test/collection.test.ts +106 -107
- package/test/computed.test.ts +351 -112
- package/test/effect.test.ts +2 -2
- package/test/list.test.ts +62 -102
- package/test/ref.test.ts +128 -2
- package/test/state.test.ts +16 -22
- package/test/store.test.ts +101 -108
- package/test/util/dependency-graph.ts +2 -2
- package/tsconfig.build.json +11 -0
- package/tsconfig.json +5 -7
- package/types/index.d.ts +3 -3
- package/types/src/classes/collection.d.ts +9 -10
- package/types/src/classes/computed.d.ts +17 -20
- package/types/src/classes/list.d.ts +8 -6
- package/types/src/classes/ref.d.ts +8 -12
- package/types/src/classes/state.d.ts +5 -8
- package/types/src/classes/store.d.ts +14 -13
- package/types/src/effect.d.ts +1 -2
- package/types/src/errors.d.ts +2 -1
- package/types/src/signal.d.ts +3 -2
- package/types/src/system.d.ts +47 -34
- package/types/src/util.d.ts +1 -2
- package/src/classes/composite.ts +0 -176
- package/types/src/classes/composite.d.ts +0 -15
package/test/computed.test.ts
CHANGED
|
@@ -229,7 +229,7 @@ describe('Computed', () => {
|
|
|
229
229
|
test('should detect and throw error for circular dependencies', () => {
|
|
230
230
|
const a = new State(1)
|
|
231
231
|
const b = new Memo(() => c.get() + 1)
|
|
232
|
-
const c = new Memo(() => b.get() + a.get())
|
|
232
|
+
const c = new Memo((): number => b.get() + a.get())
|
|
233
233
|
expect(() => {
|
|
234
234
|
b.get() // This should trigger the circular dependency
|
|
235
235
|
}).toThrow('Circular dependency detected in memo')
|
|
@@ -268,7 +268,7 @@ describe('Computed', () => {
|
|
|
268
268
|
expect(a.get()).toBe(1)
|
|
269
269
|
expect(true).toBe(false) // This line should not be reached
|
|
270
270
|
} catch (error) {
|
|
271
|
-
expect(error.message).toBe('Calculation error')
|
|
271
|
+
expect((error as Error).message).toBe('Calculation error')
|
|
272
272
|
} finally {
|
|
273
273
|
expect(c.get()).toBe('c: recovered')
|
|
274
274
|
expect(okCount).toBe(2)
|
|
@@ -429,73 +429,49 @@ describe('Computed', () => {
|
|
|
429
429
|
expect(() => {
|
|
430
430
|
// @ts-expect-error - Testing invalid input
|
|
431
431
|
new Memo(null)
|
|
432
|
-
}).toThrow('Invalid
|
|
432
|
+
}).toThrow('Invalid Memo callback null')
|
|
433
433
|
|
|
434
434
|
expect(() => {
|
|
435
435
|
// @ts-expect-error - Testing invalid input
|
|
436
436
|
new Memo(undefined)
|
|
437
|
-
}).toThrow('Invalid
|
|
437
|
+
}).toThrow('Invalid Memo callback undefined')
|
|
438
438
|
|
|
439
439
|
expect(() => {
|
|
440
440
|
// @ts-expect-error - Testing invalid input
|
|
441
441
|
new Memo(42)
|
|
442
|
-
}).toThrow('Invalid
|
|
442
|
+
}).toThrow('Invalid Memo callback 42')
|
|
443
443
|
|
|
444
444
|
expect(() => {
|
|
445
445
|
// @ts-expect-error - Testing invalid input
|
|
446
446
|
new Memo('not a function')
|
|
447
|
-
}).toThrow('Invalid
|
|
447
|
+
}).toThrow('Invalid Memo callback "not a function"')
|
|
448
448
|
|
|
449
449
|
expect(() => {
|
|
450
450
|
// @ts-expect-error - Testing invalid input
|
|
451
451
|
new Memo({ not: 'a function' })
|
|
452
|
-
}).toThrow('Invalid
|
|
452
|
+
}).toThrow('Invalid Memo callback {"not":"a function"}')
|
|
453
453
|
|
|
454
454
|
expect(() => {
|
|
455
455
|
// @ts-expect-error - Testing invalid input
|
|
456
456
|
new Memo((_a: unknown, _b: unknown, _c: unknown) => 42)
|
|
457
|
-
}).toThrow('Invalid
|
|
457
|
+
}).toThrow('Invalid Memo callback (_a, _b, _c) => 42')
|
|
458
458
|
|
|
459
459
|
expect(() => {
|
|
460
460
|
// @ts-expect-error - Testing invalid input
|
|
461
461
|
new Memo(async (_a: unknown, _b: unknown) => 42)
|
|
462
|
-
}).toThrow('Invalid
|
|
462
|
+
}).toThrow('Invalid Memo callback async (_a, _b) => 42')
|
|
463
463
|
|
|
464
464
|
expect(() => {
|
|
465
465
|
// @ts-expect-error - Testing invalid input
|
|
466
466
|
new Task((_a: unknown) => 42)
|
|
467
|
-
}).toThrow('Invalid
|
|
467
|
+
}).toThrow('Invalid Task callback (_a) => 42')
|
|
468
468
|
})
|
|
469
469
|
|
|
470
|
-
test('should
|
|
470
|
+
test('should expect type error if null is passed for options.initialValue', () => {
|
|
471
471
|
expect(() => {
|
|
472
472
|
// @ts-expect-error - Testing invalid input
|
|
473
|
-
new Memo(() => 42, null)
|
|
474
|
-
}).toThrow(
|
|
475
|
-
})
|
|
476
|
-
|
|
477
|
-
test('should throw specific error types for invalid inputs', () => {
|
|
478
|
-
try {
|
|
479
|
-
// @ts-expect-error - Testing invalid input
|
|
480
|
-
new Memo(null)
|
|
481
|
-
expect(true).toBe(false) // Should not reach here
|
|
482
|
-
} catch (error) {
|
|
483
|
-
expect(error).toBeInstanceOf(TypeError)
|
|
484
|
-
expect(error.name).toBe('InvalidCallbackError')
|
|
485
|
-
expect(error.message).toBe('Invalid memo callback null')
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
try {
|
|
489
|
-
// @ts-expect-error - Testing invalid input
|
|
490
|
-
new Memo(() => 42, null)
|
|
491
|
-
expect(true).toBe(false) // Should not reach here
|
|
492
|
-
} catch (error) {
|
|
493
|
-
expect(error).toBeInstanceOf(TypeError)
|
|
494
|
-
expect(error.name).toBe('NullishSignalValueError')
|
|
495
|
-
expect(error.message).toBe(
|
|
496
|
-
'Nullish signal values are not allowed in memo',
|
|
497
|
-
)
|
|
498
|
-
}
|
|
473
|
+
new Memo(() => 42, { initialValue: null })
|
|
474
|
+
}).not.toThrow()
|
|
499
475
|
})
|
|
500
476
|
|
|
501
477
|
test('should allow valid callbacks and non-nullish initialValues', () => {
|
|
@@ -505,36 +481,43 @@ describe('Computed', () => {
|
|
|
505
481
|
}).not.toThrow()
|
|
506
482
|
|
|
507
483
|
expect(() => {
|
|
508
|
-
new Memo(() => 42, 0)
|
|
484
|
+
new Memo(() => 42, { initialValue: 0 })
|
|
509
485
|
}).not.toThrow()
|
|
510
486
|
|
|
511
487
|
expect(() => {
|
|
512
|
-
new Memo(() => 'foo', '')
|
|
488
|
+
new Memo(() => 'foo', { initialValue: '' })
|
|
513
489
|
}).not.toThrow()
|
|
514
490
|
|
|
515
491
|
expect(() => {
|
|
516
|
-
new Memo(() => true, false)
|
|
492
|
+
new Memo(() => true, { initialValue: false })
|
|
517
493
|
}).not.toThrow()
|
|
518
494
|
|
|
519
495
|
expect(() => {
|
|
520
|
-
new Task(async () => ({ id: 42, name: 'John' }),
|
|
496
|
+
new Task(async () => ({ id: 42, name: 'John' }), {
|
|
497
|
+
initialValue: UNSET,
|
|
498
|
+
})
|
|
521
499
|
}).not.toThrow()
|
|
522
500
|
})
|
|
523
501
|
})
|
|
524
502
|
|
|
525
503
|
describe('Initial Value and Old Value', () => {
|
|
526
504
|
test('should use initialValue when provided', () => {
|
|
527
|
-
const computed = new Memo((oldValue: number) => oldValue + 1,
|
|
505
|
+
const computed = new Memo((oldValue: number) => oldValue + 1, {
|
|
506
|
+
initialValue: 10,
|
|
507
|
+
})
|
|
528
508
|
expect(computed.get()).toBe(11)
|
|
529
509
|
})
|
|
530
510
|
|
|
531
511
|
test('should pass current value as oldValue to callback', () => {
|
|
532
512
|
const state = new State(5)
|
|
533
513
|
let receivedOldValue: number | undefined
|
|
534
|
-
const computed = new Memo(
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
514
|
+
const computed = new Memo(
|
|
515
|
+
(oldValue: number) => {
|
|
516
|
+
receivedOldValue = oldValue
|
|
517
|
+
return state.get() * 2
|
|
518
|
+
},
|
|
519
|
+
{ initialValue: 0 },
|
|
520
|
+
)
|
|
538
521
|
|
|
539
522
|
expect(computed.get()).toBe(10)
|
|
540
523
|
expect(receivedOldValue).toBe(0)
|
|
@@ -546,10 +529,13 @@ describe('Computed', () => {
|
|
|
546
529
|
|
|
547
530
|
test('should work as reducer function with oldValue', () => {
|
|
548
531
|
const increment = new State(0)
|
|
549
|
-
const sum = new Memo(
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
+
)
|
|
553
539
|
|
|
554
540
|
expect(sum.get()).toBe(0)
|
|
555
541
|
|
|
@@ -565,10 +551,13 @@ describe('Computed', () => {
|
|
|
565
551
|
|
|
566
552
|
test('should handle array accumulation with oldValue', () => {
|
|
567
553
|
const item = new State('')
|
|
568
|
-
const items = new Memo(
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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
|
+
)
|
|
572
561
|
|
|
573
562
|
expect(items.get()).toEqual([])
|
|
574
563
|
|
|
@@ -585,11 +574,16 @@ describe('Computed', () => {
|
|
|
585
574
|
test('should handle counter with oldValue and multiple dependencies', () => {
|
|
586
575
|
const reset = new State(false)
|
|
587
576
|
const add = new State(0)
|
|
588
|
-
const counter = new Memo(
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
+
)
|
|
593
587
|
|
|
594
588
|
expect(counter.get()).toBe(0)
|
|
595
589
|
|
|
@@ -622,11 +616,16 @@ describe('Computed', () => {
|
|
|
622
616
|
test('should work with async computation and oldValue', async () => {
|
|
623
617
|
let receivedOldValue: number | undefined
|
|
624
618
|
|
|
625
|
-
const asyncComputed = new Task(
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
+
)
|
|
630
629
|
|
|
631
630
|
// Initially returns initialValue before async computation completes
|
|
632
631
|
expect(asyncComputed.get()).toBe(10)
|
|
@@ -647,7 +646,7 @@ describe('Computed', () => {
|
|
|
647
646
|
if (k === '' || v === '') return oldValue
|
|
648
647
|
return { ...oldValue, [k]: v }
|
|
649
648
|
},
|
|
650
|
-
{} as Record<string, string
|
|
649
|
+
{ initialValue: {} as Record<string, string> },
|
|
651
650
|
)
|
|
652
651
|
|
|
653
652
|
expect(obj.get()).toEqual({})
|
|
@@ -681,7 +680,9 @@ describe('Computed', () => {
|
|
|
681
680
|
|
|
682
681
|
return source.get() + oldValue
|
|
683
682
|
},
|
|
684
|
-
|
|
683
|
+
{
|
|
684
|
+
initialValue: 0,
|
|
685
|
+
},
|
|
685
686
|
)
|
|
686
687
|
|
|
687
688
|
// Initial computation
|
|
@@ -703,14 +704,19 @@ describe('Computed', () => {
|
|
|
703
704
|
const shouldError = new State(false)
|
|
704
705
|
const counter = new State(1)
|
|
705
706
|
|
|
706
|
-
const computed = new Memo(
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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
|
+
)
|
|
714
720
|
|
|
715
721
|
expect(computed.get()).toBe(11) // 10 + 1
|
|
716
722
|
|
|
@@ -735,23 +741,28 @@ describe('Computed', () => {
|
|
|
735
741
|
>('increment')
|
|
736
742
|
const amount = new State(1)
|
|
737
743
|
|
|
738
|
-
const calculator = new Memo(
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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
|
+
)
|
|
755
766
|
|
|
756
767
|
expect(calculator.get()).toBe(1) // 0 + 1
|
|
757
768
|
|
|
@@ -772,9 +783,10 @@ describe('Computed', () => {
|
|
|
772
783
|
|
|
773
784
|
test('should handle edge cases with initialValue and oldValue', () => {
|
|
774
785
|
// Test with null/undefined-like values
|
|
775
|
-
const nullishComputed = new Memo(
|
|
776
|
-
|
|
777
|
-
|
|
786
|
+
const nullishComputed = new Memo(
|
|
787
|
+
oldValue => `${oldValue} updated`,
|
|
788
|
+
{ initialValue: '' },
|
|
789
|
+
)
|
|
778
790
|
|
|
779
791
|
expect(nullishComputed.get()).toBe(' updated')
|
|
780
792
|
|
|
@@ -793,9 +805,11 @@ describe('Computed', () => {
|
|
|
793
805
|
items: [...oldValue.items, `item${oldValue.count + 1}`],
|
|
794
806
|
}),
|
|
795
807
|
{
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
808
|
+
initialValue: {
|
|
809
|
+
count: 0,
|
|
810
|
+
items: [] as string[],
|
|
811
|
+
meta: { created: now },
|
|
812
|
+
},
|
|
799
813
|
},
|
|
800
814
|
)
|
|
801
815
|
|
|
@@ -807,18 +821,28 @@ describe('Computed', () => {
|
|
|
807
821
|
|
|
808
822
|
test('should preserve initialValue type consistency', () => {
|
|
809
823
|
// Test that oldValue type is consistent with initialValue
|
|
810
|
-
const stringComputed = new Memo(
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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
|
+
)
|
|
814
833
|
|
|
815
834
|
expect(stringComputed.get()).toBe('HELLO')
|
|
816
835
|
|
|
817
|
-
const numberComputed = new Memo(
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
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
|
+
)
|
|
822
846
|
|
|
823
847
|
expect(numberComputed.get()).toBe(10)
|
|
824
848
|
})
|
|
@@ -828,12 +852,16 @@ describe('Computed', () => {
|
|
|
828
852
|
|
|
829
853
|
const first = new Memo(
|
|
830
854
|
(oldValue: number) => oldValue + source.get(),
|
|
831
|
-
|
|
855
|
+
{
|
|
856
|
+
initialValue: 10,
|
|
857
|
+
},
|
|
832
858
|
)
|
|
833
859
|
|
|
834
860
|
const second = new Memo(
|
|
835
861
|
(oldValue: number) => oldValue + first.get(),
|
|
836
|
-
|
|
862
|
+
{
|
|
863
|
+
initialValue: 20,
|
|
864
|
+
},
|
|
837
865
|
)
|
|
838
866
|
|
|
839
867
|
expect(first.get()).toBe(11) // 10 + 1
|
|
@@ -848,10 +876,15 @@ describe('Computed', () => {
|
|
|
848
876
|
const trigger = new State(0)
|
|
849
877
|
let computationCount = 0
|
|
850
878
|
|
|
851
|
-
const accumulator = new Memo(
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
879
|
+
const accumulator = new Memo(
|
|
880
|
+
(oldValue: number) => {
|
|
881
|
+
computationCount++
|
|
882
|
+
return oldValue + trigger.get()
|
|
883
|
+
},
|
|
884
|
+
{
|
|
885
|
+
initialValue: 100,
|
|
886
|
+
},
|
|
887
|
+
)
|
|
855
888
|
|
|
856
889
|
expect(accumulator.get()).toBe(100) // 100 + 0
|
|
857
890
|
expect(computationCount).toBe(1)
|
|
@@ -866,4 +899,210 @@ describe('Computed', () => {
|
|
|
866
899
|
expect(accumulator.get()).toBe(115) // Final accumulated value
|
|
867
900
|
})
|
|
868
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
|
+
})
|
|
869
1108
|
})
|
package/test/effect.test.ts
CHANGED
|
@@ -223,7 +223,7 @@ describe('Effect', () => {
|
|
|
223
223
|
|
|
224
224
|
// Check if console.error was called with the error message
|
|
225
225
|
expect(mockConsoleError).toHaveBeenCalledWith(
|
|
226
|
-
'
|
|
226
|
+
'Error in effect callback:',
|
|
227
227
|
expect.any(Error),
|
|
228
228
|
)
|
|
229
229
|
} finally {
|
|
@@ -507,7 +507,7 @@ describe('Effect - Async with AbortSignal', () => {
|
|
|
507
507
|
|
|
508
508
|
// Should have logged the async error
|
|
509
509
|
expect(mockConsoleError).toHaveBeenCalledWith(
|
|
510
|
-
'
|
|
510
|
+
'Error in async effect callback:',
|
|
511
511
|
expect.any(Error),
|
|
512
512
|
)
|
|
513
513
|
} finally {
|