@esportsplus/reactivity 0.30.3 → 0.31.1

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/tests/system.ts CHANGED
@@ -423,6 +423,27 @@ describe('onCleanup', () => {
423
423
  expect(returned).toBe(fn);
424
424
  expect(fn).not.toHaveBeenCalled();
425
425
  });
426
+
427
+ it('throwing cleanup skips subsequent cleanups in same array', () => {
428
+ let called: number[] = [],
429
+ c = computed((onCleanup) => {
430
+ onCleanup(() => { called.push(1); });
431
+ onCleanup(() => { called.push(2); throw new Error('cleanup boom'); });
432
+ onCleanup(() => { called.push(3); });
433
+ return 42;
434
+ });
435
+
436
+ expect(called).toEqual([]);
437
+
438
+ // dispose() calls cleanup() synchronously — no try/catch wraps it
439
+ // cleanup iterates the array: index 0 (push 1), index 1 (push 2, throws)
440
+ // index 2 (push 3) is never reached because no try/catch in cleanup()
441
+ expect(() => dispose(c)).toThrow('cleanup boom');
442
+
443
+ expect(called).toContain(1);
444
+ expect(called).toContain(2);
445
+ expect(called).not.toContain(3);
446
+ });
426
447
  });
427
448
 
428
449
 
@@ -478,6 +499,33 @@ describe('root', () => {
478
499
  expect(outer).toBe(1);
479
500
  expect(inner).toBe(1);
480
501
  });
502
+
503
+ it('tracks disposables counter for unowned computeds', () => {
504
+ let before = root.disposables;
505
+
506
+ root(() => {
507
+ computed(() => 1);
508
+ computed(() => 2);
509
+ computed(() => 3);
510
+ });
511
+
512
+ // root restores disposables to outer value after execution
513
+ expect(root.disposables).toBe(before);
514
+
515
+ // Nested: inner root creates computeds, outer root creates computeds
516
+ root(() => {
517
+ computed(() => 10);
518
+
519
+ root(() => {
520
+ computed(() => 20);
521
+ computed(() => 30);
522
+ });
523
+
524
+ computed(() => 40);
525
+ });
526
+
527
+ expect(root.disposables).toBe(before);
528
+ });
481
529
  });
482
530
 
483
531
 
@@ -548,10 +596,93 @@ describe('isComputed', () => {
548
596
  expect(isComputed(1)).toBe(false);
549
597
  expect(isComputed(null)).toBe(false);
550
598
  });
599
+
600
+ it('returns false for objects with state field but no STATE_COMPUTED bit', () => {
601
+ expect(isComputed({ state: 0 })).toBe(false);
602
+ expect(isComputed({ state: 1 })).toBe(false);
603
+ });
604
+ });
605
+
606
+ describe('computed object size', () => {
607
+ it('does not have a type field', () => {
608
+ let c = computed(() => 42);
609
+
610
+ expect('type' in c).toBe(false);
611
+ });
612
+
613
+ it('has fewer own properties than 14 (old size)', () => {
614
+ let c = computed(() => 42);
615
+
616
+ expect(Object.keys(c).length).toBeLessThan(14);
617
+ });
551
618
  });
552
619
 
553
620
 
