@esportsplus/reactivity 0.31.3 → 0.32.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.
Files changed (68) hide show
  1. package/.claude/CHANGELOG.md +43 -0
  2. package/README.md +13 -6
  3. package/bench/cellx.bench.ts +61 -0
  4. package/bench/dynamic.bench.ts +103 -0
  5. package/bench/kairo.bench.ts +368 -0
  6. package/bench/lib/reactive-adapter.ts +51 -0
  7. package/bench/molbench.bench.ts +75 -0
  8. package/{tests/bench/system.ts → bench/system.bench.ts} +3 -3
  9. package/build/compiler/array.d.ts +1 -1
  10. package/build/compiler/constants.d.ts +7 -6
  11. package/build/compiler/constants.js +6 -7
  12. package/build/compiler/object.d.ts +1 -1
  13. package/build/compiler/object.js +1 -2
  14. package/build/compiler/primitives.d.ts +1 -1
  15. package/build/constants.d.ts +3 -1
  16. package/build/constants.js +3 -1
  17. package/build/reactive/array.d.ts +8 -5
  18. package/build/reactive/array.js +14 -14
  19. package/build/reactive/index.d.ts +2 -2
  20. package/build/reactive/index.js +1 -1
  21. package/build/reactive/object.d.ts +4 -4
  22. package/build/reactive/object.js +8 -23
  23. package/build/system.d.ts +15 -6
  24. package/build/system.js +415 -69
  25. package/build/types.d.ts +16 -4
  26. package/package.json +5 -2
  27. package/src/compiler/array.ts +1 -1
  28. package/src/compiler/constants.ts +8 -6
  29. package/src/compiler/index.ts +2 -1
  30. package/src/compiler/object.ts +2 -2
  31. package/src/compiler/plugins/vite.ts +1 -0
  32. package/src/compiler/primitives.ts +1 -1
  33. package/src/constants.ts +6 -2
  34. package/src/reactive/array.ts +26 -23
  35. package/src/reactive/index.ts +6 -6
  36. package/src/reactive/object.ts +16 -41
  37. package/src/system.ts +612 -80
  38. package/src/types.ts +29 -9
  39. package/test/async-computed.test.ts +323 -0
  40. package/test/async-errors.test.ts +146 -0
  41. package/test/async-hardening.test.ts +73 -0
  42. package/test/async-iterable.test.ts +221 -0
  43. package/test/async-nested.test.ts +19 -0
  44. package/test/deep-graphs.test.ts +215 -0
  45. package/{tests/effects.ts → test/effects.test.ts} +60 -0
  46. package/test/equals.test.ts +142 -0
  47. package/test/errors.test.ts +201 -0
  48. package/test/flush.test.ts +185 -0
  49. package/test/glitch-freedom.test.ts +115 -0
  50. package/test/invalidate.test.ts +147 -0
  51. package/test/lib/wait-for.ts +28 -0
  52. package/test/pending-writes.test.ts +139 -0
  53. package/{tests/reactive.ts → test/reactive/reactive.test.ts} +20 -19
  54. package/test/read-dedup.test.ts +120 -0
  55. package/test/signal-selector.test.ts +187 -0
  56. package/{tests/system.ts → test/system.test.ts} +214 -23
  57. package/test/tsconfig.json +16 -0
  58. package/test/untrack.test.ts +146 -0
  59. package/vitest.config.ts +2 -2
  60. package/tests/async-computed.ts +0 -239
  61. /package/{tests/bench/array.ts → bench/reactive/array.bench.ts} +0 -0
  62. /package/{tests/bench/reactive-object.ts → bench/reactive/reactive-object.bench.ts} +0 -0
  63. /package/{tests → bench}/tsconfig.json +0 -0
  64. /package/{tests/compiler.ts → test/compiler/compiler.test.ts} +0 -0
  65. /package/{tests/primitives.ts → test/primitives.test.ts} +0 -0
  66. /package/{tests/array.ts → test/reactive/array.test.ts} +0 -0
  67. /package/{tests/nested.ts → test/reactive/nested.test.ts} +0 -0
  68. /package/{tests/objects.ts → test/reactive/objects.test.ts} +0 -0
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
  import { computed, dispose, effect, isComputed, isSignal, onCleanup, read, root, signal, write } from '~/system';
