Skip to content

Commit 7a62c6d

Browse files
committed
Add async computed
1 parent fae3d1e commit 7a62c6d

File tree

2 files changed

+232
-3
lines changed

2 files changed

+232
-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: 81 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,79 @@ 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+
// eslint-disable-next-line no-console
94+
console.log("fetching", url);
95+
return new Promise(res => {
96+
resolve = res;
97+
});
98+
};
99+
100+
it("Should reactively update when the promise resolves", async () => {
101+
const AsyncComponent = (props: any) => {
102+
const data = useAsyncComputed<{ foo: string }>(
103+
async () => fetchResult(props.url.value),
104+
{ suspend: false }
105+
);
106+
const hasData = data.value !== undefined;
107+
return (
108+
<p>
109+
{data.pending ? "pending" : hasData ? data.value?.foo : "error"}
110+
</p>
111+
);
112+
};
113+
const url = signal("/api/foo?id=1");
114+
act(() => {
115+
render(<AsyncComponent url={url} />, scratch);
116+
});
117+
expect(scratch.innerHTML).to.eq("<p>pending</p>");
118+
119+
await act(async () => {
120+
await resolve({ foo: "bar" });
121+
await new Promise(resolve => setTimeout(resolve, 100));
122+
});
123+
124+
expect(scratch.innerHTML).to.eq("<p>bar</p>");
125+
});
126+
127+
it("Should fetch when the input changes", async () => {
128+
const AsyncComponent = (props: any) => {
129+
const data = useAsyncComputed<{ foo: string }>(
130+
async () => fetchResult(props.url.value),
131+
{ suspend: false }
132+
);
133+
const hasData = data.value !== undefined;
134+
return (
135+
<p>
136+
{data.pending ? "pending" : hasData ? data.value?.foo : "error"}
137+
</p>
138+
);
139+
};
140+
const url = signal("/api/foo?id=1");
141+
act(() => {
142+
render(<AsyncComponent url={url} />, scratch);
143+
});
144+
expect(scratch.innerHTML).to.eq("<p>pending</p>");
145+
146+
await act(async () => {
147+
await resolve({ foo: "bar" });
148+
await new Promise(resolve => setTimeout(resolve));
149+
});
150+
151+
expect(scratch.innerHTML).to.eq("<p>bar</p>");
152+
153+
act(() => {
154+
url.value = "/api/foo?id=2";
155+
});
156+
157+
await act(async () => {
158+
await resolve({ foo: "baz" });
159+
await new Promise(resolve => setTimeout(resolve));
160+
});
161+
expect(scratch.innerHTML).to.eq("<p>baz</p>");
162+
});
163+
});
84164
});

0 commit comments

Comments
 (0)