@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
@@ -0,0 +1,139 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { computed, effect, read, signal, write } from '~/system';
3
+
4
+
5
+ describe('pending-only writes', () => {
6
+ it('N writes to one signal between settles produce exactly one fan-out', async () => {
7
+ let runs = 0,
8
+ s = signal(0);
9
+
10
+ let c = computed(() => {
11
+ runs++;
12
+
13
+ return read(s);
14
+ });
15
+
16
+ effect(() => {
17
+ read(c);
18
+ });
19
+
20
+ runs = 0;
21
+
22
+ write(s, 1);
23
+ write(s, 2);
24
+ write(s, 3);
25
+ write(s, 4);
26
+ write(s, 5);
27
+
28
+ await Promise.resolve();
29
+ await Promise.resolve();
30
+
31
+ // One drain, one recompute — not one per write
32
+ expect(runs).toBe(1);
33
+ expect(read(c)).toBe(5);
34
+ });
35
+
36
+ it('an unchanged recompute does not cascade to downstream subscribers', async () => {
37
+ let downstream = 0,
38
+ s = signal(0);
39
+
40
+ // Parity: value only changes on even inputs, so odd writes recompute c but must not re-run d
41
+ let c = computed(() => read(s) - (read(s) % 2));
42
+
43
+ let d = computed(() => {
44
+ downstream++;
45
+
46
+ return read(c);
47
+ });
48
+
49
+ effect(() => {
50
+ read(d);
51
+ });
52
+
53
+ downstream = 0;
54
+
55
+ write(s, 1);
56
+ await Promise.resolve();
57
+ await Promise.resolve();
58
+
59
+ // c recomputed to the same value (0) → no propagation to d
60
+ expect(downstream).toBe(0);
61
+ expect(read(d)).toBe(0);
62
+
63
+ write(s, 2);
64
+ await Promise.resolve();
65
+ await Promise.resolve();
66
+
67
+ expect(downstream).toBe(1);
68
+ expect(read(d)).toBe(2);
69
+ });
70
+
71
+ it('tracked reads converge on pending writes at settle (invariant 1, parity with main)', async () => {
72
+ let observed: number[] = [],
73
+ s = signal(0),
74
+ t = signal(10);
75
+
76
+ let c = computed(() => read(t));
77
+
78
+ // Effect writes t then reads c, and a downstream effect reads c too — at settle every
79
+ // tracked reader must see the pending write reflected (main's coherence contract, which
80
+ // the deferred queue must preserve rather than strand a write un-fanned-out)
81
+ effect(() => {
82
+ let v = read(s);
83
+
84
+ if (v > 0) {
85
+ write(t, v * 100);
86
+ }
87
+
88
+ observed.push(read(c));
89
+ });
90
+
91
+ let downstream = 0;
92
+
93
+ effect(() => {
94
+ read(c);
95
+ downstream++;
96
+ });
97
+
98
+ observed.length = 0;
99
+ downstream = 0;
100
+
101
+ write(s, 1);
102
+ await Promise.resolve();
103
+ await Promise.resolve();
104
+
105
+ // Both readers and the node itself settle on the written value — the queue never drops it
106
+ expect(read(c)).toBe(100);
107
+ expect(observed[observed.length - 1]).toBe(100);
108
+ expect(downstream).toBeGreaterThan(0);
109
+ });
110
+
111
+ it('repeated writes across two signals coalesce, final graph values correct', async () => {
112
+ let runs = 0,
113
+ a = signal(1),
114
+ b = signal(1);
115
+
116
+ let sum = computed(() => {
117
+ runs++;
118
+
119
+ return read(a) + read(b);
120
+ });
121
+
122
+ effect(() => {
123
+ read(sum);
124
+ });
125
+
126
+ runs = 0;
127
+
128
+ write(a, 2);
129
+ write(a, 3);
130
+ write(b, 4);
131
+ write(b, 5);
132
+
133
+ await Promise.resolve();
134
+ await Promise.resolve();
135
+
136
+ expect(runs).toBe(1);
137
+ expect(read(sum)).toBe(8);
138
+ });
139
+ });
@@ -4,6 +4,7 @@ import { SIGNAL } from '~/constants';
4
4
  import { ReactiveObject, isReactiveObject } from '~/reactive/object';
5
5
  import reactive from '~/reactive/index';
6
6
  import { ReactiveArray } from '~/reactive/array';
7
+ import { tick, waitFor } from '../lib/wait-for';
7
8
 
8
9
 