3
+ import { waitFor } from './lib/wait-for';
3
4
 
4
5
 
5
6
  describe('signal', () => {
@@ -424,7 +425,7 @@ describe('onCleanup', () => {
424
425
  expect(fn).not.toHaveBeenCalled();
425
426
  });
426
427
 
427
- it('throwing cleanup skips subsequent cleanups in same array', () => {
428
+ it('throwing cleanup still runs subsequent cleanups and rethrows to the dispose caller', () => {
428
429
  let called: number[] = [],
429
430
  c = computed((onCleanup) => {
430
431
  onCleanup(() => { called.push(1); });
@@ -435,14 +436,89 @@ describe('onCleanup', () => {
435
436
 
436
437
  expect(called).toEqual([]);
437
438
 
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
439
  expect(() => dispose(c)).toThrow('cleanup boom');
442
440
 
443
- expect(called).toContain(1);
444
- expect(called).toContain(2);
445
- expect(called).not.toContain(3);
441
+ // Every disposer ran despite the throw
442
+ expect(called).toEqual([1, 2, 3]);
443
+ expect(c.cleanup).toBe(null);
444
+ });
445
+
446
+ it('single throwing cleanup rethrows the exact error', () => {
447
+ let error = new Error('solo boom'),
448
+ c = computed((onCleanup) => {
449
+ onCleanup(() => { throw error; });
450
+ return 1;
451
+ });
452
+
453
+ try {
454
+ dispose(c);
455
+ expect.unreachable();
456
+ }
457
+ catch (e) {
458
+ expect(e).toBe(error);
459
+ }
460
+ });
461
+
462
+ it('multiple throwing cleanups aggregate into AggregateError with all captured', () => {
463
+ let called: number[] = [],
464
+ c = computed((onCleanup) => {
465
+ onCleanup(() => { called.push(1); throw new Error('first'); });
466
+ onCleanup(() => { called.push(2); throw new Error('second'); });
467
+ onCleanup(() => { called.push(3); });
468
+ return 1;
469
+ });
470
+
471
+ try {
472
+ dispose(c);
473
+ expect.unreachable();
474
+ }
475
+ catch (e) {
476
+ expect(e).toBeInstanceOf(AggregateError);
477
+ expect((e as AggregateError).errors.map((x) => (x as Error).message)).toEqual(['first', 'second']);
478
+ }
479
+
480
+ expect(called).toEqual([1, 2, 3]);
481
+ expect(c.cleanup).toBe(null);
482
+ });
483
+
484
+ it('throwing cleanup during recompute does not abort the stabilize pass', async () => {
485
+ let s = signal(0),
486
+ captured: unknown[] = [],
487
+ listeners = process.listeners('uncaughtException'),
488
+ otherValues: number[] = [],
489
+ values: number[] = [];
490
+
491
+ effect(() => {
492
+ values.push(read(s));
493
+ onCleanup(() => { throw new Error('teardown boom'); });
494
+ });
495
+
496
+ effect(() => {
497
+ otherValues.push(read(s));
498
+ });
499
+
500
+ process.removeAllListeners('uncaughtException');
501
+ process.on('uncaughtException', (e) => { captured.push(e); });
502
+
503
+ try {
504
+ write(s, 1);
505
+ await waitFor(() => captured.length === 1, 'teardown error surfaces as uncaught');
506
+ }
507
+ finally {
508
+ process.removeAllListeners('uncaughtException');
509
+
510
+ for (let i = 0, n = listeners.length; i < n; i++) {
511
+ process.on('uncaughtException', listeners[i]);
512
+ }
513
+ }
514
+
515
+ // Both effects re-ran — the pass survived the teardown failure
516
+ expect(values).toEqual([0, 1]);
517
+ expect(otherValues).toEqual([0, 1]);
518
+
519
+ // And the teardown error surfaced as an uncaught microtask exception
520
+ expect(captured.length).toBe(1);
521
+ expect((captured[0] as Error).message).toBe('teardown boom');
446
522
  });
447
523
  });
448
524
 
@@ -610,10 +686,109 @@ describe('computed object size', () => {
610
686
  expect('type' in c).toBe(false);
611
687
  });
612
688
 
613
- it('has fewer own properties than 14 (old size)', () => {
689
+ it('has no more own properties than 16 (14 slimmed + gv + rv)', () => {
614
690
  let c = computed(() => 42);
615
691
 
616
- expect(Object.keys(c).length).toBeLessThan(14);
692
+ expect(Object.keys(c).length).toBeLessThanOrEqual(16);
693
+ });
694
+ });
695
+
696
+ describe('globalVersion fast path', () => {
697
+ it('a computed pulled by two tracked readers in one settle runs its fn at most once with correct values', async () => {
698
+ let cRuns = 0,
699
+ s = signal(0),
700
+ seen1: number[] = [],
701
+ seen2: number[] = [];
702
+
703
+ let c = computed(() => {
704
+ cRuns++;
705
+
706
+ return read(s) + 1;
707
+ });
708
+
709
+ effect(() => {
710
+ if (read(s) > 0) {
711
+ seen1.push(read(c));
712
+ }
713
+ });
714
+
715
+ effect(() => {
716
+ if (read(s) > 0) {
717
+ seen2.push(read(c));
718
+ }
719
+ });
720
+
721
+ cRuns = 0;
722
+
723
+ write(s, 1);
724
+ await Promise.resolve();
725
+ await Promise.resolve();
726
+
727
+ expect(cRuns).toBe(1);
728
+ expect(seen1[seen1.length - 1]).toBe(2);
729
+ expect(seen2[seen2.length - 1]).toBe(2);
730
+ });
731
+
732
+ it('write during recompute leaves the racing node validating normally (no stale skip)', async () => {
733
+ let s = signal(0),
734
+ t = signal(0);
735
+
736
+ let c = computed(() => {
737
+ let v = read(s);
738
+
739
+ if (v > 0) {
740
+ write(t, v * 10);
741
+ }
742
+
743
+ return v;
744
+ });
745
+
746
+ let d = computed(() => read(t) + read(c));
747
+
748
+ effect(() => {
749
+ read(d);
750
+ });
751
+
752
+ write(s, 2);
753
+ await Promise.resolve();
754
+ await Promise.resolve();
755
+ await Promise.resolve();
756
+
757
+ expect(read(t)).toBe(20);
758
+ expect(read(c)).toBe(2);
759
+ expect(read(d)).toBe(22);
760
+ });
761
+
762
+ it('a fresh signal write invalidates the fast path (no stale skip across a settle boundary)', async () => {
763
+ let cRuns = 0,
764
+ s = signal(1),
765
+ seen: number[] = [];
766
+
767
+ let c = computed(() => {
768
+ cRuns++;
769
+
770
+ return read(s) * 2;
771
+ });
772
+
773
+ effect(() => {
774
+ seen.push(read(c));
775
+ });
776
+
777
+ expect(seen[seen.length - 1]).toBe(2);
778
+
779
+ write(s, 5);
780
+ await Promise.resolve();
781
+ await Promise.resolve();
782
+
783
+ expect(read(c)).toBe(10);
784
+ expect(seen[seen.length - 1]).toBe(10);
785
+
786
+ write(s, 7);
787
+ await Promise.resolve();
788
+ await Promise.resolve();
789
+
790
+ expect(read(c)).toBe(14);
791
+ expect(seen[seen.length - 1]).toBe(14);
617
792
  });
618
793
  });
619
794
 
@@ -842,8 +1017,9 @@ describe('edge cases', () => {
842
1017
  expect(cValues).toEqual([0, 30]);
843
1018
  });
844
1019
 
845
- it('computed that throws on update retains previous value', async () => {
1020
+ it('computed that throws on update caches the error and rethrows at read', async () => {
846
1021
  let s = signal(0),
1022
+ effectErrors: unknown[] = [],
847
1023
  effectValues: number[] = [],
848
1024
  c = computed(() => {
849
1025
  let val = read(s);
@@ -855,9 +1031,14 @@ describe('edge cases', () => {
855
1031
  return val * 10;
856
1032
  });
857
1033
 
858
- effect(() => {
859
- effectValues.push(read(c));
860
- });
1034
+ effect(
1035
+ () => {
1036
+ effectValues.push(read(c));
1037
+ },
1038
+ (e) => {
1039
+ effectErrors.push(e);
1040
+ }
1041
+ );
861
1042
 
862
1043
  expect(effectValues).toEqual([0]);
863
1044
 
@@ -870,13 +1051,16 @@ describe('edge cases', () => {
870
1051
  write(s, 2);
871
1052
  await Promise.resolve();
872
1053
 
873
- // Value should remain 10 since throw prevented update
874
- expect(read(c)).toBe(10);
1054
+ // Error propagates to the subscribed effect and rethrows at read
1055
+ expect(() => read(c)).toThrow('boom');
1056
+ expect(effectErrors.length).toBe(1);
1057
+ expect((effectErrors[0] as Error).message).toBe('boom');
875
1058
  expect(effectValues).toEqual([0, 10]);
876
1059
  });
877
1060
 
878
1061
  it('computed alternates between throwing and succeeding', async () => {
879
1062
  let s = signal(0),
1063
+ effectErrors: unknown[] = [],
880
1064
  effectValues: number[] = [],
881
1065
  c = computed(() => {
882
1066
  let val = read(s);
@@ -888,31 +1072,38 @@ describe('edge cases', () => {
888
1072
  return val;
889
1073
  });
890
1074
 
891
- effect(() => {
892
- effectValues.push(read(c));
893
- });
1075
+ effect(
1076
+ () => {
1077
+ effectValues.push(read(c));
1078
+ },
1079
+ (e) => {
1080
+ effectErrors.push(e);
1081
+ }
1082
+ );
894
1083
 
895
1084
  expect(effectValues).toEqual([0]);
896
1085
 
897
1086
  write(s, 1);
898
1087
  await Promise.resolve();
899
1088
 
900
- // Threw on odd, value stays 0
901
- expect(read(c)).toBe(0);
1089
+ // Threw on odd, error cached and rethrown at read
1090
+ expect(() => read(c)).toThrow('odd');
1091
+ expect(effectErrors.length).toBe(1);
902
1092
  expect(effectValues).toEqual([0]);
903
1093
 
904
1094
  write(s, 2);
905
1095
  await Promise.resolve();
906
1096
 
907
- // Succeeds on even, value updates
1097
+ // Succeeds on even, error cleared, value updates
908
1098
  expect(read(c)).toBe(2);
909
1099
  expect(effectValues).toEqual([0, 2]);
910
1100
 
911
1101
  write(s, 3);
912
1102
  await Promise.resolve();
913
1103
 
914
- // Threw on odd again, value stays 2
915
- expect(read(c)).toBe(2);
1104
+ // Threw on odd again the error caches on the Nth failure too
1105
+ expect(() => read(c)).toThrow('odd');
1106
+ expect(effectErrors.length).toBe(2);
916
1107
  expect(effectValues).toEqual([0, 2]);
917
1108
 
918
1109
  write(s, 4);
@@ -0,0 +1,16 @@
1
+ {
2
+ "extends": "../tsconfig.json",
3
+ "compilerOptions": {
4
+ "noEmit": true,
5
+ "paths": {
6
+ "~/*": [
7
+ "../src/*"
8
+ ]
9
+ },
10
+ "rootDir": ".."
11
+ },
12
+ "include": [
13
+ "./**/*",
14
+ "../src/**/*"
15
+ ]
16
+ }
@@ -0,0 +1,146 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { computed, effect, peek, read, signal, untrack, write } from '~/system';
3
+
4
+
5
+ describe('untrack', () => {
6
+ it('does not re-run an effect for a signal read only inside untrack, and sees current values', async () => {
7
+ let a = signal(1),
8
+ b = signal(10),
9
+ calls = 0,
10
+ seenB: number[] = [];
11
+
12
+ effect(() => {
13
+ read(a);
14
+ seenB.push(untrack(() => read(b)));
15
+ calls++;
16
+ });
17
+
18
+ expect(calls).toBe(1);
19
+ expect(seenB).toEqual([10]);
20
+
21
+ write(b, 20);
22
+ await Promise.resolve();
23
+
24
+ // b changed but was only ever read inside untrack — the effect must not re-run
25
+ expect(calls).toBe(1);
26
+
27
+ write(a, 2);
28
+ await Promise.resolve();
29
+
30
+ // a re-runs the effect; the untracked read of b picks up its current value
31
+ expect(calls).toBe(2);
32
+ expect(seenB).toEqual([10, 20]);
33
+ });
34
+
35
+ it('restores the observer when fn throws, so a later read in the same effect still tracks', async () => {
36
+ let a = signal(1),
37
+ b = signal(1),
38
+ calls = 0;
39
+
40
+ effect(() => {
41
+ calls++;
42
+
43
+ try {
44
+ untrack(() => {
45
+ throw new Error('untrack boom');
46
+ });
47
+ }
48
+ catch {
49
+ // swallow — only the observer-restoration-after-throw behavior is under test
50
+ }
51
+
52
+ read(a);
53
+ read(b);
54
+ });
55
+
56
+ expect(calls).toBe(1);
57
+
58
+ write(a, 2);
59
+ await Promise.resolve();
60
+
61
+ expect(calls).toBe(2);
62
+
63
+ write(b, 2);
64
+ await Promise.resolve();
65
+
66
+ expect(calls).toBe(3);
67
+ });
68
+
69
+ it('returns the value fn produces', () => {
70
+ expect(untrack(() => 42)).toBe(42);
71
+ });
72
+ });
73
+
74
+
75
+ describe('peek', () => {
76
+ it('returns the current value of a signal', () => {
77
+ let s = signal(5);
78
+
79
+ expect(peek(s)).toBe(5);
80
+
81
+ write(s, 6);
82
+
83
+ expect(peek(s)).toBe(6);
84
+ });
85
+
86
+ it('returns the up-to-date value of a dirty computed without subscribing', async () => {
87
+ let s = signal(1),
88
+ c = computed(() => read(s) * 2),
89
+ writerRuns = 0;
90
+
91
+ effect(() => {
92
+ read(c);
93
+ });
94
+
95
+ effect(() => {
96
+ read(s);
97
+ writerRuns++;
98
+ });
99
+
100
+ expect(writerRuns).toBe(1);
101
+
102
+ write(s, 5);
103
+
104
+ // No await — c is queued/dirty, stabilize() has not run yet
105
+ expect(peek(c)).toBe(10);
106
+
107
+ // peek must not create a subscription: the writer effect's run count is untouched
108
+ expect(writerRuns).toBe(1);
109
+
110
+ await Promise.resolve();
111
+ await Promise.resolve();
112
+ });
113
+
114
+ it('does not add a dependency link when called from inside a tracking scope', async () => {
115
+ let s = signal(1),
116
+ c = computed(() => read(s) * 2),
117
+ caller = computed(() => peek(c));
118
+
119
+ expect(read(caller)).toBe(2);
120
+ expect(caller.deps).toBe(null);
121
+
122
+ write(s, 5);
123
+ await Promise.resolve();
124
+ await Promise.resolve();
125
+
126
+ // caller never subscribed to c, so it does not react to s changing
127
+ expect(read(caller)).toBe(2);
128
+ });
129
+
130
+ it('rethrows the cached error for an errored computed', () => {
131
+ let s = signal(0),
132
+ c = computed(() => {
133
+ if (read(s) > 0) {
134
+ throw new Error('peek boom');
135
+ }
136
+
137
+ return read(s);
138
+ });
139
+
140
+ expect(peek(c)).toBe(0);
141
+
142
+ write(s, 1);
143
+
144
+ expect(() => peek(c)).toThrow('peek boom');
145
+ });
146
+ });
package/vitest.config.ts CHANGED
@@ -11,8 +11,8 @@ export default defineConfig({
11
11
  },
12
12
  test: {
13
13
  benchmark: {
14
- include: ['tests/bench/**/*.ts']
14
+ include: ['bench/**/*.bench.ts']
15
15
  },
16
- include: ['tests/*.ts']
16
+ include: ['test/**/*.test.ts']
17
17
  }
18
18
  });