@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,201 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { computed, effect, read, signal, write } from '~/system';
3
+ import { tick } from './lib/wait-for';
4
+
5
+
6
+ async function captureUncaught(run: () => void | Promise<void>): Promise<unknown[]> {
7
+ let captured: unknown[] = [],
8
+ listeners = process.listeners('uncaughtException');
9
+
10
+ process.removeAllListeners('uncaughtException');
11
+ process.on('uncaughtException', capture);
12
+
13
+ function capture(e: unknown) {
14
+ captured.push(e);
15
+ }
16
+
17
+ try {
18
+ await run();
19
+ await tick();
20
+ }
21
+ finally {
22
+ process.removeListener('uncaughtException', capture);
23
+
24
+ for (let i = 0, n = listeners.length; i < n; i++) {
25
+ process.on('uncaughtException', listeners[i]);
26
+ }
27
+ }
28
+
29
+ return captured;
30
+ }
31
+
32
+
33
+ describe('computed error caching', () => {
34
+ it('caches the error and rethrows at read without re-running fn', async () => {
35
+ let calls = 0,
36
+ s = signal(1),
37
+ c = computed(() => {
38
+ calls++;
39
+
40
+ if (read(s) % 2 !== 0) {
41
+ throw new Error('odd');
42
+ }
43
+
44
+ return read(s);
45
+ });
46
+
47
+ expect(() => read(c)).toThrow('odd');
48
+ expect(calls).toBe(1);
49
+
50
+ expect(() => read(c)).toThrow('odd');
51
+ expect(() => read(c)).toThrow('odd');
52
+ expect(calls).toBe(1);
53
+
54
+ write(s, 2);
55
+ await Promise.resolve();
56
+
57
+ expect(read(c)).toBe(2);
58
+ expect(calls).toBe(2);
59
+ });
60
+
61
+ it('rethrows the exact error instance', () => {
62
+ let error = new Error('boom'),
63
+ c = computed(() => {
64
+ throw error;
65
+ });
66
+
67
+ try {
68
+ read(c);
69
+ expect.unreachable();
70
+ }
71
+ catch (e) {
72
+ expect(e).toBe(error);
73
+ }
74
+ });
75
+
76
+ it('propagates through a computed chain', async () => {
77
+ let s = signal(1),
78
+ c1 = computed(() => {
79
+ if (read(s) % 2 !== 0) {
80
+ throw new Error('odd');
81
+ }
82
+
83
+ return read(s);
84
+ }),
85
+ c2 = computed(() => read(c1)),
86
+ c3 = computed(() => read(c2));
87
+
88
+ expect(() => read(c3)).toThrow('odd');
89
+
90
+ write(s, 2);
91
+ await Promise.resolve();
92
+
93
+ expect(read(c3)).toBe(2);
94
+ });
95
+
96
+ it('error to success with unchanged value still wakes dependents', async () => {
97
+ let s = signal(0),
98
+ c1 = computed(() => {
99
+ if (read(s) % 2 !== 0) {
100
+ throw new Error('odd');
101
+ }
102
+
103
+ return 10;
104
+ }),
105
+ c2 = computed(() => read(c1));
106
+
107
+ expect(read(c2)).toBe(10);
108
+
109
+ write(s, 1);
110
+ await Promise.resolve();
111
+
112
+ expect(() => read(c2)).toThrow('odd');
113
+
114
+ write(s, 2);
115
+ await Promise.resolve();
116
+
117
+ expect(read(c2)).toBe(10);
118
+ });
119
+ });
120
+
121
+
122
+ describe('effect error contract', () => {
123
+ it('effect without onError rethrows via microtask on first-run failure', async () => {
124
+ let captured = await captureUncaught(() => {
125
+ effect(() => {
126
+ throw new Error('first run boom');
127
+ });
128
+ });
129
+
130
+ expect(captured.length).toBe(1);
131
+ expect((captured[0] as Error).message).toBe('first run boom');
132
+ });
133
+
134
+ it('effect without onError rethrows via microtask on dependency-triggered failure', async () => {
135
+ let s = signal(0),
136
+ stop = effect(() => {
137
+ if (read(s) === 1) {
138
+ throw new Error('effect boom');
139
+ }
140
+ });
141
+
142
+ let captured = await captureUncaught(() => {
143
+ write(s, 1);
144
+ });
145
+
146
+ expect(captured.length).toBe(1);
147
+ expect((captured[0] as Error).message).toBe('effect boom');
148
+
149
+ stop();
150
+ });
151
+
152
+ it('effect rethrows on every failure, not just the first', async () => {
153
+ let s = signal(0),
154
+ stop = effect(() => {
155
+ let val = read(s);
156
+
157
+ if (val % 2 !== 0) {
158
+ throw new Error(`boom ${val}`);
159
+ }
160
+ });
161
+
162
+ let captured = await captureUncaught(async () => {
163
+ write(s, 1);
164
+ await tick();
165
+ write(s, 2);
166
+ await tick();
167
+ write(s, 3);
168
+ });
169
+
170
+ expect(captured.length).toBe(2);
171
+ expect((captured[0] as Error).message).toBe('boom 1');
172
+ expect((captured[1] as Error).message).toBe('boom 3');
173
+
174
+ stop();
175
+ });
176
+
177
+ it('effect onError receives the error and nothing rethrows', async () => {
178
+ let errors: unknown[] = [],
179
+ s = signal(0),
180
+ stop = effect(
181
+ () => {
182
+ if (read(s) === 1) {
183
+ throw new Error('handled');
184
+ }
185
+ },
186
+ (e) => {
187
+ errors.push(e);
188
+ }
189
+ );
190
+
191
+ let captured = await captureUncaught(() => {
192
+ write(s, 1);
193
+ });
194
+
195
+ expect(captured.length).toBe(0);
196
+ expect(errors.length).toBe(1);
197
+ expect((errors[0] as Error).message).toBe('handled');
198
+
199
+ stop();
200
+ });
201
+ });
@@ -0,0 +1,185 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { batch, computed, effect, flush, read, signal, write } from '~/system';
3
+
4
+
5
+ describe('flush()', () => {
6
+ it('runs the dependent effect synchronously before returning', () => {
7
+ let runs = 0,
8
+ s = signal(0);
9
+
10
+ effect(() => {
11
+ runs++;
12
+ read(s);
13
+ });
14
+
15
+ expect(runs).toBe(1);
16
+
17
+ write(s, 1);
18
+
19
+ expect(runs).toBe(1);
20
+
21
+ flush();
22
+
23
+ expect(runs).toBe(2);
24
+ });
25
+
26
+ it('drains a write-during-effect reschedule tail before returning', () => {
27
+ let s = signal(0),
28
+ c = computed(() => read(s) + 1),
29
+ innerRuns = 0,
30
+ outerRuns = 0,
31
+ proof = signal(0);
32
+
33
+ effect(() => {
34
+ outerRuns++;
35
+
36
+ if (read(c) === 2) {
37
+ write(proof, 1);
38
+ }
39
+ });
40
+
41
+ effect(() => {
42
+ innerRuns++;
43
+ read(proof);
44
+ });
45
+
46
+ expect(outerRuns).toBe(1);
47
+ expect(innerRuns).toBe(1);
48
+
49
+ write(s, 1);
50
+ flush();
51
+
52
+ expect(outerRuns).toBe(2);
53
+ expect(innerRuns).toBe(2);
54
+ expect(read(proof)).toBe(1);
55
+ });
56
+
57
+ it('is a safe no-op when called re-entrantly during stabilization, and does not block subsequent propagation', () => {
58
+ let effectARuns = 0,
59
+ effectBRuns = 0,
60
+ s = signal(0),
61
+ t = signal(0);
62
+
63
+ effect(() => {
64
+ effectARuns++;
65
+
66
+ if (read(s) === 1) {
67
+ flush();
68
+ write(t, 99);
69
+ }
70
+ });
71
+
72
+ effect(() => {
73
+ effectBRuns++;
74
+ read(t);
75
+ });
76
+
77
+ write(s, 1);
78
+ flush();
79
+
80
+ expect(effectARuns).toBe(2);
81
+ expect(effectBRuns).toBe(2);
82
+ expect(read(t)).toBe(99);
83
+ });
84
+
85
+ it('is a no-op when nothing is pending', () => {
86
+ expect(() => flush()).not.toThrow();
87
+ });
88
+
89
+ it('leaves the previously-queued microtask as a no-op after a manual flush', async () => {
90
+ let runs = 0,
91
+ s = signal(0);
92
+
93
+ effect(() => {
94
+ runs++;
95
+ read(s);
96
+ });
97
+
98
+ write(s, 1);
99
+ flush();
100
+
101
+ expect(runs).toBe(2);
102
+
103
+ await Promise.resolve();
104
+ await Promise.resolve();
105
+
106
+ expect(runs).toBe(2);
107
+ });
108
+ });
109
+
110
+ describe('batch()', () => {
111
+ it('defers scheduling until the batch completes, producing exactly one effect re-run', async () => {
112
+ let runs = 0,
113
+ s1 = signal(0),
114
+ s2 = signal(0);
115
+
116
+ effect(() => {
117
+ runs++;
118
+ read(s1);
119
+ read(s2);
120
+ });
121
+
122
+ expect(runs).toBe(1);
123
+
124
+ batch(() => {
125
+ write(s1, 1);
126
+ write(s2, 2);
127
+ });
128
+
129
+ await Promise.resolve();
130
+
131
+ expect(runs).toBe(2);
132
+ });
133
+
134
+ it('settles synchronously when paired with flush', () => {
135
+ let runs = 0,
136
+ s1 = signal(0),
137
+ s2 = signal(0);
138
+
139
+ effect(() => {
140
+ runs++;
141
+ read(s1);
142
+ read(s2);
143
+ });
144
+
145
+ batch(() => {
146
+ write(s1, 1);
147
+ write(s2, 2);
148
+ });
149
+ flush();
150
+
151
+ expect(runs).toBe(2);
152
+ });
153
+
154
+ it('returns the wrapped function value', () => {
155
+ let result = batch(() => 42);
156
+
157
+ expect(result).toBe(42);
158
+ });
159
+
160
+ it('restores depth when the wrapped function throws', () => {
161
+ let s = signal(0);
162
+
163
+ expect(() => {
164
+ batch(() => {
165
+ write(s, 1);
166
+ throw new Error('reactivity: test error');
167
+ });
168
+ }).toThrow('reactivity: test error');
169
+
170
+ // depth must have unwound to 0, so a fresh write schedules normally instead of deferring forever.
171
+ let runs = 0;
172
+
173
+ effect(() => {
174
+ runs++;
175
+ read(s);
176
+ });
177
+
178
+ expect(runs).toBe(1);
179
+
180
+ write(s, 2);
181
+ flush();
182
+
183
+ expect(runs).toBe(2);
184
+ });
185
+ });
@@ -0,0 +1,115 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { computed, effect, read, signal, write } from '~/system';
3
+
4
+
5
+ // Correctness guarantees the algorithm items (read-version-dedup,
6
+ // global-version-fast-path, pending-only-writes) must preserve
7
+
8
+ describe('glitch freedom', () => {
9
+ it('diamond: join computes exactly once per write and never observes mixed state', async () => {
10
+ let s = signal(1),
11
+ observed: [number, number][] = [],
12
+ b = computed(() => read(s) + 1),
13
+ c = computed(() => read(s) * 2),
14
+ d = computed(() => {
15
+ let bv = read(b),
16
+ cv = read(c);
17
+
18
+ observed.push([bv, cv]);
19
+
20
+ return bv + cv;
21
+ });
22
+
23
+ effect(() => {
24
+ read(d);
25
+ });
26
+
27
+ expect(read(d)).toBe(4);
28
+
29
+ observed.length = 0;
30
+
31
+ write(s, 2);
32
+ await Promise.resolve();
33
+
34
+ // One run, and the pair is consistent for s=2 — never a mixed b/c state
35
+ expect(observed).toEqual([[3, 4]]);
36
+ expect(read(d)).toBe(7);
37
+
38
+ observed.length = 0;
39
+
40
+ write(s, 5);
41
+ await Promise.resolve();
42
+
43
+ expect(observed).toEqual([[6, 10]]);
44
+ expect(read(d)).toBe(16);
45
+ });
46
+
47
+ it('unstable: dynamic dep swap mid-propagation settles correctly with no double-run', async () => {
48
+ let runs = 0,
49
+ s = signal(1),
50
+ double = computed(() => read(s) * 2),
51
+ inverse = computed(() => -read(s)),
52
+ current = computed(() => {
53
+ runs++;
54
+
55
+ return read(s) % 2 ? read(double) : read(inverse);
56
+ });
57
+
58
+ // Keepers hold a subscription on both branches — last-sub unlink auto-disposes a computed
59
+ effect(() => {
60
+ read(double);
61
+ });
62
+
63
+ effect(() => {
64
+ read(inverse);
65
+ });
66
+
67
+ effect(() => {
68
+ read(current);
69
+ });
70
+
71
+ expect(read(current)).toBe(2);
72
+
73
+ runs = 0;
74
+
75
+ write(s, 2);
76
+ await Promise.resolve();
77
+
78
+ // Swapped from double to inverse in one settle pass
79
+ expect(read(current)).toBe(-2);
80
+ expect(runs).toBe(1);
81
+
82
+ runs = 0;
83
+
84
+ write(s, 3);
85
+ await Promise.resolve();
86
+
87
+ // Swapped back
88
+ expect(read(current)).toBe(6);
89
+ expect(runs).toBe(1);
90
+ });
91
+
92
+ it('repeated observers: one effect reading the same signal 3x re-runs exactly once per write', async () => {
93
+ let runs = 0,
94
+ s = signal(0);
95
+
96
+ effect(() => {
97
+ read(s);
98
+ read(s);
99
+ read(s);
100
+ runs++;
101
+ });
102
+
103
+ expect(runs).toBe(1);
104
+
105
+ write(s, 1);
106
+ await Promise.resolve();
107
+
108
+ expect(runs).toBe(2);
109
+
110
+ write(s, 2);
111
+ await Promise.resolve();
112
+
113
+ expect(runs).toBe(3);
114
+ });
115
+ });
@@ -0,0 +1,147 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { computed, effect, flush, peek, read, signal, write } from '~/system';
3
+ import { waitFor } from './lib/wait-for';
4
+
5
+
6
+ describe('invalidate()', () => {
7
+ it('re-runs the computed fn with no dependency change', () => {
8
+ let runs = 0,
9
+ s = signal(1);
10
+
11
+ let c = computed(() => {
12
+ runs++;
13
+
14
+ return read(s);
15
+ });
16
+
17
+ effect(() => {
18
+ read(c);
19
+ });
20
+
21
+ expect(runs).toBe(1);
22
+
23
+ computed.invalidate(c);
24
+ flush();
25
+
26
+ expect(runs).toBe(2);
27
+ });
28
+
29
+ it('dependents re-run only when the forced re-run changes the value', () => {
30
+ let changingRuns = 0,
31
+ s = signal(1),
32
+ stableRuns = 0,
33
+ ticks = 0;
34
+
35
+ let changing = computed(() => {
36
+ read(s);
37
+
38
+ return ++ticks;
39
+ });
40
+
41
+ let stable = computed(() => read(s));
42
+
43
+ effect(() => {
44
+ stableRuns++;
45
+ read(stable);
46
+ });
47
+
48
+ effect(() => {
49
+ changingRuns++;
50
+ read(changing);
51
+ });
52
+
53
+ computed.invalidate(stable);
54
+ flush();
55
+
56
+ expect(stableRuns).toBe(1);
57
+
58
+ computed.invalidate(changing);
59
+ flush();
60
+
61
+ expect(changingRuns).toBe(2);
62
+ });
63
+
64
+ it('re-dispatches an asyncComputed factory (refetch via the asyncMeta redirect)', async () => {
65
+ let fetches = 0;
66
+
67
+ let node = computed(() => {
68
+ fetches++;
69
+
70
+ return Promise.resolve(fetches);
71
+ });
72
+
73
+ let stop = effect(() => {
74
+ read(node);
75
+ });
76
+
77
+ await waitFor(() => read(node) === 1, 'node resolves to 1');
78
+
79
+ expect(fetches).toBe(1);
80
+ expect(read(node)).toBe(1);
81
+
82
+ computed.invalidate(node);
83
+ await waitFor(() => read(node) === 2, 'node refetches to 2');
84
+
85
+ expect(fetches).toBe(2);
86
+ expect(read(node)).toBe(2);
87
+
88
+ stop();
89
+ });
90
+
91
+ it('forces a side-effecting computed (the public effect encoding) to re-run without propagating', () => {
92
+ let keeperRuns = 0,
93
+ s = signal(1),
94
+ sideRuns = 0;
95
+
96
+ let c = computed<void>(() => {
97
+ read(s);
98
+ sideRuns++;
99
+ });
100
+
101
+ effect(() => {
102
+ keeperRuns++;
103
+ read(c);
104
+ });
105
+
106
+ expect(sideRuns).toBe(1);
107
+ expect(keeperRuns).toBe(1);
108
+
109
+ computed.invalidate(c);
110
+ flush();
111
+
112
+ expect(sideRuns).toBe(2);
113
+ expect(keeperRuns).toBe(1);
114
+ });
115
+
116
+ it('the global-version fast path does not swallow a forced re-run', () => {
117
+ let runs = 0,
118
+ s = signal(1);
119
+
120
+ let c = computed(() => {
121
+ runs++;
122
+
123
+ return read(s);
124
+ });
125
+
126
+ effect(() => {
127
+ read(c);
128
+ });
129
+
130
+ write(s, 2);
131
+ flush();
132
+
133
+ expect(runs).toBe(2);
134
+
135
+ // Fully settled: c carries the current gv stamp. Without writes++ inside invalidate,
136
+ // this synchronous pull would exit through the fast path and skip the re-run. peek()
137
+ // forces the pull unconditionally (read() outside an observer returns the cached value).
138
+ computed.invalidate(c);
139
+
140
+ expect(peek(c)).toBe(2);
141
+ expect(runs).toBe(3);
142
+
143
+ flush();
144
+
145
+ expect(runs).toBe(3);
146
+ });
147
+ });
@@ -0,0 +1,28 @@
1
+ const tick = (times = 1): Promise<void> => {
2
+ let p = Promise.resolve();
3
+
4
+ for (let i = 0; i < times; i++) {
5
+ p = p.then(() => new Promise<void>((resolve) => setTimeout(resolve, 0)));
6
+ }
7
+
8
+ return p;
9
+ };
10
+
11
+ const waitFor = (condition: () => boolean, description: string, timeoutMs = 1000): Promise<void> => {
12
+ return new Promise<void>((resolve, reject) => {
13
+ let deadline = Date.now() + timeoutMs,
14
+ timer = setInterval(() => {
15
+ if (condition()) {
16
+ clearInterval(timer);
17
+ resolve();
18
+ }
19
+ else if (Date.now() >= deadline) {
20
+ clearInterval(timer);
21
+ reject(new Error('wait-for: timed out waiting for ' + description));
22
+ }
23
+ }, 10);
24
+ });
25
+ };
26
+
27
+
28
+ export { tick, waitFor };