diff --git a/.changeset/quick-spies-fetch.md b/.changeset/quick-spies-fetch.md new file mode 100644 index 000000000..34fa91bf3 --- /dev/null +++ b/.changeset/quick-spies-fetch.md @@ -0,0 +1,7 @@ +--- +"@preact/signals-core": minor +"@preact/signals": minor +"@preact/signals-react": minor +--- + +Add experimental async computed signals with model ownership and Preact and React hooks. diff --git a/packages/core/README.md b/packages/core/README.md index ee9107482..ec9c61c50 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -12,6 +12,7 @@ Read the [announcement post](https://preactjs.com/blog/introducing-signals/) to - [`signal(initialValue)`](#signalinitialvalue) - [`signal.peek()`](#signalpeek) - [`computed(fn)`](#computedfn) + - [`asyncComputed(fn)`](#asynccomputedfn) - [`effect(fn)`](#effectfn) - [`batch(fn)`](#batchfn) - [`untracked(fn)`](#untrackedfn) @@ -107,6 +108,42 @@ console.log(fullName.value); Any signal that is accessed inside the `computed`'s callback function will be automatically subscribed to and tracked as a dependency of the computed signal. +### `asyncComputed(fn)` + +The experimental `asyncComputed` function creates a read-only signal from a synchronous or asynchronous callback. It also exposes `pending` and `error` signals. When a dependency changes, the callback starts again while retaining the last successful value, and stale promise results are ignored. + +```js +import { asyncComputed, signal } from "@preact/signals-core"; + +const userId = signal("1"); +const user = asyncComputed(async () => { + // Read dependencies before awaiting so they remain reactive. + const id = userId.value; + const response = await fetch(`/api/users/${id}`); + return response.json(); +}); + +console.log(user.value); // undefined until the first result settles +console.log(user.pending.value); // true while the latest run is pending +console.log(user.settled.value); // true after the first run finishes +console.log(user.failed.value); // true if the latest run failed +console.log(user.error.value); // the latest error, if any +``` + +Signal dependency tracking is synchronous. Reads after the first `await` are not tracked, so capture every reactive input before awaiting. While a run is pending, `user.settlement` is a stable promise that resolves when that run settles or is replaced. Call `user.dispose()` to stop tracking and ignore in-flight results. + +Async computeds created inside `createModel` are automatically disposed with the model, just like effects: + +```js +const UserModel = createModel(userId => ({ + user: asyncComputed(async () => { + const id = userId.value; + const response = await fetch(`/api/users/${id}`); + return response.json(); + }), +})); +``` + ### `effect(fn)` The `effect` function is the last piece that makes everything reactive. When you access a signal inside its callback function, that signal and every dependency of said signal will be activated and subscribed to. In that regard it is very similar to [`computed(fn)`](#computedfn). By default all updates are lazy, so nothing will update until you access a signal inside `effect`. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 25a641ffe..5f3977dc0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1124,6 +1124,133 @@ function createModel( //#endregion createModel +//#region AsyncComputed (experimental) + +/** + * A function used by {@link asyncComputed}. Signal reads are tracked until the + * function returns, which means an async function must read its dependencies + * before its first `await`. + */ +export type AsyncComputedFn = () => PromiseLike | T; + +/** The reactive state of an asynchronous computation. */ +export interface AsyncComputedSignal extends ReadonlySignal { + /** True while the latest run is waiting for a promise to settle. */ + readonly pending: ReadonlySignal; + /** True after at least one run has settled, including with `undefined`. */ + readonly settled: ReadonlySignal; + /** Whether the latest settled run failed, even if it rejected with `undefined`. */ + readonly failed: ReadonlySignal; + /** The error from the latest failed run, or undefined after a successful run. */ + readonly error: ReadonlySignal; + /** The current run's stable settlement promise, when it is pending. */ + readonly settlement: Promise | undefined; + /** Stop tracking dependencies and ignore any result still in flight. */ + dispose(): void; +} + +/** + * Create a signal whose value is produced by a synchronous or asynchronous + * function. Dependencies read synchronously are tracked and restart the + * computation when they change. The last successful value is retained while + * a newer run is pending or if it fails. + */ +export function asyncComputed( + fn: AsyncComputedFn, + options?: SignalOptions +): AsyncComputedSignal { + const out = new Signal(undefined, options); + const pending = new Signal(false); + const settled = new Signal(false); + const failed = new Signal(false); + const error = new Signal(undefined); + const facade = out as unknown as AsyncComputedSignal & { + pending: Signal; + settled: Signal; + failed: Signal; + error: Signal; + settlement: Promise | undefined; + }; + facade.pending = pending; + facade.settled = settled; + facade.failed = failed; + facade.error = error; + facade.settlement = undefined; + + let runId = 0; + let resolveSettlement: (() => void) | undefined; + + function finishSettlement() { + facade.settlement = undefined; + resolveSettlement?.(); + resolveSettlement = undefined; + } + + function settle(id: number, failed: boolean, result: unknown) { + if (id !== runId) return; + + batch(() => { + if (failed) { + error.value = result; + } else { + error.value = undefined; + out.value = result as T; + } + facade.failed.value = failed; + settled.value = true; + pending.value = false; + }); + finishSettlement(); + } + + const dispose = effect( + () => { + const id = ++runId; + let result: PromiseLike | T; + let isThenable: boolean; + + try { + result = fn(); + isThenable = + result != null && + typeof (result as PromiseLike).then === "function"; + } catch (err) { + settle(id, true, err); + return () => { + if (id === runId) runId++; + }; + } + + if (isThenable) { + facade.settlement = new Promise(resolve => { + resolveSettlement = resolve; + }); + pending.value = true; + Promise.resolve(result).then( + value => settle(id, false, value), + err => settle(id, true, err) + ); + } else { + settle(id, false, result); + } + + return () => { + if (id === runId) { + runId++; + pending.value = false; + finishSettlement(); + } + }; + }, + { name: options?.name } + ); + + facade.dispose = dispose; + return facade; +} + +//#endregion AsyncComputed + export { computed, effect, diff --git a/packages/core/test/async.test.tsx b/packages/core/test/async.test.tsx new file mode 100644 index 000000000..efa8a7e82 --- /dev/null +++ b/packages/core/test/async.test.tsx @@ -0,0 +1,226 @@ +import { describe, it, expect } from "vitest"; +import { + asyncComputed, + computed, + createModel, + effect, + signal, +} from "@preact/signals-core"; + +function defer() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +const tick = () => new Promise(resolve => setTimeout(resolve, 0)); + +function track(dependency: unknown, value: T): T { + void dependency; + return value; +} + +describe("asyncComputed", () => { + it("settles synchronous results and tracks dependencies", () => { + const source = signal(2); + const result = asyncComputed(() => source.value * 2); + + expect(result.value).to.equal(4); + expect(result.pending.value).to.equal(false); + expect(result.error.value).to.equal(undefined); + + source.value = 3; + expect(result.value).to.equal(6); + }); + + it("exposes pending state until an asynchronous result settles", async () => { + const deferred = defer(); + const result = asyncComputed(() => deferred.promise); + + expect(result.value).to.equal(undefined); + expect(result.pending.value).to.equal(true); + expect(result.settled.value).to.equal(false); + const settlement = result.settlement; + expect(settlement).to.be.instanceOf(Promise); + + deferred.resolve("done"); + await settlement; + expect(result.value).to.equal("done"); + expect(result.pending.value).to.equal(false); + expect(result.settled.value).to.equal(true); + expect(result.settlement).to.equal(undefined); + }); + + it("only tracks dependencies read before the first await", async () => { + const before = signal(1); + const after = signal(10); + let deferred = defer(); + let runs = 0; + const result = asyncComputed(async () => { + runs++; + const first = before.value; + await deferred.promise; + return first + after.value; + }); + + deferred.resolve(); + await tick(); + expect(result.value).to.equal(11); + + after.value = 20; + await tick(); + expect(runs).to.equal(1); + expect(result.value).to.equal(11); + + deferred = defer(); + before.value = 2; + expect(runs).to.equal(2); + deferred.resolve(); + await tick(); + expect(result.value).to.equal(22); + }); + + it("ignores stale resolutions when a dependency changes", async () => { + const source = signal(0); + const deferreds: ReturnType>[] = []; + const result = asyncComputed(() => { + const deferred = defer(); + deferreds.push(deferred); + return track(source.value, deferred.promise); + }); + + source.value = 1; + expect(deferreds).to.have.length(2); + + deferreds[1].resolve(200); + await tick(); + expect(result.value).to.equal(200); + expect(result.pending.value).to.equal(false); + + deferreds[0].resolve(100); + await tick(); + expect(result.value).to.equal(200); + }); + + it("retains the last value while revalidating and after errors", async () => { + const source = signal(1); + let deferred = defer(); + const result = asyncComputed(() => track(source.value, deferred.promise)); + + deferred.resolve(10); + await tick(); + expect(result.value).to.equal(10); + + deferred = defer(); + source.value = 2; + expect(result.value).to.equal(10); + expect(result.pending.value).to.equal(true); + + const error = new Error("boom"); + deferred.reject(error); + await tick(); + expect(result.value).to.equal(10); + expect(result.error.value).to.equal(error); + expect(result.pending.value).to.equal(false); + + deferred = defer(); + source.value = 3; + deferred.resolve(30); + await tick(); + expect(result.value).to.equal(30); + expect(result.error.value).to.equal(undefined); + }); + + it("captures synchronous errors and recovers", () => { + const shouldThrow = signal(true); + const error = new Error("boom"); + const result = asyncComputed(() => { + if (shouldThrow.value) throw error; + return 42; + }); + + expect(result.error.value).to.equal(error); + expect(result.value).to.equal(undefined); + + shouldThrow.value = false; + expect(result.value).to.equal(42); + expect(result.error.value).to.equal(undefined); + }); + + it("supports undefined as a successful result", async () => { + const result = asyncComputed(async () => undefined); + expect(result.pending.value).to.equal(true); + await tick(); + expect(result.value).to.equal(undefined); + expect(result.error.value).to.equal(undefined); + expect(result.pending.value).to.equal(false); + }); + + it("distinguishes an undefined rejection from success", async () => { + const result = asyncComputed(() => Promise.reject(undefined)); + await result.settlement; + expect(result.settled.value).to.equal(true); + expect(result.failed.value).to.equal(true); + expect(result.error.value).to.equal(undefined); + }); + + it("composes with computeds and effects", async () => { + const deferred = defer(); + const result = asyncComputed(() => deferred.promise); + const doubled = computed(() => (result.value ?? 0) * 2); + const seen: number[] = []; + const dispose = effect(() => { + seen.push(doubled.value); + }); + + deferred.resolve(4); + await tick(); + expect(seen).to.deep.equal([0, 8]); + dispose(); + }); + + it("stops reacting and ignores in-flight results after dispose", async () => { + const source = signal(1); + const deferred = defer(); + let runs = 0; + const result = asyncComputed(() => { + runs++; + return track(source.value, deferred.promise); + }); + + const settlement = result.settlement; + result.dispose(); + expect(result.pending.value).to.equal(false); + await settlement; + deferred.resolve(10); + source.value = 2; + await tick(); + expect(runs).to.equal(1); + expect(result.value).to.equal(undefined); + }); + + it("is automatically disposed with its model", async () => { + const source = signal(1); + const deferred = defer(); + let runs = 0; + const AsyncModel = createModel(() => ({ + result: asyncComputed(() => { + runs++; + return track(source.value, deferred.promise); + }), + })); + const model = new AsyncModel(); + + model[Symbol.dispose](); + expect(model.result.pending.value).to.equal(false); + deferred.resolve(10); + source.value = 2; + await tick(); + expect(runs).to.equal(1); + expect(model.result.value).to.equal(undefined); + }); +}); diff --git a/packages/preact/README.md b/packages/preact/README.md index a2d67ebb2..71d6c4dc4 100644 --- a/packages/preact/README.md +++ b/packages/preact/README.md @@ -11,11 +11,13 @@ Read the [announcement post](https://preactjs.com/blog/introducing-signals/) to - [`signal(initialValue)`](../core/README.md#signalinitialvalue) - [`signal.peek()`](../core/README.md#signalpeek) - [`computed(fn)`](../core/README.md#computedfn) + - [`asyncComputed(fn)`](../core/README.md#asynccomputedfn) - [`effect(fn)`](../core/README.md#effectfn) - [`batch(fn)`](../core/README.md#batchfn) - [`untracked(fn)`](../core/README.md#untrackedfn) - [Preact Integration](#preact-integration) - [Hooks](#hooks) + - [`useAsyncComputed`](#useasynccomputed) - [Rendering optimizations](#rendering-optimizations) - [Attribute optimization (experimental)](#attribute-optimization-experimental) - [Utility Components and Hooks](#utility-components-and-hooks) @@ -95,6 +97,62 @@ function Counter() { If your model needs constructor arguments, pass a factory function to `useModel` that creates the instance. +#### `useAsyncComputed` + +`useAsyncComputed` creates an async computed for the component and disposes it on unmount. Read reactive inputs before the first `await`; signal reads after an `await` are not tracked. + +```js +import { signal, useAsyncComputed } from "@preact/signals"; + +const userId = signal("1"); + +function User() { + const user = useAsyncComputed(async () => { + const id = userId.value; + const response = await fetch(`/api/users/${id}`); + return response.json(); + }); + + if (user.pending.value) return

Loading…

; + return

{user.value?.name}

; +} +``` + +By default errors are thrown during render for an error boundary. Pass `{ throwOnError: false }` to read `user.error` yourself. + +Async computeds also fit directly in models and are disposed with their model. Pass a model-owned instance to the hook when a child should use Suspense; the instance must be created above the boundary so it survives a suspended render. + +```js +import { + asyncComputed, + createModel, + useAsyncComputed, + useModel, +} from "@preact/signals"; +import { Suspense } from "preact/compat"; + +const UserModel = createModel(id => ({ + user: asyncComputed(async () => { + const response = await fetch(`/api/users/${id.value}`); + return response.json(); + }), +})); + +function UserPage({ id }) { + const model = useModel(() => new UserModel(id)); + return ( + Loading…

}> + +
+ ); +} + +function UserDetails({ model }) { + const user = useAsyncComputed(model.user, { suspend: true }); + return

{user.value.name}

; +} +``` + ### Rendering optimizations The Preact adapter ships with several optimizations it can apply out of the box to skip virtual-dom rendering entirely. If you pass a signal directly into JSX, it will bind directly to the DOM `Text` node that is created and update that whenever the signal changes. diff --git a/packages/preact/src/index.ts b/packages/preact/src/index.ts index 0458027ed..87ce2e683 100644 --- a/packages/preact/src/index.ts +++ b/packages/preact/src/index.ts @@ -1,5 +1,5 @@ import { options, Component, isValidElement, Fragment } from "preact"; -import { useRef, useMemo, useEffect } from "preact/hooks"; +import { useRef, useMemo, useEffect, useLayoutEffect } from "preact/hooks"; import { signal, computed, @@ -7,6 +7,9 @@ import { effect, action, createModel, + asyncComputed, + type AsyncComputedFn, + type AsyncComputedSignal, type Model, type ModelConstructor, type ModelFactory, @@ -32,6 +35,9 @@ export { batch, effect, action, + asyncComputed, + type AsyncComputedFn, + type AsyncComputedSignal, type Model, type ModelConstructor, type ModelFactory, @@ -465,6 +471,96 @@ export function useComputed(compute: () => T, options?: SignalOptions) { return useMemo(() => computed(() => $compute.current(), options), []); } +export interface UseAsyncComputedOptions extends SignalOptions< + T | undefined +> { + /** Rethrow the current error during render. Defaults to true. */ + throwOnError?: boolean; + /** + * Suspend while an externally owned instance has no value. Hook-created + * instances cannot suspend on initial mount because hook state is discarded + * when the component suspends. Defaults to false. + */ + suspend?: boolean; +} + +type OwnedAsyncComputedOptions = Omit< + UseAsyncComputedOptions, + "suspend" +> & { suspend?: false }; + +/** Create and own an async computed for the lifetime of this component. */ +export function useAsyncComputed( + compute: AsyncComputedFn, + options?: OwnedAsyncComputedOptions +): AsyncComputedSignal; +/** Observe a model- or otherwise externally-owned async computed. */ +export function useAsyncComputed( + instance: AsyncComputedSignal, + options?: UseAsyncComputedOptions +): AsyncComputedSignal; +export function useAsyncComputed( + source: AsyncComputedFn | AsyncComputedSignal, + options?: UseAsyncComputedOptions +): AsyncComputedSignal { + const $compute = useRef(typeof source === "function" ? source : undefined); + if (typeof source === "function") $compute.current = source; + + // Keep callback work out of render. An abandoned or server render only owns + // this local activation signal and never subscribes to application state. + const activation = useMemo( + () => (typeof source === "function" ? signal(false) : undefined), + [] + ); + const idle = useMemo( + () => (activation ? new Promise(() => {}) : undefined), + [] + ); + const owned = useMemo( + () => + activation + ? asyncComputed( + () => + activation.value + ? ($compute.current as AsyncComputedFn)() + : (idle as Promise), + options + ) + : undefined, + [] + ); + const instance = (owned || source) as AsyncComputedSignal; + useLayoutEffect(() => { + if (!owned || !activation) return; + + activation.value = true; + return () => { + activation.value = false; + // Strict effects can clean up and restart the same hook state. Defer + // final disposal so a same-turn restart can reactivate it. + queueMicrotask(() => { + if (!activation.peek()) instance.dispose(); + }); + }; + }, [instance]); + + if (options?.throwOnError !== false && instance.failed.value) { + const error = instance.error.value; + throw error === undefined + ? new Error("Async computed failed without an error") + : error; + } + if ( + owned === undefined && + options?.suspend && + !instance.settled.value && + instance.settlement + ) { + throw instance.settlement; + } + return instance; +} + function safeRaf(callback: () => void) { const done = () => { clearTimeout(timeout); diff --git a/packages/preact/test/browser/asyncComputed.test.tsx b/packages/preact/test/browser/asyncComputed.test.tsx new file mode 100644 index 000000000..77a27fff0 --- /dev/null +++ b/packages/preact/test/browser/asyncComputed.test.tsx @@ -0,0 +1,261 @@ +import { asyncComputed, signal, useAsyncComputed } from "@preact/signals"; +import { Component, createElement, render } from "preact"; +import type { ComponentChildren, FunctionComponent } from "preact"; +// @ts-ignore The untyped shim avoids preact/compat's global React declaration. +import { Suspense as CompatSuspense } from "./suspense-compat.js"; +import { act } from "preact/test-utils"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +const Suspense = CompatSuspense as FunctionComponent<{ + fallback?: ComponentChildren; + children?: ComponentChildren; +}>; + +function defer() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +const tick = () => new Promise(resolve => setTimeout(resolve, 0)); + +function track(dependency: unknown, value: T): T { + void dependency; + return value; +} + +class Boundary extends Component< + { children: ComponentChildren }, + { error?: Error } +> { + state: { error?: Error } = {}; + componentDidCatch(error: Error) { + this.setState({ error }); + } + render() { + return this.state.error ? ( +

caught:{this.state.error.message}

+ ) : ( + this.props.children + ); + } +} + +describe("useAsyncComputed", () => { + let scratch: HTMLDivElement; + + beforeEach(() => { + scratch = document.createElement("div"); + }); + + afterEach(() => { + render(null, scratch); + }); + + it("owns a callback-created instance and renders its result", async () => { + const deferred = defer(); + function App() { + const result = useAsyncComputed(() => deferred.promise); + return

{result.value ?? "none"}

; + } + + render(, scratch); + expect(scratch.textContent).to.equal("none"); + + await act(async () => { + deferred.resolve("hello"); + await tick(); + }); + expect(scratch.textContent).to.equal("hello"); + }); + + it("uses the latest callback after a reactive dependency changes", async () => { + const dependency = signal(1); + function App({ prefix }: { prefix: string }) { + const result = useAsyncComputed(() => `${prefix}:${dependency.value}`); + return

{result.value ?? "none"}

; + } + + act(() => render(, scratch)); + expect(scratch.textContent).to.equal("first:1"); + + render(, scratch); + await act(async () => { + dependency.value = 2; + await tick(); + }); + expect(scratch.textContent).to.equal("second:2"); + }); + + it("throws errors to an error boundary by default", async () => { + const deferred = defer(); + function App() { + const result = useAsyncComputed(() => deferred.promise); + return

{result.value ?? "none"}

; + } + + render( + + + , + scratch + ); + await act(async () => { + deferred.reject(new Error("boom")); + await tick(); + }); + expect(scratch.textContent).to.equal("caught:boom"); + }); + + it("throws a useful error for an undefined rejection", async () => { + const deferred = defer(); + function App() { + const result = useAsyncComputed(() => deferred.promise); + return

{result.value ?? "none"}

; + } + + render( + + + , + scratch + ); + await act(async () => { + deferred.reject(undefined); + await tick(); + }); + expect(scratch.textContent).to.equal( + "caught:Async computed failed without an error" + ); + }); + + it("exposes errors when throwOnError is disabled", async () => { + const deferred = defer(); + function App() { + const result = useAsyncComputed(() => deferred.promise, { + throwOnError: false, + }); + return ( +

+ {result.error.value + ? `error:${(result.error.value as Error).message}` + : "ok"} +

+ ); + } + + render(, scratch); + deferred.reject(new Error("boom")); + await act(tick); + expect(scratch.textContent).to.equal("error:boom"); + }); + + it("disposes callback-created instances on unmount", async () => { + const dependency = signal(1); + const deferred = defer(); + let runs = 0; + function App() { + const result = useAsyncComputed(() => { + runs++; + const current = dependency.value; + return deferred.promise.then(() => current); + }); + return

{result.value ?? "none"}

; + } + + act(() => render(, scratch)); + await act(tick); + act(() => render(null, scratch)); + dependency.value = 2; + deferred.resolve(1); + await tick(); + expect(runs).to.equal(1); + }); + + it("suspends with an externally owned instance and then resolves", async () => { + const deferred = defer(); + const external = asyncComputed(() => deferred.promise); + function App() { + const result = useAsyncComputed(external, { suspend: true }); + return

{result.value}

; + } + + act(() => { + render( + loading}> + + , + scratch + ); + }); + expect(scratch.textContent).to.equal("loading"); + + await act(async () => { + deferred.resolve("hello"); + await tick(); + }); + expect(scratch.textContent).to.equal("hello"); + }); + + it("does not re-suspend after settling with undefined", async () => { + const dependency = signal(1); + const deferreds: ReturnType>[] = []; + const external = asyncComputed(() => { + const deferred = defer(); + deferreds.push(deferred); + return track(dependency.value, deferred.promise); + }); + function App() { + const result = useAsyncComputed(external, { suspend: true }); + return

{result.settled.value ? "ready" : "pending"}

; + } + + act(() => { + render( + loading}> + + , + scratch + ); + }); + expect(scratch.textContent).to.equal("loading"); + + await act(async () => { + deferreds[0].resolve(); + await tick(); + }); + expect(scratch.textContent).to.equal("ready"); + + act(() => { + dependency.value = 2; + }); + expect(external.pending.value).to.equal(true); + expect(scratch.textContent).to.equal("ready"); + deferreds[1].resolve(); + external.dispose(); + }); + + it("does not dispose externally owned instances", async () => { + const dependency = signal(1); + let runs = 0; + const external = asyncComputed(() => { + runs++; + return dependency.value; + }); + function App() { + const result = useAsyncComputed(external); + return

{result.value}

; + } + + render(, scratch); + render(null, scratch); + dependency.value = 2; + expect(runs).to.equal(2); + expect(external.value).to.equal(2); + external.dispose(); + }); +}); diff --git a/packages/preact/test/browser/suspense-compat.js b/packages/preact/test/browser/suspense-compat.js new file mode 100644 index 000000000..adc18b378 --- /dev/null +++ b/packages/preact/test/browser/suspense-compat.js @@ -0,0 +1,3 @@ +// Untyped re-export: preact/compat's types declare `export as namespace React`, +// which collides with React's types in the monorepo tsconfig. +export { Suspense } from "preact/compat"; diff --git a/packages/preact/test/ssr.test.tsx b/packages/preact/test/ssr.test.tsx index 824081eb4..4c0fd8a00 100644 --- a/packages/preact/test/ssr.test.tsx +++ b/packages/preact/test/ssr.test.tsx @@ -1,7 +1,12 @@ -import { signal, useSignal, useComputed } from "@preact/signals"; +import { + signal, + useSignal, + useComputed, + useAsyncComputed, +} from "@preact/signals"; import { createElement } from "preact"; import { renderToString } from "preact-render-to-string"; -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; const sleep = (ms?: number) => new Promise(r => setTimeout(r, ms)); @@ -95,6 +100,17 @@ describe("@preact/signals", () => { expect(renderToString(

{a}

)).to.equal(`

