From 98fd3456fb19b1305816ccba8f45d43eb1e367da Mon Sep 17 00:00:00 2001 From: Tobias Koppers Date: Thu, 22 Jan 2026 15:30:59 +0100 Subject: [PATCH 01/10] Turbopack: query conditions in rules follow-ups (#88801) Rebase of https://github.com/vercel/next.js/pull/87291 --- crates/next-core/src/next_config.rs | 12 +++++++++++- .../05-config/01-next-config-js/turbopack.mdx | 3 +++ test/e2e/turbopack-loader-config/app/api/route.ts | 3 ++- test/e2e/turbopack-loader-config/next.config.ts | 10 ++++++++-- turbopack/crates/turbopack/src/module_options/mod.rs | 7 ++++--- 5 files changed, 28 insertions(+), 7 deletions(-) diff --git a/crates/next-core/src/next_config.rs b/crates/next-core/src/next_config.rs index b9f3f83fe445f..49696c22d317a 100644 --- a/crates/next-core/src/next_config.rs +++ b/crates/next-core/src/next_config.rs @@ -2106,6 +2106,13 @@ mod tests { "browser", { "path": { "type": "glob", "value": "*.svg"}, + "query": { + "type": "regex", + "value": { + "source": "@someQuery", + "flags": "" + } + }, "content": { "source": "@someTag", "flags": "" @@ -2140,7 +2147,10 @@ mod tests { source: rcstr!("@someTag"), flags: rcstr!(""), }), - query: None, + query: Some(ConfigConditionQuery::Regex(RegexComponents { + source: rcstr!("@someQuery"), + flags: rcstr!(""), + })), }, ] .into(), diff --git a/docs/01-app/03-api-reference/05-config/01-next-config-js/turbopack.mdx b/docs/01-app/03-api-reference/05-config/01-next-config-js/turbopack.mdx index 91caa3d0902ee..65793319ae9b4 100644 --- a/docs/01-app/03-api-reference/05-config/01-next-config-js/turbopack.mdx +++ b/docs/01-app/03-api-reference/05-config/01-next-config-js/turbopack.mdx @@ -197,6 +197,9 @@ module.exports = { { any: [ { path: '*.svg' }, + // 'query' matches anywhere in the full query string, + // which can be empty, or start with `?`. + { query: /[?&]svgr(?=&|$)/ }, // 'content' is always a RegExp, and can match // anywhere in the file. { content: /\ {} } - if let Some(content) = content { - rule_conditions.push(RuleCondition::ResourceContentEsRegex(content.await?)); - } match &query { Some(ConditionQuery::Constant(value)) => { rule_conditions.push(RuleCondition::ResourceQueryEquals(value.clone().into())); @@ -144,6 +141,10 @@ async fn rule_condition_from_webpack_condition( } None => {} } + // Add the content condition last since matching requires a more expensive file read. + if let Some(content) = content { + rule_conditions.push(RuleCondition::ResourceContentEsRegex(content.await?)); + } RuleCondition::All(rule_conditions) } }) From 85d043a64aec4f49a6432e4c1a693881e6688e49 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Thu, 22 Jan 2026 15:38:37 +0100 Subject: [PATCH 02/10] Create-next-app update message (#88706) ### What? This PR fixes `create-next-app` update notifications for prerelease versions by checking against the correct npm dist-tag. ### Why? Users running `pnpx create-next-app@canary` were incorrectly prompted to update to the stable version because: 1. The `update-check` library queries npm's `latest` dist-tag by default 2. Version comparison using `localeCompare` considers `16.1.1-canary.32` less than `16.1.3` (since base version `16.1.1 < 16.1.3`) 3. This incorrectly triggered an update notification ### How? Instead of skipping the update check entirely for prerelease versions, this PR: 1. Extracts the dist-tag from the current version (e.g., `canary` from `16.1.1-canary.32`) 2. Passes this as the `distTag` option to `update-check` 3. Updates the suggested command to include the correct tag (e.g., `pnpm add -g create-next-app@canary`) This ensures: - Stable users are notified about newer stable releases - Canary users are notified about newer canary releases - Beta/RC users are notified about newer versions of their respective channels Co-authored-by: Cursor Agent --- packages/create-next-app/index.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/create-next-app/index.ts b/packages/create-next-app/index.ts index cfc57d842b7e1..0939d62eeb251 100644 --- a/packages/create-next-app/index.ts +++ b/packages/create-next-app/index.ts @@ -656,7 +656,18 @@ async function run(): Promise { conf.set('preferences', preferences) } -const update = updateCheck(packageJson).catch(() => null) +// Determine the appropriate dist-tag to check for updates. +// For prerelease versions like "16.1.1-canary.32", extract "canary" and check +// against that dist-tag. This ensures canary users are notified about newer +// canary releases, not incorrectly prompted to "update" to stable. +function getDistTag(version: string): string { + const prereleaseMatch = version.match(/-([a-z]+)/) + return prereleaseMatch ? prereleaseMatch[1] : 'latest' +} + +const update = updateCheck(packageJson, { + distTag: getDistTag(packageJson.version), +}).catch(() => null) async function notifyUpdate(): Promise { try { @@ -667,7 +678,9 @@ async function notifyUpdate(): Promise { pnpm: 'pnpm add -g', bun: 'bun add -g', } - const updateMessage = `${global[packageManager]} create-next-app` + const distTag = getDistTag(packageJson.version) + const pkgTag = distTag === 'latest' ? '' : `@${distTag}` + const updateMessage = `${global[packageManager]} create-next-app${pkgTag}` console.log( yellow(bold('A new version of `create-next-app` is available!')) + '\n' + From 61bf6f633f26a6eadee31693fe4b11c5f43e97cd Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Thu, 22 Jan 2026 15:56:50 +0100 Subject: [PATCH 03/10] Turbopack: Fix next/font preloading for page.mdx (#88848) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What? This PR fixes font preloading for MDX pages in Turbopack and adds an e2e test to verify the fix. ## Why? When using `next/font` with MDX pages, font preloading was failing with Turbopack because the LoaderTree was storing the **transformed module path** instead of the **original source path**. ## The Bug In Turbopack, MDX files are transformed by adding a `.tsx` extension: - Source file: `app/page.mdx` - After transform: `app/page.mdx.tsx` The `create_module_tuple_code` function in `base_loader_tree.rs` was using `module.ident().path()` (the transformed path) instead of the original `path` parameter. This caused a mismatch at runtime: - Font manifest key: `[project]/app/page` - LoaderTree stored: `[project]/app/page.mdx.tsx` - After stripping `.tsx`: `[project]/app/page.mdx` - **No match** → fonts not preloaded ## The Fix Changed `crates/next-core/src/base_loader_tree.rs` to use the original source path: ```rust // Before let module_path = module.ident().path().to_string().await?; // After let module_path = path.value_to_string().await?; ``` Now the LoaderTree stores `[project]/app/page.mdx`, which after stripping `.mdx` at runtime becomes `[project]/app/page`, matching the font manifest key. ## Test Added `test/e2e/app-dir/mdx-font-preload/` which verifies: 1. MDX page renders correctly 2. Font class from layout is applied 3. Font is correctly preloaded ## How I tested these changes ```bash pnpm test-start-turbo test/e2e/app-dir/mdx-font-preload/mdx-font-preload.test.ts ``` --------- Co-authored-by: Niklas Mischkulnig <4586894+mischnic@users.noreply.github.com> --- crates/next-core/src/base_loader_tree.rs | 7 ++- .../client_reference_manifest.rs | 6 +- .../server_component_module.rs | 24 +++++++- .../server_component_transition.rs | 52 +++++++++++++----- .../app-dir/mdx-font-preload/app/layout.tsx | 18 ++++++ .../e2e/app-dir/mdx-font-preload/app/page.mdx | 5 ++ .../mdx-font-preload/fonts/font1_roboto.woff2 | Bin 0 -> 11028 bytes .../mdx-font-preload/mdx-components.tsx | 5 ++ .../mdx-font-preload/mdx-font-preload.test.ts | 47 ++++++++++++++++ .../app-dir/mdx-font-preload/next.config.mjs | 12 ++++ .../crates/turbopack/src/transition/mod.rs | 8 +-- 11 files changed, 162 insertions(+), 22 deletions(-) create mode 100644 test/e2e/app-dir/mdx-font-preload/app/layout.tsx create mode 100644 test/e2e/app-dir/mdx-font-preload/app/page.mdx create mode 100644 test/e2e/app-dir/mdx-font-preload/fonts/font1_roboto.woff2 create mode 100644 test/e2e/app-dir/mdx-font-preload/mdx-components.tsx create mode 100644 test/e2e/app-dir/mdx-font-preload/mdx-font-preload.test.ts create mode 100644 test/e2e/app-dir/mdx-font-preload/next.config.mjs diff --git a/crates/next-core/src/base_loader_tree.rs b/crates/next-core/src/base_loader_tree.rs index a48bc1d056fbb..dc786bcd1df72 100644 --- a/crates/next-core/src/base_loader_tree.rs +++ b/crates/next-core/src/base_loader_tree.rs @@ -1,7 +1,7 @@ use anyhow::Result; use indoc::formatdoc; use turbo_rcstr::RcStr; -use turbo_tasks::{FxIndexMap, ResolvedVc, ValueToString, Vc}; +use turbo_tasks::{FxIndexMap, ResolvedVc, Vc}; use turbo_tasks_fs::FileSystemPath; use turbopack::{ModuleAssetContext, transition::Transition}; use turbopack_core::{ @@ -115,7 +115,10 @@ impl BaseLoaderTreeBuilder { self.inner_assets .insert(format!("MODULE_{i}").into(), module); - let module_path = module.ident().path().to_string().await?; + // Use the original source path, not the transformed module path. + // This is important for MDX files where page.mdx becomes page.mdx.tsx after + // transformation, but the font manifest uses the original source path. + let module_path = path.value_to_string().await?; Ok(format!( "[{identifier}, {path}]", diff --git a/crates/next-core/src/next_manifests/client_reference_manifest.rs b/crates/next-core/src/next_manifests/client_reference_manifest.rs index 6ab0e36bb815e..771dd9374ada7 100644 --- a/crates/next-core/src/next_manifests/client_reference_manifest.rs +++ b/crates/next-core/src/next_manifests/client_reference_manifest.rs @@ -405,8 +405,12 @@ async fn build_manifest( // per layout segment chunks need to be emitted into the manifest too for (server_component, client_assets) in layout_segment_client_chunks.iter() { + // Use source_path() to get the original source path (e.g., page.mdx) instead of + // server_path() which returns the transformed path (e.g., page.mdx.tsx). + // This ensures the manifest key matches what the LoaderTree stores and what + // the runtime looks up after stripping one extension. let server_component_name = server_component - .server_path() + .source_path() .await? .with_extension("") .value_to_string() diff --git a/crates/next-core/src/next_server_component/server_component_module.rs b/crates/next-core/src/next_server_component/server_component_module.rs index e3f65fe7d18a3..5a36f168ea613 100644 --- a/crates/next-core/src/next_server_component/server_component_module.rs +++ b/crates/next-core/src/next_server_component/server_component_module.rs @@ -30,15 +30,35 @@ use super::server_component_reference::NextServerComponentModuleReference; #[turbo_tasks::value(shared)] pub struct NextServerComponentModule { pub module: ResolvedVc>, + /// The original source path before any transformations (e.g., page.mdx before it becomes + /// page.mdx.tsx). This is used to generate consistent manifest keys that match what the + /// LoaderTree stores. + source_path: FileSystemPath, } #[turbo_tasks::value_impl] impl NextServerComponentModule { #[turbo_tasks::function] - pub fn new(module: ResolvedVc>) -> Vc { - NextServerComponentModule { module }.cell() + pub fn new( + module: ResolvedVc>, + source_path: FileSystemPath, + ) -> Vc { + NextServerComponentModule { + module, + source_path, + } + .cell() + } + + /// Returns the original source path (before transformations like MDX -> MDX.tsx). + /// Use this for manifest key generation to match the LoaderTree paths. + #[turbo_tasks::function] + pub fn source_path(&self) -> Vc { + self.source_path.clone().cell() } + /// Returns the transformed module path (e.g., page.mdx.tsx for MDX files). + /// This is the path of the actual compiled module. #[turbo_tasks::function] pub fn server_path(&self) -> Vc { self.module.ident().path() diff --git a/crates/next-core/src/next_server_component/server_component_transition.rs b/crates/next-core/src/next_server_component/server_component_transition.rs index adc9c37a1b9dc..1fc5f43dbb6c2 100644 --- a/crates/next-core/src/next_server_component/server_component_transition.rs +++ b/crates/next-core/src/next_server_component/server_component_transition.rs @@ -1,7 +1,11 @@ use anyhow::{Result, bail}; -use turbo_tasks::Vc; +use turbo_tasks::{ResolvedVc, Vc}; use turbopack::{ModuleAssetContext, transition::Transition}; -use turbopack_core::module::Module; +use turbopack_core::{ + context::{AssetContext, ProcessResult}, + reference_type::ReferenceType, + source::Source, +}; use turbopack_ecmascript::chunk::EcmascriptChunkPlaceable; use super::server_component_module::NextServerComponentModule; @@ -26,18 +30,40 @@ impl NextServerComponentTransition { #[turbo_tasks::value_impl] impl Transition for NextServerComponentTransition { + /// Override process to capture the original source path before transformation. + /// This is important for MDX files where page.mdx becomes page.mdx.tsx after + /// transformation, but we need the original path for manifest key generation. #[turbo_tasks::function] - async fn process_module( + async fn process( self: Vc, - module: Vc>, - _context: Vc, - ) -> Result>> { - let Some(module) = - Vc::try_resolve_sidecast::>(module).await? - else { - bail!("not an ecmascript module"); - }; - - Ok(Vc::upcast(NextServerComponentModule::new(module))) + source: Vc>, + module_asset_context: Vc, + reference_type: ReferenceType, + ) -> Result> { + // Capture the original source path before any transformation + let source_path = source.ident().path().owned().await?; + + let source = self.process_source(source); + let module_asset_context = self.process_context(module_asset_context); + + Ok( + match &*module_asset_context.process(source, reference_type).await? { + ProcessResult::Module(module) => { + let Some(module) = + ResolvedVc::try_sidecast::>(*module) + else { + bail!("not an ecmascript module"); + }; + + // Create the server component module with the original source path + let server_component = NextServerComponentModule::new(*module, source_path); + + ProcessResult::Module(ResolvedVc::upcast(server_component.to_resolved().await?)) + .cell() + } + ProcessResult::Unknown(source) => ProcessResult::Unknown(*source).cell(), + ProcessResult::Ignore => ProcessResult::Ignore.cell(), + }, + ) } } diff --git a/test/e2e/app-dir/mdx-font-preload/app/layout.tsx b/test/e2e/app-dir/mdx-font-preload/app/layout.tsx new file mode 100644 index 0000000000000..6feb75b2ca9b9 --- /dev/null +++ b/test/e2e/app-dir/mdx-font-preload/app/layout.tsx @@ -0,0 +1,18 @@ +import localFont from 'next/font/local' + +const myFont = localFont({ + src: '../fonts/font1_roboto.woff2', + variable: '--font-my-font', +}) + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/mdx-font-preload/app/page.mdx b/test/e2e/app-dir/mdx-font-preload/app/page.mdx new file mode 100644 index 0000000000000..2f262f46621f7 --- /dev/null +++ b/test/e2e/app-dir/mdx-font-preload/app/page.mdx @@ -0,0 +1,5 @@ +# MDX Page + +This is an MDX page with font preloading. + +The font should be preloaded from the layout. diff --git a/test/e2e/app-dir/mdx-font-preload/fonts/font1_roboto.woff2 b/test/e2e/app-dir/mdx-font-preload/fonts/font1_roboto.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..b65a361a2e44d16996cb4da74d8bfd7ff19bbc6c GIT binary patch literal 11028 zcmV+vE9=yEPew8T0RR9104o##4gdfE08}Ud04lry0RR9100000000000000000000 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 ON3B$ub4ks;IsgFgYn-P5 literal 0 HcmV?d00001 diff --git a/test/e2e/app-dir/mdx-font-preload/mdx-components.tsx b/test/e2e/app-dir/mdx-font-preload/mdx-components.tsx new file mode 100644 index 0000000000000..044b58e674e71 --- /dev/null +++ b/test/e2e/app-dir/mdx-font-preload/mdx-components.tsx @@ -0,0 +1,5 @@ +export function useMDXComponents(components: Record) { + return { + ...components, + } +} diff --git a/test/e2e/app-dir/mdx-font-preload/mdx-font-preload.test.ts b/test/e2e/app-dir/mdx-font-preload/mdx-font-preload.test.ts new file mode 100644 index 0000000000000..4f543fc6a3c46 --- /dev/null +++ b/test/e2e/app-dir/mdx-font-preload/mdx-font-preload.test.ts @@ -0,0 +1,47 @@ +import { nextTestSetup } from 'e2e-utils' + +describe('mdx-font-preload', () => { + const { next } = nextTestSetup({ + files: __dirname, + dependencies: { + '@next/mdx': 'canary', + '@mdx-js/loader': '^2.2.1', + '@mdx-js/react': '^2.2.1', + }, + }) + + it('should render MDX page', async () => { + const browser = await next.browser('/') + expect(await browser.elementByCss('h1').text()).toBe('MDX Page') + expect(await browser.elementByCss('p').text()).toBe( + 'This is an MDX page with font preloading.' + ) + }) + + it('should apply font class from layout', async () => { + const browser = await next.browser('/') + const fontFamily = await browser.eval( + 'getComputedStyle(document.body).fontFamily' + ) + expect(fontFamily).toMatch(/myFont/) + }) + + it('should preload font from layout on MDX page', async () => { + const browser = await next.browser('/') + + // Check for font preload link in DOM + const fontPreloadLinks = await browser.elementsByCss('link[as="font"]') + expect(fontPreloadLinks.length).toBeGreaterThan(0) + + // Verify the preload link attributes + const rel = await fontPreloadLinks[0].getAttribute('rel') + const as = await fontPreloadLinks[0].getAttribute('as') + const type = await fontPreloadLinks[0].getAttribute('type') + const crossorigin = await fontPreloadLinks[0].getAttribute('crossorigin') + + expect(rel).toBe('preload') + expect(as).toBe('font') + expect(type).toBe('font/woff2') + expect(crossorigin).toBe('') + }) +}) diff --git a/test/e2e/app-dir/mdx-font-preload/next.config.mjs b/test/e2e/app-dir/mdx-font-preload/next.config.mjs new file mode 100644 index 0000000000000..d4ba66d426f7e --- /dev/null +++ b/test/e2e/app-dir/mdx-font-preload/next.config.mjs @@ -0,0 +1,12 @@ +import nextMDX from '@next/mdx' + +const withMDX = nextMDX({ + extension: /\.mdx?$/, +}) + +/** @type {import('next').NextConfig} */ +const nextConfig = { + pageExtensions: ['js', 'jsx', 'ts', 'tsx', 'mdx'], +} + +export default withMDX(nextConfig) diff --git a/turbopack/crates/turbopack/src/transition/mod.rs b/turbopack/crates/turbopack/src/transition/mod.rs index a6bfc46616faa..074777bf212a8 100644 --- a/turbopack/crates/turbopack/src/transition/mod.rs +++ b/turbopack/crates/turbopack/src/transition/mod.rs @@ -102,16 +102,16 @@ pub trait Transition { #[turbo_tasks::function] async fn process( self: Vc, - asset: Vc>, + source: Vc>, module_asset_context: Vc, reference_type: ReferenceType, ) -> Result> { - let asset = self.process_source(asset); + let source = self.process_source(source); let module_asset_context = self.process_context(module_asset_context); - let asset = asset.to_resolved().await?; + let source = source.to_resolved().await?; Ok(match &*module_asset_context - .process_default(asset, reference_type) + .process_default(source, reference_type) .await? .await? { From d4e64f52bb9aeb5dbcf6bb484f204742b2e3d743 Mon Sep 17 00:00:00 2001 From: nextjs-bot Date: Thu, 22 Jan 2026 15:00:16 +0000 Subject: [PATCH 04/10] v16.2.0-canary.2 --- lerna.json | 2 +- packages/create-next-app/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-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 | 16 ++++++++-------- 20 files changed, 35 insertions(+), 35 deletions(-) diff --git a/lerna.json b/lerna.json index f1e00319f5628..96a1e9c3cfcb9 100644 --- a/lerna.json +++ b/lerna.json @@ -15,5 +15,5 @@ "registry": "https://registry.npmjs.org/" } }, - "version": "16.2.0-canary.1" + "version": "16.2.0-canary.2" } \ No newline at end of file diff --git a/packages/create-next-app/package.json b/packages/create-next-app/package.json index 4f668a2aae671..89ab9fa29a483 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.2.0-canary.1", + "version": "16.2.0-canary.2", "keywords": [ "react", "next", diff --git a/packages/eslint-config-next/package.json b/packages/eslint-config-next/package.json index 5d4b63db62d92..e41670a33649c 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.2.0-canary.1", + "version": "16.2.0-canary.2", "description": "ESLint configuration used by Next.js.", "license": "MIT", "repository": { @@ -12,7 +12,7 @@ "dist" ], "dependencies": { - "@next/eslint-plugin-next": "16.2.0-canary.1", + "@next/eslint-plugin-next": "16.2.0-canary.2", "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 17e19fc56e4db..2b8c03e3718e0 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.2.0-canary.1", + "version": "16.2.0-canary.2", "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 350dfafa0dc85..e780bf7c66544 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.2.0-canary.1", + "version": "16.2.0-canary.2", "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 710f10a7033c1..c948ad4e0c1b4 100644 --- a/packages/font/package.json +++ b/packages/font/package.json @@ -1,7 +1,7 @@ { "name": "@next/font", "private": true, - "version": "16.2.0-canary.1", + "version": "16.2.0-canary.2", "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 b658f2a910dcf..2e4b0de039bff 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.2.0-canary.1", + "version": "16.2.0-canary.2", "main": "index.js", "types": "index.d.ts", "license": "MIT", diff --git a/packages/next-codemod/package.json b/packages/next-codemod/package.json index b5e0b4fa30f09..5ce0ae781de46 100644 --- a/packages/next-codemod/package.json +++ b/packages/next-codemod/package.json @@ -1,6 +1,6 @@ { "name": "@next/codemod", - "version": "16.2.0-canary.1", + "version": "16.2.0-canary.2", "license": "MIT", "repository": { "type": "git", diff --git a/packages/next-env/package.json b/packages/next-env/package.json index 3a498595b5fe5..5b854800d2766 100644 --- a/packages/next-env/package.json +++ b/packages/next-env/package.json @@ -1,6 +1,6 @@ { "name": "@next/env", - "version": "16.2.0-canary.1", + "version": "16.2.0-canary.2", "keywords": [ "react", "next", diff --git a/packages/next-mdx/package.json b/packages/next-mdx/package.json index 71ed3b7400464..766fc611d6336 100644 --- a/packages/next-mdx/package.json +++ b/packages/next-mdx/package.json @@ -1,6 +1,6 @@ { "name": "@next/mdx", - "version": "16.2.0-canary.1", + "version": "16.2.0-canary.2", "main": "index.js", "license": "MIT", "repository": { diff --git a/packages/next-plugin-storybook/package.json b/packages/next-plugin-storybook/package.json index b8e52416948cb..9f492f3f766ad 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.2.0-canary.1", + "version": "16.2.0-canary.2", "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 220e1af3cef9b..8f8cb2df1ea5b 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.2.0-canary.1", + "version": "16.2.0-canary.2", "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 bf447d6950b98..6aefa7030886f 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.2.0-canary.1", + "version": "16.2.0-canary.2", "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 05dfdb1cdd9a8..1bf7a62e49cc5 100644 --- a/packages/next-routing/package.json +++ b/packages/next-routing/package.json @@ -1,6 +1,6 @@ { "name": "@next/routing", - "version": "16.2.0-canary.1", + "version": "16.2.0-canary.2", "keywords": [ "react", "next", diff --git a/packages/next-rspack/package.json b/packages/next-rspack/package.json index 17b79a5ad04c2..3a9f51bec13a9 100644 --- a/packages/next-rspack/package.json +++ b/packages/next-rspack/package.json @@ -1,6 +1,6 @@ { "name": "next-rspack", - "version": "16.2.0-canary.1", + "version": "16.2.0-canary.2", "repository": { "url": "vercel/next.js", "directory": "packages/next-rspack" diff --git a/packages/next-swc/package.json b/packages/next-swc/package.json index 32a6e9df9365e..aba49f4ce4e4a 100644 --- a/packages/next-swc/package.json +++ b/packages/next-swc/package.json @@ -1,6 +1,6 @@ { "name": "@next/swc", - "version": "16.2.0-canary.1", + "version": "16.2.0-canary.2", "private": true, "files": [ "native/" diff --git a/packages/next/package.json b/packages/next/package.json index a714fdbb4e1c7..eaaa0cd79e964 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "next", - "version": "16.2.0-canary.1", + "version": "16.2.0-canary.2", "description": "The React Framework", "main": "./dist/server/next.js", "license": "MIT", @@ -97,7 +97,7 @@ ] }, "dependencies": { - "@next/env": "16.2.0-canary.1", + "@next/env": "16.2.0-canary.2", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", @@ -162,11 +162,11 @@ "@modelcontextprotocol/sdk": "1.18.1", "@mswjs/interceptors": "0.23.0", "@napi-rs/triples": "1.2.0", - "@next/font": "16.2.0-canary.1", - "@next/polyfill-module": "16.2.0-canary.1", - "@next/polyfill-nomodule": "16.2.0-canary.1", - "@next/react-refresh-utils": "16.2.0-canary.1", - "@next/swc": "16.2.0-canary.1", + "@next/font": "16.2.0-canary.2", + "@next/polyfill-module": "16.2.0-canary.2", + "@next/polyfill-nomodule": "16.2.0-canary.2", + "@next/react-refresh-utils": "16.2.0-canary.2", + "@next/swc": "16.2.0-canary.2", "@opentelemetry/api": "1.6.0", "@playwright/test": "1.51.1", "@rspack/core": "1.6.7", diff --git a/packages/react-refresh-utils/package.json b/packages/react-refresh-utils/package.json index 5453eeb820e85..b6cf62115b6fc 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.2.0-canary.1", + "version": "16.2.0-canary.2", "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 4ca40907b9419..393899c6a85eb 100644 --- a/packages/third-parties/package.json +++ b/packages/third-parties/package.json @@ -1,6 +1,6 @@ { "name": "@next/third-parties", - "version": "16.2.0-canary.1", + "version": "16.2.0-canary.2", "repository": { "url": "vercel/next.js", "directory": "packages/third-parties" @@ -26,7 +26,7 @@ "third-party-capital": "1.0.20" }, "devDependencies": { - "next": "16.2.0-canary.1", + "next": "16.2.0-canary.2", "outdent": "0.8.0", "prettier": "2.5.1", "typescript": "5.9.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6907f4aa59c02..8833e8214d77f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1008,7 +1008,7 @@ importers: packages/eslint-config-next: dependencies: '@next/eslint-plugin-next': - specifier: 16.2.0-canary.1 + specifier: 16.2.0-canary.2 version: link:../eslint-plugin-next eslint: specifier: '>=9.0.0' @@ -1085,7 +1085,7 @@ importers: packages/next: dependencies: '@next/env': - specifier: 16.2.0-canary.1 + specifier: 16.2.0-canary.2 version: link:../next-env '@swc/helpers': specifier: 0.5.15 @@ -1213,19 +1213,19 @@ importers: specifier: 1.2.0 version: 1.2.0 '@next/font': - specifier: 16.2.0-canary.1 + specifier: 16.2.0-canary.2 version: link:../font '@next/polyfill-module': - specifier: 16.2.0-canary.1 + specifier: 16.2.0-canary.2 version: link:../next-polyfill-module '@next/polyfill-nomodule': - specifier: 16.2.0-canary.1 + specifier: 16.2.0-canary.2 version: link:../next-polyfill-nomodule '@next/react-refresh-utils': - specifier: 16.2.0-canary.1 + specifier: 16.2.0-canary.2 version: link:../react-refresh-utils '@next/swc': - specifier: 16.2.0-canary.1 + specifier: 16.2.0-canary.2 version: link:../next-swc '@opentelemetry/api': specifier: 1.6.0 @@ -1943,7 +1943,7 @@ importers: version: 1.0.20 devDependencies: next: - specifier: 16.2.0-canary.1 + specifier: 16.2.0-canary.2 version: link:../next outdent: specifier: 0.8.0 From 6632b909eadb9d20bfb63e86e7a4cdfdf6825c96 Mon Sep 17 00:00:00 2001 From: Niklas Mischkulnig <4586894+mischnic@users.noreply.github.com> Date: Thu, 22 Jan 2026 17:30:14 +0100 Subject: [PATCH 05/10] Remove `deploymentId` from App Router `RenderOptsPartial` (#88866) --- packages/next/src/build/templates/app-page.ts | 2 +- .../next/src/build/templates/edge-ssr-app.ts | 2 +- packages/next/src/build/templates/edge-ssr.ts | 3 ++- packages/next/src/export/index.ts | 2 +- packages/next/src/export/types.ts | 2 ++ packages/next/src/export/worker.ts | 6 +++-- .../next/src/server/app-render/app-render.tsx | 23 ++++++++++--------- .../app-render/get-asset-query-string.ts | 4 ++-- packages/next/src/server/app-render/types.ts | 1 - .../app-render/work-async-storage.external.ts | 3 +++ packages/next/src/server/base-server.ts | 7 +++--- packages/next/src/server/next-server.ts | 5 ++-- packages/next/src/server/render.tsx | 2 +- .../route-modules/pages/pages-handler.ts | 2 +- 14 files changed, 36 insertions(+), 28 deletions(-) diff --git a/packages/next/src/build/templates/app-page.ts b/packages/next/src/build/templates/app-page.ts index 8a33936a02215..289962e1d5a0b 100644 --- a/packages/next/src/build/templates/app-page.ts +++ b/packages/next/src/build/templates/app-page.ts @@ -611,6 +611,7 @@ export async function handler( page: normalizedSrcPage, sharedContext: { buildId, + deploymentId, }, serverComponentsHmrCache: getRequestMeta( req, @@ -659,7 +660,6 @@ export async function handler( trailingSlash: nextConfig.trailingSlash, images: nextConfig.images, previewProps: prerenderManifest.preview, - deploymentId: deploymentId, enableTainting: nextConfig.experimental.taint, htmlLimitedBots: nextConfig.htmlLimitedBots, reactMaxHeadersLength: nextConfig.reactMaxHeadersLength, diff --git a/packages/next/src/build/templates/edge-ssr-app.ts b/packages/next/src/build/templates/edge-ssr-app.ts index 25a1e4bca3ed9..04c043f4c777e 100644 --- a/packages/next/src/build/templates/edge-ssr-app.ts +++ b/packages/next/src/build/templates/edge-ssr-app.ts @@ -106,6 +106,7 @@ async function requestHandler( sharedContext: { buildId, + deploymentId, }, fallbackRouteParams: null, @@ -141,7 +142,6 @@ async function requestHandler( trailingSlash: nextConfig.trailingSlash, images: nextConfig.images, previewProps: prerenderManifest.preview, - deploymentId, enableTainting: nextConfig.experimental.taint, htmlLimitedBots: nextConfig.htmlLimitedBots, reactMaxHeadersLength: nextConfig.reactMaxHeadersLength, diff --git a/packages/next/src/build/templates/edge-ssr.ts b/packages/next/src/build/templates/edge-ssr.ts index 9677d6c9453ae..17b89cb01e633 100644 --- a/packages/next/src/build/templates/edge-ssr.ts +++ b/packages/next/src/build/templates/edge-ssr.ts @@ -110,6 +110,7 @@ async function requestHandler( params, buildId, nextConfig, + deploymentId, isNextDataRequest, buildManifest, prerenderManifest, @@ -127,7 +128,7 @@ async function requestHandler( sharedContext: { buildId, - deploymentId: process.env.NEXT_DEPLOYMENT_ID, + deploymentId, customServer: undefined, }, diff --git a/packages/next/src/export/index.ts b/packages/next/src/export/index.ts index 418917b3a76ab..50ab3db0c5363 100644 --- a/packages/next/src/export/index.ts +++ b/packages/next/src/export/index.ts @@ -500,7 +500,6 @@ async function exportAppImpl( join(distDir, 'server', `${NEXT_FONT_MANIFEST}.json`) ), images: nextConfig.images, - deploymentId: nextConfig.deploymentId, htmlLimitedBots: nextConfig.htmlLimitedBots.source, experimental: { clientTraceMetadata: nextConfig.experimental.clientTraceMetadata, @@ -713,6 +712,7 @@ async function exportAppImpl( batches.map(async (batch) => worker.exportPages({ buildId, + deploymentId: nextConfig.deploymentId, exportPaths: batch, parentSpanId: span.getId(), pagesDataDir, diff --git a/packages/next/src/export/types.ts b/packages/next/src/export/types.ts index 028526a158f52..cf8f510b6574f 100644 --- a/packages/next/src/export/types.ts +++ b/packages/next/src/export/types.ts @@ -23,6 +23,7 @@ export type ExportPathEntry = ExportPathMap[keyof ExportPathMap] & { export interface ExportPagesInput { buildId: string + deploymentId: string exportPaths: ExportPathEntry[] parentSpanId: number dir: string @@ -41,6 +42,7 @@ export interface ExportPagesInput { export interface ExportPageInput { buildId: string + deploymentId: string exportPath: ExportPathEntry distDir: string outDir: string diff --git a/packages/next/src/export/worker.ts b/packages/next/src/export/worker.ts index a58ff9a73bf3d..684b49ea3823e 100644 --- a/packages/next/src/export/worker.ts +++ b/packages/next/src/export/worker.ts @@ -85,6 +85,7 @@ async function exportPageImpl( renderOpts: commonRenderOpts, outDir: commonOutDir, buildId, + deploymentId, renderResumeDataCache, } = input @@ -274,7 +275,7 @@ async function exportPageImpl( // Handle App Pages if (isAppDir) { - const sharedContext: AppSharedContext = { buildId } + const sharedContext: AppSharedContext = { buildId, deploymentId } return exportAppPage( req, @@ -294,7 +295,7 @@ async function exportPageImpl( } else { const sharedContext: PagesSharedContext = { buildId, - deploymentId: commonRenderOpts.deploymentId, + deploymentId, customServer: undefined, } @@ -416,6 +417,7 @@ export async function exportPages( enableExperimentalReact: needsExperimentalReact(nextConfig), sriEnabled: Boolean(nextConfig.experimental.sri?.algorithm), buildId: input.buildId, + deploymentId: input.deploymentId, renderResumeDataCache, }), hasDebuggerAttached diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 87db2a8e202b8..7e4c2101e8e9a 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -234,6 +234,7 @@ export type GenerateFlight = typeof generateDynamicFlightRenderResult export type AppSharedContext = { buildId: string + deploymentId: string } export type AppRenderContext = { @@ -2886,7 +2887,7 @@ async function renderToStream( ), getServerInsertedHTML, getServerInsertedMetadata, - deploymentId: ctx.renderOpts.deploymentId, + deploymentId: ctx.sharedContext.deploymentId, }) } } @@ -2964,7 +2965,7 @@ async function renderToStream( isStaticGeneration: generateStaticHTML, isBuildTimePrerendering: ctx.workStore.isBuildTimePrerendering === true, buildId: ctx.workStore.buildId, - deploymentId: ctx.renderOpts.deploymentId, + deploymentId: ctx.sharedContext.deploymentId, getServerInsertedHTML, getServerInsertedMetadata, validateRootLayout: dev, @@ -3134,7 +3135,7 @@ async function renderToStream( isBuildTimePrerendering: ctx.workStore.isBuildTimePrerendering === true, buildId: ctx.workStore.buildId, - deploymentId: ctx.renderOpts.deploymentId, + deploymentId: ctx.sharedContext.deploymentId, getServerInsertedHTML: makeGetServerInsertedHTML({ polyfills, renderServerInsertedHTML, @@ -4840,7 +4841,7 @@ async function prerenderToStream( stream: await continueDynamicPrerender(prelude, { getServerInsertedHTML, getServerInsertedMetadata, - deploymentId: ctx.renderOpts.deploymentId, + deploymentId: ctx.sharedContext.deploymentId, }), dynamicAccess: consumeDynamicAccess( serverDynamicTracking, @@ -4938,7 +4939,7 @@ async function prerenderToStream( isBuildTimePrerendering: ctx.workStore.isBuildTimePrerendering === true, buildId: ctx.workStore.buildId, - deploymentId: ctx.renderOpts.deploymentId, + deploymentId: ctx.sharedContext.deploymentId, }) } else { // Normal static prerender case, no fallback param handling needed @@ -4953,7 +4954,7 @@ async function prerenderToStream( isBuildTimePrerendering: ctx.workStore.isBuildTimePrerendering === true, buildId: ctx.workStore.buildId, - deploymentId: ctx.renderOpts.deploymentId, + deploymentId: ctx.sharedContext.deploymentId, }) } @@ -5127,7 +5128,7 @@ async function prerenderToStream( stream: await continueDynamicPrerender(prelude, { getServerInsertedHTML, getServerInsertedMetadata, - deploymentId: ctx.renderOpts.deploymentId, + deploymentId: ctx.sharedContext.deploymentId, }), dynamicAccess: dynamicTracking.dynamicAccesses, // TODO: Should this include the SSR pass? @@ -5149,7 +5150,7 @@ async function prerenderToStream( stream: await continueDynamicPrerender(prelude, { getServerInsertedHTML, getServerInsertedMetadata, - deploymentId: ctx.renderOpts.deploymentId, + deploymentId: ctx.sharedContext.deploymentId, }), dynamicAccess: dynamicTracking.dynamicAccesses, // TODO: Should this include the SSR pass? @@ -5216,7 +5217,7 @@ async function prerenderToStream( isBuildTimePrerendering: ctx.workStore.isBuildTimePrerendering === true, buildId: ctx.workStore.buildId, - deploymentId: ctx.renderOpts.deploymentId, + deploymentId: ctx.sharedContext.deploymentId, }), dynamicAccess: dynamicTracking.dynamicAccesses, // TODO: Should this include the SSR pass? @@ -5317,7 +5318,7 @@ async function prerenderToStream( buildId: ctx.workStore.buildId, getServerInsertedHTML, getServerInsertedMetadata, - deploymentId: ctx.renderOpts.deploymentId, + deploymentId: ctx.sharedContext.deploymentId, }), // TODO: Should this include the SSR pass? collectedRevalidate: prerenderLegacyStore.revalidate, @@ -5503,7 +5504,7 @@ async function prerenderToStream( }), getServerInsertedMetadata, validateRootLayout: dev, - deploymentId: ctx.renderOpts.deploymentId, + deploymentId: ctx.sharedContext.deploymentId, }), dynamicAccess: null, collectedRevalidate: diff --git a/packages/next/src/server/app-render/get-asset-query-string.ts b/packages/next/src/server/app-render/get-asset-query-string.ts index 118e5aabf5e58..0d83c7101eed8 100644 --- a/packages/next/src/server/app-render/get-asset-query-string.ts +++ b/packages/next/src/server/app-render/get-asset-query-string.ts @@ -18,8 +18,8 @@ export function getAssetQueryString( qs += `?v=${ctx.requestTimestamp}` } - if (ctx.renderOpts.deploymentId) { - qs += `${shouldAddVersion ? '&' : '?'}dpl=${ctx.renderOpts.deploymentId}` + if (ctx.sharedContext.deploymentId) { + qs += `${shouldAddVersion ? '&' : '?'}dpl=${ctx.sharedContext.deploymentId}` } return qs } diff --git a/packages/next/src/server/app-render/types.ts b/packages/next/src/server/app-render/types.ts index f4472eb276936..013f655cdde04 100644 --- a/packages/next/src/server/app-render/types.ts +++ b/packages/next/src/server/app-render/types.ts @@ -126,7 +126,6 @@ export interface RenderOptsPartial { nextConfigOutput?: 'standalone' | 'export' onInstrumentationRequestError?: ServerOnInstrumentationRequestError isDraftMode?: boolean - deploymentId?: string onUpdateCookies?: (cookies: string[]) => void loadConfig?: ( phase: string, diff --git a/packages/next/src/server/app-render/work-async-storage.external.ts b/packages/next/src/server/app-render/work-async-storage.external.ts index ca22465538d82..87e3f2b657123 100644 --- a/packages/next/src/server/app-render/work-async-storage.external.ts +++ b/packages/next/src/server/app-render/work-async-storage.external.ts @@ -94,6 +94,9 @@ export interface WorkStore { isUnstableNoStore?: boolean isPrefetchRequest?: boolean + /** + * Prefer `sharedContext.buildId` instead. This only exists because it's needed in use-cache-wrapper + */ buildId: string readonly reactLoadableManifest?: DeepReadonly< diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index 16765f75f9caf..e9f4768126ccf 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -324,6 +324,7 @@ export default abstract class Server< protected readonly pagesManifest?: PagesManifest protected readonly appPathsManifest?: PagesManifest protected readonly buildId: string + protected readonly deploymentId: string protected readonly minimalMode: boolean protected readonly renderOpts: BaseRenderOpts protected readonly serverOptions: Readonly @@ -454,20 +455,19 @@ export default abstract class Server< // values from causing issues as this can be user provided this.nextConfig = conf as NextConfigRuntime - let deploymentId if (this.nextConfig.experimental.runtimeServerDeploymentId) { if (!process.env.NEXT_DEPLOYMENT_ID) { throw new Error( 'process.env.NEXT_DEPLOYMENT_ID is missing but runtimeServerDeploymentId is enabled' ) } - deploymentId = process.env.NEXT_DEPLOYMENT_ID + this.deploymentId = process.env.NEXT_DEPLOYMENT_ID } else { let id = this.nextConfig.experimental.useSkewCookie ? '' : this.nextConfig.deploymentId || '' - deploymentId = id + this.deploymentId = id process.env.NEXT_DEPLOYMENT_ID = id } @@ -530,7 +530,6 @@ export default abstract class Server< dir: this.dir, supportsDynamicResponse: true, trailingSlash: this.nextConfig.trailingSlash, - deploymentId: deploymentId, poweredByHeader: this.nextConfig.poweredByHeader, generateEtags, previewProps: this.getPrerenderManifest().preview, diff --git a/packages/next/src/server/next-server.ts b/packages/next/src/server/next-server.ts index b5269e22a8297..9a1f46db58bf6 100644 --- a/packages/next/src/server/next-server.ts +++ b/packages/next/src/server/next-server.ts @@ -356,7 +356,7 @@ export default class NextNodeServer extends BaseServer< // when using compile mode static env isn't inlined so we // need to populate in normal runtime env if (this.renderOpts.isExperimentalCompile) { - populateStaticEnv(this.nextConfig, this.renderOpts.deploymentId || '') + populateStaticEnv(this.nextConfig, this.deploymentId || '') } const shouldRemoveUncaughtErrorAndRejectionListeners = Boolean( @@ -715,6 +715,7 @@ export default class NextNodeServer extends BaseServer< this.getServerComponentsHmrCache(), { buildId: this.buildId, + deploymentId: this.deploymentId, } ) } else { @@ -729,7 +730,7 @@ export default class NextNodeServer extends BaseServer< renderOpts as LoadedRenderOpts, { buildId: this.buildId, - deploymentId: this.renderOpts.deploymentId, + deploymentId: this.deploymentId, customServer: this.serverOptions.customServer || undefined, }, { diff --git a/packages/next/src/server/render.tsx b/packages/next/src/server/render.tsx index c48635404742a..048beb9439f91 100644 --- a/packages/next/src/server/render.tsx +++ b/packages/next/src/server/render.tsx @@ -306,7 +306,7 @@ export type PagesSharedContext = { /** * The deployment ID if the user is deploying to a platform that provides one. */ - deploymentId: string | undefined + deploymentId: string /** * True if the user is using a custom server. diff --git a/packages/next/src/server/route-modules/pages/pages-handler.ts b/packages/next/src/server/route-modules/pages/pages-handler.ts index 0b69e2160bfb3..fc5b4386f8d60 100644 --- a/packages/next/src/server/route-modules/pages/pages-handler.ts +++ b/packages/next/src/server/route-modules/pages/pages-handler.ts @@ -265,7 +265,7 @@ export const getHandler = ({ buildId, customServer: Boolean(routerServerContext?.isCustomServer) || undefined, - deploymentId: getDeploymentId(), + deploymentId: getDeploymentId() || '', }, renderOpts: { params, From 4e8671cf5c142b1661b6b53404782aace2fb8d71 Mon Sep 17 00:00:00 2001 From: Vercel Release Bot <88769842+vercel-release-bot@users.noreply.github.com> Date: Thu, 22 Jan 2026 11:55:03 -0500 Subject: [PATCH 06/10] Update Rspack production test manifest (#88882) This auto-generated PR updates the production integration test manifest used when testing Rspack. --- test/rspack-build-tests-manifest.json | 156 ++++++++++++++++++++++---- 1 file changed, 137 insertions(+), 19 deletions(-) diff --git a/test/rspack-build-tests-manifest.json b/test/rspack-build-tests-manifest.json index 95c118c6d519b..e58d9b5d5a25c 100644 --- a/test/rspack-build-tests-manifest.json +++ b/test/rspack-build-tests-manifest.json @@ -1961,6 +1961,7 @@ "Cache Components Errors Build With --prerender-debug Error Attribution with Sync IO Guarded RSC with unguarded Client sync IO should error the build with a reason related to sync IO access", "Cache Components Errors Build With --prerender-debug Error Attribution with Sync IO Unguarded RSC with guarded Client sync IO should error the build with a reason related dynamic data", "Cache Components Errors Build With --prerender-debug Error Attribution with Sync IO unguarded RSC with unguarded Client sync IO should error the build with a reason related to sync IO access", + "Cache Components Errors Build With --prerender-debug IO accessed in Client Components should error the build if IO is accessed in a Client Component", "Cache Components Errors Build With --prerender-debug Inside `use cache` cacheLife with expire < 5 minutes microtasky cache should error the build", "Cache Components Errors Build With --prerender-debug Inside `use cache` cacheLife with expire < 5 minutes slow cache should error the build", "Cache Components Errors Build With --prerender-debug Inside `use cache` cacheLife with revalidate: 0 microtasky cache should error the build", @@ -2007,6 +2008,7 @@ "Cache Components Errors Build Without --prerender-debug Error Attribution with Sync IO Guarded RSC with unguarded Client sync IO should error the build with a reason related to sync IO access", "Cache Components Errors Build Without --prerender-debug Error Attribution with Sync IO Unguarded RSC with guarded Client sync IO should error the build with a reason related dynamic data", "Cache Components Errors Build Without --prerender-debug Error Attribution with Sync IO unguarded RSC with unguarded Client sync IO should error the build with a reason related to sync IO access", + "Cache Components Errors Build Without --prerender-debug IO accessed in Client Components should error the build if IO is accessed in a Client Component", "Cache Components Errors Build Without --prerender-debug Inside `use cache` cacheLife with expire < 5 minutes microtasky cache should error the build", "Cache Components Errors Build Without --prerender-debug Inside `use cache` cacheLife with expire < 5 minutes slow cache should error the build", "Cache Components Errors Build Without --prerender-debug Inside `use cache` cacheLife with revalidate: 0 microtasky cache should error the build", @@ -2311,6 +2313,15 @@ "flakey": [], "runtimeError": false }, + "test/e2e/app-dir/client-reference-chunking/client-reference-chunking.test.ts": { + "passed": [ + "client-reference-chunking should use the same chunks for client references across routes" + ], + "failed": [], + "pending": [], + "flakey": [], + "runtimeError": false + }, "test/e2e/app-dir/concurrent-navigations/mismatching-prefetch.test.ts": { "passed": [ "mismatching prefetch recovers when a navigation rewrites to a different route than the one that was prefetched" @@ -3079,6 +3090,15 @@ "flakey": [], "runtimeError": false }, + "test/e2e/app-dir/gesture-transitions/gesture-transitions.test.ts": { + "passed": [ + "gesture-transitions shows optimistic state during gesture, then canonical state after" + ], + "failed": [], + "pending": [], + "flakey": [], + "runtimeError": false + }, "test/e2e/app-dir/global-error/basic/index.test.ts": { "passed": [ "app dir - global-error should catch metadata error in error boundary if presented", @@ -3907,6 +3927,7 @@ "app dir - metadata missing metadataBase should not show warning in vercel deployment output in default build output mode", "app dir - metadata missing metadataBase should not warn for viewport properties during manually merging metadata", "app dir - metadata missing metadataBase should not warn metadataBase is missing and a relative URL is used", + "app dir - metadata missing metadataBase should warn for deprecated fields in other property", "app dir - metadata missing metadataBase should warn for unsupported metadata properties" ], "failed": [], @@ -5929,6 +5950,15 @@ "flakey": [], "runtimeError": false }, + "test/e2e/app-dir/ppr-root-param-fallback/ppr-root-param-fallback.test.ts": { + "passed": [ + "ppr-root-param-fallback should have use-cache content in fallback shells for all pregenerated locales" + ], + "failed": [], + "pending": [], + "flakey": [], + "runtimeError": false + }, "test/e2e/app-dir/ppr-unstable-cache/ppr-unstable-cache.test.ts": { "passed": ["ppr-unstable-cache should not cache inner fetch calls"], "failed": [], @@ -6170,6 +6200,16 @@ "flakey": [], "runtimeError": false }, + "test/e2e/app-dir/revalidate-path-with-rewrites/revalidate-path-with-rewrites.test.ts": { + "passed": [ + "revalidatePath with rewrites dynamic page should revalidate a dynamic page that was rewritten", + "revalidatePath with rewrites static page should revalidate a static page that was rewritten" + ], + "failed": [], + "pending": [], + "flakey": [], + "runtimeError": false + }, "test/e2e/app-dir/revalidatetag-rsc/revalidatetag-rsc.test.ts": { "passed": [ "revalidateTag-rsc should error if revalidateTag is called during render", @@ -6967,6 +7007,7 @@ }, "test/e2e/app-dir/segment-cache/force-stale/force-stale.test.ts": { "passed": [ + "force stale during a \"full\" prefetch, read from bfcache before issuing new prefetch request", "force stale during a navigation, don't request segments that have a pending \"full\" prefetch already in progress" ], "failed": [], @@ -7172,6 +7213,15 @@ "flakey": [], "runtimeError": false }, + "test/e2e/app-dir/server-action-logging/server-action-logging.test.ts": { + "passed": [ + "server-action-logging should not log server actions in production mode" + ], + "failed": [], + "pending": [], + "flakey": [], + "runtimeError": false + }, "test/e2e/app-dir/server-actions-redirect-middleware-rewrite/server-actions-redirect-middleware-rewrite.test.ts": { "passed": [ "app-dir - server-actions-redirect-middleware-rewrite.test should redirect correctly in edge runtime with middleware rewrite", @@ -7308,6 +7358,22 @@ "flakey": [], "runtimeError": false }, + "test/e2e/app-dir/static-siblings/static-siblings.test.ts": { + "passed": [ + "static-siblings cross-route-group siblings should include static sibling info in the server response", + "static-siblings cross-route-group siblings should navigate to static sibling after visiting dynamic route", + "static-siblings deeply nested siblings should include static sibling info in the server response", + "static-siblings deeply nested siblings should navigate to static sibling after visiting dynamic route", + "static-siblings parallel route siblings should include static sibling info in the server response", + "static-siblings parallel route siblings should navigate to static sibling after visiting dynamic route", + "static-siblings same-directory siblings should include static sibling info in the server response", + "static-siblings same-directory siblings should navigate to static sibling after visiting dynamic route" + ], + "failed": [], + "pending": [], + "flakey": [], + "runtimeError": false + }, "test/e2e/app-dir/sub-shell-generation-middleware/sub-shell-generation-middleware.test.ts": { "passed": [ "middleware-static-rewrite should eventually result in a cache hit" @@ -7410,7 +7476,9 @@ "app-dir trailingSlash handling should redirect route when clicking link", "app-dir trailingSlash handling should redirect route when requesting it directly", "app-dir trailingSlash handling should redirect route when requesting it directly by browser", - "app-dir trailingSlash handling should render link with trailing slash" + "app-dir trailingSlash handling should render link with trailing slash", + "app-dir trailingSlash handling should revalidate a page with generated static params (withSlash=false)", + "app-dir trailingSlash handling should revalidate a page with generated static params (withSlash=true)" ], "failed": [], "pending": [], @@ -7824,8 +7892,12 @@ "flakey": [], "runtimeError": false }, - "test/e2e/app-dir/webpack-loader-resource-query/turbopack-loader-resource-query.test.ts": { - "passed": ["webpack-loader-resource-query should pass query to loader"], + "test/e2e/app-dir/webpack-loader-resource-query/webpack-loader-resource-query.test.js": { + "passed": [ + "webpack-loader-resource-query should apply loader based on resourceQuery", + "webpack-loader-resource-query should apply loader based on resourceQuery regex", + "webpack-loader-resource-query should pass query to loader" + ], "failed": [], "pending": [], "flakey": [], @@ -7870,8 +7942,11 @@ }, "test/e2e/app-dir/worker/worker.test.ts": { "passed": [ + "app dir - workers should have access to NEXT_DEPLOYMENT_ID in web worker", "app dir - workers should not bundle web workers with string specifiers", + "app dir - workers should support loading WASM files in workers", "app dir - workers should support module web workers with dynamic imports", + "app dir - workers should support shared workers", "app dir - workers should support web workers with dynamic imports" ], "failed": [], @@ -11704,6 +11779,7 @@ "CLI Usage production mode build should not throw UnhandledPromiseRejectionWarning", "CLI Usage production mode build should warn when unknown argument provided", "CLI Usage production mode start --help", + "CLI Usage production mode start --inspect", "CLI Usage production mode start --keepAliveTimeout Infinity", "CLI Usage production mode start --keepAliveTimeout happy path", "CLI Usage production mode start --keepAliveTimeout negative number", @@ -12002,6 +12078,8 @@ }, "test/integration/create-next-app/package-manager/pnpm.test.ts": { "passed": [ + "create-next-app with package manager pnpm should NOT create pnpm-workspace.yaml for pnpm v9", + "create-next-app with package manager pnpm should create pnpm-workspace.yaml for pnpm v10+", "create-next-app with package manager pnpm should use pnpm for --use-pnpm flag", "create-next-app with package manager pnpm should use pnpm for --use-pnpm flag with example", "create-next-app with package manager pnpm should use pnpm when user-agent is pnpm", @@ -20011,7 +20089,6 @@ "config telemetry production mode emits telemetry for default React Compiler options", "config telemetry production mode emits telemetry for enabled React Compiler", "config telemetry production mode emits telemetry for filesystem cache in build mode", - "config telemetry production mode emits telemetry for filesystem cache in dev mode", "config telemetry production mode emits telemetry for isolatedDevBuild disabled", "config telemetry production mode emits telemetry for isolatedDevBuild enabled by default", "config telemetry production mode emits telemetry for middleware related options", @@ -20026,7 +20103,9 @@ "config telemetry production mode emits telemetry for usage of swc plugins", "config telemetry production mode emits telemetry for useCache directive" ], - "failed": [], + "failed": [ + "config telemetry production mode emits telemetry for filesystem cache in dev mode" + ], "pending": [], "flakey": [], "runtimeError": false @@ -20183,11 +20262,12 @@ }, "test/integration/typescript-app-type-declarations/test/index.test.ts": { "passed": [ - "TypeScript App Type Declarations should not touch an existing correct next-env.d.ts", + "TypeScript App Type Declarations should not touch an existing correct next-env.d.ts" + ], + "failed": [ "TypeScript App Type Declarations should overwrite next-env.d.ts if an incorrect one exists", "TypeScript App Type Declarations should write a new next-env.d.ts if none exist" ], - "failed": [], "pending": [], "flakey": [], "runtimeError": false @@ -20854,6 +20934,16 @@ "flakey": [], "runtimeError": false }, + "test/production/app-dir/metadata-spread-types/metadata-spread-types.test.ts": { + "passed": [ + "metadata spread types should allow spreading entire resolved parent metadata", + "metadata spread types should allow spreading resolved parent metadata into child metadata" + ], + "failed": [], + "pending": [], + "flakey": [], + "runtimeError": false + }, "test/production/app-dir/metadata-static-route-cache/metadata-static-route-cache.test.ts": { "passed": [ "app dir - metadata static routes cache should generate different content after replace the static metadata file" @@ -20989,6 +21079,20 @@ "flakey": [], "runtimeError": false }, + "test/production/app-dir/route-handler-manifest-size/route-handler-manifest-size.test.ts": { + "passed": [ + "route-handler-manifest-size should have significantly smaller manifest for pure route handler compared to page", + "route-handler-manifest-size should include page client components in page manifest", + "route-handler-manifest-size should not include page client components in pure route handler manifest", + "route-handler-manifest-size should not include page components in route handler that imports client module", + "route-handler-manifest-size should render page with client components", + "route-handler-manifest-size should respond correctly from pure route handler" + ], + "failed": [], + "pending": [], + "flakey": [], + "runtimeError": false + }, "test/production/app-dir/server-action-period-hash/server-action-period-hash-custom-key.test.ts": { "passed": [ "app-dir - server-action-period-hash-custom-key should have a different manifest if the encryption key from process env is changed", @@ -21251,15 +21355,22 @@ }, "test/production/debug-build-path/debug-build-paths.test.ts": { "passed": [ - "debug-build-paths explicit path formats should build dynamic route with literal [slug] path", - "debug-build-paths explicit path formats should build multiple pages routes", - "debug-build-paths explicit path formats should build single page with pages/ prefix", - "debug-build-paths glob pattern matching should match app and pages routes with glob patterns", - "debug-build-paths glob pattern matching should match hybrid pattern with literal [slug] and glob **", - "debug-build-paths glob pattern matching should match multiple app routes with explicit patterns", - "debug-build-paths glob pattern matching should match nested routes with app/blog/**/page.tsx pattern", - "debug-build-paths typechecking with debug-build-paths should fail typechecking when route with type error is included", - "debug-build-paths typechecking with debug-build-paths should skip typechecking for excluded app routes" + "debug-build-paths default fixture explicit path formats should build dynamic route with literal [slug] path", + "debug-build-paths default fixture explicit path formats should build multiple pages routes", + "debug-build-paths default fixture explicit path formats should build single page with pages/ prefix", + "debug-build-paths default fixture glob pattern matching should build everything except excluded paths when only negation patterns are provided", + "debug-build-paths default fixture glob pattern matching should exclude dynamic route paths with negation", + "debug-build-paths default fixture glob pattern matching should exclude paths matching negation patterns", + "debug-build-paths default fixture glob pattern matching should match app and pages routes with glob patterns", + "debug-build-paths default fixture glob pattern matching should match dynamic routes with glob before brackets like app/**/[slug]/page.tsx", + "debug-build-paths default fixture glob pattern matching should match hybrid pattern with literal [slug] and glob **", + "debug-build-paths default fixture glob pattern matching should match multiple app routes with explicit patterns", + "debug-build-paths default fixture glob pattern matching should match nested routes with app/blog/**/page.tsx pattern", + "debug-build-paths default fixture glob pattern matching should support multiple negation patterns", + "debug-build-paths default fixture typechecking with debug-build-paths should fail typechecking when route with type error is included", + "debug-build-paths default fixture typechecking with debug-build-paths should skip typechecking for excluded app routes", + "debug-build-paths with-compile-error fixture should fail compilation when route with compile error is included", + "debug-build-paths with-compile-error fixture should skip compilation of excluded routes with compile errors" ], "failed": [], "pending": [], @@ -21302,6 +21413,13 @@ "deployment-id-handling enabled with CUSTOM_DEPLOYMENT_ID should contain deployment id in RSC payload request headers", "deployment-id-handling enabled with CUSTOM_DEPLOYMENT_ID should contain deployment id in prefetch request", "deployment-id-handling enabled with CUSTOM_DEPLOYMENT_ID should have deployment id env available", + "deployment-id-handling enabled with NEXT_DEPLOYMENT_ID and runtimeServerDeploymentId should append dpl query to all assets correctly for /", + "deployment-id-handling enabled with NEXT_DEPLOYMENT_ID and runtimeServerDeploymentId should append dpl query to all assets correctly for /from-app", + "deployment-id-handling enabled with NEXT_DEPLOYMENT_ID and runtimeServerDeploymentId should append dpl query to all assets correctly for /from-app/edge", + "deployment-id-handling enabled with NEXT_DEPLOYMENT_ID and runtimeServerDeploymentId should append dpl query to all assets correctly for /pages-edge", + "deployment-id-handling enabled with NEXT_DEPLOYMENT_ID and runtimeServerDeploymentId should contain deployment id in RSC payload request headers", + "deployment-id-handling enabled with NEXT_DEPLOYMENT_ID and runtimeServerDeploymentId should contain deployment id in prefetch request", + "deployment-id-handling enabled with NEXT_DEPLOYMENT_ID and runtimeServerDeploymentId should have deployment id env available", "deployment-id-handling enabled with NEXT_DEPLOYMENT_ID should append dpl query to all assets correctly for /", "deployment-id-handling enabled with NEXT_DEPLOYMENT_ID should append dpl query to all assets correctly for /from-app", "deployment-id-handling enabled with NEXT_DEPLOYMENT_ID should append dpl query to all assets correctly for /from-app/edge", @@ -21325,11 +21443,11 @@ "runtimeError": false }, "test/production/deterministic-build/no-change.test.ts": { - "passed": [ + "passed": [], + "failed": [], + "pending": [ "deterministic build - no-change build should have same md5 file across build" ], - "failed": [], - "pending": [], "flakey": [], "runtimeError": false }, From 092458fd6a84bd0cf1b6fa7eb814bcb13f4e6637 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 22 Jan 2026 11:45:15 -0700 Subject: [PATCH 07/10] feat: implement LRU cache with invocation ID scoping for minimal mode response cache (#88509) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Implements an LRU cache with compound keys for the minimal mode response cache to improve cache hit rates during parallel revalidation scenarios. **Problem**: The previous single-entry cache (`previousCacheItem`) keyed by pathname caused cache collisions when multiple concurrent invocations (e.g., during ISR revalidation) accessed the same pathname. Each invocation would overwrite the previous entry, leading to cache misses and redundant work. **Solution**: An LRU cache using compound keys (`pathname + invocationID`) that allows multiple invocations to cache entries for the same pathname independently: ``` Cache Key Structure ───────────────────── /blog/post-1\0inv-abc → {entry, expiresAt} /blog/post-1\0inv-def → {entry, expiresAt} /blog/post-1\0__ttl__ → {entry, expiresAt} (TTL fallback) /api/data\0inv-ghi → {entry, expiresAt} ``` ### Cache Key Strategy - **With `x-invocation-id` header**: Entries are keyed by invocation ID for exact-match lookups (always a cache hit if the entry exists) - **Without header (TTL fallback)**: Entries use a `__ttl__` sentinel key and validate via expiration timestamp ### Configuration via Environment Variables Cache sizing can be tuned via environment variables (using `NEXT_PRIVATE_*` prefix for infrastructure-level settings): | Environment Variable | Default | Description | |---------------------|---------|-------------| | `NEXT_PRIVATE_RESPONSE_CACHE_MAX_SIZE` | 150 | Max entries in the LRU cache | | `NEXT_PRIVATE_RESPONSE_CACHE_TTL` | 10000 | TTL in ms for cache entries (fallback validation) | ### LRU Cache Enhancement Added an optional `onEvict` callback to `LRUCache` that fires when entries are evicted due to capacity limits. This enables tracking evicted invocation IDs for warning detection without introducing timer-based cleanup. ### Eviction Warnings When a cache entry is evicted and later accessed by the same invocation, a warning is logged suggesting to increase `NEXT_PRIVATE_RESPONSE_CACHE_MAX_SIZE`. This helps developers tune cache sizes for their workload. ### Additional Changes - Renamed header from `x-vercel-id` to `x-invocation-id` for clarity - Added `withInvocationId()` test helper for cache testing ## Test Plan - Existing response cache tests pass with updated header name - Unit tests for `LRUCache` including `onEvict` callback behavior - Updated standalone mode tests to use `withInvocationId()` helper --- .../next/src/server/lib/lru-cache.test.ts | 71 ++ packages/next/src/server/lib/lru-cache.ts | 13 +- .../next/src/server/response-cache/index.ts | 225 ++++++- .../src/server/route-modules/route-module.ts | 3 + test/lib/next-test-utils.ts | 32 + .../test/index.test.ts | 251 ++++--- .../required-server-files-app.test.ts | 245 +++++-- .../required-server-files-i18n.test.ts | 418 ++++++++---- .../required-server-files-ppr.test.ts | 261 +++++--- .../required-server-files.test.ts | 619 +++++++++++------- .../response-cache/index.test.ts | 4 +- 11 files changed, 1507 insertions(+), 635 deletions(-) diff --git a/packages/next/src/server/lib/lru-cache.test.ts b/packages/next/src/server/lib/lru-cache.test.ts index 3be3357369250..d184cdb69c853 100644 --- a/packages/next/src/server/lib/lru-cache.test.ts +++ b/packages/next/src/server/lib/lru-cache.test.ts @@ -226,4 +226,75 @@ describe('LRUCache', () => { expect(cache.has('key149')).toBe(true) // recent keys retained }) }) + + describe('onEvict Callback', () => { + it('should call onEvict when an entry is evicted', () => { + const evicted: Array<{ key: string; value: string }> = [] + const cache = new LRUCache(2, undefined, (key, value) => { + evicted.push({ key, value }) + }) + + cache.set('a', 'value-a') + cache.set('b', 'value-b') + expect(evicted.length).toBe(0) + + cache.set('c', 'value-c') // should evict 'a' + expect(evicted.length).toBe(1) + expect(evicted[0]).toEqual({ key: 'a', value: 'value-a' }) + }) + + it('should not call onEvict when updating existing entry', () => { + const evicted: string[] = [] + const cache = new LRUCache(2, undefined, (key) => { + evicted.push(key) + }) + + cache.set('a', 'value-a') + cache.set('a', 'new-value-a') + expect(evicted.length).toBe(0) + }) + + it('should call onEvict for each evicted entry when multiple are evicted', () => { + const evicted: string[] = [] + const cache = new LRUCache( + 10, + (value) => value.length, + (key) => { + evicted.push(key) + } + ) + + cache.set('key1', 'ab') // size 2 + cache.set('key2', 'cd') // size 2 + cache.set('key3', 'ef') // size 2, total = 6 + cache.set('key4', 'ghijklmno') // size 9, should evict key1, key2, key3 + + expect(evicted).toEqual(['key1', 'key2', 'key3']) + }) + + it('should work without onEvict callback', () => { + const cache = new LRUCache(2) + cache.set('a', 'value-a') + cache.set('b', 'value-b') + cache.set('c', 'value-c') // should evict without error + expect(cache.has('a')).toBe(false) + }) + + it('should pass the evicted value to the callback', () => { + const evicted: Array<{ id: number }> = [] + const cache = new LRUCache<{ id: number }>( + 1, + undefined, + (_key, value) => { + evicted.push(value) + } + ) + + cache.set('obj1', { id: 1 }) + cache.set('obj2', { id: 2 }) // should evict obj1 + + expect(evicted.length).toBe(1) + expect(evicted[0]).toEqual({ id: 1 }) + }) + }) }) diff --git a/packages/next/src/server/lib/lru-cache.ts b/packages/next/src/server/lib/lru-cache.ts index 663bc71ab4264..e8fc56809f216 100644 --- a/packages/next/src/server/lib/lru-cache.ts +++ b/packages/next/src/server/lib/lru-cache.ts @@ -50,10 +50,16 @@ export class LRUCache { private totalSize: number = 0 private readonly maxSize: number private readonly calculateSize: ((value: T) => number) | undefined + private readonly onEvict: ((key: string, value: T) => void) | undefined - constructor(maxSize: number, calculateSize?: (value: T) => number) { + constructor( + maxSize: number, + calculateSize?: (value: T) => number, + onEvict?: (key: string, value: T) => void + ) { this.maxSize = maxSize this.calculateSize = calculateSize + this.onEvict = onEvict // Create sentinel nodes to simplify doubly-linked list operations // HEAD <-> TAIL (empty list) @@ -144,6 +150,7 @@ export class LRUCache { const tail = this.removeTail() this.cache.delete(tail.key) this.totalSize -= tail.size + this.onEvict?.(tail.key, tail.data) } } @@ -191,6 +198,10 @@ export class LRUCache { * Removes a specific key from the cache. * Updates both the hash map and doubly-linked list. * + * Note: This is an explicit removal and does NOT trigger the `onEvict` + * callback. Use this for intentional deletions where eviction tracking + * is not needed. + * * Time Complexity: O(1) */ public remove(key: string): void { diff --git a/packages/next/src/server/response-cache/index.ts b/packages/next/src/server/response-cache/index.ts index ab0057e231097..e40ef2f00596a 100644 --- a/packages/next/src/server/response-cache/index.ts +++ b/packages/next/src/server/response-cache/index.ts @@ -7,6 +7,8 @@ import type { } from './types' import { Batcher } from '../../lib/batcher' +import { LRUCache } from '../lib/lru-cache' +import { warnOnce } from '../../build/output/log' import { scheduleOnNextTick } from '../../lib/scheduler' import { fromResponseCacheEntry, @@ -15,6 +17,92 @@ import { } from './utils' import type { RouteKind } from '../route-kind' +/** + * Parses an environment variable as a positive integer, returning the fallback + * if the value is missing, not a number, or not positive. + */ +function parsePositiveInt( + envValue: string | undefined, + fallback: number +): number { + if (!envValue) return fallback + const parsed = parseInt(envValue, 10) + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback +} + +/** + * Default TTL (in milliseconds) for minimal mode response cache entries. + * Used for cache hit validation as a fallback for providers that don't + * send the x-invocation-id header yet. + * + * 10 seconds chosen because: + * - Long enough to dedupe rapid successive requests (e.g., page + data) + * - Short enough to not serve stale data across unrelated requests + * + * Can be configured via `NEXT_PRIVATE_RESPONSE_CACHE_TTL` environment variable. + */ +const DEFAULT_TTL_MS = parsePositiveInt( + process.env.NEXT_PRIVATE_RESPONSE_CACHE_TTL, + 10_000 +) + +/** + * Default maximum number of entries in the response cache. + * Can be configured via `NEXT_PRIVATE_RESPONSE_CACHE_MAX_SIZE` environment variable. + */ +const DEFAULT_MAX_SIZE = parsePositiveInt( + process.env.NEXT_PRIVATE_RESPONSE_CACHE_MAX_SIZE, + 150 +) + +/** + * Separator used in compound cache keys to join pathname and invocationID. + * Using null byte (\0) since it cannot appear in valid URL paths or UUIDs. + */ +const KEY_SEPARATOR = '\0' + +/** + * Sentinel value used for TTL-based cache entries (when invocationID is undefined). + * Uses KEY_SEPARATOR prefix to guarantee uniqueness since null bytes cannot appear + * in HTTP headers (RFC 7230), making collision with real invocation IDs impossible. + */ +const TTL_SENTINEL = `${KEY_SEPARATOR}ttl` + +/** + * Entry stored in the LRU cache. + */ +type CacheEntry = { + entry: IncrementalResponseCacheEntry | null + /** + * TTL expiration timestamp in milliseconds. Used as a fallback for + * cache hit validation when providers don't send x-invocation-id. + * Memory pressure is managed by LRU eviction rather than timers. + */ + expiresAt: number +} + +/** + * Creates a compound cache key from pathname and invocationID. + */ +function createCacheKey( + pathname: string, + invocationID: string | undefined +): string { + return `${pathname}${KEY_SEPARATOR}${invocationID ?? TTL_SENTINEL}` +} + +/** + * Extracts the invocationID from a compound cache key. + * Returns undefined if the key used TTL_SENTINEL. + */ +function extractInvocationID(compoundKey: string): string | undefined { + const separatorIndex = compoundKey.lastIndexOf(KEY_SEPARATOR) + if (separatorIndex === -1) return undefined + + const invocationID = compoundKey.slice(separatorIndex + 1) + return invocationID === TTL_SENTINEL ? undefined : invocationID +} + export * from './types' export default class ResponseCache implements ResponseCacheBase { @@ -43,19 +131,63 @@ export default class ResponseCache implements ResponseCacheBase { schedulerFn: scheduleOnNextTick, }) - private previousCacheItem?: { - key: string - entry: IncrementalResponseCacheEntry | null - expiresAt: number - } + /** + * LRU cache for minimal mode using compound keys (pathname + invocationID). + * This allows multiple invocations to cache the same pathname without + * overwriting each other's entries. + */ + private readonly cache: LRUCache + + /** + * Set of invocation IDs that have had cache entries evicted. + * Used to detect when the cache size may be too small. + * Bounded to prevent memory growth. + */ + private readonly evictedInvocationIDs: Set = new Set() + + /** + * The configured max size, stored for logging. + */ + private readonly maxSize: number + + /** + * The configured TTL for cache entries in milliseconds. + */ + private readonly ttl: number // we don't use minimal_mode name here as this.minimal_mode is // statically replace for server runtimes but we need it to // be dynamic here private minimal_mode?: boolean - constructor(minimal_mode: boolean) { + constructor( + minimal_mode: boolean, + maxSize: number = DEFAULT_MAX_SIZE, + ttl: number = DEFAULT_TTL_MS + ) { this.minimal_mode = minimal_mode + this.maxSize = maxSize + this.ttl = ttl + + // Create the LRU cache with eviction tracking + this.cache = new LRUCache(maxSize, undefined, (compoundKey) => { + const invocationID = extractInvocationID(compoundKey) + if (invocationID) { + // Bound to 100 entries to prevent unbounded memory growth. + // FIFO eviction is acceptable here because: + // 1. Invocations are short-lived (single request lifecycle), so older + // invocations are unlikely to still be active after 100 newer ones + // 2. This warning mechanism is best-effort for developer guidance— + // missing occasional eviction warnings doesn't affect correctness + // 3. If a long-running invocation is somehow evicted and then has + // another cache entry evicted, it will simply be re-added + if (this.evictedInvocationIDs.size >= 100) { + const first = this.evictedInvocationIDs.values().next().value + if (first) this.evictedInvocationIDs.delete(first) + } + this.evictedInvocationIDs.add(invocationID) + } + }) } /** @@ -77,6 +209,12 @@ export default class ResponseCache implements ResponseCacheBase { isRoutePPREnabled?: boolean isFallback?: boolean waitUntil?: (prom: Promise) => void + + /** + * The invocation ID from the infrastructure. Used to scope the + * in-memory cache to a single revalidation request in minimal mode. + */ + invocationID?: string } ): Promise { // If there is no key for the cache, we can't possibly look this up in the @@ -88,13 +226,38 @@ export default class ResponseCache implements ResponseCacheBase { }) } - // Check minimal mode cache before doing any other work - if ( - this.minimal_mode && - this.previousCacheItem?.key === key && - this.previousCacheItem.expiresAt > Date.now() - ) { - return toResponseCacheEntry(this.previousCacheItem.entry) + // Check minimal mode cache before doing any other work. + if (this.minimal_mode) { + const cacheKey = createCacheKey(key, context.invocationID) + const cachedItem = this.cache.get(cacheKey) + + if (cachedItem) { + // With invocationID: exact match found - always a hit + // With TTL mode: must check expiration + if (context.invocationID !== undefined) { + return toResponseCacheEntry(cachedItem.entry) + } + + // TTL mode: check expiration + const now = Date.now() + if (cachedItem.expiresAt > now) { + return toResponseCacheEntry(cachedItem.entry) + } + + // TTL expired - clean up + this.cache.remove(cacheKey) + } + + // Warn if this invocation had entries evicted - indicates cache may be too small. + if ( + context.invocationID && + this.evictedInvocationIDs.has(context.invocationID) + ) { + warnOnce( + `Response cache entry was evicted for invocation ${context.invocationID}. ` + + `Consider increasing NEXT_PRIVATE_RESPONSE_CACHE_MAX_SIZE (current: ${this.maxSize}).` + ) + } } const { @@ -105,6 +268,7 @@ export default class ResponseCache implements ResponseCacheBase { isPrefetch = false, waitUntil, routeKind, + invocationID, } = context const response = await this.getBatcher.batch( @@ -120,6 +284,7 @@ export default class ResponseCache implements ResponseCacheBase { isRoutePPREnabled, isPrefetch, routeKind, + invocationID, }, resolve ) @@ -153,6 +318,7 @@ export default class ResponseCache implements ResponseCacheBase { isRoutePPREnabled: boolean isPrefetch: boolean routeKind: RouteKind + invocationID: string | undefined }, resolve: (value: IncrementalResponseCacheEntry | null) => void ): Promise { @@ -188,13 +354,18 @@ export default class ResponseCache implements ResponseCacheBase { context.isFallback, responseGenerator, previousIncrementalCacheEntry, - previousIncrementalCacheEntry !== null && !context.isOnDemandRevalidate + previousIncrementalCacheEntry !== null && !context.isOnDemandRevalidate, + undefined, + context.invocationID ) // Handle null response if (!incrementalResponseCacheEntry) { - // Unset the previous cache item if it was set so we don't use it again. - if (this.minimal_mode) this.previousCacheItem = undefined + // Remove the cache item if it was set so we don't use it again. + if (this.minimal_mode) { + const cacheKey = createCacheKey(key, context.invocationID) + this.cache.remove(cacheKey) + } return null } @@ -226,6 +397,8 @@ export default class ResponseCache implements ResponseCacheBase { * @param responseGenerator - The response generator to use to generate the response cache entry. * @param previousIncrementalCacheEntry - The previous cache entry to use to revalidate the cache entry. * @param hasResolved - Whether the response has been resolved. + * @param waitUntil - Optional function to register background work. + * @param invocationID - The invocation ID for cache key scoping. * @returns The revalidated cache entry. */ public async revalidate( @@ -236,7 +409,8 @@ export default class ResponseCache implements ResponseCacheBase { responseGenerator: ResponseGenerator, previousIncrementalCacheEntry: IncrementalResponseCacheEntry | null, hasResolved: boolean, - waitUntil?: (prom: Promise) => void + waitUntil?: (prom: Promise) => void, + invocationID?: string ) { return this.revalidateBatcher.batch(key, () => { const promise = this.handleRevalidate( @@ -246,7 +420,8 @@ export default class ResponseCache implements ResponseCacheBase { isFallback, responseGenerator, previousIncrementalCacheEntry, - hasResolved + hasResolved, + invocationID ) // We need to ensure background revalidates are passed to waitUntil. @@ -263,7 +438,8 @@ export default class ResponseCache implements ResponseCacheBase { isFallback: boolean, responseGenerator: ResponseGenerator, previousIncrementalCacheEntry: IncrementalResponseCacheEntry | null, - hasResolved: boolean + hasResolved: boolean, + invocationID: string | undefined ) { try { // Generate the response cache entry using the response generator. @@ -286,11 +462,14 @@ export default class ResponseCache implements ResponseCacheBase { // defined. if (incrementalResponseCacheEntry.cacheControl) { if (this.minimal_mode) { - this.previousCacheItem = { - key, + // Set TTL expiration for cache hit validation. Entries are validated + // by invocationID when available, with TTL as a fallback for providers + // that don't send x-invocation-id. Memory is managed by LRU eviction. + const cacheKey = createCacheKey(key, invocationID) + this.cache.set(cacheKey, { entry: incrementalResponseCacheEntry, - expiresAt: Date.now() + 1000, - } + expiresAt: Date.now() + this.ttl, + }) } else { await incrementalCache.set(key, incrementalResponseCacheEntry.value, { cacheControl: incrementalResponseCacheEntry.cacheControl, diff --git a/packages/next/src/server/route-modules/route-module.ts b/packages/next/src/server/route-modules/route-module.ts index b8229c52765c6..308f3c3118329 100644 --- a/packages/next/src/server/route-modules/route-module.ts +++ b/packages/next/src/server/route-modules/route-module.ts @@ -1018,6 +1018,9 @@ export abstract class RouteModule< isRoutePPREnabled, isOnDemandRevalidate, isPrefetch: req.headers.purpose === 'prefetch', + // Use x-invocation-id header to scope the in-memory cache to a single + // revalidation request in minimal mode. + invocationID: req.headers['x-invocation-id'] as string | undefined, incrementalCache: await this.getIncrementalCache( req, nextConfig, diff --git a/test/lib/next-test-utils.ts b/test/lib/next-test-utils.ts index 7017ab0a54449..32298e6b03e9e 100644 --- a/test/lib/next-test-utils.ts +++ b/test/lib/next-test-utils.ts @@ -16,6 +16,7 @@ import { writeFile } from 'fs-extra' import getPort from 'get-port' import { getRandomPort } from 'get-port-please' import fetch from 'node-fetch' +import { nanoid } from 'nanoid' import qs from 'querystring' import treeKill from 'tree-kill' import { once } from 'events' @@ -199,6 +200,37 @@ export function fetchViaHTTP( return fetch(getFullUrl(appPort, url), opts) } +/** + * Creates request options with a unique x-invocation-id header for testing + * cache deduplication in minimal mode. Use this when you need to ensure each + * request is treated as independent, or when multiple requests need to share + * the same invocation ID. + * + * @example + * // Independent requests (each gets its own invocation ID) + * const res1 = await fetchViaHTTP(appPort, '/page', undefined, withInvocationId()) + * const res2 = await fetchViaHTTP(appPort, '/page', undefined, withInvocationId()) + * + * @example + * // Grouped requests (share the same invocation ID for cache testing) + * const sharedOpts = withInvocationId() + * const res1 = await fetchViaHTTP(appPort, '/page', undefined, sharedOpts) + * const res2 = await fetchViaHTTP(appPort, '/_next/data/.../page.json', undefined, sharedOpts) + * + * @param opts - Optional existing RequestInit to merge with + * @returns RequestInit with x-invocation-id header added + */ +export function withInvocationId(opts?: RequestInit): RequestInit { + const invocationId = `test:${nanoid()}` + return { + ...opts, + headers: { + ...opts?.headers, + 'x-invocation-id': invocationId, + }, + } +} + export function renderViaHTTP( appPort: string | number, pathname: string, diff --git a/test/production/required-server-files-ssr-404/test/index.test.ts b/test/production/required-server-files-ssr-404/test/index.test.ts index 56be852c7420c..54cc4c0e7b7bb 100644 --- a/test/production/required-server-files-ssr-404/test/index.test.ts +++ b/test/production/required-server-files-ssr-404/test/index.test.ts @@ -3,8 +3,14 @@ import fs from 'fs-extra' import { join } from 'path' import cheerio from 'cheerio' -import { nextServer, startApp, waitFor } from 'next-test-utils' -import { fetchViaHTTP, renderViaHTTP } from 'next-test-utils' +import { + fetchViaHTTP, + nextServer, + renderViaHTTP, + startApp, + waitFor, + withInvocationId, +} from 'next-test-utils' import { nextTestSetup } from 'e2e-utils' describe('Required Server Files', () => { @@ -90,14 +96,24 @@ describe('Required Server Files', () => { }) it('should render SSR page correctly', async () => { - const html = await renderViaHTTP(appPort, '/') + const html = await renderViaHTTP( + appPort, + '/', + undefined, + withInvocationId() + ) const $ = cheerio.load(html) const data = JSON.parse($('#props').text()) expect($('#index').text()).toBe('index page') expect(data.hello).toBe('world') - const html2 = await renderViaHTTP(appPort, '/') + const html2 = await renderViaHTTP( + appPort, + '/', + undefined, + withInvocationId() + ) const $2 = cheerio.load(html2) const data2 = JSON.parse($2('#props').text()) @@ -107,7 +123,12 @@ describe('Required Server Files', () => { }) it('should render dynamic SSR page correctly', async () => { - const html = await renderViaHTTP(appPort, '/dynamic/first') + const html = await renderViaHTTP( + appPort, + '/dynamic/first', + undefined, + withInvocationId() + ) const $ = cheerio.load(html) const data = JSON.parse($('#props').text()) @@ -115,7 +136,12 @@ describe('Required Server Files', () => { expect($('#slug').text()).toBe('first') expect(data.hello).toBe('world') - const html2 = await renderViaHTTP(appPort, '/dynamic/second') + const html2 = await renderViaHTTP( + appPort, + '/dynamic/second', + undefined, + withInvocationId() + ) const $2 = cheerio.load(html2) const data2 = JSON.parse($2('#props').text()) @@ -126,7 +152,12 @@ describe('Required Server Files', () => { }) it('should render fallback page correctly', async () => { - const html = await renderViaHTTP(appPort, '/fallback/first') + const html = await renderViaHTTP( + appPort, + '/fallback/first', + undefined, + withInvocationId() + ) const $ = cheerio.load(html) const data = JSON.parse($('#props').text()) @@ -135,7 +166,12 @@ describe('Required Server Files', () => { expect(data.hello).toBe('world') await waitFor(2000) - const html2 = await renderViaHTTP(appPort, '/fallback/first') + const html2 = await renderViaHTTP( + appPort, + '/fallback/first', + undefined, + withInvocationId() + ) const $2 = cheerio.load(html2) const data2 = JSON.parse($2('#props').text()) @@ -144,7 +180,12 @@ describe('Required Server Files', () => { expect(isNaN(data2.random)).toBe(false) expect(data2.random).not.toBe(data.random) - const html3 = await renderViaHTTP(appPort, '/fallback/second') + const html3 = await renderViaHTTP( + appPort, + '/fallback/second', + undefined, + withInvocationId() + ) const $3 = cheerio.load(html3) const data3 = JSON.parse($3('#props').text()) @@ -155,7 +196,9 @@ describe('Required Server Files', () => { const { pageProps: data4 } = JSON.parse( await renderViaHTTP( appPort, - `/_next/data/${buildId}/fallback/third.json` + `/_next/data/${buildId}/fallback/third.json`, + undefined, + withInvocationId() ) ) expect(data4.hello).toBe('world') @@ -167,11 +210,11 @@ describe('Required Server Files', () => { appPort, '/some-other-path', undefined, - { + withInvocationId({ headers: { 'x-matched-path': '/', }, - } + }) ) const $ = cheerio.load(html) const data = JSON.parse($('#props').text()) @@ -183,11 +226,11 @@ describe('Required Server Files', () => { appPort, '/some-other-path', undefined, - { + withInvocationId({ headers: { 'x-matched-path': '/', }, - } + }) ) const $2 = cheerio.load(html2) const data2 = JSON.parse($2('#props').text()) @@ -202,11 +245,11 @@ describe('Required Server Files', () => { appPort, '/some-other-path?nxtPslug=first', undefined, - { + withInvocationId({ headers: { 'x-matched-path': '/dynamic/[slug]', }, - } + }) ) const $ = cheerio.load(html) const data = JSON.parse($('#props').text()) @@ -219,11 +262,11 @@ describe('Required Server Files', () => { appPort, '/some-other-path?slug=second', undefined, - { + withInvocationId({ headers: { 'x-matched-path': '/dynamic/[slug]', }, - } + }) ) const $2 = cheerio.load(html2) const data2 = JSON.parse($2('#props').text()) @@ -239,12 +282,12 @@ describe('Required Server Files', () => { appPort, '/fallback/first', undefined, - { + withInvocationId({ headers: { 'x-matched-path': '/fallback/first', 'x-now-route-matches': 'nxtPslug=first', }, - } + }) ) const $ = cheerio.load(html) const data = JSON.parse($('#props').text()) @@ -257,12 +300,12 @@ describe('Required Server Files', () => { appPort, `/fallback/[slug]`, undefined, - { + withInvocationId({ headers: { 'x-matched-path': '/fallback/[slug]', 'x-now-route-matches': 'nxtPslug=second', }, - } + }) ) const $2 = cheerio.load(html2) const data2 = JSON.parse($2('#props').text()) @@ -278,11 +321,11 @@ describe('Required Server Files', () => { appPort, `/_next/data/${buildId}/dynamic/first.json?nxtPslug=first`, undefined, - { + withInvocationId({ headers: { 'x-matched-path': '/dynamic/[slug]', }, - } + }) ) const { pageProps: data } = await res.json() @@ -294,12 +337,12 @@ describe('Required Server Files', () => { appPort, `/_next/data/${buildId}/fallback/[slug].json`, undefined, - { + withInvocationId({ headers: { 'x-matched-path': `/_next/data/${buildId}/fallback/[slug].json`, 'x-now-route-matches': 'nxtPslug=second', }, - } + }) ) const { pageProps: data2 } = await res2.json() @@ -313,12 +356,12 @@ describe('Required Server Files', () => { appPort, '/catch-all/[[...rest]]', undefined, - { + withInvocationId({ headers: { 'x-matched-path': '/catch-all/[[...rest]]', 'x-now-route-matches': '', }, - } + }) ) const $ = cheerio.load(html) const data = JSON.parse($('#props').text()) @@ -331,12 +374,12 @@ describe('Required Server Files', () => { appPort, '/catch-all/[[...rest]]', undefined, - { + withInvocationId({ headers: { 'x-matched-path': '/catch-all/[[...rest]]', 'x-now-route-matches': 'nxtPrest=hello', }, - } + }) ) const $2 = cheerio.load(html2) const data2 = JSON.parse($2('#props').text()) @@ -350,13 +393,13 @@ describe('Required Server Files', () => { appPort, '/catch-all/[[...rest]]', undefined, - { + withInvocationId({ headers: { 'x-matched-path': '/catch-all/[[...rest]]', 'x-now-route-matches': 'nxtPrest=hello/world&catchAll=hello/world', }, - } + }) ) const $3 = cheerio.load(html3) const data3 = JSON.parse($3('#props').text()) @@ -370,11 +413,11 @@ describe('Required Server Files', () => { appPort, '/catch-all/[[...rest]]', { nxtPrest: 'frank' }, - { + withInvocationId({ headers: { 'x-matched-path': '/catch-all/[[...rest]]', }, - } + }) ) const $4 = cheerio.load(html4) const data4 = JSON.parse($4('#props').text()) @@ -388,11 +431,11 @@ describe('Required Server Files', () => { appPort, '/catch-all/[[...rest]]', {}, - { + withInvocationId({ headers: { 'x-matched-path': '/catch-all/[[...rest]]', }, - } + }) ) const $5 = cheerio.load(html5) const data5 = JSON.parse($5('#props').text()) @@ -406,11 +449,11 @@ describe('Required Server Files', () => { appPort, '/catch-all/[[...rest]]', { nxtPrest: 'frank' }, - { + withInvocationId({ headers: { 'x-matched-path': '/catch-all/[[...rest]]', }, - } + }) ) const $6 = cheerio.load(html6) const data6 = JSON.parse($6('#props').text()) @@ -443,11 +486,11 @@ describe('Required Server Files', () => { appPort, '/partial-catch-all/[domain]/[[...rest]]', query, - { + withInvocationId({ headers: { 'x-matched-path': '/partial-catch-all/[domain]/[[...rest]]', }, - } + }) ) const $ = cheerio.load(html) @@ -464,11 +507,11 @@ describe('Required Server Files', () => { appPort, `/_next/data/${buildId}/catch-all.json`, undefined, - { + withInvocationId({ headers: { 'x-matched-path': '/catch-all/[[...rest]]', }, - } + }) ) const { pageProps: data } = await res.json() @@ -480,12 +523,12 @@ describe('Required Server Files', () => { appPort, `/_next/data/${buildId}/catch-all/[[...rest]].json`, undefined, - { + withInvocationId({ headers: { 'x-matched-path': `/_next/data/${buildId}/catch-all/[[...rest]].json`, 'x-now-route-matches': 'nxtPrest=hello&rest=hello', }, - } + }) ) const { pageProps: data2 } = await res2.json() @@ -497,12 +540,12 @@ describe('Required Server Files', () => { appPort, `/_next/data/${buildId}/catch-all/[[...rest]].json`, undefined, - { + withInvocationId({ headers: { 'x-matched-path': `/_next/data/${buildId}/catch-all/[[...rest]].json`, 'x-now-route-matches': 'nxtPrest=hello/world&rest=hello/world', }, - } + }) ) const { pageProps: data3 } = await res3.json() @@ -521,9 +564,14 @@ describe('Required Server Files', () => { '/fallback/another/', '/fallback/another', ]) { - const res = await fetchViaHTTP(appPort, path, undefined, { - redirect: 'manual', - }) + const res = await fetchViaHTTP( + appPort, + path, + undefined, + withInvocationId({ + redirect: 'manual', + }) + ) expect(res.status).toBe(200) } @@ -536,11 +584,11 @@ describe('Required Server Files', () => { { path: 'hello/world', }, - { + withInvocationId({ headers: { 'x-matched-path': '/', }, - } + }) ) const $ = cheerio.load(html) expect(JSON.parse($('#router').text()).query).toEqual({ @@ -549,19 +597,34 @@ describe('Required Server Files', () => { }) it('should bubble error correctly for gip page', async () => { - const res = await fetchViaHTTP(appPort, '/errors/gip', { crash: '1' }) + const res = await fetchViaHTTP( + appPort, + '/errors/gip', + { crash: '1' }, + withInvocationId() + ) expect(res.status).toBe(500) expect(await res.text()).toBe('Internal Server Error') }) it('should bubble error correctly for gssp page', async () => { - const res = await fetchViaHTTP(appPort, '/errors/gssp', { crash: '1' }) + const res = await fetchViaHTTP( + appPort, + '/errors/gssp', + { crash: '1' }, + withInvocationId() + ) expect(res.status).toBe(500) expect(await res.text()).toBe('Internal Server Error') }) it('should bubble error correctly for gsp page', async () => { - const res = await fetchViaHTTP(appPort, '/errors/gsp/crash') + const res = await fetchViaHTTP( + appPort, + '/errors/gsp/crash', + undefined, + withInvocationId() + ) expect(res.status).toBe(500) expect(await res.text()).toBe('Internal Server Error') }) @@ -571,11 +634,11 @@ describe('Required Server Files', () => { appPort, '/optional-ssp', { nxtPrest: '', another: 'value' }, - { + withInvocationId({ headers: { 'x-matched-path': '/optional-ssp/[[...rest]]', }, - } + }) ) const html = await res.text() @@ -590,11 +653,11 @@ describe('Required Server Files', () => { appPort, '/optional-ssg', { rest: '', another: 'value' }, - { + withInvocationId({ headers: { 'x-matched-path': '/optional-ssg/[[...rest]]', }, - } + }) ) const html = await res.text() @@ -608,11 +671,11 @@ describe('Required Server Files', () => { appPort, '/api/optional', { nxtPrest: '', another: 'value' }, - { + withInvocationId({ headers: { 'x-matched-path': '/api/optional/[[...rest]]', }, - } + }) ) const json = await res.json() @@ -621,12 +684,17 @@ describe('Required Server Files', () => { }) it('should match the index page correctly', async () => { - const res = await fetchViaHTTP(appPort, '/', undefined, { - headers: { - 'x-matched-path': '/index', - }, - redirect: 'manual', - }) + const res = await fetchViaHTTP( + appPort, + '/', + undefined, + withInvocationId({ + headers: { + 'x-matched-path': '/index', + }, + redirect: 'manual', + }) + ) const html = await res.text() const $ = cheerio.load(html) @@ -634,12 +702,17 @@ describe('Required Server Files', () => { }) it('should match the root dynamic page correctly', async () => { - const res = await fetchViaHTTP(appPort, '/slug-1', undefined, { - headers: { - 'x-matched-path': '/[slug]', - }, - redirect: 'manual', - }) + const res = await fetchViaHTTP( + appPort, + '/slug-1', + undefined, + withInvocationId({ + headers: { + 'x-matched-path': '/[slug]', + }, + redirect: 'manual', + }) + ) const html = await res.text() const $ = cheerio.load(html) @@ -652,12 +725,17 @@ describe('Required Server Files', () => { '/non-existent', '/404', ]) { - const res = await fetchViaHTTP(appPort, pathname, undefined, { - headers: { - 'x-matched-path': '/404', - redirect: 'manual', - }, - }) + const res = await fetchViaHTTP( + appPort, + pathname, + undefined, + withInvocationId({ + headers: { + 'x-matched-path': '/404', + redirect: 'manual', + }, + }) + ) expect(res.status).toBe(404) expect(await res.text()).toContain('custom 404') } @@ -666,12 +744,17 @@ describe('Required Server Files', () => { '/_next/static/chunks/pages/index-abc123.js', '/_next/static/some-file.js', ]) { - const res = await fetchViaHTTP(appPort, pathname, undefined, { - headers: { - 'x-matched-path': '/404', - redirect: 'manual', - }, - }) + const res = await fetchViaHTTP( + appPort, + pathname, + undefined, + withInvocationId({ + headers: { + 'x-matched-path': '/404', + redirect: 'manual', + }, + }) + ) expect(res.status).toBe(404) expect(res.headers.get('content-type')).toBe( 'text/plain; charset=utf-8' diff --git a/test/production/standalone-mode/required-server-files/required-server-files-app.test.ts b/test/production/standalone-mode/required-server-files/required-server-files-app.test.ts index 19a0aad43a173..02f10d104feb3 100644 --- a/test/production/standalone-mode/required-server-files/required-server-files-app.test.ts +++ b/test/production/standalone-mode/required-server-files/required-server-files-app.test.ts @@ -10,6 +10,7 @@ import { findPort, initNextServerScript, killApp, + withInvocationId, } from 'next-test-utils' import { ChildProcess } from 'child_process' @@ -103,14 +104,19 @@ describe('required server files app router', () => { }) it('should send the right cache headers for an app route', async () => { - const res = await fetchViaHTTP(appPort, '/api/test/123', undefined, { - headers: { - 'x-matched-path': '/api/test/[slug]', - 'x-now-route-matches': createNowRouteMatches({ - slug: '123', - }).toString(), - }, - }) + const res = await fetchViaHTTP( + appPort, + '/api/test/123', + undefined, + withInvocationId({ + headers: { + 'x-matched-path': '/api/test/[slug]', + 'x-now-route-matches': createNowRouteMatches({ + slug: '123', + }).toString(), + }, + }) + ) expect(res.status).toBe(200) expect(res.headers.get('cache-control')).toBe('s-maxage=31536000') }) @@ -120,7 +126,7 @@ describe('required server files app router', () => { appPort, '/optional-catchall/[lang]/[flags]/[[...slug]]', undefined, - { + withInvocationId({ headers: { 'x-matched-path': '/optional-catchall/[lang]/[flags]/[[...slug]]', 'x-now-route-matches': createNowRouteMatches({ @@ -129,7 +135,7 @@ describe('required server files app router', () => { slug: 'slug', }).toString(), }, - } + }) ) expect(res.status).toBe(200) @@ -142,7 +148,7 @@ describe('required server files app router', () => { appPort, '/optional-catchall/[lang]/[flags]/[[...slug]]', undefined, - { + withInvocationId({ headers: { 'x-matched-path': '/optional-catchall/[lang]/[flags]/[[...slug]]', 'x-now-route-matches': createNowRouteMatches({ @@ -150,7 +156,7 @@ describe('required server files app router', () => { flags: 'flags', }).toString(), }, - } + }) ) expect(res.status).toBe(200) @@ -162,14 +168,19 @@ describe('required server files app router', () => { }) it('should send the right cache headers for an app page', async () => { - const res = await fetchViaHTTP(appPort, '/test/123', undefined, { - headers: { - 'x-matched-path': '/test/[slug]', - 'x-now-route-matches': createNowRouteMatches({ - slug: '123', - }).toString(), - }, - }) + const res = await fetchViaHTTP( + appPort, + '/test/123', + undefined, + withInvocationId({ + headers: { + 'x-matched-path': '/test/[slug]', + 'x-now-route-matches': createNowRouteMatches({ + slug: '123', + }).toString(), + }, + }) + ) expect(res.status).toBe(200) expect(res.headers.get('cache-control')).toBe( 's-maxage=3600, stale-while-revalidate=31532400' @@ -181,13 +192,18 @@ describe('required server files app router', () => { }) it('should properly handle prerender for bot request', async () => { - const res = await fetchViaHTTP(appPort, '/isr/first', undefined, { - headers: { - 'user-agent': - 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.179 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)', - 'x-matched-path': '/isr/first', - }, - }) + const res = await fetchViaHTTP( + appPort, + '/isr/first', + undefined, + withInvocationId({ + headers: { + 'user-agent': + 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.179 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)', + 'x-matched-path': '/isr/first', + }, + }) + ) expect(res.status).toBe(200) const html = await res.text() @@ -195,28 +211,38 @@ describe('required server files app router', () => { expect($('#page').text()).toBe('/isr/[slug]') - const rscRes = await fetchViaHTTP(appPort, '/isr/first.rsc', undefined, { - headers: { - 'user-agent': - 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.179 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)', - 'x-matched-path': '/isr/first', - }, - }) + const rscRes = await fetchViaHTTP( + appPort, + '/isr/first.rsc', + undefined, + withInvocationId({ + headers: { + 'user-agent': + 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.179 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)', + 'x-matched-path': '/isr/first', + }, + }) + ) expect(rscRes.status).toBe(200) }) it('should properly handle fallback for bot request', async () => { - const res = await fetchViaHTTP(appPort, '/isr/[slug]', undefined, { - headers: { - 'user-agent': - 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.179 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)', - 'x-now-route-matches': createNowRouteMatches({ - slug: 'new', - }).toString(), - 'x-matched-path': '/isr/[slug]', - }, - }) + const res = await fetchViaHTTP( + appPort, + '/isr/[slug]', + undefined, + withInvocationId({ + headers: { + 'user-agent': + 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.179 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)', + 'x-now-route-matches': createNowRouteMatches({ + slug: 'new', + }).toString(), + 'x-matched-path': '/isr/[slug]', + }, + }) + ) expect(res.status).toBe(200) const html = await res.text() @@ -224,16 +250,21 @@ describe('required server files app router', () => { expect($('#page').text()).toBe('/isr/[slug]') - const rscRes = await fetchViaHTTP(appPort, '/isr/[slug].rsc', undefined, { - headers: { - 'user-agent': - 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.179 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)', - 'x-now-route-matches': createNowRouteMatches({ - slug: 'new', - }).toString(), - 'x-matched-path': '/isr/[slug]', - }, - }) + const rscRes = await fetchViaHTTP( + appPort, + '/isr/[slug].rsc', + undefined, + withInvocationId({ + headers: { + 'user-agent': + 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.179 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)', + 'x-now-route-matches': createNowRouteMatches({ + slug: 'new', + }).toString(), + 'x-matched-path': '/isr/[slug]', + }, + }) + ) expect(rscRes.status).toBe(200) }) @@ -258,9 +289,14 @@ describe('required server files app router', () => { ], ]) { require('console').error('checking', { path, tags }) - const res = await fetchViaHTTP(appPort, path, undefined, { - redirect: 'manual', - }) + const res = await fetchViaHTTP( + appPort, + path, + undefined, + withInvocationId({ + redirect: 'manual', + }) + ) expect(res.status).toBe(200) expect(res.headers.get('x-next-cache-tags')).toBe(tags) } @@ -273,9 +309,14 @@ describe('required server files app router', () => { '/api/ssr/first', '/api/ssr/second', ]) { - const res = await fetchViaHTTP(appPort, path, undefined, { - redirect: 'manual', - }) + const res = await fetchViaHTTP( + appPort, + path, + undefined, + withInvocationId({ + redirect: 'manual', + }) + ) expect(res.status).toBe(200) expect(res.headers.get('x-next-cache-tags')).toBeFalsy() } @@ -292,9 +333,9 @@ describe('required server files app router', () => { appPort, path, { hello: 'world' }, - { + withInvocationId({ redirect: 'manual', - } + }) ) expect(res.status).toBe(200) expect(res.headers.get('x-next-cache-tags')).toBeFalsy() @@ -306,11 +347,11 @@ describe('required server files app router', () => { appPort, '/search/[key]', { key: 'searchParams', nxtPkey: 'params' }, - { + withInvocationId({ headers: { 'x-matched-path': '/search/[key]', }, - } + }) ) const html = await res.text() @@ -318,4 +359,80 @@ describe('required server files app router', () => { expect($('dd[data-params]').text()).toBe('params') expect($('dd[data-searchParams]').text()).toBe('searchParams') }) + + it('should de-dupe HTML/RSC requests for ISR pages', async () => { + // Create a shared invocation ID for HTML and RSC requests to test de-duplication + const sharedOpts = withInvocationId() + + // First request: HTML for ISR page + const htmlRes = await fetchViaHTTP(appPort, '/isr/[slug]', undefined, { + ...sharedOpts, + headers: { + ...sharedOpts.headers, + 'x-matched-path': '/isr/[slug]', + 'x-now-route-matches': createNowRouteMatches({ + slug: 'first', + }).toString(), + }, + }) + expect(htmlRes.status).toBe(200) + const html = await htmlRes.text() + const $ = cheerio.load(html) + const timestamp1 = $('#now').text() + + // Second request: RSC for same page with same x-invocation-id + const rscRes = await fetchViaHTTP(appPort, '/isr/[slug].rsc', undefined, { + ...sharedOpts, + headers: { + ...sharedOpts.headers, + 'x-matched-path': '/isr/[slug]', + 'x-now-route-matches': createNowRouteMatches({ + slug: 'first', + }).toString(), + }, + }) + expect(rscRes.status).toBe(200) + const rscText = await rscRes.text() + + // Both should have the same timestamp (same cached render) + expect(rscText).toContain(timestamp1) + }) + + it('should isolate cache between different ISR request groups', async () => { + // First group makes a request with its own invocation ID + const group1Opts = withInvocationId() + const res1 = await fetchViaHTTP(appPort, '/isr/[slug]', undefined, { + ...group1Opts, + headers: { + ...group1Opts.headers, + 'x-matched-path': '/isr/[slug]', + 'x-now-route-matches': createNowRouteMatches({ + slug: 'first', + }).toString(), + }, + }) + expect(res1.status).toBe(200) + const $1 = cheerio.load(await res1.text()) + const data1 = $1('#data').text() + + // Second group with different x-invocation-id + const group2Opts = withInvocationId() + const res2 = await fetchViaHTTP(appPort, '/isr/[slug]', undefined, { + ...group2Opts, + headers: { + ...group2Opts.headers, + 'x-matched-path': '/isr/[slug]', + 'x-now-route-matches': createNowRouteMatches({ + slug: 'first', + }).toString(), + }, + }) + expect(res2.status).toBe(200) + const $2 = cheerio.load(await res2.text()) + const data2 = $2('#data').text() + + // Each group should get its own render with different random data + // since ISR pages fetch fresh data on each render + expect(data1).not.toBe(data2) + }) }) diff --git a/test/production/standalone-mode/required-server-files/required-server-files-i18n.test.ts b/test/production/standalone-mode/required-server-files/required-server-files-i18n.test.ts index b6ba636e8a769..50ce6820835dd 100644 --- a/test/production/standalone-mode/required-server-files/required-server-files-i18n.test.ts +++ b/test/production/standalone-mode/required-server-files/required-server-files-i18n.test.ts @@ -13,6 +13,7 @@ import { killApp, renderViaHTTP, waitFor, + withInvocationId, } from 'next-test-utils' import nodeFetch from 'node-fetch' import { ChildProcess } from 'child_process' @@ -136,22 +137,32 @@ describe('required server files i18n', () => { }) it('should not apply locale redirect in minimal mode', async () => { - const res = await fetchViaHTTP(appPort, '/', undefined, { - redirect: 'manual', - headers: { - 'accept-language': 'fr', - }, - }) + const res = await fetchViaHTTP( + appPort, + '/', + undefined, + withInvocationId({ + redirect: 'manual', + headers: { + 'accept-language': 'fr', + }, + }) + ) expect(res.status).toBe(200) expect(await res.text()).toContain('index page') - const resCookie = await fetchViaHTTP(appPort, '/', undefined, { - redirect: 'manual', - headers: { - 'accept-language': 'en', - cookie: 'NEXT_LOCALE=fr', - }, - }) + const resCookie = await fetchViaHTTP( + appPort, + '/', + undefined, + withInvocationId({ + redirect: 'manual', + headers: { + 'accept-language': 'en', + cookie: 'NEXT_LOCALE=fr', + }, + }) + ) expect(resCookie.status).toBe(200) expect(await resCookie.text()).toContain('index page') }) @@ -170,9 +181,14 @@ describe('required server files i18n', () => { it('should set correct SWR headers with notFound gsp', async () => { await next.patchFile('standalone/data.txt', 'show') - const res = await fetchViaHTTP(appPort, '/gsp', undefined, { - redirect: 'manual', - }) + const res = await fetchViaHTTP( + appPort, + '/gsp', + undefined, + withInvocationId({ + redirect: 'manual', + }) + ) expect(res.status).toBe(200) expect(res.headers.get('cache-control')).toBe( 's-maxage=1, stale-while-revalidate=31535999' @@ -181,9 +197,14 @@ describe('required server files i18n', () => { await waitFor(2000) await next.patchFile('standalone/data.txt', 'hide') - const res2 = await fetchViaHTTP(appPort, '/gsp', undefined, { - redirect: 'manual', - }) + const res2 = await fetchViaHTTP( + appPort, + '/gsp', + undefined, + withInvocationId({ + redirect: 'manual', + }) + ) expect(res2.status).toBe(404) expect(res2.headers.get('cache-control')).toBe( 's-maxage=1, stale-while-revalidate=31535999' @@ -193,9 +214,14 @@ describe('required server files i18n', () => { it('should set correct SWR headers with notFound gssp', async () => { await next.patchFile('standalone/data.txt', 'show') - const res = await fetchViaHTTP(appPort, '/gssp', undefined, { - redirect: 'manual', - }) + const res = await fetchViaHTTP( + appPort, + '/gssp', + undefined, + withInvocationId({ + redirect: 'manual', + }) + ) expect(res.status).toBe(200) expect(res.headers.get('cache-control')).toBe( 's-maxage=1, stale-while-revalidate=31535999' @@ -203,9 +229,14 @@ describe('required server files i18n', () => { await next.patchFile('standalone/data.txt', 'hide') - const res2 = await fetchViaHTTP(appPort, '/gssp', undefined, { - redirect: 'manual', - }) + const res2 = await fetchViaHTTP( + appPort, + '/gssp', + undefined, + withInvocationId({ + redirect: 'manual', + }) + ) await next.patchFile('standalone/data.txt', 'show') expect(res2.status).toBe(404) @@ -215,14 +246,24 @@ describe('required server files i18n', () => { }) it('should render SSR page correctly', async () => { - const html = await renderViaHTTP(appPort, '/gssp') + const html = await renderViaHTTP( + appPort, + '/gssp', + undefined, + withInvocationId() + ) const $ = cheerio.load(html) const data = JSON.parse($('#props').text()) expect($('#gssp').text()).toBe('getServerSideProps page') expect(data.hello).toBe('world') - const html2 = await renderViaHTTP(appPort, '/gssp') + const html2 = await renderViaHTTP( + appPort, + '/gssp', + undefined, + withInvocationId() + ) const $2 = cheerio.load(html2) const data2 = JSON.parse($2('#props').text()) @@ -232,7 +273,12 @@ describe('required server files i18n', () => { }) it('should render dynamic SSR page correctly', async () => { - const html = await renderViaHTTP(appPort, '/dynamic/first') + const html = await renderViaHTTP( + appPort, + '/dynamic/first', + undefined, + withInvocationId() + ) const $ = cheerio.load(html) const data = JSON.parse($('#props').text()) @@ -240,7 +286,12 @@ describe('required server files i18n', () => { expect($('#slug').text()).toBe('first') expect(data.hello).toBe('world') - const html2 = await renderViaHTTP(appPort, '/dynamic/second') + const html2 = await renderViaHTTP( + appPort, + '/dynamic/second', + undefined, + withInvocationId() + ) const $2 = cheerio.load(html2) const data2 = JSON.parse($2('#props').text()) @@ -251,7 +302,12 @@ describe('required server files i18n', () => { }) it('should render fallback page correctly', async () => { - const html = await renderViaHTTP(appPort, '/fallback/first') + const html = await renderViaHTTP( + appPort, + '/fallback/first', + undefined, + withInvocationId() + ) const $ = cheerio.load(html) const data = JSON.parse($('#props').text()) @@ -260,7 +316,13 @@ describe('required server files i18n', () => { expect(data.hello).toBe('world') await waitFor(2000) - const html2 = await renderViaHTTP(appPort, '/fallback/first') + + const html2 = await renderViaHTTP( + appPort, + '/fallback/first', + undefined, + withInvocationId() + ) const $2 = cheerio.load(html2) const data2 = JSON.parse($2('#props').text()) @@ -269,7 +331,12 @@ describe('required server files i18n', () => { expect(isNaN(data2.random)).toBe(false) expect(data2.random).not.toBe(data.random) - const html3 = await renderViaHTTP(appPort, '/fallback/second') + const html3 = await renderViaHTTP( + appPort, + '/fallback/second', + undefined, + withInvocationId() + ) const $3 = cheerio.load(html3) const data3 = JSON.parse($3('#props').text()) @@ -280,7 +347,9 @@ describe('required server files i18n', () => { const { pageProps: data4 } = JSON.parse( await renderViaHTTP( appPort, - `/_next/data/${next.buildId}/en/fallback/third.json` + `/_next/data/${next.buildId}/en/fallback/third.json`, + undefined, + withInvocationId() ) ) expect(data4.hello).toBe('world') @@ -288,22 +357,32 @@ describe('required server files i18n', () => { }) it('should render SSR page correctly with x-matched-path', async () => { - const html = await renderViaHTTP(appPort, '/some-other-path', undefined, { - headers: { - 'x-matched-path': '/gssp', - }, - }) + const html = await renderViaHTTP( + appPort, + '/some-other-path', + undefined, + withInvocationId({ + headers: { + 'x-matched-path': '/gssp', + }, + }) + ) const $ = cheerio.load(html) const data = JSON.parse($('#props').text()) expect($('#gssp').text()).toBe('getServerSideProps page') expect(data.hello).toBe('world') - const html2 = await renderViaHTTP(appPort, '/some-other-path', undefined, { - headers: { - 'x-matched-path': '/gssp', - }, - }) + const html2 = await renderViaHTTP( + appPort, + '/some-other-path', + undefined, + withInvocationId({ + headers: { + 'x-matched-path': '/gssp', + }, + }) + ) const $2 = cheerio.load(html2) const data2 = JSON.parse($2('#props').text()) @@ -317,11 +396,11 @@ describe('required server files i18n', () => { appPort, '/some-other-path?nxtPslug=first', undefined, - { + withInvocationId({ headers: { 'x-matched-path': '/dynamic/[slug]', }, - } + }) ) const $ = cheerio.load(html) const data = JSON.parse($('#props').text()) @@ -334,11 +413,11 @@ describe('required server files i18n', () => { appPort, '/some-other-path?nxtPslug=second', undefined, - { + withInvocationId({ headers: { 'x-matched-path': '/dynamic/[slug]', }, - } + }) ) const $2 = cheerio.load(html2) const data2 = JSON.parse($2('#props').text()) @@ -352,11 +431,11 @@ describe('required server files i18n', () => { appPort, '/some-other-path?nxtPslug=second', undefined, - { + withInvocationId({ headers: { 'x-matched-path': '/dynamic/[slug]?slug=%5Bslug%5D.json', }, - } + }) ) const $3 = cheerio.load(html3) const data3 = JSON.parse($3('#props').text()) @@ -368,14 +447,19 @@ describe('required server files i18n', () => { }) it('should render fallback page correctly with x-matched-path and routes-matches', async () => { - const html = await renderViaHTTP(appPort, '/fallback/first', undefined, { - headers: { - 'x-matched-path': '/fallback/first', - 'x-now-route-matches': createNowRouteMatches({ - slug: 'first', - }).toString(), - }, - }) + const html = await renderViaHTTP( + appPort, + '/fallback/first', + undefined, + withInvocationId({ + headers: { + 'x-matched-path': '/fallback/first', + 'x-now-route-matches': createNowRouteMatches({ + slug: 'first', + }).toString(), + }, + }) + ) const $ = cheerio.load(html) const data = JSON.parse($('#props').text()) @@ -383,14 +467,19 @@ describe('required server files i18n', () => { expect($('#slug').text()).toBe('first') expect(data.hello).toBe('world') - const html2 = await renderViaHTTP(appPort, `/fallback/[slug]`, undefined, { - headers: { - 'x-matched-path': '/fallback/[slug]', - 'x-now-route-matches': createNowRouteMatches({ - slug: 'second', - }).toString(), - }, - }) + const html2 = await renderViaHTTP( + appPort, + `/fallback/[slug]`, + undefined, + withInvocationId({ + headers: { + 'x-matched-path': '/fallback/[slug]', + 'x-now-route-matches': createNowRouteMatches({ + slug: 'second', + }).toString(), + }, + }) + ) const $2 = cheerio.load(html2) const data2 = JSON.parse($2('#props').text()) @@ -407,11 +496,11 @@ describe('required server files i18n', () => { { slug: 'first' } ).toString()}`, undefined, - { + withInvocationId({ headers: { 'x-matched-path': '/dynamic/[slug]', }, - } + }) ) const { pageProps: data } = await res.json() @@ -423,14 +512,14 @@ describe('required server files i18n', () => { appPort, `/_next/data/${next.buildId}/en/fallback/[slug].json`, undefined, - { + withInvocationId({ headers: { 'x-matched-path': `/_next/data/${next.buildId}/en/fallback/[slug].json`, 'x-now-route-matches': createNowRouteMatches({ slug: 'second', }).toString(), }, - } + }) ) const { pageProps: data2 } = await res2.json() @@ -444,12 +533,12 @@ describe('required server files i18n', () => { appPort, '/catch-all/[[...rest]]', undefined, - { + withInvocationId({ headers: { 'x-matched-path': '/catch-all/[[...rest]]', 'x-now-route-matches': '', }, - } + }) ) const $ = cheerio.load(html) const data = JSON.parse($('#props').text()) @@ -462,14 +551,14 @@ describe('required server files i18n', () => { appPort, '/catch-all/[[...rest]]', undefined, - { + withInvocationId({ headers: { 'x-matched-path': '/catch-all/[[...rest]]', 'x-now-route-matches': createNowRouteMatches({ rest: 'hello', }).toString(), }, - } + }) ) const $2 = cheerio.load(html2) const data2 = JSON.parse($2('#props').text()) @@ -483,14 +572,14 @@ describe('required server files i18n', () => { appPort, '/catch-all/[[...rest]]', undefined, - { + withInvocationId({ headers: { 'x-matched-path': '/catch-all/[[...rest]]', 'x-now-route-matches': createNowRouteMatches({ rest: 'hello/world', }).toString(), }, - } + }) ) const $3 = cheerio.load(html3) const data3 = JSON.parse($3('#props').text()) @@ -506,11 +595,11 @@ describe('required server files i18n', () => { appPort, `/_next/data/${next.buildId}/en/catch-all.json`, undefined, - { + withInvocationId({ headers: { 'x-matched-path': '/en/catch-all/[[...rest]]', }, - } + }) ) const { pageProps: data } = await res.json() @@ -522,14 +611,14 @@ describe('required server files i18n', () => { appPort, `/_next/data/${next.buildId}/en/catch-all/[[...rest]].json`, undefined, - { + withInvocationId({ headers: { 'x-matched-path': `/_next/data/${next.buildId}/en/catch-all/[[...rest]].json`, 'x-now-route-matches': createNowRouteMatches({ rest: 'hello', }).toString(), }, - } + }) ) const { pageProps: data2 } = await res2.json() @@ -541,14 +630,14 @@ describe('required server files i18n', () => { appPort, `/_next/data/${next.buildId}/en/catch-all/[[...rest]].json`, undefined, - { + withInvocationId({ headers: { 'x-matched-path': `/_next/data/${next.buildId}/en/catch-all/[[...rest]].json`, 'x-now-route-matches': createNowRouteMatches({ rest: 'hello/world', }).toString(), }, - } + }) ) const { pageProps: data3 } = await res3.json() @@ -567,9 +656,14 @@ describe('required server files i18n', () => { '/fallback/another/', '/fallback/another', ]) { - const res = await fetchViaHTTP(appPort, path, undefined, { - redirect: 'manual', - }) + const res = await fetchViaHTTP( + appPort, + path, + undefined, + withInvocationId({ + redirect: 'manual', + }) + ) expect(res.status).toBe(200) } @@ -582,11 +676,11 @@ describe('required server files i18n', () => { { path: 'hello/world', }, - { + withInvocationId({ headers: { 'x-matched-path': '/gssp', }, - } + }) ) const $ = cheerio.load(html) expect(JSON.parse($('#router').text()).query.path).toEqual([ @@ -604,18 +698,23 @@ describe('required server files i18n', () => { path: '%c0.%c0.', }) ), - { + withInvocationId({ headers: { 'x-matched-path': '/dynamic/[slug]', }, - } + }) ) expect(res.status).toBe(400) expect(await res.text()).toContain('Bad Request') }) it('should bubble error correctly for gip page', async () => { - const res = await fetchViaHTTP(appPort, '/errors/gip', { crash: '1' }) + const res = await fetchViaHTTP( + appPort, + '/errors/gip', + { crash: '1' }, + withInvocationId() + ) expect(res.status).toBe(500) expect(await res.text()).toBe('Internal Server Error') @@ -629,7 +728,12 @@ describe('required server files i18n', () => { }) it('should bubble error correctly for gssp page', async () => { - const res = await fetchViaHTTP(appPort, '/errors/gssp', { crash: '1' }) + const res = await fetchViaHTTP( + appPort, + '/errors/gssp', + { crash: '1' }, + withInvocationId() + ) expect(res.status).toBe(500) expect(await res.text()).toBe('Internal Server Error') await check( @@ -642,7 +746,12 @@ describe('required server files i18n', () => { }) it('should bubble error correctly for gsp page', async () => { - const res = await fetchViaHTTP(appPort, '/errors/gsp/crash') + const res = await fetchViaHTTP( + appPort, + '/errors/gsp/crash', + undefined, + withInvocationId() + ) expect(res.status).toBe(500) expect(await res.text()).toBe('Internal Server Error') await check( @@ -656,7 +765,12 @@ describe('required server files i18n', () => { it('should bubble error correctly for API page', async () => { errors = [] - const res = await fetchViaHTTP(appPort, '/api/error') + const res = await fetchViaHTTP( + appPort, + '/api/error', + undefined, + withInvocationId() + ) expect(res.status).toBe(500) expect(await res.text()).toBe('Internal Server Error') await check( @@ -682,11 +796,11 @@ describe('required server files i18n', () => { } ) ), - { + withInvocationId({ headers: { 'x-matched-path': '/optional-ssp/[[...rest]]', }, - } + }) ) const html = await res.text() @@ -701,7 +815,7 @@ describe('required server files i18n', () => { appPort, '/en/optional-ssg/[[...rest]]', undefined, - { + withInvocationId({ headers: { 'x-matched-path': '/en/optional-ssg/[[...rest]]', 'x-now-route-matches': createNowRouteMatches( @@ -711,7 +825,7 @@ describe('required server files i18n', () => { } ).toString(), }, - } + }) ) const html = await res.text() @@ -725,7 +839,7 @@ describe('required server files i18n', () => { appPort, '/en/[slug]/social/[[...rest]]', undefined, - { + withInvocationId({ headers: { 'x-matched-path': '/en/[slug]/social/[[...rest]]', 'x-now-route-matches': createNowRouteMatches( @@ -733,7 +847,7 @@ describe('required server files i18n', () => { { nextLocale: 'en' } ).toString(), }, - } + }) ) const html = await res.text() @@ -750,12 +864,12 @@ describe('required server files i18n', () => { appPort, '/optional-ssg/[[...rest]]', undefined, - { + withInvocationId({ headers: { 'x-matched-path': '/optional-ssg/[[...rest]]', 'x-now-route-matches': 'nxtPrest=en%2Fes%2Fhello%252Fworld', }, - } + }) ) const html = await res.text() @@ -780,11 +894,11 @@ describe('required server files i18n', () => { } ) ), - { + withInvocationId({ headers: { 'x-matched-path': '/api/optional/[[...rest]]', }, - } + }) ) const json = await res.json() @@ -793,12 +907,17 @@ describe('required server files i18n', () => { }) it('should match the index page correctly', async () => { - const res = await fetchViaHTTP(appPort, '/', undefined, { - headers: { - 'x-matched-path': '/index', - }, - redirect: 'manual', - }) + const res = await fetchViaHTTP( + appPort, + '/', + undefined, + withInvocationId({ + headers: { + 'x-matched-path': '/index', + }, + redirect: 'manual', + }) + ) const html = await res.text() const $ = cheerio.load(html) @@ -806,12 +925,17 @@ describe('required server files i18n', () => { }) it('should match the root dyanmic page correctly', async () => { - const res = await fetchViaHTTP(appPort, '/slug-1', undefined, { - headers: { - 'x-matched-path': '/[slug]', - }, - redirect: 'manual', - }) + const res = await fetchViaHTTP( + appPort, + '/slug-1', + undefined, + withInvocationId({ + headers: { + 'x-matched-path': '/[slug]', + }, + redirect: 'manual', + }) + ) const html = await res.text() const $ = cheerio.load(html) @@ -819,20 +943,25 @@ describe('required server files i18n', () => { }) it('should have the correct asPath for fallback page', async () => { - const res = await fetchViaHTTP(appPort, '/en/fallback/[slug]', undefined, { - headers: { - 'x-matched-path': '/en/fallback/[slug]', - 'x-now-route-matches': createNowRouteMatches( - { - slug: 'another', - }, - { - nextLocale: 'en', - } - ).toString(), - }, - redirect: 'manual', - }) + const res = await fetchViaHTTP( + appPort, + '/en/fallback/[slug]', + undefined, + withInvocationId({ + headers: { + 'x-matched-path': '/en/fallback/[slug]', + 'x-now-route-matches': createNowRouteMatches( + { + slug: 'another', + }, + { + nextLocale: 'en', + } + ).toString(), + }, + redirect: 'manual', + }) + ) const html = await res.text() const $ = cheerio.load(html) @@ -844,20 +973,25 @@ describe('required server files i18n', () => { }) it('should have the correct asPath for fallback page locale', async () => { - const res = await fetchViaHTTP(appPort, '/fr/fallback/[slug]', undefined, { - headers: { - 'x-matched-path': '/fr/fallback/[slug]', - 'x-now-route-matches': createNowRouteMatches( - { - slug: 'another', - }, - { - nextLocale: 'fr', - } - ).toString(), - }, - redirect: 'manual', - }) + const res = await fetchViaHTTP( + appPort, + '/fr/fallback/[slug]', + undefined, + withInvocationId({ + headers: { + 'x-matched-path': '/fr/fallback/[slug]', + 'x-now-route-matches': createNowRouteMatches( + { + slug: 'another', + }, + { + nextLocale: 'fr', + } + ).toString(), + }, + redirect: 'manual', + }) + ) const html = await res.text() const $ = cheerio.load(html) diff --git a/test/production/standalone-mode/required-server-files/required-server-files-ppr.test.ts b/test/production/standalone-mode/required-server-files/required-server-files-ppr.test.ts index 3329c96d7a4a7..5cbc455a2decf 100644 --- a/test/production/standalone-mode/required-server-files/required-server-files-ppr.test.ts +++ b/test/production/standalone-mode/required-server-files/required-server-files-ppr.test.ts @@ -10,6 +10,7 @@ import { initNextServerScript, killApp, retry, + withInvocationId, } from 'next-test-utils' import { ChildProcess } from 'node:child_process' @@ -144,14 +145,19 @@ describe.skip('required server files app router', () => { require('console').error('requesting', outputSegmentPath) - const res = await fetchViaHTTP(appPort, outputSegmentPath, undefined, { - headers: { - 'x-matched-path': '/isr/[slug].segments/_tree.segment.rsc', - 'x-now-route-matches': createNowRouteMatches({ - slug: 'first', - }).toString(), - }, - }) + const res = await fetchViaHTTP( + appPort, + outputSegmentPath, + undefined, + withInvocationId({ + headers: { + 'x-matched-path': '/isr/[slug].segments/_tree.segment.rsc', + 'x-now-route-matches': createNowRouteMatches({ + slug: 'first', + }).toString(), + }, + }) + ) expect(res.status).toBe(200) expect(res.headers.get('content-type')).toBe('text/x-component') @@ -167,14 +173,19 @@ describe.skip('required server files app router', () => { }) it('should properly stream resume with Next-Resume', async () => { - const res = await fetchViaHTTP(appPort, '/delayed', undefined, { - headers: { - 'x-matched-path': '/delayed', - 'next-resume': '1', - }, - method: 'POST', - body: delayedPostpone, - }) + const res = await fetchViaHTTP( + appPort, + '/delayed', + undefined, + withInvocationId({ + headers: { + 'x-matched-path': '/delayed', + 'next-resume': '1', + }, + method: 'POST', + body: delayedPostpone, + }) + ) expect(res.status).toBe(200) @@ -199,13 +210,18 @@ describe.skip('required server files app router', () => { }) it('should properly handle prerender for bot request', async () => { - const res = await fetchViaHTTP(appPort, '/isr/first', undefined, { - headers: { - 'user-agent': - 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.179 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)', - 'x-matched-path': '/isr/first', - }, - }) + const res = await fetchViaHTTP( + appPort, + '/isr/first', + undefined, + withInvocationId({ + headers: { + 'user-agent': + 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.179 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)', + 'x-matched-path': '/isr/first', + }, + }) + ) expect(res.status).toBe(200) const html = await res.text() @@ -213,28 +229,38 @@ describe.skip('required server files app router', () => { expect($('#page').text()).toBe('/isr/[slug]') - const rscRes = await fetchViaHTTP(appPort, '/isr/first.rsc', undefined, { - headers: { - 'user-agent': - 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.179 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)', - 'x-matched-path': '/isr/first', - }, - }) + const rscRes = await fetchViaHTTP( + appPort, + '/isr/first.rsc', + undefined, + withInvocationId({ + headers: { + 'user-agent': + 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.179 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)', + 'x-matched-path': '/isr/first', + }, + }) + ) expect(rscRes.status).toBe(200) }) it('should properly handle fallback for bot request', async () => { - const res = await fetchViaHTTP(appPort, '/isr/[slug]', undefined, { - headers: { - 'user-agent': - 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.179 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)', - 'x-now-route-matches': createNowRouteMatches({ - slug: 'new', - }).toString(), - 'x-matched-path': '/isr/[slug]', - }, - }) + const res = await fetchViaHTTP( + appPort, + '/isr/[slug]', + undefined, + withInvocationId({ + headers: { + 'user-agent': + 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.179 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)', + 'x-now-route-matches': createNowRouteMatches({ + slug: 'new', + }).toString(), + 'x-matched-path': '/isr/[slug]', + }, + }) + ) expect(res.status).toBe(200) const html = await res.text() @@ -242,16 +268,21 @@ describe.skip('required server files app router', () => { expect($('#page').text()).toBe('/isr/[slug]') - const rscRes = await fetchViaHTTP(appPort, '/isr/[slug].rsc', undefined, { - headers: { - 'user-agent': - 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.179 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)', - 'x-now-route-matches': createNowRouteMatches({ - slug: 'new', - }).toString(), - 'x-matched-path': '/isr/[slug]', - }, - }) + const rscRes = await fetchViaHTTP( + appPort, + '/isr/[slug].rsc', + undefined, + withInvocationId({ + headers: { + 'user-agent': + 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.179 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)', + 'x-now-route-matches': createNowRouteMatches({ + slug: 'new', + }).toString(), + 'x-matched-path': '/isr/[slug]', + }, + }) + ) expect(rscRes.status).toBe(200) }) @@ -267,7 +298,7 @@ describe.skip('required server files app router', () => { // route backed by PPR. `/_next/data/${next.buildId}/index.json`, undefined, - { + withInvocationId({ method: 'POST', headers: { 'x-matched-path': '/[...catchAll]', @@ -277,7 +308,7 @@ describe.skip('required server files app router', () => { 'next-resume': '1', }, body: postponed, - } + }) ) // Expect that the status code is 422, we asked for a /_next/data route and @@ -301,14 +332,14 @@ describe.skip('required server files app router', () => { appPort, '/rewrite-with-cookie', undefined, - { + withInvocationId({ method: 'POST', headers: { 'x-matched-path': '/rewrite/first-cookie', 'next-resume': '1', }, body: rewritePostpone, - } + }) ) expect(res.status).toBe(200) @@ -327,15 +358,20 @@ describe.skip('required server files app router', () => { it('should still render when postponed is corrupted with Next-Resume', async () => { const random = Math.random().toString(36).substring(2) - const res = await fetchViaHTTP(appPort, '/dyn/' + random, undefined, { - method: 'POST', - headers: { - 'x-matched-path': '/dyn/[slug]', - 'next-resume': '1', - }, - // This is a corrupted postponed JSON payload. - body: '{', - }) + const res = await fetchViaHTTP( + appPort, + '/dyn/' + random, + undefined, + withInvocationId({ + method: 'POST', + headers: { + 'x-matched-path': '/dyn/[slug]', + 'next-resume': '1', + }, + // This is a corrupted postponed JSON payload. + body: '{', + }) + ) expect(res.status).toBe(200) @@ -369,9 +405,14 @@ describe.skip('required server files app router', () => { ], ]) { require('console').error('checking', { path, tags }) - const res = await fetchViaHTTP(appPort, path, undefined, { - redirect: 'manual', - }) + const res = await fetchViaHTTP( + appPort, + path, + undefined, + withInvocationId({ + redirect: 'manual', + }) + ) expect(res.status).toBe(200) expect(res.headers.get('x-next-cache-tags')).toBe(tags) @@ -390,9 +431,14 @@ describe.skip('required server files app router', () => { '/api/ssr/first', '/api/ssr/second', ]) { - const res = await fetchViaHTTP(appPort, path, undefined, { - redirect: 'manual', - }) + const res = await fetchViaHTTP( + appPort, + path, + undefined, + withInvocationId({ + redirect: 'manual', + }) + ) expect(res.status).toBe(200) expect(res.headers.get('x-next-cache-tags')).toBeFalsy() @@ -416,9 +462,9 @@ describe.skip('required server files app router', () => { appPort, path, { hello: 'world' }, - { + withInvocationId({ redirect: 'manual', - } + }) ) expect(res.status).toBe(200) @@ -433,14 +479,19 @@ describe.skip('required server files app router', () => { }) it('should handle RSC requests', async () => { - const res = await fetchViaHTTP(appPort, '/dyn/first.rsc', undefined, { - headers: { - 'x-matched-path': '/dyn/[slug]', - 'x-now-route-matches': createNowRouteMatches({ - slug: 'first', - }).toString(), - }, - }) + const res = await fetchViaHTTP( + appPort, + '/dyn/first.rsc', + undefined, + withInvocationId({ + headers: { + 'x-matched-path': '/dyn/[slug]', + 'x-now-route-matches': createNowRouteMatches({ + slug: 'first', + }).toString(), + }, + }) + ) expect(res.status).toBe(200) expect(res.headers.get('content-type')).toEqual('text/x-component') @@ -466,14 +517,14 @@ describe.skip('required server files app router', () => { appPort, '/rewrite/second-cookie.rsc', undefined, - { + withInvocationId({ headers: { 'x-matched-path': '/rewrite/[slug]', 'x-now-route-matches': createNowRouteMatches({ slug: 'second-cookie', }).toString(), }, - } + }) ) expect(res.status).toBe(200) @@ -508,17 +559,22 @@ describe.skip('required server files app router', () => { // Then let's get the Dynamic RSC request and verify that the random value // is present in the response by passing the postponed state. - res = await fetchViaHTTP(appPort, '/rewrite/second-cookie.rsc', undefined, { - method: 'POST', - headers: { - 'x-matched-path': '/rewrite/[slug]', - 'x-now-route-matches': createNowRouteMatches({ - slug: 'second-cookie', - }).toString(), - 'next-resume': '1', - }, - body: secondCookiePostpone, - }) + res = await fetchViaHTTP( + appPort, + '/rewrite/second-cookie.rsc', + undefined, + withInvocationId({ + method: 'POST', + headers: { + 'x-matched-path': '/rewrite/[slug]', + 'x-now-route-matches': createNowRouteMatches({ + slug: 'second-cookie', + }).toString(), + 'next-resume': '1', + }, + body: secondCookiePostpone, + }) + ) expect(res.status).toBe(200) expect(res.headers.get('content-type')).toEqual('text/x-component') @@ -551,14 +607,19 @@ describe.skip('required server files app router', () => { }) it('should handle revalidating the fallback page', async () => { - const res = await fetchViaHTTP(appPort, '/postpone/isr/[slug]', undefined, { - headers: { - 'x-matched-path': '/postpone/isr/[slug]', - // We don't include the `x-now-route-matches` header because we want to - // test that the fallback route params are correctly set. - 'x-now-route-matches': '', - }, - }) + const res = await fetchViaHTTP( + appPort, + '/postpone/isr/[slug]', + undefined, + withInvocationId({ + headers: { + 'x-matched-path': '/postpone/isr/[slug]', + // We don't include the `x-now-route-matches` header because we want to + // test that the fallback route params are correctly set. + 'x-now-route-matches': '', + }, + }) + ) expect(res.status).toBe(200) diff --git a/test/production/standalone-mode/required-server-files/required-server-files.test.ts b/test/production/standalone-mode/required-server-files/required-server-files.test.ts index 47f60363ebbcd..7ced25b418ff1 100644 --- a/test/production/standalone-mode/required-server-files/required-server-files.test.ts +++ b/test/production/standalone-mode/required-server-files/required-server-files.test.ts @@ -15,6 +15,7 @@ import { renderViaHTTP, retry, waitFor, + withInvocationId, } from 'next-test-utils' import { ChildProcess } from 'child_process' @@ -177,12 +178,12 @@ describe('required server files', () => { appPort, '/route-resolving/import/first', undefined, - { + withInvocationId({ redirect: 'manual', headers: { 'x-matched-path': '/route-resolving/import/[slug]', }, - } + }) ) expect(res.status).toBe(307) expect(new URL(res.headers.get('location'), 'http://n').pathname).toBe( @@ -201,11 +202,16 @@ describe('required server files', () => { await next.renameFile(toRename, `${toRename}.bak`) try { - const res = await fetchViaHTTP(appPort, '/auto-static', undefined, { - headers: { - 'x-matched-path': '/auto-static', - }, - }) + const res = await fetchViaHTTP( + appPort, + '/auto-static', + undefined, + withInvocationId({ + headers: { + 'x-matched-path': '/auto-static', + }, + }) + ) expect(res.status).toBe(500) await check(() => stderr, /Invariant: failed to load static page/) @@ -230,9 +236,14 @@ describe('required server files', () => { ])( `should have correct cache-control for $case`, async ({ path, dest, cacheControl }) => { - const res = await fetchViaHTTP(appPort, path, undefined, { - redirect: 'manual', - }) + const res = await fetchViaHTTP( + appPort, + path, + undefined, + withInvocationId({ + redirect: 'manual', + }) + ) expect(res.status).toBe(307) expect(new URL(res.headers.get('location'), 'http://n').pathname).toBe( dest @@ -243,9 +254,9 @@ describe('required server files', () => { appPort, `/_next/data/${next.buildId}${path}.json`, undefined, - { + withInvocationId({ redirect: 'manual', - } + }) ) expect((await dataRes.json()).pageProps).toEqual({ __N_REDIRECT: dest, @@ -260,11 +271,11 @@ describe('required server files', () => { appPort, `/_next/data/${next.buildId}/catch-all.json`, {}, - { + withInvocationId({ headers: { 'x-matched-path': `/_next/data/${next.buildId}/catch-all.json`, }, - } + }) ) expect(res.status).toBe(200) @@ -277,11 +288,11 @@ describe('required server files', () => { appPort, `/_next/data/${next.buildId}/catch-all/next.js.json`, {}, - { + withInvocationId({ headers: { 'x-matched-path': `/_next/data/${next.buildId}/catch-all/next.js.json`, }, - } + }) ) expect(res.status).toBe(200) @@ -307,9 +318,14 @@ describe('required server files', () => { ])( `should have correct cache-control for $case`, async ({ path, dest, cacheControl }) => { - const res = await fetchViaHTTP(appPort, path, undefined, { - redirect: 'manual', - }) + const res = await fetchViaHTTP( + appPort, + path, + undefined, + withInvocationId({ + redirect: 'manual', + }) + ) expect(res.status).toBe(404) expect(res.headers.get('cache-control')).toBe(cacheControl) @@ -317,16 +333,21 @@ describe('required server files', () => { appPort, `/_next/data/${next.buildId}${path}.json`, undefined, - { + withInvocationId({ redirect: 'manual', - } + }) ) expect(dataRes.headers.get('cache-control')).toBe(cacheControl) } ) it('should have the correct cache-control for props with no revalidate', async () => { - const res = await fetchViaHTTP(appPort, '/optional-ssg/props-no-revalidate') + const res = await fetchViaHTTP( + appPort, + '/optional-ssg/props-no-revalidate', + undefined, + withInvocationId() + ) expect(res.status).toBe(200) expect(res.headers.get('cache-control')).toBe('s-maxage=31536000') const $ = cheerio.load(await res.text()) @@ -337,7 +358,8 @@ describe('required server files', () => { const dataRes = await fetchViaHTTP( appPort, `/_next/data/${next.buildId}/optional-ssg/props-no-revalidate.json`, - undefined + undefined, + withInvocationId() ) expect(dataRes.status).toBe(200) expect(res.headers.get('cache-control')).toBe('s-maxage=31536000') @@ -350,7 +372,7 @@ describe('required server files', () => { ;(process.env.IS_TURBOPACK_TEST ? it.skip : it)( 'should warn when "next" is imported directly', async () => { - await renderViaHTTP(appPort, '/gssp') + await renderViaHTTP(appPort, '/gssp', undefined, withInvocationId()) await check( () => stderr, /"next" should not be imported directly, imported in/ @@ -430,9 +452,14 @@ describe('required server files', () => { }) it('should de-dupe HTML/data requests', async () => { + // Create a shared invocation ID for /gsp - both HTML and JSON requests share same x-invocation-id + const gspOpts = withInvocationId() + const res = await fetchViaHTTP(appPort, '/gsp', undefined, { + ...gspOpts, redirect: 'manual', headers: { + ...gspOpts.headers, // ensure the nextjs-data header being present // doesn't incorrectly return JSON for HTML path // during prerendering @@ -450,6 +477,7 @@ describe('required server files', () => { `/_next/data/${next.buildId}/gsp.json`, undefined, { + ...gspOpts, redirect: 'manual', } ) @@ -458,9 +486,14 @@ describe('required server files', () => { const { pageProps: props2 } = await res2.json() expect(props2.gspCalls).toBe(props.gspCalls) + // Create a separate shared invocation ID for /index - different x-invocation-id + const indexOpts = withInvocationId() + const res3 = await fetchViaHTTP(appPort, '/index', undefined, { + ...indexOpts, redirect: 'manual', headers: { + ...indexOpts.headers, 'x-matched-path': '/index', }, }) @@ -474,6 +507,7 @@ describe('required server files', () => { `/_next/data/${next.buildId}/index.json`, undefined, { + ...indexOpts, redirect: 'manual', } ) @@ -482,30 +516,6 @@ describe('required server files', () => { expect(props4.gspCalls).toBe(props3.gspCalls) }) - it('should cap de-dupe previousCacheItem expires time', async () => { - const res = await fetchViaHTTP(appPort, '/gsp-long-revalidate', undefined, { - redirect: 'manual', - }) - expect(res.status).toBe(200) - const $ = cheerio.load(await res.text()) - const props = JSON.parse($('#props').text()) - expect(props.gspCalls).toBeDefined() - - await waitFor(1000) - - const res2 = await fetchViaHTTP( - appPort, - `/_next/data/${next.buildId}/gsp-long-revalidate.json`, - undefined, - { - redirect: 'manual', - } - ) - expect(res2.status).toBe(200) - const { pageProps: props2 } = await res2.json() - expect(props2.gspCalls).not.toBe(props.gspCalls) - }) - it('should not 404 for onlyGenerated on-demand revalidate in minimal mode', async () => { const previewProps = JSON.parse( await next.readFile('standalone/.next/prerender-manifest.json') @@ -515,12 +525,12 @@ describe('required server files', () => { appPort, '/optional-ssg/only-generated-1', undefined, - { + withInvocationId({ headers: { 'x-prerender-revalidate': previewProps.previewModeId, 'x-prerender-revalidate-if-generated': '1', }, - } + }) ) expect(res.status).toBe(200) }) @@ -529,9 +539,14 @@ describe('required server files', () => { await waitFor(2000) await next.patchFile('standalone/data.txt', 'show') - const res = await fetchViaHTTP(appPort, '/gsp', undefined, { - redirect: 'manual', - }) + const res = await fetchViaHTTP( + appPort, + '/gsp', + undefined, + withInvocationId({ + redirect: 'manual', + }) + ) expect(res.status).toBe(200) expect(res.headers.get('cache-control')).toBe( 's-maxage=1, stale-while-revalidate=31535999' @@ -540,9 +555,14 @@ describe('required server files', () => { await waitFor(2000) await next.patchFile('standalone/data.txt', 'hide') - const res2 = await fetchViaHTTP(appPort, '/gsp', undefined, { - redirect: 'manual', - }) + const res2 = await fetchViaHTTP( + appPort, + '/gsp', + undefined, + withInvocationId({ + redirect: 'manual', + }) + ) expect(res2.status).toBe(404) expect(res2.headers.get('cache-control')).toBe( 's-maxage=1, stale-while-revalidate=31535999' @@ -552,9 +572,14 @@ describe('required server files', () => { it('should set correct SWR headers with notFound gssp', async () => { await next.patchFile('standalone/data.txt', 'show') - const res = await fetchViaHTTP(appPort, '/gssp', undefined, { - redirect: 'manual', - }) + const res = await fetchViaHTTP( + appPort, + '/gssp', + undefined, + withInvocationId({ + redirect: 'manual', + }) + ) expect(res.status).toBe(200) expect(res.headers.get('cache-control')).toBe( 's-maxage=1, stale-while-revalidate=31535999' @@ -562,9 +587,14 @@ describe('required server files', () => { await next.patchFile('standalone/data.txt', 'hide') - const res2 = await fetchViaHTTP(appPort, '/gssp', undefined, { - redirect: 'manual', - }) + const res2 = await fetchViaHTTP( + appPort, + '/gssp', + undefined, + withInvocationId({ + redirect: 'manual', + }) + ) await next.patchFile('standalone/data.txt', 'show') expect(res2.status).toBe(404) @@ -574,14 +604,24 @@ describe('required server files', () => { }) it('should render SSR page correctly', async () => { - const html = await renderViaHTTP(appPort, '/gssp') + const html = await renderViaHTTP( + appPort, + '/gssp', + undefined, + withInvocationId() + ) const $ = cheerio.load(html) const data = JSON.parse($('#props').text()) expect($('#gssp').text()).toBe('getServerSideProps page') expect(data.hello).toBe('world') - const html2 = await renderViaHTTP(appPort, '/gssp') + const html2 = await renderViaHTTP( + appPort, + '/gssp', + undefined, + withInvocationId() + ) const $2 = cheerio.load(html2) const data2 = JSON.parse($2('#props').text()) @@ -591,7 +631,12 @@ describe('required server files', () => { }) it('should render dynamic SSR page correctly', async () => { - const html = await renderViaHTTP(appPort, '/dynamic/first') + const html = await renderViaHTTP( + appPort, + '/dynamic/first', + undefined, + withInvocationId() + ) const $ = cheerio.load(html) const data = JSON.parse($('#props').text()) @@ -599,7 +644,12 @@ describe('required server files', () => { expect($('#slug').text()).toBe('first') expect(data.hello).toBe('world') - const html2 = await renderViaHTTP(appPort, '/dynamic/second') + const html2 = await renderViaHTTP( + appPort, + '/dynamic/second', + undefined, + withInvocationId() + ) const $2 = cheerio.load(html2) const data2 = JSON.parse($2('#props').text()) @@ -610,7 +660,12 @@ describe('required server files', () => { }) it('should render fallback page correctly', async () => { - const html = await renderViaHTTP(appPort, '/fallback/first') + const html = await renderViaHTTP( + appPort, + '/fallback/first', + undefined, + withInvocationId() + ) const $ = cheerio.load(html) const data = JSON.parse($('#props').text()) @@ -619,7 +674,13 @@ describe('required server files', () => { expect(data.hello).toBe('world') await waitFor(2000) - const html2 = await renderViaHTTP(appPort, '/fallback/first') + + const html2 = await renderViaHTTP( + appPort, + '/fallback/first', + undefined, + withInvocationId() + ) const $2 = cheerio.load(html2) const data2 = JSON.parse($2('#props').text()) @@ -628,7 +689,12 @@ describe('required server files', () => { expect(isNaN(data2.random)).toBe(false) expect(data2.random).not.toBe(data.random) - const html3 = await renderViaHTTP(appPort, '/fallback/second') + const html3 = await renderViaHTTP( + appPort, + '/fallback/second', + undefined, + withInvocationId() + ) const $3 = cheerio.load(html3) const data3 = JSON.parse($3('#props').text()) @@ -639,7 +705,9 @@ describe('required server files', () => { const { pageProps: data4 } = JSON.parse( await renderViaHTTP( appPort, - `/_next/data/${next.buildId}/fallback/third.json` + `/_next/data/${next.buildId}/fallback/third.json`, + undefined, + withInvocationId() ) ) expect(data4.hello).toBe('world') @@ -647,22 +715,32 @@ describe('required server files', () => { }) it('should render SSR page correctly with x-matched-path', async () => { - const html = await renderViaHTTP(appPort, '/some-other-path', undefined, { - headers: { - 'x-matched-path': '/gssp', - }, - }) + const html = await renderViaHTTP( + appPort, + '/some-other-path', + undefined, + withInvocationId({ + headers: { + 'x-matched-path': '/gssp', + }, + }) + ) const $ = cheerio.load(html) const data = JSON.parse($('#props').text()) expect($('#gssp').text()).toBe('getServerSideProps page') expect(data.hello).toBe('world') - const html2 = await renderViaHTTP(appPort, '/some-other-path', undefined, { - headers: { - 'x-matched-path': '/gssp', - }, - }) + const html2 = await renderViaHTTP( + appPort, + '/some-other-path', + undefined, + withInvocationId({ + headers: { + 'x-matched-path': '/gssp', + }, + }) + ) const $2 = cheerio.load(html2) const data2 = JSON.parse($2('#props').text()) @@ -676,11 +754,11 @@ describe('required server files', () => { appPort, '/some-other-path?nxtPslug=first', undefined, - { + withInvocationId({ headers: { 'x-matched-path': '/dynamic/[slug]', }, - } + }) ) const $ = cheerio.load(html) const data = JSON.parse($('#props').text()) @@ -693,11 +771,11 @@ describe('required server files', () => { appPort, '/some-other-path?nxtPslug=second', undefined, - { + withInvocationId({ headers: { 'x-matched-path': '/dynamic/[slug]', }, - } + }) ) const $2 = cheerio.load(html2) const data2 = JSON.parse($2('#props').text()) @@ -711,11 +789,11 @@ describe('required server files', () => { appPort, '/some-other-path?nxtPslug=second', undefined, - { + withInvocationId({ headers: { 'x-matched-path': '/dynamic/[slug]', }, - } + }) ) const $3 = cheerio.load(html3) const data3 = JSON.parse($3('#props').text()) @@ -727,14 +805,19 @@ describe('required server files', () => { }) it('should render fallback page correctly with x-matched-path and routes-matches', async () => { - const html = await renderViaHTTP(appPort, '/fallback/first', undefined, { - headers: { - 'x-matched-path': '/fallback/first', - 'x-now-route-matches': createNowRouteMatches({ - slug: 'first', - }).toString(), - }, - }) + const html = await renderViaHTTP( + appPort, + '/fallback/first', + undefined, + withInvocationId({ + headers: { + 'x-matched-path': '/fallback/first', + 'x-now-route-matches': createNowRouteMatches({ + slug: 'first', + }).toString(), + }, + }) + ) const $ = cheerio.load(html) const data = JSON.parse($('#props').text()) @@ -742,14 +825,19 @@ describe('required server files', () => { expect($('#slug').text()).toBe('first') expect(data.hello).toBe('world') - const html2 = await renderViaHTTP(appPort, `/fallback/[slug]`, undefined, { - headers: { - 'x-matched-path': '/fallback/[slug]', - 'x-now-route-matches': createNowRouteMatches({ - slug: 'second', - }).toString(), - }, - }) + const html2 = await renderViaHTTP( + appPort, + `/fallback/[slug]`, + undefined, + withInvocationId({ + headers: { + 'x-matched-path': '/fallback/[slug]', + 'x-now-route-matches': createNowRouteMatches({ + slug: 'second', + }).toString(), + }, + }) + ) const $2 = cheerio.load(html2) const data2 = JSON.parse($2('#props').text()) @@ -760,14 +848,19 @@ describe('required server files', () => { }) it('should favor valid route params over routes-matches', async () => { - const html = await renderViaHTTP(appPort, '/fallback/first', undefined, { - headers: { - 'x-matched-path': '/fallback/first', - 'x-now-route-matches': createNowRouteMatches({ - slug: 'fallback/first', - }).toString(), - }, - }) + const html = await renderViaHTTP( + appPort, + '/fallback/first', + undefined, + withInvocationId({ + headers: { + 'x-matched-path': '/fallback/first', + 'x-now-route-matches': createNowRouteMatches({ + slug: 'fallback/first', + }).toString(), + }, + }) + ) const $ = cheerio.load(html) const data = JSON.parse($('#props').text()) @@ -775,14 +868,19 @@ describe('required server files', () => { expect($('#slug').text()).toBe('first') expect(data.hello).toBe('world') - const html2 = await renderViaHTTP(appPort, `/fallback/second`, undefined, { - headers: { - 'x-matched-path': '/fallback/[slug]', - 'x-now-route-matches': createNowRouteMatches({ - slug: 'fallback/second', - }).toString(), - }, - }) + const html2 = await renderViaHTTP( + appPort, + `/fallback/second`, + undefined, + withInvocationId({ + headers: { + 'x-matched-path': '/fallback/[slug]', + 'x-now-route-matches': createNowRouteMatches({ + slug: 'fallback/second', + }).toString(), + }, + }) + ) const $2 = cheerio.load(html2) const data2 = JSON.parse($2('#props').text()) @@ -793,24 +891,34 @@ describe('required server files', () => { }) it('should favor valid route params over routes-matches optional', async () => { - const html = await renderViaHTTP(appPort, '/optional-ssg', undefined, { - headers: { - 'x-matched-path': '/optional-ssg', - 'x-now-route-matches': '', - }, - }) + const html = await renderViaHTTP( + appPort, + '/optional-ssg', + undefined, + withInvocationId({ + headers: { + 'x-matched-path': '/optional-ssg', + 'x-now-route-matches': '', + }, + }) + ) const $ = cheerio.load(html) const data = JSON.parse($('#props').text()) expect(data.params).toEqual({}) - const html2 = await renderViaHTTP(appPort, `/optional-ssg`, undefined, { - headers: { - 'x-matched-path': '/optional-ssg', - 'x-now-route-matches': createNowRouteMatches({ - slug: 'another', - }).toString(), - }, - }) + const html2 = await renderViaHTTP( + appPort, + `/optional-ssg`, + undefined, + withInvocationId({ + headers: { + 'x-matched-path': '/optional-ssg', + 'x-now-route-matches': createNowRouteMatches({ + slug: 'another', + }).toString(), + }, + }) + ) const $2 = cheerio.load(html2) const data2 = JSON.parse($2('#props').text()) @@ -825,11 +933,11 @@ describe('required server files', () => { slug: 'first', }).toString()}`, undefined, - { + withInvocationId({ headers: { 'x-matched-path': `/dynamic/[slug]`, }, - } + }) ) const { pageProps: data } = await res.json() @@ -841,14 +949,14 @@ describe('required server files', () => { appPort, `/_next/data/${next.buildId}/fallback/[slug].json`, undefined, - { + withInvocationId({ headers: { 'x-matched-path': `/_next/data/${next.buildId}/fallback/[slug].json`, 'x-now-route-matches': createNowRouteMatches({ slug: 'second', }).toString(), }, - } + }) ) const { pageProps: data2 } = await res2.json() @@ -862,12 +970,12 @@ describe('required server files', () => { appPort, '/catch-all/[[...rest]]', undefined, - { + withInvocationId({ headers: { 'x-matched-path': '/catch-all/[[...rest]]', 'x-now-route-matches': '', }, - } + }) ) const $ = cheerio.load(html) const data = JSON.parse($('#props').text()) @@ -880,14 +988,14 @@ describe('required server files', () => { appPort, '/catch-all/[[...rest]]', undefined, - { + withInvocationId({ headers: { 'x-matched-path': '/catch-all/[[...rest]]', 'x-now-route-matches': createNowRouteMatches({ rest: 'hello', }).toString(), }, - } + }) ) const $2 = cheerio.load(html2) const data2 = JSON.parse($2('#props').text()) @@ -901,14 +1009,14 @@ describe('required server files', () => { appPort, '/catch-all/[[...rest]]', undefined, - { + withInvocationId({ headers: { 'x-matched-path': '/catch-all/[[...rest]]', 'x-now-route-matches': createNowRouteMatches({ rest: 'hello/world', }).toString(), }, - } + }) ) const $3 = cheerio.load(html3) const data3 = JSON.parse($3('#props').text()) @@ -925,11 +1033,11 @@ describe('required server files', () => { `/_next/data/${next.buildId}/catch-all.json`, undefined, - { + withInvocationId({ headers: { 'x-matched-path': '/catch-all/[[...rest]]', }, - } + }) ) const { pageProps: data } = await res.json() @@ -941,14 +1049,14 @@ describe('required server files', () => { appPort, `/_next/data/${next.buildId}/catch-all/[[...rest]].json`, undefined, - { + withInvocationId({ headers: { 'x-matched-path': `/_next/data/${next.buildId}/catch-all/[[...rest]].json`, 'x-now-route-matches': createNowRouteMatches({ rest: 'hello', }).toString(), }, - } + }) ) const { pageProps: data2 } = await res2.json() @@ -960,14 +1068,14 @@ describe('required server files', () => { appPort, `/_next/data/${next.buildId}/catch-all/[[...rest]].json`, undefined, - { + withInvocationId({ headers: { 'x-matched-path': `/_next/data/${next.buildId}/catch-all/[[...rest]].json`, 'x-now-route-matches': createNowRouteMatches({ rest: 'hello/world', }).toString(), }, - } + }) ) const { pageProps: data3 } = await res3.json() @@ -986,9 +1094,14 @@ describe('required server files', () => { '/fallback/another/', '/fallback/another', ]) { - const res = await fetchViaHTTP(appPort, path, undefined, { - redirect: 'manual', - }) + const res = await fetchViaHTTP( + appPort, + path, + undefined, + withInvocationId({ + redirect: 'manual', + }) + ) expect(res.status).toBe(200) } @@ -1001,11 +1114,11 @@ describe('required server files', () => { { path: 'hello/world', }, - { + withInvocationId({ headers: { 'x-matched-path': '/gssp', }, - } + }) ) const $ = cheerio.load(html) expect(JSON.parse($('#router').text()).query).toEqual({ @@ -1020,33 +1133,43 @@ describe('required server files', () => { { path: '%c0.%c0.', }, - { + withInvocationId({ headers: { 'x-matched-path': '/dynamic/[slug]', }, - } + }) ) expect(res.status).toBe(400) expect(await res.text()).toContain('Bad Request') }) it('should have correct resolvedUrl from rewrite', async () => { - const res = await fetchViaHTTP(appPort, '/to-dynamic/post-1', undefined, { - headers: { - 'x-matched-path': '/dynamic/[slug]', - }, - }) + const res = await fetchViaHTTP( + appPort, + '/to-dynamic/post-1', + undefined, + withInvocationId({ + headers: { + 'x-matched-path': '/dynamic/[slug]', + }, + }) + ) expect(res.status).toBe(200) const $ = cheerio.load(await res.text()) expect($('#resolved-url').text()).toBe('/dynamic/post-1') }) it('should have correct resolvedUrl from rewrite with added query', async () => { - const res = await fetchViaHTTP(appPort, '/to-dynamic/post-2', undefined, { - headers: { - 'x-matched-path': '/dynamic/[slug]', - }, - }) + const res = await fetchViaHTTP( + appPort, + '/to-dynamic/post-2', + undefined, + withInvocationId({ + headers: { + 'x-matched-path': '/dynamic/[slug]', + }, + }) + ) expect(res.status).toBe(200) const $ = cheerio.load(await res.text()) expect($('#resolved-url').text()).toBe('/dynamic/post-2') @@ -1058,11 +1181,11 @@ describe('required server files', () => { appPort, `/_next/data/${next.buildId}/dynamic/post-2.json`, { slug: 'post-2' }, - { + withInvocationId({ headers: { 'x-matched-path': '/dynamic/[slug]', }, - } + }) ) expect(res.status).toBe(200) const json = await res.json() @@ -1070,7 +1193,12 @@ describe('required server files', () => { }) it('should bubble error correctly for gip page', async () => { - const res = await fetchViaHTTP(appPort, '/errors/gip', { crash: '1' }) + const res = await fetchViaHTTP( + appPort, + '/errors/gip', + { crash: '1' }, + withInvocationId() + ) expect(res.status).toBe(500) expect(await res.text()).toBe('Internal Server Error') @@ -1080,7 +1208,12 @@ describe('required server files', () => { }) it('should bubble error correctly for gssp page', async () => { - const res = await fetchViaHTTP(appPort, '/errors/gssp', { crash: '1' }) + const res = await fetchViaHTTP( + appPort, + '/errors/gssp', + { crash: '1' }, + withInvocationId() + ) expect(res.status).toBe(500) expect(await res.text()).toBe('Internal Server Error') @@ -1090,7 +1223,12 @@ describe('required server files', () => { }) it('should bubble error correctly for gsp page', async () => { - const res = await fetchViaHTTP(appPort, '/errors/gsp/crash') + const res = await fetchViaHTTP( + appPort, + '/errors/gsp/crash', + undefined, + withInvocationId() + ) expect(res.status).toBe(500) expect(await res.text()).toBe('Internal Server Error') @@ -1100,7 +1238,12 @@ describe('required server files', () => { }) it('should bubble error correctly for API page', async () => { - const res = await fetchViaHTTP(appPort, '/api/error') + const res = await fetchViaHTTP( + appPort, + '/api/error', + undefined, + withInvocationId() + ) expect(res.status).toBe(500) expect(await res.text()).toBe('Internal Server Error') @@ -1114,11 +1257,11 @@ describe('required server files', () => { appPort, '/optional-ssp', { nxtPrest: '', another: 'value' }, - { + withInvocationId({ headers: { 'x-matched-path': '/optional-ssp/[[...rest]]', }, - } + }) ) const html = await res.text() @@ -1133,11 +1276,11 @@ describe('required server files', () => { appPort, '/optional-ssg', { nxtPrest: '', another: 'value' }, - { + withInvocationId({ headers: { 'x-matched-path': '/optional-ssg/[[...rest]]', }, - } + }) ) const html = await res.text() @@ -1187,7 +1330,7 @@ describe('required server files', () => { headers: { 'x-matched-path': `/_next/data/${next.buildId}/optional-ssg/[[...rest]].json`, 'x-now-route-matches': '', - 'x-vercel-id': 'cle1::', + 'x-invocation-id': 'cle1::', }, }, { @@ -1195,7 +1338,7 @@ describe('required server files', () => { headers: { 'x-matched-path': `/_next/data/${next.buildId}/optional-ssg/[[...rest]].json`, 'x-now-route-matches': '', - 'x-vercel-id': 'cle1::', + 'x-invocation-id': 'cle1::', }, }, { @@ -1203,7 +1346,7 @@ describe('required server files', () => { headers: { 'x-matched-path': `/optional-ssg/[[...rest]]`, 'x-now-route-matches': '', - 'x-vercel-id': 'cle1::', + 'x-invocation-id': 'cle1::', }, }, ] @@ -1211,6 +1354,7 @@ describe('required server files', () => { for (const req of reqs) { console.error('checking', req) const res = await fetchViaHTTP(appPort, req.path, req.query, { + ...withInvocationId(), headers: req.headers, }) @@ -1233,12 +1377,12 @@ describe('required server files', () => { appPort, '/optional-ssg/[[...rest]]', undefined, - { + withInvocationId({ headers: { 'x-matched-path': '/optional-ssg/[[...rest]]', 'x-now-route-matches': 'nxtPrest=en%2Fes%2Fhello%252Fworld', }, - } + }) ) const html = await res.text() @@ -1254,11 +1398,11 @@ describe('required server files', () => { appPort, '/api/optional', { nxtPrest: '', another: 'value' }, - { + withInvocationId({ headers: { 'x-matched-path': '/api/optional/[[...rest]]', }, - } + }) ) const json = await res.json() @@ -1271,11 +1415,11 @@ describe('required server files', () => { appPort, '/api/optional/index', { nxtPrest: 'index', another: 'value' }, - { + withInvocationId({ headers: { 'x-matched-path': '/api/optional/[[...rest]]', }, - } + }) ) const json = await res.json() @@ -1284,12 +1428,17 @@ describe('required server files', () => { }) it('should match the index page correctly', async () => { - const res = await fetchViaHTTP(appPort, '/', undefined, { - headers: { - 'x-matched-path': '/index', - }, - redirect: 'manual', - }) + const res = await fetchViaHTTP( + appPort, + '/', + undefined, + withInvocationId({ + headers: { + 'x-matched-path': '/index', + }, + redirect: 'manual', + }) + ) const html = await res.text() const $ = cheerio.load(html) @@ -1297,12 +1446,17 @@ describe('required server files', () => { }) it('should match the root dynamic page correctly', async () => { - const res = await fetchViaHTTP(appPort, '/slug-1', undefined, { - headers: { - 'x-matched-path': '/[slug]', - }, - redirect: 'manual', - }) + const res = await fetchViaHTTP( + appPort, + '/slug-1', + undefined, + withInvocationId({ + headers: { + 'x-matched-path': '/[slug]', + }, + redirect: 'manual', + }) + ) const html = await res.text() const $ = cheerio.load(html) @@ -1311,12 +1465,17 @@ describe('required server files', () => { slug: 'slug-1', }) - const res2 = await fetchViaHTTP(appPort, '/[slug]', undefined, { - headers: { - 'x-matched-path': '/[slug]', - }, - redirect: 'manual', - }) + const res2 = await fetchViaHTTP( + appPort, + '/[slug]', + undefined, + withInvocationId({ + headers: { + 'x-matched-path': '/[slug]', + }, + redirect: 'manual', + }) + ) const html2 = await res2.text() const $2 = cheerio.load(html2) @@ -1327,12 +1486,17 @@ describe('required server files', () => { }) it('should have correct asPath on dynamic SSG page correctly', async () => { - const res = await fetchViaHTTP(appPort, '/an-ssg-path', undefined, { - headers: { - 'x-matched-path': '/[slug]', - }, - redirect: 'manual', - }) + const res = await fetchViaHTTP( + appPort, + '/an-ssg-path', + undefined, + withInvocationId({ + headers: { + 'x-matched-path': '/[slug]', + }, + redirect: 'manual', + }) + ) const html = await res.text() const $ = cheerio.load(html) @@ -1353,12 +1517,17 @@ describe('required server files', () => { ] for (const check of toCheck) { console.warn('checking', check) - const res = await fetchViaHTTP(appPort, check.pathname, undefined, { - headers: { - 'x-matched-path': check.matchedPath, - }, - redirect: 'manual', - }) + const res = await fetchViaHTTP( + appPort, + check.pathname, + undefined, + withInvocationId({ + headers: { + 'x-matched-path': check.matchedPath, + }, + redirect: 'manual', + }) + ) const html = await res.text() const $ = cheerio.load(html) @@ -1370,7 +1539,12 @@ describe('required server files', () => { }) it('should read .env files and process.env', async () => { - const res = await fetchViaHTTP(appPort, '/api/env') + const res = await fetchViaHTTP( + appPort, + '/api/env', + undefined, + withInvocationId() + ) const envVariables = await res.json() @@ -1387,7 +1561,12 @@ describe('required server files', () => { it('should run middleware correctly', async () => { const standaloneDir = join(next.testDir, 'standalone') - const res = await fetchViaHTTP(appPort, '/') + const res = await fetchViaHTTP( + appPort, + '/', + undefined, + withInvocationId() + ) expect(res.status).toBe(200) expect(await res.text()).toContain('index page') @@ -1405,7 +1584,9 @@ describe('required server files', () => { const resImageResponse = await fetchViaHTTP( appPort, - '/a-non-existent-page/to-test-with-middleware' + '/a-non-existent-page/to-test-with-middleware', + undefined, + withInvocationId() ) expect(resImageResponse.status).toBe(200) @@ -1418,11 +1599,11 @@ describe('required server files', () => { appPort, `/_next/data/${nanoid()}/index.json`, undefined, - { + withInvocationId({ headers: { 'x-matched-path': '/[teamSlug]/[project]/[id]/[suffix]', }, - } + }) ) expect(res.status).toBe(404) diff --git a/test/production/standalone-mode/response-cache/index.test.ts b/test/production/standalone-mode/response-cache/index.test.ts index 04111664910aa..d92863c19064a 100644 --- a/test/production/standalone-mode/response-cache/index.test.ts +++ b/test/production/standalone-mode/response-cache/index.test.ts @@ -96,7 +96,7 @@ describe('minimal-mode-response-cache', () => { vary: 'rsc, next-router-state-tree, next-router-prefetch', 'x-now-route-matches': '1=compare&rsc=1', 'x-matched-path': '/app-blog/compare.rsc', - 'x-vercel-id': '1', + 'x-invocation-id': '1', rsc: '1', } const res1 = await fetchViaHTTP( @@ -126,7 +126,7 @@ describe('minimal-mode-response-cache', () => { vary: 'rsc, next-router-state-tree, next-router-prefetch', 'x-now-route-matches': '1=app-another&rsc=1', 'x-matched-path': '/app-another.rsc', - 'x-vercel-id': '1', + 'x-invocation-id': '1', rsc: '1', } const res1 = await fetchViaHTTP(appPort, '/app-another.rsc', undefined, { From 0c196049d5fb0f097d852e6cd45c1ccf5eead873 Mon Sep 17 00:00:00 2001 From: nextjs-bot Date: Thu, 22 Jan 2026 18:46:59 +0000 Subject: [PATCH 08/10] v16.2.0-canary.3 --- lerna.json | 2 +- packages/create-next-app/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-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 | 16 ++++++++-------- 20 files changed, 35 insertions(+), 35 deletions(-) diff --git a/lerna.json b/lerna.json index 96a1e9c3cfcb9..6da3544572b4d 100644 --- a/lerna.json +++ b/lerna.json @@ -15,5 +15,5 @@ "registry": "https://registry.npmjs.org/" } }, - "version": "16.2.0-canary.2" + "version": "16.2.0-canary.3" } \ No newline at end of file diff --git a/packages/create-next-app/package.json b/packages/create-next-app/package.json index 89ab9fa29a483..e5213e7674385 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.2.0-canary.2", + "version": "16.2.0-canary.3", "keywords": [ "react", "next", diff --git a/packages/eslint-config-next/package.json b/packages/eslint-config-next/package.json index e41670a33649c..4479004174885 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.2.0-canary.2", + "version": "16.2.0-canary.3", "description": "ESLint configuration used by Next.js.", "license": "MIT", "repository": { @@ -12,7 +12,7 @@ "dist" ], "dependencies": { - "@next/eslint-plugin-next": "16.2.0-canary.2", + "@next/eslint-plugin-next": "16.2.0-canary.3", "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 2b8c03e3718e0..16d42fa1d54d1 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.2.0-canary.2", + "version": "16.2.0-canary.3", "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 e780bf7c66544..c59c224fe1587 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.2.0-canary.2", + "version": "16.2.0-canary.3", "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 c948ad4e0c1b4..ca18d80b0afb0 100644 --- a/packages/font/package.json +++ b/packages/font/package.json @@ -1,7 +1,7 @@ { "name": "@next/font", "private": true, - "version": "16.2.0-canary.2", + "version": "16.2.0-canary.3", "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 2e4b0de039bff..bf3d075260dcd 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.2.0-canary.2", + "version": "16.2.0-canary.3", "main": "index.js", "types": "index.d.ts", "license": "MIT", diff --git a/packages/next-codemod/package.json b/packages/next-codemod/package.json index 5ce0ae781de46..883465317d8c6 100644 --- a/packages/next-codemod/package.json +++ b/packages/next-codemod/package.json @@ -1,6 +1,6 @@ { "name": "@next/codemod", - "version": "16.2.0-canary.2", + "version": "16.2.0-canary.3", "license": "MIT", "repository": { "type": "git", diff --git a/packages/next-env/package.json b/packages/next-env/package.json index 5b854800d2766..9889072fa0ff7 100644 --- a/packages/next-env/package.json +++ b/packages/next-env/package.json @@ -1,6 +1,6 @@ { "name": "@next/env", - "version": "16.2.0-canary.2", + "version": "16.2.0-canary.3", "keywords": [ "react", "next", diff --git a/packages/next-mdx/package.json b/packages/next-mdx/package.json index 766fc611d6336..ed0926510d55f 100644 --- a/packages/next-mdx/package.json +++ b/packages/next-mdx/package.json @@ -1,6 +1,6 @@ { "name": "@next/mdx", - "version": "16.2.0-canary.2", + "version": "16.2.0-canary.3", "main": "index.js", "license": "MIT", "repository": { diff --git a/packages/next-plugin-storybook/package.json b/packages/next-plugin-storybook/package.json index 9f492f3f766ad..f67df275dbbd6 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.2.0-canary.2", + "version": "16.2.0-canary.3", "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 8f8cb2df1ea5b..96bac29212e21 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.2.0-canary.2", + "version": "16.2.0-canary.3", "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 6aefa7030886f..b9294c828472a 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.2.0-canary.2", + "version": "16.2.0-canary.3", "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 1bf7a62e49cc5..6cae90bd69d72 100644 --- a/packages/next-routing/package.json +++ b/packages/next-routing/package.json @@ -1,6 +1,6 @@ { "name": "@next/routing", - "version": "16.2.0-canary.2", + "version": "16.2.0-canary.3", "keywords": [ "react", "next", diff --git a/packages/next-rspack/package.json b/packages/next-rspack/package.json index 3a9f51bec13a9..b76140b3c208d 100644 --- a/packages/next-rspack/package.json +++ b/packages/next-rspack/package.json @@ -1,6 +1,6 @@ { "name": "next-rspack", - "version": "16.2.0-canary.2", + "version": "16.2.0-canary.3", "repository": { "url": "vercel/next.js", "directory": "packages/next-rspack" diff --git a/packages/next-swc/package.json b/packages/next-swc/package.json index aba49f4ce4e4a..7dc19aba0e41b 100644 --- a/packages/next-swc/package.json +++ b/packages/next-swc/package.json @@ -1,6 +1,6 @@ { "name": "@next/swc", - "version": "16.2.0-canary.2", + "version": "16.2.0-canary.3", "private": true, "files": [ "native/" diff --git a/packages/next/package.json b/packages/next/package.json index eaaa0cd79e964..07b0f96033759 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "next", - "version": "16.2.0-canary.2", + "version": "16.2.0-canary.3", "description": "The React Framework", "main": "./dist/server/next.js", "license": "MIT", @@ -97,7 +97,7 @@ ] }, "dependencies": { - "@next/env": "16.2.0-canary.2", + "@next/env": "16.2.0-canary.3", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", @@ -162,11 +162,11 @@ "@modelcontextprotocol/sdk": "1.18.1", "@mswjs/interceptors": "0.23.0", "@napi-rs/triples": "1.2.0", - "@next/font": "16.2.0-canary.2", - "@next/polyfill-module": "16.2.0-canary.2", - "@next/polyfill-nomodule": "16.2.0-canary.2", - "@next/react-refresh-utils": "16.2.0-canary.2", - "@next/swc": "16.2.0-canary.2", + "@next/font": "16.2.0-canary.3", + "@next/polyfill-module": "16.2.0-canary.3", + "@next/polyfill-nomodule": "16.2.0-canary.3", + "@next/react-refresh-utils": "16.2.0-canary.3", + "@next/swc": "16.2.0-canary.3", "@opentelemetry/api": "1.6.0", "@playwright/test": "1.51.1", "@rspack/core": "1.6.7", diff --git a/packages/react-refresh-utils/package.json b/packages/react-refresh-utils/package.json index b6cf62115b6fc..7f380303d4bb4 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.2.0-canary.2", + "version": "16.2.0-canary.3", "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 393899c6a85eb..ef154beb6e542 100644 --- a/packages/third-parties/package.json +++ b/packages/third-parties/package.json @@ -1,6 +1,6 @@ { "name": "@next/third-parties", - "version": "16.2.0-canary.2", + "version": "16.2.0-canary.3", "repository": { "url": "vercel/next.js", "directory": "packages/third-parties" @@ -26,7 +26,7 @@ "third-party-capital": "1.0.20" }, "devDependencies": { - "next": "16.2.0-canary.2", + "next": "16.2.0-canary.3", "outdent": "0.8.0", "prettier": "2.5.1", "typescript": "5.9.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8833e8214d77f..60d1b2c177766 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1008,7 +1008,7 @@ importers: packages/eslint-config-next: dependencies: '@next/eslint-plugin-next': - specifier: 16.2.0-canary.2 + specifier: 16.2.0-canary.3 version: link:../eslint-plugin-next eslint: specifier: '>=9.0.0' @@ -1085,7 +1085,7 @@ importers: packages/next: dependencies: '@next/env': - specifier: 16.2.0-canary.2 + specifier: 16.2.0-canary.3 version: link:../next-env '@swc/helpers': specifier: 0.5.15 @@ -1213,19 +1213,19 @@ importers: specifier: 1.2.0 version: 1.2.0 '@next/font': - specifier: 16.2.0-canary.2 + specifier: 16.2.0-canary.3 version: link:../font '@next/polyfill-module': - specifier: 16.2.0-canary.2 + specifier: 16.2.0-canary.3 version: link:../next-polyfill-module '@next/polyfill-nomodule': - specifier: 16.2.0-canary.2 + specifier: 16.2.0-canary.3 version: link:../next-polyfill-nomodule '@next/react-refresh-utils': - specifier: 16.2.0-canary.2 + specifier: 16.2.0-canary.3 version: link:../react-refresh-utils '@next/swc': - specifier: 16.2.0-canary.2 + specifier: 16.2.0-canary.3 version: link:../next-swc '@opentelemetry/api': specifier: 1.6.0 @@ -1943,7 +1943,7 @@ importers: version: 1.0.20 devDependencies: next: - specifier: 16.2.0-canary.2 + specifier: 16.2.0-canary.3 version: link:../next outdent: specifier: 0.8.0 From 2cfc7ebb0065f3a016cf2a73cb24d9865f7c656f Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Thu, 22 Jan 2026 20:00:17 +0100 Subject: [PATCH 09/10] [test] Skip failing deploy test in `searchparams-reuse-loading.test.ts` (#88821) --- test/deploy-tests-manifest.json | 5 +++++ .../searchparams-reuse-loading.test.ts | 1 + 2 files changed, 6 insertions(+) diff --git a/test/deploy-tests-manifest.json b/test/deploy-tests-manifest.json index 299efecdabe18..d7a22642b8e1f 100644 --- a/test/deploy-tests-manifest.json +++ b/test/deploy-tests-manifest.json @@ -41,6 +41,11 @@ "app dir - metadata react cache should have same title and page value when navigating" ] }, + "test/e2e/app-dir/searchparams-reuse-loading/searchparams-reuse-loading.test.ts": { + "failed": [ + "searchparams-reuse-loading With Middleware should correctly return different RSC data for full prefetches with different searchParam values" + ] + }, "test/e2e/middleware-rewrites/test/index.test.ts": { "failed": ["Middleware Rewrite should handle catch-all rewrite correctly"] } diff --git a/test/e2e/app-dir/searchparams-reuse-loading/searchparams-reuse-loading.test.ts b/test/e2e/app-dir/searchparams-reuse-loading/searchparams-reuse-loading.test.ts index 9d69854e83bd4..dd62e35819b22 100644 --- a/test/e2e/app-dir/searchparams-reuse-loading/searchparams-reuse-loading.test.ts +++ b/test/e2e/app-dir/searchparams-reuse-loading/searchparams-reuse-loading.test.ts @@ -173,6 +173,7 @@ describe('searchparams-reuse-loading', () => { { path: '/with-middleware', label: 'With Middleware' }, ])('$label', ({ path }) => { it('should correctly return different RSC data for full prefetches with different searchParam values', async () => { + // TODO: Skipped in deploy tests when middleware is present const rscRequestPromise = new Map< string, { resolve: () => Promise } From 5a0b9787c7ef8beb5bc194c5e7182112919f0567 Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Thu, 22 Jan 2026 20:00:53 +0100 Subject: [PATCH 10/10] [test] Skip flaky `prefetch-runtime` tests for deploy tests (#88826) --- test/deploy-tests-manifest.json | 37 +++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/test/deploy-tests-manifest.json b/test/deploy-tests-manifest.json index d7a22642b8e1f..0c372f24ea4b6 100644 --- a/test/deploy-tests-manifest.json +++ b/test/deploy-tests-manifest.json @@ -11,6 +11,43 @@ "app dir client cache semantics (experimental staleTimes) static: 180 prefetch={undefined} - default should re-use the loading boundary for the custom static override time (3 minutes)" ] }, + "test/e2e/app-dir/segment-cache/prefetch-runtime/prefetch-runtime.test.ts": { + "flakey": [ + "runtime prefetching cache stale time handling includes private caches with cacheLife(\"seconds\")", + "runtime prefetching cache stale time handling includes public caches with cacheLife(\"seconds\")", + "runtime prefetching cache stale time handling includes short-lived public caches with a long enough staleTime", + "runtime prefetching cache stale time handling omits private caches with a short enough staleTime", + "runtime prefetching cache stale time handling omits short-lived public caches with a short enough staleTime", + "runtime prefetching errors aborts the prerender without logging an error when sync IO is used after awaiting a quickly-expiring public cache", + "runtime prefetching errors aborts the prerender without logging an error when sync IO is used after awaiting a private cache", + "runtime prefetching errors aborts the prerender without logging an error when sync IO is used after awaiting cookies()", + "runtime prefetching errors aborts the prerender without logging an error when sync IO is used after awaiting dynamic params", + "runtime prefetching errors aborts the prerender without logging an error when sync IO is used after awaiting headers()", + "runtime prefetching errors aborts the prerender without logging an error when sync IO is used after awaiting searchParams", + "runtime prefetching errors should trigger error boundaries for errors that occurred in runtime-prefetched content", + "runtime prefetching in a page can completely prefetch a page that uses cookies and no uncached IO", + "runtime prefetching in a page includes cookies, but not dynamic content", + "runtime prefetching in a page includes dynamic params, but not dynamic content", + "runtime prefetching in a page includes headers, but not dynamic content", + "runtime prefetching in a page includes root params, but not dynamic content", + "runtime prefetching in a page includes search params, but not dynamic content", + "runtime prefetching in a private cache can completely prefetch a page that uses cookies and no uncached IO", + "runtime prefetching in a private cache includes cookies, but not dynamic content", + "runtime prefetching in a private cache includes dynamic params, but not dynamic content", + "runtime prefetching in a private cache includes headers, but not dynamic content", + "runtime prefetching in a private cache includes root params, but not dynamic content", + "runtime prefetching in a private cache includes search params, but not dynamic content", + "runtime prefetching passed to a public cache can completely prefetch a page that uses cookies and no uncached IO", + "runtime prefetching passed to a public cache includes cookies, but not dynamic content", + "runtime prefetching passed to a public cache includes dynamic params, but not dynamic content", + "runtime prefetching passed to a public cache includes headers, but not dynamic content", + "runtime prefetching passed to a public cache includes root params, but not dynamic content", + "runtime prefetching passed to a public cache includes search params, but not dynamic content", + "runtime prefetching should not cache runtime prefetch responses in the browser cache or server-side different cookies should return different prefetch results - in a page", + "runtime prefetching should not cache runtime prefetch responses in the browser cache or server-side different cookies should return different prefetch results - in a private cache", + "runtime prefetching should not cache runtime prefetch responses in the browser cache or server-side private caches should return new results on each request" + ] + }, "test/e2e/app-dir/actions/app-action.test.ts": { "failed": [ "app-dir action handling fetch actions should invalidate client cache when path is revalidated",