@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.
- package/.claude/CHANGELOG.md +43 -0
- package/README.md +13 -6
- package/bench/cellx.bench.ts +61 -0
- package/bench/dynamic.bench.ts +103 -0
- package/bench/kairo.bench.ts +368 -0
- package/bench/lib/reactive-adapter.ts +51 -0
- package/bench/molbench.bench.ts +75 -0
- package/{tests/bench/system.ts → bench/system.bench.ts} +3 -3
- package/build/compiler/array.d.ts +1 -1
- package/build/compiler/constants.d.ts +7 -6
- package/build/compiler/constants.js +6 -7
- package/build/compiler/object.d.ts +1 -1
- package/build/compiler/object.js +1 -2
- package/build/compiler/primitives.d.ts +1 -1
- package/build/constants.d.ts +3 -1
- package/build/constants.js +3 -1
- package/build/reactive/array.d.ts +8 -5
- package/build/reactive/array.js +14 -14
- package/build/reactive/index.d.ts +2 -2
- package/build/reactive/index.js +1 -1
- package/build/reactive/object.d.ts +4 -4
- package/build/reactive/object.js +8 -23
- package/build/system.d.ts +15 -6
- package/build/system.js +415 -69
- package/build/types.d.ts +16 -4
- package/package.json +5 -2
- package/src/compiler/array.ts +1 -1
- package/src/compiler/constants.ts +8 -6
- package/src/compiler/index.ts +2 -1
- package/src/compiler/object.ts +2 -2
- package/src/compiler/plugins/vite.ts +1 -0
- package/src/compiler/primitives.ts +1 -1
- package/src/constants.ts +6 -2
- package/src/reactive/array.ts +26 -23
- package/src/reactive/index.ts +6 -6
- package/src/reactive/object.ts +16 -41
- package/src/system.ts +612 -80
- package/src/types.ts +29 -9
- package/test/async-computed.test.ts +323 -0
- package/test/async-errors.test.ts +146 -0
- package/test/async-hardening.test.ts +73 -0
- package/test/async-iterable.test.ts +221 -0
- package/test/async-nested.test.ts +19 -0
- package/test/deep-graphs.test.ts +215 -0
- package/{tests/effects.ts → test/effects.test.ts} +60 -0
- package/test/equals.test.ts +142 -0
- package/test/errors.test.ts +201 -0
- package/test/flush.test.ts +185 -0
- package/test/glitch-freedom.test.ts +115 -0
- package/test/invalidate.test.ts +147 -0
- package/test/lib/wait-for.ts +28 -0
- package/test/pending-writes.test.ts +139 -0
- package/{tests/reactive.ts → test/reactive/reactive.test.ts} +20 -19
- package/test/read-dedup.test.ts +120 -0
- package/test/signal-selector.test.ts +187 -0
- package/{tests/system.ts → test/system.test.ts} +214 -23
- package/test/tsconfig.json +16 -0
- package/test/untrack.test.ts +146 -0
- package/vitest.config.ts +2 -2
- package/tests/async-computed.ts +0 -239
- /package/{tests/bench/array.ts → bench/reactive/array.bench.ts} +0 -0
- /package/{tests/bench/reactive-object.ts → bench/reactive/reactive-object.bench.ts} +0 -0
- /package/{tests → bench}/tsconfig.json +0 -0
- /package/{tests/compiler.ts → test/compiler/compiler.test.ts} +0 -0
- /package/{tests/primitives.ts → test/primitives.test.ts} +0 -0
- /package/{tests/array.ts → test/reactive/array.test.ts} +0 -0
- /package/{tests/nested.ts → test/reactive/nested.test.ts} +0 -0
- /package/{tests/objects.ts → test/reactive/objects.test.ts} +0 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { computed, effect, read, root, signal, write } from '~/system';
|
|
3
|
+
import { tick } from './lib/wait-for';
|
|
4
|
+
import type { Computed } from '~/system';
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
// resolve() settle contract: fn returning undefined means "not ready yet, keep waiting"
|
|
8
|
+
// (asyncComputed nodes hold undefined pre-first-settle, so `() => read(wrapper)` composes
|
|
9
|
+
// naturally); the first non-undefined value resolves the returned promise, a throw rejects it.
|
|
10
|
+
//
|
|
11
|
+
// AsyncIterable tracking pin: an async generator's body runs synchronously up to its first
|
|
12
|
+
// await/yield INSIDE the factory's first next() call, under the polling effect's observer.
|
|
13
|
+
// Dependencies belong in the factory fn body, read synchronously BEFORE the iterable is
|
|
14
|
+
// returned — reads inside the generator body itself never link into the polling effect.
|
|
15
|
+
function gate(): { promise: Promise<void>; resolve: VoidFunction } {
|
|
16
|
+
let resolveFn!: VoidFunction;
|
|
17
|
+
|
|
18
|
+
let promise = new Promise<void>((res) => {
|
|
19
|
+
resolveFn = res;
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
return { promise, resolve: resolveFn };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
describe('asyncComputed AsyncIterable support', () => {
|
|
27
|
+
it('yields land in order; return() fires on re-run', async () => {
|
|
28
|
+
let allGates: ReturnType<typeof gate>[][] = [],
|
|
29
|
+
calls = 0,
|
|
30
|
+
node!: Computed<number | undefined>,
|
|
31
|
+
returned = 0,
|
|
32
|
+
s = signal(1);
|
|
33
|
+
|
|
34
|
+
let feed = () => {
|
|
35
|
+
calls++;
|
|
36
|
+
|
|
37
|
+
let myGates = [gate(), gate()];
|
|
38
|
+
|
|
39
|
+
allGates.push(myGates);
|
|
40
|
+
|
|
41
|
+
return (async function* () {
|
|
42
|
+
try {
|
|
43
|
+
await myGates[0].promise;
|
|
44
|
+
yield 1;
|
|
45
|
+
|
|
46
|
+
await myGates[1].promise;
|
|
47
|
+
yield 2;
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
returned++;
|
|
51
|
+
}
|
|
52
|
+
})();
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
root(() => {
|
|
56
|
+
node = computed(() => {
|
|
57
|
+
read(s);
|
|
58
|
+
|
|
59
|
+
return feed();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(calls).toBe(1);
|
|
64
|
+
|
|
65
|
+
allGates[0][0].resolve();
|
|
66
|
+
await tick();
|
|
67
|
+
|
|
68
|
+
expect(read(node)).toBe(1);
|
|
69
|
+
|
|
70
|
+
// Mid-iteration: generation 0's generator is suspended awaiting its second gate.
|
|
71
|
+
// Re-run the factory before releasing it — the old iterator is abandoned.
|
|
72
|
+
write(s, 2);
|
|
73
|
+
await tick();
|
|
74
|
+
|
|
75
|
+
expect(calls).toBe(2);
|
|
76
|
+
|
|
77
|
+
// The abandoned generation-0 generator only unwinds once its own pending internal
|
|
78
|
+
// await settles (async generator return() semantics) — release it to observe close.
|
|
79
|
+
allGates[0][1].resolve();
|
|
80
|
+
await tick();
|
|
81
|
+
|
|
82
|
+
expect(returned).toBe(1);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('return() fires on disposal', async () => {
|
|
86
|
+
let allGates: ReturnType<typeof gate>[][] = [],
|
|
87
|
+
node!: Computed<number | undefined>,
|
|
88
|
+
returned = 0,
|
|
89
|
+
s = signal(1),
|
|
90
|
+
stopRoot!: VoidFunction;
|
|
91
|
+
|
|
92
|
+
let feed = () => {
|
|
93
|
+
let myGates = [gate(), gate()];
|
|
94
|
+
|
|
95
|
+
allGates.push(myGates);
|
|
96
|
+
|
|
97
|
+
return (async function* () {
|
|
98
|
+
try {
|
|
99
|
+
await myGates[0].promise;
|
|
100
|
+
yield 1;
|
|
101
|
+
|
|
102
|
+
await myGates[1].promise;
|
|
103
|
+
yield 2;
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
returned++;
|
|
107
|
+
}
|
|
108
|
+
})();
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
root((dispose) => {
|
|
112
|
+
stopRoot = dispose;
|
|
113
|
+
|
|
114
|
+
node = computed(() => {
|
|
115
|
+
read(s);
|
|
116
|
+
|
|
117
|
+
return feed();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
let keeper = effect(() => {
|
|
122
|
+
read(node);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
allGates[0][0].resolve();
|
|
126
|
+
await tick();
|
|
127
|
+
|
|
128
|
+
expect(read(node)).toBe(1);
|
|
129
|
+
|
|
130
|
+
// Mid-iteration: dispose while the generator is suspended awaiting its second gate.
|
|
131
|
+
keeper();
|
|
132
|
+
stopRoot();
|
|
133
|
+
|
|
134
|
+
allGates[0][1].resolve();
|
|
135
|
+
await tick();
|
|
136
|
+
|
|
137
|
+
expect(returned).toBe(1);
|
|
138
|
+
expect(read(node)).toBe(1); // no further value landed
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('latest-wins across iterables: a stale yield from an abandoned iterator never lands', async () => {
|
|
142
|
+
let allGates: ReturnType<typeof gate>[][] = [],
|
|
143
|
+
calls = 0,
|
|
144
|
+
node!: Computed<number | undefined>,
|
|
145
|
+
s = signal(1);
|
|
146
|
+
|
|
147
|
+
let feed = () => {
|
|
148
|
+
calls++;
|
|
149
|
+
|
|
150
|
+
let generation = calls,
|
|
151
|
+
myGates = [gate(), gate()];
|
|
152
|
+
|
|
153
|
+
allGates.push(myGates);
|
|
154
|
+
|
|
155
|
+
return (async function* () {
|
|
156
|
+
await myGates[0].promise;
|
|
157
|
+
yield generation * 100;
|
|
158
|
+
|
|
159
|
+
await myGates[1].promise;
|
|
160
|
+
yield generation * 100 + 1;
|
|
161
|
+
})();
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
root(() => {
|
|
165
|
+
node = computed(() => {
|
|
166
|
+
read(s);
|
|
167
|
+
|
|
168
|
+
return feed();
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
allGates[0][0].resolve();
|
|
173
|
+
await tick();
|
|
174
|
+
|
|
175
|
+
expect(read(node)).toBe(100);
|
|
176
|
+
|
|
177
|
+
// Re-run before releasing generation 0's second gate — it becomes abandoned.
|
|
178
|
+
write(s, 2);
|
|
179
|
+
await tick();
|
|
180
|
+
|
|
181
|
+
expect(calls).toBe(2);
|
|
182
|
+
|
|
183
|
+
// Release the abandoned generation's pending gate: its stale second yield (101)
|
|
184
|
+
// reaches step(), but the id !== v guard drops it before it ever touches node.
|
|
185
|
+
allGates[0][1].resolve();
|
|
186
|
+
await tick();
|
|
187
|
+
|
|
188
|
+
expect(read(node)).toBe(100);
|
|
189
|
+
|
|
190
|
+
// The fresh iterator still works normally.
|
|
191
|
+
allGates[1][0].resolve();
|
|
192
|
+
await tick();
|
|
193
|
+
|
|
194
|
+
expect(read(node)).toBe(200);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('a rejecting iterator step surfaces via the error contract', async () => {
|
|
198
|
+
let node!: Computed<number | undefined>,
|
|
199
|
+
rejectGate = gate();
|
|
200
|
+
|
|
201
|
+
let feed = async function* () {
|
|
202
|
+
yield 1;
|
|
203
|
+
|
|
204
|
+
await rejectGate.promise;
|
|
205
|
+
throw new Error('iterable step boom');
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
root(() => {
|
|
209
|
+
node = computed(() => feed());
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
await tick();
|
|
213
|
+
|
|
214
|
+
expect(read(node)).toBe(1);
|
|
215
|
+
|
|
216
|
+
rejectGate.resolve();
|
|
217
|
+
await tick();
|
|
218
|
+
|
|
219
|
+
expect(() => read(node)).toThrow('iterable step boom');
|
|
220
|
+
});
|
|
221
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { computed, effect, read, root, signal } from '~/system';
|
|
3
|
+
import { waitFor } from './lib/wait-for';
|
|
4
|
+
import type { Computed } from '~/system';
|
|
5
|
+
|
|
6
|
+
describe('async computed created mid-tracking', () => {
|
|
7
|
+
it('detects async when it is not the first tracked op', async () => {
|
|
8
|
+
let captured: Computed<number | undefined> | null = null,
|
|
9
|
+
s = signal(0);
|
|
10
|
+
root(() => {
|
|
11
|
+
effect(() => {
|
|
12
|
+
read(s); // 1st tracked op
|
|
13
|
+
captured = computed(() => Promise.resolve(42)); // 2nd op — deferred branch
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
await waitFor(() => read(captured!) === 42, 'nested async resolves to 42');
|
|
17
|
+
expect(read(captured!)).toBe(42);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { computed, effect, flush, peek, read, signal, write } from '~/system';
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
// Depth chosen well past the V8 call-stack frame limit (~10-15k): a recursive notify/update/dispose
|
|
6
|
+
// would RangeError here, an iterative walk does not.
|
|
7
|
+
const DEPTH = 200000;
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
describe('deep graphs', () => {
|
|
11
|
+
it('a 200k-deep chain settles at the leaf with no overflow (scheduled + pull paths)', () => {
|
|
12
|
+
let chain: ReturnType<typeof computed<number>>[] = [],
|
|
13
|
+
s = signal(0),
|
|
14
|
+
seen = -1;
|
|
15
|
+
|
|
16
|
+
chain[0] = computed(() => read(s) + 1);
|
|
17
|
+
|
|
18
|
+
for (let i = 1; i < DEPTH; i++) {
|
|
19
|
+
let prev = chain[i - 1];
|
|
20
|
+
|
|
21
|
+
chain[i] = computed(() => read(prev) + 1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let leaf = chain[DEPTH - 1];
|
|
25
|
+
|
|
26
|
+
let stop = effect(() => {
|
|
27
|
+
seen = read(leaf);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
expect(seen).toBe(DEPTH);
|
|
31
|
+
|
|
32
|
+
// Scheduled path: write + flush drains the height buckets.
|
|
33
|
+
write(s, 1);
|
|
34
|
+
flush();
|
|
35
|
+
|
|
36
|
+
expect(seen).toBe(DEPTH + 1);
|
|
37
|
+
|
|
38
|
+
// Pull path: a synchronous peek before flush forces the notify broadcast + the update walk.
|
|
39
|
+
write(s, 2);
|
|
40
|
+
|
|
41
|
+
expect(peek(leaf)).toBe(DEPTH + 2);
|
|
42
|
+
|
|
43
|
+
flush();
|
|
44
|
+
|
|
45
|
+
expect(seen).toBe(DEPTH + 2);
|
|
46
|
+
|
|
47
|
+
stop();
|
|
48
|
+
}, 30000);
|
|
49
|
+
|
|
50
|
+
it('a wide fan-out with deep branches settles (notify sibling traversal)', () => {
|
|
51
|
+
let leaves: ReturnType<typeof computed<number>>[] = [],
|
|
52
|
+
observed = -1,
|
|
53
|
+
s = signal(1),
|
|
54
|
+
width = 100,
|
|
55
|
+
branchDepth = 50;
|
|
56
|
+
|
|
57
|
+
for (let b = 0; b < width; b++) {
|
|
58
|
+
let node = computed(() => read(s));
|
|
59
|
+
|
|
60
|
+
for (let d = 0; d < branchDepth; d++) {
|
|
61
|
+
let prev = node;
|
|
62
|
+
|
|
63
|
+
node = computed(() => read(prev) + 1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
leaves.push(node);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let sum = computed(() => {
|
|
70
|
+
let total = 0;
|
|
71
|
+
|
|
72
|
+
for (let i = 0, n = leaves.length; i < n; i++) {
|
|
73
|
+
total += read(leaves[i]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return total;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
let stop = effect(() => {
|
|
80
|
+
observed = read(sum);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(observed).toBe(width * (1 + branchDepth));
|
|
84
|
+
|
|
85
|
+
write(s, 2);
|
|
86
|
+
|
|
87
|
+
expect(peek(sum)).toBe(width * (2 + branchDepth));
|
|
88
|
+
|
|
89
|
+
flush();
|
|
90
|
+
|
|
91
|
+
expect(observed).toBe(width * (2 + branchDepth));
|
|
92
|
+
|
|
93
|
+
stop();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('a diamond recomputes the sink once per source change (glitch-free, no redundant checks)', () => {
|
|
97
|
+
let aRuns = 0,
|
|
98
|
+
bRuns = 0,
|
|
99
|
+
cRuns = 0,
|
|
100
|
+
s = signal(1);
|
|
101
|
+
|
|
102
|
+
let a = computed(() => {
|
|
103
|
+
aRuns++;
|
|
104
|
+
|
|
105
|
+
return read(s) + 1;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
let b = computed(() => {
|
|
109
|
+
bRuns++;
|
|
110
|
+
|
|
111
|
+
return read(s) * 2;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
let c = computed(() => {
|
|
115
|
+
cRuns++;
|
|
116
|
+
|
|
117
|
+
return read(a) + read(b);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
let stop = effect(() => {
|
|
121
|
+
read(c);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(aRuns).toBe(1);
|
|
125
|
+
expect(bRuns).toBe(1);
|
|
126
|
+
expect(cRuns).toBe(1);
|
|
127
|
+
expect(peek(c)).toBe(4);
|
|
128
|
+
|
|
129
|
+
write(s, 2);
|
|
130
|
+
flush();
|
|
131
|
+
|
|
132
|
+
expect(aRuns).toBe(2);
|
|
133
|
+
expect(bRuns).toBe(2);
|
|
134
|
+
expect(cRuns).toBe(2);
|
|
135
|
+
expect(peek(c)).toBe(7);
|
|
136
|
+
|
|
137
|
+
stop();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('a dynamic conditional dep does not over-recompute (DIRTY-break)', () => {
|
|
141
|
+
let a = signal(10),
|
|
142
|
+
b = signal(20),
|
|
143
|
+
cRuns = 0,
|
|
144
|
+
s = signal(true);
|
|
145
|
+
|
|
146
|
+
let c = computed(() => {
|
|
147
|
+
cRuns++;
|
|
148
|
+
|
|
149
|
+
return read(s) ? read(a) : read(b);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
let stop = effect(() => {
|
|
153
|
+
read(c);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
expect(cRuns).toBe(1);
|
|
157
|
+
expect(peek(c)).toBe(10);
|
|
158
|
+
|
|
159
|
+
// b is not a dep while s is true.
|
|
160
|
+
write(b, 99);
|
|
161
|
+
flush();
|
|
162
|
+
|
|
163
|
+
expect(cRuns).toBe(1);
|
|
164
|
+
|
|
165
|
+
write(s, false);
|
|
166
|
+
flush();
|
|
167
|
+
|
|
168
|
+
expect(cRuns).toBe(2);
|
|
169
|
+
expect(peek(c)).toBe(99);
|
|
170
|
+
|
|
171
|
+
// a is no longer a dep.
|
|
172
|
+
write(a, 5);
|
|
173
|
+
flush();
|
|
174
|
+
|
|
175
|
+
expect(cRuns).toBe(2);
|
|
176
|
+
|
|
177
|
+
stop();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('teardown of a 200k-deep chain completes without overflow; cleanup fires once per node', () => {
|
|
181
|
+
let chain: ReturnType<typeof computed<number>>[] = [],
|
|
182
|
+
cleanups = 0,
|
|
183
|
+
s = signal(0);
|
|
184
|
+
|
|
185
|
+
chain[0] = computed((onCleanup) => {
|
|
186
|
+
onCleanup(() => cleanups++);
|
|
187
|
+
|
|
188
|
+
return read(s) + 1;
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
for (let i = 1; i < DEPTH; i++) {
|
|
192
|
+
let prev = chain[i - 1];
|
|
193
|
+
|
|
194
|
+
chain[i] = computed((onCleanup) => {
|
|
195
|
+
onCleanup(() => cleanups++);
|
|
196
|
+
|
|
197
|
+
return read(prev) + 1;
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let stop = effect(() => {
|
|
202
|
+
read(chain[DEPTH - 1]);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
stop();
|
|
206
|
+
|
|
207
|
+
expect(cleanups).toBe(DEPTH);
|
|
208
|
+
|
|
209
|
+
// Nothing survives to re-run.
|
|
210
|
+
write(s, 1);
|
|
211
|
+
flush();
|
|
212
|
+
|
|
213
|
+
expect(cleanups).toBe(DEPTH);
|
|
214
|
+
}, 30000);
|
|
215
|
+
});
|
|
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|
|
2
2
|
import { computed, effect, onCleanup, read, root, signal, write } from '~/system';
|
|
3
3
|
import { ReactiveArray } from '~/reactive/array';
|
|
4
4
|
import { ReactiveObject } from '~/reactive/object';
|
|
5
|
+
import { waitFor } from './lib/wait-for';
|
|
5
6
|
|
|
6
7
|
|
|
7
8
|
// These tests validate effect patterns from the compiler integration tests,
|
|
@@ -208,4 +209,63 @@ describe('effect patterns', () => {
|
|
|
208
209
|
expect(lengths).toEqual([3, 4, 3, 2]);
|
|
209
210
|
});
|
|
210
211
|
});
|
|
212
|
+
|
|
213
|
+
describe('writes across an async boundary in a reaction', () => {
|
|
214
|
+
it('a write before the await propagates in the current flush; a write after the await propagates once settled', async () => {
|
|
215
|
+
let post = signal(0),
|
|
216
|
+
postSeen: number[] = [],
|
|
217
|
+
pre = signal(0),
|
|
218
|
+
preSeen: number[] = [],
|
|
219
|
+
trigger = signal(0);
|
|
220
|
+
|
|
221
|
+
root(() => {
|
|
222
|
+
effect(() => {
|
|
223
|
+
preSeen.push(read(pre));
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
effect(() => {
|
|
227
|
+
postSeen.push(read(post));
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// Reaction writes `pre` synchronously (before the await) and `post` in the
|
|
231
|
+
// deferred continuation (after the await) — reactively's before/after-await case
|
|
232
|
+
effect(() => {
|
|
233
|
+
let v = read(trigger);
|
|
234
|
+
|
|
235
|
+
if (v > 0) {
|
|
236
|
+
write(pre, v);
|
|
237
|
+
setTimeout(() => write(post, v * 10), 10);
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// Baseline: observers hold the initial 0
|
|
243
|
+
expect(preSeen).toEqual([0]);
|
|
244
|
+
expect(postSeen).toEqual([0]);
|
|
245
|
+
|
|
246
|
+
write(trigger, 1);
|
|
247
|
+
await Promise.resolve();
|
|
248
|
+
await Promise.resolve();
|
|
249
|
+
|
|
250
|
+
// Before-await write already landed; after-await write still pending
|
|
251
|
+
expect(preSeen).toEqual([0, 1]);
|
|
252
|
+
expect(postSeen).toEqual([0]);
|
|
253
|
+
|
|
254
|
+
await waitFor(() => postSeen.length === 2, 'after-await write lands (post=10)');
|
|
255
|
+
|
|
256
|
+
// After-await write lands once the boundary settles
|
|
257
|
+
expect(postSeen).toEqual([0, 10]);
|
|
258
|
+
|
|
259
|
+
write(trigger, 2);
|
|
260
|
+
await Promise.resolve();
|
|
261
|
+
await Promise.resolve();
|
|
262
|
+
|
|
263
|
+
expect(preSeen).toEqual([0, 1, 2]);
|
|
264
|
+
expect(postSeen).toEqual([0, 10]);
|
|
265
|
+
|
|
266
|
+
await waitFor(() => postSeen.length === 3, 'second after-await write lands (post=20)');
|
|
267
|
+
|
|
268
|
+
expect(postSeen).toEqual([0, 10, 20]);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
211
271
|
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { computed, effect, flush, peek, read, signal, write } from '~/system';
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
describe('custom equals', () => {
|
|
6
|
+
it('a signal with an always-true comparator never propagates (and never stores)', () => {
|
|
7
|
+
let runs = 0,
|
|
8
|
+
s = signal(0, () => true);
|
|
9
|
+
|
|
10
|
+
effect(() => {
|
|
11
|
+
runs++;
|
|
12
|
+
read(s);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
expect(runs).toBe(1);
|
|
16
|
+
|
|
17
|
+
write(s, 1);
|
|
18
|
+
flush();
|
|
19
|
+
|
|
20
|
+
expect(runs).toBe(1);
|
|
21
|
+
expect(peek(s)).toBe(0);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('Object.is suppresses a NaN re-write; the default === re-triggers on NaN', () => {
|
|
25
|
+
let defaultRuns = 0,
|
|
26
|
+
isRuns = 0,
|
|
27
|
+
sDefault = signal(NaN),
|
|
28
|
+
sIs = signal(NaN, Object.is);
|
|
29
|
+
|
|
30
|
+
effect(() => {
|
|
31
|
+
isRuns++;
|
|
32
|
+
read(sIs);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
effect(() => {
|
|
36
|
+
defaultRuns++;
|
|
37
|
+
read(sDefault);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
write(sIs, NaN);
|
|
41
|
+
flush();
|
|
42
|
+
|
|
43
|
+
expect(isRuns).toBe(1);
|
|
44
|
+
|
|
45
|
+
write(sDefault, NaN);
|
|
46
|
+
flush();
|
|
47
|
+
|
|
48
|
+
expect(defaultRuns).toBe(2);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('a computed custom equals suppresses propagation while the cached value still updates', () => {
|
|
52
|
+
let cRuns = 0,
|
|
53
|
+
effectRuns = 0,
|
|
54
|
+
s = signal(1);
|
|
55
|
+
|
|
56
|
+
let c = computed(() => {
|
|
57
|
+
cRuns++;
|
|
58
|
+
|
|
59
|
+
return { parity: read(s) % 2, tick: cRuns };
|
|
60
|
+
}, (a, b) => a.parity === b.parity);
|
|
61
|
+
|
|
62
|
+
effect(() => {
|
|
63
|
+
effectRuns++;
|
|
64
|
+
read(c);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(cRuns).toBe(1);
|
|
68
|
+
expect(effectRuns).toBe(1);
|
|
69
|
+
|
|
70
|
+
write(s, 3);
|
|
71
|
+
flush();
|
|
72
|
+
|
|
73
|
+
expect(cRuns).toBe(2);
|
|
74
|
+
expect(effectRuns).toBe(1);
|
|
75
|
+
expect(peek(c).tick).toBe(2);
|
|
76
|
+
|
|
77
|
+
write(s, 2);
|
|
78
|
+
flush();
|
|
79
|
+
|
|
80
|
+
expect(effectRuns).toBe(2);
|
|
81
|
+
expect(peek(c).parity).toBe(0);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('error recovery propagates under an always-true comparator (the hadError leg wins)', () => {
|
|
85
|
+
let log: number[] = [],
|
|
86
|
+
s = signal(0);
|
|
87
|
+
|
|
88
|
+
let c = computed(() => {
|
|
89
|
+
if (read(s) === 1) {
|
|
90
|
+
throw new Error('equals boom');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return 0;
|
|
94
|
+
}, () => true);
|
|
95
|
+
|
|
96
|
+
let d = computed(() => {
|
|
97
|
+
try {
|
|
98
|
+
return read(c);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return -1;
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
effect(() => {
|
|
106
|
+
log.push(read(d));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(log).toEqual([0]);
|
|
110
|
+
|
|
111
|
+
write(s, 1);
|
|
112
|
+
flush();
|
|
113
|
+
|
|
114
|
+
expect(log).toEqual([0, -1]);
|
|
115
|
+
expect(() => read(c)).toThrow('equals boom');
|
|
116
|
+
|
|
117
|
+
write(s, 0);
|
|
118
|
+
flush();
|
|
119
|
+
|
|
120
|
+
expect(log).toEqual([0, -1, 0]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('the default path is byte-identical: no comparator gates with ===', () => {
|
|
124
|
+
let runs = 0,
|
|
125
|
+
s = signal(1);
|
|
126
|
+
|
|
127
|
+
effect(() => {
|
|
128
|
+
runs++;
|
|
129
|
+
read(s);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
write(s, 1);
|
|
133
|
+
flush();
|
|
134
|
+
|
|
135
|
+
expect(runs).toBe(1);
|
|
136
|
+
|
|
137
|
+
write(s, 2);
|
|
138
|
+
flush();
|
|
139
|
+
|
|
140
|
+
expect(runs).toBe(2);
|
|
141
|
+
});
|
|
142
|
+
});
|