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/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/bin/transform.ts b/packages/next-codemod/bin/transform.ts index c8efc5aa532b..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', @@ -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]) 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 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/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/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/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/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/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' 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') + }) + }) }) 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') + }) +}) 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') 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, 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 = {