From 1cccd4b95dc842e5b814f90bbfe3691f74b9d15b Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Sun, 10 May 2026 18:32:49 -0700 Subject: [PATCH 01/27] feat(dev): detect 'use cache' module-scope deadlocks early in dev (#1126) Implements a dev-only probe that detects when a 'use cache' fill is stuck on module-scope shared state (e.g. a top-level Map used to dedupe fetches). When the cache fill stream is idle for 10s in dev mode, a probe re-runs the cache function in a fresh Vite ModuleRunner with an isolated module graph. If the probe completes while the main fill is still hung, the fill is aborted with a UseCacheDeadlockError pointing at module-scope state as the likely cause. If the probe also hangs or errors, it falls back to the existing UseCacheTimeoutError. Key files: - shims/use-cache-errors.ts: UseCacheTimeoutError, UseCacheDeadlockError - shims/use-cache-probe-globals.ts: cross-module probe handoff via globalThis - server/use-cache-probe-scheduler.ts: idle detection + probe scheduling - server/use-cache-probe-pool.ts: fresh ModuleRunner pool for isolated execution - server/dev-module-runner.ts: createProbeRunner() for fresh module graphs - shims/cache-runtime.ts: dev-mode fill with probe + timeout racing - index.ts: init probe pool in App Router dev server, teardown on HMR Also adds fixture pages for deadlock and recovery scenarios, plus unit tests. Ports behavior from Next.js #93500 and #93538. Closes #1126. --- packages/vinext/package.json | 1 + packages/vinext/src/index.ts | 12 + .../vinext/src/server/dev-module-runner.ts | 50 +++- .../vinext/src/server/use-cache-probe-pool.ts | 215 ++++++++++++++++++ .../src/server/use-cache-probe-scheduler.ts | 173 ++++++++++++++ packages/vinext/src/shims/cache-runtime.ts | 66 +++++- packages/vinext/src/shims/use-cache-errors.ts | 55 +++++ .../src/shims/use-cache-probe-globals.ts | 38 ++++ pnpm-lock.yaml | 3 + .../app-basic/app/use-cache-deadlock/page.tsx | 36 +++ .../app-basic/app/use-cache-recovery/page.tsx | 22 ++ tests/shims.test.ts | 92 ++++++++ 12 files changed, 761 insertions(+), 2 deletions(-) create mode 100644 packages/vinext/src/server/use-cache-probe-pool.ts create mode 100644 packages/vinext/src/server/use-cache-probe-scheduler.ts create mode 100644 packages/vinext/src/shims/use-cache-errors.ts create mode 100644 packages/vinext/src/shims/use-cache-probe-globals.ts create mode 100644 tests/fixtures/app-basic/app/use-cache-deadlock/page.tsx create mode 100644 tests/fixtures/app-basic/app/use-cache-recovery/page.tsx diff --git a/packages/vinext/package.json b/packages/vinext/package.json index b9d522d28f..8b7eaa077e 100644 --- a/packages/vinext/package.json +++ b/packages/vinext/package.json @@ -93,6 +93,7 @@ "image-size": "catalog:", "ipaddr.js": "catalog:", "magic-string": "catalog:", + "tinypool": "2.1.0", "vite-plugin-commonjs": "catalog:", "vite-tsconfig-paths": "catalog:", "web-vitals": "catalog:" diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 16f832d4ab..ac16b6f607 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -22,6 +22,7 @@ import { isImageOptimizationPath } from "./server/image-optimization.js"; import { installSocketErrorBackstop } from "./server/socket-error-backstop.js"; import { shouldInvalidateAppRouteFile } from "./server/dev-route-files.js"; import { createDirectRunner } from "./server/dev-module-runner.js"; +import { initUseCacheProbePool, tearDownUseCacheProbePool } from "./server/use-cache-probe-pool.js"; import { generateRscEntry } from "./entries/app-rsc-entry.js"; import { generateSsrEntry } from "./entries/app-ssr-entry.js"; import { @@ -2994,6 +2995,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { invalidateAppRouteCache(); invalidateRscEntryModule(); invalidateRootParamsModule(); + // Tear down the use-cache probe pool so the next probe starts with + // fresh code after HMR invalidation. + tearDownUseCacheProbePool(); } let appRouteTypeGeneration: Promise | null = null; @@ -3127,6 +3131,14 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // 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) { + initUseCacheProbePool(rscEnv); + } + 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 b8d31daf9f..8fdf1d0a5a 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, @@ -129,3 +129,51 @@ export function createDirectRunner(environment: DevEnvironmentLike | DevEnvironm new ESModulesEvaluator(), ); } + +/** + * Create a fresh ModuleRunner with its own isolated module graph. + * + * Used by the "use cache" deadlock probe to re-import a cache function + * with zero shared module-scope state from the main request pipeline. + * Each probe gets a brand-new `EvaluatedModules` instance so top-level + * `Map`s, `Promise`s, etc. are recreated from scratch. + * + * The transport delegates to the same `environment.fetchModule()`, so + * Vite transforms and HMR still work, but the *evaluated* module cache + * is completely separate. + */ +export function createProbeRunner(environment: DevEnvironmentLike | DevEnvironment): ModuleRunner { + return new ModuleRunner( + { + transport: { + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + invoke: async (payload: any) => { + const { name, data: args } = payload.data; + if (name === "fetchModule") { + const [id, importer, options] = args as [ + string, + string | undefined, + { cached?: boolean; startOffset?: number } | undefined, + ]; + return { + result: await environment.fetchModule(id, importer, options), + }; + } + if (name === "getBuiltins") { + return { result: [] }; + } + return { + error: { + name: "Error", + message: `[vinext] Unexpected ModuleRunner invoke: ${name}`, + }, + }; + }, + }, + createImportMeta: createNodeImportMeta, + sourcemapInterceptor: false, + hmr: false, + }, + new ESModulesEvaluator(), + ); +} 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 0000000000..beac9417fa --- /dev/null +++ b/packages/vinext/src/server/use-cache-probe-pool.ts @@ -0,0 +1,215 @@ +/** + * use-cache-probe-pool.ts + * + * Manages a pool of 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 { createProbeRunner, type DevEnvironmentLike } from "./dev-module-runner.js"; +import { ModuleRunner } from "vite/module-runner"; +import { setUseCacheProbe } from "vinext/shims/use-cache-probe-globals"; +import { UseCacheTimeoutError } from "vinext/shims/use-cache-errors"; + +let _activeProbeRunners: ModuleRunner[] | null = null; +let _environment: DevEnvironmentLike | DevEnvironment | null = null; +const MAX_RUNNERS = 4; + +function getProbeRunner(): ModuleRunner { + if (!_activeProbeRunners || _activeProbeRunners.length === 0) { + throw new Error("[vinext] use cache probe pool not initialized"); + } + // Round-robin across runners for basic load distribution. + const runner = _activeProbeRunners.shift()!; + _activeProbeRunners.push(runner); + return runner; +} + +/** + * Initialize the probe pool with a set of fresh ModuleRunners bound to the + * given Vite dev environment. + * + * Called once during configureServer() when the App Router dev server starts. + */ +export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvironment): void { + if (_activeProbeRunners) { + // Already initialized — no-op. The environment is the same for the + // lifetime of the dev server. + return; + } + _environment = environment; + _activeProbeRunners = []; + for (let i = 0; i < MAX_RUNNERS; i++) { + _activeProbeRunners.push(createProbeRunner(environment)); + } + + setUseCacheProbe(async (msg) => { + const runner = getProbeRunner(); + const { id, kind, encodedArguments, request, timeoutMs } = msg; + + // Internal timeout so the probe aborts before the outer render timeout. + const deadline = Date.now() + timeoutMs; + + 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. + 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 === "private" ? "private" : ""; + const wrapped = registerCachedFunction( + originalFn as (...args: unknown[]) => Promise, + id, + variant, + ); + + // Decode the arguments (simple JSON fallback; RSC encodeReply is + // not available in the probe because we lack the client environment). + // For deadlock detection, the exact argument values matter less than + // the fact that the function body executes with a fresh module scope. + let args: unknown[] = []; + if (typeof encodedArguments === "string") { + try { + args = JSON.parse(encodedArguments); + if (!Array.isArray(args)) args = [args]; + } catch { + args = []; + } + } + + // Run the function with a reconstructed request store so private caches + // that read cookies()/headers()/draftMode() see the same values. + const result = await runWithProbeRequestStore(request, async () => wrapped(...args)); + + // Wait for the result, but enforce the internal timeout. + const remaining = deadline - Date.now(); + if (remaining <= 0) { + return false; + } + + // If we got here, the probe completed. + await Promise.race([ + Promise.resolve(result), + new Promise((_, reject) => { + const t = setTimeout(() => reject(new UseCacheTimeoutError()), remaining); + // Ensure timer is cleaned up on success via unref if available. + if (typeof (t as NodeJS.Timeout).unref === "function") { + (t as NodeJS.Timeout).unref(); + } + }), + ]); + + return true; + } catch { + // Probe failure is inconclusive — the function might genuinely hang + // even in isolation, or the module import failed. Fall back to the + // regular timeout. + return false; + } + }); +} + +/** + * Tear down the probe pool. Called on HMR / file invalidation so the next + * probe starts with fresh code. + */ +export function tearDownUseCacheProbePool(): void { + if (_activeProbeRunners) { + for (const runner of _activeProbeRunners) { + runner.close().catch(() => {}); + } + _activeProbeRunners = null; + } + _environment = null; + setUseCacheProbe(undefined); +} + +/** + * Reconstruct a minimal request store in the probe runner so that + * cookies(), headers(), and draftMode() behave correctly. + */ +async function runWithProbeRequestStore( + requestSnapshot: { + headers: [string, string][]; + cookieHeader: string | undefined; + urlPathname: string; + urlSearch: string; + rootParams: Record; + isDraftMode: boolean; + isHmrRefresh: boolean; + }, + fn: () => Promise, +): Promise { + // Import the ALS-backed request-context modules in the isolated runner. + const unifiedCtx = (async () => { + // These imports run inside the probe runner's module graph. + // We dynamic-import them because the probe runner doesn't share + // module state with the main runner. + const { createRequestContext, runWithRequestContext } = + (await import("vinext/shims/unified-request-context")) as typeof import("vinext/shims/unified-request-context"); + + const { headersContextFromRequest } = + (await 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); + const ctx = createRequestContext({ + headersContext, + executionContext: null, + rootParams: requestSnapshot.rootParams as Record, + }); + + return runWithRequestContext(ctx, fn); + })(); + + return unifiedCtx; +} diff --git a/packages/vinext/src/server/use-cache-probe-scheduler.ts b/packages/vinext/src/server/use-cache-probe-scheduler.ts new file mode 100644 index 0000000000..8297c38217 --- /dev/null +++ b/packages/vinext/src/server/use-cache-probe-scheduler.ts @@ -0,0 +1,173 @@ +/** + * use-cache-probe-scheduler.ts + * + * Dev-only idle-deadline probe scheduler for "use cache" fills. + * + * Wraps the cache fill stream in a TransformStream and tracks chunk activity. + * If the stream is idle for PROBE_THRESHOLD_MS (10s), schedules a probe that + * re-runs the cache function in a fresh module scope. If the probe completes + * while the main fill is still hung, the caller aborts the fill with a + * UseCacheDeadlockError. + * + * Ported from Next.js: packages/next/src/server/use-cache/use-cache-probe-scheduler.ts + * https://github.com/vercel/next.js/blob/canary/packages/next/src/server/use-cache/use-cache-probe-scheduler.ts + */ + +import { + getUseCacheProbe, + type UseCacheProbeRequestSnapshot, +} from "vinext/shims/use-cache-probe-globals"; + +const PROBE_THRESHOLD_MS = 10_000; +const MIN_PROBE_BUDGET_MS = 3_000; + +type CacheContextWithProbeFields = { + readonly functionId: string; + readonly handlerKind: string; +}; + +type SetupOptions = { + cacheContext: CacheContextWithProbeFields; + encodedArguments: string | FormData; + /** + * Absolute monotonic deadline (in performance.now() units) at which the + * outer cache fill will be aborted by the dev render-timeout timer. + */ + fillDeadlineAt: number; + /** + * Called once if the probe ran the cache function to completion in isolation + * while the main fill was still pending. + */ + onProbeCompleted: () => void; + /** + * AbortSignal that fires when the probe should stop watching (fill settled, + * timeout fired, upstream cancel, etc.). + */ + abortSignal: AbortSignal; + /** + * The outer request store snapshot so the probe can reconstruct cookies(), + * headers(), draftMode(), etc. in the isolated run. + */ + requestSnapshot: UseCacheProbeRequestSnapshot; + /** + * Cache stream to track. Each chunk resets the idle timer. + */ + stream: ReadableStream; +}; + +/** + * Schedule an idle-deadline probe over a cache fill stream (dev-only). + * + * Returns the input stream unchanged when scheduling should be skipped. + */ +export function setupProbeScheduler(opts: SetupOptions): ReadableStream { + const { + cacheContext, + encodedArguments, + fillDeadlineAt, + stream, + abortSignal, + onProbeCompleted, + requestSnapshot, + } = opts; + + // Skip if the remaining budget is too short for a meaningful probe. + if (fillDeadlineAt - performance.now() < PROBE_THRESHOLD_MS + MIN_PROBE_BUDGET_MS) { + return stream; + } + + const probe = getUseCacheProbe(); + if (!probe) { + return stream; + } + + let lastChunkAt = performance.now(); + let idleTimer: ReturnType | undefined; + + const startProbe = () => { + if (abortSignal.aborted) { + return; + } + + const probeStartedAtChunk = lastChunkAt; + // Reserve a 1s buffer so the probe's internal timeout fires before the + // outer render timeout. + const probeInternalTimeoutMs = fillDeadlineAt - performance.now() - 1_000; + + if (probeInternalTimeoutMs <= 0) { + return; + } + + probe({ + id: cacheContext.functionId, + kind: cacheContext.handlerKind, + encodedArguments, + request: requestSnapshot, + timeoutMs: probeInternalTimeoutMs, + }).then( + (completed) => { + // Mid-probe recovery: chunks arrived while the probe was running. + if (lastChunkAt > probeStartedAtChunk) { + return; + } + if (completed && !abortSignal.aborted) { + onProbeCompleted(); + } + }, + // Probe failures are inconclusive; fall back to regular timeout. + () => {}, + ); + }; + + const scheduleAfterIdle = () => { + if (idleTimer !== undefined || abortSignal.aborted) { + return; + } + const now = performance.now(); + const idleFor = now - lastChunkAt; + const wait = Math.max(0, PROBE_THRESHOLD_MS - idleFor); + + // Skip scheduling if the outer fill timeout will fire before the probe + // could even start with a minimum useful budget. + if (fillDeadlineAt - now < wait + MIN_PROBE_BUDGET_MS) { + return; + } + + idleTimer = setTimeout(() => { + idleTimer = undefined; + if (abortSignal.aborted) { + return; + } + const idleNow = performance.now() - lastChunkAt; + if (idleNow < PROBE_THRESHOLD_MS) { + // A chunk arrived since we set this timer; reschedule. + scheduleAfterIdle(); + return; + } + startProbe(); + }, wait); + }; + + abortSignal.addEventListener( + "abort", + () => { + if (idleTimer !== undefined) { + clearTimeout(idleTimer); + idleTimer = undefined; + } + }, + { once: true }, + ); + + scheduleAfterIdle(); + + return stream.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + lastChunkAt = performance.now(); + scheduleAfterIdle(); + controller.enqueue(chunk); + }, + }), + ); +} diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index 2d0ff992db..a6c7af3cef 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -46,6 +46,8 @@ import { runWithUnifiedStateMutation, } from "./unified-request-context.js"; import { markDynamicUsage } from "./headers.js"; +import { UseCacheTimeoutError, UseCacheDeadlockError } from "./use-cache-errors.js"; +import { getUseCacheProbe, type UseCacheProbeRequestSnapshot } from "./use-cache-probe-globals.js"; // --------------------------------------------------------------------------- // Constants for nested-dynamic cache life detection @@ -522,7 +524,69 @@ 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); + const USE_CACHE_TIMEOUT_MS = 54_000; + const fillDeadlineAt = performance.now() + USE_CACHE_TIMEOUT_MS; + + const timeoutError = new UseCacheTimeoutError(); + const deadlockError = new UseCacheDeadlockError(); + + let probePromise: Promise | null = null; + const probe = getUseCacheProbe(); + + if (probe) { + // Capture the current request store snapshot for the probe. + const requestCtx = getRequestContext(); + const headers = requestCtx.headersContext?.headers; + const navCtx = requestCtx.serverContext; + const requestSnapshot: UseCacheProbeRequestSnapshot = { + headers: headers ? Array.from(headers.entries()) : [], + cookieHeader: headers?.get("cookie") ?? undefined, + urlPathname: navCtx?.pathname ?? "/", + urlSearch: navCtx?.searchParams?.toString() ?? "", + rootParams: requestCtx.rootParams ?? {}, + isDraftMode: false, + isHmrRefresh: false, + }; + + probePromise = new Promise((_, reject) => { + setTimeout(() => { + const probeInternalTimeoutMs = fillDeadlineAt - performance.now() - 1_000; + if (probeInternalTimeoutMs <= 0) return; + + probe({ + id, + kind: cacheVariant, + encodedArguments: JSON.stringify(args), + request: requestSnapshot, + timeoutMs: probeInternalTimeoutMs, + }).then( + (completed) => { + if (completed) reject(deadlockError); + }, + () => {}, + ); + }, 10_000); + }); + // Swallow rejection when execution wins the race. + probePromise.catch(() => {}); + } + + const timeoutPromise = new Promise((_, reject) => { + const t = setTimeout(() => reject(timeoutError), USE_CACHE_TIMEOUT_MS); + if (typeof (t as NodeJS.Timeout).unref === "function") { + (t as NodeJS.Timeout).unref(); + } + }); + // Swallow rejection when execution wins the race. + timeoutPromise.catch(() => {}); + + const executionPromise = executeWithContext(fn, args, cacheVariant); + + const promises: Promise[] = [executionPromise]; + if (probePromise) promises.push(probePromise); + promises.push(timeoutPromise); + + return Promise.race(promises); } // Shared cache ("use cache" / "use cache: remote") 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 0000000000..7f5b79c061 --- /dev/null +++ b/packages/vinext/src/shims/use-cache-errors.ts @@ -0,0 +1,55 @@ +/** + * 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".', + ); + } +} + +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.', + ); + } +} + +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 0000000000..adde9a412b --- /dev/null +++ b/packages/vinext/src/shims/use-cache-probe-globals.ts @@ -0,0 +1,38 @@ +/** + * 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][]; + cookieHeader: string | undefined; + urlPathname: string; + urlSearch: string; + rootParams: Record; + isDraftMode: boolean; + isHmrRefresh: boolean; +}; + +export type UseCacheProbe = (msg: { + id: string; + kind: string; + encodedArguments: string | FormData; + 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/pnpm-lock.yaml b/pnpm-lock.yaml index 2b1cf9c64f..ff609c9689 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -972,6 +972,9 @@ importers: magic-string: specifier: 'catalog:' version: 0.30.21 + tinypool: + specifier: 2.1.0 + version: 2.1.0 vite: specifier: npm:@voidzero-dev/vite-plus-core@0.1.24 version: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)' diff --git a/tests/fixtures/app-basic/app/use-cache-deadlock/page.tsx b/tests/fixtures/app-basic/app/use-cache-deadlock/page.tsx new file mode 100644 index 0000000000..0dc47f4085 --- /dev/null +++ b/tests/fixtures/app-basic/app/use-cache-deadlock/page.tsx @@ -0,0 +1,36 @@ +"use cache"; + +// Simulates a module-scope deadlock pattern: a top-level Map used to dedupe +// fetches, where the cache function awaits a promise created by the outer scope. +// In dev mode, the probe should detect this and surface a UseCacheDeadlockError. + +const dedupeMap = new Map>(); + +function getDedupedData(key: string): Promise { + const existing = dedupeMap.get(key); + if (existing) return existing; + + // Create a promise that only resolves when explicitly signaled. + // In the main request, something else (the outer render) is supposed to + // resolve this, but since the render is blocked on the cache fill, it never + // does — causing a deadlock. + const promise = new Promise(() => { + // Intentionally never resolves — simulates hanging on outer-scope state. + }); + + dedupeMap.set(key, promise); + return promise; +} + +export default async function UseCacheDeadlockPage() { + // This will hang forever because getDedupedData creates a never-resolving + // promise and stores it in the module-scope Map. + await getDedupedData("deadlock-key"); + + return ( +
+

Use Cache Deadlock Test

+

This page should not render

+
+ ); +} diff --git a/tests/fixtures/app-basic/app/use-cache-recovery/page.tsx b/tests/fixtures/app-basic/app/use-cache-recovery/page.tsx new file mode 100644 index 0000000000..2c2a0be29c --- /dev/null +++ b/tests/fixtures/app-basic/app/use-cache-recovery/page.tsx @@ -0,0 +1,22 @@ +"use cache"; + +// Simulates a slow "use cache" function that takes time but eventually +// completes. The probe should NOT fire a deadlock error for this — the +// function is genuinely slow, not deadlocked on module-scope state. + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export default async function UseCacheRecoveryPage() { + // Wait 12 seconds — longer than the 10s probe threshold, but the function + // completes on its own (no module-scope deadlock). + await delay(12_000); + + return ( +
+

