Skip to content

Commit c6d0fc7

Browse files
committed
Add async computed
1 parent fae3d1e commit c6d0fc7

File tree

2 files changed

+227
-3
lines changed

2 files changed

+227
-3
lines changed

packages/preact/utils/src/index.ts

Lines changed: 151 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { ReadonlySignal, Signal } from "@preact/signals-core";
1+
import { ReadonlySignal, signal, Signal, effect } from "@preact/signals-core";
22
import { useSignal } from "@preact/signals";
33
import { Fragment, createElement, JSX } from "preact";
4-
import { useMemo } from "preact/hooks";
4+
import { useMemo, useRef, useEffect, useId } from "preact/hooks";
55

66
interface ShowProps<T = boolean> {
77
when: Signal<T> | ReadonlySignal<T>;
@@ -69,3 +69,152 @@ const refSignalProto = {
6969
this.value = v;
7070
},
7171
};
72+
73+
/**
74+
* Represents a Promise with optional value and error properties
75+
*/
76+
interface AugmentedPromise<T> extends Promise<T> {
77+
value?: T;
78+
error?: unknown;
79+
}
80+
81+
/**
82+
* Represents the state and behavior of an async computed value
83+
*/
84+
interface AsyncComputed<T> extends Signal<T> {
85+
value: T;
86+
error: Signal<unknown>;
87+
pending?: AugmentedPromise<T> | null;
88+
/** @internal */
89+
_cleanup(): void;
90+
}
91+
92+
/**
93+
* Options for configuring async computed behavior
94+
*/
95+
interface AsyncComputedOptions {
96+
/** Whether to throw pending promises for Suspense support */
97+
suspend?: boolean;
98+
}
99+
100+
/**
101+
* Creates a signal that computes its value asynchronously
102+
* @template T The type of the computed value
103+
* @param compute Function that returns a Promise or value
104+
* @returns AsyncComputed signal
105+
*/
106+
export function asyncComputed<T>(
107+
compute: () => Promise<T> | T
108+
): AsyncComputed<T | undefined> {
109+
const out = signal<T | undefined>(undefined) as AsyncComputed<T | undefined>;
110+
out.error = signal<unknown>(undefined);
111+
112+
const applyResult = (value: T | undefined, error?: unknown) => {
113+
if (out.pending) {
114+
out.pending.error = error;
115+
out.pending.value = value;
116+
out.pending = null;
117+
}
118+
119+
if (out.error.peek() !== error) {
120+
out.error.value = error;
121+
}
122+
123+
if (out.peek() !== value) {
124+
out.value = value;
125+
}
126+
};
127+
128+
let computeCounter = 0;
129+
130+
out._cleanup = effect(() => {
131+
const currentId = ++computeCounter;
132+
133+
try {
134+
const result = compute();
135+
136+
// Handle synchronous resolution
137+
if (isPromise(result)) {
138+
if ("error" in result) {
139+
return applyResult(undefined, result.error);
140+
}
141+
if ("value" in result) {
142+
return applyResult(result.value as T);
143+
}
144+
145+
// Handle async resolution
146+
out.pending = result.then(
147+
(value: T) => {
148+
applyResult(value);
149+
return value;
150+
},
151+
(error: unknown) => {
152+
if (currentId === computeCounter) {
153+
applyResult(undefined, error);
154+
}
155+
return undefined;
156+
}
157+
) as AugmentedPromise<T>;
158+
} else {
159+
applyResult(result);
160+
}
161+
} catch (error) {
162+
applyResult(undefined, error);
163+
}
164+
});
165+
166+
return out;
167+
}
168+
169+
const ASYNC_COMPUTED_CACHE = new Map<string, AsyncComputed<any>>();
170+
171+
/**
172+
* Hook for using async computed values with optional Suspense support
173+
* @template T The type of the computed value
174+
* @param compute Function that returns a Promise or value
175+
* @param options Configuration options
176+
* @returns AsyncComputed signal
177+
*/
178+
export function useAsyncComputed<T>(
179+
compute: () => Promise<T> | T,
180+
options: AsyncComputedOptions = {}
181+
): AsyncComputed<T | undefined> {
182+
const id = useId();
183+
const computeRef = useRef(compute);
184+
computeRef.current = compute;
185+
186+
const result = useMemo(() => {
187+
const cached = ASYNC_COMPUTED_CACHE.get(id);
188+
const incoming = asyncComputed(() => computeRef.current());
189+
190+
if (cached) {
191+
incoming.value = cached.value;
192+
incoming.error.value = cached.error.peek();
193+
cached._cleanup();
194+
}
195+
196+
if (options.suspend !== false) {
197+
ASYNC_COMPUTED_CACHE.set(id, incoming);
198+
}
199+
200+
return incoming;
201+
}, []);
202+
203+
useEffect(() => result._cleanup, [result]);
204+
205+
if (
206+
options.suspend !== false &&
207+
result.pending &&
208+
!result.value &&
209+
!result.error.value
210+
) {
211+
throw result.pending;
212+
}
213+
214+
ASYNC_COMPUTED_CACHE.delete(id);
215+
return result;
216+
}
217+
218+
function isPromise(obj: any): obj is Promise<any> {
219+
return obj && "then" in obj;
220+
}

