From 96b1b06d0b538039f0929ea1db06cf72c20d11a5 Mon Sep 17 00:00:00 2001 From: Niklas Mischkulnig <4586894+mischnic@users.noreply.github.com> Date: Fri, 28 Aug 2026 21:33:13 +0200 Subject: [PATCH 1/8] Turbopack: drop rust analyzer skip annotation (#98047) --- Cargo.lock | 2 ++ turbopack/crates/turbo-tasks-macros/src/value_impl_macro.rs | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 4a2ba8198d7b..6f2ca1fef51a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3169,6 +3169,8 @@ version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.1.5", "serde", ] diff --git a/turbopack/crates/turbo-tasks-macros/src/value_impl_macro.rs b/turbopack/crates/turbo-tasks-macros/src/value_impl_macro.rs index 3958967ea044..401b7b0d5e25 100644 --- a/turbopack/crates/turbo-tasks-macros/src/value_impl_macro.rs +++ b/turbopack/crates/turbo-tasks-macros/src/value_impl_macro.rs @@ -286,7 +286,6 @@ pub fn value_impl(args: TokenStream, input: TokenStream) -> TokenStream { } quote! { // Register this `impl Trait for Concrete` into the link-time `TRAIT_IMPLS_SLICE`. - #[cfg(not(rust_analyzer))] turbo_tasks::macro_helpers::scattered_collect::declarative::scatter! { #[scatter(turbo_tasks::macro_helpers::TRAIT_IMPLS_SLICE)] const _: turbo_tasks::macro_helpers::TraitImplRecord = { From 357e514cf7bdc276bfd830003c38027c17d9de05 Mon Sep 17 00:00:00 2001 From: Janka Uryga Date: Fri, 28 Aug 2026 21:41:32 +0200 Subject: [PATCH 2/8] Revert "test: re-enable sync IO root coverage" (#98050) Reverts vercel/next.js#97996 The fix does not seem to help with the flakiness. --- .../app-dir/sync-io-blocks-root/sync-io-blocks-root.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/production/app-dir/sync-io-blocks-root/sync-io-blocks-root.test.ts b/test/production/app-dir/sync-io-blocks-root/sync-io-blocks-root.test.ts index d4cddaa87a62..a7e57f6df3b9 100644 --- a/test/production/app-dir/sync-io-blocks-root/sync-io-blocks-root.test.ts +++ b/test/production/app-dir/sync-io-blocks-root/sync-io-blocks-root.test.ts @@ -1,6 +1,8 @@ import { nextTestSetup } from 'e2e-utils' -describe('sync IO that blocks the root', () => { +// TODO(lubieowoce): reenable when the cause of flakiness is found and fixed +// (seems to have increased sharply around 2026-08-24/25) +describe.skip('sync IO that blocks the root', () => { const { next } = nextTestSetup({ files: __dirname, skipStart: true, From d8f95c12de0c8874b775346029a5c547b784a2cb Mon Sep 17 00:00:00 2001 From: Janka Uryga Date: Fri, 28 Aug 2026 22:10:21 +0200 Subject: [PATCH 3/8] [PPF] Fix navigation() in prospective runtime prerenders (#98000) ### Long story short Fixes an analogous bug to #95665, but affecting `prefetch()/navigation()`. Those two were accidentally always resolving in the **prospective** runtime prerender, so we were warming content that isn't needed for the final runtime prerender. We now guard them properly. Also, I replaced `PrerenderStoreModernRuntime.isSessionShell` with `finalStage` (the same stage we're gonna use for the final prerender), so we can include/exclude `navigation` properly -- the boolean only distinguished shell/prefetch, not navigation. ### Long story long We use runtime prerenders for three things: runtime shells, runtime prefetches, and embedding a prefetch in navigations. These are aborted after `ShellRuntime`, `Runtime`, and `NavigationRuntime` respectively (accessible in `stageController.finalStage`). If we're doing the **final** prerender for a shell, promises that would resolve in e.g. `NavigationRuntime` would never resolve, because we abort the prerender before we get there. However, the **prospective** runtime prerender has no `StagedRenderingController` and needs to model this separately (so that the same APIs hang in both). This was [previously done via `prerenderStore.isSessionShell`](https://github.com/vercel/next.js/pull/95665) which we checked in URL data apis i.e. `params/searchParams` and the metadata`pathname` to do this. `isSessionShell: true` meant they should hang because it's a shell, and `isSessionShell: false` meant they should resolve. However, I forgot about this case when implementing `navigation`/`prefetch`, and made them resolve unconditionally in the prospective prerender. This means we'd potentially warm content that would not actually be reached in the final prerender. In other words, ```ts await navigation() return ``` would needlessly render `` during the prospective render but not in the final one. This kinda defeats `navigation`'s main purpose, i.e. avoiding expensive work. This PR fixes the bug by replacing `isSessionShell: boolean` with `finalStage: AdvanceableRenderStage`, which is set to the same value we put in `stageController.finalStage`. This lets us keep the logic of what hangs and what resolves relatively close in both prerenders. --- On a meta level, the fact that the same bug happened twice tells me that having a `StageRenderingController` that is sometimes `null` is error-prone, and we should figure out a better solution, but i'm gonna keep this fix targeted and figure out a more holistic solution in a follow-up --- .../next/src/server/app-render/app-render.tsx | 77 +++--- .../work-unit-async-storage.external.ts | 7 +- .../next/src/server/request/cache-stages.ts | 31 ++- packages/next/src/server/request/params.ts | 35 +-- packages/next/src/server/request/pathname.ts | 4 +- .../next/src/server/request/search-params.ts | 21 +- .../app/page.tsx | 20 -- .../prefetch-app-shell-cached-gsp.test.ts | 70 ------ .../app/layout.tsx | 10 +- .../app/lazy-data-in-prefetch/page.tsx | 38 +++ .../app/lazy-data-in-shell/page.tsx | 38 +++ .../app/page.tsx | 35 +++ .../app/pass-navigation-to-cache/page.tsx | 36 +++ .../app/pass-prefetch-to-cache/page.tsx | 36 +++ .../app/slug/[slug]/page.tsx | 0 .../components/link-accordion.tsx | 0 .../next.config.ts | 0 .../runtime-prerender-cache-warming.test.ts | 237 ++++++++++++++++++ 18 files changed, 523 insertions(+), 172 deletions(-) delete mode 100644 test/e2e/app-dir/segment-cache/prefetch-app-shell-cached-gsp/app/page.tsx delete mode 100644 test/e2e/app-dir/segment-cache/prefetch-app-shell-cached-gsp/prefetch-app-shell-cached-gsp.test.ts rename test/e2e/app-dir/segment-cache/{prefetch-app-shell-cached-gsp => runtime-prerender-cache-warming}/app/layout.tsx (57%) create mode 100644 test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/lazy-data-in-prefetch/page.tsx create mode 100644 test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/lazy-data-in-shell/page.tsx create mode 100644 test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/page.tsx create mode 100644 test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/pass-navigation-to-cache/page.tsx create mode 100644 test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/pass-prefetch-to-cache/page.tsx rename test/e2e/app-dir/segment-cache/{prefetch-app-shell-cached-gsp => runtime-prerender-cache-warming}/app/slug/[slug]/page.tsx (100%) rename test/e2e/app-dir/segment-cache/{prefetch-app-shell-cached-gsp => runtime-prerender-cache-warming}/components/link-accordion.tsx (100%) rename test/e2e/app-dir/segment-cache/{prefetch-app-shell-cached-gsp => runtime-prerender-cache-warming}/next.config.ts (100%) create mode 100644 test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/runtime-prerender-cache-warming.test.ts diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 27e19ac968fe..7329af702e90 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -1598,17 +1598,6 @@ async function generateRuntimePrefetchResult( // but we're not going to persist this anywhere. const prerenderResumeDataCache = createPrerenderResumeDataCache() - await prospectiveRuntimeServerPrerender( - ctx, - isShellPrefetch, - generateDynamicRSCPayload.bind(null, ctx), - prerenderResumeDataCache, - rootParams, - requestStore.headers, - requestStore.cookies, - requestStore.draftMode - ) - const mode: RuntimePrerenderMode = isShellPrefetch ? { type: 'session-shell-only', @@ -1620,6 +1609,17 @@ async function generateRuntimePrefetchResult( shellByteLengthDeferred: createPromiseWithResolvers(), } + await prospectiveRuntimeServerPrerender( + ctx, + getFinalStageForRuntimePrerenderMode(mode), + generateDynamicRSCPayload.bind(null, ctx), + prerenderResumeDataCache, + rootParams, + requestStore.headers, + requestStore.cookies, + requestStore.draftMode + ) + const debugChannel = setReactDebugChannel ? createWebDebugChannel() : undefined @@ -1656,7 +1656,7 @@ async function generateRuntimePrefetchResult( async function prospectiveRuntimeServerPrerender( ctx: AppRenderContext, - isShellPrefetch: boolean, + finalStage: AdvanceableRenderStage, getPayload: () => Promise, resumeDataCache: PrerenderResumeDataCache | null, rootParams: Params, @@ -1705,9 +1705,10 @@ async function prospectiveRuntimeServerPrerender( hmrRefreshHash: undefined, // We don't track vary params during initial prerender, only the final one varyParamsAccumulator: null, - // No stage sequencing needed for prospective renders. + // We don't use sequencing needed for prospective renders, but we need to + // track where the final prerender is going to end to avoid warming too much. stagedRendering: null, - isSessionShell: isShellPrefetch, + finalStage, // These are not present in regular prerenders, but allowed in a runtime // prerender. // Any cache keyed on headers() or cookies() needs to be invalidated. @@ -1854,19 +1855,8 @@ async function finalRuntimeServerPrerender( isDebugDynamicAccesses ) - let finalStage: AdvanceableRenderStage - switch (mode.type) { - case 'session-shell-only': - finalStage = RenderStage.ShellRuntime - break - case 'rewindable-session-shell': - finalStage = RenderStage.Runtime - break - case 'navigation': - finalStage = RenderStage.NavigationRuntime - break - } - const finalStageController = new StagedRenderingController({ + const finalStage = getFinalStageForRuntimePrerenderMode(mode) + const stageController = new StagedRenderingController({ abortSignal: finalServerController.signal, abandonController: null, // In dynamic renders, we allow Sync IO in the Runtime stage @@ -1898,8 +1888,8 @@ async function finalRuntimeServerPrerender( resumeDataCache, hmrRefreshHash: undefined, varyParamsAccumulator, - stagedRendering: finalStageController, - isSessionShell: mode.type === 'session-shell-only', + stagedRendering: stageController, + finalStage, // These are not present in regular prerenders, but allowed in a runtime // prerender. headers: HeadersAdapter.fresh(headers), @@ -1923,7 +1913,7 @@ async function finalRuntimeServerPrerender( collectPrerenderChunk(collectedChunks, finalServerController.signal, chunk) increaseChunkByteLengths( stageByteLengths, - finalStageController.currentStage, + stageController.currentStage, chunk.byteLength ) } @@ -1963,7 +1953,7 @@ async function finalRuntimeServerPrerender( await runInSequentialTasks( async () => { - finalStageController.advanceStage(RenderStage.ShellStatic) + stageController.advanceStage(RenderStage.ShellStatic) let stream = workUnitAsyncStorage.run( finalServerPrerenderStore, @@ -1989,19 +1979,19 @@ async function finalRuntimeServerPrerender( }, () => { if (checkUnexpectedAbort()) return - finalStageController.advanceStage(RenderStage.PrefetchStatic) + stageController.advanceStage(RenderStage.PrefetchStatic) }, () => { if (checkUnexpectedAbort()) return - finalStageController.advanceStage(RenderStage.NavigationStatic) + stageController.advanceStage(RenderStage.NavigationStatic) }, () => { if (checkUnexpectedAbort()) return - finalStageController.advanceStage(RenderStage.Static) + stageController.advanceStage(RenderStage.Static) }, () => { if (checkUnexpectedAbort()) return - finalStageController.advanceStage(RenderStage.ShellRuntime) + stageController.advanceStage(RenderStage.ShellRuntime) }, () => { if (checkUnexpectedAbort()) return @@ -2009,7 +1999,7 @@ async function finalRuntimeServerPrerender( // We may not reach this stage depending on the mode. if (finalStage < RenderStage.Runtime) return - finalStageController.advanceStage(RenderStage.Runtime) + stageController.advanceStage(RenderStage.Runtime) }, () => { if (checkUnexpectedAbort()) return @@ -2040,7 +2030,7 @@ async function finalRuntimeServerPrerender( // We may not reach this stage depending on the mode. if (finalStage < RenderStage.NavigationRuntime) return - finalStageController.advanceStage(RenderStage.NavigationRuntime) + stageController.advanceStage(RenderStage.NavigationRuntime) }, () => { // Finish the accumulators. We need to wait for Flight to flush the result into the stream, @@ -2090,6 +2080,19 @@ async function finalRuntimeServerPrerender( } } +function getFinalStageForRuntimePrerenderMode( + mode: RuntimePrerenderMode +): AdvanceableRenderStage { + switch (mode.type) { + case 'session-shell-only': + return RenderStage.ShellRuntime + case 'rewindable-session-shell': + return RenderStage.Runtime + case 'navigation': + return RenderStage.NavigationRuntime + } +} + /** * Crawlers will inadvertently think the canonicalUrl in the RSC payload should be crawled * when our intention is to just seed the router state with the current URL. diff --git a/packages/next/src/server/app-render/work-unit-async-storage.external.ts b/packages/next/src/server/app-render/work-unit-async-storage.external.ts index 69b35b95902f..7674db24fead 100644 --- a/packages/next/src/server/app-render/work-unit-async-storage.external.ts +++ b/packages/next/src/server/app-render/work-unit-async-storage.external.ts @@ -16,7 +16,10 @@ import type { Params } from '../request/params' import type { ImplicitTags } from '../lib/implicit-tags' import type { WorkStore } from './work-async-storage.external' import { InvariantError } from '../../shared/lib/invariant-error' -import type { StagedRenderingController } from './staged-rendering' +import type { + AdvanceableRenderStage, + StagedRenderingController, +} from './staged-rendering' import type { ValidationBoundaryTracking } from './instant-validation/boundary-tracking' import type { InstantValidationSampleTracking } from './instant-validation/instant-samples' @@ -226,7 +229,7 @@ export interface PrerenderStoreModernRuntime * renders where all stages run without sequencing. */ readonly stagedRendering: StagedRenderingController | null - readonly isSessionShell: boolean + readonly finalStage: AdvanceableRenderStage readonly headers: RequestStore['headers'] readonly cookies: RequestStore['cookies'] diff --git a/packages/next/src/server/request/cache-stages.ts b/packages/next/src/server/request/cache-stages.ts index b1ebfb99265e..2850ecacf0f3 100644 --- a/packages/next/src/server/request/cache-stages.ts +++ b/packages/next/src/server/request/cache-stages.ts @@ -5,6 +5,7 @@ import { } from '../app-render/work-unit-async-storage.external' import { applyOwnerStack, + makeUntrackedHangingPromise, RENDER_STAGES_BY_DATA_KIND, trackIncompatibleShellContent, } from '../dynamic-rendering-utils' @@ -52,6 +53,7 @@ export function unstable_prefetch(): Promise { const { stagedRendering } = workUnitStore if (!stagedRendering) { // Prospective prerender + // `unstable_prefetch()` will resolve in the final prerender, so resolve it here as well. return Promise.resolve(undefined) } else { // Final prerender @@ -69,13 +71,23 @@ export function unstable_prefetch(): Promise { // Note that this does not mark the subtree as dynamic -- content guarded by // prefetch() is still considered cacheable. const { stagedRendering } = workUnitStore + const prefetchStage = RENDER_STAGES_BY_DATA_KIND.runtimeLinkData if (!stagedRendering) { // Prospective prerender - return Promise.resolve(undefined) + // Make sure we don't unblock content that won't be reached in the final prerender. + if (workUnitStore.finalStage < prefetchStage) { + return makeUntrackedHangingPromise( + workUnitStore.renderSignal, + workStore.route, + '`unstable_prefetch()`' + ) + } else { + return Promise.resolve(undefined) + } } else { // Final prerender return stagedRendering.delayUntilStage( - RENDER_STAGES_BY_DATA_KIND.runtimeLinkData, + prefetchStage, 'unstable_prefetch', undefined ) @@ -194,6 +206,7 @@ export function unstable_navigation(): Promise { const { stagedRendering } = workUnitStore if (!stagedRendering) { // Prospective prerender + // `unstable_navigation()` will resolve in the final prerender, so resolve it here as well. return Promise.resolve(undefined) } else { // Final prerender @@ -211,13 +224,23 @@ export function unstable_navigation(): Promise { // Note that this does not mark the subtree as dynamic -- content guarded by // navigation() is still considered cacheable. const { stagedRendering } = workUnitStore + const navigationStage = RenderStage.NavigationRuntime if (!stagedRendering) { // Prospective prerender - return Promise.resolve(undefined) + // Make sure we don't unblock content that won't be reached in the final prerender. + if (workUnitStore.finalStage < navigationStage) { + return makeUntrackedHangingPromise( + workUnitStore.renderSignal, + workStore.route, + '`unstable_navigation()`' + ) + } else { + return Promise.resolve(undefined) + } } else { // Final prerender return stagedRendering.delayUntilStage( - RenderStage.NavigationRuntime, + navigationStage, 'unstable_navigation', undefined ) diff --git a/packages/next/src/server/request/params.ts b/packages/next/src/server/request/params.ts index 9e01ada7c870..d8e417ceca77 100644 --- a/packages/next/src/server/request/params.ts +++ b/packages/next/src/server/request/params.ts @@ -427,36 +427,27 @@ function createRuntimePrerenderParams( return makeUntrackedParams(userspaceParams) } + // Root params are allowed in shells, so we allow them to resolve without a delay. + if (allParamsAreRootParams(underlyingParams, workUnitStore.rootParams)) { + return makeUntrackedParams(userspaceParams) + } + + // Non-root params are URL data, and we need to recover a param-less session shell, + // so we delay all params until the runtime stage (even if they're static) + const paramsStage = RENDER_STAGES_BY_DATA_KIND.runtimeLinkData + const { stagedRendering } = workUnitStore if (!stagedRendering) { - // If there's no staging, we're in a prospective runtime prerender. - if (workUnitStore.isSessionShell) { - // If we're warming up for a session shell, params should be hanging, - // because they'll be a hanging input in the final prerender. + // If there's no stage controller, we're in a prospective runtime prerender. + // Make sure we don't unblock content that won't be reached in the final prerender. + if (workUnitStore.finalStage < paramsStage) { return makeHangingParams(underlyingParams, workStore, workUnitStore) } else { return makeUntrackedParams(userspaceParams) } } - // We don't have fallbackParams in runtime prerenders, so we don't know - // when params are static. However, root params are static by definition, - // so we can at least check for that. - // Note that resolving them without a delay is valid because root params are - // allowed in shells. - if (allParamsAreRootParams(underlyingParams, workUnitStore.rootParams)) { - return makeUntrackedParams(userspaceParams) - } - - // Semantically, we should resolve static params in the static stage. - // But params are link data, and we need to recover a param-less session shell, - // so we delay all params until the runtime stage instead. - const staticParamsStage = RENDER_STAGES_BY_DATA_KIND.runtimeLinkData - return stagedRendering.delayUntilStage( - staticParamsStage, - 'params', - userspaceParams - ) + return stagedRendering.delayUntilStage(paramsStage, 'params', userspaceParams) } function createRenderParamsForPage( diff --git a/packages/next/src/server/request/pathname.ts b/packages/next/src/server/request/pathname.ts index dd78fea607ea..167b14d1c20c 100644 --- a/packages/next/src/server/request/pathname.ts +++ b/packages/next/src/server/request/pathname.ts @@ -57,15 +57,15 @@ export function createServerPathnameForMetadata( // behavior of always resolving in the runtime stage // (i.e. assuming that we have non-static params in the pathname) const { stagedRendering } = workUnitStore + const pathnameStage = RENDER_STAGES_BY_DATA_KIND.runtimeLinkData if (stagedRendering) { - const pathnameStage = RENDER_STAGES_BY_DATA_KIND.runtimeLinkData return stagedRendering.delayUntilStage( pathnameStage, undefined, underlyingPathname ) } else { - if (workUnitStore.isSessionShell) { + if (workUnitStore.finalStage < pathnameStage) { return makeDynamicHangingPromise( workUnitStore.renderSignal, workStore.route, diff --git a/packages/next/src/server/request/search-params.ts b/packages/next/src/server/request/search-params.ts index 3f0760fdae4c..ce5d9688de2f 100644 --- a/packages/next/src/server/request/search-params.ts +++ b/packages/next/src/server/request/search-params.ts @@ -245,16 +245,20 @@ function createRuntimePrerenderSearchParams( : underlyingSearchParams const result = makeUntrackedSearchParams(userspaceSearchParams) + + const searchParamsStage = RENDER_STAGES_BY_DATA_KIND.runtimeLinkData + const { stagedRendering } = workUnitStore if (!stagedRendering) { - // If there's no staging, we're in a prospective runtime prerender. - if (workUnitStore.isSessionShell) { - // If we're warming up for a session shell, search params should hang, - // because they'll be a hanging input in the final prerender. + // If there's no stage controller, we're in a prospective runtime prerender. + // Make sure we don't unblock content that won't be reached in the final prerender. + if (workUnitStore.finalStage < searchParamsStage) { return makeHangingSearchParams(workStore, workUnitStore) + } else { + return result } - return result } + // Unlike `createRuntimePrerenderParams`, which uses `delayUntilStage`, we // resolve with `waitForStage(...).then(...)` here. Switching search params to // `delayUntilStage` drops the source code frame from the instant-validation @@ -264,7 +268,6 @@ function createRuntimePrerenderSearchParams( // `suspense-boundaries` tests. The underlying reason in React's async I/O // await tracking isn't understood yet. TODO: align search params with params // on `delayUntilStage` once resolved. - const searchParamsStage = RENDER_STAGES_BY_DATA_KIND.runtimeLinkData return stagedRendering.waitForStage(searchParamsStage).then(() => result) } @@ -382,9 +385,9 @@ function makeHangingSearchParams( prerenderStore.renderSignal, workStore.route, '`searchParams`', - // This promise is created for every page whether or not it reads search - // params, so recording the access at creation would mark every render. - // The access is tracked in the proxy traps below instead. + // Passing `null` for the store disables tracking of params usage. + // We want accesses of chained promises to be tracked as well. + // TODO: The custom tracking seems unnecessary, we should standardize it null ) diff --git a/test/e2e/app-dir/segment-cache/prefetch-app-shell-cached-gsp/app/page.tsx b/test/e2e/app-dir/segment-cache/prefetch-app-shell-cached-gsp/app/page.tsx deleted file mode 100644 index 78e4e16658da..000000000000 --- a/test/e2e/app-dir/segment-cache/prefetch-app-shell-cached-gsp/app/page.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { LinkAccordion } from '../components/link-accordion' - -export default function Page() { - return ( -
-
    -
  • - - prerendered from gSP - /slug/prerendered - -
  • -
  • - - not prerendered - /slug/not-prerendered - -
  • -
-
- ) -} diff --git a/test/e2e/app-dir/segment-cache/prefetch-app-shell-cached-gsp/prefetch-app-shell-cached-gsp.test.ts b/test/e2e/app-dir/segment-cache/prefetch-app-shell-cached-gsp/prefetch-app-shell-cached-gsp.test.ts deleted file mode 100644 index a28caeeb88b3..000000000000 --- a/test/e2e/app-dir/segment-cache/prefetch-app-shell-cached-gsp/prefetch-app-shell-cached-gsp.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { nextTestSetup } from 'e2e-utils' -import type * as Playwright from 'playwright' -import { createRouterAct } from 'router-act' -import { retry } from 'next-test-utils' - -const CACHE_MISS_WARNING = 'Unexpected cache miss after cache warming phase' - -// The App Shell prefetch (next-router-prefetch: '3') renders the runtime shell -// of the /slug/[slug] route. Because the page is a `'use cache'` page that -// awaits static params, params are a hanging input in the final prerender. If -// the prospective (cache-warming) prerender resolved those params instead of -// leaving them hanging, the cached page's key would differ between the two -// prerenders and the final prerender would log an "Unexpected cache miss" -// warning and degrade the cached segment to a dynamic hole. -describe('App Shell prefetching - cached page with generateStaticParams', () => { - const { next, isNextDev } = nextTestSetup({ - files: __dirname, - }) - if (isNextDev) { - it('is skipped', () => {}) - return - } - - it('does not report a cache miss when prefetching the shell of a cached page that reads static params', async () => { - let page: Playwright.Page - const browser = await next.browser('/', { - beforePageLoad(p: Playwright.Page) { - page = p - }, - }) - const act = createRouterAct(page, { includeAppShellRequests: true }) - - // Revealing the link prefetches the App Shell for /slug/[slug]. Because the - // shell is param-independent, this single request drives the runtime shell - // prerender of the cached page whose params are a hanging input. - await act(async () => { - await browser - .elementByCss('input[data-link-accordion="/slug/prerendered"]') - .click() - }) - - expect(next.cliOutput).not.toContain(CACHE_MISS_WARNING) - }) - - it('renders the cached page content after navigating to a prefetched shell', async () => { - let page: Playwright.Page - const browser = await next.browser('/', { - beforePageLoad(p: Playwright.Page) { - page = p - }, - }) - const act = createRouterAct(page, { includeAppShellRequests: true }) - - await act(async () => { - await browser - .elementByCss('input[data-link-accordion="/slug/prerendered"]') - .click() - }) - - await browser.elementByCss('a[href="/slug/prerendered"]').click() - - await retry(async () => { - expect(await browser.elementById('slug').text()).toEqual( - 'Slug: prerendered' - ) - }) - - expect(next.cliOutput).not.toContain(CACHE_MISS_WARNING) - }) -}) diff --git a/test/e2e/app-dir/segment-cache/prefetch-app-shell-cached-gsp/app/layout.tsx b/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/layout.tsx similarity index 57% rename from test/e2e/app-dir/segment-cache/prefetch-app-shell-cached-gsp/app/layout.tsx rename to test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/layout.tsx index fa1546fe2d01..3e61d2fa2638 100644 --- a/test/e2e/app-dir/segment-cache/prefetch-app-shell-cached-gsp/app/layout.tsx +++ b/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/layout.tsx @@ -2,12 +2,10 @@ import { Suspense } from 'react' import { cookies } from 'next/headers' // Reading a cookie makes the shell depend on session data, so a prefetch -// renders a runtime App Shell. This is what exercises the prospective/final -// runtime prerender where params are a hanging input in a cached page. +// renders a runtime App Shell. async function SessionData() { - const cookieStore = await cookies() - const value = cookieStore.get('testCookie')?.value ?? 'none' - return + await cookies() + return null } export default async function Layout({ children }: LayoutProps<'/'>) { @@ -17,7 +15,7 @@ export default async function Layout({ children }: LayoutProps<'/'>) { Loading cookie...

}>
- {children} + {children} ) diff --git a/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/lazy-data-in-prefetch/page.tsx b/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/lazy-data-in-prefetch/page.tsx new file mode 100644 index 000000000000..b46d88e0ef7d --- /dev/null +++ b/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/lazy-data-in-prefetch/page.tsx @@ -0,0 +1,38 @@ +import { + unstable_prefetch as prefetch, + unstable_navigation as navigation, +} from 'next/cache' +import { cookies } from 'next/headers' +import { Suspense } from 'react' + +export default async function Page() { + await cookies() // Avoid static prerendering + return ( +
+

This page gates caches behind prefetch and navigation.

+ Loading prefetch data...

}> + +
+ Loading navigation data...

}> + +
+
+ ) +} + +async function PrefetchData() { + await prefetch() + await cachedFn('after prefetch') + return

Prefetch data

+} + +async function NavigationData() { + await navigation() + await cachedFn('after navigation') + return

Navigation data

+} + +async function cachedFn(key: string) { + 'use cache' + console.log('cachedFn :: ' + key) +} diff --git a/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/lazy-data-in-shell/page.tsx b/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/lazy-data-in-shell/page.tsx new file mode 100644 index 000000000000..b46d88e0ef7d --- /dev/null +++ b/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/lazy-data-in-shell/page.tsx @@ -0,0 +1,38 @@ +import { + unstable_prefetch as prefetch, + unstable_navigation as navigation, +} from 'next/cache' +import { cookies } from 'next/headers' +import { Suspense } from 'react' + +export default async function Page() { + await cookies() // Avoid static prerendering + return ( +
+

This page gates caches behind prefetch and navigation.

+ Loading prefetch data...

}> + +
+ Loading navigation data...

}> + +
+
+ ) +} + +async function PrefetchData() { + await prefetch() + await cachedFn('after prefetch') + return

Prefetch data

+} + +async function NavigationData() { + await navigation() + await cachedFn('after navigation') + return

Navigation data

+} + +async function cachedFn(key: string) { + 'use cache' + console.log('cachedFn :: ' + key) +} diff --git a/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/page.tsx b/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/page.tsx new file mode 100644 index 000000000000..5db8656dc31a --- /dev/null +++ b/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/page.tsx @@ -0,0 +1,35 @@ +import { LinkAccordion } from '../components/link-accordion' + +export default function Page() { + return ( +
+
    +
  • + + prerendered from gSP - /slug/prerendered + +
  • +
  • + + /pass-prefetch-to-cache + +
  • +
  • + + /pass-navigation-to-cache (prefetch=true) + +
  • +
  • + + /lazy-data-in-shell + +
  • +
  • + + /lazy-data-in-prefetch (prefetch=true) + +
  • +
+
+ ) +} diff --git a/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/pass-navigation-to-cache/page.tsx b/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/pass-navigation-to-cache/page.tsx new file mode 100644 index 000000000000..995a7ce3967f --- /dev/null +++ b/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/pass-navigation-to-cache/page.tsx @@ -0,0 +1,36 @@ +import { unstable_navigation as navigation } from 'next/cache' +import { cookies } from 'next/headers' +import { connection } from 'next/server' +import { Suspense } from 'react' + +export default async function Page() { + await cookies() // Avoid static prerendering + + // `navigation()` should be a hanging input. + const result = await cachedFn(navigation()) + return ( +
+

This page passes a prefetch() promise to a cache

+

{result}

+ + + + +
+ ) +} + +async function DynamicData() { + await connection() + return 'Dynamic data' +} + +async function cachedFn(input: any) { + 'use cache' + // Make sure the argument is not optimized away as unused + if (Math.random() < 0) { + console.log(input) + } + await new Promise((resolve) => setTimeout(resolve)) + return 'Cached data: ' + Date.now() +} diff --git a/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/pass-prefetch-to-cache/page.tsx b/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/pass-prefetch-to-cache/page.tsx new file mode 100644 index 000000000000..640d3748cf53 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/pass-prefetch-to-cache/page.tsx @@ -0,0 +1,36 @@ +import { unstable_prefetch as prefetch } from 'next/cache' +import { cookies } from 'next/headers' +import { connection } from 'next/server' +import { Suspense } from 'react' + +export default async function Page() { + await cookies() // Avoid static prerendering + + // `prefetch()` should be a hanging input. + const result = await cachedFn(prefetch()) + return ( +
+

This page passes a prefetch() promise to a cache

+

{result}

+ + + + +
+ ) +} + +async function DynamicData() { + await connection() + return 'Dynamic data' +} + +async function cachedFn(input: any) { + 'use cache' + // Make sure the argument is not optimized away as unused + if (Math.random() < 0) { + console.log(input) + } + await new Promise((resolve) => setTimeout(resolve)) + return 'Cached data: ' + Date.now() +} diff --git a/test/e2e/app-dir/segment-cache/prefetch-app-shell-cached-gsp/app/slug/[slug]/page.tsx b/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/slug/[slug]/page.tsx similarity index 100% rename from test/e2e/app-dir/segment-cache/prefetch-app-shell-cached-gsp/app/slug/[slug]/page.tsx rename to test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/app/slug/[slug]/page.tsx diff --git a/test/e2e/app-dir/segment-cache/prefetch-app-shell-cached-gsp/components/link-accordion.tsx b/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/components/link-accordion.tsx similarity index 100% rename from test/e2e/app-dir/segment-cache/prefetch-app-shell-cached-gsp/components/link-accordion.tsx rename to test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/components/link-accordion.tsx diff --git a/test/e2e/app-dir/segment-cache/prefetch-app-shell-cached-gsp/next.config.ts b/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/next.config.ts similarity index 100% rename from test/e2e/app-dir/segment-cache/prefetch-app-shell-cached-gsp/next.config.ts rename to test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/next.config.ts diff --git a/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/runtime-prerender-cache-warming.test.ts b/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/runtime-prerender-cache-warming.test.ts new file mode 100644 index 000000000000..ce8cd3b174a2 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/runtime-prerender-cache-warming/runtime-prerender-cache-warming.test.ts @@ -0,0 +1,237 @@ +import { nextTestSetup } from 'e2e-utils' +import type * as Playwright from 'playwright' +import { createRouterAct } from 'router-act' + +const CACHE_MISS_WARNING = 'Unexpected cache miss after cache warming phase' + +describe('runtime prerender cache warming', () => { + const { next, isNextDev, skipped } = nextTestSetup({ + files: __dirname, + skipDeployment: true, // reads CLI output + }) + if (skipped) return + + if (isNextDev) { + it.skip('no prefetching in dev', () => {}) + return + } + + it('does not report a cache miss when prefetching the shell of a cached page that reads static params', async () => { + let page: Playwright.Page + const browser = await next.browser('/', { + beforePageLoad(p: Playwright.Page) { + page = p + }, + }) + const act = createRouterAct(page, { includeAppShellRequests: true }) + + // Load the App Shell for /slug/[slug]. + // Shells cannot access params, so the page gets them as a hanging input. + await act(async () => { + await browser + .elementByCss('input[data-link-accordion="/slug/prerendered"]') + .click() + }, [ + { + includes: 'Slug: prerendered', + block: 'reject', + }, + ]) + + // The App Shell request renders the runtime shell of the /slug/[slug] route. + // The page is a `'use cache'` page that awaits static params and params + // should be a hanging input in the final prerender. + // If the prospective (cache-warming) prerender resolved params instead of + // leaving them hanging, the cached page's key would differ between the two + // prerenders and the final prerender would log an "Unexpected cache miss" + // warning and degrade the cached segment to a dynamic hole. + expect(next.cliOutput).not.toContain(CACHE_MISS_WARNING) + + // When we navigate, params become available. + await act(() => browser.elementByCss('a[href="/slug/prerendered"]').click()) + expect(await browser.elementById('slug').text()).toEqual( + 'Slug: prerendered' + ) + }) + + it('prefetch() is a hanging input when passed to a cache in a runtime shell', async () => { + let page: Playwright.Page + const browser = await next.browser('/', { + beforePageLoad(p: Playwright.Page) { + page = p + }, + }) + const act = createRouterAct(page, { includeAppShellRequests: true }) + + // Load the App Shell for the page. + // It should include the result of the cache. + await act(async () => { + await browser + .elementByCss('input[data-link-accordion="/pass-prefetch-to-cache"]') + .click() + }, [ + { + includes: 'Cached data', + kind: 'runtime', + }, + ]) + + expect(next.cliOutput).not.toContain(CACHE_MISS_WARNING) + + // Navigate, but capture the result of the cache as shown in the shell. + const cachedDataInShell = await act(async () => { + await browser.elementByCss('a[href="/pass-prefetch-to-cache"]').click() + return browser.elementByCss('#cached-data').text() + }) + + // prefetch() was a hanging input in the shell, but resolves in a navigation, + // so the keys don't match and we should get a fresh cache result. + const cachedDataInNavigation = await browser + .elementByCss('#cached-data') + .text() + expect(cachedDataInShell).not.toEqual(cachedDataInNavigation) + }) + + it('navigation() is a hanging input when passed to a cache in both a runtime shell and prefetch', async () => { + let page: Playwright.Page + const browser = await next.browser('/', { + beforePageLoad(p: Playwright.Page) { + page = p + }, + }) + const act = createRouterAct(page, { includeAppShellRequests: true }) + + // Prefetch the page. + // It should include the result of the cache. + await act(async () => { + await browser + .elementByCss('input[data-link-accordion="/pass-navigation-to-cache"]') + .click() + }, [ + // We assert on shells and prefetch behavior in one go, + // because the router fetches both, in two request. + // Shell + { + includes: 'Cached data', + kind: 'runtime', + }, + // Prefetch + { + includes: 'Cached data', + kind: 'runtime', + }, + ]) + + expect(next.cliOutput).not.toContain(CACHE_MISS_WARNING) + + // Navigate, but capture the result of the cache as shown in the shell. + const cachedDataInShell = await act(async () => { + await browser.elementByCss('a[href="/pass-navigation-to-cache"]').click() + return browser.elementByCss('#cached-data').text() + }) + + // navigation() was a hanging input in the shell, but resolves in a navigation, + // so the keys don't match and we should get a fresh cache result. + const cachedDataInNavigation = await browser + .elementByCss('#cached-data') + .text() + expect(cachedDataInShell).not.toEqual(cachedDataInNavigation) + }) + + it('caches hidden behind prefetch() are not warmed when rendering a runtime shell', async () => { + let page: Playwright.Page + const browser = await next.browser('/', { + beforePageLoad(p: Playwright.Page) { + page = p + }, + }) + const act = createRouterAct(page, { includeAppShellRequests: true }) + const beforeShellIx = next.cliOutput.length + + // Load the shell for the page. + await act(async () => { + await browser + .elementByCss('input[data-link-accordion="/lazy-data-in-shell"]') + .click() + }, [ + { + includes: 'This page gates caches behind prefetch and navigation.', + kind: 'runtime', + }, + { + includes: 'Prefetch data', + block: 'reject', + }, + { + includes: 'Navigation data', + block: 'reject', + }, + ]) + + // The caches should not have been warmed, because the prospective prerender + // for a shell shouldn't resolve prefetch() or navigation(). + const shellLogs = next.cliOutput.slice(beforeShellIx) + expect(shellLogs).not.toContain('cachedFn :: after prefetch') + expect(shellLogs).not.toContain('cachedFn :: after navigation') + + const beforeNavigationIx = next.cliOutput.length + // Navigate, which should run the caches and thus print the logs. + await act(() => + browser.elementByCss('a[href="/lazy-data-in-shell"]').click() + ) + const navigationLogs = next.cliOutput.slice(beforeNavigationIx) + expect(navigationLogs).toContain('cachedFn :: after prefetch') + expect(navigationLogs).toContain('cachedFn :: after navigation') + }) + + it('caches hidden behind navigation() are not warmed when rendering a runtime shell or prefetch', async () => { + let page: Playwright.Page + const browser = await next.browser('/', { + beforePageLoad(p: Playwright.Page) { + page = p + }, + }) + const act = createRouterAct(page, { includeAppShellRequests: true }) + const beforePrefetchIx = next.cliOutput.length + + // Load the shell for the page. + await act(async () => { + await browser + .elementByCss('input[data-link-accordion="/lazy-data-in-prefetch"]') + .click() + }, [ + // Shell + { + includes: 'This page gates caches behind prefetch and navigation.', + kind: 'runtime', + }, + // Prefetch + { + includes: 'Prefetch data', + kind: 'runtime', + }, + { + includes: 'Navigation data', + block: 'reject', + }, + ]) + + const shellAndPrefetchLogs = next.cliOutput.slice(beforePrefetchIx) + // The cache behind prefetch() should've been warmed in the prefetch request. + expect(shellAndPrefetchLogs).toContain('cachedFn :: after prefetch') + // The cache behind navigation() should not have been warmed. + expect(shellAndPrefetchLogs).not.toContain('cachedFn :: after navigation') + + const beforeNavigationIx = next.cliOutput.length + + // Navigate, which should run the caches and thus print the logs. + await act(() => + browser.elementByCss('a[href="/lazy-data-in-prefetch"]').click() + ) + const navigationLogs = next.cliOutput.slice(beforeNavigationIx) + // Already warmed by the prefetch request, and logs are not replayed. + expect(navigationLogs).not.toContain('cachedFn :: after prefetch') + // New in this request. + expect(navigationLogs).toContain('cachedFn :: after navigation') + }) +}) From 4d36801fdbc0b6b97c497c6c1820005477b9aa6d Mon Sep 17 00:00:00 2001 From: Marcos Hernanz <96699542+marcoshernanz@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:50:12 -0700 Subject: [PATCH 4/8] Fix intercepted route params after Proxy rewrites (#97953) ## What? Preserve the final internal rewrite pathname in the RSC response when an earlier Proxy rewrite has already populated the rewritten-path header. This also adds an end-to-end regression assertion for a dynamic interception route behind a locale-injecting middleware rewrite. ## Why? The route resolver set the final marker-bearing pathname directly on the response, but router-server later reapplied the accumulated Proxy response headers. That restored the earlier markerless pathname. The client then parsed an intercepted dynamic param against that stale path and removed three real characters from the value. ## How? Mirror the final internal rewrite pathname into the accumulated response-header map. When router-server applies that map after route resolution, the last rewrite remains authoritative. Fixes #97939 ## Tests - Production interception regression suite: 4/4 passed - Development interception regression suite: 4/4 passed - Related production rewrite/interception suites: 43/43 passed - Next package build, targeted ESLint, Prettier, and git diff checks passed --- .../server/lib/router-utils/resolve-routes.ts | 7 ++++++- ...rception-dynamic-segment-middleware.test.ts | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/next/src/server/lib/router-utils/resolve-routes.ts b/packages/next/src/server/lib/router-utils/resolve-routes.ts index 6438eafaf1da..01803ef268f9 100644 --- a/packages/next/src/server/lib/router-utils/resolve-routes.ts +++ b/packages/next/src/server/lib/router-utils/resolve-routes.ts @@ -888,12 +888,17 @@ export function getResolveRoutes( (!parsedDestination.origin || isAllowedOrigin) ) { // We set the rewritten path and query headers on the response now - // that we know that the it's not an external rewrite. + // that we know that the it's not an external rewrite. Mirror the + // path in resHeaders because those are applied after route + // resolution; otherwise, an earlier Proxy rewrite would overwrite + // the final route destination. if (parsedUrl.pathname !== parsedDestination.pathname) { res.setHeader( NEXT_REWRITTEN_PATH_HEADER, parsedDestination.pathname ) + resHeaders[NEXT_REWRITTEN_PATH_HEADER] = + parsedDestination.pathname } if (parsedUrl.search !== parsedDestination.search) { res.setHeader( diff --git a/test/e2e/app-dir/interception-dynamic-segment-middleware/interception-dynamic-segment-middleware.test.ts b/test/e2e/app-dir/interception-dynamic-segment-middleware/interception-dynamic-segment-middleware.test.ts index 53e4dbdf4dc0..c8cb97cce9a6 100644 --- a/test/e2e/app-dir/interception-dynamic-segment-middleware/interception-dynamic-segment-middleware.test.ts +++ b/test/e2e/app-dir/interception-dynamic-segment-middleware/interception-dynamic-segment-middleware.test.ts @@ -65,4 +65,22 @@ describe('interception-dynamic-segment-middleware', () => { }) } }) + + it('should preserve the final interception rewrite after a middleware rewrite', async () => { + let rewrittenPath: string | undefined + const browser = await next.browser('/', { + beforePageLoad(page) { + page.on('response', (response) => { + if (new URL(response.url()).pathname === '/foo/p/1') { + rewrittenPath = response.headers()['x-nextjs-rewritten-path'] + } + }) + }, + }) + + await browser.elementByCss('[href="/foo/p/1"]').click() + await retry(() => { + expect(rewrittenPath).toBe('/en/(.)foo/p/1') + }) + }) }) From a41e98e72534f87952990058a0b146bc99ca63ea Mon Sep 17 00:00:00 2001 From: Marcos Hernanz <96699542+marcoshernanz@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:50:36 -0700 Subject: [PATCH 5/8] Fix optimistic routing for encoded dynamic params (#97948) ## Summary Fixes #97842 by canonicalizing encoded pathname parts before using them as dynamic segment cache keys in optimistic route predictions. This keeps predicted route trees consistent with server Flight data and prevents query-only navigation from remounting pages when regular or catch-all params contain encoded characters. Add coverage that verifies bfcache IDs and form state are preserved for `@` in a regular dynamic param and for `@` plus `%2F` in a catch-all param. ## Verification - Existing encoded-slash route-cache E2E suite in Turbopack and webpack production modes - Existing optimistic-routing E2E suite in Turbopack and webpack production modes - `pnpm test-types` - `pnpm types` - `pnpm prettier-check` - `pnpm lint-ast-grep` - `pnpm lint-language` - Changed-file Prettier and ESLint checks via the pre-commit hook Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com> --- .../segment-cache/optimistic-routes.ts | 6 ++--- .../app/[group]/[page]/leaf-content.tsx | 7 ++++++ .../app/catchall/[...slug]/page.tsx | 16 ++++++++++++ .../use-router-bfcache-id.test.ts | 25 +++++++++++++++++++ 4 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 test/e2e/app-dir/use-router-bfcache-id/app/catchall/[...slug]/page.tsx diff --git a/packages/next/src/client/components/segment-cache/optimistic-routes.ts b/packages/next/src/client/components/segment-cache/optimistic-routes.ts index 6630b903f6eb..d5245d828be4 100644 --- a/packages/next/src/client/components/segment-cache/optimistic-routes.ts +++ b/packages/next/src/client/components/segment-cache/optimistic-routes.ts @@ -921,7 +921,7 @@ function matchKnownRoutePart( ) { resolvedParams.set( paramName, - pathnameParts.slice(partIndex).join('/') + pathnameParts.slice(partIndex).map(canonicalizeURLPart).join('/') ) return { part: dynamicPart, pattern: dynamicPattern } } @@ -932,7 +932,7 @@ function matchKnownRoutePart( if (urlPart !== null) { resolvedParams.set( paramName, - pathnameParts.slice(partIndex).join('/') + pathnameParts.slice(partIndex).map(canonicalizeURLPart).join('/') ) return { part: dynamicPart, pattern: dynamicPattern } } @@ -951,7 +951,7 @@ function matchKnownRoutePart( // Unlike catch-all which terminates here, regular dynamic must // continue recursing to find the leaf pattern. if (urlPart !== null) { - resolvedParams.set(paramName, urlPart) + resolvedParams.set(paramName, canonicalizeURLPart(urlPart)) return matchKnownRoutePart( now, dynamicPart, diff --git a/test/e2e/app-dir/use-router-bfcache-id/app/[group]/[page]/leaf-content.tsx b/test/e2e/app-dir/use-router-bfcache-id/app/[group]/[page]/leaf-content.tsx index 70db17eb8061..122b4723513c 100644 --- a/test/e2e/app-dir/use-router-bfcache-id/app/[group]/[page]/leaf-content.tsx +++ b/test/e2e/app-dir/use-router-bfcache-id/app/[group]/[page]/leaf-content.tsx @@ -31,6 +31,7 @@ export function LeafContent() { {search} + {bfcacheId}
@@ -38,6 +39,12 @@ export function LeafContent() { same page (#section) + diff --git a/test/e2e/app-dir/use-router-bfcache-id/app/catchall/[...slug]/page.tsx b/test/e2e/app-dir/use-router-bfcache-id/app/catchall/[...slug]/page.tsx new file mode 100644 index 000000000000..21967833e3ff --- /dev/null +++ b/test/e2e/app-dir/use-router-bfcache-id/app/catchall/[...slug]/page.tsx @@ -0,0 +1,16 @@ +import { Suspense } from 'react' +import { connection } from 'next/server' +import { LeafContent } from '../../[group]/[page]/leaf-content' + +async function DynamicLeafContent() { + await connection() + return +} + +export default function CatchAllPage() { + return ( + + + + ) +} diff --git a/test/e2e/app-dir/use-router-bfcache-id/use-router-bfcache-id.test.ts b/test/e2e/app-dir/use-router-bfcache-id/use-router-bfcache-id.test.ts index f82e478eda81..11b05a66aa57 100644 --- a/test/e2e/app-dir/use-router-bfcache-id/use-router-bfcache-id.test.ts +++ b/test/e2e/app-dir/use-router-bfcache-id/use-router-bfcache-id.test.ts @@ -107,6 +107,31 @@ describe('use-router-bfcache-id', () => { ).toBe('hello') }) + it.each([ + ['regular dynamic', '/ko/@alice'], + ['catch-all dynamic', '/catchall/@alice/nested%2Fvalue'], + ])( + 'preserves form state across query-only replace with an encoded %s parameter', + async (_paramType, initialPath) => { + const { browser, act } = await setup(initialPath) + await browser.elementByCss('[data-testid="leaf-input"]').type('hello') + const initialBFCacheId = await browser + .elementByCss('[data-testid="bfcache-id"]') + .text() + + await act(async () => { + await browser.elementByCss('[data-testid="query-only-replace"]').click() + }) + + expect( + await browser.elementByCss('[data-testid="bfcache-id"]').text() + ).toBe(initialBFCacheId) + expect( + await browser.elementByCss('[data-testid="leaf-input"]').getValue() + ).toBe('hello') + } + ) + it('preserves form state across router.refresh()', async () => { const { browser, act } = await setup('/x/1') await browser.elementByCss('[data-testid="leaf-input"]').type('hello') From 0d4dc0c3a51458687524827729e8f8c050d9d26a Mon Sep 17 00:00:00 2001 From: "next-js-bot[bot]" <279046576+next-js-bot[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:18:43 +0000 Subject: [PATCH 6/8] v16.4.0-canary.11 --- lerna.json | 2 +- packages/create-next-app/package.json | 2 +- packages/devlow-bench/package.json | 2 +- packages/eslint-config-next/package.json | 4 ++-- packages/eslint-plugin-internal/package.json | 2 +- packages/eslint-plugin-next/package.json | 2 +- packages/font/package.json | 2 +- packages/next-bundle-analyzer/package.json | 2 +- packages/next-codemod/package.json | 2 +- packages/next-env/package.json | 2 +- packages/next-mdx/package.json | 2 +- packages/next-playwright/package.json | 2 +- packages/next-plugin-storybook/package.json | 2 +- packages/next-polyfill-module/package.json | 2 +- packages/next-polyfill-nomodule/package.json | 2 +- packages/next-routing/package.json | 2 +- packages/next-rspack/package.json | 2 +- packages/next-swc/package.json | 2 +- packages/next/package.json | 14 ++++++------- packages/react-refresh-utils/package.json | 2 +- packages/third-parties/package.json | 4 ++-- pnpm-lock.yaml | 22 ++++++++++---------- 22 files changed, 40 insertions(+), 40 deletions(-) diff --git a/lerna.json b/lerna.json index 1abd07f907ce..6e082cd5b47f 100644 --- a/lerna.json +++ b/lerna.json @@ -15,5 +15,5 @@ "registry": "https://registry.npmjs.org/" } }, - "version": "16.4.0-canary.10" + "version": "16.4.0-canary.11" } \ No newline at end of file diff --git a/packages/create-next-app/package.json b/packages/create-next-app/package.json index cf56b456fd6d..be3ae47823de 100644 --- a/packages/create-next-app/package.json +++ b/packages/create-next-app/package.json @@ -1,6 +1,6 @@ { "name": "create-next-app", - "version": "16.4.0-canary.10", + "version": "16.4.0-canary.11", "keywords": [ "react", "next", diff --git a/packages/devlow-bench/package.json b/packages/devlow-bench/package.json index 293b6a02d0ae..c725443b1ec7 100644 --- a/packages/devlow-bench/package.json +++ b/packages/devlow-bench/package.json @@ -1,7 +1,7 @@ { "name": "@vercel/devlow-bench", "private": true, - "version": "16.4.0-canary.10", + "version": "16.4.0-canary.11", "description": "Benchmarking tool for the developer workflow", "repository": { "type": "git", diff --git a/packages/eslint-config-next/package.json b/packages/eslint-config-next/package.json index c03c631fc11e..3892c11c18ca 100644 --- a/packages/eslint-config-next/package.json +++ b/packages/eslint-config-next/package.json @@ -1,6 +1,6 @@ { "name": "eslint-config-next", - "version": "16.4.0-canary.10", + "version": "16.4.0-canary.11", "description": "ESLint configuration used by Next.js.", "license": "MIT", "repository": { @@ -12,7 +12,7 @@ "dist" ], "dependencies": { - "@next/eslint-plugin-next": "16.4.0-canary.10", + "@next/eslint-plugin-next": "16.4.0-canary.11", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", diff --git a/packages/eslint-plugin-internal/package.json b/packages/eslint-plugin-internal/package.json index 187883fffc01..a13b9d3494c9 100644 --- a/packages/eslint-plugin-internal/package.json +++ b/packages/eslint-plugin-internal/package.json @@ -1,7 +1,7 @@ { "name": "@next/eslint-plugin-internal", "private": true, - "version": "16.4.0-canary.10", + "version": "16.4.0-canary.11", "description": "ESLint plugin for working on Next.js.", "exports": { ".": "./src/eslint-plugin-internal.js" diff --git a/packages/eslint-plugin-next/package.json b/packages/eslint-plugin-next/package.json index f8847e7785ff..7f30170aa0b6 100644 --- a/packages/eslint-plugin-next/package.json +++ b/packages/eslint-plugin-next/package.json @@ -1,6 +1,6 @@ { "name": "@next/eslint-plugin-next", - "version": "16.4.0-canary.10", + "version": "16.4.0-canary.11", "description": "ESLint plugin for Next.js.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/font/package.json b/packages/font/package.json index b19dcdbcad2c..b264b04499d5 100644 --- a/packages/font/package.json +++ b/packages/font/package.json @@ -1,7 +1,7 @@ { "name": "@next/font", "private": true, - "version": "16.4.0-canary.10", + "version": "16.4.0-canary.11", "repository": { "url": "vercel/next.js", "directory": "packages/font" diff --git a/packages/next-bundle-analyzer/package.json b/packages/next-bundle-analyzer/package.json index 09409943ab50..8061e0975e16 100644 --- a/packages/next-bundle-analyzer/package.json +++ b/packages/next-bundle-analyzer/package.json @@ -1,6 +1,6 @@ { "name": "@next/bundle-analyzer", - "version": "16.4.0-canary.10", + "version": "16.4.0-canary.11", "main": "index.js", "types": "index.d.ts", "license": "MIT", diff --git a/packages/next-codemod/package.json b/packages/next-codemod/package.json index bd5c6dc82cfd..e10068efe64b 100644 --- a/packages/next-codemod/package.json +++ b/packages/next-codemod/package.json @@ -1,6 +1,6 @@ { "name": "@next/codemod", - "version": "16.4.0-canary.10", + "version": "16.4.0-canary.11", "license": "MIT", "repository": { "type": "git", diff --git a/packages/next-env/package.json b/packages/next-env/package.json index 50daed34841d..d38cb2c0d12b 100644 --- a/packages/next-env/package.json +++ b/packages/next-env/package.json @@ -1,6 +1,6 @@ { "name": "@next/env", - "version": "16.4.0-canary.10", + "version": "16.4.0-canary.11", "keywords": [ "react", "next", diff --git a/packages/next-mdx/package.json b/packages/next-mdx/package.json index 4e19075d1408..99868f6b87c5 100644 --- a/packages/next-mdx/package.json +++ b/packages/next-mdx/package.json @@ -1,6 +1,6 @@ { "name": "@next/mdx", - "version": "16.4.0-canary.10", + "version": "16.4.0-canary.11", "main": "index.js", "license": "MIT", "repository": { diff --git a/packages/next-playwright/package.json b/packages/next-playwright/package.json index 9656d7411628..fda7cff10352 100644 --- a/packages/next-playwright/package.json +++ b/packages/next-playwright/package.json @@ -1,6 +1,6 @@ { "name": "@next/playwright", - "version": "16.4.0-canary.10", + "version": "16.4.0-canary.11", "repository": { "url": "vercel/next.js", "directory": "packages/next-playwright" diff --git a/packages/next-plugin-storybook/package.json b/packages/next-plugin-storybook/package.json index f3f3ddc055f0..6dfb79810ebc 100644 --- a/packages/next-plugin-storybook/package.json +++ b/packages/next-plugin-storybook/package.json @@ -1,6 +1,6 @@ { "name": "@next/plugin-storybook", - "version": "16.4.0-canary.10", + "version": "16.4.0-canary.11", "repository": { "url": "vercel/next.js", "directory": "packages/next-plugin-storybook" diff --git a/packages/next-polyfill-module/package.json b/packages/next-polyfill-module/package.json index 27bbfee20b09..46b933008304 100644 --- a/packages/next-polyfill-module/package.json +++ b/packages/next-polyfill-module/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-module", - "version": "16.4.0-canary.10", + "version": "16.4.0-canary.11", "description": "A standard library polyfill for ES Modules supporting browsers (Edge 16+, Firefox 60+, Chrome 61+, Safari 10.1+)", "main": "dist/polyfill-module.js", "license": "MIT", diff --git a/packages/next-polyfill-nomodule/package.json b/packages/next-polyfill-nomodule/package.json index be939f2fde28..27e80379e5da 100644 --- a/packages/next-polyfill-nomodule/package.json +++ b/packages/next-polyfill-nomodule/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-nomodule", - "version": "16.4.0-canary.10", + "version": "16.4.0-canary.11", "description": "A polyfill for non-dead, nomodule browsers.", "main": "dist/polyfill-nomodule.js", "license": "MIT", diff --git a/packages/next-routing/package.json b/packages/next-routing/package.json index b93ef8b3eb46..af6e684a14e8 100644 --- a/packages/next-routing/package.json +++ b/packages/next-routing/package.json @@ -1,6 +1,6 @@ { "name": "@next/routing", - "version": "16.4.0-canary.10", + "version": "16.4.0-canary.11", "keywords": [ "react", "next", diff --git a/packages/next-rspack/package.json b/packages/next-rspack/package.json index 560c76396d82..457162ce6a1f 100644 --- a/packages/next-rspack/package.json +++ b/packages/next-rspack/package.json @@ -1,6 +1,6 @@ { "name": "next-rspack", - "version": "16.4.0-canary.10", + "version": "16.4.0-canary.11", "repository": { "url": "vercel/next.js", "directory": "packages/next-rspack" diff --git a/packages/next-swc/package.json b/packages/next-swc/package.json index 59286d533d02..f78f4a55d8b4 100644 --- a/packages/next-swc/package.json +++ b/packages/next-swc/package.json @@ -1,6 +1,6 @@ { "name": "@next/swc", - "version": "16.4.0-canary.10", + "version": "16.4.0-canary.11", "private": true, "files": [ "native/" diff --git a/packages/next/package.json b/packages/next/package.json index 321ba3dc6367..a7d94c1770e0 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "next", - "version": "16.4.0-canary.10", + "version": "16.4.0-canary.11", "description": "The React Framework", "main": "./dist/server/next.js", "license": "MIT", @@ -100,7 +100,7 @@ ] }, "dependencies": { - "@next/env": "16.4.0-canary.10", + "@next/env": "16.4.0-canary.11", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -164,11 +164,11 @@ "@modelcontextprotocol/sdk": "1.18.1", "@mswjs/interceptors": "0.42.0", "@napi-rs/triples": "1.2.0", - "@next/font": "16.4.0-canary.10", - "@next/polyfill-module": "16.4.0-canary.10", - "@next/polyfill-nomodule": "16.4.0-canary.10", - "@next/react-refresh-utils": "16.4.0-canary.10", - "@next/swc": "16.4.0-canary.10", + "@next/font": "16.4.0-canary.11", + "@next/polyfill-module": "16.4.0-canary.11", + "@next/polyfill-nomodule": "16.4.0-canary.11", + "@next/react-refresh-utils": "16.4.0-canary.11", + "@next/swc": "16.4.0-canary.11", "@opentelemetry/api": "1.6.0", "@playwright/test": "1.61.0", "@rspack/core": "1.6.7", diff --git a/packages/react-refresh-utils/package.json b/packages/react-refresh-utils/package.json index 3df93e3f67e7..be8d7f6c8519 100644 --- a/packages/react-refresh-utils/package.json +++ b/packages/react-refresh-utils/package.json @@ -1,6 +1,6 @@ { "name": "@next/react-refresh-utils", - "version": "16.4.0-canary.10", + "version": "16.4.0-canary.11", "description": "An experimental package providing utilities for React Refresh.", "repository": { "url": "vercel/next.js", diff --git a/packages/third-parties/package.json b/packages/third-parties/package.json index 7d450309beea..62c8e74571ed 100644 --- a/packages/third-parties/package.json +++ b/packages/third-parties/package.json @@ -1,6 +1,6 @@ { "name": "@next/third-parties", - "version": "16.4.0-canary.10", + "version": "16.4.0-canary.11", "repository": { "url": "vercel/next.js", "directory": "packages/third-parties" @@ -26,7 +26,7 @@ "third-party-capital": "1.0.20" }, "devDependencies": { - "next": "16.4.0-canary.10", + "next": "16.4.0-canary.11", "outdent": "0.8.0", "prettier": "2.5.1", "typescript": "6.0.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ae4a6114100..23d9b1fc06dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1024,7 +1024,7 @@ importers: packages/eslint-config-next: dependencies: '@next/eslint-plugin-next': - specifier: 16.4.0-canary.10 + specifier: 16.4.0-canary.11 version: link:../eslint-plugin-next eslint: specifier: '>=9.0.0' @@ -1107,7 +1107,7 @@ importers: packages/next: dependencies: '@next/env': - specifier: 16.4.0-canary.10 + specifier: 16.4.0-canary.11 version: link:../next-env '@swc/helpers': specifier: 0.5.23 @@ -1228,19 +1228,19 @@ importers: specifier: 1.2.0 version: 1.2.0 '@next/font': - specifier: 16.4.0-canary.10 + specifier: 16.4.0-canary.11 version: link:../font '@next/polyfill-module': - specifier: 16.4.0-canary.10 + specifier: 16.4.0-canary.11 version: link:../next-polyfill-module '@next/polyfill-nomodule': - specifier: 16.4.0-canary.10 + specifier: 16.4.0-canary.11 version: link:../next-polyfill-nomodule '@next/react-refresh-utils': - specifier: 16.4.0-canary.10 + specifier: 16.4.0-canary.11 version: link:../react-refresh-utils '@next/swc': - specifier: 16.4.0-canary.10 + specifier: 16.4.0-canary.11 version: link:../next-swc '@opentelemetry/api': specifier: 1.6.0 @@ -1956,7 +1956,7 @@ importers: devDependencies: '@napi-rs/cli': specifier: 3.7.2 - version: 3.7.2(@emnapi/runtime@1.9.2)(@types/node@20.17.7(patch_hash=fe6957869a9b616ec526ededb72bbe846b863bd2349204218772df5c1e62d6ab))(node-addon-api@6.1.0) + version: 3.7.2(@emnapi/runtime@1.11.2)(@types/node@20.17.7(patch_hash=fe6957869a9b616ec526ededb72bbe846b863bd2349204218772df5c1e62d6ab))(node-addon-api@6.1.0) cross-env: specifier: 6.0.3 version: 6.0.3 @@ -1983,7 +1983,7 @@ importers: version: 1.0.20 devDependencies: next: - specifier: 16.4.0-canary.10 + specifier: 16.4.0-canary.11 version: link:../next outdent: specifier: 0.8.0 @@ -21870,7 +21870,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@napi-rs/cli@3.7.2(@emnapi/runtime@1.9.2)(@types/node@20.17.7(patch_hash=fe6957869a9b616ec526ededb72bbe846b863bd2349204218772df5c1e62d6ab))(node-addon-api@6.1.0)': + '@napi-rs/cli@3.7.2(@emnapi/runtime@1.11.2)(@types/node@20.17.7(patch_hash=fe6957869a9b616ec526ededb72bbe846b863bd2349204218772df5c1e62d6ab))(node-addon-api@6.1.0)': dependencies: '@inquirer/prompts': 8.5.2(@types/node@20.17.7(patch_hash=fe6957869a9b616ec526ededb72bbe846b863bd2349204218772df5c1e62d6ab)) '@napi-rs/cross-toolchain': 1.0.3 @@ -21885,7 +21885,7 @@ snapshots: semver: 7.8.5 typanion: 3.14.0 optionalDependencies: - '@emnapi/runtime': 1.9.2 + '@emnapi/runtime': 1.11.2 transitivePeerDependencies: - '@napi-rs/cross-toolchain-arm64-target-aarch64' - '@napi-rs/cross-toolchain-arm64-target-armv7' From 6fb98fae8aa66d1c6ca5f529c888232361839599 Mon Sep 17 00:00:00 2001 From: Jiwon Choi Date: Sat, 29 Aug 2026 01:42:42 +0200 Subject: [PATCH 7/8] Transform .mjs files with Next.js codemods (#98029) ### Why? The shared codemod runner only passed `tsx`, `ts`, `jsx`, and `js` files to jscodeshift. As a result, config transforms that recognize the supported `next.config.mjs` format never received those files, leaving migrations such as `experimental.turbo` to `turbopack` incomplete. ### How? Add `mjs` to the existing jscodeshift `--extensions` argument. Existing parser selection and Next.js config detection already support this format. Unsupported `next.config.cjs` files remain excluded. --- packages/next-codemod/bin/transform.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/next-codemod/bin/transform.ts b/packages/next-codemod/bin/transform.ts index c8efc5aa532b..f9512fb21f21 100644 --- a/packages/next-codemod/bin/transform.ts +++ b/packages/next-codemod/bin/transform.ts @@ -139,7 +139,7 @@ export async function runTransform( args.push('--ignore-pattern=**/node_modules/**') args.push('--ignore-pattern=**/.next/**') - args.push('--extensions=tsx,ts,jsx,js') + args.push('--extensions=tsx,ts,jsx,js,mjs') args = args.concat(['--transform', transformerPath]) From 920a76735ca7c640ff8bd031440fd5e440b8d699 Mon Sep 17 00:00:00 2001 From: Jiwon Choi Date: Sat, 29 Aug 2026 01:44:28 +0200 Subject: [PATCH 8/8] Honor non-interactive mode in upgrade codemod prompts (#98030) ### Why? `@next/codemod upgrade --yes` accepts defaults for the upgrade command's prompts, but `next-request-geo-ip` asked its own deployment question inside `runTransform`. Because the non-interactive state was not forwarded, automated upgrades could stop at that nested prompt despite using `--yes`. ### How? Pass the resolved non-interactive state from `runUpgrade` into each transform. The geo/IP transform skips its deployment prompt in non-interactive mode while preserving the existing prompt for direct interactive runs. --- packages/next-codemod/bin/transform.ts | 2 +- packages/next-codemod/bin/upgrade.ts | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/next-codemod/bin/transform.ts b/packages/next-codemod/bin/transform.ts index f9512fb21f21..120cbb4d8766 100644 --- a/packages/next-codemod/bin/transform.ts +++ b/packages/next-codemod/bin/transform.ts @@ -80,7 +80,7 @@ export async function runTransform( transformer = res.transformer } - if (transformer === 'next-request-geo-ip') { + if (transformer === 'next-request-geo-ip' && !options.nonInteractive) { const { isAppDeployedToVercel } = await prompts( { type: 'confirm', diff --git a/packages/next-codemod/bin/upgrade.ts b/packages/next-codemod/bin/upgrade.ts index a1b83f2bd9e2..cca79d83bbc0 100644 --- a/packages/next-codemod/bin/upgrade.ts +++ b/packages/next-codemod/bin/upgrade.ts @@ -447,7 +447,11 @@ export async function runUpgrade( runInstallation(packageManager, { cwd }) for (const codemod of codemods) { - await runTransform(codemod, cwd, { force: true, verbose }) + await runTransform(codemod, cwd, { + force: true, + verbose, + nonInteractive, + }) } // To reduce user-side burden of selecting which codemods to run as it needs additional