Use Cache Recovery Test

+

Slow but working

+
+ ); +} diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 70aaea1321..de3aa0b0e5 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -5668,6 +5668,98 @@ 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); + }); +}); + +describe("use-cache probe scheduler", () => { + it("setupProbeScheduler returns stream unchanged when no probe is installed", async () => { + const { setupProbeScheduler } = + await import("../packages/vinext/src/server/use-cache-probe-scheduler.js"); + + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2, 3])); + controller.close(); + }, + }); + + const abortController = new AbortController(); + const result = setupProbeScheduler({ + cacheContext: { functionId: "test:fn", handlerKind: "" }, + encodedArguments: "[]", + fillDeadlineAt: performance.now() + 60_000, + stream, + abortSignal: abortController.signal, + onProbeCompleted: () => {}, + requestSnapshot: { + headers: [], + cookieHeader: undefined, + urlPathname: "/", + urlSearch: "", + rootParams: {}, + isDraftMode: false, + isHmrRefresh: false, + }, + }); + + // When no probe is installed globally, the stream should pass through unchanged. + expect(result).toBe(stream); + }); + + it("setupProbeScheduler returns stream unchanged when budget is too short", async () => { + const { setupProbeScheduler } = + await import("../packages/vinext/src/server/use-cache-probe-scheduler.js"); + + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2, 3])); + controller.close(); + }, + }); + + const abortController = new AbortController(); + const result = setupProbeScheduler({ + cacheContext: { functionId: "test:fn", handlerKind: "" }, + encodedArguments: "[]", + // Budget is less than PROBE_THRESHOLD_MS + MIN_PROBE_BUDGET_MS + fillDeadlineAt: performance.now() + 5_000, + stream, + abortSignal: abortController.signal, + onProbeCompleted: () => {}, + requestSnapshot: { + headers: [], + cookieHeader: undefined, + urlPathname: "/", + urlSearch: "", + rootParams: {}, + isDraftMode: false, + isHmrRefresh: false, + }, + }); + + expect(result).toBe(stream); + }); +}); + describe("replyToCacheKey deterministic hashing", () => { it("returns string replies as-is", async () => { const { replyToCacheKey } = await import("../packages/vinext/src/shims/cache-runtime.js"); From 8ab447b0345d6c78deb68e0fd189246b3fd53596 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Sun, 10 May 2026 18:38:43 -0700 Subject: [PATCH 02/27] ci: fix benchmarks/vinext tsconfig missing vinext path and remove redundant type union --- benchmarks/vinext/tsconfig.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/benchmarks/vinext/tsconfig.json b/benchmarks/vinext/tsconfig.json index d899beda71..39af512259 100644 --- a/benchmarks/vinext/tsconfig.json +++ b/benchmarks/vinext/tsconfig.json @@ -12,7 +12,8 @@ "resolveJsonModule": true, "isolatedModules": true, "paths": { - "@/*": ["./*"] + "@/*": ["./*"], + "vinext": ["../../packages/vinext/src/index.ts"] } }, "include": ["**/*.ts", "**/*.tsx"], From e7cc4b44305d9e026c9eb5b14e095fc6b57fe588 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Sun, 10 May 2026 18:44:25 -0700 Subject: [PATCH 03/27] chore: remove unused tinypool dependency causing knip failure --- packages/vinext/package.json | 1 - pnpm-lock.yaml | 3 --- 2 files changed, 4 deletions(-) diff --git a/packages/vinext/package.json b/packages/vinext/package.json index 8b7eaa077e..b9d522d28f 100644 --- a/packages/vinext/package.json +++ b/packages/vinext/package.json @@ -93,7 +93,6 @@ "image-size": "catalog:", "ipaddr.js": "catalog:", "magic-string": "catalog:", - "tinypool": "2.1.0", "vite-plugin-commonjs": "catalog:", "vite-tsconfig-paths": "catalog:", "web-vitals": "catalog:" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff609c9689..2b1cf9c64f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -972,9 +972,6 @@ importers: magic-string: specifier: 'catalog:' version: 0.30.21 - tinypool: - specifier: 2.1.0 - version: 2.1.0 vite: specifier: npm:@voidzero-dev/vite-plus-core@0.1.24 version: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)' From 538e3ad7bce581ab39492350ed6269e578c98939 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Sun, 10 May 2026 18:55:28 -0700 Subject: [PATCH 04/27] fix(tests): remove unused use-cache deadlock fixtures causing static export timeout The use-cache-deadlock and use-cache-recovery fixture pages in app-basic were causing the App Router Static export tests to hang: - use-cache-deadlock creates a never-resolving Promise (intentional deadlock) - use-cache-recovery delays for 12 seconds These fixtures are not referenced by any tests (the probe scheduler is tested in isolation via direct module imports). Removing them fixes the integration test timeouts in CI. --- .../app-basic/app/use-cache-deadlock/page.tsx | 36 ------------------- .../app-basic/app/use-cache-recovery/page.tsx | 22 ------------ 2 files changed, 58 deletions(-) delete mode 100644 tests/fixtures/app-basic/app/use-cache-deadlock/page.tsx delete mode 100644 tests/fixtures/app-basic/app/use-cache-recovery/page.tsx diff --git a/tests/fixtures/app-basic/app/use-cache-deadlock/page.tsx b/tests/fixtures/app-basic/app/use-cache-deadlock/page.tsx deleted file mode 100644 index 0dc47f4085..0000000000 --- a/tests/fixtures/app-basic/app/use-cache-deadlock/page.tsx +++ /dev/null @@ -1,36 +0,0 @@ -"use cache"; - -// Simulates a module-scope deadlock pattern: a top-level Map used to dedupe -// fetches, where the cache function awaits a promise created by the outer scope. -// In dev mode, the probe should detect this and surface a UseCacheDeadlockError. - -const dedupeMap = new Map>(); - -function getDedupedData(key: string): Promise { - const existing = dedupeMap.get(key); - if (existing) return existing; - - // Create a promise that only resolves when explicitly signaled. - // In the main request, something else (the outer render) is supposed to - // resolve this, but since the render is blocked on the cache fill, it never - // does — causing a deadlock. - const promise = new Promise(() => { - // Intentionally never resolves — simulates hanging on outer-scope state. - }); - - dedupeMap.set(key, promise); - return promise; -} - -export default async function UseCacheDeadlockPage() { - // This will hang forever because getDedupedData creates a never-resolving - // promise and stores it in the module-scope Map. - await getDedupedData("deadlock-key"); - - return ( -
-

Use Cache Deadlock Test

-

This page should not render

-
- ); -} diff --git a/tests/fixtures/app-basic/app/use-cache-recovery/page.tsx b/tests/fixtures/app-basic/app/use-cache-recovery/page.tsx deleted file mode 100644 index 2c2a0be29c..0000000000 --- a/tests/fixtures/app-basic/app/use-cache-recovery/page.tsx +++ /dev/null @@ -1,22 +0,0 @@ -"use cache"; - -// Simulates a slow "use cache" function that takes time but eventually -// completes. The probe should NOT fire a deadlock error for this — the -// function is genuinely slow, not deadlocked on module-scope state. - -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -export default async function UseCacheRecoveryPage() { - // Wait 12 seconds — longer than the 10s probe threshold, but the function - // completes on its own (no module-scope deadlock). - await delay(12_000); - - return ( -
-

Use Cache Recovery Test

-

Slow but working