554
621
  describe('edge cases', () => {
622
+ it('diamond graph dedup — notify state mask prevents redundant recomputation', async () => {
623
+ let s = signal(1),
624
+ calls = 0,
625
+ left = computed(() => read(s) + 1),
626
+ right = computed(() => read(s) * 2),
627
+ join = computed(() => {
628
+ calls++;
629
+ return read(left) + read(right);
630
+ });
631
+
632
+ effect(() => {
633
+ read(join);
634
+ });
635
+
636
+ expect(read(join)).toBe(4);
637
+ calls = 0;
638
+
639
+ write(s, 2);
640
+ await Promise.resolve();
641
+
642
+ expect(read(join)).toBe(7);
643
+ expect(calls).toBe(1);
644
+ });
645
+
646
+ it('dynamic height adjustment — correct ordering after switching deps', async () => {
647
+ let s = signal(1),
648
+ toggle = signal(true),
649
+ a = computed(() => read(s) + 1),
650
+ b = computed(() => read(a) + 1),
651
+ c = computed(() => read(b) + 1),
652
+ order: string[] = [],
653
+ d = computed(() => {
654
+ order.push('d');
655
+
656
+ if (read(toggle)) {
657
+ return read(a);
658
+ }
659
+
660
+ return read(c);
661
+ });
662
+
663
+ effect(() => {
664
+ read(d);
665
+ });
666
+
667
+ expect(read(d)).toBe(2);
668
+ order.length = 0;
669
+
670
+ // Switch to reading `c` (height 3) instead of `a` (height 1)
671
+ write(toggle, false);
672
+ await Promise.resolve();
673
+
674
+ expect(read(d)).toBe(4);
675
+
676
+ order.length = 0;
677
+
678
+ // Write to source — d should recompute after c due to height adjustment
679
+ write(s, 10);
680
+ await Promise.resolve();
681
+
682
+ expect(read(d)).toBe(13);
683
+ expect(order).toEqual(['d']);
684
+ });
685
+
555
686
  it('handles circular computed reads without infinite loop', () => {
556
687
  let s = signal(0),
557
688
  c1 = computed(() => read(s)),
@@ -652,4 +783,382 @@ describe('edge cases', () => {
652
783
 
653
784
  expect(innerDisposed).toBe(true);
654
785
  });
786
+
787
+ it('stabilizer re-schedules when effect writes to signal during stabilization', async () => {
788
+ let a = signal(0),
789
+ b = signal(0),
790
+ bValues: number[] = [];
791
+
792
+ effect(() => {
793
+ let val = read(a);
794
+
795
+ if (val > 0) {
796
+ write(b, val * 100);
797
+ }
798
+ });
799
+
800
+ effect(() => {
801
+ bValues.push(read(b));
802
+ });
803
+
804
+ write(a, 3);
805
+ await Promise.resolve();
806
+ await Promise.resolve();
807
+
808
+ expect(bValues).toEqual([0, 300]);
809
+ });
810
+
811
+ it('stabilizer re-schedules with nested write chain A → B → C', async () => {
812
+ let a = signal(0),
813
+ b = signal(0),
814
+ c = signal(0),
815
+ cValues: number[] = [];
816
+
817
+ effect(() => {
818
+ let val = read(a);
819
+
820
+ if (val > 0) {
821
+ write(b, val * 2);
822
+ }
823
+ });
824
+
825
+ effect(() => {
826
+ let val = read(b);
827
+
828
+ if (val > 0) {
829
+ write(c, val * 3);
830
+ }
831
+ });
832
+
833
+ effect(() => {
834
+ cValues.push(read(c));
835
+ });
836
+
837
+ write(a, 5);
838
+ await Promise.resolve();
839
+ await Promise.resolve();
840
+ await Promise.resolve();
841
+
842
+ expect(cValues).toEqual([0, 30]);
843
+ });
844
+
845
+ it('computed that throws on update retains previous value', async () => {
846
+ let s = signal(0),
847
+ effectValues: number[] = [],
848
+ c = computed(() => {
849
+ let val = read(s);
850
+
851
+ if (val === 2) {
852
+ throw new Error('boom');
853
+ }
854
+
855
+ return val * 10;
856
+ });
857
+
858
+ effect(() => {
859
+ effectValues.push(read(c));
860
+ });
861
+
862
+ expect(effectValues).toEqual([0]);
863
+
864
+ write(s, 1);
865
+ await Promise.resolve();
866
+
867
+ expect(effectValues).toEqual([0, 10]);
868
+ expect(read(c)).toBe(10);
869
+
870
+ write(s, 2);
871
+ await Promise.resolve();
872
+
873
+ // Value should remain 10 since throw prevented update
874
+ expect(read(c)).toBe(10);
875
+ expect(effectValues).toEqual([0, 10]);
876
+ });
877
+
878
+ it('computed alternates between throwing and succeeding', async () => {
879
+ let s = signal(0),
880
+ effectValues: number[] = [],
881
+ c = computed(() => {
882
+ let val = read(s);
883
+
884
+ if (val % 2 !== 0) {
885
+ throw new Error('odd');
886
+ }
887
+
888
+ return val;
889
+ });
890
+
891
+ effect(() => {
892
+ effectValues.push(read(c));
893
+ });
894
+
895
+ expect(effectValues).toEqual([0]);
896
+
897
+ write(s, 1);
898
+ await Promise.resolve();
899
+
900
+ // Threw on odd, value stays 0
901
+ expect(read(c)).toBe(0);
902
+ expect(effectValues).toEqual([0]);
903
+
904
+ write(s, 2);
905
+ await Promise.resolve();
906
+
907
+ // Succeeds on even, value updates
908
+ expect(read(c)).toBe(2);
909
+ expect(effectValues).toEqual([0, 2]);
910
+
911
+ write(s, 3);
912
+ await Promise.resolve();
913
+
914
+ // Threw on odd again, value stays 2
915
+ expect(read(c)).toBe(2);
916
+ expect(effectValues).toEqual([0, 2]);
917
+
918
+ write(s, 4);
919
+ await Promise.resolve();
920
+
921
+ // Succeeds again
922
+ expect(read(c)).toBe(4);
923
+ expect(effectValues).toEqual([0, 2, 4]);
924
+ });
925
+
926
+ it('heap auto-resizes for computed chain deeper than 64', async () => {
927
+ let s = signal(0),
928
+ chain: ReturnType<typeof computed>[] = [computed(() => read(s) + 1)];
929
+
930
+ for (let i = 1; i < 80; i++) {
931
+ let prev = chain[i - 1];
932
+
933
+ chain.push(computed(() => read(prev) + 1));
934
+ }
935
+
936
+ let tail = chain[chain.length - 1],
937
+ result = -1;
938
+
939
+ effect(() => {
940
+ result = read(tail);
941
+ });
942
+
943
+ expect(result).toBe(80);
944
+
945
+ write(s, 10);
946
+ await Promise.resolve();
947
+
948
+ expect(result).toBe(90);
949
+ });
950
+
951
+ it('system remains functional under high effect churn', async () => {
952
+ let s = signal(0),
953
+ stops: (() => void)[] = [];
954
+
955
+ for (let i = 0; i < 200; i++) {
956
+ stops.push(effect(() => { read(s); }));
957
+ }
958
+
959
+ for (let i = 0, n = stops.length; i < n; i++) {
960
+ stops[i]();
961
+ }
962
+
963
+ stops.length = 0;
964
+
965
+ let result = -1;
966
+
967
+ effect(() => {
968
+ result = read(s);
969
+ });
970
+
971
+ write(s, 42);
972
+ await Promise.resolve();
973
+
974
+ expect(result).toBe(42);
975
+
976
+ for (let i = 0; i < 200; i++) {
977
+ stops.push(effect(() => { read(s); }));
978
+ }
979
+
980
+ for (let i = 0, n = stops.length; i < n; i++) {
981
+ stops[i]();
982
+ }
983
+
984
+ write(s, 99);
985
+ await Promise.resolve();
986
+
987
+ expect(result).toBe(99);
988
+ });
989
+
990
+ it('write during stabilization triggers reschedule and computed sees updated value', async () => {
991
+ let s1 = signal(1),
992
+ s2 = signal(0),
993
+ c = computed(() => read(s2) * 10),
994
+ cValues: number[] = [];
995
+
996
+ // Effect 1: writes to s2 when s1 changes — triggers during stabilization
997
+ effect(() => {
998
+ let val = read(s1);
999
+
1000
+ if (val > 1) {
1001
+ write(s2, val);
1002
+ }
1003
+ });
1004
+
1005
+ // Effect 2: reads computed c which depends on s2
1006
+ effect(() => {
1007
+ cValues.push(read(c));
1008
+ });
1009
+
1010
+ expect(cValues).toEqual([0]);
1011
+
1012
+ // Write s1 → effect1 runs during stabilization → writes s2=5
1013
+ // → reschedule → c recomputes with s2=5 → effect2 sees 50
1014
+ write(s1, 5);
1015
+ await Promise.resolve();
1016
+ await Promise.resolve();
1017
+
1018
+ expect(cValues).toEqual([0, 50]);
1019
+ });
1020
+
1021
+ it('diamond with write during stabilization propagates through both branches', async () => {
1022
+ let source = signal(1),
1023
+ trigger = signal(0),
1024
+ left = computed(() => read(source) + 1),
1025
+ right = computed(() => read(source) * 2),
1026
+ join = computed(() => read(left) + read(right)),
1027
+ joinValues: number[] = [];
1028
+
1029
+ // Effect that writes source during stabilization
1030
+ effect(() => {
1031
+ let val = read(trigger);
1032
+
1033
+ if (val > 0) {
1034
+ write(source, val);
1035
+ }
1036
+ });
1037
+
1038
+ // Effect that reads the diamond join
1039
+ effect(() => {
1040
+ joinValues.push(read(join));
1041
+ });
1042
+
1043
+ // Initial: source=1, left=2, right=2, join=4
1044
+ expect(joinValues).toEqual([4]);
1045
+
1046
+ // trigger=10 → effect writes source=10 → reschedule
1047
+ // left=11, right=20, join=31
1048
+ write(trigger, 10);
1049
+ await Promise.resolve();
1050
+ await Promise.resolve();
1051
+
1052
+ expect(joinValues).toEqual([4, 31]);
1053
+ });
1054
+
1055
+ it('disposed computed does not prevent new computed from reading same signal', async () => {
1056
+ let s = signal(10),
1057
+ c1 = computed(() => read(s) * 2),
1058
+ c2Values: number[] = [];
1059
+
1060
+ expect(read(c1)).toBe(20);
1061
+
1062
+ dispose(c1);
1063
+
1064
+ // After dispose, c1 retains its last computed value
1065
+ expect(c1.value).toBe(20);
1066
+
1067
+ // New computed can read the same signal
1068
+ let c2 = computed(() => read(s) + 5);
1069
+
1070
+ expect(read(c2)).toBe(15);
1071
+
1072
+ // Subscribe with an effect so c2 reacts to changes
1073
+ effect(() => {
1074
+ c2Values.push(read(c2));
1075
+ });
1076
+
1077
+ expect(c2Values).toEqual([15]);
1078
+
1079
+ // Signal still propagates to new computed
1080
+ write(s, 20);
1081
+ await Promise.resolve();
1082
+
1083
+ expect(c2Values).toEqual([15, 25]);
1084
+
1085
+ // Disposed computed value is stale — not updated
1086
+ expect(c1.value).toBe(20);
1087
+ });
1088
+
1089
+ it('disposed computed retains last value and does not recompute', async () => {
1090
+ let s = signal(1),
1091
+ calls = 0,
1092
+ c = computed(() => {
1093
+ calls++;
1094
+ return read(s) * 3;
1095
+ }),
1096
+ result = -1;
1097
+
1098
+ // Subscribe so it's in the heap
1099
+ effect(() => {
1100
+ result = read(c);
1101
+ });
1102
+
1103
+ expect(result).toBe(3);
1104
+ expect(calls).toBe(1);
1105
+
1106
+ dispose(c);
1107
+
1108
+ // After disposal, value is retained
1109
+ expect(c.value).toBe(3);
1110
+
1111
+ write(s, 10);
1112
+ await Promise.resolve();
1113
+
1114
+ // Not recomputed after dispose
1115
+ expect(calls).toBe(1);
1116
+ expect(c.value).toBe(3);
1117
+ });
1118
+
1119
+ it('link pool handles >1000 dependencies with disposal and reuse', async () => {
1120
+ let signals: ReturnType<typeof signal>[] = [],
1121
+ stops: (() => void)[] = [];
1122
+
1123
+ for (let i = 0; i < 1100; i++) {
1124
+ signals.push(signal(i));
1125
+ }
1126
+
1127
+ // Create effect reading all 1100 signals
1128
+ stops.push(effect(() => {
1129
+ for (let i = 0, n = signals.length; i < n; i++) {
1130
+ read(signals[i]);
1131
+ }
1132
+ }));
1133
+
1134
+ // Dispose to return links to pool
1135
+ stops[0]();
1136
+ stops.length = 0;
1137
+
1138
+ // Create new effects reusing pooled links
1139
+ let sum = -1;
1140
+
1141
+ stops.push(effect(() => {
1142
+ let total = 0;
1143
+
1144
+ for (let i = 0; i < 50; i++) {
1145
+ total += read(signals[i]);
1146
+ }
1147
+
1148
+ sum = total;
1149
+ }));
1150
+
1151
+ // sum of 0..49 = 1225
1152
+ expect(sum).toBe(1225);
1153
+
1154
+ write(signals[0], 100);
1155
+ await Promise.resolve();
1156
+
1157
+ // 1225 - 0 + 100 = 1325
1158
+ expect(sum).toBe(1325);
1159
+
1160
+ for (let i = 0, n = stops.length; i < n; i++) {
1161
+ stops[i]();
1162
+ }
1163
+ });
655
1164
  });
@@ -0,0 +1,17 @@
1
+ {
2
+ "extends": "../tsconfig.json",
3
+ "compilerOptions": {
4
+ "baseUrl": "..",
5
+ "noEmit": true,
6
+ "paths": {
7
+ "~/*": [
8
+ "src/*"
9
+ ]
10
+ },
11
+ "rootDir": ".."
12
+ },
13
+ "include": [
14
+ "./**/*",
15
+ "../src/**/*"
16
+ ]
17
+ }