diff --git a/packages/vinext/src/build/prerender.ts b/packages/vinext/src/build/prerender.ts index 76bb4d67..63a38039 100644 --- a/packages/vinext/src/build/prerender.ts +++ b/packages/vinext/src/build/prerender.ts @@ -976,7 +976,7 @@ export async function prerenderApp({ try { // Invoke RSC handler directly with a synthetic Request. // Each request is wrapped in its own ALS context via runWithHeadersContext - // so per-request state (dynamicUsageDetected, headersContext, etc.) is + // so per-request state (dynamicUsageCount, headersContext, etc.) is // isolated and never bleeds into other renders or into _fallbackState. // // NOTE: for Cloudflare Workers builds `rscHandler` is a thin HTTP proxy diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 93070ba3..b63a61da 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -495,6 +495,48 @@ const __isrDebug = process.env.NEXT_PRIVATE_DEBUG_CACHE ? console.debug.bind(console, "[vinext] ISR:") : undefined; +// Track route handler patterns that have used dynamic APIs (headers, cookies, +// searchParams). On first request (cache MISS), the handler runs and we detect +// dynamic usage. On subsequent requests, we skip the cache read for handlers +// in this set, matching Next.js behavior where dynamic handlers are never cached. +/** @type {Set} */ +const __dynamicRouteHandlers = new Set(); + +// Wrap the Request passed to route handlers in a Proxy that detects dynamic +// API access. Accessing request.headers or request.nextUrl.searchParams marks +// the handler as dynamic, preventing ISR caching. +function __proxyRouteRequest(req, markDynamic) { + let _nextUrl; + return new Proxy(req, { + get(target, prop, receiver) { + if (prop === "headers") { + markDynamic(); + return target.headers; + } + if (prop === "nextUrl") { + if (!_nextUrl) { + const realUrl = new URL(target.url); + _nextUrl = new Proxy(realUrl, { + get(urlTarget, urlProp) { + if (urlProp === "searchParams") markDynamic(); + const val = Reflect.get(urlTarget, urlProp); + return typeof val === "function" ? val.bind(urlTarget) : val; + }, + }); + } + return _nextUrl; + } + // Bind methods to the original Request to preserve internal slots. + // Web API objects like Request use internal slots for .json(), + // .text(), .arrayBuffer(), etc. that break when called through + // a Proxy (the Proxy becomes the receiver instead of the real Request). + const value = Reflect.get(target, prop); + if (typeof value === "function") return value.bind(target); + return value; + }, + }); +} + // Normalize null-prototype objects from matchPattern() into thenable objects // that work both as Promises (for Next.js 15+ async params) and as plain // objects with synchronous property access (for pre-15 code like params.id). @@ -2257,11 +2299,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // ISR cache read for route handlers (production only). // Only GET/HEAD (auto-HEAD) with finite revalidate > 0 are ISR-eligible. - // This runs before handler execution so a cache HIT skips the handler entirely. + // Skip cache read if this handler was previously detected as dynamic. if ( process.env.NODE_ENV === "production" && revalidateSeconds !== null && handler.dynamic !== "force-dynamic" && + !__dynamicRouteHandlers.has(handler.pattern) && (method === "GET" || isAutoHead) && typeof handlerFn === "function" ) { @@ -2299,11 +2342,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { await _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); setNavigationContext({ pathname: cleanPathname, searchParams: __revalSearchParams, params: __revalParams }); - const __syntheticReq = new Request(__revalUrl, { method: "GET" }); + const __syntheticReq = __proxyRouteRequest( + new Request(__revalUrl, { method: "GET" }), + markDynamicUsage, + ); const __revalResponse = await __revalHandlerFn(__syntheticReq, { params: __revalParams }); const __regenDynamic = consumeDynamicUsage(); setNavigationContext(null); if (__regenDynamic) { + __dynamicRouteHandlers.add(handler.pattern); __isrDebug?.("route regen skipped (dynamic usage)", cleanPathname); return; } @@ -2336,11 +2383,17 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (typeof handlerFn === "function") { const previousHeadersPhase = setHeadersAccessPhase("route-handler"); + const __proxiedRequest = __proxyRouteRequest(request, markDynamicUsage); try { const response = await handlerFn(request, { params }); const dynamicUsedInHandler = consumeDynamicUsage(); const handlerSetCacheControl = response.headers.has("cache-control"); + // Remember this handler as dynamic so future requests skip cache read. + if (dynamicUsedInHandler) { + __dynamicRouteHandlers.add(handler.pattern); + } + // Apply Cache-Control from route segment config (export const revalidate = N). // Runtime request APIs like headers() / cookies() make GET handlers dynamic, // so only attach ISR headers when the handler stayed static. diff --git a/packages/vinext/src/shims/headers.ts b/packages/vinext/src/shims/headers.ts index 170fcaf6..fa3b763d 100644 --- a/packages/vinext/src/shims/headers.ts +++ b/packages/vinext/src/shims/headers.ts @@ -34,7 +34,7 @@ export type HeadersAccessPhase = "render" | "action" | "route-handler"; export type VinextHeadersShimState = { headersContext: HeadersContext | null; - dynamicUsageDetected: boolean; + dynamicUsageCount: number; pendingSetCookies: string[]; draftModeCookieHeader: string | null; phase: HeadersAccessPhase; @@ -55,7 +55,7 @@ const _als = (_g[_ALS_KEY] ??= const _fallbackState = (_g[_FALLBACK_KEY] ??= { headersContext: null, - dynamicUsageDetected: false, + dynamicUsageCount: 0, pendingSetCookies: [], draftModeCookieHeader: null, phase: "render", @@ -81,7 +81,11 @@ function _getState(): VinextHeadersShimState { * Called by connection(), cookies(), headers(), and noStore(). */ export function markDynamicUsage(): void { - _getState().dynamicUsageDetected = true; + _getState().dynamicUsageCount++; +} + +export function getDynamicUsageCount(): number { + return _getState().dynamicUsageCount; } // --------------------------------------------------------------------------- @@ -137,8 +141,8 @@ export function throwIfInsideCacheScope(apiName: string): void { */ export function consumeDynamicUsage(): boolean { const state = _getState(); - const used = state.dynamicUsageDetected; - state.dynamicUsageDetected = false; + const used = state.dynamicUsageCount > 0; + state.dynamicUsageCount = 0; return used; } @@ -182,7 +186,7 @@ export function setHeadersContext(ctx: HeadersContext | null): void { const state = _getState(); if (ctx !== null) { state.headersContext = ctx; - state.dynamicUsageDetected = false; + state.dynamicUsageCount = 0; state.pendingSetCookies = []; state.draftModeCookieHeader = null; state.phase = "render"; @@ -209,7 +213,7 @@ export function runWithHeadersContext( if (isInsideUnifiedScope()) { return runWithUnifiedStateMutation((uCtx) => { uCtx.headersContext = ctx; - uCtx.dynamicUsageDetected = false; + uCtx.dynamicUsageCount = 0; uCtx.pendingSetCookies = []; uCtx.draftModeCookieHeader = null; uCtx.phase = "render"; @@ -218,7 +222,7 @@ export function runWithHeadersContext( const state: VinextHeadersShimState = { headersContext: ctx, - dynamicUsageDetected: false, + dynamicUsageCount: 0, pendingSetCookies: [], draftModeCookieHeader: null, phase: "render", diff --git a/packages/vinext/src/shims/unified-request-context.ts b/packages/vinext/src/shims/unified-request-context.ts index eab6aa47..911ca01e 100644 --- a/packages/vinext/src/shims/unified-request-context.ts +++ b/packages/vinext/src/shims/unified-request-context.ts @@ -81,7 +81,7 @@ function _getInheritedExecutionContext(): ExecutionContextLike | null { export function createRequestContext(opts?: Partial): UnifiedRequestContext { return { headersContext: null, - dynamicUsageDetected: false, + dynamicUsageCount: 0, pendingSetCookies: [], draftModeCookieHeader: null, phase: "render", diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index f9ff613c..30e078c6 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -544,6 +544,48 @@ const __isrDebug = process.env.NEXT_PRIVATE_DEBUG_CACHE ? console.debug.bind(console, "[vinext] ISR:") : undefined; +// Track route handler patterns that have used dynamic APIs (headers, cookies, +// searchParams). On first request (cache MISS), the handler runs and we detect +// dynamic usage. On subsequent requests, we skip the cache read for handlers +// in this set, matching Next.js behavior where dynamic handlers are never cached. +/** @type {Set} */ +const __dynamicRouteHandlers = new Set(); + +// Wrap the Request passed to route handlers in a Proxy that detects dynamic +// API access. Accessing request.headers or request.nextUrl.searchParams marks +// the handler as dynamic, preventing ISR caching. +function __proxyRouteRequest(req, markDynamic) { + let _nextUrl; + return new Proxy(req, { + get(target, prop, receiver) { + if (prop === "headers") { + markDynamic(); + return target.headers; + } + if (prop === "nextUrl") { + if (!_nextUrl) { + const realUrl = new URL(target.url); + _nextUrl = new Proxy(realUrl, { + get(urlTarget, urlProp) { + if (urlProp === "searchParams") markDynamic(); + const val = Reflect.get(urlTarget, urlProp); + return typeof val === "function" ? val.bind(urlTarget) : val; + }, + }); + } + return _nextUrl; + } + // Bind methods to the original Request to preserve internal slots. + // Web API objects like Request use internal slots for .json(), + // .text(), .arrayBuffer(), etc. that break when called through + // a Proxy (the Proxy becomes the receiver instead of the real Request). + const value = Reflect.get(target, prop); + if (typeof value === "function") return value.bind(target); + return value; + }, + }); +} + // Normalize null-prototype objects from matchPattern() into thenable objects // that work both as Promises (for Next.js 15+ async params) and as plain // objects with synchronous property access (for pre-15 code like params.id). @@ -2274,11 +2316,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // ISR cache read for route handlers (production only). // Only GET/HEAD (auto-HEAD) with finite revalidate > 0 are ISR-eligible. - // This runs before handler execution so a cache HIT skips the handler entirely. + // Skip cache read if this handler was previously detected as dynamic. if ( process.env.NODE_ENV === "production" && revalidateSeconds !== null && handler.dynamic !== "force-dynamic" && + !__dynamicRouteHandlers.has(handler.pattern) && (method === "GET" || isAutoHead) && typeof handlerFn === "function" ) { @@ -2316,11 +2359,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { await _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); setNavigationContext({ pathname: cleanPathname, searchParams: __revalSearchParams, params: __revalParams }); - const __syntheticReq = new Request(__revalUrl, { method: "GET" }); + const __syntheticReq = __proxyRouteRequest( + new Request(__revalUrl, { method: "GET" }), + markDynamicUsage, + ); const __revalResponse = await __revalHandlerFn(__syntheticReq, { params: __revalParams }); const __regenDynamic = consumeDynamicUsage(); setNavigationContext(null); if (__regenDynamic) { + __dynamicRouteHandlers.add(handler.pattern); __isrDebug?.("route regen skipped (dynamic usage)", cleanPathname); return; } @@ -2353,11 +2400,17 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (typeof handlerFn === "function") { const previousHeadersPhase = setHeadersAccessPhase("route-handler"); + const __proxiedRequest = __proxyRouteRequest(request, markDynamicUsage); try { const response = await handlerFn(request, { params }); const dynamicUsedInHandler = consumeDynamicUsage(); const handlerSetCacheControl = response.headers.has("cache-control"); + // Remember this handler as dynamic so future requests skip cache read. + if (dynamicUsedInHandler) { + __dynamicRouteHandlers.add(handler.pattern); + } + // Apply Cache-Control from route segment config (export const revalidate = N). // Runtime request APIs like headers() / cookies() make GET handlers dynamic, // so only attach ISR headers when the handler stayed static. @@ -3530,6 +3583,48 @@ const __isrDebug = process.env.NEXT_PRIVATE_DEBUG_CACHE ? console.debug.bind(console, "[vinext] ISR:") : undefined; +// Track route handler patterns that have used dynamic APIs (headers, cookies, +// searchParams). On first request (cache MISS), the handler runs and we detect +// dynamic usage. On subsequent requests, we skip the cache read for handlers +// in this set, matching Next.js behavior where dynamic handlers are never cached. +/** @type {Set} */ +const __dynamicRouteHandlers = new Set(); + +// Wrap the Request passed to route handlers in a Proxy that detects dynamic +// API access. Accessing request.headers or request.nextUrl.searchParams marks +// the handler as dynamic, preventing ISR caching. +function __proxyRouteRequest(req, markDynamic) { + let _nextUrl; + return new Proxy(req, { + get(target, prop, receiver) { + if (prop === "headers") { + markDynamic(); + return target.headers; + } + if (prop === "nextUrl") { + if (!_nextUrl) { + const realUrl = new URL(target.url); + _nextUrl = new Proxy(realUrl, { + get(urlTarget, urlProp) { + if (urlProp === "searchParams") markDynamic(); + const val = Reflect.get(urlTarget, urlProp); + return typeof val === "function" ? val.bind(urlTarget) : val; + }, + }); + } + return _nextUrl; + } + // Bind methods to the original Request to preserve internal slots. + // Web API objects like Request use internal slots for .json(), + // .text(), .arrayBuffer(), etc. that break when called through + // a Proxy (the Proxy becomes the receiver instead of the real Request). + const value = Reflect.get(target, prop); + if (typeof value === "function") return value.bind(target); + return value; + }, + }); +} + // Normalize null-prototype objects from matchPattern() into thenable objects // that work both as Promises (for Next.js 15+ async params) and as plain // objects with synchronous property access (for pre-15 code like params.id). @@ -5263,11 +5358,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // ISR cache read for route handlers (production only). // Only GET/HEAD (auto-HEAD) with finite revalidate > 0 are ISR-eligible. - // This runs before handler execution so a cache HIT skips the handler entirely. + // Skip cache read if this handler was previously detected as dynamic. if ( process.env.NODE_ENV === "production" && revalidateSeconds !== null && handler.dynamic !== "force-dynamic" && + !__dynamicRouteHandlers.has(handler.pattern) && (method === "GET" || isAutoHead) && typeof handlerFn === "function" ) { @@ -5305,11 +5401,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { await _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); setNavigationContext({ pathname: cleanPathname, searchParams: __revalSearchParams, params: __revalParams }); - const __syntheticReq = new Request(__revalUrl, { method: "GET" }); + const __syntheticReq = __proxyRouteRequest( + new Request(__revalUrl, { method: "GET" }), + markDynamicUsage, + ); const __revalResponse = await __revalHandlerFn(__syntheticReq, { params: __revalParams }); const __regenDynamic = consumeDynamicUsage(); setNavigationContext(null); if (__regenDynamic) { + __dynamicRouteHandlers.add(handler.pattern); __isrDebug?.("route regen skipped (dynamic usage)", cleanPathname); return; } @@ -5342,11 +5442,17 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (typeof handlerFn === "function") { const previousHeadersPhase = setHeadersAccessPhase("route-handler"); + const __proxiedRequest = __proxyRouteRequest(request, markDynamicUsage); try { const response = await handlerFn(request, { params }); const dynamicUsedInHandler = consumeDynamicUsage(); const handlerSetCacheControl = response.headers.has("cache-control"); + // Remember this handler as dynamic so future requests skip cache read. + if (dynamicUsedInHandler) { + __dynamicRouteHandlers.add(handler.pattern); + } + // Apply Cache-Control from route segment config (export const revalidate = N). // Runtime request APIs like headers() / cookies() make GET handlers dynamic, // so only attach ISR headers when the handler stayed static. @@ -6519,6 +6625,48 @@ const __isrDebug = process.env.NEXT_PRIVATE_DEBUG_CACHE ? console.debug.bind(console, "[vinext] ISR:") : undefined; +// Track route handler patterns that have used dynamic APIs (headers, cookies, +// searchParams). On first request (cache MISS), the handler runs and we detect +// dynamic usage. On subsequent requests, we skip the cache read for handlers +// in this set, matching Next.js behavior where dynamic handlers are never cached. +/** @type {Set} */ +const __dynamicRouteHandlers = new Set(); + +// Wrap the Request passed to route handlers in a Proxy that detects dynamic +// API access. Accessing request.headers or request.nextUrl.searchParams marks +// the handler as dynamic, preventing ISR caching. +function __proxyRouteRequest(req, markDynamic) { + let _nextUrl; + return new Proxy(req, { + get(target, prop, receiver) { + if (prop === "headers") { + markDynamic(); + return target.headers; + } + if (prop === "nextUrl") { + if (!_nextUrl) { + const realUrl = new URL(target.url); + _nextUrl = new Proxy(realUrl, { + get(urlTarget, urlProp) { + if (urlProp === "searchParams") markDynamic(); + const val = Reflect.get(urlTarget, urlProp); + return typeof val === "function" ? val.bind(urlTarget) : val; + }, + }); + } + return _nextUrl; + } + // Bind methods to the original Request to preserve internal slots. + // Web API objects like Request use internal slots for .json(), + // .text(), .arrayBuffer(), etc. that break when called through + // a Proxy (the Proxy becomes the receiver instead of the real Request). + const value = Reflect.get(target, prop); + if (typeof value === "function") return value.bind(target); + return value; + }, + }); +} + // Normalize null-prototype objects from matchPattern() into thenable objects // that work both as Promises (for Next.js 15+ async params) and as plain // objects with synchronous property access (for pre-15 code like params.id). @@ -8279,11 +8427,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // ISR cache read for route handlers (production only). // Only GET/HEAD (auto-HEAD) with finite revalidate > 0 are ISR-eligible. - // This runs before handler execution so a cache HIT skips the handler entirely. + // Skip cache read if this handler was previously detected as dynamic. if ( process.env.NODE_ENV === "production" && revalidateSeconds !== null && handler.dynamic !== "force-dynamic" && + !__dynamicRouteHandlers.has(handler.pattern) && (method === "GET" || isAutoHead) && typeof handlerFn === "function" ) { @@ -8321,11 +8470,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { await _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); setNavigationContext({ pathname: cleanPathname, searchParams: __revalSearchParams, params: __revalParams }); - const __syntheticReq = new Request(__revalUrl, { method: "GET" }); + const __syntheticReq = __proxyRouteRequest( + new Request(__revalUrl, { method: "GET" }), + markDynamicUsage, + ); const __revalResponse = await __revalHandlerFn(__syntheticReq, { params: __revalParams }); const __regenDynamic = consumeDynamicUsage(); setNavigationContext(null); if (__regenDynamic) { + __dynamicRouteHandlers.add(handler.pattern); __isrDebug?.("route regen skipped (dynamic usage)", cleanPathname); return; } @@ -8358,11 +8511,17 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (typeof handlerFn === "function") { const previousHeadersPhase = setHeadersAccessPhase("route-handler"); + const __proxiedRequest = __proxyRouteRequest(request, markDynamicUsage); try { const response = await handlerFn(request, { params }); const dynamicUsedInHandler = consumeDynamicUsage(); const handlerSetCacheControl = response.headers.has("cache-control"); + // Remember this handler as dynamic so future requests skip cache read. + if (dynamicUsedInHandler) { + __dynamicRouteHandlers.add(handler.pattern); + } + // Apply Cache-Control from route segment config (export const revalidate = N). // Runtime request APIs like headers() / cookies() make GET handlers dynamic, // so only attach ISR headers when the handler stayed static. @@ -9543,6 +9702,48 @@ const __isrDebug = process.env.NEXT_PRIVATE_DEBUG_CACHE ? console.debug.bind(console, "[vinext] ISR:") : undefined; +// Track route handler patterns that have used dynamic APIs (headers, cookies, +// searchParams). On first request (cache MISS), the handler runs and we detect +// dynamic usage. On subsequent requests, we skip the cache read for handlers +// in this set, matching Next.js behavior where dynamic handlers are never cached. +/** @type {Set} */ +const __dynamicRouteHandlers = new Set(); + +// Wrap the Request passed to route handlers in a Proxy that detects dynamic +// API access. Accessing request.headers or request.nextUrl.searchParams marks +// the handler as dynamic, preventing ISR caching. +function __proxyRouteRequest(req, markDynamic) { + let _nextUrl; + return new Proxy(req, { + get(target, prop, receiver) { + if (prop === "headers") { + markDynamic(); + return target.headers; + } + if (prop === "nextUrl") { + if (!_nextUrl) { + const realUrl = new URL(target.url); + _nextUrl = new Proxy(realUrl, { + get(urlTarget, urlProp) { + if (urlProp === "searchParams") markDynamic(); + const val = Reflect.get(urlTarget, urlProp); + return typeof val === "function" ? val.bind(urlTarget) : val; + }, + }); + } + return _nextUrl; + } + // Bind methods to the original Request to preserve internal slots. + // Web API objects like Request use internal slots for .json(), + // .text(), .arrayBuffer(), etc. that break when called through + // a Proxy (the Proxy becomes the receiver instead of the real Request). + const value = Reflect.get(target, prop); + if (typeof value === "function") return value.bind(target); + return value; + }, + }); +} + // Normalize null-prototype objects from matchPattern() into thenable objects // that work both as Promises (for Next.js 15+ async params) and as plain // objects with synchronous property access (for pre-15 code like params.id). @@ -11306,11 +11507,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // ISR cache read for route handlers (production only). // Only GET/HEAD (auto-HEAD) with finite revalidate > 0 are ISR-eligible. - // This runs before handler execution so a cache HIT skips the handler entirely. + // Skip cache read if this handler was previously detected as dynamic. if ( process.env.NODE_ENV === "production" && revalidateSeconds !== null && handler.dynamic !== "force-dynamic" && + !__dynamicRouteHandlers.has(handler.pattern) && (method === "GET" || isAutoHead) && typeof handlerFn === "function" ) { @@ -11348,11 +11550,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { await _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); setNavigationContext({ pathname: cleanPathname, searchParams: __revalSearchParams, params: __revalParams }); - const __syntheticReq = new Request(__revalUrl, { method: "GET" }); + const __syntheticReq = __proxyRouteRequest( + new Request(__revalUrl, { method: "GET" }), + markDynamicUsage, + ); const __revalResponse = await __revalHandlerFn(__syntheticReq, { params: __revalParams }); const __regenDynamic = consumeDynamicUsage(); setNavigationContext(null); if (__regenDynamic) { + __dynamicRouteHandlers.add(handler.pattern); __isrDebug?.("route regen skipped (dynamic usage)", cleanPathname); return; } @@ -11385,11 +11591,17 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (typeof handlerFn === "function") { const previousHeadersPhase = setHeadersAccessPhase("route-handler"); + const __proxiedRequest = __proxyRouteRequest(request, markDynamicUsage); try { const response = await handlerFn(request, { params }); const dynamicUsedInHandler = consumeDynamicUsage(); const handlerSetCacheControl = response.headers.has("cache-control"); + // Remember this handler as dynamic so future requests skip cache read. + if (dynamicUsedInHandler) { + __dynamicRouteHandlers.add(handler.pattern); + } + // Apply Cache-Control from route segment config (export const revalidate = N). // Runtime request APIs like headers() / cookies() make GET handlers dynamic, // so only attach ISR headers when the handler stayed static. @@ -12562,6 +12774,48 @@ const __isrDebug = process.env.NEXT_PRIVATE_DEBUG_CACHE ? console.debug.bind(console, "[vinext] ISR:") : undefined; +// Track route handler patterns that have used dynamic APIs (headers, cookies, +// searchParams). On first request (cache MISS), the handler runs and we detect +// dynamic usage. On subsequent requests, we skip the cache read for handlers +// in this set, matching Next.js behavior where dynamic handlers are never cached. +/** @type {Set} */ +const __dynamicRouteHandlers = new Set(); + +// Wrap the Request passed to route handlers in a Proxy that detects dynamic +// API access. Accessing request.headers or request.nextUrl.searchParams marks +// the handler as dynamic, preventing ISR caching. +function __proxyRouteRequest(req, markDynamic) { + let _nextUrl; + return new Proxy(req, { + get(target, prop, receiver) { + if (prop === "headers") { + markDynamic(); + return target.headers; + } + if (prop === "nextUrl") { + if (!_nextUrl) { + const realUrl = new URL(target.url); + _nextUrl = new Proxy(realUrl, { + get(urlTarget, urlProp) { + if (urlProp === "searchParams") markDynamic(); + const val = Reflect.get(urlTarget, urlProp); + return typeof val === "function" ? val.bind(urlTarget) : val; + }, + }); + } + return _nextUrl; + } + // Bind methods to the original Request to preserve internal slots. + // Web API objects like Request use internal slots for .json(), + // .text(), .arrayBuffer(), etc. that break when called through + // a Proxy (the Proxy becomes the receiver instead of the real Request). + const value = Reflect.get(target, prop); + if (typeof value === "function") return value.bind(target); + return value; + }, + }); +} + // Normalize null-prototype objects from matchPattern() into thenable objects // that work both as Promises (for Next.js 15+ async params) and as plain // objects with synchronous property access (for pre-15 code like params.id). @@ -14299,11 +14553,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // ISR cache read for route handlers (production only). // Only GET/HEAD (auto-HEAD) with finite revalidate > 0 are ISR-eligible. - // This runs before handler execution so a cache HIT skips the handler entirely. + // Skip cache read if this handler was previously detected as dynamic. if ( process.env.NODE_ENV === "production" && revalidateSeconds !== null && handler.dynamic !== "force-dynamic" && + !__dynamicRouteHandlers.has(handler.pattern) && (method === "GET" || isAutoHead) && typeof handlerFn === "function" ) { @@ -14341,11 +14596,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { await _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); setNavigationContext({ pathname: cleanPathname, searchParams: __revalSearchParams, params: __revalParams }); - const __syntheticReq = new Request(__revalUrl, { method: "GET" }); + const __syntheticReq = __proxyRouteRequest( + new Request(__revalUrl, { method: "GET" }), + markDynamicUsage, + ); const __revalResponse = await __revalHandlerFn(__syntheticReq, { params: __revalParams }); const __regenDynamic = consumeDynamicUsage(); setNavigationContext(null); if (__regenDynamic) { + __dynamicRouteHandlers.add(handler.pattern); __isrDebug?.("route regen skipped (dynamic usage)", cleanPathname); return; } @@ -14378,11 +14637,17 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (typeof handlerFn === "function") { const previousHeadersPhase = setHeadersAccessPhase("route-handler"); + const __proxiedRequest = __proxyRouteRequest(request, markDynamicUsage); try { const response = await handlerFn(request, { params }); const dynamicUsedInHandler = consumeDynamicUsage(); const handlerSetCacheControl = response.headers.has("cache-control"); + // Remember this handler as dynamic so future requests skip cache read. + if (dynamicUsedInHandler) { + __dynamicRouteHandlers.add(handler.pattern); + } + // Apply Cache-Control from route segment config (export const revalidate = N). // Runtime request APIs like headers() / cookies() make GET handlers dynamic, // so only attach ISR headers when the handler stayed static. @@ -15555,6 +15820,48 @@ const __isrDebug = process.env.NEXT_PRIVATE_DEBUG_CACHE ? console.debug.bind(console, "[vinext] ISR:") : undefined; +// Track route handler patterns that have used dynamic APIs (headers, cookies, +// searchParams). On first request (cache MISS), the handler runs and we detect +// dynamic usage. On subsequent requests, we skip the cache read for handlers +// in this set, matching Next.js behavior where dynamic handlers are never cached. +/** @type {Set} */ +const __dynamicRouteHandlers = new Set(); + +// Wrap the Request passed to route handlers in a Proxy that detects dynamic +// API access. Accessing request.headers or request.nextUrl.searchParams marks +// the handler as dynamic, preventing ISR caching. +function __proxyRouteRequest(req, markDynamic) { + let _nextUrl; + return new Proxy(req, { + get(target, prop, receiver) { + if (prop === "headers") { + markDynamic(); + return target.headers; + } + if (prop === "nextUrl") { + if (!_nextUrl) { + const realUrl = new URL(target.url); + _nextUrl = new Proxy(realUrl, { + get(urlTarget, urlProp) { + if (urlProp === "searchParams") markDynamic(); + const val = Reflect.get(urlTarget, urlProp); + return typeof val === "function" ? val.bind(urlTarget) : val; + }, + }); + } + return _nextUrl; + } + // Bind methods to the original Request to preserve internal slots. + // Web API objects like Request use internal slots for .json(), + // .text(), .arrayBuffer(), etc. that break when called through + // a Proxy (the Proxy becomes the receiver instead of the real Request). + const value = Reflect.get(target, prop); + if (typeof value === "function") return value.bind(target); + return value; + }, + }); +} + // Normalize null-prototype objects from matchPattern() into thenable objects // that work both as Promises (for Next.js 15+ async params) and as plain // objects with synchronous property access (for pre-15 code like params.id). @@ -17645,11 +17952,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // ISR cache read for route handlers (production only). // Only GET/HEAD (auto-HEAD) with finite revalidate > 0 are ISR-eligible. - // This runs before handler execution so a cache HIT skips the handler entirely. + // Skip cache read if this handler was previously detected as dynamic. if ( process.env.NODE_ENV === "production" && revalidateSeconds !== null && handler.dynamic !== "force-dynamic" && + !__dynamicRouteHandlers.has(handler.pattern) && (method === "GET" || isAutoHead) && typeof handlerFn === "function" ) { @@ -17687,11 +17995,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { await _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); setNavigationContext({ pathname: cleanPathname, searchParams: __revalSearchParams, params: __revalParams }); - const __syntheticReq = new Request(__revalUrl, { method: "GET" }); + const __syntheticReq = __proxyRouteRequest( + new Request(__revalUrl, { method: "GET" }), + markDynamicUsage, + ); const __revalResponse = await __revalHandlerFn(__syntheticReq, { params: __revalParams }); const __regenDynamic = consumeDynamicUsage(); setNavigationContext(null); if (__regenDynamic) { + __dynamicRouteHandlers.add(handler.pattern); __isrDebug?.("route regen skipped (dynamic usage)", cleanPathname); return; } @@ -17724,11 +18036,17 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (typeof handlerFn === "function") { const previousHeadersPhase = setHeadersAccessPhase("route-handler"); + const __proxiedRequest = __proxyRouteRequest(request, markDynamicUsage); try { const response = await handlerFn(request, { params }); const dynamicUsedInHandler = consumeDynamicUsage(); const handlerSetCacheControl = response.headers.has("cache-control"); + // Remember this handler as dynamic so future requests skip cache read. + if (dynamicUsedInHandler) { + __dynamicRouteHandlers.add(handler.pattern); + } + // Apply Cache-Control from route segment config (export const revalidate = N). // Runtime request APIs like headers() / cookies() make GET handlers dynamic, // so only attach ISR headers when the handler stayed static. diff --git a/tests/unified-request-context.test.ts b/tests/unified-request-context.test.ts index c1d3c92a..5b730c19 100644 --- a/tests/unified-request-context.test.ts +++ b/tests/unified-request-context.test.ts @@ -29,7 +29,7 @@ describe("unified-request-context", () => { const ctx = getRequestContext(); expect(ctx).toBeDefined(); expect(ctx.headersContext).toBeNull(); - expect(ctx.dynamicUsageDetected).toBe(false); + expect(ctx.dynamicUsageCount).toBe(0); expect(ctx.pendingSetCookies).toEqual([]); expect(ctx.draftModeCookieHeader).toBeNull(); expect(ctx.phase).toBe("render"); @@ -46,12 +46,12 @@ describe("unified-request-context", () => { it("returns a fresh detached context on each call outside any scope", () => { const first = getRequestContext(); - first.dynamicUsageDetected = true; + first.dynamicUsageCount = 1; first.pendingSetCookies.push("first=1"); const second = getRequestContext(); expect(second).not.toBe(first); - expect(second.dynamicUsageDetected).toBe(false); + expect(second.dynamicUsageCount).toBe(0); expect(second.pendingSetCookies).toEqual([]); }); @@ -83,7 +83,7 @@ describe("unified-request-context", () => { expect((ctx.headersContext as any).headers.get("x-test")).toBe("1"); expect((ctx.headersContext as any).cookies.get("session")).toBe("abc"); expect(ctx.executionContext).toBe(fakeCtx); - expect(ctx.dynamicUsageDetected).toBe(false); + expect(ctx.dynamicUsageCount).toBe(0); expect(ctx.phase).toBe("render"); expect(ctx.i18nContext).toBeNull(); expect(ctx.pendingSetCookies).toEqual([]); @@ -161,11 +161,11 @@ describe("unified-request-context", () => { const ctxB = createRequestContext(); const pA = runWithRequestContext(ctxA, async () => { - getRequestContext().dynamicUsageDetected = true; + getRequestContext().dynamicUsageCount = 1; getRequestContext().pendingSetCookies.push("a=1"); await new Promise((resolve) => setTimeout(resolve, 5)); return { - dynamic: getRequestContext().dynamicUsageDetected, + dynamic: getRequestContext().dynamicUsageCount, cookies: [...getRequestContext().pendingSetCookies], }; }); @@ -173,15 +173,15 @@ describe("unified-request-context", () => { const pB = runWithRequestContext(ctxB, async () => { await new Promise((resolve) => setTimeout(resolve, 1)); return { - dynamic: getRequestContext().dynamicUsageDetected, + dynamic: getRequestContext().dynamicUsageCount, cookies: [...getRequestContext().pendingSetCookies], }; }); const [a, b] = await Promise.all([pA, pB]); - expect(a.dynamic).toBe(true); + expect(a.dynamic).toBe(1); expect(a.cookies).toEqual(["a=1"]); - expect(b.dynamic).toBe(false); + expect(b.dynamic).toBe(0); expect(b.cookies).toEqual([]); }); }); @@ -270,7 +270,7 @@ describe("unified-request-context", () => { it("each sub-state getter returns correct sub-fields", () => { const reqCtx = createRequestContext({ headersContext: { headers: new Headers(), cookies: new Map() }, - dynamicUsageDetected: true, + dynamicUsageCount: 1, pendingSetCookies: ["a=b"], draftModeCookieHeader: "c=d", phase: "action", @@ -284,7 +284,7 @@ describe("unified-request-context", () => { void runWithRequestContext(reqCtx, () => { const ctx = getRequestContext(); - expect(ctx.dynamicUsageDetected).toBe(true); + expect(ctx.dynamicUsageCount).toBe(1); expect(ctx.pendingSetCookies).toEqual(["a=b"]); expect(ctx.draftModeCookieHeader).toBe("c=d"); expect(ctx.phase).toBe("action"); @@ -319,7 +319,7 @@ describe("unified-request-context", () => { void runWithRequestContext( createRequestContext({ headersContext: outerHeaders, - dynamicUsageDetected: true, + dynamicUsageCount: 1, pendingSetCookies: ["outer=1"], draftModeCookieHeader: "outer=draft", phase: "action", @@ -328,12 +328,12 @@ describe("unified-request-context", () => { void runWithHeadersContext(innerHeaders as any, () => { const ctx = getRequestContext(); expect((ctx.headersContext as any).headers.get("x-id")).toBe("inner"); - expect(ctx.dynamicUsageDetected).toBe(false); + expect(ctx.dynamicUsageCount).toBe(0); expect(ctx.pendingSetCookies).toEqual([]); expect(ctx.draftModeCookieHeader).toBeNull(); expect(ctx.phase).toBe("render"); - ctx.dynamicUsageDetected = true; + ctx.dynamicUsageCount = 1; ctx.pendingSetCookies.push("inner=1"); ctx.draftModeCookieHeader = "inner=draft"; ctx.phase = "route-handler"; @@ -341,7 +341,7 @@ describe("unified-request-context", () => { const ctx = getRequestContext(); expect(ctx.headersContext).toBe(outerHeaders); - expect(ctx.dynamicUsageDetected).toBe(true); + expect(ctx.dynamicUsageCount).toBe(1); expect(ctx.pendingSetCookies).toEqual(["outer=1"]); expect(ctx.draftModeCookieHeader).toBe("outer=draft"); expect(ctx.phase).toBe("action"); @@ -513,7 +513,7 @@ describe("unified-request-context", () => { it("creates context with all defaults", () => { const ctx = createRequestContext(); expect(ctx.headersContext).toBeNull(); - expect(ctx.dynamicUsageDetected).toBe(false); + expect(ctx.dynamicUsageCount).toBe(0); expect(ctx.pendingSetCookies).toEqual([]); expect(ctx.draftModeCookieHeader).toBeNull(); expect(ctx.phase).toBe("render"); @@ -531,10 +531,10 @@ describe("unified-request-context", () => { it("merges partial overrides", () => { const ctx = createRequestContext({ phase: "action", - dynamicUsageDetected: true, + dynamicUsageCount: 1, }); expect(ctx.phase).toBe("action"); - expect(ctx.dynamicUsageDetected).toBe(true); + expect(ctx.dynamicUsageCount).toBe(1); // Other fields get defaults expect(ctx.i18nContext).toBeNull(); expect(ctx.headersContext).toBeNull();