From 10a584ee4cc6118c488cb44528a2c8575717690a Mon Sep 17 00:00:00 2001 From: Justin Fagnani Date: Fri, 5 Jul 2024 19:18:51 -0700 Subject: [PATCH 01/10] Add AsyncComputed --- src/async-computed.ts | 202 +++++++++++++++++++++++++++++++++++ tests/async-computed.test.ts | 161 ++++++++++++++++++++++++++++ 2 files changed, 363 insertions(+) create mode 100644 src/async-computed.ts create mode 100644 tests/async-computed.test.ts diff --git a/src/async-computed.ts b/src/async-computed.ts new file mode 100644 index 0000000..9a6b379 --- /dev/null +++ b/src/async-computed.ts @@ -0,0 +1,202 @@ +import { Signal } from "signal-polyfill"; + +export type AsyncComputedState = "initial" | "pending" | "complete" | "error"; + +export interface AsyncComputedOptions { + /** + * The initial value of the AsyncComputed. + */ + initialValue?: T; +} + +/** + * A signal-like object that represents an asynchronous computation. + * + * AsyncComputed takes a compute function that performs an asynchronous + * computation and runs it inside a computed signal, while tracking the state of + * the computation, including its most recent completion value and error. + * + * Compute functions are run when the `state`, `value`, `error`, or `complete` + * properties are read, and are re-run when any signals that they read change. + * + * If a new run of the compute function is started before the previous run has + * completed, the previous run will have its AbortSignal aborted, and the + * result of the previous run will be ignored. + */ +export class AsyncComputed { + // Whether we have been notified of a pending update from the watcher. This is + // set synchronously when any dependencies of the compute function change. + #isNotified = false; + #state = new Signal.State("initial"); + + /** + * The current state of the AsyncComputed, which is one of 'initial', + * 'pending', 'complete', or 'error'. + * + * The state will be 'initial' until the compute function is first run. + * + * The state will be 'pending' while the compute function is running. If the + * state is 'pending', the `value` and `error` properties will be the result + * of the previous run of the compute function. + * + * The state will be 'complete' when the compute function has completed + * successfully. If the state is 'complete', the `value` property will be the + * result of the previous run of the compute function and the `error` property + * will be `undefined`. + * + * The state will be 'error' when the compute function has completed with an + * error. If the state is 'error', the `error` property will be the error that + * was thrown by the previous run of the compute function and the `value` + * property will be `undefined`. + * + * This value is read from a signal, so any signals that read the state will + * be marked as dependents of it. + */ + get state() { + // Unconditionally read the state signal to ensure that any signals that + // read the state are marked as dependents. + const currentState = this.#state.get(); + return this.#isNotified ? "pending" : currentState; + } + + /** + * The last value that the compute function resolved with, or `undefined` if + * the last run of the compute function threw an error, or if the compute + * function has not yet been run. + * + * This value is read from a signal, so any signals that read the state will + * be marked as dependents of it. + */ + #value: Signal.State; + get value() { + this.run(); + return this.#value.get(); + } + + /** + * The last error that the compute function threw, or `undefined` if the last + * run of the compute function resolved successfully, or if the compute + * function has not yet been run. + * + * This value is read from a signal, so any signals that read the state will + * be marked as dependents of it. + */ + #error = new Signal.State(undefined); + get error() { + this.run(); + return this.#error.get(); + } + + #deferred = new Signal.State | undefined>(undefined); + + /** + * A promise that resolves when the compute functino has completed, or rejects + * if the compute functino throws an error. + * + * If a new run of the compute functino is started before the previous run has + * completed, the promise will resolve with the result of the new run. + */ + get complete(): Promise { + this.run(); + // run() will have created a new deferred if needed. + return this.#deferred.get()!.promise; + } + + #computed: Signal.Computed; + + #watcher: Signal.subtle.Watcher; + + // A unique ID for the current run. This is used to ensure that runs that have + // been preempted by a new run do not update state or resolve the deferred + // with the wrong result. + #currentRunId = 0; + + #currentAbortController?: AbortController; + + /** + * Creates a new AsyncComputed signal. + * + * @param fn The function that performs the asynchronous computation. Any + * signals read synchronously - that is, before the first await - will be + * marked as dependencies of the AsyncComputed, and cause the function to + * re-run when they change. + */ + constructor( + fn: (signal: AbortSignal) => Promise, + options?: AsyncComputedOptions + ) { + this.#value = new Signal.State(options?.initialValue); + this.#computed = new Signal.Computed(() => { + const runId = ++this.#currentRunId; + // Untrack reading the state signal to avoid triggering the computed when + // the state changes. + const state = Signal.subtle.untrack(() => this.#state.get()); + + // If we're not already pending, create a new deferred to track the + // completion of the run. + if (state !== "pending") { + this.#deferred.set(Promise.withResolvers()); + } + this.#isNotified = false; + this.#state.set("pending"); + + this.#currentAbortController?.abort(); + this.#currentAbortController = new AbortController(); + + fn(this.#currentAbortController.signal).then( + (result) => { + // If we've been preempted by a new run, don't update the state or + // resolve the deferred. + if (runId !== this.#currentRunId) { + return; + } + this.#state.set("complete"); + this.#value.set(result); + this.#error.set(undefined); + this.#deferred.get()!.resolve(result); + }, + (error) => { + // If we've been preempted by a new run, don't update the state or + // resolve the deferred. + if (runId !== this.#currentRunId) { + return; + } + this.#state.set("error"); + this.#error.set(error); + this.#value.set(undefined); + this.#deferred.get()!.reject(error); + } + ); + }); + this.#watcher = new Signal.subtle.Watcher(() => { + this.#isNotified = true; + }); + this.#watcher.watch(this.#computed); + } + + /** + * Returns the last value that the compute function resolved with, or + * `undefined` if the compute function has not yet been run. + * + * @throws The last error that the compute function threw, is the last run of + * the compute function threw an error. + */ + get() { + const state = this.state; + if ( + state === "error" || + (state === "pending" && this.error !== undefined) + ) { + throw this.error; + } + return this.value; + } + + /** + * Runs the compute function if it is not already running and its dependencies + * have changed. + */ + run() { + this.#computed.get(); + } +} diff --git a/tests/async-computed.test.ts b/tests/async-computed.test.ts new file mode 100644 index 0000000..b96413f --- /dev/null +++ b/tests/async-computed.test.ts @@ -0,0 +1,161 @@ +import { describe, test, assert } from "vitest"; +import { Signal } from "signal-polyfill"; +import { AsyncComputed } from "../src/async-computed.ts"; + +describe("AsyncComputed", () => { + test("initialValue", async () => { + const task = new AsyncComputed(async () => 1, { initialValue: 0 }); + assert.strictEqual(task.value, 0); + }); + + test("AsyncComputed runs", async () => { + const task = new AsyncComputed(async () => { + // Make the task take more than one microtask + await 0; + return 1; + }); + assert.equal(task.state, "initial"); + + // Getting the value starts the task + assert.strictEqual(task.value, undefined); + assert.strictEqual(task.error, undefined); + assert.equal(task.state, "pending"); + + const result = await task.complete; + + assert.equal(task.state, "complete"); + assert.strictEqual(task.value, 1); + assert.strictEqual(result, 1); + assert.strictEqual(task.error, undefined); + }); + + test("AsyncComputed re-runs when signal dependencies change", async () => { + const dep = new Signal.State("a"); + const task = new AsyncComputed(async () => { + // Read dependencies before first await + const value = dep.get(); + return value; + }); + + await task.complete; + assert.equal(task.state, "complete"); + assert.strictEqual(task.value, "a"); + assert.strictEqual(task.error, undefined); + + dep.set("b"); + assert.equal(task.state, "pending"); + + await task.complete; + assert.equal(task.state, "complete"); + assert.strictEqual(task.value, "b"); + assert.strictEqual(task.error, undefined); + }); + + test("Preemptive runs reuse the same completed promise", async () => { + const dep = new Signal.State("a"); + const deferredOne = Promise.withResolvers(); + let deferred = deferredOne; + const abortSignals: Array = []; + const task = new AsyncComputed(async (abortSignal) => { + // Read dependencies before first await + const value = dep.get(); + + abortSignals.push(abortSignal); + // Wait until we're told to go. The first run will wait so that the + // second run can preempt it. + await deferred.promise; + return value; + }); + + // Capture the promise that the task will complete + const firstRunComplete = task.complete; + + // Trigger a new run with a new deferred + const deferredTwo = Promise.withResolvers(); + deferred = deferredTwo; + dep.set("b"); + const secondRunComplete = task.complete; + + assert.equal(task.state, "pending"); + assert.strictEqual(abortSignals.length, 2); + assert.strictEqual(abortSignals[0]!.aborted, true); + assert.strictEqual(abortSignals[1]!.aborted, false); + + // We should not have created a new Promise. The first Promise should be + // resolved with the result of the second run. + assert.strictEqual(firstRunComplete, secondRunComplete); + + // Resolve the second run + deferredTwo.resolve(); + const result = await task.complete; + assert.equal(result, "b"); + }); + + test("AsyncComputed errors and can re-run", async () => { + const dep = new Signal.State("a"); + const task = new AsyncComputed(async () => { + // Read dependencies before first await + const value = dep.get(); + await 0; + if (value === "a") { + throw new Error("a"); + } + return value; + }); + + task.run(); + assert.equal(task.state, "pending"); + + try { + await task.complete; + assert.fail("Task should have thrown"); + } catch (error) { + assert.equal(task.state, "error"); + assert.strictEqual(task.value, undefined); + assert.strictEqual(task.error, error); + } + + // Check that the task can re-run after an error + + dep.set("b"); + assert.equal(task.state, "pending"); + await task.complete; + assert.strictEqual(task.value, "b"); + assert.strictEqual(task.error, undefined); + }); + + test("get() throws on error", async () => { + const task = new AsyncComputed(async () => { + throw new Error("A"); + }); + task.run(); + await task.complete.catch(() => {}); + assert.throws(() => task.get()); + }); + + test("can chain a computed signal", async () => { + const dep = new Signal.State("a"); + const task = new AsyncComputed(async () => { + // Read dependencies before first await + const value = dep.get(); + await 0; + if (value === "b") { + throw new Error("b"); + } + return value; + }); + const computed = new Signal.Computed(() => task.get()); + assert.strictEqual(computed.get(), undefined); + + await task.complete; + assert.strictEqual(computed.get(), "a"); + + dep.set("b"); + await task.complete.catch(() => {}); + assert.throws(() => computed.get()); + + dep.set("c"); + await task.complete; + assert.strictEqual(computed.get(), "c"); + }); +}); From d0d40bdc859a41cb999d1a76efd24812c3823eef Mon Sep 17 00:00:00 2001 From: Justin Fagnani Date: Fri, 5 Jul 2024 19:58:04 -0700 Subject: [PATCH 02/10] Use Node 22 in tests --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3c33de..eaf4404 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,7 @@ jobs: steps: - uses: actions/checkout@v4 - uses: wyvox/action-setup-pnpm@v3 + with: { node-version: 22 } - run: pnpm install - run: pnpm build - run: pnpm vitest ${{ matrix.testenv.args }} From 3f3d29081d7ba595e1168879006abd7f2934ef88 Mon Sep 17 00:00:00 2001 From: Justin Fagnani Date: Fri, 5 Jul 2024 19:59:44 -0700 Subject: [PATCH 03/10] Format --- src/async-computed.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/async-computed.ts b/src/async-computed.ts index 9a6b379..3a3198c 100644 --- a/src/async-computed.ts +++ b/src/async-computed.ts @@ -120,10 +120,12 @@ export class AsyncComputed { * signals read synchronously - that is, before the first await - will be * marked as dependencies of the AsyncComputed, and cause the function to * re-run when they change. + * + * @param options.initialValue The initial value of the AsyncComputed. */ constructor( fn: (signal: AbortSignal) => Promise, - options?: AsyncComputedOptions + options?: AsyncComputedOptions, ) { this.#value = new Signal.State(options?.initialValue); this.#computed = new Signal.Computed(() => { @@ -165,7 +167,7 @@ export class AsyncComputed { this.#error.set(error); this.#value.set(undefined); this.#deferred.get()!.reject(error); - } + }, ); }); this.#watcher = new Signal.subtle.Watcher(() => { From 590a616392f9ec8f1edae87f6f1948ef153d379d Mon Sep 17 00:00:00 2001 From: Justin Fagnani Date: Sat, 6 Jul 2024 07:57:07 -0700 Subject: [PATCH 04/10] Re-watch the computed after a change --- src/async-computed.ts | 7 ++++--- tests/async-computed.test.ts | 38 ++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/async-computed.ts b/src/async-computed.ts index 3a3198c..d381187 100644 --- a/src/async-computed.ts +++ b/src/async-computed.ts @@ -90,10 +90,10 @@ export class AsyncComputed { #deferred = new Signal.State | undefined>(undefined); /** - * A promise that resolves when the compute functino has completed, or rejects - * if the compute functino throws an error. + * A promise that resolves when the compute function has completed, or rejects + * if the compute function throws an error. * - * If a new run of the compute functino is started before the previous run has + * If a new run of the compute function is started before the previous run has * completed, the promise will resolve with the result of the new run. */ get complete(): Promise { @@ -172,6 +172,7 @@ export class AsyncComputed { }); this.#watcher = new Signal.subtle.Watcher(() => { this.#isNotified = true; + this.#watcher.watch(); }); this.#watcher.watch(this.#computed); } diff --git a/tests/async-computed.test.ts b/tests/async-computed.test.ts index b96413f..eb2c956 100644 --- a/tests/async-computed.test.ts +++ b/tests/async-computed.test.ts @@ -49,6 +49,9 @@ describe("AsyncComputed", () => { assert.equal(task.state, "complete"); assert.strictEqual(task.value, "b"); assert.strictEqual(task.error, undefined); + + dep.set("c"); + assert.equal(task.state, "pending"); }); test("Preemptive runs reuse the same completed promise", async () => { @@ -158,4 +161,39 @@ describe("AsyncComputed", () => { await task.complete; assert.strictEqual(computed.get(), "c"); }); + + test("can chain an AsyncComputed", async () => { + const dep = new Signal.State("a"); + const task1 = new AsyncComputed(async () => { + // Read dependencies before first await + const value = dep.get(); + await 0; + if (value === "b") { + throw new Error("b"); + } + return value; + }); + const task2 = new AsyncComputed(async () => { + return task1.complete; + }); + + assert.strictEqual(task2.get(), undefined); + assert.strictEqual(task2.state, "pending"); + + await task2.complete; + assert.strictEqual(task2.get(), "a"); + assert.strictEqual(task2.state, "complete"); + + dep.set("b"); + assert.strictEqual(task2.state, "pending"); + await task2.complete.catch(() => {}); + assert.throws(() => task2.get()); + assert.strictEqual(task2.state, "error"); + + dep.set("c"); + assert.strictEqual(task2.state, "pending"); + await task2.complete; + assert.strictEqual(task2.get(), "c"); + assert.strictEqual(task2.state, "complete"); + }); }); From 09aed796fac6af5fe64af5a7b75d810a43752f51 Mon Sep 17 00:00:00 2001 From: Justin Fagnani Date: Sat, 6 Jul 2024 11:27:29 -0700 Subject: [PATCH 05/10] Rename .state to .status --- src/async-computed.ts | 75 ++++++++++++++++++------------------ tests/async-computed.test.ts | 38 +++++++++--------- 2 files changed, 57 insertions(+), 56 deletions(-) diff --git a/src/async-computed.ts b/src/async-computed.ts index d381187..f52e88b 100644 --- a/src/async-computed.ts +++ b/src/async-computed.ts @@ -1,6 +1,6 @@ import { Signal } from "signal-polyfill"; -export type AsyncComputedState = "initial" | "pending" | "complete" | "error"; +export type AsyncComputedStatus = "initial" | "pending" | "complete" | "error"; export interface AsyncComputedOptions { /** @@ -13,11 +13,12 @@ export interface AsyncComputedOptions { * A signal-like object that represents an asynchronous computation. * * AsyncComputed takes a compute function that performs an asynchronous - * computation and runs it inside a computed signal, while tracking the state of + * computation and runs it inside a computed signal, while tracking the status of * the computation, including its most recent completion value and error. * - * Compute functions are run when the `state`, `value`, `error`, or `complete` - * properties are read, and are re-run when any signals that they read change. + * Compute functions are run when the `value`, `error`, or `complete` properties + * are read, or when `get()` or `run()` are called, and are re-run when any + * signals that they read change. * * If a new run of the compute function is started before the previous run has * completed, the previous run will have its AbortSignal aborted, and the @@ -27,35 +28,35 @@ export class AsyncComputed { // Whether we have been notified of a pending update from the watcher. This is // set synchronously when any dependencies of the compute function change. #isNotified = false; - #state = new Signal.State("initial"); + #status = new Signal.State("initial"); /** - * The current state of the AsyncComputed, which is one of 'initial', + * The current status of the AsyncComputed, which is one of 'initial', * 'pending', 'complete', or 'error'. * - * The state will be 'initial' until the compute function is first run. + * The status will be 'initial' until the compute function is first run. * - * The state will be 'pending' while the compute function is running. If the - * state is 'pending', the `value` and `error` properties will be the result + * The status will be 'pending' while the compute function is running. If the + * status is 'pending', the `value` and `error` properties will be the result * of the previous run of the compute function. * - * The state will be 'complete' when the compute function has completed - * successfully. If the state is 'complete', the `value` property will be the + * The status will be 'complete' when the compute function has completed + * successfully. If the status is 'complete', the `value` property will be the * result of the previous run of the compute function and the `error` property * will be `undefined`. * - * The state will be 'error' when the compute function has completed with an - * error. If the state is 'error', the `error` property will be the error that - * was thrown by the previous run of the compute function and the `value` + * The status will be 'error' when the compute function has completed with an + * error. If the status is 'error', the `error` property will be the error + * that was thrown by the previous run of the compute function and the `value` * property will be `undefined`. * - * This value is read from a signal, so any signals that read the state will - * be marked as dependents of it. + * This value is read from a signal, so any signals that read it will be + * tracked as dependents of it. */ - get state() { - // Unconditionally read the state signal to ensure that any signals that - // read the state are marked as dependents. - const currentState = this.#state.get(); + get status() { + // Unconditionally read the status signal to ensure that any signals that + // read it are tracked as dependents. + const currentState = this.#status.get(); return this.#isNotified ? "pending" : currentState; } @@ -64,8 +65,8 @@ export class AsyncComputed { * the last run of the compute function threw an error, or if the compute * function has not yet been run. * - * This value is read from a signal, so any signals that read the state will - * be marked as dependents of it. + * This value is read from a signal, so any signals that read it will be + * tracked as dependents of it. */ #value: Signal.State; get value() { @@ -78,8 +79,8 @@ export class AsyncComputed { * run of the compute function resolved successfully, or if the compute * function has not yet been run. * - * This value is read from a signal, so any signals that read the state will - * be marked as dependents of it. + * This value is read from a signal, so any signals that read it will be + * tracked as dependents of it. */ #error = new Signal.State(undefined); get error() { @@ -118,7 +119,7 @@ export class AsyncComputed { * * @param fn The function that performs the asynchronous computation. Any * signals read synchronously - that is, before the first await - will be - * marked as dependencies of the AsyncComputed, and cause the function to + * tracked as dependencies of the AsyncComputed, and cause the function to * re-run when they change. * * @param options.initialValue The initial value of the AsyncComputed. @@ -130,40 +131,40 @@ export class AsyncComputed { this.#value = new Signal.State(options?.initialValue); this.#computed = new Signal.Computed(() => { const runId = ++this.#currentRunId; - // Untrack reading the state signal to avoid triggering the computed when - // the state changes. - const state = Signal.subtle.untrack(() => this.#state.get()); + // Untrack reading the status signal to avoid triggering the computed when + // the status changes. + const status = Signal.subtle.untrack(() => this.#status.get()); // If we're not already pending, create a new deferred to track the // completion of the run. - if (state !== "pending") { + if (status !== "pending") { this.#deferred.set(Promise.withResolvers()); } this.#isNotified = false; - this.#state.set("pending"); + this.#status.set("pending"); this.#currentAbortController?.abort(); this.#currentAbortController = new AbortController(); fn(this.#currentAbortController.signal).then( (result) => { - // If we've been preempted by a new run, don't update the state or + // If we've been preempted by a new run, don't update the status or // resolve the deferred. if (runId !== this.#currentRunId) { return; } - this.#state.set("complete"); + this.#status.set("complete"); this.#value.set(result); this.#error.set(undefined); this.#deferred.get()!.resolve(result); }, (error) => { - // If we've been preempted by a new run, don't update the state or + // If we've been preempted by a new run, don't update the status or // resolve the deferred. if (runId !== this.#currentRunId) { return; } - this.#state.set("error"); + this.#status.set("error"); this.#error.set(error); this.#value.set(undefined); this.#deferred.get()!.reject(error); @@ -185,10 +186,10 @@ export class AsyncComputed { * the compute function threw an error. */ get() { - const state = this.state; + const status = this.status; if ( - state === "error" || - (state === "pending" && this.error !== undefined) + status === "error" || + (status === "pending" && this.error !== undefined) ) { throw this.error; } diff --git a/tests/async-computed.test.ts b/tests/async-computed.test.ts index eb2c956..85cde82 100644 --- a/tests/async-computed.test.ts +++ b/tests/async-computed.test.ts @@ -14,16 +14,16 @@ describe("AsyncComputed", () => { await 0; return 1; }); - assert.equal(task.state, "initial"); + assert.equal(task.status, "initial"); // Getting the value starts the task assert.strictEqual(task.value, undefined); assert.strictEqual(task.error, undefined); - assert.equal(task.state, "pending"); + assert.equal(task.status, "pending"); const result = await task.complete; - assert.equal(task.state, "complete"); + assert.equal(task.status, "complete"); assert.strictEqual(task.value, 1); assert.strictEqual(result, 1); assert.strictEqual(task.error, undefined); @@ -38,20 +38,20 @@ describe("AsyncComputed", () => { }); await task.complete; - assert.equal(task.state, "complete"); + assert.equal(task.status, "complete"); assert.strictEqual(task.value, "a"); assert.strictEqual(task.error, undefined); dep.set("b"); - assert.equal(task.state, "pending"); + assert.equal(task.status, "pending"); await task.complete; - assert.equal(task.state, "complete"); + assert.equal(task.status, "complete"); assert.strictEqual(task.value, "b"); assert.strictEqual(task.error, undefined); dep.set("c"); - assert.equal(task.state, "pending"); + assert.equal(task.status, "pending"); }); test("Preemptive runs reuse the same completed promise", async () => { @@ -79,10 +79,10 @@ describe("AsyncComputed", () => { dep.set("b"); const secondRunComplete = task.complete; - assert.equal(task.state, "pending"); + assert.equal(task.status, "pending"); assert.strictEqual(abortSignals.length, 2); - assert.strictEqual(abortSignals[0]!.aborted, true); - assert.strictEqual(abortSignals[1]!.aborted, false); + assert.strictEqual(abortSignals[0].aborted, true); + assert.strictEqual(abortSignals[1].aborted, false); // We should not have created a new Promise. The first Promise should be // resolved with the result of the second run. @@ -107,13 +107,13 @@ describe("AsyncComputed", () => { }); task.run(); - assert.equal(task.state, "pending"); + assert.equal(task.status, "pending"); try { await task.complete; assert.fail("Task should have thrown"); } catch (error) { - assert.equal(task.state, "error"); + assert.equal(task.status, "error"); assert.strictEqual(task.value, undefined); assert.strictEqual(task.error, error); } @@ -121,7 +121,7 @@ describe("AsyncComputed", () => { // Check that the task can re-run after an error dep.set("b"); - assert.equal(task.state, "pending"); + assert.equal(task.status, "pending"); await task.complete; assert.strictEqual(task.value, "b"); assert.strictEqual(task.error, undefined); @@ -178,22 +178,22 @@ describe("AsyncComputed", () => { }); assert.strictEqual(task2.get(), undefined); - assert.strictEqual(task2.state, "pending"); + assert.strictEqual(task2.status, "pending"); await task2.complete; assert.strictEqual(task2.get(), "a"); - assert.strictEqual(task2.state, "complete"); + assert.strictEqual(task2.status, "complete"); dep.set("b"); - assert.strictEqual(task2.state, "pending"); + assert.strictEqual(task2.status, "pending"); await task2.complete.catch(() => {}); assert.throws(() => task2.get()); - assert.strictEqual(task2.state, "error"); + assert.strictEqual(task2.status, "error"); dep.set("c"); - assert.strictEqual(task2.state, "pending"); + assert.strictEqual(task2.status, "pending"); await task2.complete; assert.strictEqual(task2.get(), "c"); - assert.strictEqual(task2.state, "complete"); + assert.strictEqual(task2.status, "complete"); }); }); From 4a00ab9965a60ef31c48ac2e4c26be6ba32ee549 Mon Sep 17 00:00:00 2001 From: Justin Fagnani Date: Sat, 6 Jul 2024 11:45:07 -0700 Subject: [PATCH 06/10] Add non-null assertions --- tests/async-computed.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/async-computed.test.ts b/tests/async-computed.test.ts index 85cde82..22ebabd 100644 --- a/tests/async-computed.test.ts +++ b/tests/async-computed.test.ts @@ -81,8 +81,8 @@ describe("AsyncComputed", () => { assert.equal(task.status, "pending"); assert.strictEqual(abortSignals.length, 2); - assert.strictEqual(abortSignals[0].aborted, true); - assert.strictEqual(abortSignals[1].aborted, false); + assert.strictEqual(abortSignals[0]!.aborted, true); + assert.strictEqual(abortSignals[1]!.aborted, false); // We should not have created a new Promise. The first Promise should be // resolved with the result of the second run. From fad16893af8ee0e6aff962bde7f90b23b30cb204 Mon Sep 17 00:00:00 2001 From: Justin Fagnani Date: Sat, 6 Jul 2024 16:25:19 -0700 Subject: [PATCH 07/10] Update volta.node in package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c98249c..9baab95 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ }, "packageManager": "pnpm@9.0.6", "volta": { - "node": "20.12.2", + "node": "22.0.0", "pnpm": "9.0.5" } } From 2cc2b169ebd0dca0efb219eee9925d0229029464 Mon Sep 17 00:00:00 2001 From: Justin Fagnani Date: Mon, 7 Oct 2024 18:51:12 -0700 Subject: [PATCH 08/10] Docs --- README.md | 69 +++++++++++++++++++++++++++++++++++++++++++ src/async-computed.ts | 47 +++++++++++++++++++++-------- 2 files changed, 104 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 95677c5..94f5018 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ npm add signal-utils signal-polyfill - subtle utilities - [effect](#leaky-effect-via-queuemicrotask) - [reaction](#reaction) + - [Batched Effects](#batched-effects) + - [AsyncComputed](#asynccomputed) ### `@signal` @@ -543,6 +545,73 @@ batch(() => { Synchronous batched effects can be useful when abstracting over signals to use them as a backing storage mechanism. In some cases you may want the effect of a signal update to be synchronously observable, but also to allow batching when possible for the usual performacne and coherence reasons. +#### AsyncComputed + +The `AsyncComputed` class reprents an _async_ computation that consumes other signals. + +While computing a value based on other signals _synchronously_ is covered by the core signals API, computing a value _asynchronously_ is not. (There is an ongoing [discussion about how to handle async computations](https://github.com/tc39/proposal-signals/issues/30 however). + +`AsyncComputed` is similar to `Signal.Computed`, except that it takes an async (or Promise-returning) function as the computation function. + +All _synchronous_ access to signals within the function is tracked (by running it within a `Signal.Computed`), so that when the signal dependencies change, the computation is rerun. New runs of the async computation preempt pending runs of the computation. + +```ts +import {AsyncComputed} from 'signal-utils/async-computed'; + +const count = new Signal.State(1); + +const asyncDoubled = new AsyncComputed(async () => { + // Wait 10ms + await new Promise((res) => setTimeout(res, 10)); + + return count.get() * 2; +}); + +console.log(asyncDoubled.status); // Logs: pending +console.log(asyncDoubled.value); // Logs: undefined + +await asyncDoubled.complete; + +console.log(asyncDoubled.status); // Logs: complete +console.log(asyncDoubled.value); // Logs: 2 +``` + +An `AsyncComputed` instance tracks its "status", which is either `"initial"`, +`"pending"`, `"complete"`, or `"error"`. + +##### AsyncComputed API + +- `constructor(fn, options)` + - arguments: + - `fn: (signal: AbortSignal) => Promise`: The compute function. + Synchronous signal access (before the first await) is tracked. + + If a run is preempted by another run because dependencies change, the + AbortSignal will abort. It's recomended to call `signal.throwIfAborted()` + after any `await`. + - `options?: AsyncComputedOptions`: + - `initialValue`: The initial value to return from `.value` before the + computation has yet run. +- `status: "initial" | "pending" | "complete" | "error"` +- `value: T | undefined`: The last value that the compute function resolved + with, or `undefined` if the last run of the compute function threw an error. + If the compute function has not yet been run `value` will be the value of the + `initialValue` or `undefined`. +- `error: unknown`: The last error that the compute function threw, or + `undefined` if the last run of the compute function resolved successfully, or + if the compute function has not yet been run. +- `complete: Promise`: A promise that resolves when the compute function has + completed, or rejects if the compute function throws an error. + + If a new run of the compute function is started before the previous run has + completed, the promise will resolve with the result of the new run. +- `run(): void`: Runs the compute function if it is not already running and its + dependencies have changed. +- `get(): T | undefined`: Retruns the current `value` or throws if the last + completion result was an error. This method is best used for accessing from + other computed signals, since it will propagate error states into the other + computed signals. + ## Contributing See: [./CONTRIBUTING.md](CONTRIBUTING.md) diff --git a/src/async-computed.ts b/src/async-computed.ts index f52e88b..5046659 100644 --- a/src/async-computed.ts +++ b/src/async-computed.ts @@ -13,16 +13,16 @@ export interface AsyncComputedOptions { * A signal-like object that represents an asynchronous computation. * * AsyncComputed takes a compute function that performs an asynchronous - * computation and runs it inside a computed signal, while tracking the status of - * the computation, including its most recent completion value and error. + * computation and runs it inside a computed signal, while tracking the status + * of the computation, including its most recent completion value and error. * * Compute functions are run when the `value`, `error`, or `complete` properties * are read, or when `get()` or `run()` are called, and are re-run when any * signals that they read change. * * If a new run of the compute function is started before the previous run has - * completed, the previous run will have its AbortSignal aborted, and the - * result of the previous run will be ignored. + * completed, the previous run will have its AbortSignal aborted, and the result + * of the previous run will be ignored. */ export class AsyncComputed { // Whether we have been notified of a pending update from the watcher. This is @@ -52,28 +52,40 @@ export class AsyncComputed { * * This value is read from a signal, so any signals that read it will be * tracked as dependents of it. + * + * Accessing this property will cause the compute function to run if it hasn't + * already. */ get status() { // Unconditionally read the status signal to ensure that any signals that // read it are tracked as dependents. const currentState = this.#status.get(); + // Read from the non-signal #isNotified field, which can be set by the + // watcher synchronously. return this.#isNotified ? "pending" : currentState; } + #value: Signal.State; + /** * The last value that the compute function resolved with, or `undefined` if - * the last run of the compute function threw an error, or if the compute - * function has not yet been run. + * the last run of the compute function threw an error. If the compute + * function has not yet been run `value` will be the value of the + * `initialValue` or `undefined`. * * This value is read from a signal, so any signals that read it will be * tracked as dependents of it. + * + * Accessing this property will cause the compute function to run if it hasn't + * already. */ - #value: Signal.State; get value() { this.run(); return this.#value.get(); } + #error = new Signal.State(undefined); + /** * The last error that the compute function threw, or `undefined` if the last * run of the compute function resolved successfully, or if the compute @@ -81,8 +93,10 @@ export class AsyncComputed { * * This value is read from a signal, so any signals that read it will be * tracked as dependents of it. + * + * Accessing this property will cause the compute function to run if it hasn't + * already. */ - #error = new Signal.State(undefined); get error() { this.run(); return this.#error.get(); @@ -96,6 +110,13 @@ export class AsyncComputed { * * If a new run of the compute function is started before the previous run has * completed, the promise will resolve with the result of the new run. + * + * This value is read from a signal, so any signals that read it will be + * tracked as dependents of it. The identity of the promise will change if the + * compute function is re-run after having completed or errored. + * + * Accessing this property will cause the compute function to run if it hasn't + * already. */ get complete(): Promise { this.run(); @@ -126,7 +147,7 @@ export class AsyncComputed { */ constructor( fn: (signal: AbortSignal) => Promise, - options?: AsyncComputedOptions, + options?: AsyncComputedOptions ) { this.#value = new Signal.State(options?.initialValue); this.#computed = new Signal.Computed(() => { @@ -168,10 +189,12 @@ export class AsyncComputed { this.#error.set(error); this.#value.set(undefined); this.#deferred.get()!.reject(error); - }, + } ); }); - this.#watcher = new Signal.subtle.Watcher(() => { + this.#watcher = new Signal.subtle.Watcher(async () => { + // Set the #isNotified flag synchronously when any dependencies change, so + // that it can be read synchronously by the status getter. this.#isNotified = true; this.#watcher.watch(); }); @@ -180,7 +203,7 @@ export class AsyncComputed { /** * Returns the last value that the compute function resolved with, or - * `undefined` if the compute function has not yet been run. + * the initial value if the compute function has not yet been run. * * @throws The last error that the compute function threw, is the last run of * the compute function threw an error. From 4d59565b95b46b14530698f10d7b6af416cc5869 Mon Sep 17 00:00:00 2001 From: Justin Fagnani Date: Mon, 7 Oct 2024 18:55:45 -0700 Subject: [PATCH 09/10] Rename the AbortSignal argument to `abortSignal` --- README.md | 2 +- src/async-computed.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e05ef9b..b0f1473 100644 --- a/README.md +++ b/README.md @@ -583,7 +583,7 @@ An `AsyncComputed` instance tracks its "status", which is either `"initial"`, - `constructor(fn, options)` - arguments: - - `fn: (signal: AbortSignal) => Promise`: The compute function. + - `fn: (abortSignal: AbortSignal) => Promise`: The compute function. Synchronous signal access (before the first await) is tracked. If a run is preempted by another run because dependencies change, the diff --git a/src/async-computed.ts b/src/async-computed.ts index 5046659..e7ad232 100644 --- a/src/async-computed.ts +++ b/src/async-computed.ts @@ -146,7 +146,7 @@ export class AsyncComputed { * @param options.initialValue The initial value of the AsyncComputed. */ constructor( - fn: (signal: AbortSignal) => Promise, + fn: (abortSignal: AbortSignal) => Promise, options?: AsyncComputedOptions ) { this.#value = new Signal.State(options?.initialValue); From 7142b4f80d23a3af57bd8aed8ba0204ebee56455 Mon Sep 17 00:00:00 2001 From: Justin Fagnani Date: Mon, 7 Oct 2024 18:58:29 -0700 Subject: [PATCH 10/10] Format --- src/async-computed.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/async-computed.ts b/src/async-computed.ts index e7ad232..08d912f 100644 --- a/src/async-computed.ts +++ b/src/async-computed.ts @@ -147,7 +147,7 @@ export class AsyncComputed { */ constructor( fn: (abortSignal: AbortSignal) => Promise, - options?: AsyncComputedOptions + options?: AsyncComputedOptions, ) { this.#value = new Signal.State(options?.initialValue); this.#computed = new Signal.Computed(() => { @@ -189,7 +189,7 @@ export class AsyncComputed { this.#error.set(error); this.#value.set(undefined); this.#deferred.get()!.reject(error); - } + }, ); }); this.#watcher = new Signal.subtle.Watcher(async () => {