diff --git a/.changeset/twelve-meals-tease.md b/.changeset/twelve-meals-tease.md new file mode 100644 index 000000000..fad62505b --- /dev/null +++ b/.changeset/twelve-meals-tease.md @@ -0,0 +1,5 @@ +--- +"@preact/signals": minor +--- + +Add `asyncComputed` primitive to have a first class util to handle (suspenseful) data fetching. diff --git a/packages/preact/README.md b/packages/preact/README.md index 9c303a587..fdfbee61c 100644 --- a/packages/preact/README.md +++ b/packages/preact/README.md @@ -189,6 +189,68 @@ function Component() { } ``` +### `useAsyncComputed(compute: () => Promise | T, options?: AsyncComputedOptions)` + +A Preact hook that creates a signal that computes its value asynchronously. This is particularly useful for handling async data fetching and other asynchronous operations in a reactive way. + +> You can also import `asyncComputed` as a non-hook way + +#### Parameters + +- `compute`: A function that returns either a Promise or a direct value. + Using signals here will track them, when the signal changes it will re-execute `compute`. +- `options`: Configuration options + - `suspend?: boolean`: Whether to enable Suspense support (defaults to true) + +#### Returns + +An `AsyncComputed` object with the following properties: + +- `value: T | undefined`: The current value (undefined while loading) +- `error: Signal`: Signal containing any error that occurred +- `running: Signal`: Signal indicating if the computation is in progress + +> When inputs to `compute` change the value and error will be retained but `running` will be `true`. + +#### Example + +```typescript +import { useAsyncComputed } from "@preact/signals/utils"; + +function UserProfile({ userId }: { userId: Signal }) { + const userData = useAsyncComputed( + async () => { + const response = await fetch(`/api/users/${userId.value}`); + return response.json(); + }, + { suspend: false } + ); + + if (userData.running.value) { + return
Loading...
; + } + + if (userData.error.value) { + return
Error: {String(userData.error.value)}
; + } + + return ( +
+

{userData.value?.name}

+

{userData.value?.email}

+
+ ); +} +``` + +The hook will automatically: + +- Recompute when dependencies change (e.g., when `userId` changes) +- Handle loading and error states +- Clean up subscriptions when the component unmounts +- Cache results between re-renders +- Support Suspense when `suspend: true` + ## License `MIT`, see the [LICENSE](../../LICENSE) file. diff --git a/packages/preact/utils/src/index.ts b/packages/preact/utils/src/index.ts index 98478882d..22a91aabd 100644 --- a/packages/preact/utils/src/index.ts +++ b/packages/preact/utils/src/index.ts @@ -1,7 +1,7 @@ -import { ReadonlySignal, Signal } from "@preact/signals-core"; +import { ReadonlySignal, signal, Signal, effect } from "@preact/signals-core"; import { useSignal } from "@preact/signals"; import { Fragment, createElement, JSX } from "preact"; -import { useMemo } from "preact/hooks"; +import { useMemo, useRef, useEffect, useId } from "preact/hooks"; interface ShowProps { when: Signal | ReadonlySignal; @@ -69,3 +69,164 @@ const refSignalProto = { this.value = v; }, }; + +/** + * Represents a Promise with optional value and error properties + */ +interface AugmentedPromise extends Promise { + value?: T; + error?: unknown; +} + +/** + * Represents the state and behavior of an async computed value + */ +interface AsyncComputed extends Signal { + value: T; + error: Signal; + running: Signal; + pending?: AugmentedPromise | null; + /** @internal */ + _cleanup(): void; +} + +/** + * Options for configuring async computed behavior + */ +interface AsyncComputedOptions { + /** Whether to throw pending promises for Suspense support */ + suspend?: boolean; +} + +/** + * Creates a signal that computes its value asynchronously + * @template T The type of the computed value + * @param compute Function that returns a Promise or value + * @returns AsyncComputed signal + */ +export function asyncComputed( + compute: () => Promise | T +): AsyncComputed { + const out = signal(undefined) as AsyncComputed; + out.error = signal(undefined); + out.running = signal(false); + + const applyResult = (value: T | undefined, error?: unknown) => { + if (out.running.value) { + out.running.value = false; + } + + if (out.pending) { + out.pending.error = error; + out.pending.value = value; + out.pending = null; + } + + if (out.error.peek() !== error) { + out.error.value = error; + } + + if (out.peek() !== value) { + out.value = value; + } + }; + + let computeCounter = 0; + + out._cleanup = effect(() => { + const currentId = ++computeCounter; + + try { + const result = compute(); + + // Handle synchronous resolution + if (isPromise(result)) { + if ("error" in result) { + return applyResult(undefined, result.error); + } + if ("value" in result) { + return applyResult(result.value as T); + } + + out.running.value = true; + + // Handle async resolution + out.pending = result.then( + (value: T) => { + if (currentId === computeCounter) { + applyResult(value); + } + return value; + }, + (error: unknown) => { + if (currentId === computeCounter) { + applyResult(undefined, error); + } + return undefined; + } + ) as AugmentedPromise; + } else { + out.running.value = false; + applyResult(result); + } + } catch (error) { + applyResult(undefined, error); + } + }); + + return out; +} + +const ASYNC_COMPUTED_CACHE = new Map>(); + +/** + * Hook for using async computed values with optional Suspense support + * @template T The type of the computed value + * @param compute Function that returns a Promise or value + * @param options Configuration options + * @returns AsyncComputed signal + */ +export function useAsyncComputed( + compute: () => Promise | T, + options: AsyncComputedOptions = {} +): AsyncComputed { + const id = useId(); + const computeRef = useRef(compute); + computeRef.current = compute; + + const result = useMemo(() => { + const cached = ASYNC_COMPUTED_CACHE.get(id); + const incoming = asyncComputed(() => computeRef.current()); + + if (cached) { + incoming.running = cached.running; + incoming.value = cached.value; + incoming.error.value = cached.error.peek(); + cached._cleanup(); + } + + if (options.suspend !== false) { + ASYNC_COMPUTED_CACHE.set(id, incoming); + } + + return incoming; + }, []); + + useEffect(() => result._cleanup, [result]); + + if ( + options.suspend !== false && + result.pending && + !result.value && + !result.error.value + ) { + throw result.pending; + } + + ASYNC_COMPUTED_CACHE.delete(id); + return result; +} + +function isPromise(obj: any): obj is Promise { + return obj && "then" in obj; +} diff --git a/packages/preact/utils/test/browser/index.test.tsx b/packages/preact/utils/test/browser/index.test.tsx index dc1653634..534aad8dc 100644 --- a/packages/preact/utils/test/browser/index.test.tsx +++ b/packages/preact/utils/test/browser/index.test.tsx @@ -1,5 +1,10 @@ import { signal } from "@preact/signals"; -import { For, Show, useSignalRef } from "@preact/signals/utils"; +import { + For, + Show, + useAsyncComputed, + useSignalRef, +} from "@preact/signals/utils"; import { render, createElement } from "preact"; import { act } from "preact/test-utils"; @@ -81,4 +86,121 @@ describe("@preact/signals-utils", () => { expect((ref as any).value instanceof HTMLSpanElement).to.eq(true); }); }); + + describe("asyncComputed", () => { + let resolve: (value: { foo: string }) => void; + const fetchResult = (url: string): Promise<{ foo: string }> => { + // eslint-disable-next-line no-console + console.log("fetching", url); + return new Promise(res => { + resolve = res; + }); + }; + + it("Should reactively update when the promise resolves", async () => { + const AsyncComponent = (props: any) => { + const data = useAsyncComputed<{ foo: string }>( + async () => fetchResult(props.url.value), + { suspend: false } + ); + const hasData = data.value !== undefined; + return ( +

+ {data.pending ? "pending" : hasData ? data.value?.foo : "error"} +

+ ); + }; + const url = signal("/api/foo?id=1"); + act(() => { + render(, scratch); + }); + expect(scratch.innerHTML).to.eq("

pending

"); + + await act(async () => { + await resolve({ foo: "bar" }); + await new Promise(resolve => setTimeout(resolve, 100)); + }); + + expect(scratch.innerHTML).to.eq("

bar

"); + }); + + it("Should fetch when the input changes", async () => { + const AsyncComponent = (props: any) => { + const data = useAsyncComputed<{ foo: string }>( + async () => fetchResult(props.url.value), + { suspend: false } + ); + const hasData = data.value !== undefined; + return ( +

+ {data.pending ? "pending" : hasData ? data.value?.foo : "error"} +

+ ); + }; + const url = signal("/api/foo?id=1"); + act(() => { + render(, scratch); + }); + expect(scratch.innerHTML).to.eq("

pending

"); + + await act(async () => { + await resolve({ foo: "bar" }); + await new Promise(resolve => setTimeout(resolve)); + }); + + expect(scratch.innerHTML).to.eq("

bar

"); + + act(() => { + url.value = "/api/foo?id=2"; + }); + + await act(async () => { + await resolve({ foo: "baz" }); + await new Promise(resolve => setTimeout(resolve)); + }); + expect(scratch.innerHTML).to.eq("

baz

"); + }); + + it("Should apply the 'running' signal", async () => { + const AsyncComponent = (props: any) => { + const data = useAsyncComputed<{ foo: string }>( + async () => fetchResult(props.url.value), + { suspend: false } + ); + const hasData = data.value !== undefined; + return ( +

+ {data.running.value + ? "running" + : hasData + ? data.value?.foo + : "error"} +

+ ); + }; + const url = signal("/api/foo?id=1"); + act(() => { + render(, scratch); + }); + expect(scratch.innerHTML).to.eq("

running

"); + + await act(async () => { + await resolve({ foo: "bar" }); + await new Promise(resolve => setTimeout(resolve)); + }); + + expect(scratch.innerHTML).to.eq("

bar

"); + + act(() => { + url.value = "/api/foo?id=2"; + }); + expect(scratch.innerHTML).to.eq("

running

"); + + await act(async () => { + await resolve({ foo: "baz" }); + await new Promise(resolve => setTimeout(resolve)); + }); + expect(scratch.innerHTML).to.eq("

baz

"); + }); + }); });