Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
1cccd4b
feat(dev): detect 'use cache' module-scope deadlocks early in dev (#1…
Divkix May 11, 2026
8ab447b
ci: fix benchmarks/vinext tsconfig missing vinext path and remove red…
Divkix May 11, 2026
e7cc4b4
chore: remove unused tinypool dependency causing knip failure
Divkix May 11, 2026
538e3ad
fix(tests): remove unused use-cache deadlock fixtures causing static …
Divkix May 11, 2026
9e9624a
fix(dev): address use-cache probe review issues
Divkix May 11, 2026
35f0da7
fix(use-cache): address all review issues for deadlock probe
Divkix May 11, 2026
d888012
fix(use-cache): address v3 review feedback on deadlock probe
Divkix May 11, 2026
9bb642e
fix(use-cache): address v4 review feedback on deadlock probe
Divkix May 11, 2026
11705f4
fix(use-cache): address v5 review feedback on deadlock probe
Divkix May 11, 2026
abb5c21
refactor(server): use performance.now() in use-cache probe pool
Divkix May 12, 2026
5cf00c6
fix(shims/cache-runtime): review round 6 cleanups for use-cache probe
Divkix May 12, 2026
e2f69c8
refactor(server): lazy-load use-cache probe pool and document variant…
Divkix May 12, 2026
9bb6753
fix(server): surface use-cache probe failures
Divkix May 14, 2026
00f736c
fix(shims): address use-cache probe review nits
Divkix May 14, 2026
066cd32
fix(shims): address ask-bonk review nits for use-cache probe
Divkix May 16, 2026
f82eacd
fix(tests): use vi.stubEnv instead of direct process.env.NODE_ENV ass…
Divkix May 16, 2026
e9cd15e
fix: address review feedback on use-cache probe
Divkix May 18, 2026
b408fb2
fix(shims): address ask-bonk parity review (#2, #3)
Divkix May 19, 2026
299ce73
fix(shims): address probe review retry path
Divkix May 19, 2026
a29995d
fix(shims): cast Promise.race result to TResult in dev probe block
Divkix Jun 8, 2026
6ac4309
fix(lint): remove redundant union type and void floating promises
Divkix Jun 8, 2026
ec0c359
docs(use-cache): address ask-bonk round-13 review comments
Divkix Jun 8, 2026
b1b6873
Merge branch 'main' into fix/issue-1009
Divkix Jun 11, 2026
5eefe5f
Merge branch 'main' into fix/issue-1009
Divkix Jun 12, 2026
6ad24aa
merge: refresh use-cache deadlock detection
james-elicx Jun 14, 2026
cedafca
fix(dev): harden use-cache deadlock probes
james-elicx Jun 14, 2026
0344a49
fix(dev): preserve probe request search params
james-elicx Jun 14, 2026
23f94c7
fix(dev): preserve use-cache probe variants
james-elicx Jun 14, 2026
c9cebc3
fix(dev): preserve private page probe query args
james-elicx Jun 14, 2026
422378a
fix(dev): reset use-cache probes across lifecycle
james-elicx Jun 14, 2026
5385eab
Merge remote-tracking branch 'origin/main' into fix/issue-1009
Divkix Jun 18, 2026
2209579
Merge branch 'main' into fix/issue-1009
Divkix Jun 21, 2026
fa25fea
Merge remote-tracking branch 'origin/main' into fix/issue-1009-2
Divkix Jul 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/vinext/src/config/next-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,8 @@ export type ResolvedNextConfig = {
* and partial object config into concrete thresholds.
*/
prefetchInlining: PrefetchInliningConfig;
/** Dev "use cache" fill timeout in seconds. Defaults to 54. */
useCacheTimeout: number;
redirects: NextRedirect[];
rewrites: {
beforeFiles: NextRewrite[];
Expand Down Expand Up @@ -1540,6 +1542,7 @@ export async function resolveNextConfig(
appNavFailHandling: false,
gestureTransition: false,
prefetchInlining: false,
useCacheTimeout: 54,
redirects: [],
rewrites: { beforeFiles: [], afterFiles: [], fallback: [] },
headers: [],
Expand Down Expand Up @@ -1874,6 +1877,10 @@ export async function resolveNextConfig(
appNavFailHandling: experimental?.appNavFailHandling === true,
gestureTransition: experimental?.gestureTransition === true,
prefetchInlining,
useCacheTimeout:
typeof experimental?.useCacheTimeout === "number" && experimental.useCacheTimeout > 0
? experimental.useCacheTimeout
: 54,
redirects,
rewrites,
headers,
Expand Down
66 changes: 62 additions & 4 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,19 @@ function hasServerOnlyMarkerImport(code: string): boolean {
}

const __dirname = import.meta.dirname;

// Lazy load the use-cache probe pool so it is only loaded when the dev
// server starts (configureServer), not during production builds.
let _probePoolModulePromise: Promise<typeof import("./server/use-cache-probe-pool.js")> | null =
null;
function getProbePoolModule(): Promise<typeof import("./server/use-cache-probe-pool.js")> {
_probePoolModulePromise ??= import("./server/use-cache-probe-pool.js").catch((error) => {
_probePoolModulePromise = null;
throw error;
});
return _probePoolModulePromise;
}

type VitePluginReactModule = typeof import("@vitejs/plugin-react");

function resolveOptionalDependency(projectRoot: string, specifier: string): string | null {
Expand Down Expand Up @@ -2133,6 +2146,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
defines["process.env.__VINEXT_DEPLOYMENT_ID"] = JSON.stringify(
nextConfig.deploymentId ?? "",
);
defines["process.env.__VINEXT_USE_CACHE_TIMEOUT_MS"] = JSON.stringify(
nextConfig.useCacheTimeout * 1_000,
);
// Public `process.env.NEXT_DEPLOYMENT_ID` — Next.js statically inlines
// this into client (and web worker) bundles via its DefinePlugin so
// that user code like `new Worker(new URL('./w.ts', import.meta.url))`
Expand Down Expand Up @@ -4099,7 +4115,7 @@ export const loadServerActionClient = ${
},
},

configureServer(server: ViteDevServer) {
async configureServer(server: ViteDevServer) {
// Watch route files for additions/removals to invalidate route cache.
const pageExtensions = fileMatcher.extensionRegex;

Expand Down Expand Up @@ -4194,11 +4210,30 @@ export const loadServerActionClient = ${
pagesRunner?.clearCache();
}

function invalidateAppRoutingModules() {
async function invalidateAppRoutingModules() {
invalidateAppRouteCache();
invalidateMetadataFileCache();
invalidateRscEntryModule();
invalidateRootParamsModule();
await resetUseCacheProbePool();
}

async function resetUseCacheProbePool() {
const { tearDownUseCacheProbePool, initUseCacheProbePool } = await getProbePoolModule();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Latency on HMR invalidation. invalidateAppRoutingModules is now async because it awaits getProbePoolModule(). The three synchronous invalidation calls above (lines 2064-2066) run immediately, but the probe teardown+reinit is deferred to the next microtask. This means there's a brief window where routes are invalidated but the old probe closure is still active.

In practice this is fine — the old probe closure captured env locally and will still work correctly if it fires during this window. Just noting the ordering for future readers.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The await getProbePoolModule() here makes invalidateAppRoutingModules async, but the three synchronous invalidation calls above (lines 2064-2066) still run immediately. After the first call, _probePoolModule is cached so the await resolves in one tick. The ordering is safe.

One subtlety: if getProbePoolModule() rejects (e.g., import error), the tearDownUseCacheProbePool() call is skipped and the old probe closure remains active. The .catch() handler at the call sites (lines 2095, 2105) will log the error but the stale probe closure could fire on the next deadlocked call with potentially outdated module references. In practice, if the dynamic import fails once, it'll likely fail on every subsequent HMR cycle too, so this is a terminal failure mode anyway.

Non-blocking.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: invalidateAppRoutingModules is now async, but the three synchronous invalidation calls above (lines 2225-2227) still execute synchronously and immediately. The probe teardown/reinit is deferred to after getProbePoolModule() resolves. This ordering is fine — the synchronous invalidations don't depend on the probe pool, and after the first call _probePoolModulePromise is cached so the await resolves in one tick.

One subtlety worth noting for future maintainers: if getProbePoolModule() rejects (e.g., import error), tearDownUseCacheProbePool() is skipped and the old probe closure remains active. The .catch() handler at the call sites (lines 2256, 2266) will log the error, which is sufficient for a dev-only diagnostic.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Note: making invalidateAppRoutingModules async changes error handling for the synchronous calls. The three calls on lines 2225-2227 (invalidateAppRouteCache, invalidateRscEntryModule, invalidateRootParamsModule) are synchronous. Previously, if they threw, the error would propagate to the watcher callback. Now that this function is async, a throw from any of them becomes a rejected promise, caught only by the .catch() at the call sites (lines 2256, 2266).

This is fine since the call sites do log the error, but the behavior change is worth being aware of — previously a throw from invalidateAppRouteCache would crash the watcher handler (visible in the console); now it's a console.warn.

// Tear down the use-cache probe pool so the next probe starts with
// fresh code after HMR invalidation.
tearDownUseCacheProbePool();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Critical: probe permanently disabled after first HMR. tearDownUseCacheProbePool() nulls _probeEnvironment and clears the probe function. But initUseCacheProbePool() is only called once (line 2146), not after each teardown. After the first file change, the probe is permanently gone.

Either re-initialize here:

Suggested change
tearDownUseCacheProbePool();
tearDownUseCacheProbePool();
// Re-initialize so probes continue working after HMR.
const rscEnv = server.environments["rsc"];
if (rscEnv) {
initUseCacheProbePool(rscEnv);
}

Or restructure tearDownUseCacheProbePool so it doesn't unset the probe function — since the environment reference is the same, you could just trust that fresh runners (created per-probe) already give you fresh code.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The teardown+reinit pattern looks correct now. tearDownUseCacheProbePool() nulls _probeEnvironment and clears the probe, then initUseCacheProbePool() sees _probeEnvironment === null, re-assigns it, and re-installs the probe closure.

One thing to watch: if invalidateAppRoutingModules() is called frequently during rapid saves, each call creates a new probe closure. The old closure is garbage-collected since setUseCacheProbe() overwrites the globalThis symbol. Any in-flight probe from the old closure will still hold a reference to _probeEnvironment via the closure capture (the _probeEnvironment! on line 47 of use-cache-probe-pool.ts). Since _probeEnvironment is reassigned to null then immediately back to the same rscEnv, this is fine — but if the environment could change, the captured null would be a problem.

// Re-initialize so probes continue working after HMR.
const rscEnv = server.environments["rsc"];
if (rscEnv) {
initUseCacheProbePool(rscEnv);
}
Comment on lines +4213 to +4230

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

One thing to watch: if getProbePoolModule() rejects (transient import failure), the catch((err) => ...) at the call sites logs a warning but tearDownUseCacheProbePool() is never called. The old probe closure from before the HMR cycle remains active on globalThis. This means the old closure (which captured env locally) will still fire for the next deadlocked call.

This is actually fine — the captured env is the same object, and the old probe closure creates fresh runners per probe, so there's no stale-code issue. But the initUseCacheProbePool guard (line 36) means the next successful invalidateAppRoutingModules will see _probeEnvironment still set from the previous init and skip re-initialization. The teardown from that cycle will null it and the subsequent init will proceed, so it self-corrects after one extra cycle.

Non-blocking — the retry-on-error pattern in getProbePoolModule() (line 155-157) handles the transient case correctly.

}

function tearDownUseCacheProbePoolOnServerClose() {
void getProbePoolModule().then(({ tearDownUseCacheProbePool }) => {
tearDownUseCacheProbePool();
});
}

let hybridRouteValidation: Promise<void> = Promise.resolve();
Expand Down Expand Up @@ -4341,7 +4376,7 @@ export const loadServerActionClient = ${
routeChanged = true;
}
if (hasAppDir && shouldInvalidateAppRouteFile(appDir, filePath, fileMatcher)) {
invalidateAppRoutingModules();
void invalidateAppRoutingModules();
regenerateAppRouteTypes();
routeChanged = true;
}
Expand Down Expand Up @@ -4382,7 +4417,7 @@ export const loadServerActionClient = ${
routeChanged = true;
}
if (hasAppDir && shouldInvalidateAppRouteFile(appDir, filePath, fileMatcher)) {
invalidateAppRoutingModules();
void invalidateAppRoutingModules();
regenerateAppRouteTypes();
routeChanged = true;
}
Expand All @@ -4393,6 +4428,17 @@ export const loadServerActionClient = ${
revalidateHybridRoutes();
}
});
server.watcher.on("change", (filePath: string) => {
if (
hasAppDir &&
isInsideDirectory(appDir, filePath) &&
fileMatcher.extensionRegex.test(filePath)
) {
void resetUseCacheProbePool();
}
});
server.watcher.once("close", tearDownUseCacheProbePoolOnServerClose);
server.httpServer?.once("close", tearDownUseCacheProbePoolOnServerClose);

// ── Dev request origin check ─────────────────────────────────────
// Registered directly (not in the returned function) so it runs
Expand Down Expand Up @@ -4527,6 +4573,18 @@ export const loadServerActionClient = ${
// it is flushed to the client.
// 2. Logs the full request after res finishes, using those timings.
if (hasAppDir) {
// Initialize the "use cache" deadlock probe pool for the App
// Router dev server. We bind to the RSC environment because
// "use cache" functions run inside the RSC module graph.
const rscEnv = server.environments["rsc"];
if (rscEnv) {
getProbePoolModule()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor: this is fire-and-forget, so the first request arriving before the probe pool finishes initializing will silently skip deadlock detection. This is fine for a dev-only diagnostic — just noting it for awareness. The error logging in .catch() is the right pattern here (good fix from a prior round).

.then(({ initUseCacheProbePool }) => initUseCacheProbePool(rscEnv))
.catch((err) => {
console.warn("[vinext] Failed to initialize use-cache probe pool:", err);
});
}

server.middlewares.use((req, res, next) => {
const url = req.url ?? "/";
// Skip Vite internals, HMR, and static assets.
Expand Down
2 changes: 1 addition & 1 deletion packages/vinext/src/server/dev-module-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading