diff --git a/packages/vinext/src/config/next-config.ts b/packages/vinext/src/config/next-config.ts index e9b343eb0..d986ea374 100644 --- a/packages/vinext/src/config/next-config.ts +++ b/packages/vinext/src/config/next-config.ts @@ -405,6 +405,8 @@ export type ResolvedNextConfig = { * and partial object config into concrete thresholds. */ prefetchInlining: PrefetchInliningConfig; + /** Dev "use cache" fill timeout in seconds. Defaults to 54. */ + useCacheTimeout: number; redirects: NextRedirect[]; rewrites: { beforeFiles: NextRewrite[]; @@ -1540,6 +1542,7 @@ export async function resolveNextConfig( appNavFailHandling: false, gestureTransition: false, prefetchInlining: false, + useCacheTimeout: 54, redirects: [], rewrites: { beforeFiles: [], afterFiles: [], fallback: [] }, headers: [], @@ -1874,6 +1877,10 @@ export async function resolveNextConfig( appNavFailHandling: experimental?.appNavFailHandling === true, gestureTransition: experimental?.gestureTransition === true, prefetchInlining, + useCacheTimeout: + typeof experimental?.useCacheTimeout === "number" && experimental.useCacheTimeout > 0 + ? experimental.useCacheTimeout + : 54, redirects, rewrites, headers, diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 026947e70..90acdc73b 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -344,6 +344,19 @@ function hasServerOnlyMarkerImport(code: string): boolean { } const __dirname = import.meta.dirname; + +// Lazy load the use-cache probe pool so it is only loaded when the dev +// server starts (configureServer), not during production builds. +let _probePoolModulePromise: Promise | null = + null; +function getProbePoolModule(): Promise { + _probePoolModulePromise ??= import("./server/use-cache-probe-pool.js").catch((error) => { + _probePoolModulePromise = null; + throw error; + }); + return _probePoolModulePromise; +} + type VitePluginReactModule = typeof import("@vitejs/plugin-react"); function resolveOptionalDependency(projectRoot: string, specifier: string): string | null { @@ -2133,6 +2146,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { defines["process.env.__VINEXT_DEPLOYMENT_ID"] = JSON.stringify( nextConfig.deploymentId ?? "", ); + defines["process.env.__VINEXT_USE_CACHE_TIMEOUT_MS"] = JSON.stringify( + nextConfig.useCacheTimeout * 1_000, + ); // Public `process.env.NEXT_DEPLOYMENT_ID` — Next.js statically inlines // this into client (and web worker) bundles via its DefinePlugin so // that user code like `new Worker(new URL('./w.ts', import.meta.url))` @@ -4099,7 +4115,7 @@ export const loadServerActionClient = ${ }, }, - configureServer(server: ViteDevServer) { + async configureServer(server: ViteDevServer) { // Watch route files for additions/removals to invalidate route cache. const pageExtensions = fileMatcher.extensionRegex; @@ -4194,11 +4210,30 @@ export const loadServerActionClient = ${ pagesRunner?.clearCache(); } - function invalidateAppRoutingModules() { + async function invalidateAppRoutingModules() { invalidateAppRouteCache(); invalidateMetadataFileCache(); invalidateRscEntryModule(); invalidateRootParamsModule(); + await resetUseCacheProbePool(); + } + + async function resetUseCacheProbePool() { + const { tearDownUseCacheProbePool, initUseCacheProbePool } = await getProbePoolModule(); + // Tear down the use-cache probe pool so the next probe starts with + // fresh code after HMR invalidation. + tearDownUseCacheProbePool(); + // Re-initialize so probes continue working after HMR. + const rscEnv = server.environments["rsc"]; + if (rscEnv) { + initUseCacheProbePool(rscEnv); + } + } + + function tearDownUseCacheProbePoolOnServerClose() { + void getProbePoolModule().then(({ tearDownUseCacheProbePool }) => { + tearDownUseCacheProbePool(); + }); } let hybridRouteValidation: Promise = Promise.resolve(); @@ -4341,7 +4376,7 @@ export const loadServerActionClient = ${ routeChanged = true; } if (hasAppDir && shouldInvalidateAppRouteFile(appDir, filePath, fileMatcher)) { - invalidateAppRoutingModules(); + void invalidateAppRoutingModules(); regenerateAppRouteTypes(); routeChanged = true; } @@ -4382,7 +4417,7 @@ export const loadServerActionClient = ${ routeChanged = true; } if (hasAppDir && shouldInvalidateAppRouteFile(appDir, filePath, fileMatcher)) { - invalidateAppRoutingModules(); + void invalidateAppRoutingModules(); regenerateAppRouteTypes(); routeChanged = true; } @@ -4393,6 +4428,17 @@ export const loadServerActionClient = ${ revalidateHybridRoutes(); } }); + server.watcher.on("change", (filePath: string) => { + if ( + hasAppDir && + isInsideDirectory(appDir, filePath) && + fileMatcher.extensionRegex.test(filePath) + ) { + void resetUseCacheProbePool(); + } + }); + server.watcher.once("close", tearDownUseCacheProbePoolOnServerClose); + server.httpServer?.once("close", tearDownUseCacheProbePoolOnServerClose); // ── Dev request origin check ───────────────────────────────────── // Registered directly (not in the returned function) so it runs @@ -4527,6 +4573,18 @@ export const loadServerActionClient = ${ // it is flushed to the client. // 2. Logs the full request after res finishes, using those timings. if (hasAppDir) { + // Initialize the "use cache" deadlock probe pool for the App + // Router dev server. We bind to the RSC environment because + // "use cache" functions run inside the RSC module graph. + const rscEnv = server.environments["rsc"]; + if (rscEnv) { + getProbePoolModule() + .then(({ initUseCacheProbePool }) => initUseCacheProbePool(rscEnv)) + .catch((err) => { + console.warn("[vinext] Failed to initialize use-cache probe pool:", err); + }); + } + server.middlewares.use((req, res, next) => { const url = req.url ?? "/"; // Skip Vite internals, HMR, and static assets. diff --git a/packages/vinext/src/server/dev-module-runner.ts b/packages/vinext/src/server/dev-module-runner.ts index 505589bd9..cb81d8640 100644 --- a/packages/vinext/src/server/dev-module-runner.ts +++ b/packages/vinext/src/server/dev-module-runner.ts @@ -65,7 +65,7 @@ import type { DevEnvironment } from "vite"; * environment types — including Cloudflare's custom environments that don't * support the hot-channel-based transport. */ -type DevEnvironmentLike = { +export type DevEnvironmentLike = { fetchModule: ( id: string, importer?: string, diff --git a/packages/vinext/src/server/use-cache-probe-pool.ts b/packages/vinext/src/server/use-cache-probe-pool.ts new file mode 100644 index 000000000..3546498b5 --- /dev/null +++ b/packages/vinext/src/server/use-cache-probe-pool.ts @@ -0,0 +1,249 @@ +/** + * use-cache-probe-pool.ts + * + * Manages isolated ModuleRunners for "use cache" deadlock probes. + * + * In dev mode, when a cache fill appears stuck, we re-run the same cache + * function in a fresh module graph. If it completes there but the main fill + * is still hung, the hang is attributable to module-scope shared state + * (e.g. a top-level Map used to dedupe fetches) from the outer render. + * + * Unlike Next.js (which uses jest-worker with real OS processes), vinext + * creates a fresh Vite ModuleRunner per probe. Each runner has its own + * EvaluatedModules instance, so top-level module state is recreated from + * scratch while still using the same Vite transform pipeline. + * + * The pool is torn down on HMR / file invalidation so the next probe + * starts with fresh transformed code. + */ + +import type { DevEnvironment } from "vite"; +import { createDirectRunner, type DevEnvironmentLike } from "./dev-module-runner.js"; +import type { ModuleRunner } from "vite/module-runner"; +import { setUseCacheProbe } from "vinext/shims/use-cache-probe-globals"; +import type { EncodedArgsForProbe } from "vinext/shims/use-cache-probe-globals"; +import { UseCacheTimeoutError } from "vinext/shims/use-cache-errors"; + +let _probeEnvironment: DevEnvironmentLike | DevEnvironment | null = null; +const _activeProbeRunners = new Set(); + +/** + * Initialize the probe pool with the Vite dev environment. + * + * Called during configureServer() when the App Router dev server starts, + * and re-called after each HMR teardown cycle. + */ +export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvironment): void { + if (_probeEnvironment === environment) { + // Guard against double-init within the same cycle (e.g., if + // initUseCacheProbePool is called without a preceding teardown). + return; + } + if (_probeEnvironment) { + tearDownUseCacheProbePool(); + } + _probeEnvironment = environment; + + // Capture the environment in a local variable so the probe closure is + // immune to HMR teardown setting _probeEnvironment = null mid-flight. + const env = environment; + + setUseCacheProbe(async (msg) => { + // Create a fresh runner per probe so the module graph is completely + // isolated from previous probes. Reusing runners would leave stale + // top-level state in EvaluatedModules. + // createDirectRunner creates a fresh ModuleRunner with its own isolated + // EvaluatedModules instance, which is exactly what we need for probes. + const runner = createDirectRunner(env); + _activeProbeRunners.add(runner); + const { id, kind, encodedArguments, request, timeoutMs } = msg; + + // Internal timeout so the probe aborts before the outer render timeout. + const deadline = performance.now() + timeoutMs; + + let probeTimeoutTimer: ReturnType | undefined; + try { + // Import the cache-runtime shim in the isolated runner. + // The shim's registerCachedFunction will create fresh module-scope state. + const cacheRuntime = (await runner.import("vinext/shims/cache-runtime")) as Record< + string, + unknown + >; + const registerCachedFunction = cacheRuntime.registerCachedFunction as + | ( Promise>( + fn: T, + id: string, + variant?: string, + ) => T) + | undefined; + + if (!registerCachedFunction) { + return false; + } + + // We need to locate the original cached function module in the isolated + // runner. The function id is ":". We split it + // to find the module and the export. + // NOTE: This assumes export names don't contain colons. + const lastColon = id.lastIndexOf(":"); + const modulePath = lastColon >= 0 ? id.slice(0, lastColon) : id; + const exportName = lastColon >= 0 ? id.slice(lastColon + 1) : "default"; + + // Import the module containing the original "use cache" function. + const mod = (await runner.import(modulePath)) as Record; + const originalFn = mod[exportName]; + if (typeof originalFn !== "function") { + return false; + } + + // Wrap it with registerCachedFunction so the probe runs through the + // same cache-runtime path (fresh ALS, no shared state). + const variant = kind; + const wrapped = registerCachedFunction( + originalFn as (...args: unknown[]) => Promise, + id, + variant, + ); + + // Decode args via the probe runner's RSC decodeReply so + // thenable params/searchParams are reconstructed accurately. + const args = await decodeProbeArgs(runner, encodedArguments); + if (args === null) { + return false; + } + + // Run the function with a reconstructed request store so private caches + // that read cookies()/headers()/draftMode() see the same values. + // Mark the context as _probeDepth === 1 so nested 'use cache' calls + // skip probe scheduling (mirrors Next.js useCacheProbeMode). + // Race against the internal timeout. + const remaining = deadline - performance.now(); + if (remaining <= 0) { + return false; + } + + await Promise.race([ + runWithProbeRequestStore(runner, request, async () => wrapped(...args)), + new Promise((_, reject) => { + probeTimeoutTimer = setTimeout(() => reject(new UseCacheTimeoutError()), remaining); + if (typeof (probeTimeoutTimer as NodeJS.Timeout).unref === "function") { + (probeTimeoutTimer as NodeJS.Timeout).unref(); + } + }), + ]); + + return true; + } catch { + // Import, decode, request reconstruction, timeout, and user-function + // errors are all inconclusive. Only a successful isolated completion + // proves the outer fill is stuck on module-scoped state. + return false; + } finally { + if (probeTimeoutTimer !== undefined) clearTimeout(probeTimeoutTimer); + _activeProbeRunners.delete(runner); + runner.close().catch(() => {}); + } + }); +} + +/** + * Tear down the probe pool. Called on HMR / file invalidation so the next + * probe starts with fresh code. + */ +export function tearDownUseCacheProbePool(): void { + _probeEnvironment = null; + setUseCacheProbe(undefined); + for (const runner of _activeProbeRunners) { + runner.close().catch(() => {}); + } + _activeProbeRunners.clear(); +} + +/** + * Decode probe arguments from the wire-format `EncodedArgsForProbe` using + * the probe runner's own RSC `decodeReply` so thenable params/searchParams + * are reconstructed accurately. Mirrors Next.js `use-cache-probe-worker.ts`. + */ +async function decodeProbeArgs( + runner: ModuleRunner, + encoded: EncodedArgsForProbe, +): Promise { + try { + const rsc = (await runner.import("@vitejs/plugin-rsc/react/rsc")) as { + createTemporaryReferenceSet: () => unknown; + decodeReply: ( + data: string | FormData, + options?: { temporaryReferences?: unknown }, + ) => Promise; + }; + const temporaryReferences = rsc.createTemporaryReferenceSet(); + + if (encoded.kind === "string") { + const decoded = await rsc.decodeReply(encoded.data, { temporaryReferences }); + if (!Array.isArray(decoded)) return [decoded]; + return decoded; + } + + // formdata kind: reconstruct FormData from serialized entries. + const formData = new FormData(); + for (const entry of encoded.entries) { + if (entry.length === 2 && typeof entry[1] === "string") { + formData.append(entry[0], entry[1]); + } else { + const blob = entry[1] as { kind: "blob"; bytes: string; type: string }; + const bytes = Buffer.from(blob.bytes, "base64"); + formData.append(entry[0], new File([bytes], "", { type: blob.type })); + } + } + + const decoded = await rsc.decodeReply(formData, { temporaryReferences }); + if (!Array.isArray(decoded)) return [decoded]; + return decoded; + } catch { + return null; + } +} + +/** + * Reconstruct a minimal request store in the probe runner so that + * cookies(), headers(), and draftMode() behave correctly. + */ +async function runWithProbeRequestStore( + runner: ModuleRunner, + requestSnapshot: { + headers: [string, string][]; + urlPathname: string; + urlSearch: string; + rootParams: Record; + draftModeSecret?: string; + }, + fn: () => Promise, +): Promise { + // Import the ALS-backed request-context modules through the probe runner + // so they load inside the isolated module graph, not the main runner's. + const { createRequestContext, runWithRequestContext } = (await runner.import( + "vinext/shims/unified-request-context", + )) as typeof import("vinext/shims/unified-request-context"); + + const { headersContextFromRequest } = (await runner.import( + "vinext/shims/headers", + )) as typeof import("vinext/shims/headers"); + + // Build a Request from the snapshot so headersContextFromRequest works. + const url = new URL(requestSnapshot.urlPathname + requestSnapshot.urlSearch, "http://localhost"); + const request = new Request(url, { + headers: new Headers(requestSnapshot.headers), + }); + + const headersContext = headersContextFromRequest(request, { + draftModeSecret: requestSnapshot.draftModeSecret, + }); + const ctx = createRequestContext({ + headersContext, + executionContext: null, + rootParams: requestSnapshot.rootParams, + _probeDepth: 1, + }); + + return runWithRequestContext(ctx, fn); +} diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index 2f528b1f5..218b66a9f 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -41,6 +41,7 @@ import { } from "./cache-request-state.js"; import { VINEXT_RSC_MARKER_HEADER } from "../server/headers.js"; import { addCollectedRequestTags, getCurrentFetchSoftTags } from "./fetch-cache.js"; +import type { EncodedArgsForProbe } from "./use-cache-probe-globals.js"; import { getOrCreateAls } from "./internal/als-registry.js"; import { isInsideUnifiedScope, @@ -205,6 +206,48 @@ function getUseCacheKeySeed(): string | undefined { return getUseCacheDeploymentIdDefine() || getUseCacheBuildIdDefine(); } +function getUseCacheTimeoutMs(): number { + try { + const configured = Number(process.env.__VINEXT_USE_CACHE_TIMEOUT_MS); + return Number.isFinite(configured) && configured > 0 ? configured : 54_000; + } catch (error) { + if (error instanceof ReferenceError) return 54_000; + throw error; + } +} + +type ProbeModules = { + UseCacheTimeoutError: typeof import("./use-cache-errors.js").UseCacheTimeoutError; + UseCacheDeadlockError: typeof import("./use-cache-errors.js").UseCacheDeadlockError; + getUseCacheProbe: typeof import("./use-cache-probe-globals.js").getUseCacheProbe; +}; + +let _probeModules: ProbeModules | undefined; +let _probeModulesPromise: Promise | undefined; + +async function loadProbeModules(): Promise { + const [errors, globals] = await Promise.all([ + import("./use-cache-errors.js"), + import("./use-cache-probe-globals.js"), + ]); + return { + UseCacheTimeoutError: errors.UseCacheTimeoutError, + UseCacheDeadlockError: errors.UseCacheDeadlockError, + getUseCacheProbe: globals.getUseCacheProbe, + }; +} + +async function getProbeModules(): Promise { + if (_probeModules) return _probeModules; + + _probeModulesPromise ??= loadProbeModules().catch((error) => { + _probeModulesPromise = undefined; + throw error; + }); + _probeModules = await _probeModulesPromise; + return _probeModules; +} + function buildUseCacheKey(id: string, keySeed: string | undefined, argsKey?: string): string { const scopedId = keySeed ? `build:${encodeURIComponent(keySeed)}:${id}` : id; return argsKey === undefined ? `use-cache:${scopedId}` : `use-cache:${scopedId}:${argsKey}`; @@ -343,6 +386,28 @@ function resolveCacheLife(configs: CacheLifeConfig[]): CacheLifeConfig { return result; } +type EncodedEntry = [string, string] | [string, { kind: "blob"; bytes: string; type: string }]; + +async function encodedArgumentsToProbe(encoded: string | FormData): Promise { + if (typeof encoded === "string") { + return { kind: "string", data: encoded }; + } + + const entries: EncodedEntry[] = []; + for (const [key, value] of encoded.entries()) { + if (typeof value === "string") { + entries.push([key, value]); + } else { + const bytes = new Uint8Array(await value.arrayBuffer()); + entries.push([ + key, + { kind: "blob", bytes: Buffer.from(bytes).toString("base64"), type: value.type }, + ]); + } + } + return { kind: "formdata", entries }; +} + // --------------------------------------------------------------------------- // Private per-request cache for "use cache: private" // Uses AsyncLocalStorage for request isolation so concurrent requests @@ -438,7 +503,11 @@ export function registerCachedFunction( options: RegisterCachedFunctionOptions = {}, ): (...args: TArgs) => Promise { const cacheVariant = variant ?? ""; - const omitAppPageSearchParamsFromFirstArg = options.appPageDefaultExport === true; + // Public cached pages intentionally omit searchParams because accessing them + // is invalid dynamic usage. Private cached pages allow searchParams, so they + // must remain in both the request-scoped key and isolated probe arguments. + const omitAppPageSearchParamsFromFirstArg = + options.appPageDefaultExport === true && cacheVariant !== "private"; // In dev mode, skip the shared cache so code changes are immediately // visible after HMR. Without this, the MemoryCacheHandler returns stale @@ -487,6 +556,99 @@ export function registerCachedFunction( return fn(...args); } + const executeDevFill = async (): Promise => { + const useCacheTimeoutMs = getUseCacheTimeoutMs(); + const fillDeadlineAt = performance.now() + useCacheTimeoutMs; + const requestCtx = getRequestContext(); + if ((requestCtx._probeDepth ?? 0) > 0) { + return executeWithContext(fn, args, cacheVariant); + } + + const probeModules = _probeModules ?? (await getProbeModules()); + const { UseCacheTimeoutError, UseCacheDeadlockError, getUseCacheProbe } = probeModules; + let probeTimer: ReturnType | undefined; + let timeoutTimer: ReturnType | undefined; + let probePromise: Promise | null = null; + const probe = getUseCacheProbe(); + + if (probe) { + const headers = requestCtx.headersContext?.headers; + const navCtx = requestCtx.serverContext; + const search = navCtx?.searchParams?.toString(); + const requestSnapshot = { + headers: headers ? Array.from(headers.entries()) : [], + urlPathname: navCtx?.pathname ?? "/", + urlSearch: search ? `?${search}` : "", + rootParams: Object.fromEntries( + Object.entries(requestCtx.rootParams ?? {}).map(([key, value]) => [ + key, + typeof value === "string" || Array.isArray(value) ? value : undefined, + ]), + ) as Record, + draftModeSecret: requestCtx.headersContext?.draftModeSecret, + }; + + let encodedArgsForProbe: EncodedArgsForProbe | null = null; + try { + if (rsc) { + const tempRefs = rsc.createClientTemporaryReferenceSet(); + const processedArgs = + args.length > 0 + ? unwrapThenableObjectArray(args, { omitAppPageSearchParamsFromFirstArg }) + : []; + const encoded = await rsc.encodeReply(processedArgs, { + temporaryReferences: tempRefs, + }); + encodedArgsForProbe = await encodedArgumentsToProbe(encoded); + } else { + encodedArgsForProbe = { kind: "string", data: JSON.stringify(args) }; + } + } catch { + // Skip probing when arguments cannot be faithfully reconstructed. + } + + if (encodedArgsForProbe) { + probePromise = new Promise((_, reject) => { + probeTimer = setTimeout(() => { + const probeInternalTimeoutMs = fillDeadlineAt - performance.now() - 1_000; + if (probeInternalTimeoutMs <= 0) return; + + probe({ + id, + kind: cacheVariant, + encodedArguments: encodedArgsForProbe, + request: requestSnapshot, + timeoutMs: probeInternalTimeoutMs, + }).then( + (completed) => { + if (completed) reject(new UseCacheDeadlockError()); + }, + () => {}, + ); + }, 10_000); + }); + probePromise.catch(() => {}); + } + } + + const timeoutPromise = new Promise((_, reject) => { + timeoutTimer = setTimeout(() => reject(new UseCacheTimeoutError()), useCacheTimeoutMs); + if (typeof (timeoutTimer as NodeJS.Timeout).unref === "function") { + (timeoutTimer as NodeJS.Timeout).unref(); + } + }); + timeoutPromise.catch(() => {}); + + const executionPromise = executeWithContext(fn, args, cacheVariant); + const promises: Promise[] = [executionPromise, timeoutPromise]; + if (probePromise) promises.push(probePromise); + + return (Promise.race(promises) as Promise).finally(() => { + if (probeTimer !== undefined) clearTimeout(probeTimer); + if (timeoutTimer !== undefined) clearTimeout(timeoutTimer); + }); + }; + // "use cache: private" uses per-request in-memory cache if (cacheVariant === "private") { const parentCtx = cacheContextStorage.getStore(); @@ -509,7 +671,9 @@ export function registerCachedFunction( return privateHit as TResult; } - const result = await executeWithContext(fn, args, cacheVariant); + const result = await (isDev + ? executeDevFill() + : executeWithContext(fn, args, cacheVariant)); privateCache.set(cacheKey, result); return result; } @@ -517,7 +681,7 @@ export function registerCachedFunction( // In dev mode, always execute fresh — skip shared cache lookup/storage. // This ensures HMR changes are reflected immediately. if (isDev) { - return executeWithContext(fn, args, cacheVariant); + return executeDevFill(); } // Shared cache ("use cache" / "use cache: remote") diff --git a/packages/vinext/src/shims/unified-request-context.ts b/packages/vinext/src/shims/unified-request-context.ts index 264bee56a..016e85db7 100644 --- a/packages/vinext/src/shims/unified-request-context.ts +++ b/packages/vinext/src/shims/unified-request-context.ts @@ -45,6 +45,15 @@ export type UnifiedRequestContext = { /** Per-request cache for cacheForRequest(). Keyed by factory function reference. */ // oxlint-disable-next-line @typescript-eslint/no-explicit-any requestCache: WeakMap<(...args: any[]) => any, unknown>; + + // ── use-cache-probe-globals.ts ───────────────────────────────────── + /** + * Probe recursion depth for "use cache" deadlock detection. + * 0 = regular request; >0 = inside a probe re-execution. + * Prevents concurrent requests from interfering with each other's + * probe scheduling (unlike a process-global counter). + */ + _probeDepth: number; } & VinextHeadersShimState & I18nState & NavigationState & @@ -115,6 +124,7 @@ export function createRequestContext(opts?: Partial): Uni ssrHeadChildren: [], documentInitialHead: [], rootParams: null, + _probeDepth: 0, ...opts, }; } diff --git a/packages/vinext/src/shims/use-cache-errors.ts b/packages/vinext/src/shims/use-cache-errors.ts new file mode 100644 index 000000000..9f55e48ae --- /dev/null +++ b/packages/vinext/src/shims/use-cache-errors.ts @@ -0,0 +1,68 @@ +/** + * use-cache-errors.ts + * + * Error classes for "use cache" fill failures. + * + * Ported from Next.js: packages/next/src/server/use-cache/use-cache-errors.ts + * https://github.com/vercel/next.js/blob/canary/packages/next/src/server/use-cache/use-cache-errors.ts + */ + +const USE_CACHE_TIMEOUT_ERROR_CODE = "USE_CACHE_TIMEOUT" as const; +const USE_CACHE_DEADLOCK_ERROR_CODE = "USE_CACHE_DEADLOCK" as const; + +export class UseCacheTimeoutError extends Error { + digest: typeof USE_CACHE_TIMEOUT_ERROR_CODE = USE_CACHE_TIMEOUT_ERROR_CODE; + + constructor() { + super( + 'Filling a cache during prerender timed out, likely because request-specific arguments such as params, searchParams, cookies() or dynamic data were used inside "use cache".', + ); + this.name = "UseCacheTimeoutError"; + } +} + +export class UseCacheDeadlockError extends Error { + digest: typeof USE_CACHE_DEADLOCK_ERROR_CODE = USE_CACHE_DEADLOCK_ERROR_CODE; + + constructor() { + super( + 'Filling a "use cache" entry appears to be stuck on shared state from the outer render scope. The same function completed when run in isolation, which usually means a module-scoped value (for example a top-level Map used to dedupe fetches) is joining a promise created outside the cache. "use cache" already dedupes calls with the same arguments — within a request and across requests on the same server instance — so the surrounding dedupe layer is both unnecessary and the likely cause. Remove it and rely on "use cache" alone for deduping.', + ); + this.name = "UseCacheDeadlockError"; + } +} + +// The digest fields and type guards below provide module-graph-robust detection +// of these errors. Plain `instanceof` is fragile across the RSC/SSR/probe +// module graphs because each graph holds its own class identity. The string +// digest survives those boundaries. +// +// Currently consumed by the probe logic (use-cache-probe-pool.ts) and the +// unit tests. +// +// TODO: Wire isUseCacheTimeoutError / isUseCacheDeadlockError into the App +// Router dev error handler to surface a friendly overlay message keyed off the +// digest (matching Next.js behaviour). +export function isUseCacheTimeoutError(err: unknown): err is UseCacheTimeoutError { + if ( + typeof err !== "object" || + err === null || + !("digest" in err) || + typeof err.digest !== "string" + ) { + return false; + } + return err.digest === USE_CACHE_TIMEOUT_ERROR_CODE; +} + +export function isUseCacheDeadlockError(err: unknown): err is UseCacheDeadlockError { + if ( + typeof err !== "object" || + err === null || + !("digest" in err) || + typeof err.digest !== "string" + ) { + return false; + } + return err.digest === USE_CACHE_DEADLOCK_ERROR_CODE; +} diff --git a/packages/vinext/src/shims/use-cache-probe-globals.ts b/packages/vinext/src/shims/use-cache-probe-globals.ts new file mode 100644 index 000000000..ab3ab8d77 --- /dev/null +++ b/packages/vinext/src/shims/use-cache-probe-globals.ts @@ -0,0 +1,44 @@ +/** + * use-cache-probe-globals.ts + * + * Dev-only cross-module handoff for the "use cache" deadlock probe. + * Uses a Symbol.for on globalThis so the dev server can install the probe + * without importing dev-only code into the production cache runtime. + * + * Ported from Next.js: packages/next/src/server/use-cache/use-cache-probe-globals.ts + * https://github.com/vercel/next.js/blob/canary/packages/next/src/server/use-cache/use-cache-probe-globals.ts + */ + +const SYMBOL = Symbol.for("vinext.dev.useCacheProbe"); + +export type UseCacheProbeRequestSnapshot = { + headers: [string, string][]; + urlPathname: string; + urlSearch: string; + rootParams: Record; + draftModeSecret?: string; +}; + +/** Wire-format for encoded probe arguments */ +export type EncodedArgsForProbe = + | { kind: "string"; data: string } + | { + kind: "formdata"; + entries: Array<[string, string] | [string, { kind: "blob"; bytes: string; type: string }]>; + }; + +export type UseCacheProbe = (msg: { + id: string; + kind: string; + encodedArguments: EncodedArgsForProbe; + request: UseCacheProbeRequestSnapshot; + timeoutMs: number; +}) => Promise; + +export function setUseCacheProbe(fn: UseCacheProbe | undefined): void { + (globalThis as Record)[SYMBOL] = fn; +} + +export function getUseCacheProbe(): UseCacheProbe | undefined { + return (globalThis as Record)[SYMBOL] as UseCacheProbe | undefined; +} diff --git a/tests/next-config.test.ts b/tests/next-config.test.ts index 0e04967b4..b8a32b21c 100644 --- a/tests/next-config.test.ts +++ b/tests/next-config.test.ts @@ -19,6 +19,15 @@ import { } from "../packages/vinext/src/shims/constants.js"; import { toSlash } from "pathslash"; +describe("experimental.useCacheTimeout", () => { + it("defaults to 54 seconds and preserves a positive configured value", async () => { + await expect(resolveNextConfig(null)).resolves.toMatchObject({ useCacheTimeout: 54 }); + await expect( + resolveNextConfig({ experimental: { useCacheTimeout: 40 } }), + ).resolves.toMatchObject({ useCacheTimeout: 40 }); + }); +}); + function makeTempDir(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "vinext-config-test-")); } @@ -2038,6 +2047,7 @@ describe("detectNextIntlConfig", () => { appNavFailHandling: false, gestureTransition: false, prefetchInlining: false, + useCacheTimeout: 54, redirects: [], rewrites: { beforeFiles: [], afterFiles: [], fallback: [] }, headers: [], diff --git a/tests/shims.test.ts b/tests/shims.test.ts index e6dfbb6bf..e4540f398 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -6072,6 +6072,45 @@ describe('"use cache" runtime', () => { expect(r3).toEqual({ count: 2 }); }); + it("includes private cached page searchParams in the request-scoped key", async () => { + const { registerCachedFunction } = + await import("../packages/vinext/src/shims/cache-runtime.js"); + const { makeThenableParams } = await import("../packages/vinext/src/shims/thenable-params.js"); + const { createRequestContext, runWithRequestContext } = + await import("../packages/vinext/src/shims/unified-request-context.js"); + + let callCount = 0; + const cached = registerCachedFunction( + async (props: { + params: Promise<{ slug: string }>; + searchParams: Promise<{ q: string }>; + }) => { + callCount++; + return { q: (await props.searchParams).q }; + }, + "/fixture/app/private/page.tsx:default", + "private", + { appPageDefaultExport: true }, + ); + + await runWithRequestContext(createRequestContext(), async () => { + await expect( + cached({ + params: makeThenableParams({ slug: "same" }), + searchParams: makeThenableParams({ q: "first" }), + }), + ).resolves.toEqual({ q: "first" }); + await expect( + cached({ + params: makeThenableParams({ slug: "same" }), + searchParams: makeThenableParams({ q: "second" }), + }), + ).resolves.toEqual({ q: "second" }); + }); + + expect(callCount).toBe(2); + }); + it("private variant marks prerender output dynamic", async () => { const { registerCachedFunction } = await import("../packages/vinext/src/shims/cache-runtime.js"); @@ -6892,6 +6931,270 @@ describe('"use cache" runtime', () => { }); }); +describe("use-cache errors", () => { + it("UseCacheTimeoutError has correct digest and message", async () => { + const { UseCacheTimeoutError, isUseCacheTimeoutError } = + await import("../packages/vinext/src/shims/use-cache-errors.js"); + const err = new UseCacheTimeoutError(); + expect(err.digest).toBe("USE_CACHE_TIMEOUT"); + expect(err.message).toContain("timed out"); + expect(isUseCacheTimeoutError(err)).toBe(true); + expect(isUseCacheTimeoutError(new Error("other"))).toBe(false); + }); + + it("UseCacheDeadlockError has correct digest and message", async () => { + const { UseCacheDeadlockError, isUseCacheDeadlockError } = + await import("../packages/vinext/src/shims/use-cache-errors.js"); + const err = new UseCacheDeadlockError(); + expect(err.digest).toBe("USE_CACHE_DEADLOCK"); + expect(err.message).toContain("shared state"); + expect(isUseCacheDeadlockError(err)).toBe(true); + expect(isUseCacheDeadlockError(new Error("other"))).toBe(false); + }); + + it("UseCacheTimeoutError has correct name", async () => { + const { UseCacheTimeoutError } = + await import("../packages/vinext/src/shims/use-cache-errors.js"); + const err = new UseCacheTimeoutError(); + expect(err.name).toBe("UseCacheTimeoutError"); + }); + + it("UseCacheDeadlockError has correct name", async () => { + const { UseCacheDeadlockError } = + await import("../packages/vinext/src/shims/use-cache-errors.js"); + const err = new UseCacheDeadlockError(); + expect(err.name).toBe("UseCacheDeadlockError"); + }); +}); + +describe("use-cache deadlock probe behavior", () => { + it("does not schedule probe when _probeDepth > 0", async () => { + const { setUseCacheProbe } = + await import("../packages/vinext/src/shims/use-cache-probe-globals.js"); + + let probeCalled = false; + setUseCacheProbe(async () => { + probeCalled = true; + return true; + }); + + // Ensure dev mode path is taken by resetting modules and setting NODE_ENV. + vi.stubEnv("NODE_ENV", "development"); + vi.resetModules(); + + const { registerCachedFunction } = + await import("../packages/vinext/src/shims/cache-runtime.js"); + const { createRequestContext, runWithRequestContext } = + await import("../packages/vinext/src/shims/unified-request-context.js"); + + const fn = async () => "result"; + const cached = registerCachedFunction(fn, "test:no-recurse", ""); + + // A probe re-execution sets _probeDepth === 1 on its request context. + const probeCtx = createRequestContext({ _probeDepth: 1 }); + try { + const result = await runWithRequestContext(probeCtx, () => cached()); + expect(result).toBe("result"); + expect(probeCalled).toBe(false); + } finally { + vi.unstubAllEnvs(); + setUseCacheProbe(undefined); + } + }); + + it("probe completing surfaces UseCacheDeadlockError via Promise.race", async () => { + const { setUseCacheProbe } = + await import("../packages/vinext/src/shims/use-cache-probe-globals.js"); + + // Install a probe that reports the function completed in isolation. + setUseCacheProbe(async () => true); + + vi.stubEnv("NODE_ENV", "development"); + vi.useFakeTimers(); + vi.resetModules(); + + const { registerCachedFunction } = + await import("../packages/vinext/src/shims/cache-runtime.js"); + const { isUseCacheDeadlockError } = + await import("../packages/vinext/src/shims/use-cache-errors.js"); + + let resolveHung!: (v: string) => void; + const hungFn = () => + new Promise((resolve) => { + resolveHung = resolve; + }); + const cached = registerCachedFunction(hungFn, "test:deadlock-race", ""); + + try { + const promise = cached(); + // Attach a handler immediately so Node never sees this as unhandled. + let caughtError: Error | undefined; + promise.catch((err) => { + caughtError = err as Error; + }); + + // Advance to the 10 s probe timer. + await vi.advanceTimersByTimeAsync(10_000); + // Flush microtasks until the deadlock rejection propagates. + // Loop avoids fragility if the promise chain length changes. + for (let i = 0; i < 10 && !caughtError; i++) { + await Promise.resolve(); + } + + expect(isUseCacheDeadlockError(caughtError)).toBe(true); + expect(caughtError?.message).toContain("shared state"); + } finally { + resolveHung?.("cleanup"); + vi.useRealTimers(); + vi.unstubAllEnvs(); + setUseCacheProbe(undefined); + } + }); + + it('schedules private "use cache" misses with the private probe variant', async () => { + const { setUseCacheProbe } = + await import("../packages/vinext/src/shims/use-cache-probe-globals.js"); + + let probedKind: string | undefined; + let probedDraftModeSecret: string | undefined; + let probedUrlSearch: string | undefined; + setUseCacheProbe(async ({ kind, request }) => { + probedKind = kind; + probedDraftModeSecret = request.draftModeSecret; + probedUrlSearch = request.urlSearch; + return true; + }); + + vi.stubEnv("NODE_ENV", "development"); + vi.useFakeTimers(); + vi.resetModules(); + + const { registerCachedFunction } = + await import("../packages/vinext/src/shims/cache-runtime.js"); + const { createRequestContext, runWithRequestContext } = + await import("../packages/vinext/src/shims/unified-request-context.js"); + + let resolveHung!: (value: string) => void; + const cached = registerCachedFunction( + () => + new Promise((resolve) => { + resolveHung = resolve; + }), + "test:private-deadlock-race", + "private", + ); + + try { + const ctx = createRequestContext({ + headersContext: { + headers: new Headers(), + cookies: new Map(), + draftModeSecret: "probe-draft-secret", + }, + serverContext: { + pathname: "/private", + searchParams: new URLSearchParams({ mode: "probe" }), + params: {}, + }, + }); + const promise = runWithRequestContext(ctx, () => cached()); + promise.catch(() => {}); + + await vi.advanceTimersByTimeAsync(10_000); + await Promise.resolve(); + + expect(probedKind).toBe("private"); + expect(probedDraftModeSecret).toBe("probe-draft-secret"); + expect(probedUrlSearch).toBe("?mode=probe"); + } finally { + resolveHung?.("cleanup"); + vi.useRealTimers(); + vi.unstubAllEnvs(); + setUseCacheProbe(undefined); + } + }); + + it("uses the configured dev use-cache timeout", async () => { + vi.stubEnv("NODE_ENV", "development"); + vi.stubEnv("__VINEXT_USE_CACHE_TIMEOUT_MS", "2500"); + vi.useFakeTimers(); + vi.resetModules(); + + const { registerCachedFunction } = + await import("../packages/vinext/src/shims/cache-runtime.js"); + const { isUseCacheTimeoutError } = + await import("../packages/vinext/src/shims/use-cache-errors.js"); + + let resolveHung!: (value: string) => void; + const cached = registerCachedFunction( + () => + new Promise((resolve) => { + resolveHung = resolve; + }), + "test:configured-timeout", + ); + + try { + const promise = cached(); + let caughtError: Error | undefined; + promise.catch((error) => { + caughtError = error as Error; + }); + + await vi.advanceTimersByTimeAsync(2_500); + for (let index = 0; index < 10 && !caughtError; index++) { + await Promise.resolve(); + } + + expect(isUseCacheTimeoutError(caughtError)).toBe(true); + } finally { + resolveHung?.("cleanup"); + vi.useRealTimers(); + vi.unstubAllEnvs(); + } + }); + + it('preserves the "use cache: remote" probe variant', async () => { + const { setUseCacheProbe } = + await import("../packages/vinext/src/shims/use-cache-probe-globals.js"); + + let probedKind: string | undefined; + setUseCacheProbe(async ({ kind }) => { + probedKind = kind; + return true; + }); + + vi.stubEnv("NODE_ENV", "development"); + vi.useFakeTimers(); + vi.resetModules(); + + const { registerCachedFunction } = + await import("../packages/vinext/src/shims/cache-runtime.js"); + let resolveHung!: (value: string) => void; + const cached = registerCachedFunction( + () => + new Promise((resolve) => { + resolveHung = resolve; + }), + "test:remote-deadlock-race", + "remote", + ); + + try { + const promise = cached(); + promise.catch(() => {}); + await vi.advanceTimersByTimeAsync(10_000); + await Promise.resolve(); + expect(probedKind).toBe("remote"); + } finally { + resolveHung?.("cleanup"); + vi.useRealTimers(); + vi.unstubAllEnvs(); + setUseCacheProbe(undefined); + } + }); +}); + describe("replyToCacheKey deterministic hashing", () => { it("returns string replies as-is", async () => { const { replyToCacheKey } = await import("../packages/vinext/src/shims/cache-runtime.js");