feat: implement cacheForRequest() per-request factory cache#646
feat: implement cacheForRequest() per-request factory cache#646JamesbbBriz wants to merge 1 commit intocloudflare:mainfrom
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f2f1d15111
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (!isInsideUnifiedScope()) { | ||
| // Outside request scope: no caching, run factory every time. | ||
| return factory(); |
There was a problem hiding this comment.
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 👍 / 👎.
|
Re: middleware scope — this is intentional. Caching without a proper request scope would risk leaking state across requests, which is worse than re-running the factory. Pages Router middleware is the only path without unified scope; wrapping it would be a separate middleware pipeline change. The fallback (factory runs every time) is safe and predictable. |
packages/vinext/package.json
Outdated
| "./cache-for-request": { | ||
| "types": "./dist/shims/cache-for-request.d.ts", | ||
| "import": "./dist/shims/cache-for-request.js" | ||
| }, |
There was a problem hiding this comment.
can this be made available through vinext/cache?
|
/bigbonk review |
commit: |
There was a problem hiding this comment.
Review: cacheForRequest() per-request factory cache
Clean implementation — the core logic is simple and correct. A few things to address before merge:
Must-fix
-
Missing
resolve.aliasentry in the Vite plugin. The documented import path (vinext/cache-for-request) won't resolve during dev because it's not in the alias map atindex.ts:1653-1663. Thepackage.jsonexports only take effect for production builds / external consumers. During dev, Vite usesresolve.alias, and all othervinext/*internal shims are registered there. Without this, importing fromvinext/cache-for-requestin user code duringvite devwill fail to resolve. -
Missing tests. The PR description says "happy to add unit tests" — they should be included before merge. The existing
tests/unified-request-context.test.tsis the natural home. At minimum:- Factory runs every time outside unified scope (no caching)
- Factory runs once per request inside unified scope
- Different factories get different cached values within the same request
- Different requests get independent cached values
- Async factory: the Promise is cached (concurrent
awaitshares one invocation) - Nested scopes (
runWithUnifiedStateMutation) share the same cache
-
Maintainer feedback on export path. @james-elicx asked if this can be
vinext/cacheinstead ofvinext/cache-for-request. That conflicts with the existingnext/cacheshim atshims/cache.ts(mapped via./shims/*wildcard), so it would need the file renamed or a re-export. This needs resolution before merge.
Nits
-
The
requestCachefield is not asserted in the existinggetRequestContextdefaults test (unified-request-context.test.ts:28-44). Even without the new test file, the existing defaults test should be updated to verifyctx.requestCacheis aWeakMap. -
WeakMap<Function, unknown>— theFunctiontype is the globalFunctioninterface which is very broad. ConsiderWeakMap<WeakKey, unknown>(since that's whatWeakMapactually requires) orWeakMap<(...args: any[]) => any, unknown>for slightly tighter typing. Minor — the runtime behavior is fine either way.
| * @module | ||
| */ | ||
|
|
||
| import { getRequestContext, isInsideUnifiedScope } from "./unified-request-context.js"; |
There was a problem hiding this comment.
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.
|
|
||
| // ── cache-for-request.ts ────────────────────────────────────────── | ||
| /** Per-request cache for cacheForRequest(). Keyed by factory function reference. */ | ||
| requestCache: WeakMap<Function, unknown>; |
There was a problem hiding this comment.
Nit: Function is the broad global interface. Since WeakMap keys just need to be objects, and the actual usage is always () => T factory functions, a narrower type would be slightly more precise:
| requestCache: WeakMap<Function, unknown>; | |
| requestCache: WeakMap<(...args: any[]) => any, unknown>; |
Minor — the runtime behavior is identical.
| * @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<T>(factory: () => T): () => T { |
There was a problem hiding this comment.
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.
packages/vinext/package.json
Outdated
| "types": "./dist/index.d.ts", | ||
| "import": "./dist/index.js" | ||
| }, | ||
| "./cache-for-request": { |
There was a problem hiding this comment.
@james-elicx asked if this should be vinext/cache instead. Note that vinext/shims/cache already resolves (via the ./shims/* wildcard) to the next/cache shim (shims/cache.ts). So "./cache" as an export key would be ambiguous or would shadow it.
Options:
- Keep
./cache-for-request(current) — clear, no ambiguity - Re-export
cacheForRequestfromshims/cache.tsso it's available asimport { cacheForRequest } from "next/cache"orvinext/shims/cache - Use
./cachebut rename the file to avoid confusion
This needs a decision from the maintainer before merge.
|
Review submitted. Here's a summary of what I flagged: Must-fix (3 items)
Design consideration
Nits
|
Implements the cacheForRequest API discussed in cloudflare#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
f2f1d15 to
6a92c4d
Compare
|
Addressed all feedback:
|
|
/bigbonk review |
There was a problem hiding this comment.
Review: cacheForRequest() per-request factory cache
Clean implementation. The core design is sound — using function identity as cache key avoids string collision, the WeakMap ensures no memory leaks, and the rejected-Promise self-healing is a nice touch. The Vite alias entry was addressed. Tests cover the important scenarios.
Two things to fix before merge, plus a few nits.
Not in diff but should be addressed
The existing createRequestContext defaults test in tests/unified-request-context.test.ts:513-529 asserts every field — but doesn't assert the new requestCache field. Add expect(ctx.requestCache).toBeInstanceOf(WeakMap); after line 528 to keep the test exhaustive.
| @@ -0,0 +1,105 @@ | |||
| import { describe, it, expect, vi } from "vitest"; | |||
There was a problem hiding this comment.
This imports from vitest, but all other test files in the repo import from vite-plus/test (the project uses Vite+ which bundles Vitest). See tests/unified-request-context.test.ts:1, tests/shims.test.ts:1, tests/routing.test.ts:1.
| import { describe, it, expect, vi } from "vitest"; | |
| import { describe, it, expect, vi } from "vite-plus/test"; |
| 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(), | ||
| ); |
There was a problem hiding this comment.
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:
| 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.
| // so subsequent calls within the same request can retry. | ||
| if (value instanceof Promise) { | ||
| cache.set(factory, value); | ||
| (value as Promise<unknown>).catch(() => { |
There was a problem hiding this comment.
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:
| (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. |
| /** | ||
| * Create a request-scoped cached version of a factory function. | ||
| * | ||
| * @param factory - Function that creates the value. Called at most once per request. |
There was a problem hiding this comment.
Nit: JSDoc says "Called at most once per request", which is accurate for sync factories but not for async ones that reject (the cache clears, so the factory can be called again). Consider: "Called at most once per request for sync factories; async factories that reject are retried on next call."
|
Review submitted successfully. Here's a summary of what I flagged: Must-fix (1 item)
Should-fix (2 items)
Nits (2 items)
|
Summary
Implements
cacheForRequest()from RFC #623 — a per-request factory cache that lazily initializes values on first call and returns the cached result for subsequent calls within the same request.API
Key design decisions:
await getDb()share one invocation)Changes
shims/cache-for-request.tscacheForRequest()implementation (17 lines of logic)shims/unified-request-context.tsrequestCache: WeakMap<Function, unknown>field + shallow-clone comment syncpackage.json"./cache-for-request"exportBlast radius
Zero. New file + one new field with
new WeakMap()default (~0.1μs per request). No existing behavior changes.Test plan
Tested with Prisma v7 + Hyperdrive on Workers. Happy to add unit tests — let me know the preferred test file location.
Addresses #623 · Relates to #537