Skip to content

Commit 2e903bf

Browse files
committed
introduce asyncComputed
1 parent acfadc3 commit 2e903bf

File tree

3 files changed

+262
-0
lines changed

3 files changed

+262
-0
lines changed

src/runtime/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export { proxy, markRaw, toRaw } from "./reactivity/proxy";
4545
export { untrack, ReactiveValue } from "./reactivity/computations";
4646
export { signal, Signal } from "./reactivity/signal";
4747
export { computed } from "./reactivity/computed";
48+
export { asyncComputed } from "./reactivity/async_computed";
4849
export { effect } from "./reactivity/effect";
4950
export { useEffect, useListener, useApp } from "./hooks";
5051
export { batched, EventBus, htmlEscape, whenReady, markup } from "./utils";
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import {
2+
ComputationState,
3+
atomSymbol,
4+
onReadAtom,
5+
onWriteAtom,
6+
updateComputation,
7+
createComputation,
8+
} from "./computations";
9+
10+
export function asyncComputed<T>(fn: () => Promise<T>): () => Promise<T> {
11+
let currentVersion = 0;
12+
13+
const computation = createComputation(
14+
() => {
15+
const version = ++currentVersion;
16+
const innerPromise = fn();
17+
const promise = new Promise<T>((resolve, reject) => {
18+
innerPromise.then(
19+
(value) => {
20+
if (version === currentVersion && computation.state === ComputationState.EXECUTED) {
21+
resolve(value);
22+
}
23+
},
24+
(error) => {
25+
if (version === currentVersion && computation.state === ComputationState.EXECUTED) {
26+
reject(error);
27+
}
28+
}
29+
);
30+
});
31+
onWriteAtom(computation);
32+
return promise;
33+
},
34+
true
35+
);
36+
37+
function readAsyncComputed(): Promise<T> {
38+
updateComputation(computation);
39+
onReadAtom(computation);
40+
return computation.value;
41+
}
42+
(readAsyncComputed as any)[atomSymbol] = computation;
43+
44+
return readAsyncComputed;
45+
}
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import { asyncComputed, effect, proxy, signal } from "../../src";
2+
import { makeDeferred, nextMicroTick } from "../helpers";
3+
4+
async function waitScheduler() {
5+
await nextMicroTick();
6+
return Promise.resolve();
7+
}
8+
9+
describe("asyncComputed", () => {
10+
test("returns a promise that resolves to the computed value", async () => {
11+
const d = asyncComputed(async () => 42);
12+
const result = await d();
13+
expect(result).toBe(42);
14+
});
15+
16+
test("tracks signal dependencies", async () => {
17+
const s = signal(1);
18+
const d = asyncComputed(async () => s() * 10);
19+
expect(await d()).toBe(10);
20+
});
21+
22+
test("tracks proxy dependencies", async () => {
23+
const state = proxy({ a: 5 });
24+
const d = asyncComputed(async () => state.a + 1);
25+
expect(await d()).toBe(6);
26+
});
27+
28+
test("recomputes when dependency changes", async () => {
29+
const s = signal(1);
30+
const d = asyncComputed(async () => s() * 10);
31+
expect(await d()).toBe(10);
32+
s.set(2);
33+
expect(await d()).toBe(20);
34+
});
35+
36+
test("stale promise stays pending when dependency changes before resolve", async () => {
37+
const s = signal("a");
38+
const def = makeDeferred();
39+
let callCount = 0;
40+
41+
const d = asyncComputed(async () => {
42+
const val = s();
43+
callCount++;
44+
if (callCount === 1) {
45+
await def;
46+
}
47+
return val;
48+
});
49+
50+
// First read: starts async computation, promise pending
51+
const firstPromise = d();
52+
expect(callCount).toBe(1);
53+
54+
// Change dependency before first computation resolves
55+
s.set("b");
56+
57+
// Resolve the first (now stale) async operation
58+
def.resolve();
59+
await waitScheduler();
60+
61+
// The first promise should stay pending (stale)
62+
let firstResolved = false;
63+
firstPromise.then(() => {
64+
firstResolved = true;
65+
});
66+
await waitScheduler();
67+
expect(firstResolved).toBe(false);
68+
69+
// Second read gives a new promise that resolves correctly
70+
expect(await d()).toBe("b");
71+
});
72+
73+
test("only latest computation resolves after rapid changes", async () => {
74+
const s = signal(1);
75+
const defs = [makeDeferred(), makeDeferred(), makeDeferred()];
76+
let callCount = 0;
77+
78+
const d = asyncComputed(async () => {
79+
const val = s();
80+
const def = defs[callCount++];
81+
await def;
82+
return val;
83+
});
84+
85+
// First read
86+
const p1 = d();
87+
88+
// Rapid changes
89+
s.set(2);
90+
const p2 = d();
91+
92+
s.set(3);
93+
const p3 = d();
94+
95+
// Resolve in order
96+
defs[0].resolve();
97+
defs[1].resolve();
98+
defs[2].resolve();
99+
await waitScheduler();
100+
101+
// Only the latest promise should resolve
102+
let p1resolved = false;
103+
let p2resolved = false;
104+
p1.then(() => {
105+
p1resolved = true;
106+
});
107+
p2.then(() => {
108+
p2resolved = true;
109+
});
110+
await waitScheduler();
111+
expect(p1resolved).toBe(false);
112+
expect(p2resolved).toBe(false);
113+
expect(await p3).toBe(3);
114+
});
115+
116+
test("stale promise stays pending even when resolved out of order", async () => {
117+
const s = signal("first");
118+
const def1 = makeDeferred();
119+
const def2 = makeDeferred();
120+
let callCount = 0;
121+
122+
const d = asyncComputed(async () => {
123+
const val = s();
124+
callCount++;
125+
if (callCount === 1) {
126+
await def1;
127+
} else {
128+
await def2;
129+
}
130+
return val;
131+
});
132+
133+
const p1 = d();
134+
135+
s.set("second");
136+
const p2 = d();
137+
138+
// Resolve second (newer) computation first
139+
def2.resolve();
140+
expect(await p2).toBe("second");
141+
142+
// Now resolve first (stale) computation
143+
def1.resolve();
144+
await waitScheduler();
145+
146+
let p1resolved = false;
147+
p1.then(() => {
148+
p1resolved = true;
149+
});
150+
await waitScheduler();
151+
expect(p1resolved).toBe(false);
152+
});
153+
154+
test("rejected promise propagates when computation is current", async () => {
155+
const d = asyncComputed(async () => {
156+
throw new Error("fail");
157+
});
158+
159+
await expect(d()).rejects.toThrow("fail");
160+
});
161+
162+
test("stale rejection is suppressed", async () => {
163+
const s = signal(1);
164+
const def = makeDeferred();
165+
let callCount = 0;
166+
167+
const d = asyncComputed(async () => {
168+
const val = s();
169+
callCount++;
170+
if (callCount === 1) {
171+
await def;
172+
}
173+
return val;
174+
});
175+
176+
const p1 = d();
177+
178+
// Change dependency
179+
s.set(2);
180+
181+
// Reject the stale computation
182+
def.reject(new Error("stale error"));
183+
await waitScheduler();
184+
185+
// Stale rejection doesn't surface
186+
let p1rejected = false;
187+
p1.catch(() => {
188+
p1rejected = true;
189+
});
190+
await waitScheduler();
191+
expect(p1rejected).toBe(false);
192+
193+
// New computation resolves fine
194+
expect(await d()).toBe(2);
195+
});
196+
197+
test("works with effect", async () => {
198+
const s = signal(1);
199+
const results: number[] = [];
200+
201+
const d = asyncComputed(async () => s() * 10);
202+
203+
effect(async () => {
204+
results.push(await d());
205+
});
206+
207+
await waitScheduler();
208+
expect(results).toEqual([10]);
209+
210+
s.set(2);
211+
await waitScheduler();
212+
await waitScheduler();
213+
expect(results).toEqual([10, 20]);
214+
});
215+
216+
});

0 commit comments

Comments
 (0)