@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
package/src/types.ts
CHANGED
|
@@ -7,10 +7,15 @@ interface Computed<T> {
|
|
|
7
7
|
cleanup: VoidFunction | VoidFunction[] | null;
|
|
8
8
|
deps: Link | null;
|
|
9
9
|
depsTail: Link | null;
|
|
10
|
+
disposal: VoidFunction | null;
|
|
11
|
+
equals: ((a: unknown, b: unknown) => boolean) | null;
|
|
12
|
+
error: unknown;
|
|
10
13
|
fn: (onCleanup: (fn: VoidFunction) => typeof fn) => T;
|
|
14
|
+
gv: number;
|
|
11
15
|
height: number;
|
|
12
16
|
nextHeap: Computed<unknown> | undefined;
|
|
13
17
|
prevHeap: Computed<unknown>;
|
|
18
|
+
rv: number;
|
|
14
19
|
state: number;
|
|
15
20
|
subs: Link | null;
|
|
16
21
|
subsTail: Link | null;
|
|
@@ -30,17 +35,30 @@ interface Link {
|
|
|
30
35
|
// Instead we will use this as a shim.
|
|
31
36
|
declare const READONLY: unique symbol;
|
|
32
37
|
|
|
33
|
-
type Reactive<T> = T extends (...args:
|
|
34
|
-
?
|
|
35
|
-
: T extends (
|
|
36
|
-
?
|
|
37
|
-
: T extends
|
|
38
|
-
? U[]
|
|
39
|
-
: T
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
type Reactive<T> = T extends (...args: never[]) => infer R
|
|
39
|
+
? Settled<R> & { readonly [READONLY]: true }
|
|
40
|
+
: T extends (infer U)[]
|
|
41
|
+
? U[] & Pick<ReactiveArray<U>, 'clear' | 'dispose' | 'on' | 'once'>
|
|
42
|
+
: T extends Record<PropertyKey, unknown>
|
|
43
|
+
? { [K in keyof T]: T[K] extends (infer U)[] ? Reactive<U[]> : T[K]; } & { dispose: VoidFunction }
|
|
44
|
+
: T;
|
|
45
|
+
|
|
46
|
+
type SelectorSignal<T> = Signal<boolean> & {
|
|
47
|
+
key: T;
|
|
48
|
+
parent: Signal<T>;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// A fn returning a Promise/AsyncIterable yields a settled value; a plain fn yields its value.
|
|
52
|
+
type Settled<T> =
|
|
53
|
+
T extends Promise<infer U> ? Awaited<U> | undefined :
|
|
54
|
+
T extends AsyncIterable<infer U> ? U | undefined :
|
|
55
|
+
T;
|
|
42
56
|
|
|
43
57
|
type Signal<T> = {
|
|
58
|
+
equals: ((a: unknown, b: unknown) => boolean) | null;
|
|
59
|
+
keys: Map<T, SelectorSignal<T>> | null;
|
|
60
|
+
nextPending: Signal<unknown> | null;
|
|
61
|
+
rv: number;
|
|
44
62
|
subs: Link | null;
|
|
45
63
|
subsTail: Link | null;
|
|
46
64
|
type: typeof SIGNAL;
|
|
@@ -58,6 +76,8 @@ export type {
|
|
|
58
76
|
Computed,
|
|
59
77
|
Link,
|
|
60
78
|
Reactive,
|
|
79
|
+
SelectorSignal,
|
|
80
|
+
Settled,
|
|
61
81
|
Signal,
|
|
62
82
|
TransformResult
|
|
63
83
|
};
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { computed, dispose, effect, isComputed, isSignal, read, root, signal, write } from '~/system';
|
|
3
|
+
import { tick, waitFor } from './lib/wait-for';
|
|
4
|
+
import type { Computed } from '~/system';
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
describe('asyncComputed', () => {
|
|
8
|
+
it('initial value is undefined', () => {
|
|
9
|
+
root(() => {
|
|
10
|
+
let node = computed(() => Promise.resolve(42));
|
|
11
|
+
|
|
12
|
+
expect(read(node)).toBeUndefined();
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('returns a computed, not a signal', () => {
|
|
17
|
+
root(() => {
|
|
18
|
+
let node = computed(() => Promise.resolve(42));
|
|
19
|
+
|
|
20
|
+
expect(isComputed(node)).toBe(true);
|
|
21
|
+
expect(isSignal(node)).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('resolves to correct value', async () => {
|
|
26
|
+
let node!: Computed<number | undefined>;
|
|
27
|
+
|
|
28
|
+
root(() => {
|
|
29
|
+
node = computed(() => Promise.resolve(42));
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
await waitFor(() => read(node) === 42, 'node resolves to 42');
|
|
33
|
+
|
|
34
|
+
expect(read(node)).toBe(42);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('updates when dependency changes', async () => {
|
|
38
|
+
let node!: Computed<string | undefined>,
|
|
39
|
+
s = signal('hello');
|
|
40
|
+
|
|
41
|
+
root(() => {
|
|
42
|
+
node = computed(() => Promise.resolve(read(s)));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
await waitFor(() => read(node) === 'hello', 'node resolves to hello');
|
|
46
|
+
|
|
47
|
+
expect(read(node)).toBe('hello');
|
|
48
|
+
|
|
49
|
+
write(s, 'world');
|
|
50
|
+
await waitFor(() => read(node) === 'world', 'node updates to world');
|
|
51
|
+
|
|
52
|
+
expect(read(node)).toBe('world');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('race condition — rapid changes, only latest promise writes', async () => {
|
|
56
|
+
let node!: Computed<number | undefined>,
|
|
57
|
+
resolvers: ((v: number) => void)[] = [],
|
|
58
|
+
s = signal(1);
|
|
59
|
+
|
|
60
|
+
root(() => {
|
|
61
|
+
node = computed(() => {
|
|
62
|
+
read(s);
|
|
63
|
+
return new Promise<number>((resolve) => {
|
|
64
|
+
resolvers.push(resolve);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(read(node)).toBeUndefined();
|
|
70
|
+
|
|
71
|
+
write(s, 2);
|
|
72
|
+
await Promise.resolve();
|
|
73
|
+
await Promise.resolve();
|
|
74
|
+
|
|
75
|
+
write(s, 3);
|
|
76
|
+
await Promise.resolve();
|
|
77
|
+
await Promise.resolve();
|
|
78
|
+
|
|
79
|
+
// Resolve first (stale)
|
|
80
|
+
resolvers[0](100);
|
|
81
|
+
await Promise.resolve();
|
|
82
|
+
|
|
83
|
+
expect(read(node)).toBeUndefined();
|
|
84
|
+
|
|
85
|
+
// Resolve second (stale)
|
|
86
|
+
resolvers[1](200);
|
|
87
|
+
await Promise.resolve();
|
|
88
|
+
|
|
89
|
+
expect(read(node)).toBeUndefined();
|
|
90
|
+
|
|
91
|
+
// Resolve latest — value lands after the stabilize microtask
|
|
92
|
+
resolvers[2](300);
|
|
93
|
+
await waitFor(() => read(node) === 300, 'node resolves to latest 300');
|
|
94
|
+
|
|
95
|
+
expect(read(node)).toBe(300);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('dirty-gap: a rejection settling after a re-dirty is dropped', async () => {
|
|
99
|
+
let node!: Computed<number | undefined>,
|
|
100
|
+
rejecters: ((e: Error) => void)[] = [],
|
|
101
|
+
resolvers: ((v: number) => void)[] = [],
|
|
102
|
+
s = signal(1);
|
|
103
|
+
|
|
104
|
+
root(() => {
|
|
105
|
+
node = computed(() => {
|
|
106
|
+
read(s);
|
|
107
|
+
|
|
108
|
+
return new Promise<number>((resolve, reject) => {
|
|
109
|
+
rejecters.push(reject);
|
|
110
|
+
resolvers.push(resolve);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Queue the rejection FIRST, then dirty the factory — it settles in the gap
|
|
116
|
+
rejecters[0](new Error('gap rejection'));
|
|
117
|
+
write(s, 2);
|
|
118
|
+
|
|
119
|
+
await tick();
|
|
120
|
+
|
|
121
|
+
// Dropped: the wrapper is not poisoned by the superseded rejection
|
|
122
|
+
expect(read(node)).toBeUndefined();
|
|
123
|
+
|
|
124
|
+
resolvers[1](7);
|
|
125
|
+
await waitFor(() => read(node) === 7, 'node resolves to 7');
|
|
126
|
+
|
|
127
|
+
expect(read(node)).toBe(7);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('onCleanup works for abort controller', async () => {
|
|
131
|
+
let aborted = false,
|
|
132
|
+
s = signal(1);
|
|
133
|
+
|
|
134
|
+
root(() => {
|
|
135
|
+
computed((onCleanup) => {
|
|
136
|
+
let controller = new AbortController();
|
|
137
|
+
|
|
138
|
+
controller.signal.addEventListener('abort', () => {
|
|
139
|
+
aborted = true;
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
onCleanup(() => controller.abort());
|
|
143
|
+
|
|
144
|
+
return Promise.resolve(read(s));
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
await tick();
|
|
149
|
+
|
|
150
|
+
expect(aborted).toBe(false);
|
|
151
|
+
|
|
152
|
+
write(s, 2);
|
|
153
|
+
await waitFor(() => aborted === true, 'controller aborts on re-run');
|
|
154
|
+
|
|
155
|
+
expect(aborted).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('effect tracks async computed', async () => {
|
|
159
|
+
let node!: Computed<number | undefined>,
|
|
160
|
+
s = signal(10),
|
|
161
|
+
values: (number | undefined)[] = [];
|
|
162
|
+
|
|
163
|
+
root(() => {
|
|
164
|
+
node = computed(() => Promise.resolve(read(s)));
|
|
165
|
+
|
|
166
|
+
effect(() => {
|
|
167
|
+
values.push(read(node));
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
expect(values).toEqual([undefined]);
|
|
172
|
+
|
|
173
|
+
await waitFor(() => values.length === 2, 'effect observes resolved 10');
|
|
174
|
+
|
|
175
|
+
expect(values).toEqual([undefined, 10]);
|
|
176
|
+
|
|
177
|
+
write(s, 20);
|
|
178
|
+
await waitFor(() => values.length === 3, 'effect observes updated 20');
|
|
179
|
+
|
|
180
|
+
expect(values).toEqual([undefined, 10, 20]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('dispose stops updates', async () => {
|
|
184
|
+
let node!: Computed<number | undefined>,
|
|
185
|
+
s = signal(1);
|
|
186
|
+
|
|
187
|
+
let disposeRoot!: VoidFunction;
|
|
188
|
+
|
|
189
|
+
root((dispose) => {
|
|
190
|
+
disposeRoot = dispose;
|
|
191
|
+
node = computed(() => Promise.resolve(read(s)));
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
await waitFor(() => read(node) === 1, 'node resolves to 1');
|
|
195
|
+
|
|
196
|
+
expect(read(node)).toBe(1);
|
|
197
|
+
|
|
198
|
+
disposeRoot();
|
|
199
|
+
|
|
200
|
+
write(s, 2);
|
|
201
|
+
await tick();
|
|
202
|
+
|
|
203
|
+
expect(read(node)).toBe(1);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('nested asyncComputed — B depends on A', async () => {
|
|
207
|
+
let s = signal(5),
|
|
208
|
+
nodeA!: Computed<number | undefined>,
|
|
209
|
+
nodeB!: Computed<number | undefined>;
|
|
210
|
+
|
|
211
|
+
root(() => {
|
|
212
|
+
nodeA = computed(() => Promise.resolve(read(s) * 2));
|
|
213
|
+
|
|
214
|
+
nodeB = computed(() => {
|
|
215
|
+
let a = read(nodeA);
|
|
216
|
+
|
|
217
|
+
if (a === undefined) {
|
|
218
|
+
return Promise.resolve(0);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return Promise.resolve(a + 100);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// Initially both undefined
|
|
226
|
+
expect(read(nodeA)).toBeUndefined();
|
|
227
|
+
expect(read(nodeB)).toBeUndefined();
|
|
228
|
+
|
|
229
|
+
// Wait for A to resolve
|
|
230
|
+
await waitFor(() => read(nodeA) === 10, 'nodeA resolves to 10');
|
|
231
|
+
|
|
232
|
+
expect(read(nodeA)).toBe(10);
|
|
233
|
+
|
|
234
|
+
// Wait for B to react to A's resolved value
|
|
235
|
+
await waitFor(() => read(nodeB) === 110, 'nodeB resolves to 110');
|
|
236
|
+
|
|
237
|
+
expect(read(nodeB)).toBe(110);
|
|
238
|
+
|
|
239
|
+
// Update source signal — A and B should both update
|
|
240
|
+
write(s, 20);
|
|
241
|
+
await waitFor(() => read(nodeA) === 40, 'nodeA updates to 40');
|
|
242
|
+
|
|
243
|
+
expect(read(nodeA)).toBe(40);
|
|
244
|
+
|
|
245
|
+
await waitFor(() => read(nodeB) === 140, 'nodeB updates to 140');
|
|
246
|
+
|
|
247
|
+
expect(read(nodeB)).toBe(140);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('rejected promise rethrows at read and recovers on the next resolve', async () => {
|
|
251
|
+
let node!: Computed<number | undefined>,
|
|
252
|
+
s = signal(1);
|
|
253
|
+
|
|
254
|
+
root(() => {
|
|
255
|
+
node = computed(() => {
|
|
256
|
+
let v = read(s);
|
|
257
|
+
|
|
258
|
+
if (v === 2) {
|
|
259
|
+
return Promise.reject(new Error('fail'));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return Promise.resolve(v);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
await waitFor(() => read(node) === 1, 'node resolves to 1');
|
|
267
|
+
|
|
268
|
+
expect(read(node)).toBe(1);
|
|
269
|
+
|
|
270
|
+
write(s, 2);
|
|
271
|
+
await waitFor(() => {
|
|
272
|
+
try {
|
|
273
|
+
read(node);
|
|
274
|
+
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
catch (e) {
|
|
278
|
+
return (e as Error).message === 'fail';
|
|
279
|
+
}
|
|
280
|
+
}, 'node read throws fail');
|
|
281
|
+
|
|
282
|
+
expect(() => read(node)).toThrow('fail');
|
|
283
|
+
|
|
284
|
+
write(s, 3);
|
|
285
|
+
await waitFor(() => {
|
|
286
|
+
try {
|
|
287
|
+
return read(node) === 3;
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
}, 'node recovers to 3');
|
|
293
|
+
|
|
294
|
+
// Recovers after a non-rejected promise
|
|
295
|
+
expect(read(node)).toBe(3);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('disposing the returned computed stops the polling effect and the factory', async () => {
|
|
299
|
+
let calls = 0,
|
|
300
|
+
s = signal(1);
|
|
301
|
+
|
|
302
|
+
// Created OUTSIDE any root — ownership rides the returned computed alone
|
|
303
|
+
let node = computed(() => {
|
|
304
|
+
calls++;
|
|
305
|
+
|
|
306
|
+
return Promise.resolve(read(s));
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
await waitFor(() => read(node) === 1, 'node resolves to 1');
|
|
310
|
+
|
|
311
|
+
expect(read(node)).toBe(1);
|
|
312
|
+
expect(calls).toBe(1);
|
|
313
|
+
|
|
314
|
+
dispose(node);
|
|
315
|
+
|
|
316
|
+
write(s, 2);
|
|
317
|
+
await tick();
|
|
318
|
+
|
|
319
|
+
// No further dispatches or writes land after dispose
|
|
320
|
+
expect(calls).toBe(1);
|
|
321
|
+
expect(read(node)).toBe(1);
|
|
322
|
+
});
|
|
323
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { computed, read, root, signal, write } from '~/system';
|
|
3
|
+
import { tick, waitFor } from './lib/wait-for';
|
|
4
|
+
import type { Computed } from '~/system';
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
describe('asyncComputed error propagation', () => {
|
|
8
|
+
it('rejection rethrows at read and a later success clears it', async () => {
|
|
9
|
+
let node!: Computed<number | undefined>,
|
|
10
|
+
s = signal(1);
|
|
11
|
+
|
|
12
|
+
root(() => {
|
|
13
|
+
node = computed(() => {
|
|
14
|
+
let v = read(s);
|
|
15
|
+
|
|
16
|
+
if (v === 2) {
|
|
17
|
+
return Promise.reject(new Error('fail'));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return Promise.resolve(v);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
await waitFor(() => read(node) === 1, 'node resolves to 1');
|
|
25
|
+
|
|
26
|
+
expect(read(node)).toBe(1);
|
|
27
|
+
|
|
28
|
+
write(s, 2);
|
|
29
|
+
await waitFor(() => {
|
|
30
|
+
try {
|
|
31
|
+
read(node);
|
|
32
|
+
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
return (e as Error).message === 'fail';
|
|
37
|
+
}
|
|
38
|
+
}, 'node read throws fail');
|
|
39
|
+
|
|
40
|
+
expect(() => read(node)).toThrow('fail');
|
|
41
|
+
expect(() => read(node)).toThrow('fail');
|
|
42
|
+
|
|
43
|
+
write(s, 3);
|
|
44
|
+
await waitFor(() => {
|
|
45
|
+
try {
|
|
46
|
+
return read(node) === 3;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}, 'node recovers to 3');
|
|
52
|
+
|
|
53
|
+
expect(read(node)).toBe(3);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('latest-wins: a stale rejection is discarded', async () => {
|
|
57
|
+
let node!: Computed<number | undefined>,
|
|
58
|
+
rejecters: ((e: Error) => void)[] = [],
|
|
59
|
+
resolvers: ((v: number) => void)[] = [],
|
|
60
|
+
s = signal(1);
|
|
61
|
+
|
|
62
|
+
root(() => {
|
|
63
|
+
node = computed(() => {
|
|
64
|
+
read(s);
|
|
65
|
+
|
|
66
|
+
return new Promise<number>((resolve, reject) => {
|
|
67
|
+
rejecters.push(reject);
|
|
68
|
+
resolvers.push(resolve);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
write(s, 2);
|
|
74
|
+
await Promise.resolve();
|
|
75
|
+
await Promise.resolve();
|
|
76
|
+
|
|
77
|
+
expect(rejecters.length).toBe(2);
|
|
78
|
+
|
|
79
|
+
// Reject the first (superseded) dispatch — must be discarded
|
|
80
|
+
rejecters[0](new Error('stale'));
|
|
81
|
+
await tick();
|
|
82
|
+
|
|
83
|
+
expect(read(node)).toBeUndefined();
|
|
84
|
+
|
|
85
|
+
// The latest dispatch still lands
|
|
86
|
+
resolvers[1](42);
|
|
87
|
+
await waitFor(() => read(node) === 42, 'latest resolves to 42');
|
|
88
|
+
|
|
89
|
+
expect(read(node)).toBe(42);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('stale-while-revalidate keeps the prior value readable during a refetch', async () => {
|
|
93
|
+
let node!: Computed<number | undefined>,
|
|
94
|
+
resolvers: ((v: number) => void)[] = [],
|
|
95
|
+
s = signal(1);
|
|
96
|
+
|
|
97
|
+
root(() => {
|
|
98
|
+
node = computed(() => {
|
|
99
|
+
read(s);
|
|
100
|
+
|
|
101
|
+
return new Promise<number>((resolve) => {
|
|
102
|
+
resolvers.push(resolve);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
resolvers[0](10);
|
|
108
|
+
await waitFor(() => read(node) === 10, 'node resolves to 10');
|
|
109
|
+
|
|
110
|
+
expect(read(node)).toBe(10);
|
|
111
|
+
|
|
112
|
+
write(s, 2);
|
|
113
|
+
await Promise.resolve();
|
|
114
|
+
await Promise.resolve();
|
|
115
|
+
|
|
116
|
+
// Refetch dispatched but unresolved — prior value stays readable
|
|
117
|
+
expect(resolvers.length).toBe(2);
|
|
118
|
+
expect(read(node)).toBe(10);
|
|
119
|
+
|
|
120
|
+
resolvers[1](20);
|
|
121
|
+
await waitFor(() => read(node) === 20, 'node refetches to 20');
|
|
122
|
+
|
|
123
|
+
expect(read(node)).toBe(20);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('undefined rejection reason surfaces as a real Error', async () => {
|
|
127
|
+
let node!: Computed<number | undefined>;
|
|
128
|
+
|
|
129
|
+
root(() => {
|
|
130
|
+
node = computed(() => Promise.reject(undefined));
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
await waitFor(() => {
|
|
134
|
+
try {
|
|
135
|
+
read(node);
|
|
136
|
+
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
}, 'node read throws');
|
|
143
|
+
|
|
144
|
+
expect(() => read(node)).toThrow('reactivity: async computed rejected with undefined');
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { computed, effect, read, root, signal, write } from '~/system';
|
|
3
|
+
import { tick, waitFor } from './lib/wait-for';
|
|
4
|
+
import type { Computed } from '~/system';
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
describe('asyncComputed dirty-gap guard', () => {
|
|
8
|
+
it('drops a settle landing after the factory is re-dirtied but before its re-run', async () => {
|
|
9
|
+
let node!: Computed<number | undefined>,
|
|
10
|
+
resolvers: ((v: number) => void)[] = [],
|
|
11
|
+
s = signal(1);
|
|
12
|
+
|
|
13
|
+
root(() => {
|
|
14
|
+
node = computed(() => {
|
|
15
|
+
read(s);
|
|
16
|
+
|
|
17
|
+
return new Promise<number>((resolve) => {
|
|
18
|
+
resolvers.push(resolve);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
expect(resolvers.length).toBe(1);
|
|
24
|
+
|
|
25
|
+
// Queue the settle FIRST, then dirty the factory: the settle microtask runs
|
|
26
|
+
// before the stabilize re-run, so id === v still passes — the dirty-gap.
|
|
27
|
+
resolvers[0](100);
|
|
28
|
+
write(s, 2);
|
|
29
|
+
|
|
30
|
+
await waitFor(() => resolvers.length === 2, 're-run dispatches a fresh promise');
|
|
31
|
+
|
|
32
|
+
// The stale value never landed; the re-run dispatched a fresh promise
|
|
33
|
+
expect(resolvers.length).toBe(2);
|
|
34
|
+
expect(read(node)).toBeUndefined();
|
|
35
|
+
|
|
36
|
+
resolvers[1](200);
|
|
37
|
+
await waitFor(() => read(node) === 200, 'node resolves to 200');
|
|
38
|
+
|
|
39
|
+
expect(read(node)).toBe(200);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
describe('asyncComputed outside root', () => {
|
|
45
|
+
it('stops polling after its last subscriber unlinks', async () => {
|
|
46
|
+
let calls = 0,
|
|
47
|
+
s = signal(1);
|
|
48
|
+
|
|
49
|
+
let node = computed(() => {
|
|
50
|
+
calls++;
|
|
51
|
+
|
|
52
|
+
return Promise.resolve(read(s));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
let stop = effect(() => {
|
|
56
|
+
read(node);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
await waitFor(() => read(node) === 1, 'node resolves to 1');
|
|
60
|
+
|
|
61
|
+
expect(read(node)).toBe(1);
|
|
62
|
+
expect(calls).toBe(1);
|
|
63
|
+
|
|
64
|
+
stop();
|
|
65
|
+
|
|
66
|
+
write(s, 2);
|
|
67
|
+
await tick();
|
|
68
|
+
|
|
69
|
+
// Wrapper auto-disposed through unlink — effect and factory torn down, no new dispatch
|
|
70
|
+
expect(calls).toBe(1);
|
|
71
|
+
expect(read(node)).toBe(1);
|
|
72
|
+
});
|
|
73
|
+
});
|