9
10
  describe('ReactiveObject', () => {
@@ -120,7 +121,7 @@ describe('ReactiveObject', () => {
120
121
 
121
122
  expect(obj.data).toBeUndefined();
122
123
 
123
- await new Promise((r) => setTimeout(r, 10));
124
+ await waitFor(() => obj.data === 42, 'obj.data resolves to 42');
124
125
 
125
126
  expect(obj.data).toBe(42);
126
127
  });
@@ -131,12 +132,12 @@ describe('ReactiveObject', () => {
131
132
  data: () => Promise.resolve(read(s))
132
133
  }) as any;
133
134
 
134
- await new Promise((r) => setTimeout(r, 10));
135
+ await waitFor(() => obj.data === 'hello', 'obj.data resolves to hello');
135
136
 
136
137
  expect(obj.data).toBe('hello');
137
138
 
138
139
  write(s, 'world');
139
- await new Promise((r) => setTimeout(r, 10));
140
+ await waitFor(() => obj.data === 'world', 'obj.data updates to world');
140
141
 
141
142
  expect(obj.data).toBe('world');
142
143
  });
@@ -153,6 +154,9 @@ describe('ReactiveObject', () => {
153
154
 
154
155
  expect(obj.data).toBeUndefined();
155
156
 
157
+ // Creation dispatches once: the probe is reused as the factory (no separate probe dispatch).
158
+ expect(resolvers.length).toBe(1);
159
+
156
160
  // Trigger second computation
157
161
  write(s, 2);
158
162
  await Promise.resolve();
@@ -163,26 +167,24 @@ describe('ReactiveObject', () => {
163
167
  await Promise.resolve();
164
168
  await Promise.resolve();
165
169
 
166
- // Resolve first promise (staleshould be ignored)
167
- resolvers[0](100);
168
- await Promise.resolve();
169
-
170
- expect(obj.data).toBeUndefined();
170
+ // [0] creation, [1] after write(2), [2] after write(3) the latest
171
+ expect(resolvers.length).toBe(3);
171
172
 
172
- // Resolve second promise (stale — should be ignored)
173
- resolvers[1](200);
173
+ // Resolve the stale computations all ignored
174
+ resolvers[0](50);
175
+ resolvers[1](100);
174
176
  await Promise.resolve();
175
177
 
176
178
  expect(obj.data).toBeUndefined();
177
179
 
178
- // Resolve latest promise — should write
180
+ // Resolve latest promise — lands after the stabilize microtask
179
181
  resolvers[2](300);
180
- await Promise.resolve();
182
+ await waitFor(() => obj.data === 300, 'obj.data resolves to latest 300');
181
183
 
182
184
  expect(obj.data).toBe(300);
183
185
  });
184
186
 
185
- it('dispose prevents new computations but in-flight resolves still write', async () => {
187
+ it('dispose stops in-flight resolves from landing', async () => {
186
188
  let s = signal(1),
187
189
  resolver: ((v: number) => void) | null = null,
188
190
  obj = new ReactiveObject({
@@ -200,18 +202,17 @@ describe('ReactiveObject', () => {
200
202
  write(s, 2);
201
203
  await Promise.resolve();
202
204
 
203
- // Resolve the original in-flight promise
205
+ // Resolve the original in-flight promise — teardown is permanent, nothing lands
204
206
  resolver!(999);
205
207
  await Promise.resolve();
206
208
 
207
- // In-flight promise still wrote (version matched)
208
- expect(obj.data).toBe(999);
209
+ expect(obj.data).toBeUndefined();
209
210
 
210
- // But no further computations occur from new dependency changes
211
+ // And no further computations occur from new dependency changes
211
212
  write(s, 3);
212
- await new Promise((r) => setTimeout(r, 10));
213
+ await tick();
213
214
 
214
- expect(obj.data).toBe(999);
215
+ expect(obj.data).toBeUndefined();
215
216
  });
216
217
  });
217
218
 
@@ -0,0 +1,120 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { computed, effect, read, signal, write } from '~/system';
3
+ import type { Computed } from '~/system';
4
+
5
+
6
+ function countDeps(node: Computed<unknown>): number {
7
+ let n = 0;
8
+
9
+ for (let link = node.deps; link; link = link.nextDep) {
10
+ n++;
11
+ }
12
+
13
+ return n;
14
+ }
15
+
16
+
17
+ describe('read-version dedup', () => {
18
+ it('interleaved repeat reads (x, y, x) hold exactly 2 dep links and re-run once per write', async () => {
19
+ let runs = 0,
20
+ x = signal(0),
21
+ y = signal(0);
22
+
23
+ let c = computed(() => {
24
+ runs++;
25
+
26
+ return read(x) + read(y) + read(x);
27
+ });
28
+
29
+ effect(() => {
30
+ read(c);
31
+ });
32
+
33
+ expect(countDeps(c as Computed<unknown>)).toBe(2);
34
+ expect(runs).toBe(1);
35
+
36
+ write(x, 1);
37
+ await Promise.resolve();
38
+
39
+ // Steady-state re-run exercises the RECOMPUTING reuse paths
40
+ expect(countDeps(c as Computed<unknown>)).toBe(2);
41
+ expect(runs).toBe(2);
42
+ expect(read(c)).toBe(2);
43
+
44
+ write(x, 2);
45
+ await Promise.resolve();
46
+
47
+ expect(countDeps(c as Computed<unknown>)).toBe(2);
48
+ expect(runs).toBe(3);
49
+ expect(read(c)).toBe(4);
50
+ });
51
+
52
+ it('outer first-read of a dep stamped only by a nested creation still links (no false positive)', async () => {
53
+ let outerRuns = 0,
54
+ s = signal(1);
55
+
56
+ effect(() => {
57
+ outerRuns++;
58
+
59
+ // Inline creation: depsTail is null, so the inner recompute nests here and stamps s.
60
+ // Constant value keeps the inner from ever propagating — the outer re-runs on s
61
+ // writes ONLY through its own direct link.
62
+ computed(() => (read(s), 0));
63
+
64
+ read(s);
65
+ });
66
+
67
+ expect(outerRuns).toBe(1);
68
+
69
+ write(s, 2);
70
+ await Promise.resolve();
71
+
72
+ expect(outerRuns).toBe(2);
73
+
74
+ write(s, 3);
75
+ await Promise.resolve();
76
+
77
+ expect(outerRuns).toBe(3);
78
+ });
79
+
80
+ it('repeat reads across a nested-recompute boundary re-link or reuse, never drop', async () => {
81
+ let runs = 0,
82
+ s = signal(1);
83
+
84
+ let c = computed(() => {
85
+ runs++;
86
+
87
+ // Inline nested recompute reads s and stamps it under the inner run's version.
88
+ // Undefined return keeps the inner from ever propagating back into c.
89
+ computed(() => {
90
+ read(s);
91
+ });
92
+
93
+ return read(s) + read(s);
94
+ });
95
+
96
+ effect(() => {
97
+ read(c);
98
+ });
99
+
100
+ expect(read(c)).toBe(2);
101
+ expect(runs).toBe(1);
102
+
103
+ // inner + one s link — the repeat read fast-exits, the boundary read still links
104
+ expect(countDeps(c as Computed<unknown>)).toBe(2);
105
+
106
+ write(s, 2);
107
+ await Promise.resolve();
108
+
109
+ expect(read(c)).toBe(4);
110
+ expect(runs).toBe(2);
111
+ expect(countDeps(c as Computed<unknown>)).toBe(2);
112
+
113
+ write(s, 5);
114
+ await Promise.resolve();
115
+
116
+ expect(read(c)).toBe(10);
117
+ expect(runs).toBe(3);
118
+ expect(countDeps(c as Computed<unknown>)).toBe(2);
119
+ });
120
+ });
@@ -0,0 +1,187 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { computed, effect, onCleanup, read, signal, write } from '~/system';
3
+
4
+ // signal.selector(node, key) rides a Map for entry lookup — SameValueZero semantics: NaN keys match
5
+ // themselves and ±0 collapse, slightly wider than the settled === for those two cases. Object keys
6
+ // compare by reference and MUST be stable (a re-allocated key object never matches). The initial
7
+ // node.value === key snapshot uses ===.
8
+
9
+ const settle = () => Promise.resolve().then(() => Promise.resolve());
10
+
11
+
12
+ describe('signal.selector per-key selector', () => {
13
+ it('re-runs on transitions into/out of the key, not on writes between other keys', async () => {
14
+ let runs = 0,
15
+ s = signal('a');
16
+
17
+ effect(() => {
18
+ runs++;
19
+ signal.selector(s, 'b');
20
+ });
21
+
22
+ runs = 0;
23
+
24
+ write(s, 'c');
25
+ await settle();
26
+ expect(runs).toBe(0);
27
+
28
+ write(s, 'b');
29
+ await settle();
30
+ expect(runs).toBe(1);
31
+
32
+ write(s, 'd');
33
+ await settle();
34
+ expect(runs).toBe(2);
35
+
36
+ write(s, 'e');
37
+ await settle();
38
+ expect(runs).toBe(2);
39
+ });
40
+
41
+ it('returns the correct boolean at first call, tracked and untracked', () => {
42
+ let s = signal('x'),
43
+ tracked: boolean | null = null;
44
+
45
+ expect(signal.selector(s, 'x')).toBe(true);
46
+ expect(signal.selector(s, 'y')).toBe(false);
47
+
48
+ effect(() => {
49
+ tracked = signal.selector(s, 'x');
50
+ });
51
+
52
+ expect(tracked).toBe(true);
53
+ });
54
+
55
+ it('is lazy: keys stays null until a tracked call, one entry per distinct key', () => {
56
+ let s = signal(0);
57
+
58
+ expect(s.keys).toBe(null);
59
+
60
+ signal.selector(s, 1);
61
+ expect(s.keys).toBe(null);
62
+
63
+ effect(() => {
64
+ signal.selector(s, 1);
65
+ signal.selector(s, 2);
66
+ signal.selector(s, 1);
67
+ });
68
+
69
+ expect(s.keys).not.toBe(null);
70
+ expect(s.keys!.size).toBe(2);
71
+ });
72
+
73
+ it('evicts entries on last unsubscribe and nulls keys when the map empties', () => {
74
+ let s = signal(0),
75
+ stop1 = effect(() => {
76
+ signal.selector(s, 1);
77
+ }),
78
+ stop2 = effect(() => {
79
+ signal.selector(s, 2);
80
+ });
81
+
82
+ expect(s.keys!.size).toBe(2);
83
+
84
+ stop1();
85
+ expect(s.keys!.size).toBe(1);
86
+
87
+ stop2();
88
+ expect(s.keys).toBe(null);
89
+ });
90
+
91
+ it('leaves the computed last-unsub auto-dispose behavior unchanged', () => {
92
+ let cleaned = 0,
93
+ s = signal(0);
94
+
95
+ let stop = effect(() => {
96
+ let c = computed(() => {
97
+ onCleanup(() => {
98
+ cleaned++;
99
+ });
100
+
101
+ return read(s);
102
+ });
103
+
104
+ read(c);
105
+ });
106
+
107
+ expect(cleaned).toBe(0);
108
+
109
+ // Disposing the effect unlinks c's last subscriber → c still auto-disposes and its cleanup fires.
110
+ stop();
111
+ expect(cleaned).toBe(1);
112
+ });
113
+
114
+ it('fans out keys independently; a write touches only the prev and next entries', async () => {
115
+ let s = signal('a'),
116
+ runs1 = 0,
117
+ runs2 = 0,
118
+ runs3 = 0;
119
+
120
+ effect(() => {
121
+ runs1++;
122
+ signal.selector(s, 'a');
123
+ });
124
+ effect(() => {
125
+ runs2++;
126
+ signal.selector(s, 'b');
127
+ });
128
+ effect(() => {
129
+ runs3++;
130
+ signal.selector(s, 'c');
131
+ });
132
+
133
+ runs1 = runs2 = runs3 = 0;
134
+
135
+ write(s, 'b');
136
+ await settle();
137
+
138
+ expect(runs1).toBe(1);
139
+ expect(runs2).toBe(1);
140
+ expect(runs3).toBe(0);
141
+ });
142
+
143
+ it('NaN key: the Map reuses one stable entry and transitions fan out (SameValueZero caveat)', async () => {
144
+ let observed: boolean[] = [],
145
+ runs = 0,
146
+ s = signal<number>(0);
147
+
148
+ effect(() => {
149
+ runs++;
150
+ observed.push(signal.selector(s, NaN));
151
+ });
152
+
153
+ runs = 0;
154
+ observed.length = 0;
155
+
156
+ expect(s.keys!.size).toBe(1);
157
+
158
+ write(s, NaN);
159
+ await settle();
160
+
161
+ expect(runs).toBe(1);
162
+ expect(observed[observed.length - 1]).toBe(true);
163
+ expect(s.keys!.size).toBe(1);
164
+ });
165
+
166
+ it('object key compares by reference: a stable key matches, a re-allocated one does not', async () => {
167
+ let k = { id: 1 },
168
+ observed: boolean[] = [],
169
+ s = signal<object>(k);
170
+
171
+ effect(() => {
172
+ observed.push(signal.selector(s, k));
173
+ });
174
+
175
+ expect(observed[0]).toBe(true);
176
+
177
+ observed.length = 0;
178
+
179
+ write(s, { id: 1 });
180
+ await settle();
181
+ expect(observed[observed.length - 1]).toBe(false);
182
+
183
+ write(s, k);
184
+ await settle();
185
+ expect(observed[observed.length - 1]).toBe(true);
186
+ });
187
+ });