diff --git a/async/unstable_lazy.ts b/async/unstable_lazy.ts index 5ecff0e3b9c9..7319a241d092 100644 --- a/async/unstable_lazy.ts +++ b/async/unstable_lazy.ts @@ -1,13 +1,32 @@ // Copyright 2018-2026 the Deno authors. MIT license. // This module is browser compatible. +/** + * Options for {@linkcode Lazy.prototype.get}. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + */ +export interface LazyGetOptions { + /** + * Signal used to abort the wait for initialization. + * + * Aborting does not cancel the underlying initializer — it only rejects the + * caller's promise. Other callers and any in-flight initialization are + * unaffected. + */ + signal?: AbortSignal; +} + /** * A lazy value that is initialized at most once, with built-in deduplication of * concurrent callers. Prevents the common race where two concurrent `get()` calls * both trigger the initializer; only one initialization runs and all callers share * the same promise. * - * @experimental **UNSTABLE**: New API, yet to be vetted. + * If the initializer rejects, the error is propagated to all concurrent callers + * and the internal state is cleared — the next {@linkcode Lazy.prototype.get} + * call will re-run the initializer. Compose with {@linkcode retry} for + * automatic back-off on transient failures. * * @example Concurrent deduplication * @@ -39,6 +58,8 @@ * await db.get(); * ``` * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * * @typeParam T The type of the lazily initialized value. */ export class Lazy { @@ -65,8 +86,6 @@ export class Lazy { * * Always returns a promise, even when the initializer is synchronous. * - * @experimental **UNSTABLE**: New API, yet to be vetted. - * * @example Usage * ```ts no-assert * import { Lazy } from "@std/async/unstable-lazy"; @@ -75,37 +94,71 @@ export class Lazy { * const value = await config.get(); * ``` * + * @example Abort a slow initialization + * ```ts + * import { Lazy } from "@std/async/unstable-lazy"; + * import { assertRejects } from "@std/assert"; + * + * const slow = new Lazy(() => new Promise(() => {})); + * const controller = new AbortController(); + * controller.abort(new Error("timed out")); + * await assertRejects( + * () => slow.get({ signal: controller.signal }), + * Error, + * "timed out", + * ); + * ``` + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param options Optional settings for this call. * @returns The cached or newly initialized value. */ - get(): Promise { - if (this.#promise !== undefined) { - return this.#promise; + get(options?: LazyGetOptions): Promise { + const signal = options?.signal; + if (signal?.aborted) return Promise.reject(signal.reason); + + if (this.#promise === undefined) { + const p = new Promise((resolve, reject) => { + Promise.resolve().then(() => this.#init()).then( + (value) => { + if (this.#promise === p) { + this.#value = value; + this.#settled = true; + } + resolve(value); + }, + (err) => { + if (this.#promise === p) { + this.#promise = undefined; + } + reject(err); + }, + ); + }); + this.#promise = p; } - const p = Promise.resolve().then(() => this.#init()); - this.#promise = p; - p.then( - (value) => { - if (this.#promise === p) { - this.#value = value; - this.#settled = true; - } - return value; - }, - (_err) => { - if (this.#promise === p) { - this.#promise = undefined; - } - }, - ); - return p; + + if (!signal) return this.#promise; + + return new Promise((resolve, reject) => { + const abort = () => reject(signal.reason); + signal.addEventListener("abort", abort, { once: true }); + this.#promise!.then( + (value) => { + signal.removeEventListener("abort", abort); + resolve(value); + }, + (err) => { + signal.removeEventListener("abort", abort); + reject(err); + }, + ); + }); } /** - * Whether the value has been successfully initialized. Useful for - * distinguishing "not yet initialized" from "initialized with `undefined`" - * when `T` can be `undefined`. - * - * @experimental **UNSTABLE**: New API, yet to be vetted. + * Whether the value has been successfully initialized. * * @example Check initialization state * ```ts @@ -118,6 +171,8 @@ export class Lazy { * assertEquals(lazy.initialized, true); * ``` * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * * @returns `true` if the value has been initialized, `false` otherwise. */ get initialized(): boolean { @@ -125,32 +180,40 @@ export class Lazy { } /** - * Returns the value if already resolved, `undefined` otherwise. Useful for - * fast-path checks where you do not want to await. Returns `undefined` while - * initialization is in-flight. - * - * If `T` can be `undefined`, use {@linkcode initialized} to distinguish - * "not yet initialized" from "initialized with `undefined`". - * - * @experimental **UNSTABLE**: New API, yet to be vetted. + * Returns the value if already resolved, or indicates that it is not yet + * available. The discriminated union avoids ambiguity when `T` itself can + * be `undefined`. * * @example Fast-path when already initialized - * ```ts no-assert + * ```ts * import { Lazy } from "@std/async/unstable-lazy"; + * import { assertEquals } from "@std/assert"; * * const config = new Lazy(async () => ({ port: 8080 })); * await config.get(); * - * const cached = config.peek(); - * if (cached !== undefined) { - * console.log("using cached", cached.port); - * } + * const result = config.peek(); + * assertEquals(result, { ok: true, value: { port: 8080 } }); * ``` * - * @returns The resolved value, or `undefined` if not yet initialized or still in-flight. + * @example Not yet initialized + * ```ts + * import { Lazy } from "@std/async/unstable-lazy"; + * import { assertEquals } from "@std/assert"; + * + * const lazy = new Lazy(() => 42); + * assertEquals(lazy.peek(), { ok: false }); + * ``` + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @returns `{ ok: true, value }` if the value has been initialized, or + * `{ ok: false }` if not yet initialized or still in-flight. */ - peek(): T | undefined { - return this.#settled ? this.#value : undefined; + peek(): { ok: true; value: T } | { ok: false } { + return this.#settled + ? { ok: true, value: this.#value as T } + : { ok: false }; } /** @@ -158,8 +221,6 @@ export class Lazy { * not cancel an in-flight initialization; callers that already have the * promise will still receive its result. * - * @experimental **UNSTABLE**: New API, yet to be vetted. - * * @example Force reload * ```ts ignore * import { Lazy } from "@std/async/unstable-lazy"; @@ -169,6 +230,8 @@ export class Lazy { * config.reset(); * const fresh = await config.get(); * ``` + * + * @experimental **UNSTABLE**: New API, yet to be vetted. */ reset(): void { this.#promise = undefined; diff --git a/async/unstable_lazy_test.ts b/async/unstable_lazy_test.ts index c04c8dd6257e..6b527decaae7 100644 --- a/async/unstable_lazy_test.ts +++ b/async/unstable_lazy_test.ts @@ -1,6 +1,6 @@ // Copyright 2018-2026 the Deno authors. MIT license. -import { assertEquals, assertStrictEquals } from "@std/assert"; +import { assertEquals, assertRejects, assertStrictEquals } from "@std/assert"; import { Lazy } from "./unstable_lazy.ts"; Deno.test("Lazy.get() initializes and returns sync value", async () => { @@ -65,22 +65,55 @@ Deno.test("Lazy.initialized reflects lifecycle", async () => { }), ); - // Before init assertEquals(lazy.initialized, false); - // In-flight const getPromise = lazy.get(); await Promise.resolve(); assertEquals(lazy.initialized, false); - // After init holder.resolve(1); await getPromise; assertEquals(lazy.initialized, true); - // After reset lazy.reset(); assertEquals(lazy.initialized, false); +}); + +Deno.test("Lazy.initialized is false after rejection", async () => { + const lazy = new Lazy(() => Promise.reject(new Error("fail"))); + try { + await lazy.get(); + } catch { + // expected + } + assertEquals(lazy.initialized, false); +}); + +Deno.test("Lazy.peek() reflects lifecycle", async () => { + const holder: { resolve: (v: number) => void } = { resolve: () => {} }; + const lazy = new Lazy( + () => + new Promise((res) => { + holder.resolve = res; + }), + ); + + // Before init + assertEquals(lazy.peek(), { ok: false }); + + // In-flight + const getPromise = lazy.get(); + await Promise.resolve(); + assertEquals(lazy.peek(), { ok: false }); + + // After init + holder.resolve(1); + await getPromise; + assertEquals(lazy.peek(), { ok: true, value: 1 }); + + // After reset + lazy.reset(); + assertEquals(lazy.peek(), { ok: false }); // After rejected init const failing = new Lazy(() => Promise.reject(new Error("fail"))); @@ -89,19 +122,17 @@ Deno.test("Lazy.initialized reflects lifecycle", async () => { } catch { // expected } - assertEquals(failing.initialized, false); + assertEquals(failing.peek(), { ok: false }); }); -Deno.test("Lazy.initialized disambiguates T = undefined", async () => { +Deno.test("Lazy.peek() disambiguates T = undefined", async () => { const lazy = new Lazy(() => undefined); - assertEquals(lazy.initialized, false); - assertEquals(lazy.peek(), undefined); + assertEquals(lazy.peek(), { ok: false }); await lazy.get(); - assertEquals(lazy.initialized, true); - assertEquals(lazy.peek(), undefined); + assertEquals(lazy.peek(), { ok: true, value: undefined }); }); -Deno.test("Lazy.peek() returns undefined while in-flight", async () => { +Deno.test("Lazy.peek() returns { ok: false } while in-flight", async () => { const holder: { resolve: (v: number) => void } = { resolve: () => {} }; const lazy = new Lazy( () => @@ -111,15 +142,15 @@ Deno.test("Lazy.peek() returns undefined while in-flight", async () => { ); const getPromise = lazy.get(); await Promise.resolve(); - assertEquals(lazy.peek(), undefined); + assertEquals(lazy.peek(), { ok: false }); holder.resolve(99); assertEquals(await getPromise, 99); }); -Deno.test("Lazy.peek() returns value after initialization", async () => { +Deno.test("Lazy.peek() returns { ok: true, value } after initialization", async () => { const lazy = new Lazy(() => 42); await lazy.get(); - assertEquals(lazy.peek(), 42); + assertEquals(lazy.peek(), { ok: true, value: 42 }); }); Deno.test("Lazy.reset() causes re-initialization", async () => { @@ -149,3 +180,92 @@ Deno.test("Lazy.reset() does not affect in-flight initialization", async () => { const value = await getPromise; assertEquals(value, "ok"); }); + +Deno.test("Lazy.get() resolves falsy values correctly", async (t) => { + await t.step("0", async () => { + const lazy = new Lazy(() => 0); + assertEquals(await lazy.get(), 0); + assertEquals(lazy.peek(), { ok: true, value: 0 }); + }); + + await t.step("false", async () => { + const lazy = new Lazy(() => false); + assertEquals(await lazy.get(), false); + assertEquals(lazy.peek(), { ok: true, value: false }); + }); + + await t.step("empty string", async () => { + const lazy = new Lazy(() => ""); + assertEquals(await lazy.get(), ""); + assertEquals(lazy.peek(), { ok: true, value: "" }); + }); + + await t.step("null", async () => { + const lazy = new Lazy(() => null); + assertEquals(await lazy.get(), null); + assertEquals(lazy.peek(), { ok: true, value: null }); + }); +}); + +Deno.test("Lazy.get() rejects immediately with already-aborted signal", async () => { + const lazy = new Lazy(() => 42); + const reason = new Error("aborted"); + await assertRejects( + () => lazy.get({ signal: AbortSignal.abort(reason) }), + Error, + "aborted", + ); + assertEquals(lazy.peek(), { ok: false }); +}); + +Deno.test("Lazy.get() rejects when signal is aborted during initialization", async () => { + const lazy = new Lazy( + () => new Promise(() => {}), + ); + const controller = new AbortController(); + const getPromise = lazy.get({ signal: controller.signal }); + controller.abort(new Error("cancelled")); + await assertRejects( + () => getPromise, + Error, + "cancelled", + ); +}); + +Deno.test("Lazy.get() signal does not affect other callers", async () => { + const holder: { resolve: (v: number) => void } = { resolve: () => {} }; + const lazy = new Lazy( + () => + new Promise((res) => { + holder.resolve = res; + }), + ); + const controller = new AbortController(); + const abortable = lazy.get({ signal: controller.signal }); + const normal = lazy.get(); + controller.abort(new Error("cancelled")); + + await assertRejects(() => abortable, Error, "cancelled"); + + holder.resolve(42); + assertEquals(await normal, 42); + assertEquals(lazy.peek(), { ok: true, value: 42 }); +}); + +Deno.test("Lazy.get() with signal rejects when initializer fails", async () => { + const lazy = new Lazy(() => Promise.reject(new Error("boom"))); + const controller = new AbortController(); + await assertRejects( + () => lazy.get({ signal: controller.signal }), + Error, + "boom", + ); +}); + +Deno.test("Lazy.get() signal is ignored after successful initialization", async () => { + const lazy = new Lazy(() => 42); + await lazy.get(); + const controller = new AbortController(); + const value = await lazy.get({ signal: controller.signal }); + assertEquals(value, 42); +});