packages/preact/utils/test/browser/index.test.tsx

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { signal } from "@preact/signals";
2-
import { For, Show, useSignalRef } from "@preact/signals/utils";
2+
import {
3+
For,
4+
Show,
5+
useAsyncComputed,
6+
useSignalRef,
7+
} from "@preact/signals/utils";
38
import { render, createElement } from "preact";
49
import { act } from "preact/test-utils";
510

@@ -81,4 +86,74 @@ describe("@preact/signals-utils", () => {
8186
expect((ref as any).value instanceof HTMLSpanElement).to.eq(true);
8287
});
8388
});
89+
90+
describe("asyncComputed", () => {
91+
let resolve: (value: { foo: string }) => void;
92+
const fetchResult = (url: string): Promise<{ foo: string }> => {
93+
console.log("fetching", url);
94+
return new Promise(res => {
95+
resolve = res;
96+
});
97+
};
98+
99+
it("Should reactively update when the promise resolves", async () => {
100+
const AsyncComponent = (props: any) => {
101+
const data = useAsyncComputed<{ foo: string }>(
102+
async () => fetchResult(props.url.value),
103+
false
104+
);
105+
const hasData = data.value !== undefined;
106+
return (
107+
<p>{data.pending ? "pending" : hasData ? data.value.foo : "error"}</p>
108+
);
109+
};
110+
const url = signal("/api/foo?id=1");
111+
act(() => {
112+
render(<AsyncComponent url={url} />, scratch);
113+
});
114+
expect(scratch.innerHTML).to.eq("<p>pending</p>");
115+
116+
await act(async () => {
117+
await resolve({ foo: "bar" });
118+
await new Promise(resolve => setTimeout(resolve, 100));
119+
});
120+
121+
expect(scratch.innerHTML).to.eq("<p>bar</p>");
122+
});
123+
124+
it("Should fetch when the input changes", async () => {
125+
const AsyncComponent = (props: any) => {
126+
const data = useAsyncComputed<{ foo: string }>(
127+
async () => fetchResult(props.url.value),
128+
false
129+
);
130+
const hasData = data.value !== undefined;
131+
return (
132+
<p>{data.pending ? "pending" : hasData ? data.value.foo : "error"}</p>
133+
);
134+
};
135+
const url = signal("/api/foo?id=1");
136+
act(() => {
137+
render(<AsyncComponent url={url} />, scratch);
138+
});
139+
expect(scratch.innerHTML).to.eq("<p>pending</p>");
140+
141+
await act(async () => {
142+
await resolve({ foo: "bar" });
143+
await new Promise(resolve => setTimeout(resolve));
144+
});
145+
146+
expect(scratch.innerHTML).to.eq("<p>bar</p>");
147+
148+
act(() => {
149+
url.value = "/api/foo?id=2";
150+
});
151+
152+
await act(async () => {
153+
await resolve({ foo: "baz" });
154+
await new Promise(resolve => setTimeout(resolve));
155+
});
156+
expect(scratch.innerHTML).to.eq("<p>baz</p>");
157+
});
158+
});
84159
});

0 commit comments

Comments
 (0)