-
- ); -} From 9e9624ab572c79425946fbbdb6a9ab59e8b0ba59 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Mon, 11 May 2026 01:43:16 -0700 Subject: [PATCH 05/27] fix(dev): address use-cache probe review issues - Fix runWithProbeRequestStore to use runner.import() instead of native import() so ALS/request context is set on the isolated probe runner's modules, not the main runner's. - Fix timer leak in cache-runtime.ts: both probe and timeout timers are now cleared in .finally() when executionPromise wins the race. - Remove dead code use-cache-probe-scheduler.ts and its orphaned tests. - Remove _environment dead store from probe pool. - Fix redundant await + Promise.race pattern where the timeout could never fire (result was already resolved). - Fix stale module state: create a fresh runner per probe and close() it afterward instead of round-robin reusing from a fixed pool. - Revert unrelated benchmarks/vinext/tsconfig.json vinext path mapping. - Revert unrelated request-pipeline.ts getRequestCf type change. --- benchmarks/vinext/tsconfig.json | 3 +- .../vinext/src/server/request-pipeline.ts | 2 +- .../vinext/src/server/use-cache-probe-pool.ts | 105 ++++------- .../src/server/use-cache-probe-scheduler.ts | 173 ------------------ packages/vinext/src/shims/cache-runtime.ts | 15 +- tests/shims.test.ts | 70 ------- 6 files changed, 51 insertions(+), 317 deletions(-) delete mode 100644 packages/vinext/src/server/use-cache-probe-scheduler.ts diff --git a/benchmarks/vinext/tsconfig.json b/benchmarks/vinext/tsconfig.json index 39af512259..d899beda71 100644 --- a/benchmarks/vinext/tsconfig.json +++ b/benchmarks/vinext/tsconfig.json @@ -12,8 +12,7 @@ "resolveJsonModule": true, "isolatedModules": true, "paths": { - "@/*": ["./*"], - "vinext": ["../../packages/vinext/src/index.ts"] + "@/*": ["./*"] } }, "include": ["**/*.ts", "**/*.tsx"], diff --git a/packages/vinext/src/server/request-pipeline.ts b/packages/vinext/src/server/request-pipeline.ts index a937d54106..242686ade9 100644 --- a/packages/vinext/src/server/request-pipeline.ts +++ b/packages/vinext/src/server/request-pipeline.ts @@ -635,7 +635,7 @@ export function filterInternalHeaders(headers: Headers): Headers { return filtered; } -function getRequestCf(request: Request): unknown { +function getRequestCf(request: Request): unknown | undefined { const cf = Reflect.get(request, "cf"); return cf === undefined ? undefined : cf; } diff --git a/packages/vinext/src/server/use-cache-probe-pool.ts b/packages/vinext/src/server/use-cache-probe-pool.ts index beac9417fa..ceeb09747c 100644 --- a/packages/vinext/src/server/use-cache-probe-pool.ts +++ b/packages/vinext/src/server/use-cache-probe-pool.ts @@ -1,7 +1,7 @@ /** * use-cache-probe-pool.ts * - * Manages a pool of isolated ModuleRunners for "use cache" deadlock probes. + * 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 @@ -23,40 +23,26 @@ import { ModuleRunner } from "vite/module-runner"; import { setUseCacheProbe } from "vinext/shims/use-cache-probe-globals"; import { UseCacheTimeoutError } from "vinext/shims/use-cache-errors"; -let _activeProbeRunners: ModuleRunner[] | null = null; -let _environment: DevEnvironmentLike | DevEnvironment | null = null; -const MAX_RUNNERS = 4; - -function getProbeRunner(): ModuleRunner { - if (!_activeProbeRunners || _activeProbeRunners.length === 0) { - throw new Error("[vinext] use cache probe pool not initialized"); - } - // Round-robin across runners for basic load distribution. - const runner = _activeProbeRunners.shift()!; - _activeProbeRunners.push(runner); - return runner; -} +let _probeEnvironment: DevEnvironmentLike | DevEnvironment | null = null; /** - * Initialize the probe pool with a set of fresh ModuleRunners bound to the - * given Vite dev environment. + * Initialize the probe pool with the Vite dev environment. * * Called once during configureServer() when the App Router dev server starts. */ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvironment): void { - if (_activeProbeRunners) { + if (_probeEnvironment) { // Already initialized — no-op. The environment is the same for the // lifetime of the dev server. return; } - _environment = environment; - _activeProbeRunners = []; - for (let i = 0; i < MAX_RUNNERS; i++) { - _activeProbeRunners.push(createProbeRunner(environment)); - } + _probeEnvironment = environment; setUseCacheProbe(async (msg) => { - const runner = getProbeRunner(); + // 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. + const runner = createProbeRunner(_probeEnvironment!); const { id, kind, encodedArguments, request, timeoutMs } = msg; // Internal timeout so the probe aborts before the outer render timeout. @@ -120,17 +106,14 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir // Run the function with a reconstructed request store so private caches // that read cookies()/headers()/draftMode() see the same values. - const result = await runWithProbeRequestStore(request, async () => wrapped(...args)); - - // Wait for the result, but enforce the internal timeout. + // Race against the internal timeout. const remaining = deadline - Date.now(); if (remaining <= 0) { return false; } - // If we got here, the probe completed. await Promise.race([ - Promise.resolve(result), + runWithProbeRequestStore(runner, request, async () => wrapped(...args)), new Promise((_, reject) => { const t = setTimeout(() => reject(new UseCacheTimeoutError()), remaining); // Ensure timer is cleaned up on success via unref if available. @@ -146,6 +129,8 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir // even in isolation, or the module import failed. Fall back to the // regular timeout. return false; + } finally { + runner.close().catch(() => {}); } }); } @@ -155,13 +140,7 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir * probe starts with fresh code. */ export function tearDownUseCacheProbePool(): void { - if (_activeProbeRunners) { - for (const runner of _activeProbeRunners) { - runner.close().catch(() => {}); - } - _activeProbeRunners = null; - } - _environment = null; + _probeEnvironment = null; setUseCacheProbe(undefined); } @@ -170,6 +149,7 @@ export function tearDownUseCacheProbePool(): void { * cookies(), headers(), and draftMode() behave correctly. */ async function runWithProbeRequestStore( + runner: ModuleRunner, requestSnapshot: { headers: [string, string][]; cookieHeader: string | undefined; @@ -181,35 +161,28 @@ async function runWithProbeRequestStore( }, fn: () => Promise, ): Promise { - // Import the ALS-backed request-context modules in the isolated runner. - const unifiedCtx = (async () => { - // These imports run inside the probe runner's module graph. - // We dynamic-import them because the probe runner doesn't share - // module state with the main runner. - const { createRequestContext, runWithRequestContext } = - (await import("vinext/shims/unified-request-context")) as typeof import("vinext/shims/unified-request-context"); - - const { headersContextFromRequest } = - (await 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); - const ctx = createRequestContext({ - headersContext, - executionContext: null, - rootParams: requestSnapshot.rootParams as Record, - }); - - return runWithRequestContext(ctx, fn); - })(); - - return unifiedCtx; + // 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); + const ctx = createRequestContext({ + headersContext, + executionContext: null, + rootParams: requestSnapshot.rootParams as Record, + }); + + return runWithRequestContext(ctx, fn); } diff --git a/packages/vinext/src/server/use-cache-probe-scheduler.ts b/packages/vinext/src/server/use-cache-probe-scheduler.ts deleted file mode 100644 index 8297c38217..0000000000 --- a/packages/vinext/src/server/use-cache-probe-scheduler.ts +++ /dev/null @@ -1,173 +0,0 @@ -/** - * use-cache-probe-scheduler.ts - * - * Dev-only idle-deadline probe scheduler for "use cache" fills. - * - * Wraps the cache fill stream in a TransformStream and tracks chunk activity. - * If the stream is idle for PROBE_THRESHOLD_MS (10s), schedules a probe that - * re-runs the cache function in a fresh module scope. If the probe completes - * while the main fill is still hung, the caller aborts the fill with a - * UseCacheDeadlockError. - * - * Ported from Next.js: packages/next/src/server/use-cache/use-cache-probe-scheduler.ts - * https://github.com/vercel/next.js/blob/canary/packages/next/src/server/use-cache/use-cache-probe-scheduler.ts - */ - -import { - getUseCacheProbe, - type UseCacheProbeRequestSnapshot, -} from "vinext/shims/use-cache-probe-globals"; - -const PROBE_THRESHOLD_MS = 10_000; -const MIN_PROBE_BUDGET_MS = 3_000; - -type CacheContextWithProbeFields = { - readonly functionId: string; - readonly handlerKind: string; -}; - -type SetupOptions = { - cacheContext: CacheContextWithProbeFields; - encodedArguments: string | FormData; - /** - * Absolute monotonic deadline (in performance.now() units) at which the - * outer cache fill will be aborted by the dev render-timeout timer. - */ - fillDeadlineAt: number; - /** - * Called once if the probe ran the cache function to completion in isolation - * while the main fill was still pending. - */ - onProbeCompleted: () => void; - /** - * AbortSignal that fires when the probe should stop watching (fill settled, - * timeout fired, upstream cancel, etc.). - */ - abortSignal: AbortSignal; - /** - * The outer request store snapshot so the probe can reconstruct cookies(), - * headers(), draftMode(), etc. in the isolated run. - */ - requestSnapshot: UseCacheProbeRequestSnapshot; - /** - * Cache stream to track. Each chunk resets the idle timer. - */ - stream: ReadableStream; -}; - -/** - * Schedule an idle-deadline probe over a cache fill stream (dev-only). - * - * Returns the input stream unchanged when scheduling should be skipped. - */ -export function setupProbeScheduler(opts: SetupOptions): ReadableStream { - const { - cacheContext, - encodedArguments, - fillDeadlineAt, - stream, - abortSignal, - onProbeCompleted, - requestSnapshot, - } = opts; - - // Skip if the remaining budget is too short for a meaningful probe. - if (fillDeadlineAt - performance.now() < PROBE_THRESHOLD_MS + MIN_PROBE_BUDGET_MS) { - return stream; - } - - const probe = getUseCacheProbe(); - if (!probe) { - return stream; - } - - let lastChunkAt = performance.now(); - let idleTimer: ReturnType | undefined; - - const startProbe = () => { - if (abortSignal.aborted) { - return; - } - - const probeStartedAtChunk = lastChunkAt; - // Reserve a 1s buffer so the probe's internal timeout fires before the - // outer render timeout. - const probeInternalTimeoutMs = fillDeadlineAt - performance.now() - 1_000; - - if (probeInternalTimeoutMs <= 0) { - return; - } - - probe({ - id: cacheContext.functionId, - kind: cacheContext.handlerKind, - encodedArguments, - request: requestSnapshot, - timeoutMs: probeInternalTimeoutMs, - }).then( - (completed) => { - // Mid-probe recovery: chunks arrived while the probe was running. - if (lastChunkAt > probeStartedAtChunk) { - return; - } - if (completed && !abortSignal.aborted) { - onProbeCompleted(); - } - }, - // Probe failures are inconclusive; fall back to regular timeout. - () => {}, - ); - }; - - const scheduleAfterIdle = () => { - if (idleTimer !== undefined || abortSignal.aborted) { - return; - } - const now = performance.now(); - const idleFor = now - lastChunkAt; - const wait = Math.max(0, PROBE_THRESHOLD_MS - idleFor); - - // Skip scheduling if the outer fill timeout will fire before the probe - // could even start with a minimum useful budget. - if (fillDeadlineAt - now < wait + MIN_PROBE_BUDGET_MS) { - return; - } - - idleTimer = setTimeout(() => { - idleTimer = undefined; - if (abortSignal.aborted) { - return; - } - const idleNow = performance.now() - lastChunkAt; - if (idleNow < PROBE_THRESHOLD_MS) { - // A chunk arrived since we set this timer; reschedule. - scheduleAfterIdle(); - return; - } - startProbe(); - }, wait); - }; - - abortSignal.addEventListener( - "abort", - () => { - if (idleTimer !== undefined) { - clearTimeout(idleTimer); - idleTimer = undefined; - } - }, - { once: true }, - ); - - scheduleAfterIdle(); - - return stream.pipeThrough( - new TransformStream({ - transform(chunk, controller) { - lastChunkAt = performance.now(); - scheduleAfterIdle(); - controller.enqueue(chunk); - }, - }), - ); -} diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index a6c7af3cef..0f26c3b272 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -530,6 +530,8 @@ export function registerCachedFunction( const timeoutError = new UseCacheTimeoutError(); const deadlockError = new UseCacheDeadlockError(); + let probeTimer: ReturnType | undefined; + let timeoutTimer: ReturnType | undefined; let probePromise: Promise | null = null; const probe = getUseCacheProbe(); @@ -549,7 +551,7 @@ export function registerCachedFunction( }; probePromise = new Promise((_, reject) => { - setTimeout(() => { + probeTimer = setTimeout(() => { const probeInternalTimeoutMs = fillDeadlineAt - performance.now() - 1_000; if (probeInternalTimeoutMs <= 0) return; @@ -572,9 +574,9 @@ export function registerCachedFunction( } const timeoutPromise = new Promise((_, reject) => { - const t = setTimeout(() => reject(timeoutError), USE_CACHE_TIMEOUT_MS); - if (typeof (t as NodeJS.Timeout).unref === "function") { - (t as NodeJS.Timeout).unref(); + timeoutTimer = setTimeout(() => reject(timeoutError), USE_CACHE_TIMEOUT_MS); + if (typeof (timeoutTimer as NodeJS.Timeout).unref === "function") { + (timeoutTimer as NodeJS.Timeout).unref(); } }); // Swallow rejection when execution wins the race. @@ -586,7 +588,10 @@ export function registerCachedFunction( if (probePromise) promises.push(probePromise); promises.push(timeoutPromise); - return Promise.race(promises); + return Promise.race(promises).finally(() => { + if (probeTimer !== undefined) clearTimeout(probeTimer); + if (timeoutTimer !== undefined) clearTimeout(timeoutTimer); + }); } // Shared cache ("use cache" / "use cache: remote") diff --git a/tests/shims.test.ts b/tests/shims.test.ts index de3aa0b0e5..1ac4b34517 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -5690,76 +5690,6 @@ describe("use-cache errors", () => { }); }); -describe("use-cache probe scheduler", () => { - it("setupProbeScheduler returns stream unchanged when no probe is installed", async () => { - const { setupProbeScheduler } = - await import("../packages/vinext/src/server/use-cache-probe-scheduler.js"); - - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(new Uint8Array([1, 2, 3])); - controller.close(); - }, - }); - - const abortController = new AbortController(); - const result = setupProbeScheduler({ - cacheContext: { functionId: "test:fn", handlerKind: "" }, - encodedArguments: "[]", - fillDeadlineAt: performance.now() + 60_000, - stream, - abortSignal: abortController.signal, - onProbeCompleted: () => {}, - requestSnapshot: { - headers: [], - cookieHeader: undefined, - urlPathname: "/", - urlSearch: "", - rootParams: {}, - isDraftMode: false, - isHmrRefresh: false, - }, - }); - - // When no probe is installed globally, the stream should pass through unchanged. - expect(result).toBe(stream); - }); - - it("setupProbeScheduler returns stream unchanged when budget is too short", async () => { - const { setupProbeScheduler } = - await import("../packages/vinext/src/server/use-cache-probe-scheduler.js"); - - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(new Uint8Array([1, 2, 3])); - controller.close(); - }, - }); - - const abortController = new AbortController(); - const result = setupProbeScheduler({ - cacheContext: { functionId: "test:fn", handlerKind: "" }, - encodedArguments: "[]", - // Budget is less than PROBE_THRESHOLD_MS + MIN_PROBE_BUDGET_MS - fillDeadlineAt: performance.now() + 5_000, - stream, - abortSignal: abortController.signal, - onProbeCompleted: () => {}, - requestSnapshot: { - headers: [], - cookieHeader: undefined, - urlPathname: "/", - urlSearch: "", - rootParams: {}, - isDraftMode: false, - isHmrRefresh: false, - }, - }); - - expect(result).toBe(stream); - }); -}); - describe("replyToCacheKey deterministic hashing", () => { it("returns string replies as-is", async () => { const { replyToCacheKey } = await import("../packages/vinext/src/shims/cache-runtime.js"); From 35f0da76069fbdc0cd40aa7f87103a9d50e7fe58 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Mon, 11 May 2026 02:07:32 -0700 Subject: [PATCH 06/27] fix(use-cache): address all review issues for deadlock probe - Re-init probe pool after HMR teardown so probes survive file changes - Add inside-probe guard flag to prevent recursive probing via globalThis - Remove duplicate createProbeRunner, reuse createDirectRunner - Change ModuleRunner import to type-only in use-cache-probe-pool - Set this.name in UseCacheTimeoutError and UseCacheDeadlockError - Remove unused isDraftMode/isHmrRefresh from UseCacheProbeRequestSnapshot - Add test for isInsideUseCacheProbe guard behavior --- packages/vinext/src/index.ts | 5 ++ .../vinext/src/server/dev-module-runner.ts | 48 ------------------- .../vinext/src/server/use-cache-probe-pool.ts | 14 +++--- packages/vinext/src/shims/cache-runtime.ts | 10 ++-- packages/vinext/src/shims/use-cache-errors.ts | 2 + .../src/shims/use-cache-probe-globals.ts | 11 ++++- tests/shims.test.ts | 48 +++++++++++++++++++ 7 files changed, 78 insertions(+), 60 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index ac16b6f607..83c07b2120 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -2998,6 +2998,11 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // 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); + } } let appRouteTypeGeneration: Promise | null = null; diff --git a/packages/vinext/src/server/dev-module-runner.ts b/packages/vinext/src/server/dev-module-runner.ts index 8fdf1d0a5a..07df21cf81 100644 --- a/packages/vinext/src/server/dev-module-runner.ts +++ b/packages/vinext/src/server/dev-module-runner.ts @@ -129,51 +129,3 @@ export function createDirectRunner(environment: DevEnvironmentLike | DevEnvironm new ESModulesEvaluator(), ); } - -/** - * Create a fresh ModuleRunner with its own isolated module graph. - * - * Used by the "use cache" deadlock probe to re-import a cache function - * with zero shared module-scope state from the main request pipeline. - * Each probe gets a brand-new `EvaluatedModules` instance so top-level - * `Map`s, `Promise`s, etc. are recreated from scratch. - * - * The transport delegates to the same `environment.fetchModule()`, so - * Vite transforms and HMR still work, but the *evaluated* module cache - * is completely separate. - */ -export function createProbeRunner(environment: DevEnvironmentLike | DevEnvironment): ModuleRunner { - return new ModuleRunner( - { - transport: { - // oxlint-disable-next-line @typescript-eslint/no-explicit-any - invoke: async (payload: any) => { - const { name, data: args } = payload.data; - if (name === "fetchModule") { - const [id, importer, options] = args as [ - string, - string | undefined, - { cached?: boolean; startOffset?: number } | undefined, - ]; - return { - result: await environment.fetchModule(id, importer, options), - }; - } - if (name === "getBuiltins") { - return { result: [] }; - } - return { - error: { - name: "Error", - message: `[vinext] Unexpected ModuleRunner invoke: ${name}`, - }, - }; - }, - }, - createImportMeta: createNodeImportMeta, - sourcemapInterceptor: false, - hmr: false, - }, - new ESModulesEvaluator(), - ); -} diff --git a/packages/vinext/src/server/use-cache-probe-pool.ts b/packages/vinext/src/server/use-cache-probe-pool.ts index ceeb09747c..c39c8570a9 100644 --- a/packages/vinext/src/server/use-cache-probe-pool.ts +++ b/packages/vinext/src/server/use-cache-probe-pool.ts @@ -18,9 +18,9 @@ */ import type { DevEnvironment } from "vite"; -import { createProbeRunner, type DevEnvironmentLike } from "./dev-module-runner.js"; -import { ModuleRunner } from "vite/module-runner"; -import { setUseCacheProbe } from "vinext/shims/use-cache-probe-globals"; +import { createDirectRunner, type DevEnvironmentLike } from "./dev-module-runner.js"; +import type { ModuleRunner } from "vite/module-runner"; +import { setUseCacheProbe, setInsideUseCacheProbe } from "vinext/shims/use-cache-probe-globals"; import { UseCacheTimeoutError } from "vinext/shims/use-cache-errors"; let _probeEnvironment: DevEnvironmentLike | DevEnvironment | null = null; @@ -42,12 +42,15 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir // 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. - const runner = createProbeRunner(_probeEnvironment!); + // createDirectRunner creates a fresh ModuleRunner with its own isolated + // EvaluatedModules instance, which is exactly what we need for probes. + const runner = createDirectRunner(_probeEnvironment!); const { id, kind, encodedArguments, request, timeoutMs } = msg; // Internal timeout so the probe aborts before the outer render timeout. const deadline = Date.now() + timeoutMs; + setInsideUseCacheProbe(true); try { // Import the cache-runtime shim in the isolated runner. // The shim's registerCachedFunction will create fresh module-scope state. @@ -130,6 +133,7 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir // regular timeout. return false; } finally { + setInsideUseCacheProbe(false); runner.close().catch(() => {}); } }); @@ -156,8 +160,6 @@ async function runWithProbeRequestStore( urlPathname: string; urlSearch: string; rootParams: Record; - isDraftMode: boolean; - isHmrRefresh: boolean; }, fn: () => Promise, ): Promise { diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index 0f26c3b272..30e0a1ebf8 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -47,7 +47,11 @@ import { } from "./unified-request-context.js"; import { markDynamicUsage } from "./headers.js"; import { UseCacheTimeoutError, UseCacheDeadlockError } from "./use-cache-errors.js"; -import { getUseCacheProbe, type UseCacheProbeRequestSnapshot } from "./use-cache-probe-globals.js"; +import { + getUseCacheProbe, + isInsideUseCacheProbe, + type UseCacheProbeRequestSnapshot, +} from "./use-cache-probe-globals.js"; // --------------------------------------------------------------------------- // Constants for nested-dynamic cache life detection @@ -535,7 +539,7 @@ export function registerCachedFunction( let probePromise: Promise | null = null; const probe = getUseCacheProbe(); - if (probe) { + if (probe && !isInsideUseCacheProbe()) { // Capture the current request store snapshot for the probe. const requestCtx = getRequestContext(); const headers = requestCtx.headersContext?.headers; @@ -546,8 +550,6 @@ export function registerCachedFunction( urlPathname: navCtx?.pathname ?? "/", urlSearch: navCtx?.searchParams?.toString() ?? "", rootParams: requestCtx.rootParams ?? {}, - isDraftMode: false, - isHmrRefresh: false, }; probePromise = new Promise((_, reject) => { diff --git a/packages/vinext/src/shims/use-cache-errors.ts b/packages/vinext/src/shims/use-cache-errors.ts index 7f5b79c061..40312bb4af 100644 --- a/packages/vinext/src/shims/use-cache-errors.ts +++ b/packages/vinext/src/shims/use-cache-errors.ts @@ -17,6 +17,7 @@ export class UseCacheTimeoutError extends Error { 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"; } } @@ -27,6 +28,7 @@ export class UseCacheDeadlockError extends Error { 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"; } } diff --git a/packages/vinext/src/shims/use-cache-probe-globals.ts b/packages/vinext/src/shims/use-cache-probe-globals.ts index adde9a412b..b2e92363e2 100644 --- a/packages/vinext/src/shims/use-cache-probe-globals.ts +++ b/packages/vinext/src/shims/use-cache-probe-globals.ts @@ -10,6 +10,7 @@ */ const SYMBOL = Symbol.for("vinext.dev.useCacheProbe"); +const INSIDE_PROBE_SYMBOL = Symbol.for("vinext.dev.useCacheProbe.inside"); export type UseCacheProbeRequestSnapshot = { headers: [string, string][]; @@ -17,8 +18,6 @@ export type UseCacheProbeRequestSnapshot = { urlPathname: string; urlSearch: string; rootParams: Record; - isDraftMode: boolean; - isHmrRefresh: boolean; }; export type UseCacheProbe = (msg: { @@ -36,3 +35,11 @@ export function setUseCacheProbe(fn: UseCacheProbe | undefined): void { export function getUseCacheProbe(): UseCacheProbe | undefined { return (globalThis as Record)[SYMBOL] as UseCacheProbe | undefined; } + +export function setInsideUseCacheProbe(value: boolean): void { + (globalThis as Record)[INSIDE_PROBE_SYMBOL] = value; +} + +export function isInsideUseCacheProbe(): boolean { + return !!(globalThis as Record)[INSIDE_PROBE_SYMBOL]; +} diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 1ac4b34517..3578944d8e 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -5688,6 +5688,54 @@ describe("use-cache errors", () => { 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 isInsideUseCacheProbe is true", async () => { + const { setUseCacheProbe, setInsideUseCacheProbe } = + 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 + const originalNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = "development"; + vi.resetModules(); + + const { registerCachedFunction } = + await import("../packages/vinext/src/shims/cache-runtime.js"); + + const fn = async () => "result"; + const cached = registerCachedFunction(fn, "test:no-recurse", ""); + + setInsideUseCacheProbe(true); + const result = await cached(); + setInsideUseCacheProbe(false); + + expect(result).toBe("result"); + expect(probeCalled).toBe(false); + + process.env.NODE_ENV = originalNodeEnv; + setUseCacheProbe(undefined); + }); }); describe("replyToCacheKey deterministic hashing", () => { From d88801217ff0b23e516902eb35243fe46aedc593 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Mon, 11 May 2026 10:26:02 -0700 Subject: [PATCH 07/27] fix(use-cache): address v3 review feedback on deadlock probe - Convert isInsideUseCacheProbe from global boolean to refcount to fix concurrent probe race condition. - Store and clearTimeout() the probe timeout instead of only .unref(). - Use dynamic import() for dev-only probe modules inside the isDev branch so they tree-shake out of production builds. - Wrap test assertions in try/finally to prevent state poisoning on failure. - Add comment documenting colon assumption in function ID splitting. --- .../vinext/src/server/use-cache-probe-pool.ts | 10 ++++++---- packages/vinext/src/shims/cache-runtime.ts | 16 +++++++++------- .../src/shims/use-cache-probe-globals.ts | 7 +++++-- tests/shims.test.ts | 18 ++++++++++-------- 4 files changed, 30 insertions(+), 21 deletions(-) diff --git a/packages/vinext/src/server/use-cache-probe-pool.ts b/packages/vinext/src/server/use-cache-probe-pool.ts index c39c8570a9..9e28a3f707 100644 --- a/packages/vinext/src/server/use-cache-probe-pool.ts +++ b/packages/vinext/src/server/use-cache-probe-pool.ts @@ -50,6 +50,7 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir // Internal timeout so the probe aborts before the outer render timeout. const deadline = Date.now() + timeoutMs; + let probeTimeoutTimer: ReturnType | undefined; setInsideUseCacheProbe(true); try { // Import the cache-runtime shim in the isolated runner. @@ -73,6 +74,7 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir // 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"; @@ -118,10 +120,9 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir await Promise.race([ runWithProbeRequestStore(runner, request, async () => wrapped(...args)), new Promise((_, reject) => { - const t = setTimeout(() => reject(new UseCacheTimeoutError()), remaining); - // Ensure timer is cleaned up on success via unref if available. - if (typeof (t as NodeJS.Timeout).unref === "function") { - (t as NodeJS.Timeout).unref(); + probeTimeoutTimer = setTimeout(() => reject(new UseCacheTimeoutError()), remaining); + if (typeof (probeTimeoutTimer as NodeJS.Timeout).unref === "function") { + (probeTimeoutTimer as NodeJS.Timeout).unref(); } }), ]); @@ -134,6 +135,7 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir return false; } finally { setInsideUseCacheProbe(false); + if (probeTimeoutTimer !== undefined) clearTimeout(probeTimeoutTimer); runner.close().catch(() => {}); } }); diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index 30e0a1ebf8..83c3099e2c 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -46,12 +46,6 @@ import { runWithUnifiedStateMutation, } from "./unified-request-context.js"; import { markDynamicUsage } from "./headers.js"; -import { UseCacheTimeoutError, UseCacheDeadlockError } from "./use-cache-errors.js"; -import { - getUseCacheProbe, - isInsideUseCacheProbe, - type UseCacheProbeRequestSnapshot, -} from "./use-cache-probe-globals.js"; // --------------------------------------------------------------------------- // Constants for nested-dynamic cache life detection @@ -531,6 +525,14 @@ export function registerCachedFunction( const USE_CACHE_TIMEOUT_MS = 54_000; const fillDeadlineAt = performance.now() + USE_CACHE_TIMEOUT_MS; + const [ + { UseCacheTimeoutError, UseCacheDeadlockError }, + { getUseCacheProbe, isInsideUseCacheProbe }, + ] = await Promise.all([ + import("./use-cache-errors.js"), + import("./use-cache-probe-globals.js"), + ]); + const timeoutError = new UseCacheTimeoutError(); const deadlockError = new UseCacheDeadlockError(); @@ -544,7 +546,7 @@ export function registerCachedFunction( const requestCtx = getRequestContext(); const headers = requestCtx.headersContext?.headers; const navCtx = requestCtx.serverContext; - const requestSnapshot: UseCacheProbeRequestSnapshot = { + const requestSnapshot = { headers: headers ? Array.from(headers.entries()) : [], cookieHeader: headers?.get("cookie") ?? undefined, urlPathname: navCtx?.pathname ?? "/", diff --git a/packages/vinext/src/shims/use-cache-probe-globals.ts b/packages/vinext/src/shims/use-cache-probe-globals.ts index b2e92363e2..f698560f4d 100644 --- a/packages/vinext/src/shims/use-cache-probe-globals.ts +++ b/packages/vinext/src/shims/use-cache-probe-globals.ts @@ -37,9 +37,12 @@ export function getUseCacheProbe(): UseCacheProbe | undefined { } export function setInsideUseCacheProbe(value: boolean): void { - (globalThis as Record)[INSIDE_PROBE_SYMBOL] = value; + const current = ((globalThis as Record)[INSIDE_PROBE_SYMBOL] as number) || 0; + (globalThis as Record)[INSIDE_PROBE_SYMBOL] = value + ? current + 1 + : Math.max(0, current - 1); } export function isInsideUseCacheProbe(): boolean { - return !!(globalThis as Record)[INSIDE_PROBE_SYMBOL]; + return (((globalThis as Record)[INSIDE_PROBE_SYMBOL] as number) || 0) > 0; } diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 3578944d8e..6c2d380893 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -5727,14 +5727,16 @@ describe("use-cache deadlock probe behavior", () => { const cached = registerCachedFunction(fn, "test:no-recurse", ""); setInsideUseCacheProbe(true); - const result = await cached(); - setInsideUseCacheProbe(false); - - expect(result).toBe("result"); - expect(probeCalled).toBe(false); - - process.env.NODE_ENV = originalNodeEnv; - setUseCacheProbe(undefined); + let result: unknown; + try { + result = await cached(); + expect(result).toBe("result"); + expect(probeCalled).toBe(false); + } finally { + setInsideUseCacheProbe(false); + process.env.NODE_ENV = originalNodeEnv; + setUseCacheProbe(undefined); + } }); }); From 9bb642e338734f88b3a6cb61b5af9889a9fff2db Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Mon, 11 May 2026 10:35:17 -0700 Subject: [PATCH 08/27] fix(use-cache): address v4 review feedback on deadlock probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Distinguish UseCacheTimeoutError from other thrown errors in probe catch block. A function that throws completed (with an error) and is evidence of a deadlock in the main fill, so return true. Only a probe that times out is genuinely inconclusive (return false). - Capture env in a local variable in initUseCacheProbePool to avoid race condition where HMR teardown sets _probeEnvironment = null mid-flight. - Fix rootParams type mismatch: UseCacheProbeRequestSnapshot now uses Record matching RootParams, eliminating the unsafe cast in the probe pool. - Add comment explaining getRequestContext() default behavior when called outside a request scope. - Add fake-timer unit test covering the core deadlock-detection path: probe completes → deadlockError rejection → Promise.race surfaces UseCacheDeadlockError. --- .../vinext/src/server/use-cache-probe-pool.ts | 24 ++++++---- packages/vinext/src/shims/cache-runtime.ts | 5 ++ .../src/shims/use-cache-probe-globals.ts | 2 +- tests/shims.test.ts | 47 +++++++++++++++++++ 4 files changed, 69 insertions(+), 9 deletions(-) diff --git a/packages/vinext/src/server/use-cache-probe-pool.ts b/packages/vinext/src/server/use-cache-probe-pool.ts index 9e28a3f707..f2ccf548be 100644 --- a/packages/vinext/src/server/use-cache-probe-pool.ts +++ b/packages/vinext/src/server/use-cache-probe-pool.ts @@ -38,13 +38,17 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir } _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(_probeEnvironment!); + const runner = createDirectRunner(env); const { id, kind, encodedArguments, request, timeoutMs } = msg; // Internal timeout so the probe aborts before the outer render timeout. @@ -128,11 +132,15 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir ]); return true; - } catch { - // Probe failure is inconclusive — the function might genuinely hang - // even in isolation, or the module import failed. Fall back to the - // regular timeout. - return false; + } catch (err) { + // If the probe timed out, the result is inconclusive — the function + // might genuinely hang even in isolation. + if (err instanceof UseCacheTimeoutError) { + return false; + } + // The function threw — it ran to completion (with an error). If the main + // fill is still stuck, this is evidence of a deadlock on shared state. + return true; } finally { setInsideUseCacheProbe(false); if (probeTimeoutTimer !== undefined) clearTimeout(probeTimeoutTimer); @@ -161,7 +169,7 @@ async function runWithProbeRequestStore( cookieHeader: string | undefined; urlPathname: string; urlSearch: string; - rootParams: Record; + rootParams: Record; }, fn: () => Promise, ): Promise { @@ -185,7 +193,7 @@ async function runWithProbeRequestStore( const ctx = createRequestContext({ headersContext, executionContext: null, - rootParams: requestSnapshot.rootParams as Record, + rootParams: requestSnapshot.rootParams, }); return runWithRequestContext(ctx, fn); diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index 83c3099e2c..440eb95d62 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -543,6 +543,11 @@ export function registerCachedFunction( if (probe && !isInsideUseCacheProbe()) { // Capture the current request store snapshot for the probe. + // getRequestContext() never returns null — it creates a default + // context when called outside a request scope. For deadlock detection, + // the exact headers/pathname matter less than the function body + // executing with a fresh module scope, so the default "/" pathname + // and empty headers are acceptable. const requestCtx = getRequestContext(); const headers = requestCtx.headersContext?.headers; const navCtx = requestCtx.serverContext; diff --git a/packages/vinext/src/shims/use-cache-probe-globals.ts b/packages/vinext/src/shims/use-cache-probe-globals.ts index f698560f4d..a25048d70d 100644 --- a/packages/vinext/src/shims/use-cache-probe-globals.ts +++ b/packages/vinext/src/shims/use-cache-probe-globals.ts @@ -17,7 +17,7 @@ export type UseCacheProbeRequestSnapshot = { cookieHeader: string | undefined; urlPathname: string; urlSearch: string; - rootParams: Record; + rootParams: Record; }; export type UseCacheProbe = (msg: { diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 6c2d380893..9c92571ae0 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -5738,6 +5738,53 @@ describe("use-cache deadlock probe behavior", () => { 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); + + const originalNodeEnv = process.env.NODE_ENV; + process.env.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 the microtask that carries the deadlock rejection. + await Promise.resolve(); + + expect(isUseCacheDeadlockError(caughtError)).toBe(true); + expect(caughtError?.message).toContain("shared state"); + } finally { + resolveHung?.("cleanup"); + vi.useRealTimers(); + process.env.NODE_ENV = originalNodeEnv; + setUseCacheProbe(undefined); + } + }); }); describe("replyToCacheKey deterministic hashing", () => { From 11705f4f73b325b0de4f5d32261f9d6e97225980 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Mon, 11 May 2026 12:23:00 -0700 Subject: [PATCH 09/27] fix(use-cache): address v5 review feedback on deadlock probe - Add console.warn when probe is inconclusive (returns false) so developers get a signal at 10s instead of silently waiting 54s. - Fix misleading initUseCacheProbePool comment to reflect HMR teardown and reinit cycle, not one-time init. - Remove dead private variant branch in probe pool (private caches return before probe scheduling); document with comment and remove unused kind destructuring. - Add explicit type cast for rootParams at snapshot capture site to fix Record vs Record mismatch. - Remove cookieHeader from UseCacheProbeRequestSnapshot (never consumed; headers array already includes cookie). - Wrap JSON.stringify(args) in try/catch with fallback to prevent crash on circular references or BigInts. - Add comment in test about vi.resetModules() mixing module instances. --- .../vinext/src/server/use-cache-probe-pool.ts | 11 +++++---- packages/vinext/src/shims/cache-runtime.ts | 23 +++++++++++++++---- .../src/shims/use-cache-probe-globals.ts | 1 - tests/shims.test.ts | 6 ++++- 4 files changed, 30 insertions(+), 11 deletions(-) diff --git a/packages/vinext/src/server/use-cache-probe-pool.ts b/packages/vinext/src/server/use-cache-probe-pool.ts index f2ccf548be..1a97e50c6e 100644 --- a/packages/vinext/src/server/use-cache-probe-pool.ts +++ b/packages/vinext/src/server/use-cache-probe-pool.ts @@ -32,8 +32,8 @@ let _probeEnvironment: DevEnvironmentLike | DevEnvironment | null = null; */ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvironment): void { if (_probeEnvironment) { - // Already initialized — no-op. The environment is the same for the - // lifetime of the dev server. + // Guard against double-init within the same cycle (e.g., if + // initUseCacheProbePool is called without a preceding teardown). return; } _probeEnvironment = environment; @@ -49,7 +49,7 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir // createDirectRunner creates a fresh ModuleRunner with its own isolated // EvaluatedModules instance, which is exactly what we need for probes. const runner = createDirectRunner(env); - const { id, kind, encodedArguments, request, timeoutMs } = msg; + const { id, encodedArguments, request, timeoutMs } = msg; // Internal timeout so the probe aborts before the outer render timeout. const deadline = Date.now() + timeoutMs; @@ -92,7 +92,9 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir // Wrap it with registerCachedFunction so the probe runs through the // same cache-runtime path (fresh ALS, no shared state). - const variant = kind === "private" ? "private" : ""; + // Private cache functions return before reaching the probe scheduling + // code, so kind can never be "private" here. Keep "" for safety. + const variant = ""; const wrapped = registerCachedFunction( originalFn as (...args: unknown[]) => Promise, id, @@ -166,7 +168,6 @@ async function runWithProbeRequestStore( runner: ModuleRunner, requestSnapshot: { headers: [string, string][]; - cookieHeader: string | undefined; urlPathname: string; urlSearch: string; rootParams: Record; diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index 440eb95d62..2e1aaa86ab 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -553,10 +553,12 @@ export function registerCachedFunction( const navCtx = requestCtx.serverContext; const requestSnapshot = { headers: headers ? Array.from(headers.entries()) : [], - cookieHeader: headers?.get("cookie") ?? undefined, urlPathname: navCtx?.pathname ?? "/", urlSearch: navCtx?.searchParams?.toString() ?? "", - rootParams: requestCtx.rootParams ?? {}, + rootParams: (requestCtx.rootParams ?? {}) as Record< + string, + string | string[] | undefined + >, }; probePromise = new Promise((_, reject) => { @@ -567,12 +569,25 @@ export function registerCachedFunction( probe({ id, kind: cacheVariant, - encodedArguments: JSON.stringify(args), + encodedArguments: (() => { + try { + return JSON.stringify(args); + } catch { + return "[]"; + } + })(), request: requestSnapshot, timeoutMs: probeInternalTimeoutMs, }).then( (completed) => { - if (completed) reject(deadlockError); + if (completed) { + reject(deadlockError); + } else if (typeof console !== "undefined") { + console.warn( + `[vinext] "use cache" fill for ${id} has been idle for 10s. ` + + `Probe was also inconclusive — will hard-timeout at 54s.`, + ); + } }, () => {}, ); diff --git a/packages/vinext/src/shims/use-cache-probe-globals.ts b/packages/vinext/src/shims/use-cache-probe-globals.ts index a25048d70d..353f79b595 100644 --- a/packages/vinext/src/shims/use-cache-probe-globals.ts +++ b/packages/vinext/src/shims/use-cache-probe-globals.ts @@ -14,7 +14,6 @@ const INSIDE_PROBE_SYMBOL = Symbol.for("vinext.dev.useCacheProbe.inside"); export type UseCacheProbeRequestSnapshot = { headers: [string, string][]; - cookieHeader: string | undefined; urlPathname: string; urlSearch: string; rootParams: Record; diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 9c92571ae0..ce20c02f2d 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -5715,7 +5715,11 @@ describe("use-cache deadlock probe behavior", () => { return true; }); - // Ensure dev mode path is taken by resetting modules and setting NODE_ENV + // Ensure dev mode path is taken by resetting modules and setting NODE_ENV. + // Note: vi.resetModules() re-evaluates cache-runtime.js with the new + // NODE_ENV, but probe-globals.js (imported above) is from the original + // module evaluation. Both use Symbol.for on globalThis, so they still + // share the same global state despite being different module instances. const originalNodeEnv = process.env.NODE_ENV; process.env.NODE_ENV = "development"; vi.resetModules(); From abb5c21abf9ba18e0487d5dab08e5b20f41d23d3 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Tue, 12 May 2026 10:49:31 -0700 Subject: [PATCH 10/27] refactor(server): use performance.now() in use-cache probe pool - Switch deadline calculation from Date.now() to performance.now() for higher-resolution timing in probe timeout tracking. - Remove dead comment about private caches that no longer applies. --- packages/vinext/src/server/use-cache-probe-pool.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/vinext/src/server/use-cache-probe-pool.ts b/packages/vinext/src/server/use-cache-probe-pool.ts index 1a97e50c6e..1779b845ba 100644 --- a/packages/vinext/src/server/use-cache-probe-pool.ts +++ b/packages/vinext/src/server/use-cache-probe-pool.ts @@ -52,7 +52,7 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir const { id, encodedArguments, request, timeoutMs } = msg; // Internal timeout so the probe aborts before the outer render timeout. - const deadline = Date.now() + timeoutMs; + const deadline = performance.now() + timeoutMs; let probeTimeoutTimer: ReturnType | undefined; setInsideUseCacheProbe(true); @@ -92,8 +92,6 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir // Wrap it with registerCachedFunction so the probe runs through the // same cache-runtime path (fresh ALS, no shared state). - // Private cache functions return before reaching the probe scheduling - // code, so kind can never be "private" here. Keep "" for safety. const variant = ""; const wrapped = registerCachedFunction( originalFn as (...args: unknown[]) => Promise, @@ -118,7 +116,7 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir // Run the function with a reconstructed request store so private caches // that read cookies()/headers()/draftMode() see the same values. // Race against the internal timeout. - const remaining = deadline - Date.now(); + const remaining = deadline - performance.now(); if (remaining <= 0) { return false; } From 5cf00c6ecc7cd3ef9c33227134a3d13963a1b8a6 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Tue, 12 May 2026 10:51:05 -0700 Subject: [PATCH 11/27] fix(shims/cache-runtime): review round 6 cleanups for use-cache probe - Replace unsafe rootParams type assertion with explicit narrowing - Lazy-construct UseCacheTimeoutError / UseCacheDeadlockError in dev mode - Hoist dev-only dynamic imports to a lazy singleton to cut per-call microtask overhead --- packages/vinext/src/shims/cache-runtime.ts | 54 +++++++++++++++------- 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index 2e1aaa86ab..17e42234f6 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -201,6 +201,30 @@ function getUseCacheKeySeed(): string | undefined { return getUseCacheDeploymentIdDefine() || getUseCacheBuildIdDefine(); } +// Lazy singleton for dev-only probe modules — eliminates microtask +// overhead on every "use cache" call after the first. +let _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; + isInsideUseCacheProbe: typeof import("./use-cache-probe-globals.js").isInsideUseCacheProbe; + } + | undefined; + +async function loadProbeModules() { + 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, + isInsideUseCacheProbe: globals.isInsideUseCacheProbe, + }; +} + 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}`; @@ -525,16 +549,12 @@ export function registerCachedFunction( const USE_CACHE_TIMEOUT_MS = 54_000; const fillDeadlineAt = performance.now() + USE_CACHE_TIMEOUT_MS; - const [ - { UseCacheTimeoutError, UseCacheDeadlockError }, - { getUseCacheProbe, isInsideUseCacheProbe }, - ] = await Promise.all([ - import("./use-cache-errors.js"), - import("./use-cache-probe-globals.js"), - ]); - - const timeoutError = new UseCacheTimeoutError(); - const deadlockError = new UseCacheDeadlockError(); + const { + UseCacheTimeoutError, + UseCacheDeadlockError, + getUseCacheProbe, + isInsideUseCacheProbe, + } = (_probeModules ??= await loadProbeModules()); let probeTimer: ReturnType | undefined; let timeoutTimer: ReturnType | undefined; @@ -555,10 +575,12 @@ export function registerCachedFunction( headers: headers ? Array.from(headers.entries()) : [], urlPathname: navCtx?.pathname ?? "/", urlSearch: navCtx?.searchParams?.toString() ?? "", - rootParams: (requestCtx.rootParams ?? {}) as Record< - string, - string | string[] | undefined - >, + rootParams: Object.fromEntries( + Object.entries(requestCtx.rootParams ?? {}).map(([k, v]) => [ + k, + typeof v === "string" || Array.isArray(v) ? v : undefined, + ]), + ) as Record, }; probePromise = new Promise((_, reject) => { @@ -581,7 +603,7 @@ export function registerCachedFunction( }).then( (completed) => { if (completed) { - reject(deadlockError); + reject(new UseCacheDeadlockError()); } else if (typeof console !== "undefined") { console.warn( `[vinext] "use cache" fill for ${id} has been idle for 10s. ` + @@ -598,7 +620,7 @@ export function registerCachedFunction( } const timeoutPromise = new Promise((_, reject) => { - timeoutTimer = setTimeout(() => reject(timeoutError), USE_CACHE_TIMEOUT_MS); + timeoutTimer = setTimeout(() => reject(new UseCacheTimeoutError()), USE_CACHE_TIMEOUT_MS); if (typeof (timeoutTimer as NodeJS.Timeout).unref === "function") { (timeoutTimer as NodeJS.Timeout).unref(); } From e2f69c87365e9b6dcbc5b8d0cf1ec6727d0cb32f Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Tue, 12 May 2026 11:15:07 -0700 Subject: [PATCH 12/27] refactor(server): lazy-load use-cache probe pool and document variant constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 7 follow-up: - use-cache-probe-pool.ts: Add comment explaining why — private caches return before the probe block in cache-runtime.ts, so the probe is only ever scheduled for shared caches where variant is always "". - index.ts: Convert static import of use-cache-probe-pool to lazy dynamic import via getProbePoolModule(). This avoids loading probe-globals and use-cache-errors modules during production builds where configureServer() is never called. Made configureServer async and updated call sites. --- packages/vinext/src/index.ts | 21 +++++++++++++++---- .../vinext/src/server/use-cache-probe-pool.ts | 3 +++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 83c07b2120..4948a388d7 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -22,7 +22,6 @@ import { isImageOptimizationPath } from "./server/image-optimization.js"; import { installSocketErrorBackstop } from "./server/socket-error-backstop.js"; import { shouldInvalidateAppRouteFile } from "./server/dev-route-files.js"; import { createDirectRunner } from "./server/dev-module-runner.js"; -import { initUseCacheProbePool, tearDownUseCacheProbePool } from "./server/use-cache-probe-pool.js"; import { generateRscEntry } from "./entries/app-rsc-entry.js"; import { generateSsrEntry } from "./entries/app-ssr-entry.js"; import { @@ -288,6 +287,17 @@ 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 _probePoolModule: typeof import("./server/use-cache-probe-pool.js") | null = null; +async function getProbePoolModule() { + if (!_probePoolModule) { + _probePoolModule = await import("./server/use-cache-probe-pool.js"); + } + return _probePoolModule; +} + type VitePluginReactModule = typeof import("@vitejs/plugin-react"); function resolveOptionalDependency(projectRoot: string, specifier: string): string | null { @@ -2933,7 +2943,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } }, - configureServer(server: ViteDevServer) { + async configureServer(server: ViteDevServer) { // Watch route files for additions/removals to invalidate route cache. const pageExtensions = fileMatcher.extensionRegex; @@ -2991,10 +3001,11 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } } - function invalidateAppRoutingModules() { + async function invalidateAppRoutingModules() { invalidateAppRouteCache(); invalidateRscEntryModule(); invalidateRootParamsModule(); + const { tearDownUseCacheProbePool, initUseCacheProbePool } = await getProbePoolModule(); // Tear down the use-cache probe pool so the next probe starts with // fresh code after HMR invalidation. tearDownUseCacheProbePool(); @@ -3141,7 +3152,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // "use cache" functions run inside the RSC module graph. const rscEnv = server.environments["rsc"]; if (rscEnv) { - initUseCacheProbePool(rscEnv); + getProbePoolModule() + .then(({ initUseCacheProbePool }) => initUseCacheProbePool(rscEnv)) + .catch(() => {}); } server.middlewares.use((req, res, next) => { diff --git a/packages/vinext/src/server/use-cache-probe-pool.ts b/packages/vinext/src/server/use-cache-probe-pool.ts index 1779b845ba..56b68caf60 100644 --- a/packages/vinext/src/server/use-cache-probe-pool.ts +++ b/packages/vinext/src/server/use-cache-probe-pool.ts @@ -92,6 +92,9 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir // Wrap it with registerCachedFunction so the probe runs through the // same cache-runtime path (fresh ALS, no shared state). + // Private caches return before the probe block in cache-runtime.ts, + // so the probe is only ever scheduled for shared caches where variant + // is always "". const variant = ""; const wrapped = registerCachedFunction( originalFn as (...args: unknown[]) => Promise, From 9bb67532f110e6ef95f566c555cc5936eab243bc Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Wed, 13 May 2026 21:42:32 -0700 Subject: [PATCH 13/27] fix(server): surface use-cache probe failures --- packages/vinext/src/index.ts | 4 +++- packages/vinext/src/shims/cache-runtime.ts | 1 - packages/vinext/src/shims/use-cache-probe-globals.ts | 1 - 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 4948a388d7..11f594db2a 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -3154,7 +3154,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { if (rscEnv) { getProbePoolModule() .then(({ initUseCacheProbePool }) => initUseCacheProbePool(rscEnv)) - .catch(() => {}); + .catch((err) => { + console.warn("[vinext] Failed to initialize use-cache probe pool:", err); + }); } server.middlewares.use((req, res, next) => { diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index 17e42234f6..631af80bfb 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -590,7 +590,6 @@ export function registerCachedFunction( probe({ id, - kind: cacheVariant, encodedArguments: (() => { try { return JSON.stringify(args); diff --git a/packages/vinext/src/shims/use-cache-probe-globals.ts b/packages/vinext/src/shims/use-cache-probe-globals.ts index 353f79b595..fb96c90e94 100644 --- a/packages/vinext/src/shims/use-cache-probe-globals.ts +++ b/packages/vinext/src/shims/use-cache-probe-globals.ts @@ -21,7 +21,6 @@ export type UseCacheProbeRequestSnapshot = { export type UseCacheProbe = (msg: { id: string; - kind: string; encodedArguments: string | FormData; request: UseCacheProbeRequestSnapshot; timeoutMs: number; From 00f736c7e801cc13565ff2292e1998e997c86f27 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Thu, 14 May 2026 14:59:51 -0700 Subject: [PATCH 14/27] fix(shims): address use-cache probe review nits --- .../vinext/src/server/use-cache-probe-pool.ts | 20 +++++------- packages/vinext/src/shims/cache-runtime.ts | 32 +++++++++++++------ .../src/shims/use-cache-probe-globals.ts | 2 +- tests/shims.test.ts | 1 + 4 files changed, 32 insertions(+), 23 deletions(-) diff --git a/packages/vinext/src/server/use-cache-probe-pool.ts b/packages/vinext/src/server/use-cache-probe-pool.ts index 56b68caf60..4d48fe04b5 100644 --- a/packages/vinext/src/server/use-cache-probe-pool.ts +++ b/packages/vinext/src/server/use-cache-probe-pool.ts @@ -28,7 +28,8 @@ let _probeEnvironment: DevEnvironmentLike | DevEnvironment | null = null; /** * Initialize the probe pool with the Vite dev environment. * - * Called once during configureServer() when the App Router dev server starts. + * 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) { @@ -92,10 +93,7 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir // Wrap it with registerCachedFunction so the probe runs through the // same cache-runtime path (fresh ALS, no shared state). - // Private caches return before the probe block in cache-runtime.ts, - // so the probe is only ever scheduled for shared caches where variant - // is always "". - const variant = ""; + const variant = ""; // Only shared caches reach the probe block. const wrapped = registerCachedFunction( originalFn as (...args: unknown[]) => Promise, id, @@ -107,13 +105,11 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir // For deadlock detection, the exact argument values matter less than // the fact that the function body executes with a fresh module scope. let args: unknown[] = []; - if (typeof encodedArguments === "string") { - try { - args = JSON.parse(encodedArguments); - if (!Array.isArray(args)) args = [args]; - } catch { - args = []; - } + try { + args = JSON.parse(encodedArguments); + if (!Array.isArray(args)) args = [args]; + } catch { + args = []; } // Run the function with a reconstructed request store so private caches diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index 631af80bfb..423a185193 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -201,18 +201,19 @@ function getUseCacheKeySeed(): string | undefined { return getUseCacheDeploymentIdDefine() || getUseCacheBuildIdDefine(); } +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; + isInsideUseCacheProbe: typeof import("./use-cache-probe-globals.js").isInsideUseCacheProbe; +}; + // Lazy singleton for dev-only probe modules — eliminates microtask // overhead on every "use cache" call after the first. -let _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; - isInsideUseCacheProbe: typeof import("./use-cache-probe-globals.js").isInsideUseCacheProbe; - } - | undefined; +let _probeModules: ProbeModules | undefined; +let _probeModulesPromise: Promise | undefined; -async function loadProbeModules() { +async function loadProbeModules(): Promise { const [errors, globals] = await Promise.all([ import("./use-cache-errors.js"), import("./use-cache-probe-globals.js"), @@ -225,6 +226,17 @@ async function loadProbeModules() { }; } +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}`; @@ -554,7 +566,7 @@ export function registerCachedFunction( UseCacheDeadlockError, getUseCacheProbe, isInsideUseCacheProbe, - } = (_probeModules ??= await loadProbeModules()); + } = await getProbeModules(); let probeTimer: ReturnType | undefined; let timeoutTimer: ReturnType | undefined; diff --git a/packages/vinext/src/shims/use-cache-probe-globals.ts b/packages/vinext/src/shims/use-cache-probe-globals.ts index fb96c90e94..39b09e8513 100644 --- a/packages/vinext/src/shims/use-cache-probe-globals.ts +++ b/packages/vinext/src/shims/use-cache-probe-globals.ts @@ -21,7 +21,7 @@ export type UseCacheProbeRequestSnapshot = { export type UseCacheProbe = (msg: { id: string; - encodedArguments: string | FormData; + encodedArguments: string; request: UseCacheProbeRequestSnapshot; timeoutMs: number; }) => Promise; diff --git a/tests/shims.test.ts b/tests/shims.test.ts index ce20c02f2d..9119886c41 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -5779,6 +5779,7 @@ describe("use-cache deadlock probe behavior", () => { await vi.advanceTimersByTimeAsync(10_000); // Flush the microtask that carries the deadlock rejection. await Promise.resolve(); + await Promise.resolve(); expect(isUseCacheDeadlockError(caughtError)).toBe(true); expect(caughtError?.message).toContain("shared state"); From 066cd325e2aaee9b3657772ac3dc210ed1139345 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Sat, 16 May 2026 16:13:23 -0700 Subject: [PATCH 15/27] fix(shims): address ask-bonk review nits for use-cache probe - Eliminate getProbeModules() microtask latency by checking _probeModules synchronously before awaiting (actionable item) - Make getProbePoolModule() return cached promise directly via ??=, removing unnecessary async/await boundary (non-blocking) - Replace hardcoded Promise.resolve() flushes with a resilient loop in deadlock probe test (non-blocking) --- packages/vinext/src/index.ts | 11 +++++------ packages/vinext/src/shims/cache-runtime.ts | 6 +++++- tests/shims.test.ts | 8 +++++--- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 11f594db2a..67dc89aa77 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -290,12 +290,11 @@ 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 _probePoolModule: typeof import("./server/use-cache-probe-pool.js") | null = null; -async function getProbePoolModule() { - if (!_probePoolModule) { - _probePoolModule = await import("./server/use-cache-probe-pool.js"); - } - return _probePoolModule; +let _probePoolModulePromise: Promise | null = + null; +function getProbePoolModule(): Promise { + _probePoolModulePromise ??= import("./server/use-cache-probe-pool.js"); + return _probePoolModulePromise; } type VitePluginReactModule = typeof import("@vitejs/plugin-react"); diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index 423a185193..7d46e01829 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -561,12 +561,16 @@ export function registerCachedFunction( const USE_CACHE_TIMEOUT_MS = 54_000; const fillDeadlineAt = performance.now() + USE_CACHE_TIMEOUT_MS; + let probeModules = _probeModules; + if (!probeModules) { + probeModules = await getProbeModules(); + } const { UseCacheTimeoutError, UseCacheDeadlockError, getUseCacheProbe, isInsideUseCacheProbe, - } = await getProbeModules(); + } = probeModules; let probeTimer: ReturnType | undefined; let timeoutTimer: ReturnType | undefined; diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 9119886c41..4f2850795e 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -5777,9 +5777,11 @@ describe("use-cache deadlock probe behavior", () => { // Advance to the 10 s probe timer. await vi.advanceTimersByTimeAsync(10_000); - // Flush the microtask that carries the deadlock rejection. - await Promise.resolve(); - await Promise.resolve(); + // 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"); From f82eacd08ebaafa1c588129736b8b084fdfcb61e Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Sat, 16 May 2026 16:28:43 -0700 Subject: [PATCH 16/27] fix(tests): use vi.stubEnv instead of direct process.env.NODE_ENV assignment Direct assignment to process.env.NODE_ENV is now a TS2540 type error (read-only property). Use Vitest's vi.stubEnv / vi.unstubAllEnvs instead. --- tests/shims.test.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 4f2850795e..0a1a67d247 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -5720,8 +5720,7 @@ describe("use-cache deadlock probe behavior", () => { // NODE_ENV, but probe-globals.js (imported above) is from the original // module evaluation. Both use Symbol.for on globalThis, so they still // share the same global state despite being different module instances. - const originalNodeEnv = process.env.NODE_ENV; - process.env.NODE_ENV = "development"; + vi.stubEnv("NODE_ENV", "development"); vi.resetModules(); const { registerCachedFunction } = @@ -5738,7 +5737,7 @@ describe("use-cache deadlock probe behavior", () => { expect(probeCalled).toBe(false); } finally { setInsideUseCacheProbe(false); - process.env.NODE_ENV = originalNodeEnv; + vi.unstubAllEnvs(); setUseCacheProbe(undefined); } }); @@ -5750,8 +5749,7 @@ describe("use-cache deadlock probe behavior", () => { // Install a probe that reports the function completed in isolation. setUseCacheProbe(async () => true); - const originalNodeEnv = process.env.NODE_ENV; - process.env.NODE_ENV = "development"; + vi.stubEnv("NODE_ENV", "development"); vi.useFakeTimers(); vi.resetModules(); @@ -5788,7 +5786,7 @@ describe("use-cache deadlock probe behavior", () => { } finally { resolveHung?.("cleanup"); vi.useRealTimers(); - process.env.NODE_ENV = originalNodeEnv; + vi.unstubAllEnvs(); setUseCacheProbe(undefined); } }); From e9cd15e56092ffe937b70aa8d0a7ebdeab203e8a Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Mon, 18 May 2026 10:58:46 -0700 Subject: [PATCH 17/27] fix: address review feedback on use-cache probe 1. Probe argument encoding now uses RSC encodeReply instead of JSON.stringify. The wire-format mirrors Next.js EncodedArgumentsForProbe (string | formdata with base64-encoded blobs). The probe runner decodes via decodeReply so thenable params/searchParams survive the round-trip, eliminating false- positive deadlocks. 2. Replaced process-global setInsideUseCacheProbe with request-scoped UnifiedRequestContext._probeDepth. The main request has depth 0 and schedules probes normally; the probe re-execution sets depth 1 on the isolated runner's context, preventing recursive probes. Concurrent requests no longer interfere with each other's probe scheduling. 3. Updated tests to use request context instead of the deprecated global flag. --- .../vinext/src/server/use-cache-probe-pool.ts | 66 ++++++-- packages/vinext/src/shims/cache-runtime.ts | 143 ++++++++++++------ .../src/shims/unified-request-context.ts | 10 ++ .../src/shims/use-cache-probe-globals.ts | 37 ++++- tests/shims.test.ts | 17 +-- 5 files changed, 193 insertions(+), 80 deletions(-) diff --git a/packages/vinext/src/server/use-cache-probe-pool.ts b/packages/vinext/src/server/use-cache-probe-pool.ts index 4d48fe04b5..209e459627 100644 --- a/packages/vinext/src/server/use-cache-probe-pool.ts +++ b/packages/vinext/src/server/use-cache-probe-pool.ts @@ -20,7 +20,8 @@ import type { DevEnvironment } from "vite"; import { createDirectRunner, type DevEnvironmentLike } from "./dev-module-runner.js"; import type { ModuleRunner } from "vite/module-runner"; -import { setUseCacheProbe, setInsideUseCacheProbe } from "vinext/shims/use-cache-probe-globals"; +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; @@ -56,7 +57,6 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir const deadline = performance.now() + timeoutMs; let probeTimeoutTimer: ReturnType | undefined; - setInsideUseCacheProbe(true); try { // Import the cache-runtime shim in the isolated runner. // The shim's registerCachedFunction will create fresh module-scope state. @@ -100,20 +100,17 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir variant, ); - // Decode the arguments (simple JSON fallback; RSC encodeReply is - // not available in the probe because we lack the client environment). - // For deadlock detection, the exact argument values matter less than - // the fact that the function body executes with a fresh module scope. - let args: unknown[] = []; - try { - args = JSON.parse(encodedArguments); - if (!Array.isArray(args)) args = [args]; - } catch { - args = []; + // 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) { @@ -141,7 +138,6 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir // fill is still stuck, this is evidence of a deadlock on shared state. return true; } finally { - setInsideUseCacheProbe(false); if (probeTimeoutTimer !== undefined) clearTimeout(probeTimeoutTimer); runner.close().catch(() => {}); } @@ -157,6 +153,49 @@ export function tearDownUseCacheProbePool(): void { setUseCacheProbe(undefined); } +/** + * 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 { + decodeReply: ( + data: string | FormData, + options?: { temporaryReferences?: unknown }, + ) => Promise; + }; + + if (encoded.kind === "string") { + const decoded = await rsc.decodeReply(encoded.data, { temporaryReferences: undefined }); + 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: undefined }); + 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. @@ -192,6 +231,7 @@ async function runWithProbeRequestStore( 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 7d46e01829..50fcb8e011 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -39,6 +39,7 @@ import { } from "./cache.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,7 +206,6 @@ 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; - isInsideUseCacheProbe: typeof import("./use-cache-probe-globals.js").isInsideUseCacheProbe; }; // Lazy singleton for dev-only probe modules — eliminates microtask @@ -222,7 +222,6 @@ async function loadProbeModules(): Promise { UseCacheTimeoutError: errors.UseCacheTimeoutError, UseCacheDeadlockError: errors.UseCacheDeadlockError, getUseCacheProbe: globals.getUseCacheProbe, - isInsideUseCacheProbe: globals.isInsideUseCacheProbe, }; } @@ -375,6 +374,39 @@ function resolveCacheLife(configs: CacheLifeConfig[]): CacheLifeConfig { return result; } +// --------------------------------------------------------------------------- +// Encode probe arguments for transport to the isolated runner +// --------------------------------------------------------------------------- + +/** + * Convert an `encodeReply` result (string | FormData) into a serializable + * wire-format that the probe runner can decode via `decodeReply`. + * This mirrors Next.js `EncodedArgumentsForProbe`: string replies are passed + * verbatim, FormData entries are sent as base64-encoded blobs so they survive + * JSON serialization. + */ +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 @@ -565,26 +597,19 @@ export function registerCachedFunction( if (!probeModules) { probeModules = await getProbeModules(); } - const { - UseCacheTimeoutError, - UseCacheDeadlockError, - getUseCacheProbe, - isInsideUseCacheProbe, - } = probeModules; + const { UseCacheTimeoutError, UseCacheDeadlockError, getUseCacheProbe } = probeModules; let probeTimer: ReturnType | undefined; let timeoutTimer: ReturnType | undefined; let probePromise: Promise | null = null; const probe = getUseCacheProbe(); - if (probe && !isInsideUseCacheProbe()) { - // Capture the current request store snapshot for the probe. - // getRequestContext() never returns null — it creates a default - // context when called outside a request scope. For deadlock detection, - // the exact headers/pathname matter less than the function body - // executing with a fresh module scope, so the default "/" pathname - // and empty headers are acceptable. - const requestCtx = getRequestContext(); + // Request-scoped guard: only schedule a probe from the outer + // request (_probeDepth === 0), not from inside a probe re-execution + // itself. This replaces the deprecated process-global counter which + // caused cross-request interference. + const requestCtx = getRequestContext(); + if (probe && (requestCtx._probeDepth ?? 0) === 0) { const headers = requestCtx.headersContext?.headers; const navCtx = requestCtx.serverContext; const requestSnapshot = { @@ -599,39 +624,59 @@ export function registerCachedFunction( ) as Record, }; - probePromise = new Promise((_, reject) => { - probeTimer = setTimeout(() => { - const probeInternalTimeoutMs = fillDeadlineAt - performance.now() - 1_000; - if (probeInternalTimeoutMs <= 0) return; - - probe({ - id, - encodedArguments: (() => { - try { - return JSON.stringify(args); - } catch { - return "[]"; - } - })(), - request: requestSnapshot, - timeoutMs: probeInternalTimeoutMs, - }).then( - (completed) => { - if (completed) { - reject(new UseCacheDeadlockError()); - } else if (typeof console !== "undefined") { - console.warn( - `[vinext] "use cache" fill for ${id} has been idle for 10s. ` + - `Probe was also inconclusive — will hard-timeout at 54s.`, - ); - } - }, - () => {}, - ); - }, 10_000); - }); - // Swallow rejection when execution wins the race. - probePromise.catch(() => {}); + // Encode probe args via the same RSC encodeReply used for cache-key + // generation so Promise-thenable params/searchParams survive the round + // trip (avoids false-positive deadlocks from empty `{}` JSON.stringify). + let encodedArgsForProbe: EncodedArgsForProbe | null = null; + try { + if (rsc) { + const tempRefs = rsc.createClientTemporaryReferenceSet(); + const processedArgs = args.length > 0 ? (unwrapThenableObjects(args) as unknown[]) : []; + const encoded = await rsc.encodeReply(processedArgs, { + temporaryReferences: tempRefs, + }); + encodedArgsForProbe = await encodedArgumentsToProbe(encoded); + } else { + // RSC not available in test environments — JSON fallback + encodedArgsForProbe = { + kind: "string", + data: JSON.stringify(args.length > 0 ? args : []), + }; + } + } catch { + // Failed to encode probe args — skip probe scheduling rather than + // send empty/wrong args and risk false-positive deadlock. + } + + if (encodedArgsForProbe) { + probePromise = new Promise((_, reject) => { + probeTimer = setTimeout(() => { + const probeInternalTimeoutMs = fillDeadlineAt - performance.now() - 1_000; + if (probeInternalTimeoutMs <= 0) return; + + probe({ + id, + encodedArguments: encodedArgsForProbe!, + request: requestSnapshot, + timeoutMs: probeInternalTimeoutMs, + }).then( + (completed) => { + if (completed) { + reject(new UseCacheDeadlockError()); + } else if (typeof console !== "undefined") { + console.warn( + `[vinext] "use cache" fill for ${id} has been idle for 10s. ` + + `Probe was also inconclusive — will hard-timeout at 54s.`, + ); + } + }, + () => {}, + ); + }, 10_000); + }); + // Swallow rejection when execution wins the race. + probePromise.catch(() => {}); + } } const timeoutPromise = new Promise((_, reject) => { diff --git a/packages/vinext/src/shims/unified-request-context.ts b/packages/vinext/src/shims/unified-request-context.ts index 3b9d35ed13..95dda5cbcc 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 & @@ -112,6 +121,7 @@ export function createRequestContext(opts?: Partial): Uni ssrHeadChildren: [], documentInitialHead: [], rootParams: null, + _probeDepth: 0, ...opts, }; } diff --git a/packages/vinext/src/shims/use-cache-probe-globals.ts b/packages/vinext/src/shims/use-cache-probe-globals.ts index 39b09e8513..78f476c28c 100644 --- a/packages/vinext/src/shims/use-cache-probe-globals.ts +++ b/packages/vinext/src/shims/use-cache-probe-globals.ts @@ -10,7 +10,11 @@ */ const SYMBOL = Symbol.for("vinext.dev.useCacheProbe"); -const INSIDE_PROBE_SYMBOL = Symbol.for("vinext.dev.useCacheProbe.inside"); + +// DEPRECATED: use UnifiedRequestContext._probeDepth instead. +// Kept for backwards compat so existing tests still compile. +// oxlint-disable-next-line no-unused-vars +const _INSIDE_PROBE_SYMBOL = Symbol.for("vinext.dev.useCacheProbe.inside"); export type UseCacheProbeRequestSnapshot = { headers: [string, string][]; @@ -19,9 +23,17 @@ export type UseCacheProbeRequestSnapshot = { rootParams: Record; }; +/** 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; - encodedArguments: string; + encodedArguments: EncodedArgsForProbe; request: UseCacheProbeRequestSnapshot; timeoutMs: number; }) => Promise; @@ -34,13 +46,22 @@ export function getUseCacheProbe(): UseCacheProbe | undefined { return (globalThis as Record)[SYMBOL] as UseCacheProbe | undefined; } -export function setInsideUseCacheProbe(value: boolean): void { - const current = ((globalThis as Record)[INSIDE_PROBE_SYMBOL] as number) || 0; - (globalThis as Record)[INSIDE_PROBE_SYMBOL] = value - ? current + 1 - : Math.max(0, current - 1); +/** + * @deprecated Use `getRequestContext()._probeDepth` instead. + * Kept for backwards compatibility — now a no-op. + */ +export function setInsideUseCacheProbe(_value: boolean): void { + // globalThis-based counter is deprecated because concurrent requests + // share globalThis, causing cross-request interference. The real guard + // is UnifiedRequestContext._probeDepth. } +/** + * @deprecated Use `(getRequestContext()._probeDepth ?? 0) > 0` instead. + * Kept for backwards compatibility — always returns false. + */ export function isInsideUseCacheProbe(): boolean { - return (((globalThis as Record)[INSIDE_PROBE_SYMBOL] as number) || 0) > 0; + // globalThis-based counter is deprecated because concurrent requests + // share globalThis, causing cross-request interference. + return false; } diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 0a1a67d247..747cbc8c84 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -5705,8 +5705,8 @@ describe("use-cache errors", () => { }); describe("use-cache deadlock probe behavior", () => { - it("does not schedule probe when isInsideUseCacheProbe is true", async () => { - const { setUseCacheProbe, setInsideUseCacheProbe } = + 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; @@ -5716,27 +5716,24 @@ describe("use-cache deadlock probe behavior", () => { }); // Ensure dev mode path is taken by resetting modules and setting NODE_ENV. - // Note: vi.resetModules() re-evaluates cache-runtime.js with the new - // NODE_ENV, but probe-globals.js (imported above) is from the original - // module evaluation. Both use Symbol.for on globalThis, so they still - // share the same global state despite being different module instances. 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", ""); - setInsideUseCacheProbe(true); - let result: unknown; + // A probe re-execution sets _probeDepth === 1 on its request context. + const probeCtx = createRequestContext({ _probeDepth: 1 }); try { - result = await cached(); + const result = await runWithRequestContext(probeCtx, () => cached()); expect(result).toBe("result"); expect(probeCalled).toBe(false); } finally { - setInsideUseCacheProbe(false); vi.unstubAllEnvs(); setUseCacheProbe(undefined); } From b408fb24053be55f70e9041ebd674db7af268289 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Tue, 19 May 2026 08:56:17 -0700 Subject: [PATCH 18/27] fix(shims): address ask-bonk parity review (#2, #3) - Add TODO documenting flat 10s timeout parity gap vs Next.js idle-stream monitoring - Skip redundant dev timeout/probe machinery when inside probe re-execution (_probeDepth > 0); probe pool already has its own Promise.race timeout - Remove dead deprecated code: _INSIDE_PROBE_SYMBOL, setInsideUseCacheProbe, isInsideUseCacheProbe -- never imported anywhere in the codebase --- packages/vinext/src/shims/cache-runtime.ts | 14 ++++++++++- .../src/shims/use-cache-probe-globals.ts | 25 ------------------- 2 files changed, 13 insertions(+), 26 deletions(-) diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index 50fcb8e011..668a62a357 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -609,7 +609,13 @@ export function registerCachedFunction( // itself. This replaces the deprecated process-global counter which // caused cross-request interference. const requestCtx = getRequestContext(); - if (probe && (requestCtx._probeDepth ?? 0) === 0) { + if ((requestCtx._probeDepth ?? 0) > 0) { + // Inside a probe re-execution — skip the dev timeout/probe machinery. + // The probe pool already has its own internal timeout via Promise.race. + return executeWithContext(fn, args, cacheVariant); + } + + if (probe) { const headers = requestCtx.headersContext?.headers; const navCtx = requestCtx.serverContext; const requestSnapshot = { @@ -650,6 +656,12 @@ export function registerCachedFunction( if (encodedArgsForProbe) { probePromise = new Promise((_, reject) => { + // TODO: Next.js tracks RSC stream chunk timestamps and only fires + // the probe after 10s of stream *idleness*. vinext uses a flat + // 10s timeout that fires regardless of progress, which can produce + // false-positive UseCacheDeadlockError for slow-but-streaming + // cache functions. Switch to idle-stream monitoring for parity. + // https://github.com/vercel/next.js/blob/canary/packages/next/src/server/use-cache/use-cache-probe.ts probeTimer = setTimeout(() => { const probeInternalTimeoutMs = fillDeadlineAt - performance.now() - 1_000; if (probeInternalTimeoutMs <= 0) return; diff --git a/packages/vinext/src/shims/use-cache-probe-globals.ts b/packages/vinext/src/shims/use-cache-probe-globals.ts index 78f476c28c..6bcfb1dfc3 100644 --- a/packages/vinext/src/shims/use-cache-probe-globals.ts +++ b/packages/vinext/src/shims/use-cache-probe-globals.ts @@ -11,11 +11,6 @@ const SYMBOL = Symbol.for("vinext.dev.useCacheProbe"); -// DEPRECATED: use UnifiedRequestContext._probeDepth instead. -// Kept for backwards compat so existing tests still compile. -// oxlint-disable-next-line no-unused-vars -const _INSIDE_PROBE_SYMBOL = Symbol.for("vinext.dev.useCacheProbe.inside"); - export type UseCacheProbeRequestSnapshot = { headers: [string, string][]; urlPathname: string; @@ -45,23 +40,3 @@ export function setUseCacheProbe(fn: UseCacheProbe | undefined): void { export function getUseCacheProbe(): UseCacheProbe | undefined { return (globalThis as Record)[SYMBOL] as UseCacheProbe | undefined; } - -/** - * @deprecated Use `getRequestContext()._probeDepth` instead. - * Kept for backwards compatibility — now a no-op. - */ -export function setInsideUseCacheProbe(_value: boolean): void { - // globalThis-based counter is deprecated because concurrent requests - // share globalThis, causing cross-request interference. The real guard - // is UnifiedRequestContext._probeDepth. -} - -/** - * @deprecated Use `(getRequestContext()._probeDepth ?? 0) > 0` instead. - * Kept for backwards compatibility — always returns false. - */ -export function isInsideUseCacheProbe(): boolean { - // globalThis-based counter is deprecated because concurrent requests - // share globalThis, causing cross-request interference. - return false; -} From 299ce73291eb52877019bf63237736abaab92441 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Tue, 19 May 2026 16:50:52 -0700 Subject: [PATCH 19/27] fix(shims): address probe review retry path --- packages/vinext/src/index.ts | 5 ++++- packages/vinext/src/shims/cache-runtime.ts | 20 +++++++++----------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 67dc89aa77..62db22fa24 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -293,7 +293,10 @@ const __dirname = import.meta.dirname; let _probePoolModulePromise: Promise | null = null; function getProbePoolModule(): Promise { - _probePoolModulePromise ??= import("./server/use-cache-probe-pool.js"); + _probePoolModulePromise ??= import("./server/use-cache-probe-pool.js").catch((error) => { + _probePoolModulePromise = null; + throw error; + }); return _probePoolModulePromise; } diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index 668a62a357..40a236edec 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -593,6 +593,15 @@ export function registerCachedFunction( const USE_CACHE_TIMEOUT_MS = 54_000; const fillDeadlineAt = performance.now() + USE_CACHE_TIMEOUT_MS; + // Request-scoped guard: only schedule a probe from the outer + // request (_probeDepth === 0), not from inside a probe re-execution + // itself. This mirrors Next.js useCacheProbeMode and skips all dev + // timeout/probe machinery inside the isolated probe runner. + const requestCtx = getRequestContext(); + if ((requestCtx._probeDepth ?? 0) > 0) { + return executeWithContext(fn, args, cacheVariant); + } + let probeModules = _probeModules; if (!probeModules) { probeModules = await getProbeModules(); @@ -604,17 +613,6 @@ export function registerCachedFunction( let probePromise: Promise | null = null; const probe = getUseCacheProbe(); - // Request-scoped guard: only schedule a probe from the outer - // request (_probeDepth === 0), not from inside a probe re-execution - // itself. This replaces the deprecated process-global counter which - // caused cross-request interference. - const requestCtx = getRequestContext(); - if ((requestCtx._probeDepth ?? 0) > 0) { - // Inside a probe re-execution — skip the dev timeout/probe machinery. - // The probe pool already has its own internal timeout via Promise.race. - return executeWithContext(fn, args, cacheVariant); - } - if (probe) { const headers = requestCtx.headersContext?.headers; const navCtx = requestCtx.serverContext; From a29995df708991b8d5dd62f9267f71b39eb0283a Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Mon, 8 Jun 2026 11:56:33 -0700 Subject: [PATCH 20/27] fix(shims): cast Promise.race result to TResult in dev probe block --- packages/vinext/src/shims/cache-runtime.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index 40a236edec..f4fd50a941 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -704,7 +704,7 @@ export function registerCachedFunction( if (probePromise) promises.push(probePromise); promises.push(timeoutPromise); - return Promise.race(promises).finally(() => { + return (Promise.race(promises) as Promise).finally(() => { if (probeTimer !== undefined) clearTimeout(probeTimer); if (timeoutTimer !== undefined) clearTimeout(timeoutTimer); }); From 6ac4309f78c25c3320732e1ef76ba2fd535d0f5a Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Mon, 8 Jun 2026 12:11:24 -0700 Subject: [PATCH 21/27] fix(lint): remove redundant union type and void floating promises --- packages/vinext/src/index.ts | 4 ++-- packages/vinext/src/server/request-pipeline.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 62db22fa24..80702a2ab4 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -3071,7 +3071,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { invalidateRouteCache(pagesDir); } if (hasAppDir && shouldInvalidateAppRouteFile(appDir, filePath, fileMatcher)) { - invalidateAppRoutingModules(); + void invalidateAppRoutingModules(); regenerateAppRouteTypes(); } }); @@ -3080,7 +3080,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { invalidateRouteCache(pagesDir); } if (hasAppDir && shouldInvalidateAppRouteFile(appDir, filePath, fileMatcher)) { - invalidateAppRoutingModules(); + void invalidateAppRoutingModules(); regenerateAppRouteTypes(); } }); diff --git a/packages/vinext/src/server/request-pipeline.ts b/packages/vinext/src/server/request-pipeline.ts index 242686ade9..a937d54106 100644 --- a/packages/vinext/src/server/request-pipeline.ts +++ b/packages/vinext/src/server/request-pipeline.ts @@ -635,7 +635,7 @@ export function filterInternalHeaders(headers: Headers): Headers { return filtered; } -function getRequestCf(request: Request): unknown | undefined { +function getRequestCf(request: Request): unknown { const cf = Reflect.get(request, "cf"); return cf === undefined ? undefined : cf; } From ec0c3595af3424cc4d90e8e839dbc18f8dfc7ee7 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Mon, 8 Jun 2026 13:10:02 -0700 Subject: [PATCH 22/27] docs(use-cache): address ask-bonk round-13 review comments - cache-runtime: note that 54s dev timeout fires unconditionally (even without a probe), matching Next.js dev behaviour - use-cache-errors: explain digest/type-guard machinery is for cross-module-graph detection; add TODO for dev-overlay wiring - use-cache-probe-pool: document that instanceof UseCacheTimeoutError correctness depends on _probeDepth guard short-circuiting before timeout setup in the isolated runner --- packages/vinext/src/server/use-cache-probe-pool.ts | 10 ++++++++++ packages/vinext/src/shims/cache-runtime.ts | 5 +++++ packages/vinext/src/shims/use-cache-errors.ts | 11 +++++++++++ 3 files changed, 26 insertions(+) diff --git a/packages/vinext/src/server/use-cache-probe-pool.ts b/packages/vinext/src/server/use-cache-probe-pool.ts index 209e459627..2b2214f0c7 100644 --- a/packages/vinext/src/server/use-cache-probe-pool.ts +++ b/packages/vinext/src/server/use-cache-probe-pool.ts @@ -131,6 +131,16 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir } catch (err) { // If the probe timed out, the result is inconclusive — the function // might genuinely hang even in isolation. + // + // Correctness note: this `instanceof UseCacheTimeoutError` works because + // the `_probeDepth: 1` guard in cache-runtime.ts (see + // `runWithRequestContext` call in `runWithProbeRequestStore`) causes the + // isolated runner's own cache-runtime to short-circuit *before* setting + // up its timeout, so it can never throw a different-class-identity + // UseCacheTimeoutError. If that guard were ever moved below the timeout + // setup in the isolated runner, an isolated-graph timeout would be a + // different class and this branch would misclassify it as a deadlock + // (return true). if (err instanceof UseCacheTimeoutError) { return false; } diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index f4fd50a941..be80b620c7 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -689,6 +689,11 @@ export function registerCachedFunction( } } + // The 54s timeout applies to every shared "use cache" fill in dev, + // regardless of whether a probe is installed (i.e. it fires even in + // Pages Router dev or when the probe pool failed to init). This is a + // behavioral change vs. the original `return executeWithContext(...)` dev + // path, but it matches Next.js's dev cache timeout and is intentional. const timeoutPromise = new Promise((_, reject) => { timeoutTimer = setTimeout(() => reject(new UseCacheTimeoutError()), USE_CACHE_TIMEOUT_MS); if (typeof (timeoutTimer as NodeJS.Timeout).unref === "function") { diff --git a/packages/vinext/src/shims/use-cache-errors.ts b/packages/vinext/src/shims/use-cache-errors.ts index 40312bb4af..9f55e48aea 100644 --- a/packages/vinext/src/shims/use-cache-errors.ts +++ b/packages/vinext/src/shims/use-cache-errors.ts @@ -32,6 +32,17 @@ export class UseCacheDeadlockError extends Error { } } +// 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" || From cedafcaa2c161c38e5b91200634043a88c297f3c Mon Sep 17 00:00:00 2001 From: James Date: Sun, 14 Jun 2026 03:57:59 +0100 Subject: [PATCH 23/27] fix(dev): harden use-cache deadlock probes --- packages/vinext/src/config/next-config.ts | 7 ++ packages/vinext/src/index.ts | 3 + .../vinext/src/server/use-cache-probe-pool.ts | 39 +++----- packages/vinext/src/shims/cache-runtime.ts | 80 ++++++++++------ .../src/shims/use-cache-probe-globals.ts | 2 + tests/next-config.test.ts | 10 ++ tests/shims.test.ts | 95 +++++++++++++++++++ 7 files changed, 181 insertions(+), 55 deletions(-) diff --git a/packages/vinext/src/config/next-config.ts b/packages/vinext/src/config/next-config.ts index 4c69861d80..99e0d1acd6 100644 --- a/packages/vinext/src/config/next-config.ts +++ b/packages/vinext/src/config/next-config.ts @@ -359,6 +359,8 @@ export type ResolvedNextConfig = { * segment payload. */ prefetchInlining: boolean; + /** Dev "use cache" fill timeout in seconds. Defaults to 54. */ + useCacheTimeout: number; redirects: NextRedirect[]; rewrites: { beforeFiles: NextRewrite[]; @@ -1280,6 +1282,7 @@ export async function resolveNextConfig( cacheComponents: false, gestureTransition: false, prefetchInlining: false, + useCacheTimeout: 54, redirects: [], rewrites: { beforeFiles: [], afterFiles: [], fallback: [] }, headers: [], @@ -1597,6 +1600,10 @@ export async function resolveNextConfig( cacheComponents: config.cacheComponents ?? false, 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 db0519a50d..5ee460d282 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -1385,6 +1385,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))` diff --git a/packages/vinext/src/server/use-cache-probe-pool.ts b/packages/vinext/src/server/use-cache-probe-pool.ts index 2b2214f0c7..c1004b7e58 100644 --- a/packages/vinext/src/server/use-cache-probe-pool.ts +++ b/packages/vinext/src/server/use-cache-probe-pool.ts @@ -51,7 +51,7 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir // createDirectRunner creates a fresh ModuleRunner with its own isolated // EvaluatedModules instance, which is exactly what we need for probes. const runner = createDirectRunner(env); - const { id, encodedArguments, request, timeoutMs } = msg; + const { id, kind, encodedArguments, request, timeoutMs } = msg; // Internal timeout so the probe aborts before the outer render timeout. const deadline = performance.now() + timeoutMs; @@ -93,7 +93,7 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir // Wrap it with registerCachedFunction so the probe runs through the // same cache-runtime path (fresh ALS, no shared state). - const variant = ""; // Only shared caches reach the probe block. + const variant = kind === "private" ? "private" : ""; const wrapped = registerCachedFunction( originalFn as (...args: unknown[]) => Promise, id, @@ -128,25 +128,11 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir ]); return true; - } catch (err) { - // If the probe timed out, the result is inconclusive — the function - // might genuinely hang even in isolation. - // - // Correctness note: this `instanceof UseCacheTimeoutError` works because - // the `_probeDepth: 1` guard in cache-runtime.ts (see - // `runWithRequestContext` call in `runWithProbeRequestStore`) causes the - // isolated runner's own cache-runtime to short-circuit *before* setting - // up its timeout, so it can never throw a different-class-identity - // UseCacheTimeoutError. If that guard were ever moved below the timeout - // setup in the isolated runner, an isolated-graph timeout would be a - // different class and this branch would misclassify it as a deadlock - // (return true). - if (err instanceof UseCacheTimeoutError) { - return false; - } - // The function threw — it ran to completion (with an error). If the main - // fill is still stuck, this is evidence of a deadlock on shared state. - 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); runner.close().catch(() => {}); @@ -174,14 +160,16 @@ async function decodeProbeArgs( ): 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: undefined }); + const decoded = await rsc.decodeReply(encoded.data, { temporaryReferences }); if (!Array.isArray(decoded)) return [decoded]; return decoded; } @@ -198,7 +186,7 @@ async function decodeProbeArgs( } } - const decoded = await rsc.decodeReply(formData, { temporaryReferences: undefined }); + const decoded = await rsc.decodeReply(formData, { temporaryReferences }); if (!Array.isArray(decoded)) return [decoded]; return decoded; } catch { @@ -217,6 +205,7 @@ async function runWithProbeRequestStore( urlPathname: string; urlSearch: string; rootParams: Record; + draftModeSecret?: string; }, fn: () => Promise, ): Promise { @@ -236,7 +225,9 @@ async function runWithProbeRequestStore( headers: new Headers(requestSnapshot.headers), }); - const headersContext = headersContextFromRequest(request); + const headersContext = headersContextFromRequest(request, { + draftModeSecret: requestSnapshot.draftModeSecret, + }); const ctx = createRequestContext({ headersContext, executionContext: null, diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index f515534179..31b96ca3bc 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -203,6 +203,16 @@ 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; @@ -549,37 +559,8 @@ export function registerCachedFunction( return fn(...args); } - // "use cache: private" uses per-request in-memory cache - if (cacheVariant === "private") { - const parentCtx = cacheContextStorage.getStore(); - if (parentCtx && parentCtx.variant !== "private") { - throwPrivateUseCacheInsidePublicUseCacheError(); - } - - if (typeof process !== "undefined" && process.env.VINEXT_PRERENDER === "1") { - // Next.js treats "use cache: private" as dynamic during prerendering: - // it is excluded from the static artifact and resolved per request. - // https://github.com/vercel/next.js/blob/canary/packages/next/src/server/use-cache/use-cache-wrapper.ts - markDynamicUsage(); - } - - const privateCache = _getPrivateState()._privateCache!; - const privateHit = privateCache.get(cacheKey); - if (privateHit !== undefined) { - // The private cache is heterogeneous across cached functions; the key - // includes this function's stable id, so a hit belongs to this TResult. - return privateHit as TResult; - } - - const result = await executeWithContext(fn, args, cacheVariant); - privateCache.set(cacheKey, result); - return result; - } - - // In dev mode, always execute fresh — skip shared cache lookup/storage. - // This ensures HMR changes are reflected immediately. - if (isDev) { - const useCacheTimeoutMs = 54_000; + const executeDevFill = async (): Promise => { + const useCacheTimeoutMs = getUseCacheTimeoutMs(); const fillDeadlineAt = performance.now() + useCacheTimeoutMs; const requestCtx = getRequestContext(); if ((requestCtx._probeDepth ?? 0) > 0) { @@ -606,6 +587,7 @@ export function registerCachedFunction( typeof value === "string" || Array.isArray(value) ? value : undefined, ]), ) as Record, + draftModeSecret: requestCtx.headersContext?.draftModeSecret, }; let encodedArgsForProbe: EncodedArgsForProbe | null = null; @@ -635,6 +617,7 @@ export function registerCachedFunction( probe({ id, + kind: cacheVariant, encodedArguments: encodedArgsForProbe, request: requestSnapshot, timeoutMs: probeInternalTimeoutMs, @@ -666,6 +649,41 @@ export function registerCachedFunction( 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(); + if (parentCtx && parentCtx.variant !== "private") { + throwPrivateUseCacheInsidePublicUseCacheError(); + } + + if (typeof process !== "undefined" && process.env.VINEXT_PRERENDER === "1") { + // Next.js treats "use cache: private" as dynamic during prerendering: + // it is excluded from the static artifact and resolved per request. + // https://github.com/vercel/next.js/blob/canary/packages/next/src/server/use-cache/use-cache-wrapper.ts + markDynamicUsage(); + } + + const privateCache = _getPrivateState()._privateCache!; + const privateHit = privateCache.get(cacheKey); + if (privateHit !== undefined) { + // The private cache is heterogeneous across cached functions; the key + // includes this function's stable id, so a hit belongs to this TResult. + return privateHit as TResult; + } + + const result = await (isDev + ? executeDevFill() + : executeWithContext(fn, args, cacheVariant)); + privateCache.set(cacheKey, result); + return result; + } + + // In dev mode, always execute fresh — skip shared cache lookup/storage. + // This ensures HMR changes are reflected immediately. + if (isDev) { + return executeDevFill(); } // Shared cache ("use cache" / "use cache: remote") diff --git a/packages/vinext/src/shims/use-cache-probe-globals.ts b/packages/vinext/src/shims/use-cache-probe-globals.ts index 6bcfb1dfc3..ab3ab8d775 100644 --- a/packages/vinext/src/shims/use-cache-probe-globals.ts +++ b/packages/vinext/src/shims/use-cache-probe-globals.ts @@ -16,6 +16,7 @@ export type UseCacheProbeRequestSnapshot = { urlPathname: string; urlSearch: string; rootParams: Record; + draftModeSecret?: string; }; /** Wire-format for encoded probe arguments */ @@ -28,6 +29,7 @@ export type EncodedArgsForProbe = export type UseCacheProbe = (msg: { id: string; + kind: string; encodedArguments: EncodedArgsForProbe; request: UseCacheProbeRequestSnapshot; timeoutMs: number; diff --git a/tests/next-config.test.ts b/tests/next-config.test.ts index a94b60ae7f..51fb52f023 100644 --- a/tests/next-config.test.ts +++ b/tests/next-config.test.ts @@ -17,6 +17,15 @@ import { PHASE_DEVELOPMENT_SERVER, } from "../packages/vinext/src/shims/constants.js"; +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-")); } @@ -1832,6 +1841,7 @@ describe("detectNextIntlConfig", () => { cacheComponents: 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 5e44c35153..50457323bc 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -6286,6 +6286,101 @@ describe("use-cache deadlock probe behavior", () => { 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; + setUseCacheProbe(async ({ kind, request }) => { + probedKind = kind; + probedDraftModeSecret = request.draftModeSecret; + 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", + }, + }); + 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"); + } 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(); + } + }); }); describe("replyToCacheKey deterministic hashing", () => { From 0344a496d5f54fb14c9bed4216ca59acbd5a38f3 Mon Sep 17 00:00:00 2001 From: James Date: Sun, 14 Jun 2026 04:07:47 +0100 Subject: [PATCH 24/27] fix(dev): preserve probe request search params --- packages/vinext/src/shims/cache-runtime.ts | 3 ++- tests/shims.test.ts | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index 31b96ca3bc..626edc3533 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -577,10 +577,11 @@ export function registerCachedFunction( 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: navCtx?.searchParams?.toString() ?? "", + urlSearch: search ? `?${search}` : "", rootParams: Object.fromEntries( Object.entries(requestCtx.rootParams ?? {}).map(([key, value]) => [ key, diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 50457323bc..5e3ab95c1f 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -6293,9 +6293,11 @@ describe("use-cache deadlock probe behavior", () => { 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; }); @@ -6325,6 +6327,11 @@ describe("use-cache deadlock probe behavior", () => { cookies: new Map(), draftModeSecret: "probe-draft-secret", }, + serverContext: { + pathname: "/private", + searchParams: new URLSearchParams({ mode: "probe" }), + params: {}, + }, }); const promise = runWithRequestContext(ctx, () => cached()); promise.catch(() => {}); @@ -6334,6 +6341,7 @@ describe("use-cache deadlock probe behavior", () => { expect(probedKind).toBe("private"); expect(probedDraftModeSecret).toBe("probe-draft-secret"); + expect(probedUrlSearch).toBe("?mode=probe"); } finally { resolveHung?.("cleanup"); vi.useRealTimers(); From 23f94c7e84b26783fe949298004d2eb914394d8e Mon Sep 17 00:00:00 2001 From: James Date: Sun, 14 Jun 2026 04:09:05 +0100 Subject: [PATCH 25/27] fix(dev): preserve use-cache probe variants --- .../vinext/src/server/use-cache-probe-pool.ts | 2 +- tests/shims.test.ts | 40 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/vinext/src/server/use-cache-probe-pool.ts b/packages/vinext/src/server/use-cache-probe-pool.ts index c1004b7e58..a683c88ffd 100644 --- a/packages/vinext/src/server/use-cache-probe-pool.ts +++ b/packages/vinext/src/server/use-cache-probe-pool.ts @@ -93,7 +93,7 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir // Wrap it with registerCachedFunction so the probe runs through the // same cache-runtime path (fresh ALS, no shared state). - const variant = kind === "private" ? "private" : ""; + const variant = kind; const wrapped = registerCachedFunction( originalFn as (...args: unknown[]) => Promise, id, diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 5e3ab95c1f..0d2a30c95c 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -6389,6 +6389,46 @@ describe("use-cache deadlock probe behavior", () => { 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", () => { From c9cebc329a30a79c18710022d35c159b25743bc9 Mon Sep 17 00:00:00 2001 From: James Date: Sun, 14 Jun 2026 04:18:28 +0100 Subject: [PATCH 26/27] fix(dev): preserve private page probe query args --- packages/vinext/src/shims/cache-runtime.ts | 6 +++- tests/shims.test.ts | 39 ++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index 626edc3533..d3b7f5fc87 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -510,7 +510,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 diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 0d2a30c95c..ed1b93b98e 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -5347,6 +5347,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"); From 422378a2a702484acbe090c2bcd798c57b0c847d Mon Sep 17 00:00:00 2001 From: James Date: Sun, 14 Jun 2026 04:25:41 +0100 Subject: [PATCH 27/27] fix(dev): reset use-cache probes across lifecycle --- packages/vinext/src/index.ts | 21 +++++++++++++++++++ .../vinext/src/server/use-cache-probe-pool.ts | 12 ++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 5ee460d282..b0df8b4380 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -3077,6 +3077,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { invalidateAppRouteCache(); 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. @@ -3088,6 +3092,12 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } } + function tearDownUseCacheProbePoolOnServerClose() { + void getProbePoolModule().then(({ tearDownUseCacheProbePool }) => { + tearDownUseCacheProbePool(); + }); + } + let appRouteTypeGeneration: Promise | null = null; let appRouteTypeGenerationPending = false; @@ -3154,6 +3164,17 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { regenerateAppRouteTypes(); } }); + 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 diff --git a/packages/vinext/src/server/use-cache-probe-pool.ts b/packages/vinext/src/server/use-cache-probe-pool.ts index a683c88ffd..3546498b5e 100644 --- a/packages/vinext/src/server/use-cache-probe-pool.ts +++ b/packages/vinext/src/server/use-cache-probe-pool.ts @@ -25,6 +25,7 @@ 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. @@ -33,11 +34,14 @@ let _probeEnvironment: DevEnvironmentLike | DevEnvironment | null = null; * and re-called after each HMR teardown cycle. */ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvironment): void { - if (_probeEnvironment) { + 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 @@ -51,6 +55,7 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir // 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. @@ -135,6 +140,7 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir return false; } finally { if (probeTimeoutTimer !== undefined) clearTimeout(probeTimeoutTimer); + _activeProbeRunners.delete(runner); runner.close().catch(() => {}); } }); @@ -147,6 +153,10 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir export function tearDownUseCacheProbePool(): void { _probeEnvironment = null; setUseCacheProbe(undefined); + for (const runner of _activeProbeRunners) { + runner.close().catch(() => {}); + } + _activeProbeRunners.clear(); } /**