Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/quick-spies-fetch.md
Original file line number Diff line number Diff line change
@@ -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.
37 changes: 37 additions & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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`.
Expand Down
127 changes: 127 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1124,6 +1124,133 @@ function createModel<TModel, TFactoryArgs extends any[] = []>(

//#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<T> = () => PromiseLike<T> | T;

/** The reactive state of an asynchronous computation. */
export interface AsyncComputedSignal<T> extends ReadonlySignal<T | undefined> {
/** True while the latest run is waiting for a promise to settle. */
readonly pending: ReadonlySignal<boolean>;
/** True after at least one run has settled, including with `undefined`. */
readonly settled: ReadonlySignal<boolean>;
/** Whether the latest settled run failed, even if it rejected with `undefined`. */
readonly failed: ReadonlySignal<boolean>;
/** The error from the latest failed run, or undefined after a successful run. */
readonly error: ReadonlySignal<unknown>;
/** The current run's stable settlement promise, when it is pending. */
readonly settlement: Promise<void> | 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<T>(
fn: AsyncComputedFn<T>,
options?: SignalOptions<T | undefined>
): AsyncComputedSignal<T> {
const out = new Signal<T | undefined>(undefined, options);
const pending = new Signal(false);
const settled = new Signal(false);
const failed = new Signal(false);
const error = new Signal<unknown>(undefined);
const facade = out as unknown as AsyncComputedSignal<T> & {
pending: Signal<boolean>;
settled: Signal<boolean>;
failed: Signal<boolean>;
error: Signal<unknown>;
settlement: Promise<void> | 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> | T;
let isThenable: boolean;

try {
result = fn();
isThenable =
result != null &&
typeof (result as PromiseLike<T>).then === "function";
} catch (err) {
settle(id, true, err);
return () => {
if (id === runId) runId++;
};
}

if (isThenable) {
facade.settlement = new Promise<void>(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,
Expand Down
Loading