1

`); }); + it("should not start async computed callbacks", () => { + const compute = vi.fn(() => Promise.resolve("loaded")); + function App() { + const result = useAsyncComputed(compute); + return

{result.value ?? "none"}

; + } + + expect(renderToString()).to.equal("

none

"); + expect(compute).not.toHaveBeenCalled(); + }); + it("should render computed signals", () => { function App() { const name = useSignal("Bob"); diff --git a/packages/react/README.md b/packages/react/README.md index 7075695ad..3f6fac8d3 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -11,6 +11,7 @@ Read the [announcement post](https://preactjs.com/blog/introducing-signals/) to - [`signal(initialValue)`](../core/README.md#signalinitialvalue) - [`signal.peek()`](../core/README.md#signalpeek) - [`computed(fn)`](../core/README.md#computedfn) + - [`asyncComputed(fn)`](../core/README.md#asynccomputedfn) - [`effect(fn)`](../core/README.md#effectfn) - [`batch(fn)`](../core/README.md#batchfn) - [`untracked(fn)`](../core/README.md#untrackedfn) @@ -18,6 +19,7 @@ Read the [announcement post](https://preactjs.com/blog/introducing-signals/) to - [Babel Transform](#babel-transform) - [`useSignals` hook](#usesignals-hook) - [Hooks](#hooks) + - [`useAsyncComputed`](#useasynccomputed) - [Using signals with React's SSR APIs](#using-signals-with-reacts-ssr-apis) - [Rendering optimizations](#rendering-optimizations) - [Utility Components and Hooks](#utility-components-and-hooks) @@ -126,6 +128,62 @@ function Counter() { If your model needs constructor arguments, pass a factory function to `useModel` that creates the instance. +#### `useAsyncComputed` + +`useAsyncComputed` creates an async computed for the component and disposes it on unmount. Read reactive inputs before the first `await`; signal reads after an `await` are not tracked. + +```js +import { signal, useAsyncComputed } from "@preact/signals-react"; + +const userId = signal("1"); + +function User() { + const user = useAsyncComputed(async () => { + const id = userId.value; + const response = await fetch(`/api/users/${id}`); + return response.json(); + }); + + if (user.pending.value) return

Loading…

; + return

{user.value?.name}

; +} +``` + +By default errors are thrown during render for an error boundary. Pass `{ throwOnError: false }` to read `user.error` yourself. + +Async computeds also fit directly in models and are disposed with their model. Pass a model-owned instance to the hook when a child should use Suspense; the instance must be created above the boundary so it survives a suspended render. + +```js +import { + asyncComputed, + createModel, + useAsyncComputed, + useModel, +} from "@preact/signals-react"; +import { Suspense } from "react"; + +const UserModel = createModel(id => ({ + user: asyncComputed(async () => { + const response = await fetch(`/api/users/${id.value}`); + return response.json(); + }), +})); + +function UserPage({ id }) { + const model = useModel(() => new UserModel(id)); + return ( + Loading…

}> + +
+ ); +} + +function UserDetails({ model }) { + const user = useAsyncComputed(model.user, { suspend: true }); + return

{user.value.name}

; +} +``` + ### Using signals with React's SSR APIs Components rendered using SSR APIs (e.g. `renderToString`) in a server environment (i.e. an environment without a global `window` object) will not track signals used during render. Components generally don't rerender when using SSR APIs so tracking signal usage is useless since changing these signals can't trigger a rerender. diff --git a/packages/react/runtime/src/index.ts b/packages/react/runtime/src/index.ts index 9911a8d07..fcb0d6cca 100644 --- a/packages/react/runtime/src/index.ts +++ b/packages/react/runtime/src/index.ts @@ -2,6 +2,9 @@ import { signal, computed, effect, + asyncComputed, + type AsyncComputedFn, + type AsyncComputedSignal, Signal, ReadonlySignal, SignalOptions, @@ -432,6 +435,96 @@ export function useComputed( return useMemo(() => computed(() => $compute.current(), options), Empty); } +export interface UseAsyncComputedOptions extends SignalOptions< + T | undefined +> { + /** Rethrow the current error during render. Defaults to true. */ + throwOnError?: boolean; + /** + * Suspend while an externally owned instance has no value. Hook-created + * instances cannot suspend on initial mount because hook state is discarded + * when the component suspends. Defaults to false. + */ + suspend?: boolean; +} + +type OwnedAsyncComputedOptions = Omit< + UseAsyncComputedOptions, + "suspend" +> & { suspend?: false }; + +/** Create and own an async computed for the lifetime of this component. */ +export function useAsyncComputed( + compute: AsyncComputedFn, + options?: OwnedAsyncComputedOptions +): AsyncComputedSignal; +/** Observe a model- or otherwise externally-owned async computed. */ +export function useAsyncComputed( + instance: AsyncComputedSignal, + options?: UseAsyncComputedOptions +): AsyncComputedSignal; +export function useAsyncComputed( + source: AsyncComputedFn | AsyncComputedSignal, + options?: UseAsyncComputedOptions +): AsyncComputedSignal { + const $compute = useRef(typeof source === "function" ? source : undefined); + if (typeof source === "function") $compute.current = source; + + // Keep callback work out of render. An abandoned or server render only owns + // this local activation signal and never subscribes to application state. + const activation = useMemo( + () => (typeof source === "function" ? signal(false) : undefined), + Empty + ); + const idle = useMemo( + () => (activation ? new Promise(() => {}) : undefined), + Empty + ); + const owned = useMemo( + () => + activation + ? asyncComputed( + () => + activation.value + ? ($compute.current as AsyncComputedFn)() + : (idle as Promise), + options + ) + : undefined, + Empty + ); + const instance = (owned || source) as AsyncComputedSignal; + useEffect(() => { + if (!owned || !activation) return; + + activation.value = true; + return () => { + activation.value = false; + // Strict effects can clean up and restart the same hook state. Defer + // final disposal so a same-turn restart can reactivate it. + queueMicrotask(() => { + if (!activation.peek()) instance.dispose(); + }); + }; + }, [instance]); + + if (options?.throwOnError !== false && instance.failed.value) { + const error = instance.error.value; + throw error === undefined + ? new Error("Async computed failed without an error") + : error; + } + if ( + owned === undefined && + options?.suspend && + !instance.settled.value && + instance.settlement + ) { + throw instance.settlement; + } + return instance; +} + export function useSignalEffect( cb: () => void | (() => void), options?: EffectOptions diff --git a/packages/react/runtime/test/browser/asyncComputed.test.tsx b/packages/react/runtime/test/browser/asyncComputed.test.tsx new file mode 100644 index 000000000..7c9f7919a --- /dev/null +++ b/packages/react/runtime/test/browser/asyncComputed.test.tsx @@ -0,0 +1,289 @@ +// @ts-expect-error React's act environment flag is intentionally global. +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +import { Component, createElement, StrictMode, Suspense } from "react"; +import type { ReactNode } from "react"; +import { asyncComputed, signal } from "@preact/signals-core"; +import { useAsyncComputed, useSignals } from "@preact/signals-react/runtime"; +import { + Root, + act, + checkHangingAct, + createRoot, + getConsoleErrorSpy, +} from "../../../test/shared/utils"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +function defer() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +const tick = () => new Promise(resolve => setTimeout(resolve, 0)); + +function track(dependency: unknown, value: T): T { + void dependency; + return value; +} + +class Boundary extends Component<{ children: ReactNode }, { error?: Error }> { + state: { error?: Error } = {}; + static getDerivedStateFromError(error: Error) { + return { error }; + } + render() { + return this.state.error ? ( +

caught:{this.state.error.message}

+ ) : ( + this.props.children + ); + } +} + +describe("useAsyncComputed", () => { + let scratch: HTMLDivElement; + let root: Root; + + async function render(element: Parameters[0]) { + await act(() => root.render(element)); + } + + beforeEach(async () => { + scratch = document.createElement("div"); + document.body.appendChild(scratch); + root = await createRoot(scratch); + getConsoleErrorSpy().mockClear(); + }); + + afterEach(async () => { + await act(() => root.unmount()); + scratch.remove(); + checkHangingAct(); + }); + + it("owns a callback-created instance and renders its result", async () => { + const deferred = defer(); + function App() { + useSignals(); + const result = useAsyncComputed(() => deferred.promise); + return

{result.value ?? "none"}

; + } + + await render(); + expect(scratch.textContent).to.equal("none"); + + await act(async () => { + deferred.resolve("hello"); + await tick(); + }); + expect(scratch.textContent).to.equal("hello"); + }); + + it("uses the latest callback after a reactive dependency changes", async () => { + const dependency = signal(1); + function App({ prefix }: { prefix: string }) { + useSignals(); + const result = useAsyncComputed(() => `${prefix}:${dependency.value}`); + return

{result.value ?? "none"}

; + } + + await render(); + expect(scratch.textContent).to.equal("first:1"); + + await render(); + await act(async () => { + dependency.value = 2; + await tick(); + }); + expect(scratch.textContent).to.equal("second:2"); + }); + + it("survives StrictMode effect replay", async () => { + function App() { + useSignals(); + const result = useAsyncComputed(() => "ready"); + return

{result.value ?? "none"}

; + } + + await render( + + + + ); + await act(tick); + expect(scratch.textContent).to.equal("ready"); + }); + + it("throws errors to an error boundary by default", async () => { + const deferred = defer(); + function App() { + useSignals(); + const result = useAsyncComputed(() => deferred.promise); + return

{result.value ?? "none"}

; + } + + await render( + + + + ); + await act(async () => { + deferred.reject(new Error("boom")); + await tick(); + }); + expect(scratch.textContent).to.equal("caught:boom"); + }); + + it("throws a useful error for an undefined rejection", async () => { + const deferred = defer(); + function App() { + useSignals(); + const result = useAsyncComputed(() => deferred.promise); + return

{result.value ?? "none"}

; + } + + await render( + + + + ); + await act(async () => { + deferred.reject(undefined); + await tick(); + }); + expect(scratch.textContent).to.equal( + "caught:Async computed failed without an error" + ); + }); + + it("exposes errors when throwOnError is disabled", async () => { + const deferred = defer(); + function App() { + useSignals(); + const result = useAsyncComputed(() => deferred.promise, { + throwOnError: false, + }); + return ( +

+ {result.error.value + ? `error:${(result.error.value as Error).message}` + : "ok"} +

+ ); + } + + await render(); + await act(async () => { + deferred.reject(new Error("boom")); + await tick(); + }); + expect(scratch.textContent).to.equal("error:boom"); + }); + + it("disposes callback-created instances on unmount", async () => { + const dependency = signal(1); + const deferred = defer(); + let runs = 0; + function App() { + useSignals(); + const result = useAsyncComputed(() => { + runs++; + const current = dependency.value; + return deferred.promise.then(() => current); + }); + return

{result.value ?? "none"}

; + } + + await render(); + await act(() => root.unmount()); + dependency.value = 2; + deferred.resolve(1); + await tick(); + expect(runs).to.equal(1); + }); + + it("suspends with an externally owned instance and then resolves", async () => { + const deferred = defer(); + const external = asyncComputed(() => deferred.promise); + function App() { + useSignals(); + const result = useAsyncComputed(external, { suspend: true }); + return

{result.value}

; + } + + await render( + loading}> + + + ); + expect(scratch.textContent).to.equal("loading"); + + await act(async () => { + deferred.resolve("hello"); + await tick(); + }); + expect(scratch.textContent).to.equal("hello"); + }); + + it("does not re-suspend after settling with undefined", async () => { + const dependency = signal(1); + const deferreds: ReturnType>[] = []; + const external = asyncComputed(() => { + const deferred = defer(); + deferreds.push(deferred); + return track(dependency.value, deferred.promise); + }); + function App() { + useSignals(); + const result = useAsyncComputed(external, { suspend: true }); + return

{result.settled.value ? "ready" : "pending"}

; + } + + await render( + loading}> + + + ); + expect(scratch.textContent).to.equal("loading"); + + await act(async () => { + deferreds[0].resolve(); + await tick(); + }); + expect(scratch.textContent).to.equal("ready"); + + await act(() => { + dependency.value = 2; + }); + expect(external.pending.value).to.equal(true); + expect(scratch.textContent).to.equal("ready"); + deferreds[1].resolve(); + external.dispose(); + }); + + it("does not dispose externally owned instances", async () => { + const dependency = signal(1); + let runs = 0; + const external = asyncComputed(() => { + runs++; + return dependency.value; + }); + function App() { + useSignals(); + const result = useAsyncComputed(external); + return

{result.value}

; + } + + await render(); + await act(() => root.unmount()); + dependency.value = 2; + expect(runs).to.equal(2); + expect(external.value).to.equal(2); + external.dispose(); + }); +}); diff --git a/packages/react/runtime/test/node/renderToStaticMarkup.test.tsx b/packages/react/runtime/test/node/renderToStaticMarkup.test.tsx index 86bd09ea1..fbfaf5ad7 100644 --- a/packages/react/runtime/test/node/renderToStaticMarkup.test.tsx +++ b/packages/react/runtime/test/node/renderToStaticMarkup.test.tsx @@ -1,4 +1,8 @@ -import { signal, useSignalEffect } from "@preact/signals-react"; +import { + signal, + useAsyncComputed, + useSignalEffect, +} from "@preact/signals-react"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { mountSignalsTests } from "../../../test/shared/mounting"; @@ -8,6 +12,17 @@ describe("@preact/signals-react/runtime", () => { describe("renderToStaticMarkup", () => { mountSignalsTests(el => Promise.resolve(renderToStaticMarkup(el))); + it("should not start async computed callbacks", () => { + const compute = vi.fn(() => Promise.resolve("loaded")); + function App() { + const result = useAsyncComputed(compute); + return

{result.value ?? "none"}

; + } + + expect(renderToStaticMarkup()).to.equal("

none

"); + expect(compute).not.toHaveBeenCalled(); + }); + it("should not invoke useSignalEffect", async () => { const spy = vi.fn(); const sig = signal("foo"); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 851c7f84d..ecacbc877 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -12,6 +12,9 @@ import { batch, effect, action, + asyncComputed, + type AsyncComputedFn, + type AsyncComputedSignal, type Model, type ModelConstructor, type ModelFactory, @@ -25,6 +28,8 @@ import { useSignal, useComputed, useSignalEffect, + useAsyncComputed, + type UseAsyncComputedOptions, useModel, } from "@preact/signals-react/runtime"; @@ -34,6 +39,9 @@ export { batch, effect, action, + asyncComputed, + type AsyncComputedFn, + type AsyncComputedSignal, type Model, type ModelConstructor, type ModelFactory, @@ -43,6 +51,8 @@ export { useSignal, useComputed, useSignalEffect, + useAsyncComputed, + type UseAsyncComputedOptions, useModel, untracked, };