From df4326164b9ee2568b303aa351512becbb11553f Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Sat, 4 Apr 2026 17:48:26 +0200 Subject: [PATCH 1/4] BREAKING(async/unstable): refactor `Lazy` API Made-with: Cursor --- async/unstable_lazy.ts | 177 ++++++++++++++++++++++-------------- async/unstable_lazy_test.ts | 109 ++++++++++++++++++---- 2 files changed, 202 insertions(+), 84 deletions(-) diff --git a/async/unstable_lazy.ts b/async/unstable_lazy.ts index 5ecff0e3b9c9..1d06409e7d0c 100644 --- a/async/unstable_lazy.ts +++ b/async/unstable_lazy.ts @@ -1,18 +1,37 @@ // 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 * * ```ts - * import { Lazy } from "@std/async/unstable-lazy"; + * import { Lazy } from "@std/async/lazy"; * import { assertEquals } from "@std/assert"; * * let initCount = 0; @@ -30,7 +49,7 @@ * @example Composing with retry * * ```ts ignore - * import { Lazy } from "@std/async/unstable-lazy"; + * import { Lazy } from "@std/async/lazy"; * import { retry } from "@std/async/retry"; * * const db = new Lazy(() => @@ -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,92 +86,112 @@ 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"; + * import { Lazy } from "@std/async/lazy"; * * const config = new Lazy(async () => ({ loaded: true })); * const value = await config.get(); * ``` * + * @example Abort a slow initialization + * ```ts + * import { Lazy } from "@std/async/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`. + * 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`. * - * @experimental **UNSTABLE**: New API, yet to be vetted. - * - * @example Check initialization state + * @example Fast-path when already initialized * ```ts - * import { Lazy } from "@std/async/unstable-lazy"; + * import { Lazy } from "@std/async/lazy"; * import { assertEquals } from "@std/assert"; * - * const lazy = new Lazy(() => 42); - * assertEquals(lazy.initialized, false); - * await lazy.get(); - * assertEquals(lazy.initialized, true); + * const config = new Lazy(async () => ({ port: 8080 })); + * await config.get(); + * + * const result = config.peek(); + * assertEquals(result, { ok: true, value: { port: 8080 } }); * ``` * - * @returns `true` if the value has been initialized, `false` otherwise. - */ - get initialized(): boolean { - return this.#settled; - } - - /** - * 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. + * @example Not yet initialized + * ```ts + * import { Lazy } from "@std/async/lazy"; + * import { assertEquals } from "@std/assert"; * - * If `T` can be `undefined`, use {@linkcode initialized} to distinguish - * "not yet initialized" from "initialized with `undefined`". + * const lazy = new Lazy(() => 42); + * assertEquals(lazy.peek(), { ok: false }); + * ``` * * @experimental **UNSTABLE**: New API, yet to be vetted. * - * @example Fast-path when already initialized - * ```ts no-assert - * import { Lazy } from "@std/async/unstable-lazy"; - * - * const config = new Lazy(async () => ({ port: 8080 })); - * await config.get(); - * - * const cached = config.peek(); - * if (cached !== undefined) { - * console.log("using cached", cached.port); - * } - * ``` - * - * @returns The resolved value, or `undefined` if not yet initialized or still in-flight. + * @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,17 +199,17 @@ 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"; + * import { Lazy } from "@std/async/lazy"; * * const config = new Lazy(async () => loadConfig()); * await config.get(); * 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..7690d18e4b3c 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 () => { @@ -56,7 +56,7 @@ Deno.test("Lazy.get() propagates rejection to all concurrent callers", async () ); }); -Deno.test("Lazy.initialized reflects lifecycle", async () => { +Deno.test("Lazy.peek() reflects lifecycle", async () => { const holder: { resolve: (v: number) => void } = { resolve: () => {} }; const lazy = new Lazy( () => @@ -66,21 +66,21 @@ Deno.test("Lazy.initialized reflects lifecycle", async () => { ); // Before init - assertEquals(lazy.initialized, false); + assertEquals(lazy.peek(), { ok: false }); // In-flight const getPromise = lazy.get(); await Promise.resolve(); - assertEquals(lazy.initialized, false); + assertEquals(lazy.peek(), { ok: false }); // After init holder.resolve(1); await getPromise; - assertEquals(lazy.initialized, true); + assertEquals(lazy.peek(), { ok: true, value: 1 }); // After reset lazy.reset(); - assertEquals(lazy.initialized, false); + assertEquals(lazy.peek(), { ok: false }); // After rejected init const failing = new Lazy(() => Promise.reject(new Error("fail"))); @@ -89,19 +89,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 +109,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 +147,82 @@ 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() 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); +}); From f7cec979c67b3d91dd25047250669be115a0cd80 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Sat, 4 Apr 2026 17:52:57 +0200 Subject: [PATCH 2/4] fix doc --- async/unstable_lazy.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/async/unstable_lazy.ts b/async/unstable_lazy.ts index 1d06409e7d0c..f21fccc7971f 100644 --- a/async/unstable_lazy.ts +++ b/async/unstable_lazy.ts @@ -31,7 +31,7 @@ export interface LazyGetOptions { * @example Concurrent deduplication * * ```ts - * import { Lazy } from "@std/async/lazy"; + * import { Lazy } from "@std/async/unstable-lazy"; * import { assertEquals } from "@std/assert"; * * let initCount = 0; @@ -49,7 +49,7 @@ export interface LazyGetOptions { * @example Composing with retry * * ```ts ignore - * import { Lazy } from "@std/async/lazy"; + * import { Lazy } from "@std/async/unstable-lazy"; * import { retry } from "@std/async/retry"; * * const db = new Lazy(() => @@ -88,7 +88,7 @@ export class Lazy { * * @example Usage * ```ts no-assert - * import { Lazy } from "@std/async/lazy"; + * import { Lazy } from "@std/async/unstable-lazy"; * * const config = new Lazy(async () => ({ loaded: true })); * const value = await config.get(); @@ -96,7 +96,7 @@ export class Lazy { * * @example Abort a slow initialization * ```ts - * import { Lazy } from "@std/async/lazy"; + * import { Lazy } from "@std/async/unstable-lazy"; * import { assertRejects } from "@std/assert"; * * const slow = new Lazy(() => new Promise(() => {})); @@ -164,7 +164,7 @@ export class Lazy { * * @example Fast-path when already initialized * ```ts - * import { Lazy } from "@std/async/lazy"; + * import { Lazy } from "@std/async/unstable-lazy"; * import { assertEquals } from "@std/assert"; * * const config = new Lazy(async () => ({ port: 8080 })); @@ -176,7 +176,7 @@ export class Lazy { * * @example Not yet initialized * ```ts - * import { Lazy } from "@std/async/lazy"; + * import { Lazy } from "@std/async/unstable-lazy"; * import { assertEquals } from "@std/assert"; * * const lazy = new Lazy(() => 42); @@ -201,7 +201,7 @@ export class Lazy { * * @example Force reload * ```ts ignore - * import { Lazy } from "@std/async/lazy"; + * import { Lazy } from "@std/async/unstable-lazy"; * * const config = new Lazy(async () => loadConfig()); * await config.get(); From b63c0b22ac4e0597174012a810fe01c58a063624 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Sat, 4 Apr 2026 18:14:22 +0200 Subject: [PATCH 3/4] coverage --- async/unstable_lazy_test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/async/unstable_lazy_test.ts b/async/unstable_lazy_test.ts index 7690d18e4b3c..220785813eb7 100644 --- a/async/unstable_lazy_test.ts +++ b/async/unstable_lazy_test.ts @@ -219,6 +219,16 @@ Deno.test("Lazy.get() signal does not affect other callers", async () => { 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(); From 47a1e6a504e142a8e8c305ab0d2be662b4f149b4 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Sat, 4 Apr 2026 18:23:31 +0200 Subject: [PATCH 4/4] revert --- async/unstable_lazy.ts | 22 ++++++++++++++++++++++ async/unstable_lazy_test.ts | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/async/unstable_lazy.ts b/async/unstable_lazy.ts index f21fccc7971f..7319a241d092 100644 --- a/async/unstable_lazy.ts +++ b/async/unstable_lazy.ts @@ -157,6 +157,28 @@ export class Lazy { }); } + /** + * Whether the value has been successfully initialized. + * + * @example Check initialization state + * ```ts + * import { Lazy } from "@std/async/unstable-lazy"; + * import { assertEquals } from "@std/assert"; + * + * const lazy = new Lazy(() => 42); + * assertEquals(lazy.initialized, false); + * await lazy.get(); + * 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 { + return this.#settled; + } + /** * Returns the value if already resolved, or indicates that it is not yet * available. The discriminated union avoids ambiguity when `T` itself can diff --git a/async/unstable_lazy_test.ts b/async/unstable_lazy_test.ts index 220785813eb7..6b527decaae7 100644 --- a/async/unstable_lazy_test.ts +++ b/async/unstable_lazy_test.ts @@ -56,6 +56,39 @@ Deno.test("Lazy.get() propagates rejection to all concurrent callers", async () ); }); +Deno.test("Lazy.initialized reflects lifecycle", async () => { + const holder: { resolve: (v: number) => void } = { resolve: () => {} }; + const lazy = new Lazy( + () => + new Promise((res) => { + holder.resolve = res; + }), + ); + + assertEquals(lazy.initialized, false); + + const getPromise = lazy.get(); + await Promise.resolve(); + assertEquals(lazy.initialized, false); + + holder.resolve(1); + await getPromise; + assertEquals(lazy.initialized, true); + + 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(