From e7812657367ab704fb3989dfc25c68c14ad09d1f Mon Sep 17 00:00:00 2001 From: JamesbbBriz Date: Tue, 24 Mar 2026 21:40:37 +1000 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20add=20cacheForRequest()=20=E2=80=94?= =?UTF-8?q?=20per-request=20factory=20cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the cacheForRequest API discussed in #623. API: import { cacheForRequest } from 'vinext/cache' - Factory function reference is the cache key (no string collision) - Async factories cache the Promise; rejected Promises clear their entry so subsequent calls within the same request can retry - Outside request scope: factory runs every time (no caching) - Public barrel at src/cache.ts, separate from the next/cache shim - Vite dev alias registered in index.ts - 6 unit tests covering sync, async, isolation, nested scopes, reject+retry --- packages/vinext/package.json | 4 + packages/vinext/src/cache.ts | 11 ++ packages/vinext/src/index.ts | 1 + .../vinext/src/shims/cache-for-request.ts | 88 +++++++++++++++ .../src/shims/unified-request-context.ts | 13 ++- tests/cache-for-request.test.ts | 105 ++++++++++++++++++ 6 files changed, 219 insertions(+), 3 deletions(-) create mode 100644 packages/vinext/src/cache.ts create mode 100644 packages/vinext/src/shims/cache-for-request.ts create mode 100644 tests/cache-for-request.test.ts diff --git a/packages/vinext/package.json b/packages/vinext/package.json index 76191ee4f..d24cc0dd7 100644 --- a/packages/vinext/package.json +++ b/packages/vinext/package.json @@ -21,6 +21,10 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js" }, + "./cache": { + "types": "./dist/cache.d.ts", + "import": "./dist/cache.js" + }, "./shims/*": { "types": "./dist/shims/*.d.ts", "import": "./dist/shims/*.js" diff --git a/packages/vinext/src/cache.ts b/packages/vinext/src/cache.ts new file mode 100644 index 000000000..09fa2dcbe --- /dev/null +++ b/packages/vinext/src/cache.ts @@ -0,0 +1,11 @@ +/** + * Public entry for per-request caching utilities. + * + * @example + * ```ts + * import { cacheForRequest } from "vinext/cache"; + * ``` + * + * @module + */ +export { cacheForRequest } from "./shims/cache-for-request.js"; diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index db8073349..fdbdab399 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -1661,6 +1661,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { "vinext/head-state": path.join(shimsDir, "head-state"), "vinext/i18n-state": path.join(shimsDir, "i18n-state"), "vinext/i18n-context": path.join(shimsDir, "i18n-context"), + "vinext/cache": path.resolve(__dirname, "cache"), "vinext/instrumentation": path.resolve(__dirname, "server", "instrumentation"), "vinext/html": path.resolve(__dirname, "server", "html"), }).flatMap(([k, v]) => diff --git a/packages/vinext/src/shims/cache-for-request.ts b/packages/vinext/src/shims/cache-for-request.ts new file mode 100644 index 000000000..9d0918491 --- /dev/null +++ b/packages/vinext/src/shims/cache-for-request.ts @@ -0,0 +1,88 @@ +/** + * Cache a factory function's result for the duration of a request. + * + * Returns a function that lazily invokes the factory on first call within + * a request, then returns the cached result for all subsequent calls in + * the same request. Each new request gets a fresh invocation. + * + * The factory function's identity (reference) is the cache key — no + * string keys, no collision risk between modules. + * + * Async factories are supported: the returned Promise is cached, so + * concurrent `await` calls within the same request share one invocation. + * If the Promise rejects, the cached entry is cleared so the next call + * can retry. + * + * Outside a request scope (tests, build-time), the factory runs every + * time with no caching — safe and predictable. + * + * @example + * ```ts + * import { cacheForRequest } from "vinext/cache"; + * + * const getPrisma = cacheForRequest(() => { + * const pool = new Pool({ connectionString: env.HYPERDRIVE.connectionString }); + * return new PrismaClient({ adapter: new PrismaPg(pool) }); + * }); + * + * // In a route handler or server component: + * const prisma = getPrisma(); // first call creates, subsequent calls reuse + * ``` + * + * @example + * ```ts + * // Async factory — Promise is cached, not re-invoked. + * // If it rejects, the cache is cleared for retry. + * const getDb = cacheForRequest(async () => { + * const pool = new Pool({ connectionString }); + * await pool.connect(); + * return drizzle(pool); + * }); + * + * const db = await getDb(); + * ``` + * + * @module + */ + +import { getRequestContext, isInsideUnifiedScope } from "./unified-request-context.js"; + +/** + * Create a request-scoped cached version of a factory function. + * + * @param factory - Function that creates the value. Called at most once per request. + * @returns A function with the same return type that caches the result per request. + */ +export function cacheForRequest(factory: () => T): () => T { + return (): T => { + if (!isInsideUnifiedScope()) { + return factory(); + } + + const ctx = getRequestContext(); + const cache = ctx.requestCache; + + if (cache.has(factory)) { + return cache.get(factory) as T; + } + + const value = factory(); + + // For async factories: if the Promise rejects, clear the cached entry + // so subsequent calls within the same request can retry. + if (value instanceof Promise) { + cache.set(factory, value); + (value as Promise).catch(() => { + // Only clear if the cached value is still this exact Promise + // (avoids clearing a newer retry's value). + if (cache.get(factory) === value) { + cache.delete(factory); + } + }); + } else { + cache.set(factory, value); + } + + return value; + }; +} diff --git a/packages/vinext/src/shims/unified-request-context.ts b/packages/vinext/src/shims/unified-request-context.ts index eab6aa47d..11057d6ef 100644 --- a/packages/vinext/src/shims/unified-request-context.ts +++ b/packages/vinext/src/shims/unified-request-context.ts @@ -47,6 +47,10 @@ export interface UnifiedRequestContext // ── request-context.ts ───────────────────────────────────────────── /** Cloudflare Workers ExecutionContext, or null on Node.js dev. */ executionContext: ExecutionContextLike | null; + + // ── cache-for-request.ts ────────────────────────────────────────── + /** Per-request cache for cacheForRequest(). Keyed by factory function reference. */ + requestCache: WeakMap<(...args: any[]) => any, unknown>; } // --------------------------------------------------------------------------- @@ -92,6 +96,7 @@ export function createRequestContext(opts?: Partial): Uni _privateCache: null, currentRequestTags: [], executionContext: _getInheritedExecutionContext(), // inherits from standalone ALS if present + requestCache: new WeakMap(), ssrContext: null, ssrHeadChildren: [], ...opts, @@ -129,9 +134,11 @@ export function runWithUnifiedStateMutation( const childCtx = { ...parentCtx }; // NOTE: This is a shallow clone. Array fields (pendingSetCookies, // serverInsertedHTMLCallbacks, currentRequestTags, ssrHeadChildren), the - // _privateCache Map, and object fields (headersContext, i18nContext, - // serverContext, ssrContext, executionContext, requestScopedCacheLife) - // still share references with the parent until replaced. The mutate + // _privateCache Map, requestCache WeakMap, and object fields (headersContext, + // i18nContext, serverContext, ssrContext, executionContext, + // requestScopedCacheLife) still share references with the parent until + // replaced. requestCache is intentionally shared — nested scopes within + // the same request should see the same cached values. The mutate // callback must replace those reference-typed slices (for example // `ctx.currentRequestTags = []`) rather than mutating them in-place (for // example `ctx.currentRequestTags.push(...)`) or the parent scope will diff --git a/tests/cache-for-request.test.ts b/tests/cache-for-request.test.ts new file mode 100644 index 000000000..71b0b510f --- /dev/null +++ b/tests/cache-for-request.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, vi } from "vitest"; +import { + runWithRequestContext, + createRequestContext, +} from "../packages/vinext/src/shims/unified-request-context"; +import { cacheForRequest } from "../packages/vinext/src/shims/cache-for-request"; + +describe("cacheForRequest", () => { + it("does not cache outside request scope", () => { + const factory = vi.fn(() => ({ id: Math.random() })); + const get = cacheForRequest(factory); + + const a = get(); + const b = get(); + + expect(a).not.toBe(b); + expect(factory).toHaveBeenCalledTimes(2); + }); + + it("caches within the same request", () => { + const factory = vi.fn(() => ({ id: Math.random() })); + const get = cacheForRequest(factory); + + const ctx = createRequestContext(); + runWithRequestContext(ctx, () => { + const a = get(); + const b = get(); + expect(a).toBe(b); + expect(factory).toHaveBeenCalledTimes(1); + }); + }); + + it("caches different factories separately", () => { + const factoryA = vi.fn(() => "a"); + const factoryB = vi.fn(() => "b"); + const getA = cacheForRequest(factoryA); + const getB = cacheForRequest(factoryB); + + const ctx = createRequestContext(); + runWithRequestContext(ctx, () => { + expect(getA()).toBe("a"); + expect(getB()).toBe("b"); + expect(factoryA).toHaveBeenCalledTimes(1); + expect(factoryB).toHaveBeenCalledTimes(1); + }); + }); + + it("isolates between different requests", () => { + let counter = 0; + const factory = vi.fn(() => ++counter); + const get = cacheForRequest(factory); + + const ctx1 = createRequestContext(); + const val1 = runWithRequestContext(ctx1, () => get()); + + const ctx2 = createRequestContext(); + const val2 = runWithRequestContext(ctx2, () => get()); + + expect(val1).toBe(1); + expect(val2).toBe(2); + expect(factory).toHaveBeenCalledTimes(2); + }); + + it("shares cache across nested unified scopes", async () => { + const factory = vi.fn(() => "cached"); + const get = cacheForRequest(factory); + + const ctx = createRequestContext(); + await runWithRequestContext(ctx, async () => { + const outer = get(); + // Simulate a nested scope (e.g. runWithCacheState) + const inner = await runWithRequestContext( + { ...ctx, dynamicUsageDetected: true }, + () => get(), + ); + expect(outer).toBe("cached"); + expect(inner).toBe("cached"); + expect(factory).toHaveBeenCalledTimes(1); + }); + }); + + it("caches async Promise and clears on rejection", async () => { + let callCount = 0; + const factory = vi.fn(async () => { + callCount++; + if (callCount === 1) throw new Error("fail"); + return "success"; + }); + const get = cacheForRequest(factory); + + const ctx = createRequestContext(); + await runWithRequestContext(ctx, async () => { + // First call: rejects + await expect(get()).rejects.toThrow("fail"); + + // Wait a tick for the .catch() to clear the cache + await new Promise((r) => setTimeout(r, 0)); + + // Second call: should retry (cache was cleared) + const result = await get(); + expect(result).toBe("success"); + expect(factory).toHaveBeenCalledTimes(2); + }); + }); +}); From eb667a9b6b70823a17ddf613eaeba3d3fb0c33ee Mon Sep 17 00:00:00 2001 From: JamesbbBriz Date: Fri, 27 Mar 2026 06:25:32 +1000 Subject: [PATCH 2/3] fix: address review feedback for cacheForRequest - Switch test imports from vitest to vite-plus/test (repo standard) - Use runWithUnifiedStateMutation in nested scope test (real code path) - Add requestCache assertion to createRequestContext defaults test - Fix JSDoc @param accuracy for async factory retry behavior --- packages/vinext/src/shims/cache-for-request.ts | 3 ++- tests/cache-for-request.test.ts | 11 +++++++---- tests/unified-request-context.test.ts | 1 + 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/vinext/src/shims/cache-for-request.ts b/packages/vinext/src/shims/cache-for-request.ts index 9d0918491..dadc179c3 100644 --- a/packages/vinext/src/shims/cache-for-request.ts +++ b/packages/vinext/src/shims/cache-for-request.ts @@ -50,7 +50,8 @@ import { getRequestContext, isInsideUnifiedScope } from "./unified-request-conte /** * Create a request-scoped cached version of a factory function. * - * @param factory - Function that creates the value. Called at most once per request. + * @param factory - Function that creates the value. Called once per request for sync + * factories. Async factories that reject have their cache cleared, allowing retry. * @returns A function with the same return type that caches the result per request. */ export function cacheForRequest(factory: () => T): () => T { diff --git a/tests/cache-for-request.test.ts b/tests/cache-for-request.test.ts index 71b0b510f..def3f134c 100644 --- a/tests/cache-for-request.test.ts +++ b/tests/cache-for-request.test.ts @@ -1,6 +1,7 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi } from "vite-plus/test"; import { runWithRequestContext, + runWithUnifiedStateMutation, createRequestContext, } from "../packages/vinext/src/shims/unified-request-context"; import { cacheForRequest } from "../packages/vinext/src/shims/cache-for-request"; @@ -68,9 +69,11 @@ describe("cacheForRequest", () => { const ctx = createRequestContext(); await runWithRequestContext(ctx, async () => { const outer = get(); - // Simulate a nested scope (e.g. runWithCacheState) - const inner = await runWithRequestContext( - { ...ctx, dynamicUsageDetected: true }, + // Exercise the real nested scope path via runWithUnifiedStateMutation + const inner = await runWithUnifiedStateMutation( + (child) => { + child.dynamicUsageDetected = true; + }, () => get(), ); expect(outer).toBe("cached"); diff --git a/tests/unified-request-context.test.ts b/tests/unified-request-context.test.ts index c1d3c92ad..848ee4771 100644 --- a/tests/unified-request-context.test.ts +++ b/tests/unified-request-context.test.ts @@ -526,6 +526,7 @@ describe("unified-request-context", () => { expect(ctx.executionContext).toBeNull(); expect(ctx.ssrContext).toBeNull(); expect(ctx.ssrHeadChildren).toEqual([]); + expect(ctx.requestCache).toBeInstanceOf(WeakMap); }); it("merges partial overrides", () => { From 01a76b136e525afac6e986c994e58300833b6210 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 28 Mar 2026 08:57:44 +0000 Subject: [PATCH 3/3] fix: add void operator to suppress no-floating-promises lint warnings --- tests/cache-for-request.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/cache-for-request.test.ts b/tests/cache-for-request.test.ts index def3f134c..3d1c110f5 100644 --- a/tests/cache-for-request.test.ts +++ b/tests/cache-for-request.test.ts @@ -23,7 +23,7 @@ describe("cacheForRequest", () => { const get = cacheForRequest(factory); const ctx = createRequestContext(); - runWithRequestContext(ctx, () => { + void runWithRequestContext(ctx, () => { const a = get(); const b = get(); expect(a).toBe(b); @@ -38,7 +38,7 @@ describe("cacheForRequest", () => { const getB = cacheForRequest(factoryB); const ctx = createRequestContext(); - runWithRequestContext(ctx, () => { + void runWithRequestContext(ctx, () => { expect(getA()).toBe("a"); expect(getB()).toBe("b"); expect(factoryA).toHaveBeenCalledTimes(1);