@zeix/cause-effect 0.17.2 → 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 +11 -5
- package/.github/copilot-instructions.md +1 -1
- package/.zed/settings.json +3 -0
- package/CLAUDE.md +18 -79
- package/README.md +23 -37
- package/archive/benchmark.ts +0 -5
- package/archive/collection.ts +5 -62
- package/archive/composite.ts +85 -0
- package/archive/computed.ts +17 -20
- package/archive/list.ts +6 -67
- package/archive/memo.ts +13 -14
- package/archive/store.ts +7 -66
- package/archive/task.ts +18 -20
- package/index.dev.js +438 -614
- package/index.js +1 -1
- package/index.ts +8 -19
- package/package.json +6 -6
- package/src/classes/collection.ts +59 -112
- package/src/classes/computed.ts +146 -189
- package/src/classes/list.ts +138 -105
- package/src/classes/ref.ts +16 -42
- package/src/classes/state.ts +16 -45
- package/src/classes/store.ts +107 -72
- package/src/effect.ts +9 -12
- package/src/errors.ts +12 -8
- package/src/signal.ts +3 -1
- package/src/system.ts +136 -154
- package/test/batch.test.ts +4 -11
- package/test/benchmark.test.ts +4 -2
- package/test/collection.test.ts +46 -306
- package/test/computed.test.ts +205 -223
- package/test/list.test.ts +35 -303
- package/test/ref.test.ts +38 -66
- package/test/state.test.ts +6 -12
- package/test/store.test.ts +37 -489
- package/test/util/dependency-graph.ts +2 -2
- package/tsconfig.build.json +11 -0
- package/tsconfig.json +5 -7
- package/types/index.d.ts +2 -2
- package/types/src/classes/collection.d.ts +4 -6
- package/types/src/classes/computed.d.ts +17 -37
- package/types/src/classes/list.d.ts +8 -6
- package/types/src/classes/ref.d.ts +7 -20
- package/types/src/classes/state.d.ts +5 -17
- package/types/src/classes/store.d.ts +12 -11
- package/types/src/errors.d.ts +2 -4
- package/types/src/signal.d.ts +3 -2
- package/types/src/system.d.ts +41 -44
- package/src/classes/composite.ts +0 -171
- package/types/src/classes/composite.d.ts +0 -15
package/test/computed.test.ts
CHANGED
|
@@ -10,7 +10,6 @@ import {
|
|
|
10
10
|
Task,
|
|
11
11
|
UNSET,
|
|
12
12
|
} from '../index.ts'
|
|
13
|
-
import { HOOK_WATCH } from '../src/system'
|
|
14
13
|
|
|
15
14
|
/* === Utility Functions === */
|
|
16
15
|
|
|
@@ -230,7 +229,7 @@ describe('Computed', () => {
|
|
|
230
229
|
test('should detect and throw error for circular dependencies', () => {
|
|
231
230
|
const a = new State(1)
|
|
232
231
|
const b = new Memo(() => c.get() + 1)
|
|
233
|
-
const c = new Memo(() => b.get() + a.get())
|
|
232
|
+
const c = new Memo((): number => b.get() + a.get())
|
|
234
233
|
expect(() => {
|
|
235
234
|
b.get() // This should trigger the circular dependency
|
|
236
235
|
}).toThrow('Circular dependency detected in memo')
|
|
@@ -269,7 +268,7 @@ describe('Computed', () => {
|
|
|
269
268
|
expect(a.get()).toBe(1)
|
|
270
269
|
expect(true).toBe(false) // This line should not be reached
|
|
271
270
|
} catch (error) {
|
|
272
|
-
expect(error.message).toBe('Calculation error')
|
|
271
|
+
expect((error as Error).message).toBe('Calculation error')
|
|
273
272
|
} finally {
|
|
274
273
|
expect(c.get()).toBe('c: recovered')
|
|
275
274
|
expect(okCount).toBe(2)
|
|
@@ -468,35 +467,11 @@ describe('Computed', () => {
|
|
|
468
467
|
}).toThrow('Invalid Task callback (_a) => 42')
|
|
469
468
|
})
|
|
470
469
|
|
|
471
|
-
test('should
|
|
470
|
+
test('should expect type error if null is passed for options.initialValue', () => {
|
|
472
471
|
expect(() => {
|
|
473
472
|
// @ts-expect-error - Testing invalid input
|
|
474
|
-
new Memo(() => 42, null)
|
|
475
|
-
}).toThrow(
|
|
476
|
-
})
|
|
477
|
-
|
|
478
|
-
test('should throw specific error types for invalid inputs', () => {
|
|
479
|
-
try {
|
|
480
|
-
// @ts-expect-error - Testing invalid input
|
|
481
|
-
new Memo(null)
|
|
482
|
-
expect(true).toBe(false) // Should not reach here
|
|
483
|
-
} catch (error) {
|
|
484
|
-
expect(error).toBeInstanceOf(TypeError)
|
|
485
|
-
expect(error.name).toBe('InvalidCallbackError')
|
|
486
|
-
expect(error.message).toBe('Invalid Memo callback null')
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
try {
|
|
490
|
-
// @ts-expect-error - Testing invalid input
|
|
491
|
-
new Memo(() => 42, null)
|
|
492
|
-
expect(true).toBe(false) // Should not reach here
|
|
493
|
-
} catch (error) {
|
|
494
|
-
expect(error).toBeInstanceOf(TypeError)
|
|
495
|
-
expect(error.name).toBe('NullishSignalValueError')
|
|
496
|
-
expect(error.message).toBe(
|
|
497
|
-
'Nullish signal values are not allowed in Memo',
|
|
498
|
-
)
|
|
499
|
-
}
|
|
473
|
+
new Memo(() => 42, { initialValue: null })
|
|
474
|
+
}).not.toThrow()
|
|
500
475
|
})
|
|
501
476
|
|
|
502
477
|
test('should allow valid callbacks and non-nullish initialValues', () => {
|
|
@@ -506,36 +481,43 @@ describe('Computed', () => {
|
|
|
506
481
|
}).not.toThrow()
|
|
507
482
|
|
|
508
483
|
expect(() => {
|
|
509
|
-
new Memo(() => 42, 0)
|
|
484
|
+
new Memo(() => 42, { initialValue: 0 })
|
|
510
485
|
}).not.toThrow()
|
|
511
486
|
|
|
512
487
|
expect(() => {
|
|
513
|
-
new Memo(() => 'foo', '')
|
|
488
|
+
new Memo(() => 'foo', { initialValue: '' })
|
|
514
489
|
}).not.toThrow()
|
|
515
490
|
|
|
516
491
|
expect(() => {
|
|
517
|
-
new Memo(() => true, false)
|
|
492
|
+
new Memo(() => true, { initialValue: false })
|
|
518
493
|
}).not.toThrow()
|
|
519
494
|
|
|
520
495
|
expect(() => {
|
|
521
|
-
new Task(async () => ({ id: 42, name: 'John' }),
|
|
496
|
+
new Task(async () => ({ id: 42, name: 'John' }), {
|
|
497
|
+
initialValue: UNSET,
|
|
498
|
+
})
|
|
522
499
|
}).not.toThrow()
|
|
523
500
|
})
|
|
524
501
|
})
|
|
525
502
|
|
|
526
503
|
describe('Initial Value and Old Value', () => {
|
|
527
504
|
test('should use initialValue when provided', () => {
|
|
528
|
-
const computed = new Memo((oldValue: number) => oldValue + 1,
|
|
505
|
+
const computed = new Memo((oldValue: number) => oldValue + 1, {
|
|
506
|
+
initialValue: 10,
|
|
507
|
+
})
|
|
529
508
|
expect(computed.get()).toBe(11)
|
|
530
509
|
})
|
|
531
510
|
|
|
532
511
|
test('should pass current value as oldValue to callback', () => {
|
|
533
512
|
const state = new State(5)
|
|
534
513
|
let receivedOldValue: number | undefined
|
|
535
|
-
const computed = new Memo(
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
514
|
+
const computed = new Memo(
|
|
515
|
+
(oldValue: number) => {
|
|
516
|
+
receivedOldValue = oldValue
|
|
517
|
+
return state.get() * 2
|
|
518
|
+
},
|
|
519
|
+
{ initialValue: 0 },
|
|
520
|
+
)
|
|
539
521
|
|
|
540
522
|
expect(computed.get()).toBe(10)
|
|
541
523
|
expect(receivedOldValue).toBe(0)
|
|
@@ -547,10 +529,13 @@ describe('Computed', () => {
|
|
|
547
529
|
|
|
548
530
|
test('should work as reducer function with oldValue', () => {
|
|
549
531
|
const increment = new State(0)
|
|
550
|
-
const sum = new Memo(
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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
|
+
)
|
|
554
539
|
|
|
555
540
|
expect(sum.get()).toBe(0)
|
|
556
541
|
|
|
@@ -566,10 +551,13 @@ describe('Computed', () => {
|
|
|
566
551
|
|
|
567
552
|
test('should handle array accumulation with oldValue', () => {
|
|
568
553
|
const item = new State('')
|
|
569
|
-
const items = new Memo(
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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
|
+
)
|
|
573
561
|
|
|
574
562
|
expect(items.get()).toEqual([])
|
|
575
563
|
|
|
@@ -586,11 +574,16 @@ describe('Computed', () => {
|
|
|
586
574
|
test('should handle counter with oldValue and multiple dependencies', () => {
|
|
587
575
|
const reset = new State(false)
|
|
588
576
|
const add = new State(0)
|
|
589
|
-
const counter = new Memo(
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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
|
+
)
|
|
594
587
|
|
|
595
588
|
expect(counter.get()).toBe(0)
|
|
596
589
|
|
|
@@ -623,11 +616,16 @@ describe('Computed', () => {
|
|
|
623
616
|
test('should work with async computation and oldValue', async () => {
|
|
624
617
|
let receivedOldValue: number | undefined
|
|
625
618
|
|
|
626
|
-
const asyncComputed = new Task(
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
+
)
|
|
631
629
|
|
|
632
630
|
// Initially returns initialValue before async computation completes
|
|
633
631
|
expect(asyncComputed.get()).toBe(10)
|
|
@@ -648,7 +646,7 @@ describe('Computed', () => {
|
|
|
648
646
|
if (k === '' || v === '') return oldValue
|
|
649
647
|
return { ...oldValue, [k]: v }
|
|
650
648
|
},
|
|
651
|
-
{} as Record<string, string
|
|
649
|
+
{ initialValue: {} as Record<string, string> },
|
|
652
650
|
)
|
|
653
651
|
|
|
654
652
|
expect(obj.get()).toEqual({})
|
|
@@ -682,7 +680,9 @@ describe('Computed', () => {
|
|
|
682
680
|
|
|
683
681
|
return source.get() + oldValue
|
|
684
682
|
},
|
|
685
|
-
|
|
683
|
+
{
|
|
684
|
+
initialValue: 0,
|
|
685
|
+
},
|
|
686
686
|
)
|
|
687
687
|
|
|
688
688
|
// Initial computation
|
|
@@ -704,14 +704,19 @@ describe('Computed', () => {
|
|
|
704
704
|
const shouldError = new State(false)
|
|
705
705
|
const counter = new State(1)
|
|
706
706
|
|
|
707
|
-
const computed = new Memo(
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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
|
+
)
|
|
715
720
|
|
|
716
721
|
expect(computed.get()).toBe(11) // 10 + 1
|
|
717
722
|
|
|
@@ -736,23 +741,28 @@ describe('Computed', () => {
|
|
|
736
741
|
>('increment')
|
|
737
742
|
const amount = new State(1)
|
|
738
743
|
|
|
739
|
-
const calculator = new Memo(
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
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
|
+
)
|
|
756
766
|
|
|
757
767
|
expect(calculator.get()).toBe(1) // 0 + 1
|
|
758
768
|
|
|
@@ -773,9 +783,10 @@ describe('Computed', () => {
|
|
|
773
783
|
|
|
774
784
|
test('should handle edge cases with initialValue and oldValue', () => {
|
|
775
785
|
// Test with null/undefined-like values
|
|
776
|
-
const nullishComputed = new Memo(
|
|
777
|
-
|
|
778
|
-
|
|
786
|
+
const nullishComputed = new Memo(
|
|
787
|
+
oldValue => `${oldValue} updated`,
|
|
788
|
+
{ initialValue: '' },
|
|
789
|
+
)
|
|
779
790
|
|
|
780
791
|
expect(nullishComputed.get()).toBe(' updated')
|
|
781
792
|
|
|
@@ -794,9 +805,11 @@ describe('Computed', () => {
|
|
|
794
805
|
items: [...oldValue.items, `item${oldValue.count + 1}`],
|
|
795
806
|
}),
|
|
796
807
|
{
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
808
|
+
initialValue: {
|
|
809
|
+
count: 0,
|
|
810
|
+
items: [] as string[],
|
|
811
|
+
meta: { created: now },
|
|
812
|
+
},
|
|
800
813
|
},
|
|
801
814
|
)
|
|
802
815
|
|
|
@@ -808,18 +821,28 @@ describe('Computed', () => {
|
|
|
808
821
|
|
|
809
822
|
test('should preserve initialValue type consistency', () => {
|
|
810
823
|
// Test that oldValue type is consistent with initialValue
|
|
811
|
-
const stringComputed = new Memo(
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
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
|
+
)
|
|
815
833
|
|
|
816
834
|
expect(stringComputed.get()).toBe('HELLO')
|
|
817
835
|
|
|
818
|
-
const numberComputed = new Memo(
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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
|
+
)
|
|
823
846
|
|
|
824
847
|
expect(numberComputed.get()).toBe(10)
|
|
825
848
|
})
|
|
@@ -829,12 +852,16 @@ describe('Computed', () => {
|
|
|
829
852
|
|
|
830
853
|
const first = new Memo(
|
|
831
854
|
(oldValue: number) => oldValue + source.get(),
|
|
832
|
-
|
|
855
|
+
{
|
|
856
|
+
initialValue: 10,
|
|
857
|
+
},
|
|
833
858
|
)
|
|
834
859
|
|
|
835
860
|
const second = new Memo(
|
|
836
861
|
(oldValue: number) => oldValue + first.get(),
|
|
837
|
-
|
|
862
|
+
{
|
|
863
|
+
initialValue: 20,
|
|
864
|
+
},
|
|
838
865
|
)
|
|
839
866
|
|
|
840
867
|
expect(first.get()).toBe(11) // 10 + 1
|
|
@@ -849,10 +876,15 @@ describe('Computed', () => {
|
|
|
849
876
|
const trigger = new State(0)
|
|
850
877
|
let computationCount = 0
|
|
851
878
|
|
|
852
|
-
const accumulator = new Memo(
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
879
|
+
const accumulator = new Memo(
|
|
880
|
+
(oldValue: number) => {
|
|
881
|
+
computationCount++
|
|
882
|
+
return oldValue + trigger.get()
|
|
883
|
+
},
|
|
884
|
+
{
|
|
885
|
+
initialValue: 100,
|
|
886
|
+
},
|
|
887
|
+
)
|
|
856
888
|
|
|
857
889
|
expect(accumulator.get()).toBe(100) // 100 + 0
|
|
858
890
|
expect(computationCount).toBe(1)
|
|
@@ -868,30 +900,26 @@ describe('Computed', () => {
|
|
|
868
900
|
})
|
|
869
901
|
})
|
|
870
902
|
|
|
871
|
-
describe('
|
|
903
|
+
describe('Signal Options - Lazy Resource Management', () => {
|
|
872
904
|
test('Memo - should manage external resources lazily', async () => {
|
|
873
905
|
const source = new State(1)
|
|
874
906
|
let counter = 0
|
|
875
907
|
let intervalId: Timer | undefined
|
|
876
908
|
|
|
877
909
|
// Create memo that depends on source
|
|
878
|
-
const computed = new Memo(
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
}, 10) // Fast interval for testing
|
|
887
|
-
|
|
888
|
-
// Return cleanup function
|
|
889
|
-
return () => {
|
|
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: () => {
|
|
890
918
|
if (intervalId) {
|
|
891
919
|
clearInterval(intervalId)
|
|
892
920
|
intervalId = undefined
|
|
893
921
|
}
|
|
894
|
-
}
|
|
922
|
+
},
|
|
895
923
|
})
|
|
896
924
|
|
|
897
925
|
// Counter should not be running yet
|
|
@@ -900,7 +928,7 @@ describe('Computed', () => {
|
|
|
900
928
|
expect(counter).toBe(0)
|
|
901
929
|
expect(intervalId).toBeUndefined()
|
|
902
930
|
|
|
903
|
-
// Effect subscribes to computed, triggering
|
|
931
|
+
// Effect subscribes to computed, triggering watched callback
|
|
904
932
|
const effectCleanup = createEffect(() => {
|
|
905
933
|
computed.get()
|
|
906
934
|
})
|
|
@@ -918,9 +946,6 @@ describe('Computed', () => {
|
|
|
918
946
|
await wait(50)
|
|
919
947
|
expect(counter).toBe(counterAfterStop)
|
|
920
948
|
expect(intervalId).toBeUndefined()
|
|
921
|
-
|
|
922
|
-
// Cleanup
|
|
923
|
-
cleanupHookCallback()
|
|
924
949
|
})
|
|
925
950
|
|
|
926
951
|
test('Task - should manage external resources lazily', async () => {
|
|
@@ -938,23 +963,22 @@ describe('Computed', () => {
|
|
|
938
963
|
|
|
939
964
|
return `${value}-processed-${oldValue || 'none'}`
|
|
940
965
|
},
|
|
941
|
-
|
|
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
|
+
},
|
|
942
980
|
)
|
|
943
981
|
|
|
944
|
-
// Add HOOK_WATCH callback
|
|
945
|
-
const cleanupHookCallback = computed.on(HOOK_WATCH, () => {
|
|
946
|
-
intervalId = setInterval(() => {
|
|
947
|
-
counter++
|
|
948
|
-
}, 10)
|
|
949
|
-
|
|
950
|
-
return () => {
|
|
951
|
-
if (intervalId) {
|
|
952
|
-
clearInterval(intervalId)
|
|
953
|
-
intervalId = undefined
|
|
954
|
-
}
|
|
955
|
-
}
|
|
956
|
-
})
|
|
957
|
-
|
|
958
982
|
// Counter should not be running yet
|
|
959
983
|
expect(counter).toBe(0)
|
|
960
984
|
await wait(50)
|
|
@@ -979,26 +1003,24 @@ describe('Computed', () => {
|
|
|
979
1003
|
await wait(50)
|
|
980
1004
|
expect(counter).toBe(counterAfterStop)
|
|
981
1005
|
expect(intervalId).toBeUndefined()
|
|
982
|
-
|
|
983
|
-
// Cleanup
|
|
984
|
-
cleanupHookCallback()
|
|
985
1006
|
})
|
|
986
1007
|
|
|
987
1008
|
test('Memo - multiple watchers should share resources', async () => {
|
|
988
1009
|
const source = new State(10)
|
|
989
1010
|
let subscriptionCount = 0
|
|
990
1011
|
|
|
991
|
-
const computed = new Memo(
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
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
|
+
)
|
|
1002
1024
|
|
|
1003
1025
|
expect(subscriptionCount).toBe(0)
|
|
1004
1026
|
|
|
@@ -1020,13 +1042,11 @@ describe('Computed', () => {
|
|
|
1020
1042
|
// Stop second effect
|
|
1021
1043
|
effect2()
|
|
1022
1044
|
expect(subscriptionCount).toBe(0) // Now cleaned up
|
|
1023
|
-
|
|
1024
|
-
// Cleanup
|
|
1025
|
-
cleanupHookCallback()
|
|
1026
1045
|
})
|
|
1027
1046
|
|
|
1028
1047
|
test('Task - should handle abort signals in external resources', async () => {
|
|
1029
1048
|
const source = new State('test')
|
|
1049
|
+
let controller: AbortController | undefined
|
|
1030
1050
|
const abortedControllers: AbortController[] = []
|
|
1031
1051
|
|
|
1032
1052
|
const computed = new Task(
|
|
@@ -1035,37 +1055,38 @@ describe('Computed', () => {
|
|
|
1035
1055
|
if (abort.aborted) throw new Error('Aborted')
|
|
1036
1056
|
return `${source.get()}-${oldValue || 'initial'}`
|
|
1037
1057
|
},
|
|
1038
|
-
|
|
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
|
+
},
|
|
1039
1088
|
)
|
|
1040
1089
|
|
|
1041
|
-
// HOOK_WATCH that creates external resources with abort handling
|
|
1042
|
-
const cleanupHookCallback = computed.on(HOOK_WATCH, () => {
|
|
1043
|
-
const controller = new AbortController()
|
|
1044
|
-
|
|
1045
|
-
// Simulate external async operation (catch rejections to avoid unhandled errors)
|
|
1046
|
-
new Promise(resolve => {
|
|
1047
|
-
const timeout = setTimeout(() => {
|
|
1048
|
-
if (controller.signal.aborted) {
|
|
1049
|
-
resolve('External operation aborted')
|
|
1050
|
-
} else {
|
|
1051
|
-
resolve('External operation completed')
|
|
1052
|
-
}
|
|
1053
|
-
}, 50)
|
|
1054
|
-
|
|
1055
|
-
controller.signal.addEventListener('abort', () => {
|
|
1056
|
-
clearTimeout(timeout)
|
|
1057
|
-
resolve('External operation aborted')
|
|
1058
|
-
})
|
|
1059
|
-
}).catch(() => {
|
|
1060
|
-
// Ignore promise rejections in test
|
|
1061
|
-
})
|
|
1062
|
-
|
|
1063
|
-
return () => {
|
|
1064
|
-
controller.abort()
|
|
1065
|
-
abortedControllers.push(controller)
|
|
1066
|
-
}
|
|
1067
|
-
})
|
|
1068
|
-
|
|
1069
1090
|
const effect1 = createEffect(() => {
|
|
1070
1091
|
computed.get()
|
|
1071
1092
|
})
|
|
@@ -1082,45 +1103,6 @@ describe('Computed', () => {
|
|
|
1082
1103
|
// Should have aborted external controllers
|
|
1083
1104
|
expect(abortedControllers.length).toBeGreaterThan(0)
|
|
1084
1105
|
expect(abortedControllers[0].signal.aborted).toBe(true)
|
|
1085
|
-
|
|
1086
|
-
// Cleanup
|
|
1087
|
-
cleanupHookCallback()
|
|
1088
|
-
})
|
|
1089
|
-
|
|
1090
|
-
test('Exception handling in computed HOOK_WATCH callbacks', async () => {
|
|
1091
|
-
const source = new State(1)
|
|
1092
|
-
const computed = new Memo(() => source.get() * 2)
|
|
1093
|
-
|
|
1094
|
-
let successfulCallbackCalled = false
|
|
1095
|
-
let throwingCallbackCalled = false
|
|
1096
|
-
|
|
1097
|
-
// Add throwing callback
|
|
1098
|
-
const cleanup1 = computed.on(HOOK_WATCH, () => {
|
|
1099
|
-
throwingCallbackCalled = true
|
|
1100
|
-
throw new Error('Test error in computed HOOK_WATCH')
|
|
1101
|
-
})
|
|
1102
|
-
|
|
1103
|
-
// Add successful callback
|
|
1104
|
-
const cleanup2 = computed.on(HOOK_WATCH, () => {
|
|
1105
|
-
successfulCallbackCalled = true
|
|
1106
|
-
return () => {
|
|
1107
|
-
// cleanup
|
|
1108
|
-
}
|
|
1109
|
-
})
|
|
1110
|
-
|
|
1111
|
-
// Trigger callbacks - should throw due to exception in callback
|
|
1112
|
-
expect(() => computed.get()).toThrow(
|
|
1113
|
-
'Test error in computed HOOK_WATCH',
|
|
1114
|
-
)
|
|
1115
|
-
|
|
1116
|
-
// Throwing callback should have been called
|
|
1117
|
-
expect(throwingCallbackCalled).toBe(true)
|
|
1118
|
-
// Successful callback should also have been called (resilient collection)
|
|
1119
|
-
expect(successfulCallbackCalled).toBe(true)
|
|
1120
|
-
|
|
1121
|
-
// Cleanup
|
|
1122
|
-
cleanup1()
|
|
1123
|
-
cleanup2()
|
|
1124
1106
|
})
|
|
1125
1107
|
})
|
|
1126
1108
|
})
|