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,
};