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
4 changes: 4 additions & 0 deletions packages/vinext/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
11 changes: 11 additions & 0 deletions packages/vinext/src/cache.ts
Original file line number Diff line number Diff line change
@@ -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";
1 change: 1 addition & 0 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]) =>
Expand Down
89 changes: 89 additions & 0 deletions packages/vinext/src/shims/cache-for-request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* 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";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This import uses a relative path (./unified-request-context.js), which is correct for the shims directory. However, the Vite plugin's resolve.alias map (index.ts:1653-1663) does not include a "vinext/cache-for-request" entry.

All other vinext/* shims are registered in that alias map so they resolve correctly during dev. Without this entry, import { cacheForRequest } from "vinext/cache-for-request" in user code will fail during vite dev.

Needs a corresponding entry:

"vinext/cache-for-request": path.join(shimsDir, "cache-for-request"),

in the alias map alongside the other vinext/* entries.


/**
* Create a request-scoped cached version of a factory function.
*
* @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<T>(factory: () => T): () => T {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation is clean and correct. One thing to consider: if a factory throws, the error is not cached — subsequent calls will re-invoke the factory. This is actually good behavior (retry-on-error), but it's worth documenting explicitly since the async case differs subtly: a rejected Promise will be cached (because cache.set(factory, value) stores the Promise before it rejects).

For async factories that can fail, users would get a permanently cached rejected Promise within that request. Consider whether you want to handle this:

const value = factory();
if (value instanceof Promise) {
  // Self-healing: delete from cache if the promise rejects
  value.catch(() => cache.delete(factory));
}
cache.set(factory, value);

This is a design decision rather than a bug — either behavior is defensible. But the current JSDoc says "the returned Promise is cached, so concurrent await calls within the same request share one invocation" without mentioning the rejection case.

return (): T => {
if (!isInsideUnifiedScope()) {
return factory();
Comment on lines +59 to +60

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cache in execution-context request scopes

cacheForRequest() disables caching whenever isInsideUnifiedScope() is false, but Pages Router middleware runs in a request scope without unified ALS (runMiddleware() only wraps _runWithExecutionContext, and prod server currently calls runMiddleware(webRequest, undefined)). In that path, repeated calls within a single request always re-run the factory, so this API does not provide the advertised per-request behavior for middleware/server code on Pages Router production flows.

Useful? React with 👍 / 👎.

}

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<unknown>).catch(() => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the .catch() handler swallows the rejection silently. This is correct — the caller observes the rejection via the returned Promise (line 86). But someone reading this later might think the error is eaten. A brief comment would help:

Suggested change
(value as Promise<unknown>).catch(() => {
(value as Promise<unknown>).catch(() => {
// Clean up only — the caller still observes the rejection via the
// returned Promise. This just ensures a retry is possible.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the .catch() handler only cleans the cache entry — the caller still observes the rejection via the returned Promise on line 86. But someone reading this later might think the error is swallowed. A brief clarifying comment would help:

Suggested change
(value as Promise<unknown>).catch(() => {
(value as Promise<unknown>).catch(() => {
// Cache cleanup only — the caller still observes the rejection
// via the Promise returned on the line below. This just ensures
// a subsequent call within the same request can retry.

// 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;
};
}
13 changes: 10 additions & 3 deletions packages/vinext/src/shims/unified-request-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -92,6 +96,7 @@ export function createRequestContext(opts?: Partial<UnifiedRequestContext>): Uni
_privateCache: null,
currentRequestTags: [],
executionContext: _getInheritedExecutionContext(), // inherits from standalone ALS if present
requestCache: new WeakMap(),
ssrContext: null,
ssrHeadChildren: [],
...opts,
Expand Down Expand Up @@ -129,9 +134,11 @@ export function runWithUnifiedStateMutation<T>(
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
Expand Down
108 changes: 108 additions & 0 deletions tests/cache-for-request.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
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";

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();
// Exercise the real nested scope path via runWithUnifiedStateMutation
const inner = await runWithUnifiedStateMutation(
(child) => {
child.dynamicUsageDetected = true;
},
() => get(),
);
Comment on lines +69 to +78
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test manually constructs { ...ctx, dynamicUsageDetected: true } and calls runWithRequestContext directly, but the real production code path for nested scopes goes through runWithUnifiedStateMutation. The shallow-clone sharing of requestCache is documented as intentional in unified-request-context.ts:140 — but the test doesn't exercise that code path.

Consider using runWithUnifiedStateMutation here instead, which is what the actual shims use:

Suggested change
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(),
);
const inner = await runWithUnifiedStateMutation(
(childCtx) => { childCtx.dynamicUsageDetected = true; },
() => get(),
);

This also means adding runWithUnifiedStateMutation to the import from unified-request-context.

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);
});
});
});
1 change: 1 addition & 0 deletions tests/unified-request-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down