From 5903c030dc41b506ed344aff1c3f35315af085a4 Mon Sep 17 00:00:00 2001 From: Webdesign29 Date: Fri, 13 Mar 2026 15:01:37 +0100 Subject: [PATCH 01/16] perf: use Buffer.from for base64 encoding of binary Flight data (#91221) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Replace `btoa(String.fromCodePoint(...chunk))` with `Buffer.from().toString('base64')` for encoding binary Flight data chunks in `writeFlightDataInstruction`. The spread operator `...chunk` converts the entire Uint8Array into individual arguments on the call stack. For a 64KB binary chunk, this creates 65,536 arguments — causing: - Significant call stack pressure (V8's argument limit is ~65K) - Temporary JS string allocation from `String.fromCodePoint` - The entire chunk must be converted to a JS string before base64 encoding `Buffer.from().toString('base64')` performs base64 encoding natively in C++ without any intermediate string allocation or argument spreading. **Edge runtime compatibility**: Falls back to the original `btoa` path where `Buffer` is unavailable. ## Test plan - [x] TypeScript compilation passes - [x] Prettier and ESLint pass (pre-commit hooks) - [x] Produces identical base64 output - [x] Edge runtime falls back to original path (no `Buffer` available) - [x] Node.js runtime uses `Buffer.from` for native encoding 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../next/src/server/app-render/use-flight-response.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/next/src/server/app-render/use-flight-response.tsx b/packages/next/src/server/app-render/use-flight-response.tsx index 72ce6216e021e..85aaac5057abd 100644 --- a/packages/next/src/server/app-render/use-flight-response.tsx +++ b/packages/next/src/server/app-render/use-flight-response.tsx @@ -241,7 +241,14 @@ function writeFlightDataInstruction( // Instead let's inline it in base64. // Credits to Devon Govett (devongovett) for the technique. // https://github.com/devongovett/rsc-html-stream - const base64 = btoa(String.fromCodePoint(...chunk)) + const base64 = + typeof Buffer !== 'undefined' + ? Buffer.from( + chunk.buffer, + chunk.byteOffset, + chunk.byteLength + ).toString('base64') + : btoa(String.fromCodePoint(...chunk)) htmlInlinedData = htmlEscapeJsonString( JSON.stringify([INLINE_FLIGHT_PAYLOAD_BINARY, base64]) ) From 254478317305c17871db277808eecd81ea6dd634 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Fri, 13 Mar 2026 16:17:43 +0100 Subject: [PATCH 02/16] Skip dimming when `--inspect` is used (#91271) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the Node.js inspector is active (e.g. via `next dev --inspect`), dimming wraps console arguments in a format string which defeats inspector affordances such as collapsible objects and clickable/linkified stack traces. This adds an early return in `convertToDimmedArgs` that skips dimming entirely when `inspector.url()` is defined. Terminal output without `--inspect` is unchanged. Ideally we would only skip dimming when a debugger frontend is actually attached, but Node.js does not expose a synchronous API for that — detecting it would require async polling of the `/json/list` HTTP endpoint. Test plan: - Unit tests in `console-dim.external.test.ts` - Manual: dimming almost never triggers now since we switched to in-band validation (no separate render pass). To reproduce manually, use a client component that calls `console.error(new Error(...))` during SSR, e.g.: 1. `__NEXT_CACHE_COMPONENTS=true pnpm next dev test/e2e/app-dir/server-source-maps/fixtures/default --inspect` 2. Open `http://localhost:3000/` in Chrome 3. Click the Node.js DevTools button 4. Load `http://localhost:3000/ssr-error-log` 5. Verify the first error in DevTools has source-mapped, expandable stack frames Before: before After: after --- .../console-dim.external.test.ts | 57 +++++++++++++++++++ .../console-dim.external.tsx | 11 ++++ .../default/app/ssr-error-log/page.js | 11 ++++ .../server-source-maps.test.ts | 44 ++++++++++++++ 4 files changed, 123 insertions(+) create mode 100644 test/e2e/app-dir/server-source-maps/fixtures/default/app/ssr-error-log/page.js diff --git a/packages/next/src/server/node-environment-extensions/console-dim.external.test.ts b/packages/next/src/server/node-environment-extensions/console-dim.external.test.ts index 2a0bade809b03..a8254eeb10d7b 100644 --- a/packages/next/src/server/node-environment-extensions/console-dim.external.test.ts +++ b/packages/next/src/server/node-environment-extensions/console-dim.external.test.ts @@ -357,4 +357,61 @@ describe('console-exit patches', () => { ]) }) }) + + describe('inspector-aware dimming', () => { + it('should skip dimming when inspector is open', async () => { + async function testForWorker() { + const nodeInspector = require('node:inspector') + nodeInspector.open(0) + + const { + consoleAsyncStorage, + } = require('next/dist/server/app-render/console-async-storage.external') + + const capturedCalls: Array<{ args: any[] }> = [] + console.error = function (...args) { + capturedCalls.push({ args }) + } + + require('next/dist/server/node-environment-extensions/console-dim.external') + + consoleAsyncStorage.run({ dim: true }, () => { + console.error('prefix', { key: 'value' }, 42) + }) + + nodeInspector.close() + + const call = capturedCalls[0] + + reportResult({ + type: 'serialized', + key: 'argCount', + data: JSON.stringify(call.args.length), + }) + reportResult({ + type: 'serialized', + key: 'firstArg', + data: JSON.stringify(call.args[0]), + }) + reportResult({ + type: 'serialized', + key: 'secondArg', + data: JSON.stringify(call.args[1]), + }) + reportResult({ + type: 'serialized', + key: 'thirdArg', + data: JSON.stringify(call.args[2]), + }) + } + + const { data, exitCode } = await runWorkerCode(testForWorker) + + expect(exitCode).toBe(0) + expect(data.argCount).toBe(3) + expect(data.firstArg).toBe('prefix') + expect(data.secondArg).toEqual({ key: 'value' }) + expect(data.thirdArg).toBe(42) + }) + }) }) diff --git a/packages/next/src/server/node-environment-extensions/console-dim.external.tsx b/packages/next/src/server/node-environment-extensions/console-dim.external.tsx index 7aae4fa61c355..c4c80f0e629d7 100644 --- a/packages/next/src/server/node-environment-extensions/console-dim.external.tsx +++ b/packages/next/src/server/node-environment-extensions/console-dim.external.tsx @@ -1,3 +1,4 @@ +import * as inspector from 'node:inspector' import { dim } from '../../lib/picocolors' import { consoleAsyncStorage, @@ -142,6 +143,16 @@ function convertToDimmedArgs( methodName: InterceptableConsoleMethod, args: any[] ): any[] { + // When the Node.js inspector is open (e.g. --inspect), skip dimming entirely. + // Dimming wraps arguments in a format string which defeats inspector + // affordances such as collapsible objects and clickable/linkified stack + // traces. Ideally we would only skip dimming when a debugger frontend is + // actually attached, but Node.js does not expose a synchronous API for that. + // Detecting would require async polling of the /json/list HTTP endpoint. + if (inspector.url() !== undefined) { + return args + } + switch (methodName) { case 'dir': case 'dirxml': diff --git a/test/e2e/app-dir/server-source-maps/fixtures/default/app/ssr-error-log/page.js b/test/e2e/app-dir/server-source-maps/fixtures/default/app/ssr-error-log/page.js new file mode 100644 index 0000000000000..664ac33701343 --- /dev/null +++ b/test/e2e/app-dir/server-source-maps/fixtures/default/app/ssr-error-log/page.js @@ -0,0 +1,11 @@ +'use client' + +function logError() { + const error = new Error('ssr-error-log') + console.error(error) +} + +export default function Page() { + logError() + return null +} diff --git a/test/e2e/app-dir/server-source-maps/server-source-maps.test.ts b/test/e2e/app-dir/server-source-maps/server-source-maps.test.ts index 1657fb93efb60..75997bf83eba1 100644 --- a/test/e2e/app-dir/server-source-maps/server-source-maps.test.ts +++ b/test/e2e/app-dir/server-source-maps/server-source-maps.test.ts @@ -269,6 +269,50 @@ describe('app-dir - server source maps', () => { } }) + it('logged errors in client components during ssr have a sourcemapped stack with a codeframe', async () => { + if (isNextDev) { + const outputIndex = next.cliOutput.length + await next.render('/ssr-error-log') + + await retry(() => { + expect(next.cliOutput.slice(outputIndex)).toContain( + 'Error: ssr-error-log' + ) + }) + expect(normalizeCliOutput(next.cliOutput.slice(outputIndex))).toContain( + 'Error: ssr-error-log' + + '\n at logError (app/ssr-error-log/page.js:4:17)' + + '\n at Page (app/ssr-error-log/page.js:9:3)' + + '\n 2 |' + + '\n 3 | function logError() {' + + "\n> 4 | const error = new Error('ssr-error-log')" + + '\n | ^' + + '\n 5 | console.error(error)' + + '\n 6 | }' + + '\n 7 |' + + '\n' + ) + } else { + if (isTurbopack) { + // TODO(veil): Sourcemap names + expect(normalizeCliOutput(next.cliOutput)).toContain( + 'Error: ssr-error-log' + + '\n at (app/ssr-error-log/page.js:4:17)' + + '\n 2 |' + + '\n 3 | function logError() {' + + "\n> 4 | const error = new Error('ssr-error-log')" + + '\n | ^' + + '\n 5 | console.error(error)' + + '\n 6 | }' + + '\n 7 |' + + '\n' + ) + } else { + // TODO(veil): line/column numbers are flaky in Webpack + } + } + }) + it('stack frames are ignore-listed in ssr', async () => { if (isNextDev) { const outputIndex = next.cliOutput.length From c1f13e48b0ddd9887a99d486f2c9b190584d4a0e Mon Sep 17 00:00:00 2001 From: Niklas Mischkulnig <4586894+mischnic@users.noreply.github.com> Date: Fri, 13 Mar 2026 17:00:37 +0100 Subject: [PATCH 03/16] Turbopack: rename CSS module structs for clarity (#91304) --- crates/next-api/src/module_graph.rs | 9 +++-- .../next-core/src/next_server/transforms.rs | 2 +- .../turbopack-core/src/reference_type.rs | 8 ++--- turbopack/crates/turbopack-css/src/asset.rs | 35 ++++++++++--------- turbopack/crates/turbopack-css/src/lib.rs | 4 +-- .../crates/turbopack-css/src/module_asset.rs | 20 +++++------ turbopack/crates/turbopack-css/src/process.rs | 10 +++--- turbopack/crates/turbopack/src/lib.rs | 6 ++-- .../turbopack/src/module_options/mod.rs | 14 ++++---- .../src/module_options/module_rule.rs | 6 ++-- 10 files changed, 57 insertions(+), 57 deletions(-) diff --git a/crates/next-api/src/module_graph.rs b/crates/next-api/src/module_graph.rs index 8dc2f7d1eb632..26102cadd3fa0 100644 --- a/crates/next-api/src/module_graph.rs +++ b/crates/next-api/src/module_graph.rs @@ -25,7 +25,7 @@ use turbopack_core::{ module::Module, module_graph::{GraphTraversalAction, ModuleGraph, ModuleGraphLayer}, }; -use turbopack_css::{CssModuleAsset, ModuleCssAsset}; +use turbopack_css::{CssModule, EcmascriptCssModule}; use crate::{ client_references::{ClientManifestEntryType, ClientReferenceData, map_client_references}, @@ -809,16 +809,15 @@ async fn validate_pages_css_imports_individual( // If the module being imported isn't a global css module, there is nothing to // validate. - let module_is_global_css = - ResolvedVc::try_downcast_type::(module).is_some(); + let module_is_global_css = ResolvedVc::try_downcast_type::(module).is_some(); if !module_is_global_css { return Ok(GraphTraversalAction::Continue); } let parent_is_css_module = - ResolvedVc::try_downcast_type::(parent_module).is_some() - || ResolvedVc::try_downcast_type::(parent_module).is_some(); + ResolvedVc::try_downcast_type::(parent_module).is_some() + || ResolvedVc::try_downcast_type::(parent_module).is_some(); // We also always allow .module css/scss/sass files to import global css files as // well. diff --git a/crates/next-core/src/next_server/transforms.rs b/crates/next-core/src/next_server/transforms.rs index 2e0b7db6b8ee1..e010d7ed06890 100644 --- a/crates/next-core/src/next_server/transforms.rs +++ b/crates/next-core/src/next_server/transforms.rs @@ -60,7 +60,7 @@ pub async fn get_next_server_transforms_rules( if !matches!(context_ty, ServerContextType::AppRSC { .. }) { rules.extend([ - // Ignore the inner ModuleCssAsset -> CssModuleAsset references + // Ignore the inner EcmascriptCssModule -> CssModule references // The CSS Module module itself (and the Analyze reference) is still needed to generate // the class names object. ModuleRule::new( diff --git a/turbopack/crates/turbopack-core/src/reference_type.rs b/turbopack/crates/turbopack-core/src/reference_type.rs index 6369beca82816..5aad24089fbbb 100644 --- a/turbopack/crates/turbopack-core/src/reference_type.rs +++ b/turbopack/crates/turbopack-core/src/reference_type.rs @@ -218,12 +218,12 @@ impl ImportContext { )] pub enum CssReferenceSubType { AtImport(Option>), - /// Reference from ModuleCssAsset to an imported ModuleCssAsset for retrieving the composed - /// class name + /// Reference from EcmascriptCssModule to an imported EcmascriptCssModule for retrieving the + /// composed class name Compose, - /// Reference from ModuleCssAsset to the CssModuleAsset + /// Reference from EcmascriptCssModule to the CssModule Inner, - /// Used for generating the list of classes in a ModuleCssAsset + /// Used for generating the list of classes in a EcmascriptCssModule Analyze, Custom(u8), #[default] diff --git a/turbopack/crates/turbopack-css/src/asset.rs b/turbopack/crates/turbopack-css/src/asset.rs index 596f40db3fd94..fd0ea5d132f56 100644 --- a/turbopack/crates/turbopack-css/src/asset.rs +++ b/turbopack/crates/turbopack-css/src/asset.rs @@ -18,7 +18,7 @@ use turbopack_core::{ }; use crate::{ - CssModuleAssetType, LightningCssFeatureFlags, + CssModuleType, LightningCssFeatureFlags, chunk::{CssChunkItem, CssChunkItemContent, CssChunkPlaceable, CssChunkType, CssImport}, code_gen::CodeGenerateable, process::{ @@ -32,29 +32,30 @@ use crate::{ #[turbo_tasks::value] #[derive(Clone)] -/// A global CSS asset. Notably not a `.module.css` module, which is [`ModuleCssAsset`] instead. -pub struct CssModuleAsset { +/// A global CSS asset. Notably not a `.module.css` module, which is [`EcmascriptCssModule`] +/// instead. +pub struct CssModule { source: ResolvedVc>, asset_context: ResolvedVc>, import_context: Option>, - ty: CssModuleAssetType, + ty: CssModuleType, environment: Option>, lightningcss_features: LightningCssFeatureFlags, } #[turbo_tasks::value_impl] -impl CssModuleAsset { +impl CssModule { /// Creates a new CSS asset. #[turbo_tasks::function] pub fn new( source: ResolvedVc>, asset_context: ResolvedVc>, - ty: CssModuleAssetType, + ty: CssModuleType, import_context: Option>, environment: Option>, lightningcss_features: LightningCssFeatureFlags, ) -> Vc { - Self::cell(CssModuleAsset { + Self::cell(CssModule { source, asset_context, import_context, @@ -72,7 +73,7 @@ impl CssModuleAsset { } #[turbo_tasks::value_impl] -impl ParseCss for CssModuleAsset { +impl ParseCss for CssModule { #[turbo_tasks::function] async fn parse_css(self: Vc) -> Result> { let this = self.await?; @@ -89,7 +90,7 @@ impl ParseCss for CssModuleAsset { } #[turbo_tasks::value_impl] -impl ProcessCss for CssModuleAsset { +impl ProcessCss for CssModule { #[turbo_tasks::function] async fn get_css_with_placeholder(self: Vc) -> Result> { let this = self.await?; @@ -128,7 +129,7 @@ impl ProcessCss for CssModuleAsset { } #[turbo_tasks::value_impl] -impl Module for CssModuleAsset { +impl Module for CssModule { #[turbo_tasks::function] async fn ident(&self) -> Result> { let mut ident = self @@ -166,18 +167,18 @@ impl Module for CssModuleAsset { } #[turbo_tasks::value_impl] -impl StyleModule for CssModuleAsset { +impl StyleModule for CssModule { #[turbo_tasks::function] fn style_type(&self) -> Vc { match self.ty { - CssModuleAssetType::Default => StyleType::GlobalStyle.cell(), - CssModuleAssetType::Module => StyleType::IsolatedStyle.cell(), + CssModuleType::Default => StyleType::GlobalStyle.cell(), + CssModuleType::Module => StyleType::IsolatedStyle.cell(), } } } #[turbo_tasks::value_impl] -impl ChunkableModule for CssModuleAsset { +impl ChunkableModule for CssModule { #[turbo_tasks::function] fn as_chunk_item( self: ResolvedVc, @@ -193,10 +194,10 @@ impl ChunkableModule for CssModuleAsset { } #[turbo_tasks::value_impl] -impl CssChunkPlaceable for CssModuleAsset {} +impl CssChunkPlaceable for CssModule {} #[turbo_tasks::value_impl] -impl ResolveOrigin for CssModuleAsset { +impl ResolveOrigin for CssModule { #[turbo_tasks::function] fn origin_path(&self) -> Vc { self.source.ident().path() @@ -210,7 +211,7 @@ impl ResolveOrigin for CssModuleAsset { #[turbo_tasks::value] struct CssModuleChunkItem { - module: ResolvedVc, + module: ResolvedVc, module_graph: ResolvedVc, chunking_context: ResolvedVc>, } diff --git a/turbopack/crates/turbopack-css/src/lib.rs b/turbopack/crates/turbopack-css/src/lib.rs index beb79d1df62aa..75bfe71fb3196 100644 --- a/turbopack/crates/turbopack-css/src/lib.rs +++ b/turbopack/crates/turbopack-css/src/lib.rs @@ -18,7 +18,7 @@ use bincode::{Decode, Encode}; use turbo_tasks::{NonLocalValue, TaskInput, trace::TraceRawVcs}; use crate::references::import::ImportAssetReference; -pub use crate::{asset::CssModuleAsset, module_asset::ModuleCssAsset, process::*}; +pub use crate::{asset::CssModule, module_asset::EcmascriptCssModule, process::*}; #[derive( PartialOrd, @@ -36,7 +36,7 @@ pub use crate::{asset::CssModuleAsset, module_asset::ModuleCssAsset, process::*} Encode, Decode, )] -pub enum CssModuleAssetType { +pub enum CssModuleType { /// Default parsing mode. #[default] Default, diff --git a/turbopack/crates/turbopack-css/src/module_asset.rs b/turbopack/crates/turbopack-css/src/module_asset.rs index dd202f6cf0199..7196803177bfd 100644 --- a/turbopack/crates/turbopack-css/src/module_asset.rs +++ b/turbopack/crates/turbopack-css/src/module_asset.rs @@ -38,20 +38,20 @@ use crate::{ #[turbo_tasks::value] #[derive(Clone)] -/// A CSS Module asset, as in `.module.css`. For a global CSS module, see [`CssModuleAsset`]. -pub struct ModuleCssAsset { +/// A CSS Module asset, as in `.module.css`. For a global CSS module, see [`CssModule`]. +pub struct EcmascriptCssModule { pub source: ResolvedVc>, pub asset_context: ResolvedVc>, } #[turbo_tasks::value_impl] -impl ModuleCssAsset { +impl EcmascriptCssModule { #[turbo_tasks::function] pub fn new( source: ResolvedVc>, asset_context: ResolvedVc>, ) -> Vc { - Self::cell(ModuleCssAsset { + Self::cell(EcmascriptCssModule { source, asset_context, }) @@ -59,7 +59,7 @@ impl ModuleCssAsset { } #[turbo_tasks::value_impl] -impl Module for ModuleCssAsset { +impl Module for EcmascriptCssModule { #[turbo_tasks::function] async fn ident(&self) -> Result> { Ok(self @@ -164,7 +164,7 @@ struct ModuleCssClasses( ); #[turbo_tasks::value_impl] -impl ModuleCssAsset { +impl EcmascriptCssModule { #[turbo_tasks::function] pub fn inner(&self, ty: ReferenceType) -> Vc { self.asset_context.process(*self.source, ty) @@ -244,7 +244,7 @@ impl ModuleCssAsset { } #[turbo_tasks::value_impl] -impl ChunkableModule for ModuleCssAsset { +impl ChunkableModule for EcmascriptCssModule { #[turbo_tasks::function] fn as_chunk_item( self: ResolvedVc, @@ -256,7 +256,7 @@ impl ChunkableModule for ModuleCssAsset { } #[turbo_tasks::value_impl] -impl EcmascriptChunkPlaceable for ModuleCssAsset { +impl EcmascriptChunkPlaceable for EcmascriptCssModule { #[turbo_tasks::function] fn get_exports(&self) -> Vc { EcmascriptExports::Value.cell() @@ -303,7 +303,7 @@ impl EcmascriptChunkPlaceable for ModuleCssAsset { }; let Some(css_module) = - ResolvedVc::try_downcast_type::(*resolved_module) + ResolvedVc::try_downcast_type::(*resolved_module) else { CssModuleComposesIssue { severity: IssueSeverity::Error, @@ -371,7 +371,7 @@ impl EcmascriptChunkPlaceable for ModuleCssAsset { } #[turbo_tasks::value_impl] -impl ResolveOrigin for ModuleCssAsset { +impl ResolveOrigin for EcmascriptCssModule { #[turbo_tasks::function] fn origin_path(&self) -> Vc { self.source.ident().path() diff --git a/turbopack/crates/turbopack-css/src/process.rs b/turbopack/crates/turbopack-css/src/process.rs index 7ee594e9cb892..8d604880c66b9 100644 --- a/turbopack/crates/turbopack-css/src/process.rs +++ b/turbopack/crates/turbopack-css/src/process.rs @@ -35,7 +35,7 @@ use turbopack_core::{ }; use crate::{ - CssModuleAssetType, LightningCssFeatureFlags, + CssModuleType, LightningCssFeatureFlags, lifetime_util::stylesheet_into_static, references::{ analyze_references, @@ -363,7 +363,7 @@ pub async fn parse_css( source: ResolvedVc>, origin: ResolvedVc>, import_context: Option>, - ty: CssModuleAssetType, + ty: CssModuleType, environment: Option>, feature_flags: LightningCssFeatureFlags, ) -> Result> { @@ -409,7 +409,7 @@ async fn process_content( source: ResolvedVc>, origin: ResolvedVc>, import_context: Option>, - ty: CssModuleAssetType, + ty: CssModuleType, environment: Option>, feature_flags: LightningCssFeatureFlags, ) -> Result> { @@ -427,7 +427,7 @@ async fn process_content( let config = ParserOptions { css_modules: match ty { - CssModuleAssetType::Module => Some(lightningcss::css_modules::Config { + CssModuleType::Module => Some(lightningcss::css_modules::Config { pattern: Pattern { segments: smallvec![ Segment::Name, @@ -461,7 +461,7 @@ async fn process_content( }, ) { Ok(mut ss) => { - if matches!(ty, CssModuleAssetType::Module) { + if matches!(ty, CssModuleType::Module) { let mut validator = CssValidator { errors: Vec::new() }; ss.visit(&mut validator).unwrap(); diff --git a/turbopack/crates/turbopack/src/lib.rs b/turbopack/crates/turbopack/src/lib.rs index 12b5ac2950de5..1d63bef7c6a37 100644 --- a/turbopack/crates/turbopack/src/lib.rs +++ b/turbopack/crates/turbopack/src/lib.rs @@ -43,7 +43,7 @@ use turbopack_core::{ source::Source, source_transform::SourceTransforms, }; -use turbopack_css::{CssModuleAsset, ModuleCssAsset}; +use turbopack_css::{CssModule, EcmascriptCssModule}; use turbopack_ecmascript::{ AnalyzeMode, EcmascriptInputTransforms, EcmascriptModuleAsset, EcmascriptModuleAssetType, EcmascriptOptions, TreeShakingMode, @@ -249,7 +249,7 @@ async fn apply_module_type( ResolvedVc::upcast(NodeAddonModule::new(*source).to_resolved().await?) } ModuleType::CssModule => ResolvedVc::upcast( - ModuleCssAsset::new(*source, Vc::upcast(module_asset_context)) + EcmascriptCssModule::new(*source, Vc::upcast(module_asset_context)) .to_resolved() .await?, ), @@ -259,7 +259,7 @@ async fn apply_module_type( environment, lightningcss_features, } => ResolvedVc::upcast( - CssModuleAsset::new( + CssModule::new( *source, Vc::upcast(module_asset_context), *ty, diff --git a/turbopack/crates/turbopack/src/module_options/mod.rs b/turbopack/crates/turbopack/src/module_options/mod.rs index 908b6e8b7c0b7..a51e466690843 100644 --- a/turbopack/crates/turbopack/src/module_options/mod.rs +++ b/turbopack/crates/turbopack/src/module_options/mod.rs @@ -25,7 +25,7 @@ use turbopack_core::{ }, resolve::options::{ImportMap, ImportMapping}, }; -use turbopack_css::CssModuleAssetType; +use turbopack_css::CssModuleType; use turbopack_ecmascript::{ AnalyzeMode, EcmascriptInputTransform, EcmascriptInputTransforms, EcmascriptOptions, SpecifiedModuleType, bytes_source_transform::BytesSourceTransform, @@ -819,7 +819,7 @@ impl ModuleOptions { ModuleRule::new( module_css_condition.clone(), vec![ModuleRuleEffect::ModuleType(ModuleType::Css { - ty: CssModuleAssetType::Module, + ty: CssModuleType::Module, environment, lightningcss_features, })], @@ -830,7 +830,7 @@ impl ModuleOptions { RuleCondition::ContentTypeStartsWith("text/css".to_string()), ]), vec![ModuleRuleEffect::ModuleType(ModuleType::Css { - ty: CssModuleAssetType::Default, + ty: CssModuleType::Default, environment, lightningcss_features, })], @@ -894,7 +894,7 @@ impl ModuleOptions { ))), ]), vec![ModuleRuleEffect::ModuleType(ModuleType::Css { - ty: CssModuleAssetType::Module, + ty: CssModuleType::Module, environment, lightningcss_features, })], @@ -908,7 +908,7 @@ impl ModuleOptions { ))), ]), vec![ModuleRuleEffect::ModuleType(ModuleType::Css { - ty: CssModuleAssetType::Module, + ty: CssModuleType::Module, environment, lightningcss_features, })], @@ -922,7 +922,7 @@ impl ModuleOptions { ))), ]), vec![ModuleRuleEffect::ModuleType(ModuleType::Css { - ty: CssModuleAssetType::Module, + ty: CssModuleType::Module, environment, lightningcss_features, })], @@ -937,7 +937,7 @@ impl ModuleOptions { RuleCondition::ContentTypeStartsWith("text/css".to_string()), ]), vec![ModuleRuleEffect::ModuleType(ModuleType::Css { - ty: CssModuleAssetType::Default, + ty: CssModuleType::Default, environment, lightningcss_features, })], diff --git a/turbopack/crates/turbopack/src/module_options/module_rule.rs b/turbopack/crates/turbopack/src/module_options/module_rule.rs index 04398362428d8..8d21b30cfe340 100644 --- a/turbopack/crates/turbopack/src/module_options/module_rule.rs +++ b/turbopack/crates/turbopack/src/module_options/module_rule.rs @@ -9,7 +9,7 @@ use turbopack_core::{ environment::Environment, reference_type::ReferenceType, source::Source, source_transform::SourceTransforms, }; -use turbopack_css::CssModuleAssetType; +use turbopack_css::CssModuleType; use turbopack_ecmascript::{ EcmascriptInputTransforms, EcmascriptOptions, bytes_source_transform::BytesSourceTransform, json_source_transform::JsonSourceTransform, @@ -140,7 +140,7 @@ pub enum ModuleType { NodeAddon, CssModule, Css { - ty: CssModuleAssetType, + ty: CssModuleType, environment: Option>, lightningcss_features: turbopack_css::LightningCssFeatureFlags, }, @@ -264,7 +264,7 @@ impl ConfiguredModuleType { }) } ConfiguredModuleType::Css => ModuleRuleEffect::ModuleType(ModuleType::Css { - ty: CssModuleAssetType::Default, + ty: CssModuleType::Default, environment, lightningcss_features, }), From 834c41ef20861dffcd3511cdfabd9fd2dfa25343 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Fri, 13 Mar 2026 17:03:59 +0100 Subject: [PATCH 04/16] Respect `generateStaticParams` in instant navigation shell (#91316) When the instant navigation testing cookie is set, the debug static shell path previously called `getFallbackRouteParams()` which treats all dynamic segments as fallback params regardless of `generateStaticParams`. This caused two issues: - Root params (via `next/root-params`) errored with a `NEXT_STATIC_GEN_BAILOUT` 500 response because the root layout's param access returned a hanging promise, which was treated as uncached data accessed outside of ``. - Params defined in `generateStaticParams` were incorrectly excluded from the instant shell, shown behind Suspense fallbacks instead of resolving. The fix uses `prerenderInfo?.fallbackRouteParams` from the prerender manifest instead, which correctly distinguishes between statically known params (defined in `generateStaticParams`) and unknown params. For prerendered URLs, the outer guard (`!isPrerendered`) prevents the fallback path from being entered, so no fallback params are set and all params resolve normally. To make this work in dev mode, the dev server now populates `fallbackRouteParams` in the prerender manifest's dynamic route entries, which were previously left as `undefined`. Additionally, the `fallbackParams` request metadata is now overridden when rendering a debug static shell. Without this override, the staged rendering would use the smallest set of fallback params across all prerendered routes (set by `base-server.ts` for dev validation), which may omit fallback params for the current URL when a different value for the same param is defined in `generateStaticParams`. The override ensures the route-specific fallback params are used instead. --- packages/next/src/build/templates/app-page.ts | 53 ++--- .../next/src/server/dev/next-dev-server.ts | 12 +- .../default}/app/cookies-page/page.tsx | 0 .../app/dynamic-params/[slug]/page.tsx | 4 + .../app/full-prefetch-target/loading.tsx | 0 .../app/full-prefetch-target/page.tsx | 0 .../{ => fixtures/default}/app/layout.tsx | 0 .../{ => fixtures/default}/app/page.tsx | 10 +- .../app/runtime-prefetch-target/page.tsx | 0 .../default}/app/search-params-page/page.tsx | 0 .../default}/app/target-page/loading.tsx | 0 .../default}/app/target-page/page.tsx | 0 .../{ => fixtures/default}/next.config.js | 1 + .../root-params/app/[lang]/layout.tsx | 22 +++ .../fixtures/root-params/app/[lang]/page.tsx | 7 + .../fixtures/root-params/next.config.js | 12 ++ .../instant-navigation-testing-api.test.ts | 181 ++++++++++++------ 17 files changed, 219 insertions(+), 83 deletions(-) rename test/e2e/app-dir/instant-navigation-testing-api/{ => fixtures/default}/app/cookies-page/page.tsx (100%) rename test/e2e/app-dir/instant-navigation-testing-api/{ => fixtures/default}/app/dynamic-params/[slug]/page.tsx (88%) rename test/e2e/app-dir/instant-navigation-testing-api/{ => fixtures/default}/app/full-prefetch-target/loading.tsx (100%) rename test/e2e/app-dir/instant-navigation-testing-api/{ => fixtures/default}/app/full-prefetch-target/page.tsx (100%) rename test/e2e/app-dir/instant-navigation-testing-api/{ => fixtures/default}/app/layout.tsx (100%) rename test/e2e/app-dir/instant-navigation-testing-api/{ => fixtures/default}/app/page.tsx (76%) rename test/e2e/app-dir/instant-navigation-testing-api/{ => fixtures/default}/app/runtime-prefetch-target/page.tsx (100%) rename test/e2e/app-dir/instant-navigation-testing-api/{ => fixtures/default}/app/search-params-page/page.tsx (100%) rename test/e2e/app-dir/instant-navigation-testing-api/{ => fixtures/default}/app/target-page/loading.tsx (100%) rename test/e2e/app-dir/instant-navigation-testing-api/{ => fixtures/default}/app/target-page/page.tsx (100%) rename test/e2e/app-dir/instant-navigation-testing-api/{ => fixtures/default}/next.config.js (85%) create mode 100644 test/e2e/app-dir/instant-navigation-testing-api/fixtures/root-params/app/[lang]/layout.tsx create mode 100644 test/e2e/app-dir/instant-navigation-testing-api/fixtures/root-params/app/[lang]/page.tsx create mode 100644 test/e2e/app-dir/instant-navigation-testing-api/fixtures/root-params/next.config.js diff --git a/packages/next/src/build/templates/app-page.ts b/packages/next/src/build/templates/app-page.ts index 13d8590b0f338..9e8483d341ad7 100644 --- a/packages/next/src/build/templates/app-page.ts +++ b/packages/next/src/build/templates/app-page.ts @@ -938,19 +938,29 @@ export async function handler( : normalizedSrcPage const fallbackRouteParams = - // If we're in production and we have fallback route params, always - // use them for the fallback shell. - isProduction && prerenderInfo?.fallbackRouteParams + // In production or when debugging the static shell (e.g. instant + // navigation testing), use the prerender manifest's fallback + // route params which correctly identifies which params are + // unknown. Note: in dev, this block is only entered for + // non-prerendered URLs (guarded by the outer condition). + (isProduction || isDebugStaticShell) && + prerenderInfo?.fallbackRouteParams ? createOpaqueFallbackRouteParams( prerenderInfo.fallbackRouteParams ) - : // Otherwise, if we're debugging the fallback shell or the - // static shell, then we have to manually generate the - // fallback route params. - isDebugFallbackShell || isDebugStaticShell + : // When debugging the fallback shell, treat all params as + // fallback (simulating the worst-case shell). + isDebugFallbackShell ? getFallbackRouteParams(normalizedSrcPage, routeModule) : null + // When rendering a debug static shell, override the fallback + // params on the request so that the staged rendering correctly + // defers params that are not statically known. + if (isDebugStaticShell && fallbackRouteParams) { + addRequestMeta(req, 'fallbackParams', fallbackRouteParams) + } + // We use the response cache here to handle the revalidation and // management of the fallback shell. const fallbackResponse = await routeModule.handleResponse({ @@ -1154,27 +1164,24 @@ export async function handler( } const fallbackRouteParams = - // If we're in production and we have fallback route params, then we - // can use the manifest fallback route params if we need to render the - // fallback shell. - isProduction && - prerenderInfo?.fallbackRouteParams && - getRequestMeta(req, 'renderFallbackShell') + // In production or when debugging the static shell for a + // non-prerendered URL, use the prerender manifest's fallback route + // params which correctly identifies which params are unknown. + ((isProduction && getRequestMeta(req, 'renderFallbackShell')) || + (isDebugStaticShell && !isPrerendered)) && + prerenderInfo?.fallbackRouteParams ? createOpaqueFallbackRouteParams(prerenderInfo.fallbackRouteParams) - : // Otherwise, if we're debugging the fallback shell or the static - // shell, then we have to manually generate the fallback route - // params. - isDebugFallbackShell || isDebugStaticShell + : isDebugFallbackShell ? getFallbackRouteParams(normalizedSrcPage, routeModule) : null - // For staged dynamic rendering (cached navigations), pass the fallback - // params via request meta so the RequestStore knows which params to defer - // to the runtime stage. We don't pass them as fallbackRouteParams because - // that would replace actual param values with opaque placeholders during - // segment resolution. + // For staged dynamic rendering (Cached Navigations) and debug static + // shell rendering, pass the fallback params via request meta so the + // RequestStore knows which params to defer. We don't pass them as + // fallbackRouteParams because that would replace actual param values + // with opaque placeholders during segment resolution. if ( - isProduction && + (isProduction || isDebugStaticShell) && nextConfig.cacheComponents && !isPrerendered && prerenderInfo?.fallbackRouteParams diff --git a/packages/next/src/server/dev/next-dev-server.ts b/packages/next/src/server/dev/next-dev-server.ts index 2303b04ae7994..ae3c99723cfd4 100644 --- a/packages/next/src/server/dev/next-dev-server.ts +++ b/packages/next/src/server/dev/next-dev-server.ts @@ -909,6 +909,14 @@ export default class DevServer extends Server { existingManifest.routes[staticPath] = {} as any } + // Find the fallback route from the prerendered routes. This is + // the route whose pathname matches the page pattern (e.g. + // /dynamic-params/[slug]) and has fallback route params describing + // which params are unknown at build time. + const fallbackPrerenderedRoute = prerenderedRoutes?.find( + (route) => route.pathname === pathname + ) + existingManifest.dynamicRoutes[pathname] = { dataRoute: null, dataRouteRegex: null, @@ -917,8 +925,8 @@ export default class DevServer extends Server { fallbackExpire: undefined, fallbackHeaders: undefined, fallbackStatus: undefined, - fallbackRootParams: undefined, - fallbackRouteParams: undefined, + fallbackRootParams: fallbackPrerenderedRoute?.fallbackRootParams, + fallbackRouteParams: fallbackPrerenderedRoute?.fallbackRouteParams, fallbackSourceRoute: pathname, prefetchDataRoute: undefined, prefetchDataRouteRegex: undefined, diff --git a/test/e2e/app-dir/instant-navigation-testing-api/app/cookies-page/page.tsx b/test/e2e/app-dir/instant-navigation-testing-api/fixtures/default/app/cookies-page/page.tsx similarity index 100% rename from test/e2e/app-dir/instant-navigation-testing-api/app/cookies-page/page.tsx rename to test/e2e/app-dir/instant-navigation-testing-api/fixtures/default/app/cookies-page/page.tsx diff --git a/test/e2e/app-dir/instant-navigation-testing-api/app/dynamic-params/[slug]/page.tsx b/test/e2e/app-dir/instant-navigation-testing-api/fixtures/default/app/dynamic-params/[slug]/page.tsx similarity index 88% rename from test/e2e/app-dir/instant-navigation-testing-api/app/dynamic-params/[slug]/page.tsx rename to test/e2e/app-dir/instant-navigation-testing-api/fixtures/default/app/dynamic-params/[slug]/page.tsx index e973c8bd63d23..9950f17c2db28 100644 --- a/test/e2e/app-dir/instant-navigation-testing-api/app/dynamic-params/[slug]/page.tsx +++ b/test/e2e/app-dir/instant-navigation-testing-api/fixtures/default/app/dynamic-params/[slug]/page.tsx @@ -1,5 +1,9 @@ import { Suspense } from 'react' +export function generateStaticParams() { + return [{ slug: 'hello' }] +} + export default function DynamicParamsPage({ params, }: { diff --git a/test/e2e/app-dir/instant-navigation-testing-api/app/full-prefetch-target/loading.tsx b/test/e2e/app-dir/instant-navigation-testing-api/fixtures/default/app/full-prefetch-target/loading.tsx similarity index 100% rename from test/e2e/app-dir/instant-navigation-testing-api/app/full-prefetch-target/loading.tsx rename to test/e2e/app-dir/instant-navigation-testing-api/fixtures/default/app/full-prefetch-target/loading.tsx diff --git a/test/e2e/app-dir/instant-navigation-testing-api/app/full-prefetch-target/page.tsx b/test/e2e/app-dir/instant-navigation-testing-api/fixtures/default/app/full-prefetch-target/page.tsx similarity index 100% rename from test/e2e/app-dir/instant-navigation-testing-api/app/full-prefetch-target/page.tsx rename to test/e2e/app-dir/instant-navigation-testing-api/fixtures/default/app/full-prefetch-target/page.tsx diff --git a/test/e2e/app-dir/instant-navigation-testing-api/app/layout.tsx b/test/e2e/app-dir/instant-navigation-testing-api/fixtures/default/app/layout.tsx similarity index 100% rename from test/e2e/app-dir/instant-navigation-testing-api/app/layout.tsx rename to test/e2e/app-dir/instant-navigation-testing-api/fixtures/default/app/layout.tsx diff --git a/test/e2e/app-dir/instant-navigation-testing-api/app/page.tsx b/test/e2e/app-dir/instant-navigation-testing-api/fixtures/default/app/page.tsx similarity index 76% rename from test/e2e/app-dir/instant-navigation-testing-api/app/page.tsx rename to test/e2e/app-dir/instant-navigation-testing-api/fixtures/default/app/page.tsx index 28c45abe43b28..060070218cafa 100644 --- a/test/e2e/app-dir/instant-navigation-testing-api/app/page.tsx +++ b/test/e2e/app-dir/instant-navigation-testing-api/fixtures/default/app/page.tsx @@ -23,9 +23,12 @@ export default function HomePage() { Go to cookies page - + Go to dynamic params page + + Go to static dynamic params page + Go to search params page @@ -36,9 +39,12 @@ export default function HomePage() { Go to cookies page (MPA) - + Go to dynamic params page (MPA) + + Go to static dynamic params page (MPA) + Go to search params page (MPA) diff --git a/test/e2e/app-dir/instant-navigation-testing-api/app/runtime-prefetch-target/page.tsx b/test/e2e/app-dir/instant-navigation-testing-api/fixtures/default/app/runtime-prefetch-target/page.tsx similarity index 100% rename from test/e2e/app-dir/instant-navigation-testing-api/app/runtime-prefetch-target/page.tsx rename to test/e2e/app-dir/instant-navigation-testing-api/fixtures/default/app/runtime-prefetch-target/page.tsx diff --git a/test/e2e/app-dir/instant-navigation-testing-api/app/search-params-page/page.tsx b/test/e2e/app-dir/instant-navigation-testing-api/fixtures/default/app/search-params-page/page.tsx similarity index 100% rename from test/e2e/app-dir/instant-navigation-testing-api/app/search-params-page/page.tsx rename to test/e2e/app-dir/instant-navigation-testing-api/fixtures/default/app/search-params-page/page.tsx diff --git a/test/e2e/app-dir/instant-navigation-testing-api/app/target-page/loading.tsx b/test/e2e/app-dir/instant-navigation-testing-api/fixtures/default/app/target-page/loading.tsx similarity index 100% rename from test/e2e/app-dir/instant-navigation-testing-api/app/target-page/loading.tsx rename to test/e2e/app-dir/instant-navigation-testing-api/fixtures/default/app/target-page/loading.tsx diff --git a/test/e2e/app-dir/instant-navigation-testing-api/app/target-page/page.tsx b/test/e2e/app-dir/instant-navigation-testing-api/fixtures/default/app/target-page/page.tsx similarity index 100% rename from test/e2e/app-dir/instant-navigation-testing-api/app/target-page/page.tsx rename to test/e2e/app-dir/instant-navigation-testing-api/fixtures/default/app/target-page/page.tsx diff --git a/test/e2e/app-dir/instant-navigation-testing-api/next.config.js b/test/e2e/app-dir/instant-navigation-testing-api/fixtures/default/next.config.js similarity index 85% rename from test/e2e/app-dir/instant-navigation-testing-api/next.config.js rename to test/e2e/app-dir/instant-navigation-testing-api/fixtures/default/next.config.js index 481481e3e8729..cc36dd3202b00 100644 --- a/test/e2e/app-dir/instant-navigation-testing-api/next.config.js +++ b/test/e2e/app-dir/instant-navigation-testing-api/fixtures/default/next.config.js @@ -6,6 +6,7 @@ const nextConfig = { experimental: { // Enable the testing API in production builds for these tests exposeTestingApiInProductionBuild: true, + instantNavigationDevToolsToggle: true, }, } diff --git a/test/e2e/app-dir/instant-navigation-testing-api/fixtures/root-params/app/[lang]/layout.tsx b/test/e2e/app-dir/instant-navigation-testing-api/fixtures/root-params/app/[lang]/layout.tsx new file mode 100644 index 0000000000000..dfe9c4fce4a06 --- /dev/null +++ b/test/e2e/app-dir/instant-navigation-testing-api/fixtures/root-params/app/[lang]/layout.tsx @@ -0,0 +1,22 @@ +import { lang } from 'next/root-params' +import { ReactNode } from 'react' + +export async function generateStaticParams() { + return [{ lang: 'en' }] +} + +export default async function RootLayout({ + children, +}: { + children: ReactNode +}) { + const currentLang = await lang() + return ( + + +

lang: {currentLang}

+ {children} + + + ) +} diff --git a/test/e2e/app-dir/instant-navigation-testing-api/fixtures/root-params/app/[lang]/page.tsx b/test/e2e/app-dir/instant-navigation-testing-api/fixtures/root-params/app/[lang]/page.tsx new file mode 100644 index 0000000000000..f122dc039eb40 --- /dev/null +++ b/test/e2e/app-dir/instant-navigation-testing-api/fixtures/root-params/app/[lang]/page.tsx @@ -0,0 +1,7 @@ +export default function HomePage() { + return ( +
+

Root Params Test

+
+ ) +} diff --git a/test/e2e/app-dir/instant-navigation-testing-api/fixtures/root-params/next.config.js b/test/e2e/app-dir/instant-navigation-testing-api/fixtures/root-params/next.config.js new file mode 100644 index 0000000000000..95061b713b5c9 --- /dev/null +++ b/test/e2e/app-dir/instant-navigation-testing-api/fixtures/root-params/next.config.js @@ -0,0 +1,12 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + cacheComponents: true, + experimental: { + exposeTestingApiInProductionBuild: true, + instantNavigationDevToolsToggle: true, + }, +} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/instant-navigation-testing-api/instant-navigation-testing-api.test.ts b/test/e2e/app-dir/instant-navigation-testing-api/instant-navigation-testing-api.test.ts index 3e9809e6bf6cf..3792fce9736e7 100644 --- a/test/e2e/app-dir/instant-navigation-testing-api/instant-navigation-testing-api.test.ts +++ b/test/e2e/app-dir/instant-navigation-testing-api/instant-navigation-testing-api.test.ts @@ -22,51 +22,53 @@ * API in production mode for testing purposes. */ -import { nextTestSetup } from 'e2e-utils' +import { NextInstance, nextTestSetup } from 'e2e-utils' import { instant } from '@next/playwright' import type * as Playwright from 'playwright' +import { join } from 'node:path' + +/** + * Opens a browser and returns the underlying Playwright Page instance. + * + * We use this pattern so our test assertions look as close as possible to + * what users would write with the actual Playwright helper package. The + * Next.js test infra wraps Playwright with its own BrowserInterface, but + * the Instant Navigation Testing API is designed to work with native Playwright. + */ +async function openPage( + next: NextInstance, + url: string, + options?: { cookies?: Array<{ name: string; value: string }> } +): Promise { + let page: Playwright.Page + await next.browser(url, { + beforePageLoad(p) { + page = p + if (options?.cookies) { + const { hostname } = new URL(next.url) + p.context().addCookies( + options.cookies.map((c) => ({ + ...c, + domain: hostname, + path: '/', + })) + ) + } + }, + }) + return page! +} describe('instant-navigation-testing-api', () => { const { next } = nextTestSetup({ - files: __dirname, + files: join(__dirname, 'fixtures', 'default'), // Skip deployment tests because the exposeTestingApiInProductionBuild flag // doesn't exist in the production version of Next.js yet skipDeployment: true, }) - /** - * Opens a browser and returns the underlying Playwright Page instance. - * - * We use this pattern so our test assertions look as close as possible to - * what users would write with the actual Playwright helper package. The - * Next.js test infra wraps Playwright with its own BrowserInterface, but - * the Instant Navigation Testing API is designed to work with native Playwright. - */ - async function openPage( - url: string, - options?: { cookies?: Array<{ name: string; value: string }> } - ): Promise { - let page: Playwright.Page - await next.browser(url, { - beforePageLoad(p) { - page = p - if (options?.cookies) { - const { hostname } = new URL(next.url) - p.context().addCookies( - options.cookies.map((c) => ({ - ...c, - domain: hostname, - path: '/', - })) - ) - } - }, - }) - return page! - } - it('renders prefetched loading shell instantly during navigation', async () => { - const page = await openPage('/') + const page = await openPage(next, '/') await instant(page, async () => { await page.click('#link-to-target') @@ -92,7 +94,7 @@ describe('instant-navigation-testing-api', () => { }) it('renders runtime-prefetched content instantly during navigation', async () => { - const page = await openPage('/') + const page = await openPage(next, '/') await instant(page, async () => { await page.click('#link-to-runtime-prefetch') @@ -132,7 +134,7 @@ describe('instant-navigation-testing-api', () => { }) it('renders full prefetch content instantly when prefetch={true}', async () => { - const page = await openPage('/') + const page = await openPage(next, '/') await instant(page, async () => { await page.click('#link-to-full-prefetch') @@ -148,7 +150,7 @@ describe('instant-navigation-testing-api', () => { }) it('throws when attempting to nest instant scopes', async () => { - const page = await openPage('/') + const page = await openPage(next, '/') await instant(page, async () => { // Attempt to acquire the lock again by nesting instant() calls. @@ -166,7 +168,7 @@ describe('instant-navigation-testing-api', () => { }) it('renders static shell on page reload', async () => { - const page = await openPage('/target-page') + const page = await openPage(next, '/target-page') // Wait for the page to fully load with dynamic content const dynamicContent = page.locator('[data-testid="dynamic-content"]') @@ -195,7 +197,7 @@ describe('instant-navigation-testing-api', () => { }) it('renders static shell on MPA navigation via plain anchor', async () => { - const page = await openPage('/') + const page = await openPage(next, '/') await instant(page, async () => { // Navigate using a plain anchor (triggers full page load) @@ -222,7 +224,7 @@ describe('instant-navigation-testing-api', () => { }) it('reload followed by MPA navigation, both block dynamic data', async () => { - const page = await openPage('/') + const page = await openPage(next, '/') await instant(page, async () => { // Reload the page while in instant mode @@ -253,7 +255,7 @@ describe('instant-navigation-testing-api', () => { }) it('successive MPA navigations within instant scope', async () => { - const page = await openPage('/') + const page = await openPage(next, '/') await instant(page, async () => { // First MPA navigation: reload @@ -304,7 +306,7 @@ describe('instant-navigation-testing-api', () => { // NOT be present. describe('runtime params are excluded from instant shell', () => { it('does not include cookie values in instant shell during client navigation', async () => { - const page = await openPage('/', { + const page = await openPage(next, '/', { cookies: [{ name: 'testCookie', value: 'hello' }], }) @@ -331,7 +333,7 @@ describe('instant-navigation-testing-api', () => { }) it('does not include dynamic param values in instant shell during client navigation', async () => { - const page = await openPage('/') + const page = await openPage(next, '/') await instant(page, async () => { await page.click('#link-to-dynamic-params') @@ -352,11 +354,11 @@ describe('instant-navigation-testing-api', () => { // After exiting instant scope, param value streams in const paramValue = page.locator('[data-testid="param-value"]') await paramValue.waitFor({ state: 'visible' }) - expect(await paramValue.textContent()).toContain('slug: hello') + expect(await paramValue.textContent()).toContain('slug: unknown') }) it('does not include search param values in instant shell during client navigation', async () => { - const page = await openPage('/') + const page = await openPage(next, '/') await instant(page, async () => { await page.click('#link-to-search-params') @@ -385,7 +387,7 @@ describe('instant-navigation-testing-api', () => { }) it('does not include cookie values in instant shell during page load', async () => { - const page = await openPage('/', { + const page = await openPage(next, '/', { cookies: [{ name: 'testCookie', value: 'hello' }], }) @@ -412,7 +414,7 @@ describe('instant-navigation-testing-api', () => { }) it('does not include dynamic param values in instant shell during page load', async () => { - const page = await openPage('/') + const page = await openPage(next, '/') await instant(page, async () => { await page.click('#plain-link-to-dynamic-params') @@ -433,11 +435,11 @@ describe('instant-navigation-testing-api', () => { // After exiting instant scope, param value streams in const paramValue = page.locator('[data-testid="param-value"]') await paramValue.waitFor({ state: 'visible', timeout: 10000 }) - expect(await paramValue.textContent()).toContain('slug: hello') + expect(await paramValue.textContent()).toContain('slug: unknown') }) it('does not include search param values in instant shell during page load', async () => { - const page = await openPage('/') + const page = await openPage(next, '/') await instant(page, async () => { await page.click('#plain-link-to-search-params') @@ -466,12 +468,56 @@ describe('instant-navigation-testing-api', () => { }) }) + describe('statically generated params are included in instant shell', () => { + it('includes statically generated param values in instant shell during client navigation', async () => { + const page = await openPage(next, '/') + + await instant(page, async () => { + await page.click('#link-to-static-dynamic-params') + + // Static page title is visible + const title = page.locator('[data-testid="dynamic-params-title"]') + await title.waitFor({ state: 'visible' }) + + // Param value IS in the shell (slug 'hello' is in generateStaticParams) + const paramValue = page.locator('[data-testid="param-value"]') + await paramValue.waitFor({ state: 'visible' }) + expect(await paramValue.textContent()).toContain('slug: hello') + + // Suspense fallback is NOT visible + const fallback = page.locator('[data-testid="params-fallback"]') + expect(await fallback.count()).toBe(0) + }) + }) + + it('includes statically generated param values in instant shell during page load', async () => { + const page = await openPage(next, '/') + + await instant(page, async () => { + await page.click('#plain-link-to-static-dynamic-params') + + // Static page title is visible + const title = page.locator('[data-testid="dynamic-params-title"]') + await title.waitFor({ state: 'visible' }) + + // Param value IS in the shell (slug 'hello' is in generateStaticParams) + const paramValue = page.locator('[data-testid="param-value"]') + await paramValue.waitFor({ state: 'visible' }) + expect(await paramValue.textContent()).toContain('slug: hello') + + // Suspense fallback is NOT visible + const fallback = page.locator('[data-testid="params-fallback"]') + expect(await fallback.count()).toBe(0) + }) + }) + }) + // In dev mode, hover/intent-based prefetches should not send requests // that produce stale segment data. If a hover prefetch caches the route // with resolved runtime data before the instant lock is acquired, params // will leak into the shell when instant mode is later enabled. it('does not leak runtime data from hover prefetch into instant shell', async () => { - const page = await openPage('/') + const page = await openPage(next, '/') // Hover over the dynamic params link to trigger an intent prefetch await page.hover('#link-to-dynamic-params') @@ -500,11 +546,11 @@ describe('instant-navigation-testing-api', () => { // After exiting instant scope, param value streams in const paramValue = page.locator('[data-testid="param-value"]') await paramValue.waitFor({ state: 'visible' }) - expect(await paramValue.textContent()).toContain('slug: hello') + expect(await paramValue.textContent()).toContain('slug: unknown') }) it('subsequent navigations after instant scope are not locked', async () => { - const page = await openPage('/') + const page = await openPage(next, '/') // First, do an MPA navigation within an instant scope await instant(page, async () => { @@ -536,7 +582,7 @@ describe('instant-navigation-testing-api', () => { }) it('throws descriptive error on fresh page without baseURL', async () => { - const page = await openPage('/') + const page = await openPage(next, '/') const freshPage = await page.context().newPage() try { let caughtError: Error | undefined @@ -591,7 +637,7 @@ describe('instant-navigation-testing-api', () => { }) it('sets cookie before first navigation when using baseURL', async () => { - const page = await openPage('/') + const page = await openPage(next, '/') const freshPage = await page.context().newPage() try { await instant( @@ -634,7 +680,7 @@ describe('instant-navigation-testing-api', () => { }) it('clears cookie after instant scope exits', async () => { - const page = await openPage('/') + const page = await openPage(next, '/') await instant(page, async () => { await page.reload() @@ -651,7 +697,7 @@ describe('instant-navigation-testing-api', () => { }) it('clears cookie even when callback throws', async () => { - const page = await openPage('/') + const page = await openPage(next, '/') await expect( instant(page, async () => { @@ -667,3 +713,26 @@ describe('instant-navigation-testing-api', () => { expect(instantCookie).toBeUndefined() }) }) + +describe('instant-navigation-testing-api - root params', () => { + const { next } = nextTestSetup({ + files: join(__dirname, 'fixtures', 'root-params'), + skipDeployment: true, + }) + + it('includes root param in instant shell', async () => { + const page = await openPage(next, '/en') + + const langValue = page.locator('[data-testid="lang-value"]') + await langValue.waitFor({ state: 'visible' }) + expect(await langValue.textContent()).toContain('lang: en') + + await instant(page, async () => { + await page.reload() + + // The root param value is still visible (it's statically known) + await langValue.waitFor({ state: 'visible' }) + expect(await langValue.textContent()).toContain('lang: en') + }) + }) +}) From 0e6ab3f5487a49ec0ff4d7ab39dd1a6afec73cde Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Fri, 13 Mar 2026 12:09:25 -0400 Subject: [PATCH 05/16] [Prefetch Inlining] Generate size-based hints on server (#90891) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part 1 of 2. This commit adds the server-side infrastructure for size-based segment bundling but does not change any observable behavior. The client-side changes that actually consume bundled responses are in the next commit. At build time, a measurement pass renders each segment's prefetch response, measures its gzip size, and decides which segments should be bundled together vs fetched separately. The decisions are persisted to a manifest and embedded into the route tree prefetch response so the client can act on them. The decisions are computed once at build and remain fixed for the lifetime of the deployment. They are not recomputed during ISR/revalidation — if they could change, the client would need to re-fetch the route tree after every revalidation, defeating the purpose of caching it independently. Refer to the next commit for a full description of the design and motivation. ## Config experimental.prefetchInlining accepts either a boolean or an object with threshold overrides (maxSize, maxBundleSize). When true, the default thresholds are used (2KB per-segment, 10KB total budget). The auto behavior will eventually become the default. The config will remain available for overriding thresholds. --- packages/next/src/build/index.ts | 22 + packages/next/src/build/templates/app-page.ts | 2 +- .../next/src/build/templates/edge-ssr-app.ts | 2 +- .../create-initial-router-state.ts | 7 + packages/next/src/export/routes/app-page.ts | 3 + packages/next/src/export/routes/types.ts | 2 + .../next/src/server/app-render/app-render.tsx | 72 +++- .../app-render/collect-segment-data.tsx | 383 +++++++++++++++++- ...te-flight-router-state-from-loader-tree.ts | 25 ++ .../next/src/server/app-render/entry-base.ts | 5 +- packages/next/src/server/app-render/types.ts | 10 +- .../walk-tree-with-flight-router-state.tsx | 14 + packages/next/src/server/base-server.ts | 1 + packages/next/src/server/config-schema.ts | 10 +- packages/next/src/server/config-shared.ts | 20 +- packages/next/src/server/config.ts | 18 + .../incremental-cache/file-system-cache.ts | 2 + packages/next/src/server/next-server.ts | 28 ++ packages/next/src/server/render-result.ts | 8 + .../next/src/shared/lib/app-router-types.ts | 29 ++ packages/next/src/shared/lib/constants.ts | 1 + .../app/dynamic/[slug]/page.tsx | 0 .../max-prefetch-inlining/app/layout.tsx | 8 + .../max-prefetch-inlining/app/page.tsx | 28 ++ .../app/shared/a/b/c/page.tsx | 0 .../app/shared/a/b/layout.tsx | 0 .../app/shared/a/d/e/page.tsx | 0 .../app/shared/a/d/layout.tsx | 0 .../app/shared/a/layout.tsx | 0 .../app/shared/layout.tsx | 0 .../components/link-accordion.tsx | 31 ++ .../max-prefetch-inlining.test.ts | 146 +++++++ .../max-prefetch-inlining/next.config.js | 19 + .../prefetch-inlining/app/page.tsx | 26 +- .../app/test-deep/a/b/c/layout.tsx | 4 + .../app/test-deep/a/b/c/page.tsx | 3 + .../app/test-deep/a/b/layout.tsx | 4 + .../app/test-deep/a/layout.tsx | 4 + .../app/test-deep/layout.tsx | 4 + .../app/test-dynamic/[slug]/layout.tsx | 25 ++ .../app/test-dynamic/[slug]/page.tsx | 12 + .../app/test-outlined/layout.tsx | 11 + .../app/test-outlined/page.tsx | 3 + .../app/test-parallel/@sidebar/page.tsx | 3 + .../app/test-parallel/layout.tsx | 15 + .../app/test-parallel/page.tsx | 3 + .../large-middle/after/layout.tsx | 4 + .../test-restart/large-middle/after/page.tsx | 3 + .../app/test-restart/large-middle/layout.tsx | 11 + .../app/test-restart/layout.tsx | 4 + .../app/test-small-chain/layout.tsx | 8 + .../app/test-small-chain/page.tsx | 3 + .../components/link-accordion.tsx | 4 +- .../components/no-inline.tsx | 23 ++ .../prefetch-inlining.test.ts | 363 +++++++++++------ 55 files changed, 1254 insertions(+), 182 deletions(-) rename test/e2e/app-dir/segment-cache/{prefetch-inlining => max-prefetch-inlining}/app/dynamic/[slug]/page.tsx (100%) create mode 100644 test/e2e/app-dir/segment-cache/max-prefetch-inlining/app/layout.tsx create mode 100644 test/e2e/app-dir/segment-cache/max-prefetch-inlining/app/page.tsx rename test/e2e/app-dir/segment-cache/{prefetch-inlining => max-prefetch-inlining}/app/shared/a/b/c/page.tsx (100%) rename test/e2e/app-dir/segment-cache/{prefetch-inlining => max-prefetch-inlining}/app/shared/a/b/layout.tsx (100%) rename test/e2e/app-dir/segment-cache/{prefetch-inlining => max-prefetch-inlining}/app/shared/a/d/e/page.tsx (100%) rename test/e2e/app-dir/segment-cache/{prefetch-inlining => max-prefetch-inlining}/app/shared/a/d/layout.tsx (100%) rename test/e2e/app-dir/segment-cache/{prefetch-inlining => max-prefetch-inlining}/app/shared/a/layout.tsx (100%) rename test/e2e/app-dir/segment-cache/{prefetch-inlining => max-prefetch-inlining}/app/shared/layout.tsx (100%) create mode 100644 test/e2e/app-dir/segment-cache/max-prefetch-inlining/components/link-accordion.tsx create mode 100644 test/e2e/app-dir/segment-cache/max-prefetch-inlining/max-prefetch-inlining.test.ts create mode 100644 test/e2e/app-dir/segment-cache/max-prefetch-inlining/next.config.js create mode 100644 test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-deep/a/b/c/layout.tsx create mode 100644 test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-deep/a/b/c/page.tsx create mode 100644 test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-deep/a/b/layout.tsx create mode 100644 test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-deep/a/layout.tsx create mode 100644 test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-deep/layout.tsx create mode 100644 test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-dynamic/[slug]/layout.tsx create mode 100644 test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-dynamic/[slug]/page.tsx create mode 100644 test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-outlined/layout.tsx create mode 100644 test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-outlined/page.tsx create mode 100644 test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-parallel/@sidebar/page.tsx create mode 100644 test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-parallel/layout.tsx create mode 100644 test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-parallel/page.tsx create mode 100644 test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-restart/large-middle/after/layout.tsx create mode 100644 test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-restart/large-middle/after/page.tsx create mode 100644 test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-restart/large-middle/layout.tsx create mode 100644 test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-restart/layout.tsx create mode 100644 test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-small-chain/layout.tsx create mode 100644 test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-small-chain/page.tsx create mode 100644 test/e2e/app-dir/segment-cache/prefetch-inlining/components/no-inline.tsx diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index 9d04b2dc19c65..ac7039edccb19 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -7,6 +7,7 @@ import type { import type { MiddlewareManifest } from './webpack/plugins/middleware-plugin' import type { ActionManifest } from './webpack/plugins/flight-client-entry-plugin' import type { CacheControl, Revalidate } from '../server/lib/cache-control' +import type { PrefetchHints } from '../shared/lib/app-router-types' import '../lib/setup-exception-listeners' @@ -61,6 +62,7 @@ import { IMAGES_MANIFEST, PAGES_MANIFEST, PHASE_PRODUCTION_BUILD, + PREFETCH_HINTS, PRERENDER_MANIFEST, REACT_LOADABLE_MANIFEST, ROUTES_MANIFEST, @@ -1853,6 +1855,7 @@ export default async function build( SERVER_DIRECTORY, SERVER_REFERENCE_MANIFEST + '.json' ), + path.join(SERVER_DIRECTORY, PREFETCH_HINTS), ] : []), ...(pagesDir && bundler !== Bundler.Turbopack @@ -2739,6 +2742,11 @@ export default async function build( preview: previewProps, } + // Accumulate per-route segment inlining decisions for + // prefetch-hints.json. First-writer-wins: if multiple param + // combinations exist for the same route pattern, use the first one. + const prefetchHints: Record = {} + const tbdPrerenderRoutes: string[] = [] const { i18n } = config @@ -3194,6 +3202,11 @@ export default async function build( initialCacheControl: cacheControl, }) + // Collect prefetch hints (first-writer-wins per page) + if (metadata.prefetchHints && !(page in prefetchHints)) { + prefetchHints[page] = metadata.prefetchHints + } + if (cacheControl.revalidate !== 0) { const normalizedRoute = normalizePagePath(route.pathname) @@ -3369,6 +3382,11 @@ export default async function build( builtSegmentDataRoute ) } + + // Collect prefetch hints (first-writer-wins per page) + if (metadata?.prefetchHints && !(page in prefetchHints)) { + prefetchHints[page] = metadata.prefetchHints + } } pageInfos.set(route.pathname, { @@ -3958,6 +3976,10 @@ export default async function build( config.experimental.allowedRevalidateHeaderKeys await writePrerenderManifest(distDir, prerenderManifest) + await writeManifest( + path.join(distDir, SERVER_DIRECTORY, PREFETCH_HINTS), + prefetchHints + ) await writeClientSsgManifest(prerenderManifest, { distDir, buildId, diff --git a/packages/next/src/build/templates/app-page.ts b/packages/next/src/build/templates/app-page.ts index 9e8483d341ad7..8d8e5e1be79b5 100644 --- a/packages/next/src/build/templates/app-page.ts +++ b/packages/next/src/build/templates/app-page.ts @@ -738,7 +738,7 @@ export async function handler( nextConfig.experimental.optimisticRouting ), inlineCss: Boolean(nextConfig.experimental.inlineCss), - prefetchInlining: Boolean(nextConfig.experimental.prefetchInlining), + prefetchInlining: nextConfig.experimental.prefetchInlining ?? false, authInterrupts: Boolean(nextConfig.experimental.authInterrupts), cachedNavigations: Boolean( nextConfig.experimental.cachedNavigations diff --git a/packages/next/src/build/templates/edge-ssr-app.ts b/packages/next/src/build/templates/edge-ssr-app.ts index 3dbfc809b29b1..9bcb5137353fa 100644 --- a/packages/next/src/build/templates/edge-ssr-app.ts +++ b/packages/next/src/build/templates/edge-ssr-app.ts @@ -163,7 +163,7 @@ async function requestHandler( dynamicOnHover: Boolean(nextConfig.experimental.dynamicOnHover), optimisticRouting: Boolean(nextConfig.experimental.optimisticRouting), inlineCss: Boolean(nextConfig.experimental.inlineCss), - prefetchInlining: Boolean(nextConfig.experimental.prefetchInlining), + prefetchInlining: nextConfig.experimental.prefetchInlining ?? false, authInterrupts: Boolean(nextConfig.experimental.authInterrupts), cachedNavigations: Boolean(nextConfig.experimental.cachedNavigations), clientTraceMetadata: diff --git a/packages/next/src/client/components/router-reducer/create-initial-router-state.ts b/packages/next/src/client/components/router-reducer/create-initial-router-state.ts index 4b4dc1bef477f..33f8adb64314c 100644 --- a/packages/next/src/client/components/router-reducer/create-initial-router-state.ts +++ b/packages/next/src/client/components/router-reducer/create-initial-router-state.ts @@ -69,6 +69,13 @@ export function createInitialRouterState({ // NOTE: The metadataVaryPath isn't used for anything currently because the // head is embedded into the CacheNode tree, but eventually we'll lift it out // and store it on the top-level state object. + // + // TODO: For statically-generated-at-build-time HTML pages, the + // FlightRouterState baked into the initial RSC payload won't have the + // correct segment inlining hints (ParentInlinedIntoSelf, InlinedIntoChild) + // because those are computed after the pre-render. The client will need to + // fetch the correct hints from the route tree prefetch (/_tree) response + // before acting on inlining decisions. const acc = { metadataVaryPath: null } const initialRouteTree = convertRootFlightRouterStateToRouteTree( initialTree, diff --git a/packages/next/src/export/routes/app-page.ts b/packages/next/src/export/routes/app-page.ts index 73e8a473298eb..b027703e8b254 100644 --- a/packages/next/src/export/routes/app-page.ts +++ b/packages/next/src/export/routes/app-page.ts @@ -100,6 +100,7 @@ export async function exportAppPage( fetchTags, fetchMetrics, segmentData, + prefetchHints, renderResumeDataCache, } = metadata @@ -218,6 +219,7 @@ export async function exportAppPage( headers, postponed, segmentPaths, + prefetchHints, } fileWriter.append( @@ -231,6 +233,7 @@ export async function exportAppPage( ? meta : { segmentPaths: meta.segmentPaths, + prefetchHints: meta.prefetchHints, }, hasEmptyStaticShell: Boolean(postponed) && html === '', hasPostponed: Boolean(postponed), diff --git a/packages/next/src/export/routes/types.ts b/packages/next/src/export/routes/types.ts index f8ea7eeec9352..f54e9f2cb54a3 100644 --- a/packages/next/src/export/routes/types.ts +++ b/packages/next/src/export/routes/types.ts @@ -1,8 +1,10 @@ import type { OutgoingHttpHeaders } from 'node:http' +import type { PrefetchHints } from '../../shared/lib/app-router-types' export type RouteMetadata = { status: number | undefined headers: OutgoingHttpHeaders | undefined postponed: string | undefined segmentPaths: Array | undefined + prefetchHints: PrefetchHints | undefined } diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 992fa59a301ca..5da06a40b45d8 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -11,6 +11,7 @@ import type { FlightData, InitialRSCPayload, FlightDataPath, + PrefetchHints, } from '../../shared/lib/app-router-types' import type { Readable } from 'node:stream' import { @@ -618,6 +619,7 @@ async function generateDynamicRSCPayload( rootLayoutIncluded: false, preloadCallbacks, MetadataOutlet, + hintTree: ctx.renderOpts.prefetchHints?.[ctx.pagePath] ?? null, }) ).map((path) => path.slice(1)) // remove the '' (root) segment } @@ -1667,8 +1669,10 @@ async function getRSCPayload( workStore, } = ctx + const hints = ctx.renderOpts.prefetchHints?.[ctx.pagePath] ?? null const initialTree = await createFlightRouterStateFromLoaderTree( tree, + hints, getDynamicParamFromSegment, query ) @@ -1848,8 +1852,10 @@ async function getErrorRSCPayload( createElement(Metadata, null) ) + const errorHints = ctx.renderOpts.prefetchHints?.[ctx.pagePath] ?? null const initialTree = await createFlightRouterStateFromLoaderTree( tree, + errorHints, getDynamicParamFromSegment, query ) @@ -6320,11 +6326,13 @@ async function prerenderToStream( ? metadata.flightData.subarray(1) : metadata.flightData - metadata.segmentData = await collectSegmentData( + await collectSegmentData( flightData, finalServerPrerenderStore, ComponentMod, - renderOpts + renderOpts, + ctx.pagePath, + metadata ) if (serverIsDynamic) { @@ -6574,11 +6582,13 @@ async function prerenderToStream( if (shouldGenerateStaticFlightData(workStore)) { metadata.flightData = flightData - metadata.segmentData = await collectSegmentData( + await collectSegmentData( flightData, ssrPrerenderStore, ComponentMod, - renderOpts + renderOpts, + ctx.pagePath, + metadata ) } @@ -6783,11 +6793,13 @@ async function prerenderToStream( if (shouldGenerateStaticFlightData(workStore)) { const flightData = await streamToBuffer(reactServerResult.asStream()) metadata.flightData = flightData - metadata.segmentData = await collectSegmentData( + await collectSegmentData( flightData, prerenderLegacyStore, ComponentMod, - renderOpts + renderOpts, + ctx.pagePath, + metadata ) } @@ -6951,11 +6963,13 @@ async function prerenderToStream( reactServerPrerenderResult.asStream() ) metadata.flightData = flightData - metadata.segmentData = await collectSegmentData( + await collectSegmentData( flightData, prerenderLegacyStore, ComponentMod, - renderOpts + renderOpts, + ctx.pagePath, + metadata ) } @@ -7075,8 +7089,10 @@ async function collectSegmentData( fullPageDataBuffer: Buffer, prerenderStore: PrerenderStore, ComponentMod: AppPageModule, - renderOpts: RenderOpts -): Promise | undefined> { + renderOpts: RenderOpts, + pagePath: string, + metadata: AppPageRenderResultMetadata +): Promise { // Per-segment prefetch data // // All of the segments for a page are generated simultaneously, including @@ -7107,13 +7123,45 @@ async function collectSegmentData( const selectStaleTime = createSelectStaleTime(renderOpts.experimental) const staleTime = selectStaleTime(prerenderStore.stale) - return await ComponentMod.collectSegmentData( + + // Resolve prefetch hints. At runtime (next start / ISR), the precomputed + // hints are already loaded from the prefetch-hints.json manifest. During + // build, compute them by measuring segment gzip sizes and write them to + // metadata so the build pipeline can persist them to the manifest. + let hints: PrefetchHints | null + const prefetchInlining = renderOpts.experimental.prefetchInlining + if (!prefetchInlining) { + hints = null + } else if (renderOpts.isBuildTimePrerendering) { + // Build time: compute fresh hints and store in metadata for the manifest. + hints = await ComponentMod.collectPrefetchHints( + fullPageDataBuffer, + staleTime, + clientModules, + serverConsumerManifest, + prefetchInlining.maxSize, + prefetchInlining.maxBundleSize + ) + metadata.prefetchHints = hints + } else { + // Runtime: use hints from the manifest. Never compute fresh hints + // during ISR/revalidation. + hints = renderOpts.prefetchHints?.[pagePath] ?? null + } + + // Pass the resolved hints so collectSegmentData can union them into + // the TreePrefetch. During the initial build the FlightRouterState in + // the buffer doesn't have inlining hints yet (they were just computed + // above), so we need to merge them in here. At runtime/ISR the hints + // are already embedded in the FlightRouterState, so this is null. + metadata.segmentData = await ComponentMod.collectSegmentData( renderOpts.cacheComponents, fullPageDataBuffer, staleTime, clientModules, serverConsumerManifest, - renderOpts.experimental.prefetchInlining + Boolean(renderOpts.experimental.prefetchInlining), + hints ) } diff --git a/packages/next/src/server/app-render/collect-segment-data.tsx b/packages/next/src/server/app-render/collect-segment-data.tsx index 15b40441478f1..d1a0552aa5610 100644 --- a/packages/next/src/server/app-render/collect-segment-data.tsx +++ b/packages/next/src/server/app-render/collect-segment-data.tsx @@ -5,7 +5,9 @@ import type { InitialRSCPayload, DynamicParamTypesShort, HeadData, + PrefetchHints, } from '../../shared/lib/app-router-types' +import { PrefetchHint } from '../../shared/lib/app-router-types' import { readVaryParams } from '../../shared/lib/segment-cache/vary-params-decoding' import { PAGE_SEGMENT_KEY } from '../../shared/lib/segment' import type { ManifestNode } from '../../build/webpack/plugins/flight-manifest-plugin' @@ -93,9 +95,8 @@ export type SegmentPrefetch = { * slots so all segments for a route can be bundled into a single response. * * This is a separate type from SegmentPrefetch because the inlined flow is - * temporary — eventually inlining will be the default behavior (controlled by - * a size heuristic rather than a boolean flag), and the per-segment and inlined - * paths will merge. + * still gated behind a feature flag. Eventually inlining will always be + * enabled, and the per-segment and inlined paths will merge. */ export type InlinedSegmentPrefetch = { segment: SegmentPrefetch @@ -141,13 +142,42 @@ function onSegmentPrerenderError(error: unknown) { } } +/** + * Extract the FlightRouterState, seed data, and head from a prerendered + * InitialRSCPayload. Returns null if the payload doesn't match the expected + * shape (single path with 3 elements). + */ +function extractFlightData(initialRSCPayload: InitialRSCPayload): { + buildId: string | undefined + flightRouterState: FlightRouterState + seedData: CacheNodeSeedData + head: HeadData +} | null { + const flightDataPaths = initialRSCPayload.f + // FlightDataPath is an unsound type, hence the additional checks. + if (flightDataPaths.length !== 1 && flightDataPaths[0].length !== 3) { + console.error( + 'Internal Next.js error: InitialRSCPayload does not match the expected ' + + 'shape for a prerendered page during segment prefetch generation.' + ) + return null + } + return { + buildId: initialRSCPayload.b, + flightRouterState: flightDataPaths[0][0], + seedData: flightDataPaths[0][1], + head: flightDataPaths[0][2], + } +} + export async function collectSegmentData( isCacheComponentsEnabled: boolean, fullPageDataBuffer: Buffer, staleTime: number, clientModules: ManifestNode, serverConsumerManifest: any, - prefetchInlining: boolean + prefetchInlining: boolean, + hints: PrefetchHints | null ): Promise> { // Traverse the router tree and generate a prefetch response for each segment. @@ -196,6 +226,7 @@ export async function collectSegmentData( segmentTasks={segmentTasks} onCompletedProcessingRouteTree={onCompletedProcessingRouteTree} prefetchInlining={prefetchInlining} + hints={hints} />, clientModules, { @@ -222,6 +253,311 @@ export async function collectSegmentData( return resultMap } +/** + * Compute prefetch hints for a route by measuring segment sizes and deciding + * which segments should be inlined. Only runs at build time. The results are + * written to prefetch-hints.json and loaded at server startup. + * + * This is a separate pass from collectSegmentData so that the inlining + * decisions can be fed back into collectSegmentData to control which segments + * are output as separate entries vs. inlined into their parent. + */ +export async function collectPrefetchHints( + fullPageDataBuffer: Buffer, + staleTime: number, + clientModules: ManifestNode, + serverConsumerManifest: any, + maxSize: number, + maxBundleSize: number +): Promise { + // Warm up the module cache, same as collectSegmentData. + try { + await createFromReadableStream(streamFromBuffer(fullPageDataBuffer), { + findSourceMapURL, + serverConsumerManifest, + }) + await waitAtLeastOneReactRenderTask() + } catch {} + + // Decode the Flight data to walk the route tree. + const initialRSCPayload: InitialRSCPayload = await createFromReadableStream( + createUnclosingPrefetchStream(streamFromBuffer(fullPageDataBuffer)), + { + findSourceMapURL, + serverConsumerManifest, + } + ) + + const flightData = extractFlightData(initialRSCPayload) + if (flightData === null) { + return { hints: 0, slots: null } + } + const { buildId, flightRouterState, seedData, head } = flightData + + // Measure the head (metadata/viewport) gzip size so the main traversal + // can decide whether to inline it into a page's bundle. + const headVaryParamsThenable = initialRSCPayload.h + const headVaryParams = + headVaryParamsThenable !== null + ? readVaryParams(headVaryParamsThenable) + : null + + const [, headBuffer] = await renderSegmentPrefetch( + buildId, + staleTime, + head, + HEAD_REQUEST_KEY, + headVaryParams, + clientModules + ) + const headGzipSize = await getGzipSize(headBuffer) + + // Mutable accumulator: the first page leaf that can fit the head sets + // this to true. Once set, subsequent leaves skip the check. + const headInlineState = { inlined: false } + + // Walk the tree with the parent-first, child-decides algorithm. + const { node } = await collectPrefetchHintsImpl( + flightRouterState, + buildId, + staleTime, + seedData, + clientModules, + ROOT_SEGMENT_REQUEST_KEY, + null, // root has no parent to inline + maxSize, + maxBundleSize, + headGzipSize, + headInlineState + ) + + if (!headInlineState.inlined) { + // No page could accept the head. Set HeadOutlined on the root so the + // client knows to fetch the head separately. + node.hints |= PrefetchHint.HeadOutlined + } + + return node +} + +// Measure a segment's gzip size and decide whether it should be inlined. +// +// These hints are computed once during build and never change for the +// lifetime of that deployment. The client can assume that hints delivered as +// part of one request will be the same during a subsequent request, given +// the same build ID. There's no skew to worry about as long as the build +// itself is consistent. +// +// In the Segment Cache, we split page prefetches into multiple requests so +// that each one can be cached and deduped independently. However, some +// segments are small enough that the potential caching benefits are not worth +// the additional network overhead. For these, we inline a parent's data into +// one of its children's responses, avoiding a separate request. The parent +// is inlined into the child (not the other way around) because the parent's +// response is more likely to be shared across multiple pages. The child's +// response is already page-specific, so adding the parent's data there +// doesn't meaningfully reduce deduplication. It's similar to how JS bundlers +// decide whether to inline a module into a chunk. +// +// The algorithm is parent-first, child-decides: the parent measures itself +// and passes its gzip size down. Each child decides whether to accept. A +// child rejects if the parent exceeds maxSize or if accepting would push +// the cumulative inlined bytes past maxBundleSize. This produces +// both ParentInlinedIntoSelf (on the child) and InlinedIntoChild (on the +// parent) in a single pass. +async function collectPrefetchHintsImpl( + route: FlightRouterState, + buildId: string | undefined, + staleTime: number, + seedData: CacheNodeSeedData | null, + clientModules: ManifestNode, + // TODO: Consider persisting the computed requestKey into the hints output + // so it doesn't need to be recomputed during the build. This might also + // suggest renaming prefetch-hints.json to something like + // segment-manifest.json, since it would contain more than just hints. + requestKey: SegmentRequestKey, + parentGzipSize: number | null, + maxSize: number, + maxBundleSize: number, + headGzipSize: number, + headInlineState: { inlined: boolean } +): Promise<{ + node: PrefetchHints + // Total inlined bytes accumulated along the deepest accepting path in this + // subtree. Used by ancestors for budget checks. + inlinedBytes: number +}> { + // Render current segment and measure its gzip size. + let currentGzipSize: number | null = null + if (seedData !== null) { + const varyParamsThenable = seedData[4] + const varyParams = + varyParamsThenable !== null ? readVaryParams(varyParamsThenable) : null + + const [, buffer] = await renderSegmentPrefetch( + buildId, + staleTime, + seedData[0], + requestKey, + varyParams, + clientModules + ) + currentGzipSize = await getGzipSize(buffer) + } + + // Only offer this segment to its children for inlining if its gzip size + // is below maxSize. Segments above this get their own response. + const sizeToInline = + currentGzipSize !== null && currentGzipSize < maxSize + ? currentGzipSize + : null + + // Process children serially (not in parallel) to ensure deterministic + // results. Since this only runs at build time and the rendering is just + // re-encoding cached prerenders, this won't impact build times. Each child + // receives our gzip size and decides whether to inline us. Once a child + // accepts, we stop offering to remaining siblings — the parent is only + // inlined into one child. In parallel routes, this avoids duplicating the + // parent's data across multiple sibling responses. + const children = route[1] + const seedDataChildren = seedData !== null ? seedData[1] : null + + let slots: Record | null = null + let didInlineIntoChild = false + let acceptingChildInlinedBytes = 0 + // Track the smallest inlinedBytes across all children so we know how much + // budget remains along the best path. When our own parent asks whether we + // can accept its data, the parent's bytes would flow through to the child + // with the most remaining headroom. + let smallestChildInlinedBytes = Infinity + let hasChildren = false + + for (const parallelRouteKey in children) { + hasChildren = true + const childRoute = children[parallelRouteKey] + const childSegment = childRoute[0] + const childSeedData = + seedDataChildren !== null ? seedDataChildren[parallelRouteKey] : null + + const childRequestKey = appendSegmentRequestKeyPart( + requestKey, + parallelRouteKey, + createSegmentRequestKeyPart(childSegment) + ) + + const childResult = await collectPrefetchHintsImpl( + childRoute, + buildId, + staleTime, + childSeedData, + clientModules, + childRequestKey, + // Once a child has accepted us, stop offering to remaining siblings. + didInlineIntoChild ? null : sizeToInline, + maxSize, + maxBundleSize, + headGzipSize, + headInlineState + ) + + if (slots === null) { + slots = {} + } + slots[parallelRouteKey] = childResult.node + + if (childResult.node.hints & PrefetchHint.ParentInlinedIntoSelf) { + // This child accepted our data — it will include our segment's + // response in its own. No need to track headroom anymore since + // we already know which child we're inlined into. + didInlineIntoChild = true + acceptingChildInlinedBytes = childResult.inlinedBytes + } else if (!didInlineIntoChild) { + // Track the child with the most remaining headroom. Used below + // when deciding whether to accept our own parent's data. + if (childResult.inlinedBytes < smallestChildInlinedBytes) { + smallestChildInlinedBytes = childResult.inlinedBytes + } + } + } + + // Leaf segment: no children have consumed any budget yet. + if (!hasChildren) { + smallestChildInlinedBytes = 0 + } + + // Mark this segment as InlinedIntoChild if one of its children accepted. + // This means this segment doesn't need its own prefetch response — its + // data is included in the accepting child's response instead. + let hints = 0 + if (didInlineIntoChild) { + hints |= PrefetchHint.InlinedIntoChild + } + + // inlinedBytes represents the total gzipped bytes of parent data inlined + // into the deepest "inlining target" along this branch. It starts at 0 at + // the leaves and grows as parents are inlined going back up the tree. If a + // child accepted us, our size is already counted in that child's value. + let inlinedBytes = didInlineIntoChild + ? acceptingChildInlinedBytes + : smallestChildInlinedBytes + + // At leaf nodes (pages), try to inline the head (metadata/viewport) into + // this page's response. The head is treated like an additional inlined + // entry — it counts against the same total budget. Only the first page + // that has room gets the head; subsequent pages skip via the shared + // headInlineState accumulator. + if (!hasChildren && !headInlineState.inlined) { + if (inlinedBytes + headGzipSize < maxBundleSize) { + hints |= PrefetchHint.HeadInlinedIntoSelf + inlinedBytes += headGzipSize + headInlineState.inlined = true + } + } + + // Decide whether to accept our own parent's data. Two conditions: + // + // 1. The parent offered us a size (parentGzipSize is not null). It's null + // when the parent is too large to inline or when this is the root. + // + // 2. The total inlined bytes along this branch wouldn't exceed the budget. + // Even if each segment is individually small, at some point it no + // longer makes sense to keep adding bytes because the combined response + // is unique per URL and can't be deduped. + // + // A node can be both InlinedIntoChild and ParentInlinedIntoSelf. This + // happens in multi-level chains: GP → P → C where all are small. C + // accepts P (P is InlinedIntoChild), then P also accepts GP (P is + // ParentInlinedIntoSelf). The result: C's response includes both P's + // and GP's data. The parent's data flows through to the deepest + // accepting descendant. + if (parentGzipSize !== null) { + if (inlinedBytes + parentGzipSize < maxBundleSize) { + hints |= PrefetchHint.ParentInlinedIntoSelf + inlinedBytes += parentGzipSize + } + } + + return { + node: { hints, slots }, + inlinedBytes, + } +} + +// We use gzip size rather than raw size because it better reflects the actual +// transfer cost. The inlining trade-off is about whether the overhead of an +// additional HTTP request (connection setup, headers, round trip) is worth +// the deduplication benefit of keeping a segment separate. Below some +// compressed size, the request overhead dominates and inlining is better. +// Above it, the deduplication benefit of a cacheable standalone response +// wins out. +async function getGzipSize(buffer: Buffer): Promise { + const stream = new Blob([new Uint8Array(buffer)]) + .stream() + .pipeThrough(new CompressionStream('gzip')) + const compressedBlob = await new Response(stream).blob() + return compressedBlob.size +} + async function PrefetchTreeData({ isClientParamParsingEnabled, fullPageDataBuffer, @@ -231,6 +567,7 @@ async function PrefetchTreeData({ segmentTasks, onCompletedProcessingRouteTree, prefetchInlining, + hints, }: { isClientParamParsingEnabled: boolean fullPageDataBuffer: Buffer @@ -240,6 +577,7 @@ async function PrefetchTreeData({ segmentTasks: Array> onCompletedProcessingRouteTree: () => void prefetchInlining: boolean + hints: PrefetchHints | null }): Promise { // We're currently rendering a Flight response for the route tree prefetch. // Inside this component, decode the Flight stream for the whole page. This is @@ -254,20 +592,11 @@ async function PrefetchTreeData({ } ) - const buildId = initialRSCPayload.b - - // FlightDataPath is an unsound type, hence the additional checks. - const flightDataPaths = initialRSCPayload.f - if (flightDataPaths.length !== 1 && flightDataPaths[0].length !== 3) { - console.error( - 'Internal Next.js error: InitialRSCPayload does not match the expected ' + - 'shape for a prerendered page during segment prefetch generation.' - ) + const flightData = extractFlightData(initialRSCPayload) + if (flightData === null) { return null } - const flightRouterState: FlightRouterState = flightDataPaths[0][0] - const seedData: CacheNodeSeedData = flightDataPaths[0][1] - const head: HeadData = flightDataPaths[0][2] + const { buildId, flightRouterState, seedData, head } = flightData // Extract the head vary params from the decoded response. // The head vary params thenable should be fulfilled by now; if not, treat @@ -291,7 +620,8 @@ async function PrefetchTreeData({ clientModules, ROOT_SEGMENT_REQUEST_KEY, segmentTasks, - prefetchInlining + prefetchInlining, + hints ) if (prefetchInlining) { @@ -355,7 +685,8 @@ function collectSegmentDataImpl( clientModules: ManifestNode, requestKey: SegmentRequestKey, segmentTasks: Array>, - prefetchInlining: boolean + prefetchInlining: boolean, + hintTree: PrefetchHints | null ): TreePrefetch { // Metadata about the segment. Sent as part of the tree prefetch. Null if // there are no children. @@ -374,6 +705,10 @@ function collectSegmentDataImpl( parallelRouteKey, createSegmentRequestKeyPart(childSegment) ) + const childHintTree = + hintTree !== null && hintTree.slots !== null + ? (hintTree.slots[parallelRouteKey] ?? null) + : null const childTree = collectSegmentDataImpl( isClientParamParsingEnabled, childRoute, @@ -383,7 +718,8 @@ function collectSegmentDataImpl( clientModules, childRequestKey, segmentTasks, - prefetchInlining + prefetchInlining, + childHintTree ) if (slotMetadata === null) { slotMetadata = {} @@ -391,7 +727,14 @@ function collectSegmentDataImpl( slotMetadata[parallelRouteKey] = childTree } - const prefetchHints = route[4] ?? 0 + // Union the hints already embedded in the FlightRouterState with the + // separately-computed build-time hints. During the initial build, the + // FlightRouterState was produced before collectPrefetchHints ran, so + // inlining hints (ParentInlinedIntoSelf, InlinedIntoChild) won't be in + // route[4] yet. On subsequent renders the hints are already in the + // FlightRouterState, so the union is idempotent. + const prefetchHints = + (route[4] ?? 0) | (hintTree !== null ? hintTree.hints : 0) // Determine which params this segment varies on. // Read the vary params thenable directly from the seed data. By the time diff --git a/packages/next/src/server/app-render/create-flight-router-state-from-loader-tree.ts b/packages/next/src/server/app-render/create-flight-router-state-from-loader-tree.ts index cadd1b7ff1605..1fd4f13ed972e 100644 --- a/packages/next/src/server/app-render/create-flight-router-state-from-loader-tree.ts +++ b/packages/next/src/server/app-render/create-flight-router-state-from-loader-tree.ts @@ -2,6 +2,7 @@ import type { LoaderTree } from '../lib/app-dir-module' import { PrefetchHint, type FlightRouterState, + type PrefetchHints, } from '../../shared/lib/app-router-types' import type { GetDynamicParamFromSegment } from './app-render' import { addSearchParamsIfPageSegment } from '../../shared/lib/segment' @@ -9,6 +10,7 @@ import type { AppSegmentConfig } from '../../build/segment-config/app/app-segmen async function createFlightRouterStateFromLoaderTreeImpl( loaderTree: LoaderTree, + hintTree: PrefetchHints | null, getDynamicParamFromSegment: GetDynamicParamFromSegment, searchParams: any, didFindRootLayout: boolean @@ -29,6 +31,20 @@ async function createFlightRouterStateFromLoaderTreeImpl( : undefined let prefetchHints = 0 + // Union in the precomputed build-time hints (e.g. segment inlining + // decisions) if available. When hints are not available (e.g. dev mode or + // if prefetch-hints.json was not generated), we fall through and still + // compute the other hints below. In the future this should be a build + // error, but for now we gracefully degrade. + // + // TODO: Move more of the hints computation (IsRootLayout, instant config, + // loading boundary detection) into the build-time measurement step in + // collectPrefetchHints, so this function only needs to union the + // precomputed bitmask rather than re-derive hints on every render. + if (hintTree !== null) { + prefetchHints |= hintTree.hints + } + // Mark the first segment that has a layout as the "root" layout if (!didFindRootLayout && typeof layout !== 'undefined') { didFindRootLayout = true @@ -49,8 +65,13 @@ async function createFlightRouterStateFromLoaderTreeImpl( const children: FlightRouterState[1] = {} for (const parallelRouteKey in parallelRoutes) { + // Look up the child hint node by parallel route key, traversing the + // hint tree in parallel with the loader tree. + const childHintNode = hintTree?.slots?.[parallelRouteKey] ?? null + const child = await createFlightRouterStateFromLoaderTreeImpl( parallelRoutes[parallelRouteKey], + childHintNode, getDynamicParamFromSegment, searchParams, didFindRootLayout @@ -84,12 +105,14 @@ async function createFlightRouterStateFromLoaderTreeImpl( export async function createFlightRouterStateFromLoaderTree( loaderTree: LoaderTree, + hintTree: PrefetchHints | null, getDynamicParamFromSegment: GetDynamicParamFromSegment, searchParams: any ): Promise { const didFindRootLayout = false return createFlightRouterStateFromLoaderTreeImpl( loaderTree, + hintTree, getDynamicParamFromSegment, searchParams, didFindRootLayout @@ -98,6 +121,7 @@ export async function createFlightRouterStateFromLoaderTree( export async function createRouteTreePrefetch( loaderTree: LoaderTree, + hintTree: PrefetchHints | null, getDynamicParamFromSegment: GetDynamicParamFromSegment ): Promise { // Search params should not be added to page segment's cache key during a @@ -107,6 +131,7 @@ export async function createRouteTreePrefetch( const didFindRootLayout = false return createFlightRouterStateFromLoaderTreeImpl( loaderTree, + hintTree, getDynamicParamFromSegment, searchParams, didFindRootLayout diff --git a/packages/next/src/server/app-render/entry-base.ts b/packages/next/src/server/app-render/entry-base.ts index a598d1d6df246..a6412747e9466 100644 --- a/packages/next/src/server/app-render/entry-base.ts +++ b/packages/next/src/server/app-render/entry-base.ts @@ -40,7 +40,10 @@ export { RootLayoutBoundary } from '../../lib/framework/boundary-components' export { preloadStyle, preloadFont, preconnect } from './rsc/preloads' export { Postpone } from './rsc/postpone' export { taintObjectReference } from './rsc/taint' -export { collectSegmentData } from './collect-segment-data' +export { + collectSegmentData, + collectPrefetchHints, +} from './collect-segment-data' export const InstantValidation = () => { if ( diff --git a/packages/next/src/server/app-render/types.ts b/packages/next/src/server/app-render/types.ts index e794eeb7af57d..7781b908e3c19 100644 --- a/packages/next/src/server/app-render/types.ts +++ b/packages/next/src/server/app-render/types.ts @@ -3,6 +3,7 @@ import type { ServerRuntime, SizeLimit } from '../../types' import type { ExperimentalConfig, NextConfigComplete, + PrefetchInliningConfig, } from '../../server/config-shared' import type { NextFontManifest } from '../../build/webpack/plugins/next-font-manifest-plugin' import type { ParsedUrlQuery } from 'querystring' @@ -19,6 +20,7 @@ import type { BaseNextRequest } from '../base-http' import type { IncomingMessage } from 'http' import type { RenderResumeDataCache } from '../resume-data-cache/resume-data-cache' import type { ServerCacheStatus } from '../../next-devtools/dev-overlay/cache-indicator' +import type { PrefetchHints } from '../../shared/lib/app-router-types' const dynamicParamTypesSchema = s.enums([ 'c', @@ -160,7 +162,7 @@ export interface RenderOptsPartial { dynamicOnHover: boolean optimisticRouting: boolean inlineCss: boolean - prefetchInlining: boolean + prefetchInlining: PrefetchInliningConfig authInterrupts: boolean cachedNavigations: boolean @@ -199,6 +201,12 @@ export interface RenderOptsPartial { */ reactMaxHeadersLength: number | undefined + /** + * Per-route prefetch hints from prefetch-hints.json. + * Loaded at server startup from the build output. + */ + prefetchHints?: Record + isStaticGeneration?: boolean /** diff --git a/packages/next/src/server/app-render/walk-tree-with-flight-router-state.tsx b/packages/next/src/server/app-render/walk-tree-with-flight-router-state.tsx index 380c3fcdf865f..aed5ade23058b 100644 --- a/packages/next/src/server/app-render/walk-tree-with-flight-router-state.tsx +++ b/packages/next/src/server/app-render/walk-tree-with-flight-router-state.tsx @@ -2,6 +2,7 @@ import type { FlightDataPath, FlightDataSegment, FlightRouterState, + PrefetchHints, Segment, HeadData, } from '../../shared/lib/app-router-types' @@ -37,6 +38,7 @@ export async function walkTreeWithFlightRouterState({ ctx, preloadCallbacks, MetadataOutlet, + hintTree, }: { loaderTreeToFilter: LoaderTree parentParams: { [key: string]: string | string[] } @@ -50,6 +52,7 @@ export async function walkTreeWithFlightRouterState({ ctx: AppRenderContext preloadCallbacks: PreloadCallbacks MetadataOutlet: React.ComponentType + hintTree: PrefetchHints | null }): Promise { const { renderOpts: { nextFontManifest, experimental }, @@ -151,10 +154,12 @@ export async function walkTreeWithFlightRouterState({ ? // Route tree prefetch requests contain some extra information await createRouteTreePrefetch( loaderTreeToFilter, + hintTree, getDynamicParamFromSegment ) : await createFlightRouterStateFromLoaderTree( loaderTreeToFilter, + hintTree, getDynamicParamFromSegment, query ) @@ -181,10 +186,12 @@ export async function walkTreeWithFlightRouterState({ const routerState = parsedRequestHeaders.isRouteTreePrefetchRequest ? await createRouteTreePrefetch( loaderTreeToFilter, + hintTree, getDynamicParamFromSegment ) : await createFlightRouterStateFromLoaderTree( loaderTreeToFilter, + hintTree, getDynamicParamFromSegment, query ) @@ -212,6 +219,7 @@ export async function walkTreeWithFlightRouterState({ const routerState = await createFlightRouterStateFromLoaderTree( // Create router state using the slice of the loaderTree loaderTreeToFilter, + hintTree, getDynamicParamFromSegment, query ) @@ -290,6 +298,7 @@ export async function walkTreeWithFlightRouterState({ rootLayoutIncluded: rootLayoutIncludedAtThisLevelOrAbove, preloadCallbacks, MetadataOutlet, + hintTree: hintTree?.slots?.[parallelRouteKey] ?? null, }) for (const subPath of subPaths) { @@ -329,10 +338,15 @@ export async function createFullTreeFlightDataForNavigation({ renderOpts: { experimental }, query, getDynamicParamFromSegment, + pagePath, } = ctx + const hintTreeForInitialRender = + ctx.renderOpts.prefetchHints?.[pagePath] ?? null + const routerState = await createFlightRouterStateFromLoaderTree( loaderTree, + hintTreeForInitialRender, getDynamicParamFromSegment, query ) diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index 66aecaf5fcba9..cd49c01699959 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -584,6 +584,7 @@ export default abstract class Server< }, onInstrumentationRequestError: this.instrumentationOnRequestError.bind(this), + prefetchHints: {}, reactMaxHeadersLength: this.nextConfig.reactMaxHeadersLength, logServerFunctions: typeof this.nextConfig.logging === 'object' && diff --git a/packages/next/src/server/config-schema.ts b/packages/next/src/server/config-schema.ts index 1ff8211064807..57d06b2bddb1b 100644 --- a/packages/next/src/server/config-schema.ts +++ b/packages/next/src/server/config-schema.ts @@ -224,7 +224,15 @@ export const experimentalSchema = { dynamicOnHover: z.boolean().optional(), optimisticRouting: z.boolean().optional(), varyParams: z.boolean().optional(), - prefetchInlining: z.boolean().optional(), + prefetchInlining: z + .union([ + z.boolean(), + z.object({ + maxSize: z.number().optional(), + maxBundleSize: z.number().optional(), + }), + ]) + .optional(), disableOptimizedLoading: z.boolean().optional(), disablePostcssPresetEnv: z.boolean().optional(), cacheComponents: z.boolean().optional(), diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index fedfb7da9a899..72d44c8407c3f 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -16,6 +16,14 @@ import { INFINITE_CACHE } from '../lib/constants' import { isStableBuild } from '../shared/lib/errors/canary-only-config-error' import type { FallbackRouteParam } from '../build/static-paths/types' +/** + * Resolved form of the prefetchInlining config after normalization in + * config.ts. User input (true, partial objects) is converted to this shape. + */ +export type PrefetchInliningConfig = + | false + | { maxSize: number; maxBundleSize: number } + export type NextConfigComplete = Required> & { images: Required typescript: TypeScriptConfig @@ -24,7 +32,10 @@ export type NextConfigComplete = Required> & { // override NextConfigComplete.experimental.htmlLimitedBots to string // because it's not defined in NextConfigComplete.experimental htmlLimitedBots: string | undefined - experimental: ExperimentalConfig + experimental: ExperimentalConfig & { + // Normalized by config.ts: true and partial objects become resolved objects + prefetchInlining?: PrefetchInliningConfig + } // The root directory of the distDir. In development mode, this is the parent directory of `distDir` // since development builds use `{distDir}/dev`. This is used to ensure that the bundler doesn't // traverse into the output directory. @@ -407,7 +418,12 @@ export interface ExperimentalConfig { dynamicOnHover?: boolean optimisticRouting?: boolean varyParams?: boolean - prefetchInlining?: boolean + prefetchInlining?: + | boolean + | { + maxSize?: number + maxBundleSize?: number + } preloadEntriesOnStart?: boolean clientRouterFilter?: boolean clientRouterFilterRedirects?: boolean diff --git a/packages/next/src/server/config.ts b/packages/next/src/server/config.ts index 5beed9e6f2f46..2781df87948de 100644 --- a/packages/next/src/server/config.ts +++ b/packages/next/src/server/config.ts @@ -380,6 +380,24 @@ function assignDefaultsAndValidate( }, } + // Normalize prefetchInlining: true | { maxSize?, maxBundleSize? } into a + // resolved object with concrete defaults, so consumers don't have to + // resolve the values themselves. + if (result.experimental.prefetchInlining) { + const raw = result.experimental.prefetchInlining + const maxSize = typeof raw === 'object' ? (raw.maxSize ?? 2_048) : 2_048 + const maxBundleSize = + typeof raw === 'object' ? (raw.maxBundleSize ?? 10_240) : 10_240 + result.experimental.prefetchInlining = { + // Clamp Infinity to a finite value so the config survives + // JSON.stringify (used by output: standalone). + maxSize: Number.isFinite(maxSize) ? maxSize : Number.MAX_SAFE_INTEGER, + maxBundleSize: Number.isFinite(maxBundleSize) + ? maxBundleSize + : Number.MAX_SAFE_INTEGER, + } + } + // ensure correct default is set for api-resolver revalidate handling if (!result.experimental.trustHostHeader && ciEnvironment.hasNextSupport) { result.experimental.trustHostHeader = true diff --git a/packages/next/src/server/lib/incremental-cache/file-system-cache.ts b/packages/next/src/server/lib/incremental-cache/file-system-cache.ts index b57f3e8af38c0..fbbf0f1eb1135 100644 --- a/packages/next/src/server/lib/incremental-cache/file-system-cache.ts +++ b/packages/next/src/server/lib/incremental-cache/file-system-cache.ts @@ -379,6 +379,7 @@ export default class FileSystemCache implements CacheHandler { status: data.status, postponed: undefined, segmentPaths: undefined, + prefetchHints: undefined, } writer.append( @@ -432,6 +433,7 @@ export default class FileSystemCache implements CacheHandler { status: data.status, postponed: data.postponed, segmentPaths, + prefetchHints: undefined, } writer.append( diff --git a/packages/next/src/server/next-server.ts b/packages/next/src/server/next-server.ts index b72ddafe46da7..a2b9b7c45bb30 100644 --- a/packages/next/src/server/next-server.ts +++ b/packages/next/src/server/next-server.ts @@ -39,6 +39,7 @@ import { PAGES_MANIFEST, BUILD_ID_FILE, MIDDLEWARE_MANIFEST, + PREFETCH_HINTS, PRERENDER_MANIFEST, ROUTES_MANIFEST, CLIENT_PUBLIC_FILES_PATH, @@ -111,6 +112,7 @@ import { formatDynamicImportPath } from '../lib/format-dynamic-import-path' import type { NextFontManifest } from '../build/webpack/plugins/next-font-manifest-plugin' import { isInterceptionRouteRewrite } from '../lib/is-interception-route-rewrite' import type { ServerOnInstrumentationRequestError } from './app-render/types' +import type { PrefetchHints } from '../shared/lib/app-router-types' import { RouteKind } from './route-kind' import { InvariantError } from '../shared/lib/invariant-error' import { AwaiterOnce } from './after/awaiter' @@ -201,6 +203,10 @@ export default class NextNodeServer extends BaseServer< installGlobalBehaviors(this.nextConfig) + // Load prefetch hints from the build output. This must happen before + // any render to ensure segment inlining decisions are available. + this.renderOpts.prefetchHints = this.getPrefetchHints() + const isDev = options.dev ?? false this.isDev = isDev this.sriEnabled = Boolean(options.conf.experimental?.sri?.algorithm) @@ -1906,6 +1912,28 @@ export default class NextNodeServer extends BaseServer< return this._cachedPreviewManifest } + private _cachedPrefetchHints: Record | undefined + protected getPrefetchHints(): Record { + if (this._cachedPrefetchHints) { + return this._cachedPrefetchHints + } + + this._cachedPrefetchHints = + (loadManifest( + join( + /* turbopackIgnore: true */ this.distDir, + SERVER_DIRECTORY, + PREFETCH_HINTS + ), + true, + undefined, + false, + true // handleMissing: don't crash if the file doesn't exist + ) as Record) ?? {} + + return this._cachedPrefetchHints + } + protected getRoutesManifest(): NormalizedRouteManifest | undefined { return getTracer().trace( NextNodeServerSpan.getRoutesManifest, diff --git a/packages/next/src/server/render-result.ts b/packages/next/src/server/render-result.ts index 53220cfb7831d..d46ca5a215548 100644 --- a/packages/next/src/server/render-result.ts +++ b/packages/next/src/server/render-result.ts @@ -1,6 +1,7 @@ import type { OutgoingHttpHeaders, ServerResponse } from 'http' import type { CacheControl } from './lib/cache-control' import type { FetchMetrics } from './base-http' +import type { PrefetchHints } from '../shared/lib/app-router-types' import { chainStreams, @@ -47,6 +48,13 @@ export type AppPageRenderResultMetadata = { segmentData?: Map + /** + * Per-route prefetch hints computed at build time (e.g. segment inlining + * decisions based on gzip sizes). Written to prefetch-hints.json by the + * build pipeline. + */ + prefetchHints?: PrefetchHints + /** * In development, the resume data cache is warmed up before the render. This * is attached to the metadata so that it can be used during the render. When diff --git a/packages/next/src/shared/lib/app-router-types.ts b/packages/next/src/shared/lib/app-router-types.ts index e0f86cf9dbb6d..8757a43b36bef 100644 --- a/packages/next/src/shared/lib/app-router-types.ts +++ b/packages/next/src/shared/lib/app-router-types.ts @@ -166,6 +166,18 @@ export const enum PrefetchHint { SubtreeHasLoadingBoundary = 0b01000, // This segment is the root layout of the application. IsRootLayout = 0b10000, + // This segment's response includes its parent's data inlined into it. + // Set at build time by the segment size measurement pass. + ParentInlinedIntoSelf = 0b100000, + // This segment's data is inlined into one of its children — don't fetch + // it separately. Set at build time by the segment size measurement pass. + InlinedIntoChild = 0b1000000, + // On a __PAGE__: this page's response includes the head (metadata/viewport) + // at the end of its SegmentPrefetch[] array. + HeadInlinedIntoSelf = 0b10000000, + // On the root hint node: the head was NOT inlined into any page — fetch + // it separately. Absence of this bit means the head is bundled into a page. + HeadOutlined = 0b100000000, } /** @@ -239,6 +251,23 @@ export type FlightDataPath = */ export type FlightData = Array | string +/** + * Per-route prefetch hints computed at build time. Mirrors the shape of the + * loader tree so hints can be traversed in parallel during router state + * creation. Each node stores a bitmask of PrefetchHint flags + * (ParentInlinedIntoSelf, InlinedIntoChild) computed by the segment size + * measurement pass. + * + * Persisted to prefetch-hints.json as Record (keyed + * by route pattern) and loaded at server startup. + */ +export type PrefetchHints = { + /** Bitmask of PrefetchHint flags for this segment. */ + hints: number + /** Child hint nodes, keyed by parallel route key. */ + slots: Record | null +} + export type ActionResult = Promise export type InitialRSCPayload = { diff --git a/packages/next/src/shared/lib/constants.ts b/packages/next/src/shared/lib/constants.ts index fc74ed3ab3858..7d45b64faa6b4 100644 --- a/packages/next/src/shared/lib/constants.ts +++ b/packages/next/src/shared/lib/constants.ts @@ -95,6 +95,7 @@ export const NEXT_FONT_MANIFEST = 'next-font-manifest' export const EXPORT_MARKER = 'export-marker.json' export const EXPORT_DETAIL = 'export-detail.json' export const PRERENDER_MANIFEST = 'prerender-manifest.json' +export const PREFETCH_HINTS = 'prefetch-hints.json' export const ROUTES_MANIFEST = 'routes-manifest.json' export const IMAGES_MANIFEST = 'images-manifest.json' export const SERVER_FILES_MANIFEST = 'required-server-files' diff --git a/test/e2e/app-dir/segment-cache/prefetch-inlining/app/dynamic/[slug]/page.tsx b/test/e2e/app-dir/segment-cache/max-prefetch-inlining/app/dynamic/[slug]/page.tsx similarity index 100% rename from test/e2e/app-dir/segment-cache/prefetch-inlining/app/dynamic/[slug]/page.tsx rename to test/e2e/app-dir/segment-cache/max-prefetch-inlining/app/dynamic/[slug]/page.tsx diff --git a/test/e2e/app-dir/segment-cache/max-prefetch-inlining/app/layout.tsx b/test/e2e/app-dir/segment-cache/max-prefetch-inlining/app/layout.tsx new file mode 100644 index 0000000000000..888614deda3ba --- /dev/null +++ b/test/e2e/app-dir/segment-cache/max-prefetch-inlining/app/layout.tsx @@ -0,0 +1,8 @@ +import { ReactNode } from 'react' +export default function Root({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/segment-cache/max-prefetch-inlining/app/page.tsx b/test/e2e/app-dir/segment-cache/max-prefetch-inlining/app/page.tsx new file mode 100644 index 0000000000000..523c9b9714468 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/max-prefetch-inlining/app/page.tsx @@ -0,0 +1,28 @@ +// Test fixture for max prefetch inlining. With Infinity thresholds, all +// segments are bundled into a single response per route, similar to how +// prefetching worked before the Segment Cache (pre-Next 16). +import { LinkAccordion } from '../components/link-accordion' + +export default function Home() { + return ( +
+

Home

+
    +
  • + Route A +
  • +
  • + Route B +
  • +
  • + + Route A (duplicate) + +
  • +
  • + Dynamic Route +
  • +
+
+ ) +} diff --git a/test/e2e/app-dir/segment-cache/prefetch-inlining/app/shared/a/b/c/page.tsx b/test/e2e/app-dir/segment-cache/max-prefetch-inlining/app/shared/a/b/c/page.tsx similarity index 100% rename from test/e2e/app-dir/segment-cache/prefetch-inlining/app/shared/a/b/c/page.tsx rename to test/e2e/app-dir/segment-cache/max-prefetch-inlining/app/shared/a/b/c/page.tsx diff --git a/test/e2e/app-dir/segment-cache/prefetch-inlining/app/shared/a/b/layout.tsx b/test/e2e/app-dir/segment-cache/max-prefetch-inlining/app/shared/a/b/layout.tsx similarity index 100% rename from test/e2e/app-dir/segment-cache/prefetch-inlining/app/shared/a/b/layout.tsx rename to test/e2e/app-dir/segment-cache/max-prefetch-inlining/app/shared/a/b/layout.tsx diff --git a/test/e2e/app-dir/segment-cache/prefetch-inlining/app/shared/a/d/e/page.tsx b/test/e2e/app-dir/segment-cache/max-prefetch-inlining/app/shared/a/d/e/page.tsx similarity index 100% rename from test/e2e/app-dir/segment-cache/prefetch-inlining/app/shared/a/d/e/page.tsx rename to test/e2e/app-dir/segment-cache/max-prefetch-inlining/app/shared/a/d/e/page.tsx diff --git a/test/e2e/app-dir/segment-cache/prefetch-inlining/app/shared/a/d/layout.tsx b/test/e2e/app-dir/segment-cache/max-prefetch-inlining/app/shared/a/d/layout.tsx similarity index 100% rename from test/e2e/app-dir/segment-cache/prefetch-inlining/app/shared/a/d/layout.tsx rename to test/e2e/app-dir/segment-cache/max-prefetch-inlining/app/shared/a/d/layout.tsx diff --git a/test/e2e/app-dir/segment-cache/prefetch-inlining/app/shared/a/layout.tsx b/test/e2e/app-dir/segment-cache/max-prefetch-inlining/app/shared/a/layout.tsx similarity index 100% rename from test/e2e/app-dir/segment-cache/prefetch-inlining/app/shared/a/layout.tsx rename to test/e2e/app-dir/segment-cache/max-prefetch-inlining/app/shared/a/layout.tsx diff --git a/test/e2e/app-dir/segment-cache/prefetch-inlining/app/shared/layout.tsx b/test/e2e/app-dir/segment-cache/max-prefetch-inlining/app/shared/layout.tsx similarity index 100% rename from test/e2e/app-dir/segment-cache/prefetch-inlining/app/shared/layout.tsx rename to test/e2e/app-dir/segment-cache/max-prefetch-inlining/app/shared/layout.tsx diff --git a/test/e2e/app-dir/segment-cache/max-prefetch-inlining/components/link-accordion.tsx b/test/e2e/app-dir/segment-cache/max-prefetch-inlining/components/link-accordion.tsx new file mode 100644 index 0000000000000..04a706ab9829b --- /dev/null +++ b/test/e2e/app-dir/segment-cache/max-prefetch-inlining/components/link-accordion.tsx @@ -0,0 +1,31 @@ +'use client' + +import Link, { type LinkProps } from 'next/link' +import { useState } from 'react' + +export function LinkAccordion({ + href, + children, + id, +}: { + href: LinkProps['href'] + children: React.ReactNode + id?: string +}) { + const [isVisible, setIsVisible] = useState(false) + return ( + <> + setIsVisible(!isVisible)} + data-link-accordion={id ?? href} + /> + {isVisible ? ( + {children} + ) : ( + `${children} (link is hidden)` + )} + + ) +} diff --git a/test/e2e/app-dir/segment-cache/max-prefetch-inlining/max-prefetch-inlining.test.ts b/test/e2e/app-dir/segment-cache/max-prefetch-inlining/max-prefetch-inlining.test.ts new file mode 100644 index 0000000000000..c1bbc39b0990d --- /dev/null +++ b/test/e2e/app-dir/segment-cache/max-prefetch-inlining/max-prefetch-inlining.test.ts @@ -0,0 +1,146 @@ +import { nextTestSetup } from 'e2e-utils' +import type * as Playwright from 'playwright' +import { createRouterAct } from 'router-act' + +// Tests prefetch inlining with maxSize and maxBundleSize set to Infinity, +// which inlines all segments into a single response per route. This +// approximates the behavior of pre-Segment Cache (pre-Next 16) prefetching, +// where all prefetch data was bundled into one response. The tradeoff is that +// you lose the benefits of per-layout deduplication across routes. +// +// This is a special case of the general prefetch inlining feature tested in +// the sibling `prefetch-inlining` directory. We may consolidate these tests +// in the future. +describe('max prefetch inlining', () => { + const { next, isNextDev } = nextTestSetup({ + files: __dirname, + }) + if (isNextDev) { + it('disabled in development', () => {}) + return + } + + it('bundles all segment data into a single request per route', async () => { + // The test app has two routes that are 5 segments deep: + // /shared/a/b/c and /shared/a/d/e + // Without inlining, prefetching each route would issue one request per + // segment plus one for the head (6+ requests). With inlining enabled, + // all segment data is bundled into a single response, so revealing a + // link should produce at most 2 prefetch requests per route: one for + // /_tree and one for the inlined segment data. + + let rscRequestCount = 0 + let page: Playwright.Page + + const browser = await next.browser('/', { + beforePageLoad(p: Playwright.Page) { + page = p + p.on('request', (request: Playwright.Request) => { + if (request.headers()['rsc']) { + rscRequestCount++ + } + }) + }, + }) + const act = createRouterAct(page!) + + // Reveal the first link to trigger a prefetch of /shared/a/b/c + await act( + async () => { + await browser + .elementByCss('input[data-link-accordion="/shared/a/b/c"]') + .click() + }, + { + includes: 'Page C', + } + ) + + // Snapshot the request count before revealing the second link + const countBeforeSecondPrefetch = rscRequestCount + + // Reveal the second link to trigger a prefetch of /shared/a/d/e + await act( + async () => { + await browser + .elementByCss('input[data-link-accordion="/shared/a/d/e"]') + .click() + }, + { + includes: 'Page E', + } + ) + + // The delta should be at most 2 requests (/_tree + /_inlined). + // Without inlining, there would be 6+ individual segment requests. + const delta = rscRequestCount - countBeforeSecondPrefetch + expect(delta).toBeLessThanOrEqual(2) + + // Navigate to the second route. Because the data was fully prefetched, + // there should be no additional requests. + await act(async () => { + await browser.elementByCss('a[href="/shared/a/d/e"]').click() + }, 'no-requests') + + // Verify the page rendered correctly + const text = await browser.elementByCss('#page-e').text() + expect(text).toBe('Page E') + }) + + it('works with dynamic routes', async () => { + // Regression test: the build previously failed with + // "Invariant: missing __PAGE__ segmentPath" when prefetchInlining was + // combined with dynamic routes. + const $ = await next.render$('/dynamic/hello') + expect($('#dynamic-page').text()).toBe('hello') + }) + + it('deduplicates inlined prefetch requests for the same route', async () => { + // Two links point to the same route (/shared/a/b/c). When the first + // link is revealed, its prefetch spawns an inlined request. While + // that request is still pending, revealing the second link should + // not spawn any additional requests because the segments are already + // Pending from the first task. + let page: Playwright.Page + + const browser = await next.browser('/', { + beforePageLoad(p: Playwright.Page) { + page = p + }, + }) + const act = createRouterAct(page!) + + await act( + async () => { + // Reveal the first link, blocking its response so the prefetch + // stays in-flight. + await act(async () => { + await browser + .elementByCss('input[data-link-accordion="/shared/a/b/c"]') + .click() + }, 'block') + + // While the first prefetch is still pending, reveal a second + // link to the same route. This should not spawn any new + // requests because the segments are already Pending. + await act(async () => { + await browser + .elementByCss('input[data-link-accordion="duplicate-a"]') + .click() + }, 'no-requests') + }, + { + includes: 'Page C', + } + ) + + // Navigate to the route. Because the data was fully prefetched, + // no additional requests are needed. + await act(async () => { + await browser.elementByCss('a[href="/shared/a/b/c"]').click() + }, 'no-requests') + + const text = await browser.elementByCss('#page-c').text() + expect(text).toBe('Page C') + }) +}) diff --git a/test/e2e/app-dir/segment-cache/max-prefetch-inlining/next.config.js b/test/e2e/app-dir/segment-cache/max-prefetch-inlining/next.config.js new file mode 100644 index 0000000000000..f917d7e092a59 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/max-prefetch-inlining/next.config.js @@ -0,0 +1,19 @@ +/** + * Setting both thresholds to Infinity inlines all segments into a single + * response per route, approximating pre-Segment Cache (pre-Next 16) + * prefetching behavior where all data was bundled into one response. The + * tradeoff is that per-layout deduplication across routes is lost. + * + * @type {import('next').NextConfig} + */ +const nextConfig = { + cacheComponents: true, + experimental: { + prefetchInlining: { + maxSize: Infinity, + maxBundleSize: Infinity, + }, + }, +} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/segment-cache/prefetch-inlining/app/page.tsx b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/page.tsx index d9b045a0326f7..ff7159d9149fe 100644 --- a/test/e2e/app-dir/segment-cache/prefetch-inlining/app/page.tsx +++ b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/page.tsx @@ -1,25 +1,3 @@ -import { LinkAccordion } from '../components/link-accordion' - -export default function Home() { - return ( -
-

Home

-
    -
  • - Route A -
  • -
  • - Route B -
  • -
  • - - Route A (duplicate) - -
  • -
  • - Dynamic Route -
  • -
-
- ) +export default function Page() { + return

hello world

} diff --git a/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-deep/a/b/c/layout.tsx b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-deep/a/b/c/layout.tsx new file mode 100644 index 0000000000000..b74a21c022629 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-deep/a/b/c/layout.tsx @@ -0,0 +1,4 @@ +import { ReactNode } from 'react' +export default function Layout({ children }: { children: ReactNode }) { + return
{children}
+} diff --git a/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-deep/a/b/c/page.tsx b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-deep/a/b/c/page.tsx new file mode 100644 index 0000000000000..eee08c9c1cde3 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-deep/a/b/c/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

Deep page

+} diff --git a/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-deep/a/b/layout.tsx b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-deep/a/b/layout.tsx new file mode 100644 index 0000000000000..b74a21c022629 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-deep/a/b/layout.tsx @@ -0,0 +1,4 @@ +import { ReactNode } from 'react' +export default function Layout({ children }: { children: ReactNode }) { + return
{children}
+} diff --git a/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-deep/a/layout.tsx b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-deep/a/layout.tsx new file mode 100644 index 0000000000000..b74a21c022629 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-deep/a/layout.tsx @@ -0,0 +1,4 @@ +import { ReactNode } from 'react' +export default function Layout({ children }: { children: ReactNode }) { + return
{children}
+} diff --git a/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-deep/layout.tsx b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-deep/layout.tsx new file mode 100644 index 0000000000000..b74a21c022629 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-deep/layout.tsx @@ -0,0 +1,4 @@ +import { ReactNode } from 'react' +export default function Layout({ children }: { children: ReactNode }) { + return
{children}
+} diff --git a/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-dynamic/[slug]/layout.tsx b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-dynamic/[slug]/layout.tsx new file mode 100644 index 0000000000000..9a9dd62f30988 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-dynamic/[slug]/layout.tsx @@ -0,0 +1,25 @@ +import { ReactNode } from 'react' +import { NoInline } from '../../../components/no-inline' + +// This layout renders large content that depends on the route param. +// In a fallback shell render, `await params` causes postponement so +// the segment output is small. In a concrete render the full content +// appears, pushing the segment above the 2KB gzip inlining threshold. +// If hints were incorrectly based on the fallback shell, this layout +// would appear small and get inlined — the test catches that. +export default async function Layout({ + children, + params, +}: { + children: ReactNode + params: Promise<{ slug: string }> +}) { + const { slug } = await params + return ( +
+ +

{`Dynamic layout for: ${slug}`}

+ {children} +
+ ) +} diff --git a/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-dynamic/[slug]/page.tsx b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-dynamic/[slug]/page.tsx new file mode 100644 index 0000000000000..cc1230c508223 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-dynamic/[slug]/page.tsx @@ -0,0 +1,12 @@ +export default async function Page({ + params, +}: { + params: Promise<{ slug: string }> +}) { + const { slug } = await params + return

Dynamic page: {slug}

+} + +export function generateStaticParams() { + return [{ slug: 'hello' }, { slug: 'world' }] +} diff --git a/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-outlined/layout.tsx b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-outlined/layout.tsx new file mode 100644 index 0000000000000..2a8ae5ab743ba --- /dev/null +++ b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-outlined/layout.tsx @@ -0,0 +1,11 @@ +import { ReactNode } from 'react' +import { NoInline } from '../../components/no-inline' + +export default function LargeLayout({ children }: { children: ReactNode }) { + return ( +
+ + {children} +
+ ) +} diff --git a/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-outlined/page.tsx b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-outlined/page.tsx new file mode 100644 index 0000000000000..a56dba86ef6a9 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-outlined/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

Outlined test page

+} diff --git a/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-parallel/@sidebar/page.tsx b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-parallel/@sidebar/page.tsx new file mode 100644 index 0000000000000..2a2197797bc35 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-parallel/@sidebar/page.tsx @@ -0,0 +1,3 @@ +export default function Sidebar() { + return

Sidebar content

+} diff --git a/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-parallel/layout.tsx b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-parallel/layout.tsx new file mode 100644 index 0000000000000..11d8acce7212f --- /dev/null +++ b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-parallel/layout.tsx @@ -0,0 +1,15 @@ +import { ReactNode } from 'react' +export default function ParallelLayout({ + children, + sidebar, +}: { + children: ReactNode + sidebar: ReactNode +}) { + return ( +
+
{children}
+ +
+ ) +} diff --git a/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-parallel/page.tsx b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-parallel/page.tsx new file mode 100644 index 0000000000000..60bfdf84a14f3 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-parallel/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

Main content

+} diff --git a/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-restart/large-middle/after/layout.tsx b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-restart/large-middle/after/layout.tsx new file mode 100644 index 0000000000000..b74a21c022629 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-restart/large-middle/after/layout.tsx @@ -0,0 +1,4 @@ +import { ReactNode } from 'react' +export default function Layout({ children }: { children: ReactNode }) { + return
{children}
+} diff --git a/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-restart/large-middle/after/page.tsx b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-restart/large-middle/after/page.tsx new file mode 100644 index 0000000000000..569b9a473e42f --- /dev/null +++ b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-restart/large-middle/after/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

After page

+} diff --git a/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-restart/large-middle/layout.tsx b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-restart/large-middle/layout.tsx new file mode 100644 index 0000000000000..34baa484f2ac1 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-restart/large-middle/layout.tsx @@ -0,0 +1,11 @@ +import { ReactNode } from 'react' +import { NoInline } from '../../../components/no-inline' + +export default function LargeLayout({ children }: { children: ReactNode }) { + return ( +
+ + {children} +
+ ) +} diff --git a/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-restart/layout.tsx b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-restart/layout.tsx new file mode 100644 index 0000000000000..b74a21c022629 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-restart/layout.tsx @@ -0,0 +1,4 @@ +import { ReactNode } from 'react' +export default function Layout({ children }: { children: ReactNode }) { + return
{children}
+} diff --git a/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-small-chain/layout.tsx b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-small-chain/layout.tsx new file mode 100644 index 0000000000000..5dda6d2312a62 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-small-chain/layout.tsx @@ -0,0 +1,8 @@ +import { ReactNode } from 'react' +export default function SmallChainLayout({ + children, +}: { + children: ReactNode +}) { + return
{children}
+} diff --git a/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-small-chain/page.tsx b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-small-chain/page.tsx new file mode 100644 index 0000000000000..2e93e38f41679 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-small-chain/page.tsx @@ -0,0 +1,3 @@ +export default function SmallChainPage() { + return

Small chain page

+} diff --git a/test/e2e/app-dir/segment-cache/prefetch-inlining/components/link-accordion.tsx b/test/e2e/app-dir/segment-cache/prefetch-inlining/components/link-accordion.tsx index 04a706ab9829b..eb404fec7bf98 100644 --- a/test/e2e/app-dir/segment-cache/prefetch-inlining/components/link-accordion.tsx +++ b/test/e2e/app-dir/segment-cache/prefetch-inlining/components/link-accordion.tsx @@ -6,11 +6,9 @@ import { useState } from 'react' export function LinkAccordion({ href, children, - id, }: { href: LinkProps['href'] children: React.ReactNode - id?: string }) { const [isVisible, setIsVisible] = useState(false) return ( @@ -19,7 +17,7 @@ export function LinkAccordion({ type="checkbox" checked={isVisible} onChange={() => setIsVisible(!isVisible)} - data-link-accordion={id ?? href} + data-link-accordion={href} /> {isVisible ? ( {children} diff --git a/test/e2e/app-dir/segment-cache/prefetch-inlining/components/no-inline.tsx b/test/e2e/app-dir/segment-cache/prefetch-inlining/components/no-inline.tsx new file mode 100644 index 0000000000000..2d003d9f4be1b --- /dev/null +++ b/test/e2e/app-dir/segment-cache/prefetch-inlining/components/no-inline.tsx @@ -0,0 +1,23 @@ +'use cache' + +import { gzipSync } from 'zlib' +import { randomBytes } from 'crypto' + +// Default size is 2 KB, the threshold used by Next to decide whether to inline +// a segment into the route tree prefetch. +export async function NoInline({ size = 2048 }: { size?: number }) { + let content = '' + let compressedLength = 0 + let iterations = 0 + while (compressedLength < size) { + const chunk = + iterations % 2 === 0 + ? '**Arbitrary hidden content to prevent this component from being inlined** ' + : randomBytes(128).toString('base64') + ' ' + content += chunk + compressedLength = gzipSync(content).length + iterations++ + if (iterations > 10000) break + } + return
{content}
+} diff --git a/test/e2e/app-dir/segment-cache/prefetch-inlining/prefetch-inlining.test.ts b/test/e2e/app-dir/segment-cache/prefetch-inlining/prefetch-inlining.test.ts index ff7275972ae9f..4197ce3f31f9e 100644 --- a/test/e2e/app-dir/segment-cache/prefetch-inlining/prefetch-inlining.test.ts +++ b/test/e2e/app-dir/segment-cache/prefetch-inlining/prefetch-inlining.test.ts @@ -1,137 +1,268 @@ import { nextTestSetup } from 'e2e-utils' -import type * as Playwright from 'playwright' -import { createRouterAct } from 'router-act' + +// Bit values from PrefetchHint enum (const enum, so we duplicate values here) +const ParentInlinedIntoSelf = 0b100000 // 32 +const InlinedIntoChild = 0b1000000 // 64 + +// Matches the shape of RootTreePrefetch / TreePrefetch from collect-segment- +// data.tsx. We only declare the fields we need. +type TreePrefetch = { + name: string + prefetchHints: number + slots: null | { [key: string]: TreePrefetch } +} + +type RootTreePrefetch = { + tree: TreePrefetch +} + +/** + * Renders the TreePrefetch as an ASCII tree showing inlining decisions. + * Segments marked with "⇣ inlined" have their data included in a descendant's + * response instead of being fetched separately. Validates that parent/child + * hints are consistent (every InlinedIntoChild parent must have a child with + * ParentInlinedIntoSelf, and vice versa). + */ +// "outlined ■" is the fixed-width tag (10 chars). Inlined segments show just +// the arrow, right-aligned to match. +const OUTLINED_TAG = 'outlined \u25A0' +const INLINED_TAG = '\u21E3'.padStart(OUTLINED_TAG.length) + +function renderInliningTree(tree: TreePrefetch): string { + const lines: string[] = [] + collectNodes(tree, '', true, false, lines) + return '\n' + lines.join('\n') + '\n' +} + +function collectNodes( + node: TreePrefetch, + prefix: string, + isLast: boolean, + hasParent: boolean, + lines: string[], + slotKey?: string +): void { + const inlinedIntoChild = (node.prefetchHints & InlinedIntoChild) !== 0 + const _parentInlined = (node.prefetchHints & ParentInlinedIntoSelf) !== 0 + + const slotPrefix = + slotKey !== undefined && slotKey !== 'children' ? `@${slotKey}/` : '' + const name = hasParent ? `${slotPrefix}"${node.name}"` : 'root' + const tag = inlinedIntoChild ? INLINED_TAG : OUTLINED_TAG + const connector = hasParent + ? isLast + ? '\u2514\u2500\u2500 ' + : '\u251C\u2500\u2500 ' + : '' + lines.push(`${tag} ${prefix}${connector}${name}`) + + // Validate consistency between parent and children. + if (node.slots) { + const children = Object.values(node.slots) + const childrenWithParentInlined = children.filter( + (c) => (c.prefetchHints & ParentInlinedIntoSelf) !== 0 + ) + if (inlinedIntoChild && childrenWithParentInlined.length === 0) { + throw new Error( + `"${node.name}" has InlinedIntoChild but no child has ParentInlinedIntoSelf` + ) + } + if (!inlinedIntoChild && childrenWithParentInlined.length > 0) { + const names = childrenWithParentInlined.map((c) => c.name).join(', ') + throw new Error( + `"${node.name}" does not have InlinedIntoChild but child(ren) ${names} ` + + `have ParentInlinedIntoSelf` + ) + } + + const childPrefix = + prefix + (hasParent ? (isLast ? ' ' : '\u2502 ') : '') + const keys = Object.keys(node.slots) + const hasMultipleSlots = keys.length > 1 + for (let i = 0; i < keys.length; i++) { + collectNodes( + node.slots[keys[i]], + childPrefix, + i === keys.length - 1, + true, + lines, + hasMultipleSlots ? keys[i] : undefined + ) + } + } +} + +// Temporary helper: fetches the route tree prefetch response and parses the +// RootTreePrefetch object out of it. This will be replaced by end-to-end +// tests that assert on actual client prefetch request behavior once the +// client-side changes are done. +async function fetchRouteTreePrefetch( + next: any, + pathname: string +): Promise { + const res = await next.fetch(pathname, { + headers: { + RSC: '1', + 'Next-Router-Prefetch': '1', + 'Next-Router-Segment-Prefetch': '/_tree', + }, + }) + const text = await res.text() + // The Flight response for a plain JSON object (no React nodes) is a single + // line: `0:{"tree":...,"staleTime":...}`. Strip the row ID prefix and parse. + const jsonStr = text.slice(text.indexOf(':') + 1) + return JSON.parse(jsonStr) +} describe('prefetch inlining', () => { - const { next, isNextDev } = nextTestSetup({ + const { next, isNextDev, isTurbopack } = nextTestSetup({ files: __dirname, }) + if (isNextDev) { - it('disabled in development', () => {}) + it('prefetch hints are only computed during build', () => {}) return } - it('bundles all segment data into a single request per route', async () => { - // The test app has two routes that are 5 segments deep: - // /shared/a/b/c and /shared/a/d/e - // Without inlining, prefetching each route would issue one request per - // segment plus one for the head (6+ requests). With inlining enabled, - // all segment data is bundled into a single response, so revealing a - // link should produce at most 2 prefetch requests per route: one for - // /_tree and one for the inlined segment data. - - let rscRequestCount = 0 - let page: Playwright.Page - - const browser = await next.browser('/', { - beforePageLoad(p: Playwright.Page) { - page = p - p.on('request', (request: Playwright.Request) => { - if (request.headers()['rsc']) { - rscRequestCount++ - } - }) - }, - }) - const act = createRouterAct(page!) - - // Reveal the first link to trigger a prefetch of /shared/a/b/c - await act( - async () => { - await browser - .elementByCss('input[data-link-accordion="/shared/a/b/c"]') - .click() - }, - { - includes: 'Page C', - } - ) + it('small chain: inlines multiple ancestors into deepest child', async () => { + // Root → child layout → page, all with minimal content (well under the + // 2KB gzip threshold). Both the root and child layout are small enough + // to be inlined into the page's response. The entire chain fits within + // the 10KB total budget, so everything collapses into a single fetch + // for the page segment. + const data = await fetchRouteTreePrefetch(next, '/test-small-chain') + expect(renderInliningTree(data.tree)).toMatchInlineSnapshot(` + " + ⇣ root + ⇣ └── "test-small-chain" + outlined ■ └── "__PAGE__" + " + `) + }) - // Snapshot the request count before revealing the second link - const countBeforeSecondPrefetch = rscRequestCount - - // Reveal the second link to trigger a prefetch of /shared/a/d/e - await act( - async () => { - await browser - .elementByCss('input[data-link-accordion="/shared/a/d/e"]') - .click() - }, - { - includes: 'Page E', - } - ) + it('outlined: large segment breaks the inlining chain', async () => { + // Root → large layout (> 2KB gzipped) → page. The large layout exceeds + // the per-segment inlining threshold so it can't be inlined into the + // page. Root is still small enough for the large layout to accept, so + // root gets inlined into the large layout's response. The page is + // fetched separately since its parent was too large. + const data = await fetchRouteTreePrefetch(next, '/test-outlined') + expect(renderInliningTree(data.tree)).toMatchInlineSnapshot(` + " + ⇣ root + outlined ■ └── "test-outlined" + outlined ■ └── "__PAGE__" + " + `) + }) - // The delta should be at most 2 requests (/_tree + /_inlined). - // Without inlining, there would be 6+ individual segment requests. - const delta = rscRequestCount - countBeforeSecondPrefetch - expect(delta).toBeLessThanOrEqual(2) + it('parallel routes: parent inlines into one slot only', async () => { + // Layout with two parallel slots (children + @sidebar), all small. The + // layout can only be inlined into one child — the first slot that + // accepts (children). The @sidebar slot doesn't receive the parent's + // data and is fetched independently. + // + // Turbopack and webpack produce slightly different route tree structures + // for parallel routes (turbopack adds a "(slot)" intermediate segment), + // so we use separate snapshots for each. + // Turbopack and webpack produce slightly different route tree structures + // for parallel routes. Turbopack adds a "(slot)" intermediate segment + // for the @sidebar slot; webpack puts __PAGE__ directly under it. + const data = await fetchRouteTreePrefetch(next, '/test-parallel') + if (isTurbopack) { + expect(renderInliningTree(data.tree)).toMatchInlineSnapshot(` + " + ⇣ root + ⇣ └── "test-parallel" + outlined ■ ├── "__PAGE__" + ⇣ └── @sidebar/"(slot)" + outlined ■ └── "__PAGE__" + " + `) + } else { + expect(renderInliningTree(data.tree)).toMatchInlineSnapshot(` + " + ⇣ root + ⇣ └── "test-parallel" + ⇣ ├── @sidebar/"(slot)" + outlined ■ │ └── "__PAGE__" + outlined ■ └── "__PAGE__" + " + `) + } + }) - // Navigate to the second route. Because the data was fully prefetched, - // there should be no additional requests. - await act(async () => { - await browser.elementByCss('a[href="/shared/a/d/e"]').click() - }, 'no-requests') + it('home: root inlines directly into page', async () => { + // Simplest possible case: root layout + page. Root is small and inlines + // into the page. + const data = await fetchRouteTreePrefetch(next, '/') + expect(renderInliningTree(data.tree)).toMatchInlineSnapshot(` + " + ⇣ root + outlined ■ └── "__PAGE__" + " + `) + }) - // Verify the page rendered correctly - const text = await browser.elementByCss('#page-e').text() - expect(text).toBe('Page E') + it('restart: large segment in the middle creates two inlining groups', async () => { + // root (small) → test-restart (small) → large-middle (> 2KB) → after + // (small) → page (small). The large segment can't be inlined into its + // children, splitting the tree into two inlining groups: + // [root, test-restart] → large-middle's response, and [after] → page's + // response. + const data = await fetchRouteTreePrefetch( + next, + '/test-restart/large-middle/after' + ) + expect(renderInliningTree(data.tree)).toMatchInlineSnapshot(` + " + ⇣ root + ⇣ └── "test-restart" + outlined ■ └── "large-middle" + ⇣ └── "after" + outlined ■ └── "__PAGE__" + " + `) }) - it('works with dynamic routes', async () => { - // Regression test: the build previously failed with - // "Invariant: missing __PAGE__ segmentPath" when prefetchInlining was - // combined with dynamic routes. - const $ = await next.render$('/dynamic/hello') - expect($('#dynamic-page').text()).toBe('hello') + it('deep chain: all small segments inline to the leaf', async () => { + // root → test-deep → a → b → c → page, all small. Every segment in + // the chain inlines down to the page, producing a single fetch. + const data = await fetchRouteTreePrefetch(next, '/test-deep/a/b/c') + expect(renderInliningTree(data.tree)).toMatchInlineSnapshot(` + " + ⇣ root + ⇣ └── "test-deep" + ⇣ └── "a" + ⇣ └── "b" + ⇣ └── "c" + outlined ■ └── "__PAGE__" + " + `) }) - it('deduplicates inlined prefetch requests for the same route', async () => { - // Two links point to the same route (/shared/a/b/c). When the first - // link is revealed, its prefetch spawns an inlined request. While - // that request is still pending, revealing the second link should - // not spawn any additional requests because the segments are already - // Pending from the first task. - let page: Playwright.Page - - const browser = await next.browser('/', { - beforePageLoad(p: Playwright.Page) { - page = p - }, - }) - const act = createRouterAct(page!) - - await act( - async () => { - // Reveal the first link, blocking its response so the prefetch - // stays in-flight. - await act(async () => { - await browser - .elementByCss('input[data-link-accordion="/shared/a/b/c"]') - .click() - }, 'block') - - // While the first prefetch is still pending, reveal a second - // link to the same route. This should not spawn any new - // requests because the segments are already Pending. - await act(async () => { - await browser - .elementByCss('input[data-link-accordion="duplicate-a"]') - .click() - }, 'no-requests') - }, - { - includes: 'Page C', - } - ) + it('dynamic route: hints are based on concrete params, not fallback shell', async () => { + // The [slug] layout renders large content gated behind `await params`. In + // the fallback shell, `await params` suspends so the segment appears small. + // In a concrete render the full content is included, pushing it above the + // 2KB threshold. If hints were incorrectly based on the fallback, the + // layout would get inlined. Instead it should be outlined because the + // concrete render is large. + const data = await fetchRouteTreePrefetch(next, '/test-dynamic/hello') + const helloTree = renderInliningTree(data.tree) - // Navigate to the route. Because the data was fully prefetched, - // no additional requests are needed. - await act(async () => { - await browser.elementByCss('a[href="/shared/a/b/c"]').click() - }, 'no-requests') + expect(helloTree).toMatchInlineSnapshot(` + " + ⇣ root + ⇣ └── "test-dynamic" + outlined ■ └── "slug" + outlined ■ └── "__PAGE__" + " + `) - const text = await browser.elementByCss('#page-c').text() - expect(text).toBe('Page C') + // Different param value should produce the same hints (keyed by route + // pattern, not concrete path) + const data2 = await fetchRouteTreePrefetch(next, '/test-dynamic/world') + expect(renderInliningTree(data2.tree)).toBe(helloTree) }) }) From efcfe05a0b1ece7695e091f906b56f127aecdab2 Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Fri, 13 Mar 2026 17:26:03 +0100 Subject: [PATCH 06/16] [test] Deflake `instant-navs-devtools` (#91256) --- .../instant-navs-devtools.test.ts | 132 +++++------------- .../instant-navs-devtools/tsconfig.json | 24 ++++ 2 files changed, 61 insertions(+), 95 deletions(-) create mode 100644 test/development/app-dir/instant-navs-devtools/tsconfig.json diff --git a/test/development/app-dir/instant-navs-devtools/instant-navs-devtools.test.ts b/test/development/app-dir/instant-navs-devtools/instant-navs-devtools.test.ts index 4367ed5b94260..73d8786fa0715 100644 --- a/test/development/app-dir/instant-navs-devtools/instant-navs-devtools.test.ts +++ b/test/development/app-dir/instant-navs-devtools/instant-navs-devtools.test.ts @@ -1,96 +1,60 @@ import { nextTestSetup } from 'e2e-utils' -import { - retry, - waitForDevToolsIndicator, - toggleDevToolsIndicatorPopover, -} from 'next-test-utils' +import { retry, toggleDevToolsIndicatorPopover } from 'next-test-utils' +import { Playwright } from 'next-webdriver' describe('instant-nav-panel', () => { const { next } = nextTestSetup({ files: __dirname, }) - async function clearInstantModeCookie(browser: any) { - await browser.eval(() => { - document.cookie = 'next-instant-navigation-testing=; path=/; max-age=0' - }) - } - - async function clickInstantNavMenuItem(browser: any) { - await browser.eval(() => { - const portal = [].slice - .call(document.querySelectorAll('nextjs-portal')) - .find((p: any) => - p.shadowRoot.querySelector('[data-nextjs-toast]') - ) as any - portal?.shadowRoot?.querySelector('[data-instant-nav]')?.click() - }) + async function waitForPanelRouterTransition() { + // Run all the necessary CSS transitions + // and click-outside event handler adjustment due to cascading update. + // TODO: Consider disabling transitions entirely in Next.js tests. + await new Promise((resolve) => + setTimeout( + resolve, + // MENU_DURATION_MS + 200 + ) + ) } - async function clickStartClientNav(browser: any) { + async function clearInstantModeCookie(browser: Playwright) { await browser.eval(() => { - const portal = [].slice - .call(document.querySelectorAll('nextjs-portal')) - .find((p: any) => - p.shadowRoot.querySelector('[data-nextjs-toast]') - ) as any - portal?.shadowRoot?.querySelector('[data-instant-nav-client]')?.click() + document.cookie = 'next-instant-navigation-testing=; path=/; max-age=0' }) } - async function getBadgeStatus(browser: any): Promise { - return browser.eval(() => { - const portal = [].slice - .call(document.querySelectorAll('nextjs-portal')) - .find((p: any) => - p.shadowRoot.querySelector('[data-nextjs-toast]') - ) as any - return ( - portal?.shadowRoot - ?.querySelector('[data-next-badge]') - ?.getAttribute('data-status') || '' - ) - }) + async function clickInstantNavMenuItem(browser: Playwright) { + await browser.elementByCss('[data-instant-nav]').click() } - async function getPanelText(browser: any): Promise { - return browser.eval(() => { - const portal = [].slice - .call(document.querySelectorAll('nextjs-portal')) - .find((p: any) => - p.shadowRoot.querySelector('[data-nextjs-toast]') - ) as any - const panel = portal?.shadowRoot?.querySelector('.instant-nav-panel') - return panel?.innerText || '' - }) + async function clickStartClientNav(browser: Playwright) { + await browser.elementByCssInstant('[data-instant-nav-client]').click() } - async function hasPanelOpen(browser: any): Promise { - return browser.eval(() => { - const portal = [].slice - .call(document.querySelectorAll('nextjs-portal')) - .find((p: any) => - p.shadowRoot.querySelector('[data-nextjs-toast]') - ) as any - return !!portal?.shadowRoot?.querySelector('.instant-nav-panel') - }) + async function getInstantNavPanelText(browser: Playwright): Promise { + return browser.elementByCssInstant('.instant-nav-panel').text() } - async function closePanelViaHeader(browser: any) { - await browser.eval(() => { - const portal = [].slice - .call(document.querySelectorAll('nextjs-portal')) - .find((p: any) => - p.shadowRoot.querySelector('[data-nextjs-toast]') - ) as any - portal?.shadowRoot?.querySelector('#_next-devtools-panel-close')?.click() - }) + async function closePanelViaHeader(browser: Playwright) { + return browser.elementByCss('#_next-devtools-panel-close').click() } - async function openInstantNavPanel(browser: any) { - await waitForDevToolsIndicator(browser) + async function openInstantNavPanel(browser: Playwright) { await toggleDevToolsIndicatorPopover(browser) + await waitForPanelRouterTransition() await clickInstantNavMenuItem(browser) + + await retry( + async () => { + await browser.elementByCssInstant('.instant-nav-panel') + }, + 5_000, + 500 + ) + await waitForPanelRouterTransition() } it('should open panel in waiting state without setting cookie', async () => { @@ -98,17 +62,11 @@ describe('instant-nav-panel', () => { await clearInstantModeCookie(browser) await browser.waitForElementByCss('[data-testid="home-title"]') - // Wait for initial compilation to settle - await retry(async () => { - const status = await getBadgeStatus(browser) - expect(status).toBe('none') - }) - await openInstantNavPanel(browser) // Panel should show waiting state with Page load and Client navigation sections await retry(async () => { - const text = await getPanelText(browser) + const text = await getInstantNavPanelText(browser) expect(text).toContain('Page load') expect(text).toContain('Client navigation') }) @@ -126,19 +84,8 @@ describe('instant-nav-panel', () => { await clearInstantModeCookie(browser) await browser.waitForElementByCss('[data-testid="home-title"]') - // Wait for initial compilation to settle (tsconfig creation triggers Fast Refresh) - await retry(async () => { - const status = await getBadgeStatus(browser) - expect(status).toBe('none') - }) - await openInstantNavPanel(browser) - // Wait for panel to be open - await retry(async () => { - expect(await hasPanelOpen(browser)).toBe(true) - }) - // Click Start to enter client-nav-waiting state await clickStartClientNav(browser) @@ -150,7 +97,7 @@ describe('instant-nav-panel', () => { // Panel should show client-nav-waiting state await retry(async () => { - const text = await getPanelText(browser) + const text = await getInstantNavPanelText(browser) expect(text).toContain('Client navigation') expect(text).toContain('Click any link') }) @@ -162,7 +109,7 @@ describe('instant-nav-panel', () => { // Panel should transition to client-nav state await retry(async () => { - const text = await getPanelText(browser) + const text = await getInstantNavPanelText(browser) expect(text).toContain('Client navigation') expect(text).toContain('prefetched UI') expect(text).toContain('Continue rendering') @@ -179,11 +126,6 @@ describe('instant-nav-panel', () => { await openInstantNavPanel(browser) - // Wait for panel to be open - await retry(async () => { - expect(await hasPanelOpen(browser)).toBe(true) - }) - // Click Start to activate the navigation lock await clickStartClientNav(browser) diff --git a/test/development/app-dir/instant-navs-devtools/tsconfig.json b/test/development/app-dir/instant-navs-devtools/tsconfig.json new file mode 100644 index 0000000000000..99ea9cd99e825 --- /dev/null +++ b/test/development/app-dir/instant-navs-devtools/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": false, + "noEmit": true, + "incremental": true, + "module": "esnext", + "esModuleInterop": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "plugins": [ + { + "name": "next" + } + ] + }, + "include": ["next-env.d.ts", "**/*.mts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules", "**/*.test.ts", "**/*.test.tsx"] +} From d5e886399cc89b9f2278de76e104919a4bbda301 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Fri, 13 Mar 2026 09:39:29 -0700 Subject: [PATCH 07/16] Expose resolved invocation targets in next-routing (#91242) ### What? This PR extends `@next/routing` so `resolveRoutes()` returns the concrete route resolution data callers need after rewrites and dynamic matches: - rename `matchedPathname` to `resolvedPathname` - add `resolvedQuery` - add `invocationTarget` for the concrete pathname/query that should be invoked - export the new query/invocation target types from the package entrypoint It also removes the leftover `query` alias so the result shape consistently uses `resolvedQuery`. ### Why? `matchedPathname` only described part of the result, and it was ambiguous for dynamic routes because the resolved route template and the concrete invocation target are not always the same thing. For example, a dynamic route can resolve to `/blog/[slug]` while the actual invocation target is `/blog/post-1`, and rewrites can merge query params that callers need to preserve. Exposing these values directly makes the package easier to consume from adapters without each caller reconstructing them manually. ### How? - thread resolved query construction through the route resolution paths - build `invocationTarget` alongside `resolvedPathname` wherever rewrites, static matches, and dynamic matches resolve successfully - preserve merged rewrite query params in `resolvedQuery` - update the public types, README example, and existing tests to use `resolvedPathname` - add coverage for resolved query + invocation target behavior on rewrite and dynamic route matches Verified with: - `pnpm --filter @next/routing test -- --runInBand` - `pnpm --filter @next/routing build` --- .../01-next-config-js/adapterPath.mdx | 14 ++ packages/next-routing/README.md | 6 +- .../src/__tests__/captures.test.ts | 42 ++-- .../src/__tests__/conditions.test.ts | 38 ++-- .../__tests__/dynamic-after-rewrites.test.ts | 24 +-- .../src/__tests__/i18n-resolve-routes.test.ts | 8 +- .../src/__tests__/normalize-next-data.test.ts | 58 +++--- .../src/__tests__/redirects.test.ts | 12 +- .../src/__tests__/resolve-routes.test.ts | 155 +++++++++++++-- .../src/__tests__/rewrites.test.ts | 26 +-- packages/next-routing/src/index.ts | 3 + packages/next-routing/src/resolve-routes.ts | 188 ++++++++++++++---- packages/next-routing/src/types.ts | 28 ++- 13 files changed, 435 insertions(+), 167 deletions(-) diff --git a/docs/01-app/03-api-reference/05-config/01-next-config-js/adapterPath.mdx b/docs/01-app/03-api-reference/05-config/01-next-config-js/adapterPath.mdx index 6669c8940eb70..dee5511d1d402 100644 --- a/docs/01-app/03-api-reference/05-config/01-next-config-js/adapterPath.mdx +++ b/docs/01-app/03-api-reference/05-config/01-next-config-js/adapterPath.mdx @@ -444,8 +444,22 @@ const result = await resolveRoutes({ return {} }, }) + +if (result.resolvedPathname) { + console.log('Resolved pathname:', result.resolvedPathname) + console.log('Resolved query:', result.resolvedQuery) + console.log('Invocation target:', result.invocationTarget) +} ``` +`resolveRoutes()` returns: + +- `resolvedPathname`: The route pathname selected by Next.js routing. For dynamic routes, this is the matched route template such as `/blog/[slug]`. +- `resolvedQuery`: The final query after rewrites or middleware have added or replaced search params. +- `invocationTarget`: The concrete pathname and query to invoke for the matched route. + +For example, if `/blog/post-1?draft=1` matches `/blog/[slug]?slug=post-1`, `resolvedPathname` is `/blog/[slug]` while `invocationTarget.pathname` is `/blog/post-1`. + ## Implementing PPR in an Adapter For partially prerendered app routes, `onBuildComplete` gives you the data needed to seed and resume PPR: diff --git a/packages/next-routing/README.md b/packages/next-routing/README.md index 3a3d50a649435..7ab639736a9f3 100644 --- a/packages/next-routing/README.md +++ b/packages/next-routing/README.md @@ -39,8 +39,10 @@ const result = await resolveRoutes({ }, }) -if (result.matchedPathname) { - console.log('Matched:', result.matchedPathname) +if (result.resolvedPathname) { + console.log('Resolved pathname:', result.resolvedPathname) + console.log('Resolved query:', result.resolvedQuery) + console.log('Invocation target:', result.invocationTarget) } ``` diff --git a/packages/next-routing/src/__tests__/captures.test.ts b/packages/next-routing/src/__tests__/captures.test.ts index 00e6cf7707587..50d03af1807ec 100644 --- a/packages/next-routing/src/__tests__/captures.test.ts +++ b/packages/next-routing/src/__tests__/captures.test.ts @@ -54,7 +54,7 @@ describe('Regex Captures in Destination', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/posts/my-post') + expect(result.resolvedPathname).toBe('/posts/my-post') }) it('should replace multiple numbered captures $1, $2, $3', async () => { @@ -78,7 +78,7 @@ describe('Regex Captures in Destination', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/archive/2024/01/post-title') + expect(result.resolvedPathname).toBe('/archive/2024/01/post-title') }) it('should replace named captures in destination', async () => { @@ -102,7 +102,7 @@ describe('Regex Captures in Destination', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/u/alice/p/123') + expect(result.resolvedPathname).toBe('/u/alice/p/123') }) it('should mix numbered and named captures', async () => { @@ -126,7 +126,7 @@ describe('Regex Captures in Destination', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/internal/v1/user/john') + expect(result.resolvedPathname).toBe('/internal/v1/user/john') }) it('should use captures in query parameters', async () => { @@ -150,7 +150,7 @@ describe('Regex Captures in Destination', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/api/products') + expect(result.resolvedPathname).toBe('/api/products') }) it('should replace captures in external rewrite', async () => { @@ -240,7 +240,7 @@ describe('Has Condition Captures in Destination', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/users/12345/profile') + expect(result.resolvedPathname).toBe('/users/12345/profile') }) it('should use cookie value in destination', async () => { @@ -275,7 +275,7 @@ describe('Has Condition Captures in Destination', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/sessions/abc123xyz/dashboard') + expect(result.resolvedPathname).toBe('/sessions/abc123xyz/dashboard') }) it('should use query parameter value in destination', async () => { @@ -305,7 +305,7 @@ describe('Has Condition Captures in Destination', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/results/nextjs') + expect(result.resolvedPathname).toBe('/results/nextjs') }) it('should combine regex captures and has captures', async () => { @@ -340,7 +340,7 @@ describe('Has Condition Captures in Destination', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/tenants/acme/users/123') + expect(result.resolvedPathname).toBe('/tenants/acme/users/123') }) it('should combine named regex captures and has captures', async () => { @@ -375,7 +375,7 @@ describe('Has Condition Captures in Destination', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/api/v2/products/electronics') + expect(result.resolvedPathname).toBe('/api/v2/products/electronics') }) it('should use multiple has captures in destination', async () => { @@ -415,7 +415,7 @@ describe('Has Condition Captures in Destination', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/regions/us-west/tenants/acme/data') + expect(result.resolvedPathname).toBe('/regions/us-west/tenants/acme/data') }) it('should use has captures with regex pattern match', async () => { @@ -451,7 +451,7 @@ describe('Has Condition Captures in Destination', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/localized/en-US/page') + expect(result.resolvedPathname).toBe('/localized/en-US/page') }) it('should use has captures in query string', async () => { @@ -486,7 +486,7 @@ describe('Has Condition Captures in Destination', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/internal/dashboard') + expect(result.resolvedPathname).toBe('/internal/dashboard') }) it('should use has captures in external rewrite', async () => { @@ -601,7 +601,7 @@ describe('Has Condition Captures in Destination', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/users/123') + expect(result.resolvedPathname).toBe('/users/123') expect(result.resolvedHeaders?.get('Location')).toBe('/profiles/123') expect(result.resolvedHeaders?.get('x-user-id')).toBe('123') expect(result.resolvedHeaders?.get('x-language')).toBe('es') @@ -650,7 +650,7 @@ describe('Has Condition Captures in Destination', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/api/posts') + expect(result.resolvedPathname).toBe('/api/posts') expect(result.resolvedHeaders?.get('x-resource')).toBe('posts') expect(result.resolvedHeaders?.get('x-language')).toBe('fr') expect(result.resolvedHeaders?.get('x-combined')).toBe('posts-fr') @@ -689,7 +689,7 @@ describe('Has Condition Captures in Destination', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/fr/profile/john') + expect(result.resolvedPathname).toBe('/fr/profile/john') expect(result.routeMatches).toEqual({ '1': 'john', }) @@ -736,7 +736,7 @@ describe('Complex Capture Scenarios', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe( + expect(result.resolvedPathname).toBe( '/orgs/myorg/users/john/projects/backend/issues/42' ) }) @@ -762,7 +762,7 @@ describe('Complex Capture Scenarios', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/a/test/b/test/c/test') + expect(result.resolvedPathname).toBe('/a/test/b/test/c/test') }) it('should handle capture with special characters', async () => { @@ -786,7 +786,7 @@ describe('Complex Capture Scenarios', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/storage/my-file.test.js') + expect(result.resolvedPathname).toBe('/storage/my-file.test.js') }) it('should not replace undefined captures', async () => { @@ -810,7 +810,7 @@ describe('Complex Capture Scenarios', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/result/$1/$2') + expect(result.resolvedPathname).toBe('/result/$1/$2') }) it('should handle captures across chained rewrites', async () => { @@ -838,6 +838,6 @@ describe('Complex Capture Scenarios', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/internal/user-service/alice') + expect(result.resolvedPathname).toBe('/internal/user-service/alice') }) }) diff --git a/packages/next-routing/src/__tests__/conditions.test.ts b/packages/next-routing/src/__tests__/conditions.test.ts index f5a9e02109b33..9b4811715a68b 100644 --- a/packages/next-routing/src/__tests__/conditions.test.ts +++ b/packages/next-routing/src/__tests__/conditions.test.ts @@ -66,7 +66,7 @@ describe('Has conditions', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/admin-dashboard') + expect(result.resolvedPathname).toBe('/admin-dashboard') }) it('should match route with cookie condition', async () => { @@ -102,7 +102,7 @@ describe('Has conditions', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/dark-theme-page') + expect(result.resolvedPathname).toBe('/dark-theme-page') }) it('should match route with query condition', async () => { @@ -133,7 +133,7 @@ describe('Has conditions', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/preview-page') + expect(result.resolvedPathname).toBe('/preview-page') }) it('should match route with host condition', async () => { @@ -163,7 +163,7 @@ describe('Has conditions', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/subdomain-home') + expect(result.resolvedPathname).toBe('/subdomain-home') }) it('should match when has condition checks key existence only', async () => { @@ -199,7 +199,7 @@ describe('Has conditions', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/feature-enabled') + expect(result.resolvedPathname).toBe('/feature-enabled') }) it('should match with regex pattern in has condition', async () => { @@ -235,7 +235,7 @@ describe('Has conditions', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/mobile') + expect(result.resolvedPathname).toBe('/mobile') }) it('should require ALL has conditions to match', async () => { @@ -277,7 +277,7 @@ describe('Has conditions', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/admin-beta-feature') + expect(result.resolvedPathname).toBe('/admin-beta-feature') }) it('should NOT match when one has condition fails', async () => { @@ -320,7 +320,7 @@ describe('Has conditions', () => { const result = await resolveRoutes(params) // Should not match the route, so stays at /feature - expect(result.matchedPathname).toBe('/feature') + expect(result.resolvedPathname).toBe('/feature') }) }) @@ -357,7 +357,7 @@ describe('Missing conditions', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/no-debug-page') + expect(result.resolvedPathname).toBe('/no-debug-page') }) it('should NOT match when missing condition is present', async () => { @@ -393,7 +393,7 @@ describe('Missing conditions', () => { const result = await resolveRoutes(params) // Route should not match, stays at /page - expect(result.matchedPathname).toBe('/page') + expect(result.resolvedPathname).toBe('/page') }) it('should match when missing cookie is not present', async () => { @@ -428,7 +428,7 @@ describe('Missing conditions', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/no-tracking') + expect(result.resolvedPathname).toBe('/no-tracking') }) it('should match when missing query is not present', async () => { @@ -458,7 +458,7 @@ describe('Missing conditions', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/no-preview') + expect(result.resolvedPathname).toBe('/no-preview') }) it('should require ALL missing conditions to be absent', async () => { @@ -498,7 +498,7 @@ describe('Missing conditions', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/standard-page') + expect(result.resolvedPathname).toBe('/standard-page') }) }) @@ -543,7 +543,7 @@ describe('Combined has and missing conditions', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/member-content') + expect(result.resolvedPathname).toBe('/member-content') }) it('should NOT match when has is satisfied but missing is present', async () => { @@ -587,7 +587,7 @@ describe('Combined has and missing conditions', () => { const result = await resolveRoutes(params) // Should not match, stays at /content - expect(result.matchedPathname).toBe('/content') + expect(result.resolvedPathname).toBe('/content') }) it('should NOT match when has fails even if missing is satisfied', async () => { @@ -631,7 +631,7 @@ describe('Combined has and missing conditions', () => { const result = await resolveRoutes(params) // Should not match, stays at /content - expect(result.matchedPathname).toBe('/content') + expect(result.resolvedPathname).toBe('/content') }) }) @@ -657,7 +657,7 @@ describe('Dynamic routes', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/posts/123') + expect(result.resolvedPathname).toBe('/posts/123') expect(result.routeMatches).toEqual({ '1': '123', }) @@ -684,7 +684,7 @@ describe('Dynamic routes', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/users/alice/posts/456') + expect(result.resolvedPathname).toBe('/users/alice/posts/456') expect(result.routeMatches).toEqual({ '1': 'alice', '2': '456', @@ -726,7 +726,7 @@ describe('Dynamic routes', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/profile/john') + expect(result.resolvedPathname).toBe('/profile/john') expect(result.routeMatches).toEqual({ '1': 'john', }) diff --git a/packages/next-routing/src/__tests__/dynamic-after-rewrites.test.ts b/packages/next-routing/src/__tests__/dynamic-after-rewrites.test.ts index 222b5c10bff45..519025fe663a5 100644 --- a/packages/next-routing/src/__tests__/dynamic-after-rewrites.test.ts +++ b/packages/next-routing/src/__tests__/dynamic-after-rewrites.test.ts @@ -59,7 +59,7 @@ describe('Dynamic Routes After afterFiles Rewrites', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/posts/my-post') + expect(result.resolvedPathname).toBe('/posts/my-post') expect(result.routeMatches).toEqual({ '1': 'my-post', slug: 'my-post', @@ -96,7 +96,7 @@ describe('Dynamic Routes After afterFiles Rewrites', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/posts/article') + expect(result.resolvedPathname).toBe('/posts/article') expect(result.routeMatches).toEqual({ '1': 'article', slug: 'article', @@ -137,7 +137,7 @@ describe('Dynamic Routes After afterFiles Rewrites', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/users/test') + expect(result.resolvedPathname).toBe('/users/test') expect(result.routeMatches).toEqual({ '1': 'test', username: 'test', @@ -178,7 +178,7 @@ describe('Dynamic Routes After afterFiles Rewrites', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/posts/post') + expect(result.resolvedPathname).toBe('/posts/post') expect(result.routeMatches).toEqual({ '1': 'post', slug: 'post', @@ -222,7 +222,7 @@ describe('Dynamic Routes After afterFiles Rewrites', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/users/profile') + expect(result.resolvedPathname).toBe('/users/profile') expect(result.routeMatches).toEqual({ '1': 'profile', page: 'profile', @@ -257,7 +257,7 @@ describe('Dynamic Routes After fallback Rewrites', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/catch-all/page') + expect(result.resolvedPathname).toBe('/catch-all/page') expect(result.routeMatches).toEqual({ '1': 'page', path: 'page', @@ -294,7 +294,7 @@ describe('Dynamic Routes After fallback Rewrites', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/error/404') + expect(result.resolvedPathname).toBe('/error/404') expect(result.routeMatches).toEqual({ '1': '404', code: '404', @@ -331,7 +331,7 @@ describe('Dynamic Routes After fallback Rewrites', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/fallback/404') + expect(result.resolvedPathname).toBe('/fallback/404') expect(result.routeMatches).toEqual({ '1': '404', type: '404', @@ -368,7 +368,7 @@ describe('Dynamic Routes After fallback Rewrites', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/error/500') + expect(result.resolvedPathname).toBe('/error/500') expect(result.routeMatches).toEqual({ '1': '500', code: '500', @@ -405,7 +405,7 @@ describe('Dynamic Routes After fallback Rewrites', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/users/123') + expect(result.resolvedPathname).toBe('/users/123') expect(result.routeMatches).toEqual({ '1': '123', id: '123', @@ -446,7 +446,7 @@ describe('Mixed afterFiles and fallback with Dynamic Routes', () => { const result = await resolveRoutes(params) // Should match in afterFiles -> dynamic routes, not reach fallback - expect(result.matchedPathname).toBe('/posts/article') + expect(result.resolvedPathname).toBe('/posts/article') expect(result.routeMatches).toEqual({ '1': 'article', slug: 'article', @@ -485,7 +485,7 @@ describe('Mixed afterFiles and fallback with Dynamic Routes', () => { const result = await resolveRoutes(params) // Should go through: test -> intermediate (no match) -> fallback -> posts/fallback (dynamic match) - expect(result.matchedPathname).toBe('/posts/fallback') + expect(result.resolvedPathname).toBe('/posts/fallback') expect(result.routeMatches).toEqual({ '1': 'fallback', slug: 'fallback', diff --git a/packages/next-routing/src/__tests__/i18n-resolve-routes.test.ts b/packages/next-routing/src/__tests__/i18n-resolve-routes.test.ts index 8d8b9f8db75ba..dddfab8e3531d 100644 --- a/packages/next-routing/src/__tests__/i18n-resolve-routes.test.ts +++ b/packages/next-routing/src/__tests__/i18n-resolve-routes.test.ts @@ -166,7 +166,7 @@ describe('resolveRoutes with i18n', () => { // Path locale takes priority, so no redirect expect(result.redirect).toBeUndefined() - expect(result.matchedPathname).toBe('/fr/about') + expect(result.resolvedPathname).toBe('/fr/about') }) it('should handle locale prefix with trailing slash', async () => { @@ -179,7 +179,7 @@ describe('resolveRoutes with i18n', () => { }) expect(result.redirect).toBeUndefined() - expect(result.matchedPathname).toBe('/fr/about/') + expect(result.resolvedPathname).toBe('/fr/about/') }) }) @@ -326,7 +326,7 @@ describe('resolveRoutes with i18n', () => { // Should not redirect for api routes expect(result.redirect).toBeUndefined() - expect(result.matchedPathname).toBe('/api/ping') + expect(result.resolvedPathname).toBe('/api/ping') }) it('should skip locale handling for nested api routes', async () => { @@ -352,7 +352,7 @@ describe('resolveRoutes with i18n', () => { // Should not redirect for api routes, even with locale cookies/headers expect(result.redirect).toBeUndefined() - expect(result.matchedPathname).toBe('/api/users/[id]') + expect(result.resolvedPathname).toBe('/api/users/[id]') expect(result.routeMatches).toEqual({ '1': '123', id: '123', diff --git a/packages/next-routing/src/__tests__/normalize-next-data.test.ts b/packages/next-routing/src/__tests__/normalize-next-data.test.ts index 53939eed565b5..c87245396c08c 100644 --- a/packages/next-routing/src/__tests__/normalize-next-data.test.ts +++ b/packages/next-routing/src/__tests__/normalize-next-data.test.ts @@ -56,7 +56,7 @@ describe('normalizeNextData - beforeMiddleware', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe( + expect(result.resolvedPathname).toBe( '/_next/data/BUILD_ID/api/blog/post.json' ) }) @@ -84,7 +84,7 @@ describe('normalizeNextData - beforeMiddleware', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe( + expect(result.resolvedPathname).toBe( '/base/_next/data/BUILD_ID/api/page.json' ) }) @@ -109,7 +109,9 @@ describe('normalizeNextData - pathname checking', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/_next/data/BUILD_ID/posts/hello.json') + expect(result.resolvedPathname).toBe( + '/_next/data/BUILD_ID/posts/hello.json' + ) }) it('should work with rewrites then pathname check', async () => { @@ -135,7 +137,7 @@ describe('normalizeNextData - pathname checking', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/_next/data/BUILD_ID/posts.json') + expect(result.resolvedPathname).toBe('/_next/data/BUILD_ID/posts.json') }) }) @@ -163,7 +165,7 @@ describe('normalizeNextData - afterFiles', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/_next/data/BUILD_ID/404.json') + expect(result.resolvedPathname).toBe('/_next/data/BUILD_ID/404.json') }) it('should handle complex flow: normalize -> beforeFiles -> denormalize -> normalize -> afterFiles -> denormalize', async () => { @@ -194,7 +196,7 @@ describe('normalizeNextData - afterFiles', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe( + expect(result.resolvedPathname).toBe( '/_next/data/BUILD_ID/internal/users.json' ) }) @@ -224,7 +226,7 @@ describe('normalizeNextData - dynamic routes', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/_next/data/BUILD_ID/posts/123.json') + expect(result.resolvedPathname).toBe('/_next/data/BUILD_ID/posts/123.json') expect(result.routeMatches).toEqual({ '1': '123', id: '123', @@ -261,7 +263,9 @@ describe('normalizeNextData - dynamic routes', () => { const result = await resolveRoutes(params) // Should match with denormalized pathname - expect(result.matchedPathname).toBe('/_next/data/BUILD_ID/users/alice.json') + expect(result.resolvedPathname).toBe( + '/_next/data/BUILD_ID/users/alice.json' + ) expect(result.routeMatches).toEqual({ '1': 'alice', username: 'alice', @@ -296,7 +300,7 @@ describe('normalizeNextData - dynamic routes', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe( + expect(result.resolvedPathname).toBe( '/_next/data/BUILD_ID/posts/post-1.json' ) expect(result.routeMatches).toEqual({ @@ -330,7 +334,7 @@ describe('normalizeNextData - fallback routes', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/_next/data/BUILD_ID/404.json') + expect(result.resolvedPathname).toBe('/_next/data/BUILD_ID/404.json') }) }) @@ -358,7 +362,7 @@ describe('normalizeNextData - without normalization', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/posts/post') + expect(result.resolvedPathname).toBe('/posts/post') }) it('should not normalize when normalizeNextData is undefined', async () => { @@ -384,7 +388,7 @@ describe('normalizeNextData - without normalization', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/api/page') + expect(result.resolvedPathname).toBe('/api/page') }) it('should not normalize URLs that are not data URLs', async () => { @@ -405,7 +409,7 @@ describe('normalizeNextData - without normalization', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/regular/path') + expect(result.resolvedPathname).toBe('/regular/path') }) it('should not apply normalization to non-data URLs even with rewrites', async () => { @@ -431,7 +435,7 @@ describe('normalizeNextData - without normalization', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/posts/post') + expect(result.resolvedPathname).toBe('/posts/post') }) it('should not normalize if rewrite creates a data URL pattern from non-data URL', async () => { @@ -459,7 +463,7 @@ describe('normalizeNextData - without normalization', () => { const result = await resolveRoutes(params) // Should NOT normalize because original URL was not a data URL - expect(result.matchedPathname).toBe('/_next/data/BUILD_ID/some/path.json') + expect(result.resolvedPathname).toBe('/_next/data/BUILD_ID/some/path.json') }) it('should not normalize in afterFiles if original URL was not a data URL', async () => { @@ -493,7 +497,7 @@ describe('normalizeNextData - without normalization', () => { const result = await resolveRoutes(params) // Should match because afterFiles rewrite should work on the unrewritten data URL path - expect(result.matchedPathname).toBe('/processed.json') + expect(result.resolvedPathname).toBe('/processed.json') }) it('should not normalize data URLs with different buildId', async () => { @@ -514,7 +518,7 @@ describe('normalizeNextData - without normalization', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/_next/data/DIFFERENT_ID/page.json') + expect(result.resolvedPathname).toBe('/_next/data/DIFFERENT_ID/page.json') }) it('should handle data URL without .json extension', async () => { @@ -535,7 +539,7 @@ describe('normalizeNextData - without normalization', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/_next/data/BUILD_ID/page.json') + expect(result.resolvedPathname).toBe('/_next/data/BUILD_ID/page.json') }) it('should resolve to _next/data pathname when both exist and URL is a data URL', async () => { @@ -558,7 +562,7 @@ describe('normalizeNextData - without normalization', () => { const result = await resolveRoutes(params) // Should match the denormalized path - expect(result.matchedPathname).toBe('/_next/data/BUILD_ID/posts.json') + expect(result.resolvedPathname).toBe('/_next/data/BUILD_ID/posts.json') }) it('should resolve to normalized pathname when both exist and URL is NOT a data URL', async () => { @@ -581,7 +585,7 @@ describe('normalizeNextData - without normalization', () => { const result = await resolveRoutes(params) // Should match the normalized path - expect(result.matchedPathname).toBe('/posts') + expect(result.resolvedPathname).toBe('/posts') }) it('should resolve to _next/data pathname after rewrite when both exist and original URL is data URL', async () => { @@ -609,7 +613,7 @@ describe('normalizeNextData - without normalization', () => { const result = await resolveRoutes(params) // Should match the denormalized path - expect(result.matchedPathname).toBe('/_next/data/BUILD_ID/posts.json') + expect(result.resolvedPathname).toBe('/_next/data/BUILD_ID/posts.json') }) it('should resolve to normalized pathname after rewrite when both exist and original URL is NOT data URL', async () => { @@ -637,7 +641,7 @@ describe('normalizeNextData - without normalization', () => { const result = await resolveRoutes(params) // Should match the normalized path - expect(result.matchedPathname).toBe('/posts') + expect(result.resolvedPathname).toBe('/posts') }) it('should resolve to _next/data pathname after afterFiles rewrite when original URL is data URL', async () => { @@ -665,7 +669,7 @@ describe('normalizeNextData - without normalization', () => { const result = await resolveRoutes(params) // Should match the denormalized path - expect(result.matchedPathname).toBe('/_next/data/BUILD_ID/404.json') + expect(result.resolvedPathname).toBe('/_next/data/BUILD_ID/404.json') }) it('should resolve to normalized pathname after afterFiles rewrite when original URL is NOT data URL', async () => { @@ -693,7 +697,7 @@ describe('normalizeNextData - without normalization', () => { const result = await resolveRoutes(params) // Should match the normalized path - expect(result.matchedPathname).toBe('/404') + expect(result.resolvedPathname).toBe('/404') }) it('should resolve to _next/data pathname with dynamic routes when both exist and original URL is data URL', async () => { @@ -725,7 +729,9 @@ describe('normalizeNextData - without normalization', () => { const result = await resolveRoutes(params) // Should match the denormalized path with the first dynamic route - expect(result.matchedPathname).toBe('/_next/data/BUILD_ID/posts/hello.json') + expect(result.resolvedPathname).toBe( + '/_next/data/BUILD_ID/posts/hello.json' + ) expect(result.routeMatches).toEqual({ '1': 'hello', slug: 'hello', @@ -761,7 +767,7 @@ describe('normalizeNextData - without normalization', () => { const result = await resolveRoutes(params) // Should match the normalized path with the second dynamic route - expect(result.matchedPathname).toBe('/posts/hello') + expect(result.resolvedPathname).toBe('/posts/hello') expect(result.routeMatches).toEqual({ '1': 'hello', slug: 'hello', diff --git a/packages/next-routing/src/__tests__/redirects.test.ts b/packages/next-routing/src/__tests__/redirects.test.ts index 7f4e4e2ebb064..06b31edf439cb 100644 --- a/packages/next-routing/src/__tests__/redirects.test.ts +++ b/packages/next-routing/src/__tests__/redirects.test.ts @@ -60,7 +60,7 @@ describe('Redirects with Location header', () => { expect(result.redirect).toBeDefined() expect(result.redirect?.status).toBe(301) expect(result.redirect?.url.pathname).toBe('/new') - expect(result.matchedPathname).toBeUndefined() + expect(result.resolvedPathname).toBeUndefined() expect(result.externalRewrite).toBeUndefined() }) @@ -292,9 +292,9 @@ describe('Redirects with Refresh header', () => { const result = await resolveRoutes(params) - // Should return redirect, not matchedPathname + // Should return redirect, not resolvedPathname expect(result.redirect).toBeDefined() - expect(result.matchedPathname).toBeUndefined() + expect(result.resolvedPathname).toBeUndefined() }) }) @@ -440,7 +440,7 @@ describe('Redirect edge cases', () => { // Should not redirect, should rewrite instead expect(result.redirect).toBeUndefined() - expect(result.matchedPathname).toBe('/target') + expect(result.resolvedPathname).toBe('/target') }) it('should NOT redirect when status is 3xx but no Location/Refresh header', async () => { @@ -470,7 +470,7 @@ describe('Redirect edge cases', () => { // Should not redirect without Location or Refresh header expect(result.redirect).toBeUndefined() - expect(result.matchedPathname).toBe('/target') + expect(result.resolvedPathname).toBe('/target') }) it('should stop processing routes after redirect', async () => { @@ -503,7 +503,7 @@ describe('Redirect edge cases', () => { expect(result.redirect).toBeDefined() expect(result.redirect?.url.pathname).toBe('/redirected') - expect(result.matchedPathname).toBeUndefined() + expect(result.resolvedPathname).toBeUndefined() }) it('should handle case-insensitive Location header check', async () => { diff --git a/packages/next-routing/src/__tests__/resolve-routes.test.ts b/packages/next-routing/src/__tests__/resolve-routes.test.ts index a8ca5218b017b..4aacddfc66f8f 100644 --- a/packages/next-routing/src/__tests__/resolve-routes.test.ts +++ b/packages/next-routing/src/__tests__/resolve-routes.test.ts @@ -56,7 +56,7 @@ describe('resolveRoutes - beforeMiddleware', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/new-path') + expect(result.resolvedPathname).toBe('/new-path') expect(result.resolvedHeaders).toBeDefined() }) @@ -138,7 +138,7 @@ describe('resolveRoutes - beforeMiddleware', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/third') + expect(result.resolvedPathname).toBe('/third') }) }) @@ -172,7 +172,7 @@ describe('resolveRoutes - invokeMiddleware', () => { const result = await resolveRoutes(params) expect(result.middlewareResponded).toBe(true) - expect(result.matchedPathname).toBeUndefined() + expect(result.resolvedPathname).toBeUndefined() }) it('should handle middleware redirect', async () => { @@ -206,7 +206,7 @@ describe('resolveRoutes - invokeMiddleware', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/rewritten') + expect(result.resolvedPathname).toBe('/rewritten') }) it('should handle middleware external rewrite', async () => { @@ -258,7 +258,7 @@ describe('resolveRoutes - invokeMiddleware', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/internal') + expect(result.resolvedPathname).toBe('/internal') expect(result.resolvedHeaders?.get('x-custom-header')).toBeNull() }) @@ -284,7 +284,7 @@ describe('resolveRoutes - invokeMiddleware', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/test') + expect(result.resolvedPathname).toBe('/test') expect(result.resolvedHeaders?.get('x-response-header')).toBe( 'response-value' ) @@ -304,7 +304,7 @@ describe('resolveRoutes - invokeMiddleware', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/test') + expect(result.resolvedPathname).toBe('/test') expect(result.resolvedHeaders?.get('authorization')).toBeNull() expect(result.resolvedHeaders?.get('x-request-id')).toBeNull() }) @@ -332,7 +332,7 @@ describe('resolveRoutes - beforeFiles', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/internal-api/users') + expect(result.resolvedPathname).toBe('/internal-api/users') }) it('should handle redirect in beforeFiles', async () => { @@ -419,7 +419,7 @@ describe('resolveRoutes - beforeFiles', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/final') + expect(result.resolvedPathname).toBe('/final') }) }) @@ -445,7 +445,7 @@ describe('resolveRoutes - afterFiles', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/404') + expect(result.resolvedPathname).toBe('/404') }) it('should handle redirect in afterFiles', async () => { @@ -522,7 +522,7 @@ describe('resolveRoutes - afterFiles', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/middle') + expect(result.resolvedPathname).toBe('/middle') }) }) @@ -548,7 +548,7 @@ describe('resolveRoutes - fallback', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/default') + expect(result.resolvedPathname).toBe('/default') }) it('should handle redirect in fallback', async () => { @@ -631,7 +631,7 @@ describe('resolveRoutes - fallback', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/fallback-final') + expect(result.resolvedPathname).toBe('/fallback-final') }) }) @@ -660,7 +660,7 @@ describe('resolveRoutes - routes without destination', () => { const result = await resolveRoutes(params) expect(result.resolvedHeaders?.get('x-custom-header')).toBe('value') - expect(result.matchedPathname).toBe('/headers-only') + expect(result.resolvedPathname).toBe('/headers-only') }) it('should process routes with status only', async () => { @@ -685,7 +685,7 @@ describe('resolveRoutes - routes without destination', () => { const result = await resolveRoutes(params) expect(result.status).toBe(418) - expect(result.matchedPathname).toBe('/status-only') + expect(result.resolvedPathname).toBe('/status-only') }) it('should process multiple routes without destination in sequence', async () => { @@ -721,7 +721,7 @@ describe('resolveRoutes - routes without destination', () => { expect(result.resolvedHeaders?.get('x-header-1')).toBe('1') expect(result.resolvedHeaders?.get('x-header-2')).toBe('2') expect(result.status).toBe(200) - expect(result.matchedPathname).toBe('/multi') + expect(result.resolvedPathname).toBe('/multi') }) }) @@ -749,7 +749,7 @@ describe('resolveRoutes - dynamic routes', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/dynamic/[slug]') + expect(result.resolvedPathname).toBe('/dynamic/[slug]') expect(result.routeMatches).toEqual({ '1': 'page', nxtPslug: 'page', @@ -777,7 +777,7 @@ describe('resolveRoutes - dynamic routes', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/posts/[year]/[slug]') + expect(result.resolvedPathname).toBe('/posts/[year]/[slug]') expect(result.routeMatches).toEqual({ '1': '2024', '2': 'my-article', @@ -807,7 +807,7 @@ describe('resolveRoutes - dynamic routes', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/docs/[...path]') + expect(result.resolvedPathname).toBe('/docs/[...path]') expect(result.routeMatches).toEqual({ '1': 'getting-started/installation', path: 'getting-started/installation', @@ -835,7 +835,7 @@ describe('resolveRoutes - dynamic routes', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBeUndefined() + expect(result.resolvedPathname).toBeUndefined() }) it('should apply onMatch headers for dynamic routes', async () => { @@ -866,7 +866,120 @@ describe('resolveRoutes - dynamic routes', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/api/[resource]') + expect(result.resolvedPathname).toBe('/api/[resource]') expect(result.resolvedHeaders?.get('x-matched')).toBe('true') }) + + it('should apply onMatch headers using merged destination query for dynamic routes', async () => { + const params = createBaseParams({ + url: new URL('https://example.com/blog/post-1?draft=1'), + routes: { + beforeMiddleware: [], + beforeFiles: [], + afterFiles: [], + dynamicRoutes: [ + { + sourceRegex: '^/blog/(?[^/]+?)$', + destination: '/blog/[slug]?slug=$slug', + }, + ], + onMatch: [ + { + sourceRegex: '^/blog/post-1$', + has: [ + { + type: 'query', + key: 'slug', + value: 'post-1', + }, + ], + headers: { + 'x-slug-match': 'true', + }, + }, + ], + fallback: [], + }, + pathnames: ['/blog/[slug]'], + }) + + const result = await resolveRoutes(params) + + expect(result.resolvedPathname).toBe('/blog/[slug]') + expect(result.resolvedHeaders?.get('x-slug-match')).toBe('true') + expect(result.resolvedQuery).toEqual({ + draft: '1', + slug: 'post-1', + }) + }) + + it('should expose resolved query and invocation target for rewrite matches', async () => { + const params = createBaseParams({ + url: new URL('https://example.com/rewrite-source?existing=1'), + routes: { + beforeMiddleware: [], + beforeFiles: [ + { + sourceRegex: '^/rewrite-source$', + destination: '/rewrite-target?added=2', + }, + ], + afterFiles: [], + dynamicRoutes: [], + onMatch: [], + fallback: [], + }, + pathnames: ['/rewrite-target'], + }) + + const result = await resolveRoutes(params) + + expect(result.resolvedPathname).toBe('/rewrite-target') + expect(result.resolvedQuery).toEqual({ + existing: '1', + added: '2', + }) + expect(result.invocationTarget).toEqual({ + pathname: '/rewrite-target', + query: { + existing: '1', + added: '2', + }, + }) + }) + + it('should expose concrete invocation target for dynamic route matches', async () => { + const params = createBaseParams({ + url: new URL('https://example.com/blog/post-1?draft=1'), + routes: { + beforeMiddleware: [], + beforeFiles: [], + afterFiles: [], + dynamicRoutes: [ + { + sourceRegex: '^/blog/(?[^/]+?)$', + destination: '/blog/[slug]?slug=$slug', + }, + ], + onMatch: [], + fallback: [], + }, + pathnames: ['/blog/[slug]'], + }) + + const result = await resolveRoutes(params) + + expect(result.resolvedPathname).toBe('/blog/[slug]') + expect(result.resolvedQuery).toEqual({ + draft: '1', + slug: 'post-1', + }) + expect(result.invocationTarget).toEqual({ + pathname: '/blog/post-1', + query: { + draft: '1', + slug: 'post-1', + }, + }) + }) }) diff --git a/packages/next-routing/src/__tests__/rewrites.test.ts b/packages/next-routing/src/__tests__/rewrites.test.ts index e5cfa58bba6d3..f7a1700695921 100644 --- a/packages/next-routing/src/__tests__/rewrites.test.ts +++ b/packages/next-routing/src/__tests__/rewrites.test.ts @@ -54,7 +54,7 @@ describe('Internal Rewrites', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/posts') + expect(result.resolvedPathname).toBe('/posts') expect(result.externalRewrite).toBeUndefined() }) @@ -79,7 +79,7 @@ describe('Internal Rewrites', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/api/search') + expect(result.resolvedPathname).toBe('/api/search') }) it('should preserve original query params during internal rewrite', async () => { @@ -103,7 +103,7 @@ describe('Internal Rewrites', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/internal/page') + expect(result.resolvedPathname).toBe('/internal/page') }) it('should handle regex captures in internal rewrite', async () => { @@ -127,7 +127,7 @@ describe('Internal Rewrites', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/profile/john') + expect(result.resolvedPathname).toBe('/profile/john') }) it('should handle named captures in internal rewrite', async () => { @@ -151,7 +151,7 @@ describe('Internal Rewrites', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/api/product') + expect(result.resolvedPathname).toBe('/api/product') }) }) @@ -178,7 +178,7 @@ describe('External Rewrites', () => { expect(result.externalRewrite).toBeDefined() expect(result.externalRewrite?.toString()).toBe('http://external.com/api') - expect(result.matchedPathname).toBeUndefined() + expect(result.resolvedPathname).toBeUndefined() }) it('should handle external rewrite with https', async () => { @@ -381,7 +381,7 @@ describe('Chained Internal Rewrites', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/d') + expect(result.resolvedPathname).toBe('/d') }) it('should chain rewrites across different phases', async () => { @@ -415,7 +415,7 @@ describe('Chained Internal Rewrites', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/final') + expect(result.resolvedPathname).toBe('/final') }) it('should complete chaining then check pathname match', async () => { @@ -444,7 +444,7 @@ describe('Chained Internal Rewrites', () => { const result = await resolveRoutes(params) // Should chain through all routes, then match pathname - expect(result.matchedPathname).toBe('/path3') + expect(result.resolvedPathname).toBe('/path3') }) it('should chain with regex captures preserved', async () => { @@ -472,7 +472,7 @@ describe('Chained Internal Rewrites', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/api/user-content') + expect(result.resolvedPathname).toBe('/api/user-content') }) it('should stop chaining on external rewrite', async () => { @@ -504,7 +504,7 @@ describe('Chained Internal Rewrites', () => { const result = await resolveRoutes(params) expect(result.externalRewrite?.toString()).toBe('https://external.com/api') - expect(result.matchedPathname).toBeUndefined() + expect(result.resolvedPathname).toBeUndefined() }) it('should stop chaining on redirect', async () => { @@ -542,7 +542,7 @@ describe('Chained Internal Rewrites', () => { expect(result.redirect).toBeDefined() expect(result.redirect?.status).toBe(301) expect(result.redirect?.url.pathname).toBe('/final-destination') - expect(result.matchedPathname).toBeUndefined() + expect(result.resolvedPathname).toBeUndefined() }) it('should handle complex chaining scenario', async () => { @@ -571,6 +571,6 @@ describe('Chained Internal Rewrites', () => { const result = await resolveRoutes(params) - expect(result.matchedPathname).toBe('/api/posts') + expect(result.resolvedPathname).toBe('/api/posts') }) }) diff --git a/packages/next-routing/src/index.ts b/packages/next-routing/src/index.ts index f8db6c5d2f17a..bf6aa7180380c 100644 --- a/packages/next-routing/src/index.ts +++ b/packages/next-routing/src/index.ts @@ -6,6 +6,9 @@ export type { MiddlewareResult, ResolveRoutesParams, ResolveRoutesResult, + ResolveRoutesQuery, + ResolveRoutesQueryValue, + RouteInvocationTarget, } from './types' export type { I18nConfig, I18nDomain } from './i18n' export { diff --git a/packages/next-routing/src/resolve-routes.ts b/packages/next-routing/src/resolve-routes.ts index c0a33797db022..78851d3bfd95a 100644 --- a/packages/next-routing/src/resolve-routes.ts +++ b/packages/next-routing/src/resolve-routes.ts @@ -1,4 +1,9 @@ -import type { Route, ResolveRoutesParams, ResolveRoutesResult } from './types' +import type { + Route, + ResolveRoutesParams, + ResolveRoutesQuery, + ResolveRoutesResult, +} from './types' import { checkHasConditions, checkMissingConditions } from './matchers' import { replaceDestination, @@ -168,6 +173,58 @@ function matchesPathname( return undefined } +function toResolvedQuery(url: URL): ResolveRoutesQuery { + const query: ResolveRoutesQuery = {} + for (const [key, value] of url.searchParams.entries()) { + const existing = query[key] + if (existing === undefined) { + query[key] = value + continue + } + query[key] = Array.isArray(existing) + ? [...existing, value] + : [existing, value] + } + return query +} + +function mergeDestinationQueryIntoUrl(url: URL, destination: string): URL { + const mergedUrl = new URL(url.toString()) + const destinationSearch = destination.split('?')[1] + if (!destinationSearch) { + return mergedUrl + } + + const destinationParams = new URLSearchParams(destinationSearch) + for (const [key, value] of destinationParams.entries()) { + mergedUrl.searchParams.set(key, value) + } + return mergedUrl +} + +function withResolvedInvocationTarget({ + result, + url, + resolvedPathname, + invocationPathname, +}: { + result: ResolveRoutesResult + url: URL + resolvedPathname: string + invocationPathname: string +}): ResolveRoutesResult { + const resolvedQuery = toResolvedQuery(url) + return { + ...result, + resolvedPathname, + resolvedQuery, + invocationTarget: { + pathname: invocationPathname, + query: resolvedQuery, + }, + } +} + /** * Matches dynamic routes and extracts route parameters */ @@ -265,30 +322,41 @@ function checkDynamicRoutes( ) if (hasResult.matched && missingMatched) { - // Check if the destination pathname (template path) is in the provided pathnames list - // For dynamic routes, the destination contains the template path like /dynamic/[slug] - const pathnameToCheck = route.destination + const replacedDestination = route.destination ? replaceDestination( route.destination, match.regexMatches || null, hasResult.captures - ).split('?')[0] + ) + : undefined + // Check if the destination pathname (template path) is in the provided pathnames list + // For dynamic routes, the destination contains the template path like /dynamic/[slug] + const pathnameToCheck = replacedDestination + ? replacedDestination.split('?')[0] : checkUrl.pathname const matchedPath = matchesPathname(pathnameToCheck, pathnames) if (matchedPath) { + const resolvedUrl = replacedDestination + ? mergeDestinationQueryIntoUrl(checkUrl, replacedDestination) + : checkUrl const finalHeaders = applyOnMatchHeaders( onMatchRoutes, - checkUrl, + resolvedUrl, requestHeaders, responseHeaders ) - return { - matched: true, + const result = withResolvedInvocationTarget({ result: { - matchedPathname: matchedPath, routeMatches: match.params, resolvedHeaders: finalHeaders, }, + url: resolvedUrl, + resolvedPathname: matchedPath, + invocationPathname: checkUrl.pathname, + }) + return { + matched: true, + result, resetUrl: checkUrl, // Return the denormalized URL to reset to } } @@ -580,18 +648,32 @@ export async function resolveRoutes( ) if (hasResult.matched && missingMatched) { + const replacedDestination = route.destination + ? replaceDestination( + route.destination, + match.regexMatches || null, + hasResult.captures + ) + : undefined + const resolvedUrl = replacedDestination + ? mergeDestinationQueryIntoUrl(currentUrl, replacedDestination) + : currentUrl const finalHeaders = applyOnMatchHeaders( routes.onMatch, - currentUrl, + resolvedUrl, currentRequestHeaders, currentResponseHeaders ) - return { - matchedPathname: matchedPath, - routeMatches: match.params, - resolvedHeaders: finalHeaders, - status: currentStatus, - } + return withResolvedInvocationTarget({ + result: { + routeMatches: match.params, + resolvedHeaders: finalHeaders, + status: currentStatus, + }, + url: resolvedUrl, + resolvedPathname: matchedPath, + invocationPathname: currentUrl.pathname, + }) } } } @@ -603,11 +685,15 @@ export async function resolveRoutes( currentRequestHeaders, currentResponseHeaders ) - return { - matchedPathname: matchedPath, - resolvedHeaders: finalHeaders, - status: currentStatus, - } + return withResolvedInvocationTarget({ + result: { + resolvedHeaders: finalHeaders, + status: currentStatus, + }, + url: currentUrl, + resolvedPathname: matchedPath, + invocationPathname: currentUrl.pathname, + }) } // Normalize again before processing afterFiles if this was originally a data URL @@ -712,11 +798,15 @@ export async function resolveRoutes( currentRequestHeaders, currentResponseHeaders ) - return { - matchedPathname: matchedPath, - resolvedHeaders: finalHeaders, - status: currentStatus, - } + return withResolvedInvocationTarget({ + result: { + resolvedHeaders: finalHeaders, + status: currentStatus, + }, + url: pathnameCheckUrl, + resolvedPathname: matchedPath, + invocationPathname: pathnameCheckUrl.pathname, + }) } } } @@ -740,29 +830,39 @@ export async function resolveRoutes( ) if (hasResult.matched && missingMatched) { - // Check if the destination pathname (template path) is in the provided pathnames list - // For dynamic routes, the destination contains the template path like /dynamic/[slug] - const pathnameToCheck = route.destination + const replacedDestination = route.destination ? replaceDestination( route.destination, match.regexMatches || null, hasResult.captures - ).split('?')[0] + ) + : undefined + // Check if the destination pathname (template path) is in the provided pathnames list + // For dynamic routes, the destination contains the template path like /dynamic/[slug] + const pathnameToCheck = replacedDestination + ? replacedDestination.split('?')[0] : currentUrl.pathname matchedPath = matchesPathname(pathnameToCheck, pathnames) if (matchedPath) { + const resolvedUrl = replacedDestination + ? mergeDestinationQueryIntoUrl(currentUrl, replacedDestination) + : currentUrl const finalHeaders = applyOnMatchHeaders( routes.onMatch, - currentUrl, + resolvedUrl, currentRequestHeaders, currentResponseHeaders ) - return { - matchedPathname: matchedPath, - routeMatches: match.params, - resolvedHeaders: finalHeaders, - status: currentStatus, - } + return withResolvedInvocationTarget({ + result: { + routeMatches: match.params, + resolvedHeaders: finalHeaders, + status: currentStatus, + }, + url: resolvedUrl, + resolvedPathname: matchedPath, + invocationPathname: currentUrl.pathname, + }) } } } @@ -865,11 +965,15 @@ export async function resolveRoutes( currentRequestHeaders, currentResponseHeaders ) - return { - matchedPathname: matchedPath, - resolvedHeaders: finalHeaders, - status: currentStatus, - } + return withResolvedInvocationTarget({ + result: { + resolvedHeaders: finalHeaders, + status: currentStatus, + }, + url: pathnameCheckUrl, + resolvedPathname: matchedPath, + invocationPathname: pathnameCheckUrl.pathname, + }) } } } diff --git a/packages/next-routing/src/types.ts b/packages/next-routing/src/types.ts index 709f2971eb0bc..bc9f3262baf69 100644 --- a/packages/next-routing/src/types.ts +++ b/packages/next-routing/src/types.ts @@ -69,6 +69,20 @@ export type ResolveRoutesParams = { invokeMiddleware: (ctx: MiddlewareContext) => Promise } +export type ResolveRoutesQueryValue = string | string[] +export type ResolveRoutesQuery = Record + +export type RouteInvocationTarget = { + /** + * Concrete pathname that should be invoked after routing resolution. + */ + pathname: string + /** + * Concrete query that should be invoked after routing resolution. + */ + query: ResolveRoutesQuery +} + export type ResolveRoutesResult = { middlewareResponded?: boolean externalRewrite?: URL @@ -76,7 +90,19 @@ export type ResolveRoutesResult = { url: URL status: number } - matchedPathname?: string + /** + * Resolved pathname selected by route matching. For dynamic routes this is + * the matched template pathname. + */ + resolvedPathname?: string + /** + * Merged query produced by rewrite/middleware routing. + */ + resolvedQuery?: ResolveRoutesQuery + /** + * Concrete invocation target to use when invoking the resolved route/module. + */ + invocationTarget?: RouteInvocationTarget resolvedHeaders?: Headers status?: number routeMatches?: Record From 112666134c49a95d1056409a3cd185e54cff9584 Mon Sep 17 00:00:00 2001 From: Tobias Koppers Date: Fri, 13 Mar 2026 17:44:41 +0100 Subject: [PATCH 08/16] Delete blob files during compaction when entries are superseded (#91314) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### What? During compaction in `turbo-persistence`, when entries are dropped (superseded by newer values or pruned by tombstones), blob files referenced by those entries are now marked for deletion. ### Why? Previously, compaction would merge SST files and correctly drop stale entries, but blob files referenced by those dropped entries were leaked on disk (marked with a TODO at the time). Over time this would cause unbounded disk usage growth for databases that overwrite or delete blob-sized values. ### How? When the compaction merge loop skips an entry (because `skip_remaining_for_this_key` is `true`), it now checks if the dropped entry is a `LookupValue::Blob` and, if so, pushes its sequence number to `blob_seq_numbers_to_delete`. The existing `commit()` infrastructure already handles the rest — writing `.del` files and removing the actual `.blob` files after the CURRENT pointer is updated. The change is minimal (4 lines of logic in `db.rs`): - Made `blob_seq_numbers_to_delete` mutable - Added an `else` branch to collect blob sequence numbers from dropped entries This covers both cases: - **SingleValue**: After the first (newest) entry for a key is written, all older entries are skipped. Blob references in those older entries are marked for deletion. - **MultiValue**: After a tombstone is encountered, all older entries for that key are skipped. Blob references in those older entries are marked for deletion. ### Tests Added 4 new tests: - `compaction_deletes_superseded_blob` — blob overwritten by smaller value → blob deleted after compaction - `compaction_deletes_blob_on_tombstone` — blob deleted via tombstone → blob deleted after compaction - `compaction_deletes_blob_multi_value_tombstone` — MultiValue: tombstone prunes blob → blob deleted - `compaction_preserves_active_blob` — blob still referenced → blob preserved after compaction All existing compaction tests (23) and full turbo-persistence test suite (60) continue to pass. Co-authored-by: Tobias Koppers Co-authored-by: Claude --- turbopack/crates/turbo-persistence/src/db.rs | 14 +- .../crates/turbo-persistence/src/tests.rs | 221 +++++++++++++++++- 2 files changed, 231 insertions(+), 4 deletions(-) diff --git a/turbopack/crates/turbo-persistence/src/db.rs b/turbopack/crates/turbo-persistence/src/db.rs index 271f60549ae12..5967363809af5 100644 --- a/turbopack/crates/turbo-persistence/src/db.rs +++ b/turbopack/crates/turbo-persistence/src/db.rs @@ -1085,9 +1085,7 @@ impl TurboPersistence let iter = MergeIter::new(iters.into_iter())?; - // TODO figure out how to delete blobs when they are no longer - // referenced - let blob_seq_numbers_to_delete: Vec = Vec::new(); + let mut blob_seq_numbers_to_delete: Vec = Vec::new(); struct Collector { /// The active writer and its sequence number. `None` if no @@ -1241,6 +1239,16 @@ impl TurboPersistence sequence_number, &mut keys_written, )?; + } else { + // Entry is being dropped (superseded by newer entry or + // pruned by tombstone). If it references a blob file, + // mark that blob for deletion. + if let LazyLookupValue::Eager(LookupValue::Blob { + sequence_number, + }) = &entry.value + { + blob_seq_numbers_to_delete.push(*sequence_number); + } } } diff --git a/turbopack/crates/turbo-persistence/src/tests.rs b/turbopack/crates/turbo-persistence/src/tests.rs index dca6ca5f5e89c..3ed65ec995204 100644 --- a/turbopack/crates/turbo-persistence/src/tests.rs +++ b/turbopack/crates/turbo-persistence/src/tests.rs @@ -1,4 +1,4 @@ -use std::{fs, time::Instant}; +use std::{fs, path::Path, time::Instant}; use anyhow::Result; use rayon::iter::{IntoParallelIterator, ParallelIterator}; @@ -1968,3 +1968,222 @@ fn multi_value_tombstone_shadows_older_sst_only() -> Result<()> { Ok(()) } + +/// Returns the number of `.blob` files in the given directory. +fn count_blob_files(dir: &Path) -> usize { + fs::read_dir(dir) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().is_some_and(|ext| ext == "blob")) + .count() +} + +/// Test that compaction deletes blob files when their entries are superseded +/// by newer values (SingleValue family). +#[test] +fn compaction_deletes_superseded_blob() -> Result<()> { + let tempdir = tempfile::tempdir()?; + let path = tempdir.path(); + + let db = TurboPersistence::<_, 1>::open_with_parallel_scheduler( + path.to_path_buf(), + RayonParallelScheduler, + )?; + + let blob_value = vec![42u8; MAX_MEDIUM_VALUE_SIZE + 1]; + + // Write a blob-sized value + let batch = db.write_batch()?; + batch.put(0, vec![1u8], blob_value.clone().into())?; + db.commit_write_batch(batch)?; + + assert_eq!( + count_blob_files(path), + 1, + "Should have 1 blob file after first write" + ); + + // Verify we can read it + let result = db.get(0, &vec![1u8])?; + assert_eq!(result.as_deref(), Some(&blob_value[..])); + + // Overwrite the key with a small (non-blob) value in a new batch + let batch = db.write_batch()?; + batch.put(0, vec![1u8], vec![99u8].into())?; + db.commit_write_batch(batch)?; + + // Blob file still exists before compaction (old SST still references it) + assert_eq!( + count_blob_files(path), + 1, + "Blob file should still exist before compaction" + ); + + // Compact — the old blob entry is superseded by the newer small value + db.full_compact()?; + + // After compaction, the old blob file should be deleted + assert_eq!( + count_blob_files(path), + 0, + "Old blob file should be deleted after compaction" + ); + + // The new value should still be readable + let result = db.get(0, &vec![1u8])?; + assert_eq!(result.as_deref(), Some(&[99u8][..])); + + db.shutdown()?; + Ok(()) +} + +/// Test that compaction deletes blob files when a key is deleted via tombstone +/// (SingleValue family). +#[test] +fn compaction_deletes_blob_on_tombstone() -> Result<()> { + let tempdir = tempfile::tempdir()?; + let path = tempdir.path(); + + let db = TurboPersistence::<_, 1>::open_with_parallel_scheduler( + path.to_path_buf(), + RayonParallelScheduler, + )?; + + let blob_value = vec![42u8; MAX_MEDIUM_VALUE_SIZE + 1]; + + // Write a blob-sized value + let batch = db.write_batch()?; + batch.put(0, vec![1u8], blob_value.clone().into())?; + db.commit_write_batch(batch)?; + + assert_eq!(count_blob_files(path), 1); + + // Delete the key + let batch = db.write_batch()?; + batch.delete(0, vec![1u8])?; + db.commit_write_batch(batch)?; + + // Blob file still exists before compaction + assert_eq!( + count_blob_files(path), + 1, + "Blob file should still exist before compaction" + ); + + // Compact — tombstone supersedes the blob entry + db.full_compact()?; + + // After compaction, the blob file should be deleted + assert_eq!( + count_blob_files(path), + 0, + "Blob file should be deleted after compaction" + ); + + // Key should not be found + let result = db.get(0, &vec![1u8])?; + assert!(result.is_none()); + + db.shutdown()?; + Ok(()) +} + +/// Test that compaction deletes blob files for MultiValue families when a +/// tombstone prunes older blob entries. +#[test] +fn compaction_deletes_blob_multi_value_tombstone() -> Result<()> { + let tempdir = tempfile::tempdir()?; + let path = tempdir.path(); + + let config = DbConfig { + family_configs: [FamilyConfig { + kind: FamilyKind::MultiValue, + }], + }; + + let db = TurboPersistence::<_, 1>::open_with_config_and_parallel_scheduler( + path.to_path_buf(), + config, + RayonParallelScheduler, + )?; + + let blob_value = vec![42u8; MAX_MEDIUM_VALUE_SIZE + 1]; + + // Write a blob-sized value + let batch = db.write_batch()?; + batch.put(0, vec![1u8], blob_value.clone().into())?; + db.commit_write_batch(batch)?; + + assert_eq!(count_blob_files(path), 1); + + // Delete the key (tombstone) and write a new small value in a new batch + let batch = db.write_batch()?; + batch.delete(0, vec![1u8])?; + batch.put(0, vec![1u8], vec![99u8].into())?; + db.commit_write_batch(batch)?; + + // Blob file still exists before compaction + assert_eq!(count_blob_files(path), 1); + + // Compact — tombstone prunes the old blob entry + db.full_compact()?; + + // After compaction, the old blob file should be deleted + assert_eq!( + count_blob_files(path), + 0, + "Blob file should be deleted after compaction" + ); + + // The new value should still be readable + let results = db.get_multiple(0, &vec![1u8].as_slice())?; + assert_eq!(results.len(), 1); + assert_eq!(results[0].as_ref(), &[99u8]); + + db.shutdown()?; + Ok(()) +} + +/// Test that compaction preserves blob files that are still referenced +/// (not superseded). +#[test] +fn compaction_preserves_active_blob() -> Result<()> { + let tempdir = tempfile::tempdir()?; + let path = tempdir.path(); + + let db = TurboPersistence::<_, 1>::open_with_parallel_scheduler( + path.to_path_buf(), + RayonParallelScheduler, + )?; + + let blob_value = vec![42u8; MAX_MEDIUM_VALUE_SIZE + 1]; + + // Write a blob-sized value + let batch = db.write_batch()?; + batch.put(0, vec![1u8], blob_value.clone().into())?; + db.commit_write_batch(batch)?; + + // Write a different key to create a second SST (so compaction has work to do) + let batch = db.write_batch()?; + batch.put(0, vec![2u8], vec![1u8].into())?; + db.commit_write_batch(batch)?; + + assert_eq!(count_blob_files(path), 1); + + // Compact — the blob entry is still the latest, should be preserved + db.full_compact()?; + + // Blob file should still exist + assert_eq!( + count_blob_files(path), + 1, + "Active blob file should be preserved after compaction" + ); + + // Value should still be readable + let result = db.get(0, &vec![1u8])?; + assert_eq!(result.as_deref(), Some(&blob_value[..])); + + db.shutdown()?; + Ok(()) +} From a6b36200ff911771e9b8ff5582b780ea625c1690 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Fri, 13 Mar 2026 10:25:59 -0700 Subject: [PATCH 09/16] Wire cache handlers in edge paths and add e2e regression coverage (#91236) ### What? This PR wires custom `cacheHandler` / `cacheHandlers` through the remaining edge entrypoints so edge app pages, edge app routes, edge pages SSR, middleware, and edge API routes can all see the configured handlers. It also adds an e2e regression suite covering: - pages-router ISR revalidation with a custom incremental cache handler - app-router cache handlers with cache components enabled - edge app page + edge app route wiring when cache components are disabled ### Why? We already support custom cache handlers in non-edge paths, but several edge code paths were not forwarding that configuration into the generated entrypoints/runtime wrappers. That meant custom cache handlers could be skipped in edge rendering and edge route execution, and we did not have regression coverage around those cases. ### How? - pass `cacheHandler` / `cacheHandlers` through the JS build entry plumbing for edge server entries - inject cache handler imports/registration into the webpack edge templates and loaders - mirror the same wiring in the Turbopack/Rust entry generation for edge app pages, app routes, pages SSR, and middleware - update the edge route wrapper to initialize and register cache handlers before invoking the route module - extend `next-taskless` template expansion with raw injection support so the generated edge templates can add imports and registration code - add `test/e2e/cache-handlers-upstream-wiring` fixtures to cover pages/app, edge/non-edge, and revalidation behavior --- crates/next-api/src/middleware.rs | 1 + crates/next-api/src/pages.rs | 1 + crates/next-core/src/middleware.rs | 32 +++- .../next-core/src/next_app/app_page_entry.rs | 65 ++++++++- .../next-core/src/next_app/app_route_entry.rs | 65 ++++++++- crates/next-core/src/next_config.rs | 8 + crates/next-core/src/next_pages/page_entry.rs | 71 ++++++++- crates/next-taskless/src/lib.rs | 34 ++++- packages/next/src/build/entries.ts | 7 +- .../src/build/templates/edge-app-route.ts | 8 + .../next/src/build/templates/edge-ssr-app.ts | 6 +- packages/next/src/build/templates/edge-ssr.ts | 6 +- .../next/src/build/templates/middleware.ts | 3 + .../src/build/templates/pages-edge-api.ts | 3 + .../next-edge-app-route-loader/index.ts | 60 +++++++- .../loaders/next-edge-function-loader.ts | 11 ++ .../loaders/next-edge-ssr-loader/index.ts | 61 ++++++-- .../webpack/loaders/next-middleware-loader.ts | 19 ++- .../server/web/edge-route-module-wrapper.ts | 24 ++- .../app/api/edge-route/route.js | 5 + .../app/edge-page/page.js | 5 + .../app/layout.js | 7 + .../incremental-cache-handler.js | 10 ++ .../next.config.js | 8 + .../non-edge-cache-components/app/layout.js | 7 + .../non-edge-cache-components/app/page.js | 13 ++ .../app/revalidate-actions/page.js | 24 +++ .../app/revalidate-target/page.js | 13 ++ .../modern-cache-handler.js | 36 +++++ .../non-edge-cache-components/next.config.js | 12 ++ .../incremental-cache-handler.js | 18 +++ .../pages-router-non-edge/next.config.js | 8 + .../pages/api/revalidate.js | 4 + .../pages-router-non-edge/pages/isr.js | 12 ++ .../index.test.ts | 137 ++++++++++++++++++ 35 files changed, 758 insertions(+), 46 deletions(-) create mode 100644 test/e2e/cache-handlers-upstream-wiring/fixtures/edge-without-cache-components/app/api/edge-route/route.js create mode 100644 test/e2e/cache-handlers-upstream-wiring/fixtures/edge-without-cache-components/app/edge-page/page.js create mode 100644 test/e2e/cache-handlers-upstream-wiring/fixtures/edge-without-cache-components/app/layout.js create mode 100644 test/e2e/cache-handlers-upstream-wiring/fixtures/edge-without-cache-components/incremental-cache-handler.js create mode 100644 test/e2e/cache-handlers-upstream-wiring/fixtures/edge-without-cache-components/next.config.js create mode 100644 test/e2e/cache-handlers-upstream-wiring/fixtures/non-edge-cache-components/app/layout.js create mode 100644 test/e2e/cache-handlers-upstream-wiring/fixtures/non-edge-cache-components/app/page.js create mode 100644 test/e2e/cache-handlers-upstream-wiring/fixtures/non-edge-cache-components/app/revalidate-actions/page.js create mode 100644 test/e2e/cache-handlers-upstream-wiring/fixtures/non-edge-cache-components/app/revalidate-target/page.js create mode 100644 test/e2e/cache-handlers-upstream-wiring/fixtures/non-edge-cache-components/modern-cache-handler.js create mode 100644 test/e2e/cache-handlers-upstream-wiring/fixtures/non-edge-cache-components/next.config.js create mode 100644 test/e2e/cache-handlers-upstream-wiring/fixtures/pages-router-non-edge/incremental-cache-handler.js create mode 100644 test/e2e/cache-handlers-upstream-wiring/fixtures/pages-router-non-edge/next.config.js create mode 100644 test/e2e/cache-handlers-upstream-wiring/fixtures/pages-router-non-edge/pages/api/revalidate.js create mode 100644 test/e2e/cache-handlers-upstream-wiring/fixtures/pages-router-non-edge/pages/isr.js create mode 100644 test/e2e/cache-handlers-upstream-wiring/index.test.ts diff --git a/crates/next-api/src/middleware.rs b/crates/next-api/src/middleware.rs index c7a49679f3dcf..d94bb779674a2 100644 --- a/crates/next-api/src/middleware.rs +++ b/crates/next-api/src/middleware.rs @@ -90,6 +90,7 @@ impl MiddlewareEndpoint { self.project.project_path().owned().await?, userland_module, is_proxy, + self.project.next_config(), ); if matches!(self.runtime, NextRuntime::NodeJs) { diff --git a/crates/next-api/src/pages.rs b/crates/next-api/src/pages.rs index 9be7b8ced0105..b3d5204dba4f5 100644 --- a/crates/next-api/src/pages.rs +++ b/crates/next-api/src/pages.rs @@ -885,6 +885,7 @@ impl PageEndpoint { self.source(), this.original_name.clone(), *this.pages_structure, + this.pages_project.project().next_config(), runtime, ) .await?; diff --git a/crates/next-core/src/middleware.rs b/crates/next-core/src/middleware.rs index 9e7f4a9881123..ad40c3e0b33c0 100644 --- a/crates/next-core/src/middleware.rs +++ b/crates/next-core/src/middleware.rs @@ -4,13 +4,14 @@ use turbo_tasks::{ResolvedVc, Vc, fxindexmap}; use turbo_tasks_fs::FileSystemPath; use turbopack_core::{ context::AssetContext, + file_source::FileSource, issue::{Issue, IssueExt, IssueSeverity, IssueStage, OptionStyledString, StyledString}, module::Module, reference_type::ReferenceType, }; use turbopack_ecmascript::chunk::{EcmascriptChunkPlaceable, EcmascriptExports}; -use crate::util::load_next_js_template; +use crate::{next_config::NextConfig, util::load_next_js_template}; #[turbo_tasks::function] pub async fn middleware_files(page_extensions: Vc>) -> Result>> { @@ -33,6 +34,7 @@ pub async fn get_middleware_module( project_root: FileSystemPath, userland_module: ResolvedVc>, is_proxy: bool, + next_config: Vc, ) -> Result>> { const INNER: &str = "INNER_MIDDLEWARE_MODULE"; @@ -86,6 +88,26 @@ pub async fn get_middleware_module( } // If we can't cast to EcmascriptChunkPlaceable, continue without validation // (might be a special module type that doesn't support export checking) + let mut incremental_cache_handler_import = None; + let mut cache_handler_inner_assets = fxindexmap! {}; + + for cache_handler_path in next_config + .cache_handler(project_root.clone()) + .await? + .into_iter() + { + let cache_handler_inner = rcstr!("INNER_INCREMENTAL_CACHE_HANDLER"); + incremental_cache_handler_import = Some(cache_handler_inner.clone()); + let cache_handler_module = asset_context + .process( + Vc::upcast(FileSource::new(cache_handler_path.clone())), + ReferenceType::Undefined, + ) + .module() + .to_resolved() + .await?; + cache_handler_inner_assets.insert(cache_handler_inner, cache_handler_module); + } // Load the file from the next.js codebase. let source = load_next_js_template( @@ -93,13 +115,17 @@ pub async fn get_middleware_module( project_root, [("VAR_USERLAND", INNER), ("VAR_DEFINITION_PAGE", page_path)], [], - [], + [( + "incrementalCacheHandler", + incremental_cache_handler_import.as_deref(), + )], ) .await?; - let inner_assets = fxindexmap! { + let mut inner_assets = fxindexmap! { rcstr!(INNER) => userland_module }; + inner_assets.extend(cache_handler_inner_assets); let module = asset_context .process( diff --git a/crates/next-core/src/next_app/app_page_entry.rs b/crates/next-core/src/next_app/app_page_entry.rs index 6a088fc0b07fc..77953a5a7c284 100644 --- a/crates/next-core/src/next_app/app_page_entry.rs +++ b/crates/next-core/src/next_app/app_page_entry.rs @@ -8,6 +8,7 @@ use turbopack::ModuleAssetContext; use turbopack_core::{ asset::{Asset, AssetContent}, context::AssetContext, + file_source::FileSource, module::Module, reference_type::ReferenceType, source::Source, @@ -113,6 +114,7 @@ pub async fn get_app_page_entry( project_root.clone(), rsc_entry, page, + next_config, ); }; @@ -131,21 +133,78 @@ async fn wrap_edge_page( project_root: FileSystemPath, entry: ResolvedVc>, page: AppPage, + next_config: Vc, ) -> Result>> { const INNER: &str = "INNER_PAGE_ENTRY"; + let mut cache_handler_imports = String::new(); + let mut cache_handler_registration = String::new(); + let mut incremental_cache_handler_import = None; + let mut cache_handler_inner_assets = fxindexmap! {}; + + let cache_handlers = next_config.cache_handlers_map().owned().await?; + for (index, (kind, handler_path)) in cache_handlers.iter().enumerate() { + let cache_handler_inner: RcStr = format!("INNER_CACHE_HANDLER_{index}").into(); + let cache_handler_var = format!("cacheHandler{index}"); + cache_handler_imports.push_str(&format!( + "import {cache_handler_var} from {};\n", + serde_json::to_string(&*cache_handler_inner)? + )); + cache_handler_registration.push_str(&format!( + " cacheHandlers.setCacheHandler({}, {cache_handler_var});\n", + serde_json::to_string(kind.as_str())? + )); + + let cache_handler_module = asset_context + .process( + Vc::upcast(FileSource::new(project_root.join(handler_path)?)), + ReferenceType::Undefined, + ) + .module() + .to_resolved() + .await?; + cache_handler_inner_assets.insert(cache_handler_inner, cache_handler_module); + } + + for cache_handler_path in next_config + .cache_handler(project_root.clone()) + .await? + .into_iter() + { + let cache_handler_inner: RcStr = "INNER_INCREMENTAL_CACHE_HANDLER".into(); + incremental_cache_handler_import = Some(cache_handler_inner.clone()); + let cache_handler_module = asset_context + .process( + Vc::upcast(FileSource::new(cache_handler_path.clone())), + ReferenceType::Undefined, + ) + .module() + .to_resolved() + .await?; + cache_handler_inner_assets.insert(cache_handler_inner, cache_handler_module); + } let source = load_next_js_template( "edge-ssr-app.js", project_root.clone(), [("VAR_USERLAND", INNER), ("VAR_PAGE", &page.to_string())], - [], - [("incrementalCacheHandler", None)], + [ + ("cacheHandlerImports", cache_handler_imports.as_str()), + ( + "cacheHandlerRegistration", + cache_handler_registration.as_str(), + ), + ], + [( + "incrementalCacheHandler", + incremental_cache_handler_import.as_deref(), + )], ) .await?; - let inner_assets = fxindexmap! { + let mut inner_assets = fxindexmap! { INNER.into() => entry }; + inner_assets.extend(cache_handler_inner_assets); let wrapped = asset_context .process( diff --git a/crates/next-core/src/next_app/app_route_entry.rs b/crates/next-core/src/next_app/app_route_entry.rs index 1df164f9622da..68fed17b5d0d0 100644 --- a/crates/next-core/src/next_app/app_route_entry.rs +++ b/crates/next-core/src/next_app/app_route_entry.rs @@ -5,6 +5,7 @@ use turbo_tasks_fs::FileSystemPath; use turbopack::ModuleAssetContext; use turbopack_core::{ context::AssetContext, + file_source::FileSource, module::Module, reference_type::{EntryReferenceSubType, ReferenceType}, source::Source, @@ -113,6 +114,7 @@ pub async fn get_app_route_entry( project_root, rsc_entry, page, + next_config, ); } @@ -131,21 +133,78 @@ async fn wrap_edge_route( project_root: FileSystemPath, entry: ResolvedVc>, page: AppPage, + next_config: Vc, ) -> Result>> { let inner = rcstr!("INNER_ROUTE_ENTRY"); + let mut cache_handler_imports = String::new(); + let mut cache_handler_map_entries = String::new(); + let mut incremental_cache_handler_import = None; + let mut cache_handler_inner_assets = fxindexmap! {}; + + let cache_handlers = next_config.cache_handlers_map().owned().await?; + for (index, (kind, handler_path)) in cache_handlers.iter().enumerate() { + let cache_handler_inner: RcStr = format!("INNER_CACHE_HANDLER_{index}").into(); + let cache_handler_var = format!("cacheHandler{index}"); + cache_handler_imports.push_str(&format!( + "import {cache_handler_var} from {};\n", + serde_json::to_string(&*cache_handler_inner)? + )); + cache_handler_map_entries.push_str(&format!( + " {}: {cache_handler_var},\n", + serde_json::to_string(kind.as_str())? + )); + + let cache_handler_module = asset_context + .process( + Vc::upcast(FileSource::new(project_root.join(handler_path)?)), + ReferenceType::Undefined, + ) + .module() + .to_resolved() + .await?; + cache_handler_inner_assets.insert(cache_handler_inner, cache_handler_module); + } + + for cache_handler_path in next_config + .cache_handler(project_root.clone()) + .await? + .into_iter() + { + let cache_handler_inner: RcStr = "INNER_INCREMENTAL_CACHE_HANDLER".into(); + incremental_cache_handler_import = Some(cache_handler_inner.clone()); + let cache_handler_module = asset_context + .process( + Vc::upcast(FileSource::new(cache_handler_path.clone())), + ReferenceType::Undefined, + ) + .module() + .to_resolved() + .await?; + cache_handler_inner_assets.insert(cache_handler_inner, cache_handler_module); + } let source = load_next_js_template( "edge-app-route.js", project_root.clone(), [("VAR_USERLAND", &*inner), ("VAR_PAGE", &page.to_string())], - [], - [], + [ + ("cacheHandlerImports", cache_handler_imports.as_str()), + ( + "edgeCacheHandlersRegistration", + cache_handler_map_entries.as_str(), + ), + ], + [( + "incrementalCacheHandler", + incremental_cache_handler_import.as_deref(), + )], ) .await?; - let inner_assets = fxindexmap! { + let mut inner_assets = fxindexmap! { inner => entry }; + inner_assets.extend(cache_handler_inner_assets); let wrapped = asset_context .process( diff --git a/crates/next-core/src/next_config.rs b/crates/next-core/src/next_config.rs index 45a1c847903bb..8e2db3bee4d9c 100644 --- a/crates/next-core/src/next_config.rs +++ b/crates/next-core/src/next_config.rs @@ -70,6 +70,9 @@ impl Default for CacheKinds { } } +#[turbo_tasks::value(transparent)] +pub struct CacheHandlersMap(#[bincode(with = "turbo_bincode::indexmap")] FxIndexMap); + #[turbo_tasks::value(eq = "manual")] #[derive(Clone, Debug, Default, PartialEq, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -1841,6 +1844,11 @@ impl NextConfig { } } + #[turbo_tasks::function] + pub fn cache_handlers_map(&self) -> Vc { + Vc::cell(self.cache_handlers.clone().unwrap_or_default()) + } + #[turbo_tasks::function] pub fn experimental_swc_plugins(&self) -> Vc { Vc::cell(self.experimental.swc_plugins.clone().unwrap_or_default()) diff --git a/crates/next-core/src/next_pages/page_entry.rs b/crates/next-core/src/next_pages/page_entry.rs index c1ee2386ca9f7..f7a714b8ac990 100644 --- a/crates/next-core/src/next_pages/page_entry.rs +++ b/crates/next-core/src/next_pages/page_entry.rs @@ -16,6 +16,7 @@ use turbopack_core::{ }; use crate::{ + next_config::NextConfig, next_edge::entry::wrap_edge_entry, pages_structure::{PagesStructure, PagesStructureItem}, util::{NextRuntime, file_content_rope, load_next_js_template, pages_function_name}, @@ -37,6 +38,7 @@ pub async fn create_page_ssr_entry_module( source: Vc>, next_original_name: RcStr, pages_structure: Vc, + next_config: Vc, runtime: NextRuntime, ) -> Result> { let definition_page = next_original_name; @@ -95,6 +97,56 @@ pub async fn create_page_ssr_entry_module( } let pages_structure_ref = pages_structure.await?; + let mut cache_handler_inner_assets = fxindexmap! {}; + let mut cache_handler_imports = String::new(); + let mut cache_handler_registration = String::new(); + let mut incremental_cache_handler_import = None; + + if runtime == NextRuntime::Edge { + if is_page { + let cache_handlers = next_config.cache_handlers_map().owned().await?; + for (index, (kind, handler_path)) in cache_handlers.iter().enumerate() { + let cache_handler_inner: RcStr = format!("INNER_CACHE_HANDLER_{index}").into(); + let cache_handler_var = format!("cacheHandler{index}"); + cache_handler_imports.push_str(&format!( + "import {cache_handler_var} from {};\n", + serde_json::to_string(&*cache_handler_inner)? + )); + cache_handler_registration.push_str(&format!( + " cacheHandlers.setCacheHandler({}, {cache_handler_var});\n", + serde_json::to_string(kind.as_str())? + )); + + let cache_handler_module = ssr_module_context + .process( + Vc::upcast(FileSource::new(project_root.join(handler_path)?)), + ReferenceType::Undefined, + ) + .module() + .to_resolved() + .await?; + cache_handler_inner_assets.insert(cache_handler_inner, cache_handler_module); + } + } + + for cache_handler_path in next_config + .cache_handler(project_root.clone()) + .await? + .into_iter() + { + let cache_handler_inner = rcstr!("INNER_INCREMENTAL_CACHE_HANDLER"); + incremental_cache_handler_import = Some(cache_handler_inner.clone()); + let cache_handler_module = ssr_module_context + .process( + Vc::upcast(FileSource::new(cache_handler_path.clone())), + ReferenceType::Undefined, + ) + .module() + .to_resolved() + .await?; + cache_handler_inner_assets.insert(cache_handler_inner, cache_handler_module); + } + } let (injections, imports) = if is_page && runtime == NextRuntime::Edge { let injections = vec![ @@ -116,16 +168,24 @@ pub async fn create_page_ssr_entry_module( "user500RouteModuleOptions", serde_json::to_string(&get_route_module_options(rcstr!("/500"), rcstr!("/500")))?, ), + ("cacheHandlerImports", cache_handler_imports), + ("cacheHandlerRegistration", cache_handler_registration), ]; let imports = vec![ - // TODO - ("incrementalCacheHandler", None), + ("incrementalCacheHandler", incremental_cache_handler_import), ( "userland500Page", - pages_structure_ref.error_500.map(|_| &*inner_error_500), + pages_structure_ref + .error_500 + .map(|_| inner_error_500.clone()), ), ]; (injections, imports) + } else if runtime == NextRuntime::Edge { + ( + vec![], + vec![("incrementalCacheHandler", incremental_cache_handler_import)], + ) } else { (vec![], vec![]) }; @@ -136,7 +196,9 @@ pub async fn create_page_ssr_entry_module( project_root.clone(), replacements, injections.iter().map(|(k, v)| (*k, &**v)), - imports, + imports + .iter() + .map(|(k, v)| (*k, v.as_ref().map(|value| value.as_str()))), ) .await?; @@ -167,6 +229,7 @@ pub async fn create_page_ssr_entry_module( let mut inner_assets = fxindexmap! { inner => ssr_module, }; + inner_assets.extend(cache_handler_inner_assets); // for PagesData we apply a ?server-data query parameter to avoid conflicts with the Page // module. diff --git a/crates/next-taskless/src/lib.rs b/crates/next-taskless/src/lib.rs index 58f7f1745912a..2db651d1d104e 100644 --- a/crates/next-taskless/src/lib.rs +++ b/crates/next-taskless/src/lib.rs @@ -174,18 +174,41 @@ fn expand_next_js_template_inner<'a>( ) } - // Replace the injections. + // Replace the raw injections. let mut missing_injections = Vec::new(); for (key, injection) in injections { + let mut used = false; + let full_raw = format!("// INJECT_RAW:{key}"); + + if content.contains(&full_raw) { + content = content.replace(&full_raw, injection); + used = true; + } + let full = format!("// INJECT:{key}"); if content.contains(&full) { content = content.replace(&full, &format!("const {key} = {injection}")); - } else { + used = true; + } + + if !used { missing_injections.push(key); } } + // Check to see if there's any remaining raw injections. + static INJECT_RAW_RE: LazyLock = + LazyLock::new(|| Regex::new("// INJECT_RAW:[A-Za-z0-9_]+").unwrap()); + let mut matches = INJECT_RAW_RE.find_iter(&content).peekable(); + + if matches.peek().is_some() { + bail!( + "Invariant: Expected to inject all injections, found {}", + matches.map(|m| m.as_str()).collect::>().join(", "), + ) + } + // Check to see if there's any remaining injections. static INJECT_RE: LazyLock = LazyLock::new(|| Regex::new("// INJECT:[A-Za-z0-9_]+").unwrap()); @@ -276,6 +299,7 @@ mod tests { import * as userlandPage from 'VAR_USERLAND' // OPTIONAL_IMPORT:* as userland500Page // OPTIONAL_IMPORT:incrementalCacheHandler + // INJECT_RAW:extraImports // INJECT:nextConfig const srcPage = 'VAR_PAGE' @@ -286,6 +310,7 @@ mod tests { import * as userlandPage from "INNER_PAGE_ENTRY" import * as userland500Page from "INNER_ERROR_500" const incrementalCacheHandler = null + import handlerX from "INNER_HANDLER" const nextConfig = {} const srcPage = "./some/path.js" @@ -299,7 +324,10 @@ mod tests { ("VAR_USERLAND", "INNER_PAGE_ENTRY"), ("VAR_PAGE", "./some/path.js"), ], - [("nextConfig", "{}")], + [ + ("nextConfig", "{}"), + ("extraImports", r#"import handlerX from "INNER_HANDLER""#), + ], [ ("incrementalCacheHandler", None), ("userland500Page", Some("INNER_ERROR_500")), diff --git a/packages/next/src/build/entries.ts b/packages/next/src/build/entries.ts index bfb8f3f888cab..4c8635c35ba1a 100644 --- a/packages/next/src/build/entries.ts +++ b/packages/next/src/build/entries.ts @@ -166,6 +166,8 @@ export function getEdgeServerEntry(opts: { preferredRegion: string | string[] | undefined middlewareConfig?: ProxyConfig }) { + const cacheHandler = opts.config.cacheHandler || undefined + if ( opts.pagesType === 'app' && isAppRouteRoute(opts.page) && @@ -180,6 +182,7 @@ export function getEdgeServerEntry(opts: { JSON.stringify(opts.middlewareConfig || {}) ).toString('base64'), cacheHandlers: JSON.stringify(opts.config.cacheHandlers || {}), + ...(cacheHandler ? { cacheHandler } : {}), } return { @@ -200,6 +203,7 @@ export function getEdgeServerEntry(opts: { middlewareConfig: Buffer.from( JSON.stringify(opts.middlewareConfig || {}) ).toString('base64'), + ...(cacheHandler ? { cacheHandler } : {}), } return { @@ -218,6 +222,7 @@ export function getEdgeServerEntry(opts: { middlewareConfig: Buffer.from( JSON.stringify(opts.middlewareConfig || {}) ).toString('base64'), + ...(cacheHandler ? { cacheHandler } : {}), } return { @@ -238,13 +243,13 @@ export function getEdgeServerEntry(opts: { pagesType: opts.pagesType, appDirLoader: Buffer.from(opts.appDirLoader || '').toString('base64'), sriEnabled: !opts.isDev && !!opts.config.experimental.sri?.algorithm, - cacheHandler: opts.config.cacheHandler, preferredRegion: opts.preferredRegion, middlewareConfig: Buffer.from( JSON.stringify(opts.middlewareConfig || {}) ).toString('base64'), serverActions: opts.config.experimental.serverActions, cacheHandlers: JSON.stringify(opts.config.cacheHandlers || {}), + ...(cacheHandler ? { cacheHandler } : {}), } return { diff --git a/packages/next/src/build/templates/edge-app-route.ts b/packages/next/src/build/templates/edge-app-route.ts index 6d5dc459ad9dd..901af8ba20e0b 100644 --- a/packages/next/src/build/templates/edge-app-route.ts +++ b/packages/next/src/build/templates/edge-app-route.ts @@ -8,6 +8,9 @@ import * as module from 'VAR_USERLAND' import { toNodeOutgoingHttpHeaders } from '../../server/web/utils' // injected by the loader afterwards. +declare const incrementalCacheHandler: any +// OPTIONAL_IMPORT:incrementalCacheHandler +// INJECT_RAW:cacheHandlerImports const maybeJSONParse = (str?: string) => (str ? JSON.parse(str) : undefined) @@ -24,10 +27,15 @@ if (rscManifest && rscServerManifest) { export const ComponentMod = module +const edgeCacheHandlers: any = {} +// INJECT_RAW:edgeCacheHandlersRegistration + const internalHandler: EdgeHandler = EdgeRouteModuleWrapper.wrap( module.routeModule, { page: 'VAR_PAGE', + cacheHandlers: edgeCacheHandlers, + incrementalCacheHandler, } ) diff --git a/packages/next/src/build/templates/edge-ssr-app.ts b/packages/next/src/build/templates/edge-ssr-app.ts index 9bcb5137353fa..0261cb1355404 100644 --- a/packages/next/src/build/templates/edge-ssr-app.ts +++ b/packages/next/src/build/templates/edge-ssr-app.ts @@ -9,7 +9,7 @@ import { IncrementalCache } from '../../server/lib/incremental-cache' import * as pageMod from 'VAR_USERLAND' import { setManifestsSingleton } from '../../server/app-render/manifests-singleton' -import { initializeCacheHandlers } from '../../server/use-cache/handlers' +import * as cacheHandlers from '../../server/use-cache/handlers' import { BaseServerSpan } from '../../server/lib/trace/constants' import { getTracer, SpanKind, type Span } from '../../server/lib/trace/tracer' import { WebNextRequest, WebNextResponse } from '../../server/base-http/web' @@ -32,6 +32,7 @@ import type { RequestMeta } from '../../server/request-meta' declare const incrementalCacheHandler: any // OPTIONAL_IMPORT:incrementalCacheHandler +// INJECT_RAW:cacheHandlerImports const maybeJSONParse = (str?: string) => (str ? JSON.parse(str) : undefined) @@ -89,7 +90,8 @@ async function requestHandler( } = prepareResult // Initialize the cache handlers interface. - initializeCacheHandlers(nextConfig.cacheMaxMemorySize) + cacheHandlers.initializeCacheHandlers(nextConfig.cacheMaxMemorySize) + // INJECT_RAW:cacheHandlerRegistration const isPossibleServerAction = getIsPossibleServerAction(req) const botType = getBotType(req.headers.get('User-Agent') || '') diff --git a/packages/next/src/build/templates/edge-ssr.ts b/packages/next/src/build/templates/edge-ssr.ts index 4af423bae1a85..99e2d24dd5db7 100644 --- a/packages/next/src/build/templates/edge-ssr.ts +++ b/packages/next/src/build/templates/edge-ssr.ts @@ -5,7 +5,7 @@ import { type NextRequestHint, } from '../../server/web/adapter' import { IncrementalCache } from '../../server/lib/incremental-cache' -import { initializeCacheHandlers } from '../../server/use-cache/handlers' +import * as cacheHandlers from '../../server/use-cache/handlers' import Document from 'VAR_MODULE_DOCUMENT' import * as appMod from 'VAR_MODULE_APP' @@ -38,6 +38,7 @@ declare const user500RouteModuleOptions: any // INJECT:pageRouteModuleOptions // INJECT:errorRouteModuleOptions // INJECT:user500RouteModuleOptions +// INJECT_RAW:cacheHandlerImports const pageMod = { ...userlandPage, @@ -120,7 +121,8 @@ async function requestHandler( clientAssetToken, } = prepareResult - initializeCacheHandlers(nextConfig.cacheMaxMemorySize) + cacheHandlers.initializeCacheHandlers(nextConfig.cacheMaxMemorySize) + // INJECT_RAW:cacheHandlerRegistration const renderContext: PagesRouteHandlerContext = { page: srcPage, diff --git a/packages/next/src/build/templates/middleware.ts b/packages/next/src/build/templates/middleware.ts index 8045ef2e61dc0..12a6854092754 100644 --- a/packages/next/src/build/templates/middleware.ts +++ b/packages/next/src/build/templates/middleware.ts @@ -4,6 +4,8 @@ import '../../server/web/globals' import { adapter } from '../../server/web/adapter' import { IncrementalCache } from '../../server/lib/incremental-cache' +declare const incrementalCacheHandler: any +// OPTIONAL_IMPORT:incrementalCacheHandler // Import the userland code. import * as _mod from 'VAR_USERLAND' @@ -76,6 +78,7 @@ const internalHandler: EdgeHandler = (opts) => { return adapter({ ...opts, IncrementalCache, + incrementalCacheHandler, page, handler: errorHandledHandler(handlerUserland), }) diff --git a/packages/next/src/build/templates/pages-edge-api.ts b/packages/next/src/build/templates/pages-edge-api.ts index 6f37b0d2b8a09..0a0d47b6f331a 100644 --- a/packages/next/src/build/templates/pages-edge-api.ts +++ b/packages/next/src/build/templates/pages-edge-api.ts @@ -5,6 +5,8 @@ import '../../server/web/globals' import { adapter } from '../../server/web/adapter' import { IncrementalCache } from '../../server/lib/incremental-cache' import { wrapApiHandler } from '../../server/api-utils' +declare const incrementalCacheHandler: any +// OPTIONAL_IMPORT:incrementalCacheHandler // Import the userland code. import handlerUserland from 'VAR_USERLAND' @@ -23,6 +25,7 @@ const internalHandler: EdgeHandler = (opts) => { return adapter({ ...opts, IncrementalCache, + incrementalCacheHandler, page: 'VAR_DEFINITION_PATHNAME', handler: wrapApiHandler(page, handlerUserland), }) diff --git a/packages/next/src/build/webpack/loaders/next-edge-app-route-loader/index.ts b/packages/next/src/build/webpack/loaders/next-edge-app-route-loader/index.ts index 4c692766378d4..1303263f2344d 100644 --- a/packages/next/src/build/webpack/loaders/next-edge-app-route-loader/index.ts +++ b/packages/next/src/build/webpack/loaders/next-edge-app-route-loader/index.ts @@ -12,9 +12,48 @@ export type EdgeAppRouteLoaderQuery = { appDirLoader: string preferredRegion: string | string[] | undefined middlewareConfig: string + cacheHandler?: string cacheHandlers: string } +function getCacheHandlersSetup( + cacheHandlersStringified: string, + contextifyImportPath: (path: string) => string +): { + cacheHandlerImports: string + edgeCacheHandlersRegistration: string +} { + const cacheHandlers = JSON.parse(cacheHandlersStringified || '{}') as Record< + string, + string | undefined + > + const definedCacheHandlers = Object.entries(cacheHandlers).filter( + (entry): entry is [string, string] => Boolean(entry[1]) + ) + + const cacheHandlerImports: string[] = [] + const edgeCacheHandlersRegistration: string[] = [] + + for (const [index, [kind, handlerPath]] of definedCacheHandlers.entries()) { + const cacheHandlerVarName = `edgeCacheHandler_${index}` + const cacheHandlerImportPath = contextifyImportPath(handlerPath) + cacheHandlerImports.push( + `import ${cacheHandlerVarName} from ${JSON.stringify( + cacheHandlerImportPath + )}` + ) + edgeCacheHandlersRegistration.push( + `edgeCacheHandlers[${JSON.stringify(kind)}] = ${cacheHandlerVarName}` + ) + } + + return { + cacheHandlerImports: cacheHandlerImports.join('\n') || '\n', + edgeCacheHandlersRegistration: + edgeCacheHandlersRegistration.join('\n') || '\n', + } +} + const EdgeAppRouteLoader: webpack.LoaderDefinitionFunction = async function (this) { const { @@ -23,6 +62,7 @@ const EdgeAppRouteLoader: webpack.LoaderDefinitionFunction + this.utils.contextify(this.context || this.rootContext, handlerPath) + ) + const incrementalCacheHandler = cacheHandler + ? this.utils.contextify(this.context || this.rootContext, cacheHandler) + : null // Ensure we only run this loader for as a module. if (!this._module) throw new Error('This loader is only usable as a module') @@ -68,7 +109,10 @@ const EdgeAppRouteLoader: webpack.LoaderDefinitionFunction = @@ -19,8 +20,12 @@ const nextEdgeFunctionLoader: webpack.LoaderDefinitionFunction string +): { + cacheHandlerImports: string + cacheHandlerRegistration: string +} { + const cacheHandlers = JSON.parse(cacheHandlersStringified || '{}') as Record< + string, + string | undefined + > + const definedCacheHandlers = Object.entries(cacheHandlers).filter( + (entry): entry is [string, string] => Boolean(entry[1]) + ) + + const cacheHandlerImports: string[] = [] + const cacheHandlerRegistration: string[] = [] + + for (const [index, [kind, handlerPath]] of definedCacheHandlers.entries()) { + const cacheHandlerVarName = `edgeCacheHandler_${index}` + const cacheHandlerImportPath = contextifyImportPath(handlerPath) + cacheHandlerImports.push( + `import ${cacheHandlerVarName} from ${JSON.stringify( + cacheHandlerImportPath + )}` + ) + cacheHandlerRegistration.push( + ` cacheHandlers.setCacheHandler(${JSON.stringify( + kind + )}, ${cacheHandlerVarName})` + ) + } + + return { + cacheHandlerImports: cacheHandlerImports.join('\n') || '\n', + cacheHandlerRegistration: cacheHandlerRegistration.join('\n') || '\n', + } +} + const edgeSSRLoader: webpack.LoaderDefinitionFunction = async function edgeSSRLoader(this) { const { @@ -80,13 +119,14 @@ const edgeSSRLoader: webpack.LoaderDefinitionFunction = middlewareConfig: middlewareConfigBase64, } = this.getOptions() - const cacheHandlers = JSON.parse(cacheHandlersStringified || '{}') - - if (!cacheHandlers.default) { - cacheHandlers.default = require.resolve( - '../../../../server/lib/cache-handlers/default.external' - ) - } + const cacheHandlersSetup = getCacheHandlersSetup( + cacheHandlersStringified, + (handlerPath) => + this.utils.contextify(this.context || this.rootContext, handlerPath) + ) + const incrementalCacheHandler = cacheHandler + ? this.utils.contextify(this.context || this.rootContext, cacheHandler) + : null const middlewareConfig: ProxyConfig = JSON.parse( Buffer.from(middlewareConfigBase64, 'base64').toString() @@ -154,9 +194,9 @@ const edgeSSRLoader: webpack.LoaderDefinitionFunction = VAR_USERLAND: pageModPath, VAR_PAGE: page, }, - {}, + cacheHandlersSetup, { - incrementalCacheHandler: cacheHandler ?? null, + incrementalCacheHandler, } ) } else { @@ -177,10 +217,11 @@ const edgeSSRLoader: webpack.LoaderDefinitionFunction = user500RouteModuleOptions: JSON.stringify( getRouteModuleOptions('/500') ), + ...(cacheHandlersSetup ?? {}), }, { userland500Page: userland500Path, - incrementalCacheHandler: cacheHandler ?? null, + incrementalCacheHandler, } ) } diff --git a/packages/next/src/build/webpack/loaders/next-middleware-loader.ts b/packages/next/src/build/webpack/loaders/next-middleware-loader.ts index 9f2066f3a4c29..e7bbfe7dcddd2 100644 --- a/packages/next/src/build/webpack/loaders/next-middleware-loader.ts +++ b/packages/next/src/build/webpack/loaders/next-middleware-loader.ts @@ -16,6 +16,7 @@ export type MiddlewareLoaderOptions = { matchers?: string preferredRegion: string | string[] | undefined middlewareConfig: string + cacheHandler?: string } // matchers can have special characters that break the loader params @@ -38,6 +39,7 @@ export default async function middlewareLoader(this: any) { matchers: encodedMatchers, preferredRegion, middlewareConfig: middlewareConfigBase64, + cacheHandler, }: MiddlewareLoaderOptions = this.getOptions() const matchers = encodedMatchers ? decodeMatchers(encodedMatchers) : undefined const pagePath = this.utils.contextify( @@ -67,8 +69,17 @@ export default async function middlewareLoader(this: any) { middlewareConfig, } - return await loadEntrypoint('middleware', { - VAR_USERLAND: pagePath, - VAR_DEFINITION_PAGE: page, - }) + return await loadEntrypoint( + 'middleware', + { + VAR_USERLAND: pagePath, + VAR_DEFINITION_PAGE: page, + }, + {}, + { + incrementalCacheHandler: cacheHandler + ? this.utils.contextify(this.context || this.rootContext, cacheHandler) + : null, + } + ) } diff --git a/packages/next/src/server/web/edge-route-module-wrapper.ts b/packages/next/src/server/web/edge-route-module-wrapper.ts index 2615a83c04070..47d19c398dd5a 100644 --- a/packages/next/src/server/web/edge-route-module-wrapper.ts +++ b/packages/next/src/server/web/edge-route-module-wrapper.ts @@ -6,7 +6,12 @@ import type { import './globals' import { adapter, type NextRequestHint, type EdgeHandler } from './adapter' -import { IncrementalCache } from '../lib/incremental-cache' +import { + IncrementalCache, + type CacheHandler as IncrementalCacheHandler, +} from '../lib/incremental-cache' +import type { CacheHandler } from '../lib/cache-handlers/types' +import { initializeCacheHandlers, setCacheHandler } from '../use-cache/handlers' import { RouteMatcher } from '../route-matchers/route-matcher' import type { NextFetchEvent } from './spec-extension/fetch-event' import { internal_getCurrentFunctionWaitUntil } from './internal-edge-wait-until' @@ -18,6 +23,8 @@ import { WebNextRequest } from '../../server/base-http/web' export interface WrapOptions { page: string + cacheHandlers?: Record + incrementalCacheHandler?: typeof IncrementalCacheHandler } /** @@ -34,7 +41,10 @@ export class EdgeRouteModuleWrapper { * * @param routeModule the route module to wrap */ - private constructor(private readonly routeModule: AppRouteRouteModule) { + private constructor( + private readonly routeModule: AppRouteRouteModule, + private readonly cacheHandlers: Record + ) { // TODO: (wyattjoh) possibly allow the module to define it's own matcher this.matcher = new RouteMatcher(routeModule.definition) } @@ -53,13 +63,17 @@ export class EdgeRouteModuleWrapper { options: WrapOptions ): EdgeHandler { // Create the module wrapper. - const wrapper = new EdgeRouteModuleWrapper(routeModule) + const wrapper = new EdgeRouteModuleWrapper( + routeModule, + options.cacheHandlers ?? {} + ) // Return the wrapping function. return (opts) => { return adapter({ ...opts, IncrementalCache, + incrementalCacheHandler: options.incrementalCacheHandler, // Bind the handler method to the wrapper so it still has context. handler: wrapper.handler.bind(wrapper), page: options.page, @@ -84,6 +98,10 @@ export class EdgeRouteModuleWrapper { const { nextConfig } = this.routeModule.getNextConfigEdge( new WebNextRequest(request) ) + initializeCacheHandlers(nextConfig.cacheMaxMemorySize) + for (const [kind, cacheHandler] of Object.entries(this.cacheHandlers)) { + setCacheHandler(kind, cacheHandler) + } const { params } = utils.normalizeDynamicRouteParams( searchParamsToUrlQuery(request.nextUrl.searchParams), diff --git a/test/e2e/cache-handlers-upstream-wiring/fixtures/edge-without-cache-components/app/api/edge-route/route.js b/test/e2e/cache-handlers-upstream-wiring/fixtures/edge-without-cache-components/app/api/edge-route/route.js new file mode 100644 index 0000000000000..96f1d988a480c --- /dev/null +++ b/test/e2e/cache-handlers-upstream-wiring/fixtures/edge-without-cache-components/app/api/edge-route/route.js @@ -0,0 +1,5 @@ +export const runtime = 'edge' + +export async function GET() { + return Response.json({ ok: true }) +} diff --git a/test/e2e/cache-handlers-upstream-wiring/fixtures/edge-without-cache-components/app/edge-page/page.js b/test/e2e/cache-handlers-upstream-wiring/fixtures/edge-without-cache-components/app/edge-page/page.js new file mode 100644 index 0000000000000..d6be290b8766a --- /dev/null +++ b/test/e2e/cache-handlers-upstream-wiring/fixtures/edge-without-cache-components/app/edge-page/page.js @@ -0,0 +1,5 @@ +export const runtime = 'edge' + +export default function Page() { + return
edge page
+} diff --git a/test/e2e/cache-handlers-upstream-wiring/fixtures/edge-without-cache-components/app/layout.js b/test/e2e/cache-handlers-upstream-wiring/fixtures/edge-without-cache-components/app/layout.js new file mode 100644 index 0000000000000..803f17d863c8a --- /dev/null +++ b/test/e2e/cache-handlers-upstream-wiring/fixtures/edge-without-cache-components/app/layout.js @@ -0,0 +1,7 @@ +export default function RootLayout({ children }) { + return ( + + {children} + + ) +} diff --git a/test/e2e/cache-handlers-upstream-wiring/fixtures/edge-without-cache-components/incremental-cache-handler.js b/test/e2e/cache-handlers-upstream-wiring/fixtures/edge-without-cache-components/incremental-cache-handler.js new file mode 100644 index 0000000000000..8db24331dad1a --- /dev/null +++ b/test/e2e/cache-handlers-upstream-wiring/fixtures/edge-without-cache-components/incremental-cache-handler.js @@ -0,0 +1,10 @@ +const { + default: FileSystemCache, +} = require('next/dist/server/lib/incremental-cache/file-system-cache') + +module.exports = class IncrementalCacheHandler extends FileSystemCache { + constructor(options) { + super(options) + console.log('WiringIncrementalCacheHandler::constructor') + } +} diff --git a/test/e2e/cache-handlers-upstream-wiring/fixtures/edge-without-cache-components/next.config.js b/test/e2e/cache-handlers-upstream-wiring/fixtures/edge-without-cache-components/next.config.js new file mode 100644 index 0000000000000..1482b1023947e --- /dev/null +++ b/test/e2e/cache-handlers-upstream-wiring/fixtures/edge-without-cache-components/next.config.js @@ -0,0 +1,8 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + cacheHandler: require.resolve('./incremental-cache-handler'), +} + +module.exports = nextConfig diff --git a/test/e2e/cache-handlers-upstream-wiring/fixtures/non-edge-cache-components/app/layout.js b/test/e2e/cache-handlers-upstream-wiring/fixtures/non-edge-cache-components/app/layout.js new file mode 100644 index 0000000000000..803f17d863c8a --- /dev/null +++ b/test/e2e/cache-handlers-upstream-wiring/fixtures/non-edge-cache-components/app/layout.js @@ -0,0 +1,7 @@ +export default function RootLayout({ children }) { + return ( + + {children} + + ) +} diff --git a/test/e2e/cache-handlers-upstream-wiring/fixtures/non-edge-cache-components/app/page.js b/test/e2e/cache-handlers-upstream-wiring/fixtures/non-edge-cache-components/app/page.js new file mode 100644 index 0000000000000..946ad21dc4e7f --- /dev/null +++ b/test/e2e/cache-handlers-upstream-wiring/fixtures/non-edge-cache-components/app/page.js @@ -0,0 +1,13 @@ +import { cacheTag } from 'next/cache' + +async function getCachedValue() { + 'use cache: custom' + cacheTag('custom-tag') + return Date.now().toString() +} + +export default async function Page() { + const value = await getCachedValue() + + return
{value}
+} diff --git a/test/e2e/cache-handlers-upstream-wiring/fixtures/non-edge-cache-components/app/revalidate-actions/page.js b/test/e2e/cache-handlers-upstream-wiring/fixtures/non-edge-cache-components/app/revalidate-actions/page.js new file mode 100644 index 0000000000000..d2e03b66823b4 --- /dev/null +++ b/test/e2e/cache-handlers-upstream-wiring/fixtures/non-edge-cache-components/app/revalidate-actions/page.js @@ -0,0 +1,24 @@ +import { revalidatePath, revalidateTag } from 'next/cache' + +async function revalidateTagAction() { + 'use server' + revalidateTag('custom-tag', 'max') +} + +async function revalidatePathAction() { + 'use server' + revalidatePath('/revalidate-target') +} + +export default function Page() { + return ( + <> +
+ +
+
+ +
+ + ) +} diff --git a/test/e2e/cache-handlers-upstream-wiring/fixtures/non-edge-cache-components/app/revalidate-target/page.js b/test/e2e/cache-handlers-upstream-wiring/fixtures/non-edge-cache-components/app/revalidate-target/page.js new file mode 100644 index 0000000000000..ed7847164c10f --- /dev/null +++ b/test/e2e/cache-handlers-upstream-wiring/fixtures/non-edge-cache-components/app/revalidate-target/page.js @@ -0,0 +1,13 @@ +import { cacheTag } from 'next/cache' + +async function getCachedValue() { + 'use cache: custom' + cacheTag('custom-tag') + return Date.now().toString() +} + +export default async function Page() { + const value = await getCachedValue() + + return
{value}
+} diff --git a/test/e2e/cache-handlers-upstream-wiring/fixtures/non-edge-cache-components/modern-cache-handler.js b/test/e2e/cache-handlers-upstream-wiring/fixtures/non-edge-cache-components/modern-cache-handler.js new file mode 100644 index 0000000000000..30e4cf3b8ecf2 --- /dev/null +++ b/test/e2e/cache-handlers-upstream-wiring/fixtures/non-edge-cache-components/modern-cache-handler.js @@ -0,0 +1,36 @@ +// @ts-check + +const defaultCacheHandler = + require('next/dist/server/lib/cache-handlers/default.external').default + +/** + * @type {import('next/dist/server/lib/cache-handlers/types').CacheHandler} + */ +const cacheHandler = { + async get(cacheKey, softTags) { + console.log('WiringModernCacheHandler::get', cacheKey, softTags) + return defaultCacheHandler.get(cacheKey, softTags) + }, + + async set(cacheKey, pendingEntry) { + console.log('WiringModernCacheHandler::set', cacheKey) + return defaultCacheHandler.set(cacheKey, pendingEntry) + }, + + async refreshTags() { + console.log('WiringModernCacheHandler::refreshTags') + return defaultCacheHandler.refreshTags() + }, + + async getExpiration(tags) { + console.log('WiringModernCacheHandler::getExpiration', JSON.stringify(tags)) + return Infinity + }, + + async updateTags(tags) { + console.log('WiringModernCacheHandler::updateTags', JSON.stringify(tags)) + return defaultCacheHandler.updateTags(tags) + }, +} + +module.exports = cacheHandler diff --git a/test/e2e/cache-handlers-upstream-wiring/fixtures/non-edge-cache-components/next.config.js b/test/e2e/cache-handlers-upstream-wiring/fixtures/non-edge-cache-components/next.config.js new file mode 100644 index 0000000000000..b978c7514bd7f --- /dev/null +++ b/test/e2e/cache-handlers-upstream-wiring/fixtures/non-edge-cache-components/next.config.js @@ -0,0 +1,12 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + cacheComponents: true, + cacheHandlers: { + default: require.resolve('./modern-cache-handler'), + custom: require.resolve('./modern-cache-handler'), + }, +} + +module.exports = nextConfig diff --git a/test/e2e/cache-handlers-upstream-wiring/fixtures/pages-router-non-edge/incremental-cache-handler.js b/test/e2e/cache-handlers-upstream-wiring/fixtures/pages-router-non-edge/incremental-cache-handler.js new file mode 100644 index 0000000000000..c3b5d60acfd6a --- /dev/null +++ b/test/e2e/cache-handlers-upstream-wiring/fixtures/pages-router-non-edge/incremental-cache-handler.js @@ -0,0 +1,18 @@ +const { + default: FileSystemCache, +} = require('next/dist/server/lib/incremental-cache/file-system-cache') + +module.exports = class IncrementalCacheHandler extends FileSystemCache { + constructor(options) { + super(options) + console.log('WiringPagesIncrementalCacheHandler::constructor') + } + + async revalidateTag(tags) { + console.log( + 'WiringPagesIncrementalCacheHandler::revalidateTag', + JSON.stringify(tags) + ) + return super.revalidateTag(tags) + } +} diff --git a/test/e2e/cache-handlers-upstream-wiring/fixtures/pages-router-non-edge/next.config.js b/test/e2e/cache-handlers-upstream-wiring/fixtures/pages-router-non-edge/next.config.js new file mode 100644 index 0000000000000..1482b1023947e --- /dev/null +++ b/test/e2e/cache-handlers-upstream-wiring/fixtures/pages-router-non-edge/next.config.js @@ -0,0 +1,8 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + cacheHandler: require.resolve('./incremental-cache-handler'), +} + +module.exports = nextConfig diff --git a/test/e2e/cache-handlers-upstream-wiring/fixtures/pages-router-non-edge/pages/api/revalidate.js b/test/e2e/cache-handlers-upstream-wiring/fixtures/pages-router-non-edge/pages/api/revalidate.js new file mode 100644 index 0000000000000..50c12742dbda0 --- /dev/null +++ b/test/e2e/cache-handlers-upstream-wiring/fixtures/pages-router-non-edge/pages/api/revalidate.js @@ -0,0 +1,4 @@ +export default async function handler(req, res) { + await res.revalidate('/isr') + return res.status(200).json({ revalidated: true }) +} diff --git a/test/e2e/cache-handlers-upstream-wiring/fixtures/pages-router-non-edge/pages/isr.js b/test/e2e/cache-handlers-upstream-wiring/fixtures/pages-router-non-edge/pages/isr.js new file mode 100644 index 0000000000000..a276085114161 --- /dev/null +++ b/test/e2e/cache-handlers-upstream-wiring/fixtures/pages-router-non-edge/pages/isr.js @@ -0,0 +1,12 @@ +export async function getStaticProps() { + return { + props: { + now: Date.now().toString(), + }, + revalidate: 3600, + } +} + +export default function Page({ now }) { + return

{now}

+} diff --git a/test/e2e/cache-handlers-upstream-wiring/index.test.ts b/test/e2e/cache-handlers-upstream-wiring/index.test.ts new file mode 100644 index 0000000000000..b792c16b71d35 --- /dev/null +++ b/test/e2e/cache-handlers-upstream-wiring/index.test.ts @@ -0,0 +1,137 @@ +import { nextTestSetup } from 'e2e-utils' +import { retry } from 'next-test-utils' +import { join } from 'path' + +describe('cache-handlers-upstream-wiring', () => { + describe('pages router non-edge', () => { + const { next, skipped, isNextDev } = nextTestSetup({ + files: join(__dirname, 'fixtures/pages-router-non-edge'), + skipDeployment: true, + }) + + if (skipped) { + return + } + + let outputIndex = 0 + + beforeEach(() => { + outputIndex = next.cliOutput.length + }) + + it('wires res.revalidate() through the configured custom cacheHandler', async () => { + const initialHtml = await next.render$('/isr') + const initialValue = initialHtml('#now').text() + outputIndex = next.cliOutput.length + + const revalidateResponse = await next.fetch('/api/revalidate') + expect(revalidateResponse.status).toBe(200) + expect(await revalidateResponse.json()).toEqual({ revalidated: true }) + + if (!isNextDev) { + await retry(async () => { + const htmlAfterRevalidate = await next.render$('/isr') + const valueAfterRevalidate = htmlAfterRevalidate('#now').text() + expect(valueAfterRevalidate).not.toBe(initialValue) + }) + } + + await next.render$('/isr') + + await retry(async () => { + const output = next.cliOutput.slice(outputIndex) + const constructorLogs = + output.match(/WiringPagesIncrementalCacheHandler::constructor/g) ?? [] + const sawRevalidateTag = output.includes( + 'WiringPagesIncrementalCacheHandler::revalidateTag' + ) + + expect(sawRevalidateTag || constructorLogs.length > 0).toBe(true) + }) + }) + }) + + describe('cacheComponents enabled, non-edge app router', () => { + const { next, skipped, isNextDev } = nextTestSetup({ + files: join(__dirname, 'fixtures/non-edge-cache-components'), + skipDeployment: true, + }) + + if (skipped) { + return + } + + let outputIndex = 0 + + beforeEach(() => { + outputIndex = next.cliOutput.length + }) + + it('uses configured cacheHandlers for custom app-router cache kind', async () => { + const pageResponse = await next.fetch('/') + expect(pageResponse.status).toBe(200) + + await retry(async () => { + const output = isNextDev + ? next.cliOutput.slice(outputIndex) + : next.cliOutput + expect(output).toContain('WiringModernCacheHandler::set') + expect(output).toContain('WiringModernCacheHandler::get') + }) + }) + + it('wires revalidateTag and revalidatePath to the custom cache handler', async () => { + const seedResponse = await next.fetch('/revalidate-target') + expect(seedResponse.status).toBe(200) + + const browser = await next.browser('/revalidate-actions') + await browser.elementById('revalidate-tag').click() + await browser.elementById('revalidate-path').click() + + await retry(async () => { + const output = next.cliOutput.slice(outputIndex) + const updateTagLogs = + output.match(/WiringModernCacheHandler::updateTags/g) ?? [] + + expect(updateTagLogs.length).toBeGreaterThanOrEqual(2) + expect(output).toContain('custom-tag') + }) + }) + }) + ;(process.env.__NEXT_CACHE_COMPONENTS ? describe.skip : describe)( + 'cacheComponents disabled, edge app router', + () => { + const { next, skipped } = nextTestSetup({ + files: join(__dirname, 'fixtures/edge-without-cache-components'), + skipDeployment: true, + }) + + if (skipped) { + return + } + + let outputIndex = 0 + + beforeEach(() => { + outputIndex = next.cliOutput.length + }) + + it('uses configured cacheHandler for edge app page and edge app route', async () => { + const edgePageResponse = await next.fetch('/edge-page') + expect(edgePageResponse.status).toBe(200) + + const edgeRouteResponse = await next.fetch('/api/edge-route') + expect(edgeRouteResponse.status).toBe(200) + expect(await edgeRouteResponse.json()).toEqual({ ok: true }) + + await retry(async () => { + const output = next.cliOutput.slice(outputIndex) + const constructorLogs = + output.match(/WiringIncrementalCacheHandler::constructor/g) ?? [] + + expect(constructorLogs.length).toBeGreaterThanOrEqual(2) + }) + }) + } + ) +}) From 75038781a8ced45815c5d34ef866e2930e41810e Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Fri, 13 Mar 2026 18:36:49 +0100 Subject: [PATCH 10/16] [test] Resolve stale merge issues (#91329) Caused by https://github.com/vercel/next.js/pull/91207 and https://github.com/vercel/next.js/pull/91256/ --- .../instant-navs-devtools.test.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/test/development/app-dir/instant-navs-devtools/instant-navs-devtools.test.ts b/test/development/app-dir/instant-navs-devtools/instant-navs-devtools.test.ts index 73d8786fa0715..82fb93cd8e92e 100644 --- a/test/development/app-dir/instant-navs-devtools/instant-navs-devtools.test.ts +++ b/test/development/app-dir/instant-navs-devtools/instant-navs-devtools.test.ts @@ -42,6 +42,10 @@ describe('instant-nav-panel', () => { return browser.elementByCss('#_next-devtools-panel-close').click() } + async function hasInstantNavPanelOpen(browser: Playwright): Promise { + await browser.elementByCssInstant('.instant-nav-panel') + } + async function openInstantNavPanel(browser: Playwright) { await toggleDevToolsIndicatorPopover(browser) await waitForPanelRouterTransition() @@ -49,7 +53,7 @@ describe('instant-nav-panel', () => { await retry( async () => { - await browser.elementByCssInstant('.instant-nav-panel') + await hasInstantNavPanelOpen(browser) }, 5_000, 500 @@ -158,9 +162,6 @@ describe('instant-nav-panel', () => { // Open the panel and click Start to set the cookie await openInstantNavPanel(browser) - await retry(async () => { - expect(await hasPanelOpen(browser)).toBe(true) - }) await clickStartClientNav(browser) // Reload — the cookie persists, so the panel should auto-open @@ -168,7 +169,7 @@ describe('instant-nav-panel', () => { await browser.waitForElementByCss('[data-testid="home-title"]') await retry(async () => { - expect(await hasPanelOpen(browser)).toBe(true) + await hasInstantNavPanelOpen(browser) }) // Clean up From d7de6f105ce82e9ec6561bbb94655ee8bcf61421 Mon Sep 17 00:00:00 2001 From: Benjamin Woodruff Date: Fri, 13 Mar 2026 10:43:28 -0700 Subject: [PATCH 11/16] Turbopack: Remove the IntoTraitRef trait, make it an inherent method on Vc (#91223) This is basically one-shot from Opus 4.6, with a minor tweak to the doc comment by hand. Discussed this with @lukesandberg, and we realized that there's only one implementation of this trait, so it doesn't make a ton of sense as a trait. --- crates/next-api/src/project.rs | 4 +- .../ecmascript_client_reference_module.rs | 2 +- .../tests/trait_ref_cell.rs | 2 +- .../tests/trait_ref_cell_mode.rs | 3 +- turbopack/crates/turbo-tasks/src/lib.rs | 2 +- turbopack/crates/turbo-tasks/src/trait_ref.rs | 37 +------------------ turbopack/crates/turbo-tasks/src/vc/mod.rs | 16 ++++++++ .../crates/turbo-tasks/src/vc/operation.rs | 5 +-- turbopack/crates/turbo-tasks/src/vc/traits.rs | 2 - .../src/ecmascript/list/content.rs | 4 +- .../src/ecmascript/list/update.rs | 2 +- .../src/ecmascript/merged/update.rs | 2 +- .../crates/turbopack-core/src/issue/mod.rs | 6 +-- .../turbopack-core/src/resolve/error.rs | 2 +- .../crates/turbopack-core/src/version.rs | 2 +- turbopack/crates/turbopack-css/src/asset.rs | 2 +- .../crates/turbopack-css/src/module_asset.rs | 2 +- .../turbopack-dev-server/src/update/stream.rs | 3 +- .../crates/turbopack-ecmascript/src/lib.rs | 4 +- .../crates/turbopack-node/src/evaluate.rs | 4 +- .../src/ecmascript/node/update.rs | 2 +- .../crates/turbopack-wasm/src/module_asset.rs | 2 +- turbopack/crates/turbopack-wasm/src/raw.rs | 2 +- .../turbopack/src/module_options/mod.rs | 2 +- 24 files changed, 45 insertions(+), 69 deletions(-) diff --git a/crates/next-api/src/project.rs b/crates/next-api/src/project.rs index 0ea89113d9cdc..9d1f2048b5c64 100644 --- a/crates/next-api/src/project.rs +++ b/crates/next-api/src/project.rs @@ -33,8 +33,8 @@ use serde::{Deserialize, Serialize}; use tracing::{Instrument, field::Empty}; use turbo_rcstr::{RcStr, rcstr}; use turbo_tasks::{ - Completion, Completions, FxIndexMap, IntoTraitRef, NonLocalValue, OperationValue, OperationVc, - ReadRef, ResolvedVc, State, TaskInput, TransientInstance, TryFlatJoinIterExt, Vc, + Completion, Completions, FxIndexMap, NonLocalValue, OperationValue, OperationVc, ReadRef, + ResolvedVc, State, TaskInput, TransientInstance, TryFlatJoinIterExt, Vc, debug::ValueDebugFormat, fxindexmap, trace::TraceRawVcs, }; use turbo_tasks_env::{EnvMap, ProcessEnv}; diff --git a/crates/next-core/src/next_client_reference/ecmascript_client_reference/ecmascript_client_reference_module.rs b/crates/next-core/src/next_client_reference/ecmascript_client_reference/ecmascript_client_reference_module.rs index 6f8b7c7d36180..3e653dfbf3d84 100644 --- a/crates/next-core/src/next_client_reference/ecmascript_client_reference/ecmascript_client_reference_module.rs +++ b/crates/next-core/src/next_client_reference/ecmascript_client_reference/ecmascript_client_reference_module.rs @@ -3,7 +3,7 @@ use std::{io::Write, iter::once}; use anyhow::{Context, Result, bail}; use indoc::writedoc; use turbo_rcstr::{RcStr, rcstr}; -use turbo_tasks::{IntoTraitRef, ResolvedVc, ValueToString, Vc}; +use turbo_tasks::{ResolvedVc, ValueToString, Vc}; use turbo_tasks_fs::{File, FileContent}; use turbopack_core::{ asset::AssetContent, diff --git a/turbopack/crates/turbo-tasks-backend/tests/trait_ref_cell.rs b/turbopack/crates/turbo-tasks-backend/tests/trait_ref_cell.rs index 4b6437fa75809..93c6928851858 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/trait_ref_cell.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/trait_ref_cell.rs @@ -6,7 +6,7 @@ use std::{collections::HashSet, mem::take, sync::Mutex}; use anyhow::Result; use turbo_tasks::{ - IntoTraitRef, Invalidator, TraitRef, Vc, get_invalidator, + Invalidator, TraitRef, Vc, get_invalidator, unmark_top_level_task_may_leak_eventually_consistent_state, with_turbo_tasks, }; use turbo_tasks_testing::{Registration, register, run_once}; diff --git a/turbopack/crates/turbo-tasks-backend/tests/trait_ref_cell_mode.rs b/turbopack/crates/turbo-tasks-backend/tests/trait_ref_cell_mode.rs index 3df6de5b26289..055d23a36f6cf 100644 --- a/turbopack/crates/turbo-tasks-backend/tests/trait_ref_cell_mode.rs +++ b/turbopack/crates/turbo-tasks-backend/tests/trait_ref_cell_mode.rs @@ -3,8 +3,7 @@ use anyhow::Result; use turbo_tasks::{ - IntoTraitRef, State, TraitRef, Upcast, Vc, - unmark_top_level_task_may_leak_eventually_consistent_state, + State, TraitRef, Upcast, Vc, unmark_top_level_task_may_leak_eventually_consistent_state, }; use turbo_tasks_testing::{Registration, register, run_once}; diff --git a/turbopack/crates/turbo-tasks/src/lib.rs b/turbopack/crates/turbo-tasks/src/lib.rs index ec757e177b2bf..346214884771b 100644 --- a/turbopack/crates/turbo-tasks/src/lib.rs +++ b/turbopack/crates/turbo-tasks/src/lib.rs @@ -108,7 +108,7 @@ pub use crate::{ task_input::{EitherTaskInput, TaskInput}, }, task_execution_reason::TaskExecutionReason, - trait_ref::{IntoTraitRef, TraitRef}, + trait_ref::TraitRef, value::{TransientInstance, TransientValue}, value_type::{TraitMethod, TraitType, ValueType}, vc::{ diff --git a/turbopack/crates/turbo-tasks/src/trait_ref.rs b/turbopack/crates/turbo-tasks/src/trait_ref.rs index 0e90deacceb2f..4bf6fc9cebd40 100644 --- a/turbopack/crates/turbo-tasks/src/trait_ref.rs +++ b/turbopack/crates/turbo-tasks/src/trait_ref.rs @@ -1,12 +1,7 @@ -use std::{fmt::Debug, future::Future, marker::PhantomData}; - -use anyhow::Result; +use std::{fmt::Debug, marker::PhantomData}; use crate::{ - Vc, VcValueTrait, - registry::get_value_type, - task::shared_reference::TypedSharedReference, - vc::{ReadVcFuture, VcValueTraitCast, cast::VcCast}, + Vc, VcValueTrait, registry::get_value_type, task::shared_reference::TypedSharedReference, }; /// Similar to a [`ReadRef`][crate::ReadRef], but contains a value trait object instead. @@ -117,31 +112,3 @@ where (value_type.raw_cell)(shared_reference).into() } } - -/// A trait that allows a value trait vc to be converted into a trait reference. -/// -/// The signature is similar to `IntoFuture`, but we don't want trait vcs to -/// have the same future-like semantics as value vcs when it comes to producing -/// refs. This behavior is rarely needed, so in most cases, `.await`ing a trait -/// vc is a mistake. -pub trait IntoTraitRef { - type ValueTrait: VcValueTrait + ?Sized; - type Future: Future as VcCast>::Output>>; - - fn into_trait_ref(self) -> Self::Future; -} - -impl IntoTraitRef for Vc -where - T: VcValueTrait + ?Sized, -{ - type ValueTrait = T; - - type Future = ReadVcFuture>; - - fn into_trait_ref(self) -> Self::Future { - self.node - .into_read_with_unknown_is_serializable_cell_content() - .into() - } -} diff --git a/turbopack/crates/turbo-tasks/src/vc/mod.rs b/turbopack/crates/turbo-tasks/src/vc/mod.rs index 03ae3f13be67b..4cdddc021e410 100644 --- a/turbopack/crates/turbo-tasks/src/vc/mod.rs +++ b/turbopack/crates/turbo-tasks/src/vc/mod.rs @@ -514,6 +514,22 @@ where impl Unpin for Vc where T: ?Sized {} +impl Vc +where + T: VcValueTrait + ?Sized, +{ + /// Converts this trait vc into a trait reference. + /// + /// The signature is similar to [`IntoFuture::into_future`], but we don't want trait vcs to + /// have the same future-like semantics as value vcs when it comes to producing refs. This + /// behavior is rarely needed, so in most cases, `.await`ing a trait vc is a mistake. + pub fn into_trait_ref(self) -> ReadVcFuture> { + self.node + .into_read_with_unknown_is_serializable_cell_content() + .into() + } +} + impl Default for Vc where T: ValueDefault, diff --git a/turbopack/crates/turbo-tasks/src/vc/operation.rs b/turbopack/crates/turbo-tasks/src/vc/operation.rs index 1e83f521d23c0..f59cd06b9f8ec 100644 --- a/turbopack/crates/turbo-tasks/src/vc/operation.rs +++ b/turbopack/crates/turbo-tasks/src/vc/operation.rs @@ -7,9 +7,8 @@ use serde::{Deserialize, Serialize}; pub use turbo_tasks_macros::OperationValue; use crate::{ - CollectiblesSource, IntoTraitRef, RawVc, ReadVcFuture, ResolvedVc, TaskInput, UpcastStrict, Vc, - VcValueTrait, VcValueTraitCast, VcValueType, marker_trait::impl_auto_marker_trait, - trace::TraceRawVcs, + CollectiblesSource, RawVc, ReadVcFuture, ResolvedVc, TaskInput, UpcastStrict, Vc, VcValueTrait, + VcValueTraitCast, VcValueType, marker_trait::impl_auto_marker_trait, trace::TraceRawVcs, }; /// A "subtype" (can be converted via [`.connect()`]) of [`Vc`] that diff --git a/turbopack/crates/turbo-tasks/src/vc/traits.rs b/turbopack/crates/turbo-tasks/src/vc/traits.rs index 8773f92333a81..c9ffebe0f1e8a 100644 --- a/turbopack/crates/turbo-tasks/src/vc/traits.rs +++ b/turbopack/crates/turbo-tasks/src/vc/traits.rs @@ -114,8 +114,6 @@ pub unsafe trait VcValueType: ShrinkToFit + Sized + Send + Sync + 'static { /// functions defined on the trait to be called. /// /// ```ignore -/// use turbo_tasks::IntoTraitRef; -/// /// let trait_vc: Vc> = ...; /// let trait_ref: TraitRef> = trait_vc.into_trait_ref().await?; /// diff --git a/turbopack/crates/turbopack-browser/src/ecmascript/list/content.rs b/turbopack/crates/turbopack-browser/src/ecmascript/list/content.rs index 732fe122548eb..940002ffc02a0 100644 --- a/turbopack/crates/turbopack-browser/src/ecmascript/list/content.rs +++ b/turbopack/crates/turbopack-browser/src/ecmascript/list/content.rs @@ -6,9 +6,7 @@ use either::Either; use indoc::writedoc; use serde::{Deserialize, Serialize}; use turbo_rcstr::RcStr; -use turbo_tasks::{ - FxIndexMap, IntoTraitRef, NonLocalValue, ResolvedVc, TryJoinIterExt, Vc, trace::TraceRawVcs, -}; +use turbo_tasks::{FxIndexMap, NonLocalValue, ResolvedVc, TryJoinIterExt, Vc, trace::TraceRawVcs}; use turbo_tasks_fs::{File, FileContent}; use turbopack_core::{ asset::{Asset, AssetContent}, diff --git a/turbopack/crates/turbopack-browser/src/ecmascript/list/update.rs b/turbopack/crates/turbopack-browser/src/ecmascript/list/update.rs index b3df2be9b6ab8..35770006da9b2 100644 --- a/turbopack/crates/turbopack-browser/src/ecmascript/list/update.rs +++ b/turbopack/crates/turbopack-browser/src/ecmascript/list/update.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use anyhow::Result; use serde::Serialize; -use turbo_tasks::{FxIndexMap, IntoTraitRef, ResolvedVc, TraitRef, Vc}; +use turbo_tasks::{FxIndexMap, ResolvedVc, TraitRef, Vc}; use turbopack_core::version::{ MergeableVersionedContent, PartialUpdate, TotalUpdate, Update, Version, VersionedContent, VersionedContentMerger, diff --git a/turbopack/crates/turbopack-browser/src/ecmascript/merged/update.rs b/turbopack/crates/turbopack-browser/src/ecmascript/merged/update.rs index 420bc5185a102..c6fe0ad359f15 100644 --- a/turbopack/crates/turbopack-browser/src/ecmascript/merged/update.rs +++ b/turbopack/crates/turbopack-browser/src/ecmascript/merged/update.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use anyhow::Result; use serde::Serialize; -use turbo_tasks::{FxIndexMap, FxIndexSet, IntoTraitRef, ReadRef, ResolvedVc, TryJoinIterExt, Vc}; +use turbo_tasks::{FxIndexMap, FxIndexSet, ReadRef, ResolvedVc, TryJoinIterExt, Vc}; use turbo_tasks_fs::rope::Rope; use turbopack_core::{ chunk::{ChunkingContext, ModuleId}, diff --git a/turbopack/crates/turbopack-core/src/issue/mod.rs b/turbopack/crates/turbopack-core/src/issue/mod.rs index b26a3965e018a..8a243c82f6e8d 100644 --- a/turbopack/crates/turbopack-core/src/issue/mod.rs +++ b/turbopack/crates/turbopack-core/src/issue/mod.rs @@ -15,9 +15,9 @@ use serde::{Deserialize, Serialize}; use turbo_esregex::EsRegex; use turbo_rcstr::RcStr; use turbo_tasks::{ - CollectiblesSource, IntoTraitRef, NonLocalValue, OperationVc, RawVc, ReadRef, ResolvedVc, - TaskInput, TransientValue, TryFlatJoinIterExt, TryJoinIterExt, Upcast, ValueDefault, - ValueToString, Vc, emit, trace::TraceRawVcs, + CollectiblesSource, NonLocalValue, OperationVc, RawVc, ReadRef, ResolvedVc, TaskInput, + TransientValue, TryFlatJoinIterExt, TryJoinIterExt, Upcast, ValueDefault, ValueToString, Vc, + emit, trace::TraceRawVcs, }; use turbo_tasks_fs::{ FileContent, FileLine, FileLinesContent, FileSystem, FileSystemPath, glob::Glob, diff --git a/turbopack/crates/turbopack-core/src/resolve/error.rs b/turbopack/crates/turbopack-core/src/resolve/error.rs index bd620fa66d4c7..b5dd0b74699e9 100644 --- a/turbopack/crates/turbopack-core/src/resolve/error.rs +++ b/turbopack/crates/turbopack-core/src/resolve/error.rs @@ -1,6 +1,6 @@ use anyhow::Result; use turbo_rcstr::RcStr; -use turbo_tasks::{IntoTraitRef, PrettyPrintError, ResolvedVc, Vc}; +use turbo_tasks::{PrettyPrintError, ResolvedVc, Vc}; use turbo_tasks_fs::FileSystemPath; use crate::{ diff --git a/turbopack/crates/turbopack-core/src/version.rs b/turbopack/crates/turbopack-core/src/version.rs index 44c91da25c4cd..a82040587dc31 100644 --- a/turbopack/crates/turbopack-core/src/version.rs +++ b/turbopack/crates/turbopack-core/src/version.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use anyhow::{Context, Result, bail}; use turbo_rcstr::RcStr; use turbo_tasks::{ - IntoTraitRef, NonLocalValue, OperationValue, ReadRef, ResolvedVc, State, TraitRef, Vc, + NonLocalValue, OperationValue, ReadRef, ResolvedVc, State, TraitRef, Vc, debug::ValueDebugFormat, trace::TraceRawVcs, }; use turbo_tasks_hash::HashAlgorithm; diff --git a/turbopack/crates/turbopack-css/src/asset.rs b/turbopack/crates/turbopack-css/src/asset.rs index fd0ea5d132f56..ad9536bd65fbb 100644 --- a/turbopack/crates/turbopack-css/src/asset.rs +++ b/turbopack/crates/turbopack-css/src/asset.rs @@ -1,6 +1,6 @@ use anyhow::Result; use turbo_rcstr::rcstr; -use turbo_tasks::{IntoTraitRef, ResolvedVc, TryJoinIterExt, Vc, turbofmt}; +use turbo_tasks::{ResolvedVc, TryJoinIterExt, Vc, turbofmt}; use turbo_tasks_fs::{FileContent, FileSystemPath}; use turbopack_core::{ chunk::{ChunkItem, ChunkType, ChunkableModule, ChunkingContext, MinifyType}, diff --git a/turbopack/crates/turbopack-css/src/module_asset.rs b/turbopack/crates/turbopack-css/src/module_asset.rs index 7196803177bfd..4e9f2acd17516 100644 --- a/turbopack/crates/turbopack-css/src/module_asset.rs +++ b/turbopack/crates/turbopack-css/src/module_asset.rs @@ -4,7 +4,7 @@ use anyhow::{Context, Result}; use lightningcss::css_modules::CssModuleReference; use swc_core::common::{BytePos, FileName, LineCol, SourceMap}; use turbo_rcstr::{RcStr, rcstr}; -use turbo_tasks::{FxIndexMap, IntoTraitRef, ResolvedVc, Vc, turbofmt}; +use turbo_tasks::{FxIndexMap, ResolvedVc, Vc, turbofmt}; use turbo_tasks_fs::{FileSystemPath, rope::Rope}; use turbopack_core::{ chunk::{AsyncModuleInfo, ChunkableModule, ChunkingContext, ModuleChunkItemIdExt}, diff --git a/turbopack/crates/turbopack-dev-server/src/update/stream.rs b/turbopack/crates/turbopack-dev-server/src/update/stream.rs index 7706c84ba8e9d..665fb89a6e6c2 100644 --- a/turbopack/crates/turbopack-dev-server/src/update/stream.rs +++ b/turbopack/crates/turbopack-dev-server/src/update/stream.rs @@ -7,8 +7,7 @@ use tokio_stream::wrappers::ReceiverStream; use tracing::Instrument; use turbo_rcstr::{RcStr, rcstr}; use turbo_tasks::{ - IntoTraitRef, NonLocalValue, OperationVc, PrettyPrintError, ReadRef, ResolvedVc, - TransientInstance, Vc, + NonLocalValue, OperationVc, PrettyPrintError, ReadRef, ResolvedVc, TransientInstance, Vc, trace::{TraceRawVcs, TraceRawVcsContext}, }; use turbo_tasks_fs::{FileSystem, FileSystemPath}; diff --git a/turbopack/crates/turbopack-ecmascript/src/lib.rs b/turbopack/crates/turbopack-ecmascript/src/lib.rs index f9ab5c59ddd73..97d069266a660 100644 --- a/turbopack/crates/turbopack-ecmascript/src/lib.rs +++ b/turbopack/crates/turbopack-ecmascript/src/lib.rs @@ -78,8 +78,8 @@ use swc_core::{ use tracing::{Instrument, Level, instrument}; use turbo_rcstr::{RcStr, rcstr}; use turbo_tasks::{ - FxDashMap, FxIndexMap, IntoTraitRef, NonLocalValue, ReadRef, ResolvedVc, TaskInput, - TryJoinIterExt, Upcast, ValueToString, Vc, trace::TraceRawVcs, turbofmt, + FxDashMap, FxIndexMap, NonLocalValue, ReadRef, ResolvedVc, TaskInput, TryJoinIterExt, Upcast, + ValueToString, Vc, trace::TraceRawVcs, turbofmt, }; use turbo_tasks_fs::{FileJsonContent, FileSystemPath, glob::Glob, rope::Rope}; use turbopack_core::{ diff --git a/turbopack/crates/turbopack-node/src/evaluate.rs b/turbopack/crates/turbopack-node/src/evaluate.rs index 53e84f32c53b6..44e1c4ea74de2 100644 --- a/turbopack/crates/turbopack-node/src/evaluate.rs +++ b/turbopack/crates/turbopack-node/src/evaluate.rs @@ -10,8 +10,8 @@ use serde::{Deserialize, Serialize, de::DeserializeOwned}; use serde_json::Value as JsonValue; use turbo_rcstr::{RcStr, rcstr}; use turbo_tasks::{ - Completion, Effects, FxIndexMap, IntoTraitRef, NonLocalValue, OperationVc, PrettyPrintError, - ReadRef, ResolvedVc, TaskInput, TryJoinIterExt, Vc, duration_span, fxindexmap, get_effects, + Completion, Effects, FxIndexMap, NonLocalValue, OperationVc, PrettyPrintError, ReadRef, + ResolvedVc, TaskInput, TryJoinIterExt, Vc, duration_span, fxindexmap, get_effects, trace::TraceRawVcs, }; use turbo_tasks_env::{EnvMap, ProcessEnv}; diff --git a/turbopack/crates/turbopack-nodejs/src/ecmascript/node/update.rs b/turbopack/crates/turbopack-nodejs/src/ecmascript/node/update.rs index ffeb452ec5119..3b47a0bee1b21 100644 --- a/turbopack/crates/turbopack-nodejs/src/ecmascript/node/update.rs +++ b/turbopack/crates/turbopack-nodejs/src/ecmascript/node/update.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use anyhow::Result; use serde::Serialize; -use turbo_tasks::{FxIndexMap, FxIndexSet, IntoTraitRef, ReadRef, ResolvedVc, Vc}; +use turbo_tasks::{FxIndexMap, FxIndexSet, ReadRef, ResolvedVc, Vc}; use turbo_tasks_fs::rope::Rope; use turbopack_core::{ chunk::ModuleId, diff --git a/turbopack/crates/turbopack-wasm/src/module_asset.rs b/turbopack/crates/turbopack-wasm/src/module_asset.rs index 22cc433f23d29..03e69bca2cc9d 100644 --- a/turbopack/crates/turbopack-wasm/src/module_asset.rs +++ b/turbopack/crates/turbopack-wasm/src/module_asset.rs @@ -1,6 +1,6 @@ use anyhow::{Result, bail}; use turbo_rcstr::rcstr; -use turbo_tasks::{IntoTraitRef, ResolvedVc, Vc, fxindexmap}; +use turbo_tasks::{ResolvedVc, Vc, fxindexmap}; use turbo_tasks_fs::FileSystemPath; use turbopack_core::{ chunk::{ diff --git a/turbopack/crates/turbopack-wasm/src/raw.rs b/turbopack/crates/turbopack-wasm/src/raw.rs index 356bca0697e8f..c640174d0b5de 100644 --- a/turbopack/crates/turbopack-wasm/src/raw.rs +++ b/turbopack/crates/turbopack-wasm/src/raw.rs @@ -1,6 +1,6 @@ use anyhow::{Result, bail}; use turbo_rcstr::rcstr; -use turbo_tasks::{IntoTraitRef, ResolvedVc, Vc}; +use turbo_tasks::{ResolvedVc, Vc}; use turbopack_core::{ chunk::{AsyncModuleInfo, ChunkableModule, ChunkingContext}, context::AssetContext, diff --git a/turbopack/crates/turbopack/src/module_options/mod.rs b/turbopack/crates/turbopack/src/module_options/mod.rs index a51e466690843..4148e3906d95d 100644 --- a/turbopack/crates/turbopack/src/module_options/mod.rs +++ b/turbopack/crates/turbopack/src/module_options/mod.rs @@ -11,7 +11,7 @@ pub use module_options_context::*; pub use module_rule::*; pub use rule_condition::*; use turbo_rcstr::{RcStr, rcstr}; -use turbo_tasks::{IntoTraitRef, ResolvedVc, TryJoinIterExt, Vc}; +use turbo_tasks::{ResolvedVc, TryJoinIterExt, Vc}; use turbo_tasks_fs::{ FileSystemPath, glob::{Glob, GlobOptions}, From 80385f53402c7609443ae7e12c46e69e668ee305 Mon Sep 17 00:00:00 2001 From: nextjs-bot Date: Fri, 13 Mar 2026 17:52:41 +0000 Subject: [PATCH 12/16] v16.2.0-canary.96 --- 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-playwright/package.json | 2 +- packages/next-plugin-storybook/package.json | 2 +- packages/next-polyfill-module/package.json | 2 +- packages/next-polyfill-nomodule/package.json | 2 +- packages/next-routing/package.json | 2 +- packages/next-rspack/package.json | 2 +- packages/next-swc/package.json | 2 +- packages/next/package.json | 14 +++++++------- packages/react-refresh-utils/package.json | 2 +- packages/third-parties/package.json | 4 ++-- pnpm-lock.yaml | 16 ++++++++-------- 21 files changed, 36 insertions(+), 36 deletions(-) diff --git a/lerna.json b/lerna.json index 70ae036d4eb63..0ddd6bfe0bee4 100644 --- a/lerna.json +++ b/lerna.json @@ -15,5 +15,5 @@ "registry": "https://registry.npmjs.org/" } }, - "version": "16.2.0-canary.95" + "version": "16.2.0-canary.96" } \ No newline at end of file diff --git a/packages/create-next-app/package.json b/packages/create-next-app/package.json index bc68061b5d3fc..c4a13dba3ddb6 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.95", + "version": "16.2.0-canary.96", "keywords": [ "react", "next", diff --git a/packages/eslint-config-next/package.json b/packages/eslint-config-next/package.json index cfa98077e7d85..574d2a1fc6d71 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.95", + "version": "16.2.0-canary.96", "description": "ESLint configuration used by Next.js.", "license": "MIT", "repository": { @@ -12,7 +12,7 @@ "dist" ], "dependencies": { - "@next/eslint-plugin-next": "16.2.0-canary.95", + "@next/eslint-plugin-next": "16.2.0-canary.96", "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 4508c2a2b9809..aa79d48e4a58a 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.95", + "version": "16.2.0-canary.96", "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 e2a4e021fced1..e88f37614b4ff 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.95", + "version": "16.2.0-canary.96", "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 59acbb60e1986..5c71376d178a9 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.95", + "version": "16.2.0-canary.96", "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 2d935c550ca4c..877f74d6127ff 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.95", + "version": "16.2.0-canary.96", "main": "index.js", "types": "index.d.ts", "license": "MIT", diff --git a/packages/next-codemod/package.json b/packages/next-codemod/package.json index 7867fdfa08684..5a65beb370d28 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.95", + "version": "16.2.0-canary.96", "license": "MIT", "repository": { "type": "git", diff --git a/packages/next-env/package.json b/packages/next-env/package.json index 3b0ba074b7d00..ebb5b6cc5c234 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.95", + "version": "16.2.0-canary.96", "keywords": [ "react", "next", diff --git a/packages/next-mdx/package.json b/packages/next-mdx/package.json index 07c98adf7743c..578460eecab1a 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.95", + "version": "16.2.0-canary.96", "main": "index.js", "license": "MIT", "repository": { diff --git a/packages/next-playwright/package.json b/packages/next-playwright/package.json index c2ee86bb0ca1e..3bf66519b9fd2 100644 --- a/packages/next-playwright/package.json +++ b/packages/next-playwright/package.json @@ -1,6 +1,6 @@ { "name": "@next/playwright", - "version": "16.2.0-canary.95", + "version": "16.2.0-canary.96", "repository": { "url": "vercel/next.js", "directory": "packages/next-playwright" diff --git a/packages/next-plugin-storybook/package.json b/packages/next-plugin-storybook/package.json index e32f24487aea0..fc2226f90bf32 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.95", + "version": "16.2.0-canary.96", "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 dbdd0922aee3a..a31b5a176bd2d 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.95", + "version": "16.2.0-canary.96", "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 f18df644f2084..3e0a46f467a3b 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.95", + "version": "16.2.0-canary.96", "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 bfaa08b46def7..024c21f749ac5 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.95", + "version": "16.2.0-canary.96", "keywords": [ "react", "next", diff --git a/packages/next-rspack/package.json b/packages/next-rspack/package.json index 126c4e645bb15..da4dd7185849b 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.95", + "version": "16.2.0-canary.96", "repository": { "url": "vercel/next.js", "directory": "packages/next-rspack" diff --git a/packages/next-swc/package.json b/packages/next-swc/package.json index 93071e5360a40..a8ab05af3b578 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.95", + "version": "16.2.0-canary.96", "private": true, "files": [ "native/" diff --git a/packages/next/package.json b/packages/next/package.json index 4885970eb6c7f..163f1664cf5af 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "next", - "version": "16.2.0-canary.95", + "version": "16.2.0-canary.96", "description": "The React Framework", "main": "./dist/server/next.js", "license": "MIT", @@ -97,7 +97,7 @@ ] }, "dependencies": { - "@next/env": "16.2.0-canary.95", + "@next/env": "16.2.0-canary.96", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -161,11 +161,11 @@ "@modelcontextprotocol/sdk": "1.18.1", "@mswjs/interceptors": "0.23.0", "@napi-rs/triples": "1.2.0", - "@next/font": "16.2.0-canary.95", - "@next/polyfill-module": "16.2.0-canary.95", - "@next/polyfill-nomodule": "16.2.0-canary.95", - "@next/react-refresh-utils": "16.2.0-canary.95", - "@next/swc": "16.2.0-canary.95", + "@next/font": "16.2.0-canary.96", + "@next/polyfill-module": "16.2.0-canary.96", + "@next/polyfill-nomodule": "16.2.0-canary.96", + "@next/react-refresh-utils": "16.2.0-canary.96", + "@next/swc": "16.2.0-canary.96", "@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 4d0407e33acfa..428f7cf5bc0a3 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.95", + "version": "16.2.0-canary.96", "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 f4afb1124ff52..d74ee5ff41c6e 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.95", + "version": "16.2.0-canary.96", "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.95", + "next": "16.2.0-canary.96", "outdent": "0.8.0", "prettier": "2.5.1", "typescript": "5.9.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 527411cb04b23..a2ebf7fce621f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1017,7 +1017,7 @@ importers: packages/eslint-config-next: dependencies: '@next/eslint-plugin-next': - specifier: 16.2.0-canary.95 + specifier: 16.2.0-canary.96 version: link:../eslint-plugin-next eslint: specifier: '>=9.0.0' @@ -1094,7 +1094,7 @@ importers: packages/next: dependencies: '@next/env': - specifier: 16.2.0-canary.95 + specifier: 16.2.0-canary.96 version: link:../next-env '@swc/helpers': specifier: 0.5.15 @@ -1219,19 +1219,19 @@ importers: specifier: 1.2.0 version: 1.2.0 '@next/font': - specifier: 16.2.0-canary.95 + specifier: 16.2.0-canary.96 version: link:../font '@next/polyfill-module': - specifier: 16.2.0-canary.95 + specifier: 16.2.0-canary.96 version: link:../next-polyfill-module '@next/polyfill-nomodule': - specifier: 16.2.0-canary.95 + specifier: 16.2.0-canary.96 version: link:../next-polyfill-nomodule '@next/react-refresh-utils': - specifier: 16.2.0-canary.95 + specifier: 16.2.0-canary.96 version: link:../react-refresh-utils '@next/swc': - specifier: 16.2.0-canary.95 + specifier: 16.2.0-canary.96 version: link:../next-swc '@opentelemetry/api': specifier: 1.6.0 @@ -1956,7 +1956,7 @@ importers: version: 1.0.20 devDependencies: next: - specifier: 16.2.0-canary.95 + specifier: 16.2.0-canary.96 version: link:../next outdent: specifier: 0.8.0 From afe3993eb998d02c9c4f094c1a9769b14b2ad5a0 Mon Sep 17 00:00:00 2001 From: Zack Tanner <1939140+ztanner@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:19:43 -0700 Subject: [PATCH 13/16] re-enable RDC deployment tests (#91327) Flag has been re-enabled and is attached to the test team. --- test/deploy-tests-manifest.json | 7 ------- 1 file changed, 7 deletions(-) diff --git a/test/deploy-tests-manifest.json b/test/deploy-tests-manifest.json index cf9457673addf..2b83e66d4be8b 100644 --- a/test/deploy-tests-manifest.json +++ b/test/deploy-tests-manifest.json @@ -86,13 +86,6 @@ }, "test/e2e/middleware-rewrites/test/index.test.ts": { "failed": ["Middleware Rewrite should handle catch-all rewrite correctly"] - }, - "test/e2e/app-dir/resume-data-cache/resume-data-cache.test.ts": { - "failed": [ - "resume-data-cache should have consistent data between static and dynamic renders with fetch cache", - "resume-data-cache should have consistent data between static and dynamic renders with use cache", - "resume-data-cache should use RDC for server action re-renders" - ] } }, "rules": { From 07189c522401b19ae8942d2db707ccea299f1717 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Fri, 13 Mar 2026 11:22:47 -0700 Subject: [PATCH 14/16] Expose edge runtime fields in build-complete (#91239) ### What? - Add canonical edge entrypoint metadata to edge function definitions in the middleware manifest. - Surface that canonical entrypoint through `build-complete` as `edgeRuntime` metadata in adapter outputs. - Update middleware and adapter tests to assert the new metadata instead of relying on bundler-specific emitted file names. ### Why? Adapter consumers need a stable way to load and invoke edge outputs without depending on `next-server`'s sandbox handling or guessing which emitted file is the executable entrypoint. The existing checks were also brittle across webpack and Turbopack because the emitted file lists can differ. ### How? - Add an optional `entrypoint` field to `EdgeFunctionDefinition` and populate it in both the webpack middleware manifest generator and the Turbopack manifest emitters for app, pages, and middleware edge functions. - In `packages/next/src/build/adapter/build-complete.ts`, use that canonical entrypoint for `filePath` and expose `edgeRuntime.modulePath`, `edgeRuntime.entryKey`, and `edgeRuntime.handlerExport` for edge outputs. - Adjust the middleware manifest tests to validate cross-bundler invariants and add adapter-config coverage for the new `edgeRuntime` fields. --- crates/next-api/src/app.rs | 8 ++++ crates/next-api/src/middleware.rs | 15 ++++-- crates/next-api/src/pages.rs | 8 ++++ crates/next-core/src/next_manifests/mod.rs | 1 + .../01-next-config-js/adapterPath.mdx | 47 +++++++++++++++++++ packages/next/errors.json | 3 +- .../next/src/build/adapter/build-complete.ts | 44 ++++++++++++----- .../webpack/plugins/middleware-plugin.ts | 30 ++++++++++++ .../e2e/middleware-general/test/index.test.ts | 25 +++++----- .../test/index.test.ts | 24 +++++----- .../adapter-config/adapter-config.test.ts | 18 +++++++ 11 files changed, 179 insertions(+), 44 deletions(-) diff --git a/crates/next-api/src/app.rs b/crates/next-api/src/app.rs index d7f9ba358cc3d..b4d525eb852e4 100644 --- a/crates/next-api/src/app.rs +++ b/crates/next-api/src/app.rs @@ -1632,12 +1632,20 @@ impl AppEndpoint { original_source: app_entry.pathname.clone(), ..Default::default() }; + let entrypoint_chunk = *app_entry_chunks_ref + .last() + .context("expected app entry chunks for edge app endpoint")?; + let entrypoint = node_root_value + .get_path_to(&*entrypoint_chunk.path().await?) + .context("expected app entry chunk to be within node root")? + .into(); let edge_function_definition = EdgeFunctionDefinition { files: file_paths_from_root.into_iter().collect(), wasm: wasm_paths_to_bindings(wasm_paths_from_root).await?, assets: paths_to_bindings(all_assets), name: app_function_name(&app_entry.original_name).into(), page: app_entry.original_name.clone(), + entrypoint, regions: app_entry .config .await? diff --git a/crates/next-api/src/middleware.rs b/crates/next-api/src/middleware.rs index d94bb779674a2..862c5a4b4a3f1 100644 --- a/crates/next-api/src/middleware.rs +++ b/crates/next-api/src/middleware.rs @@ -1,6 +1,6 @@ use std::future::IntoFuture; -use anyhow::Result; +use anyhow::{Context, Result}; use next_core::{ middleware::get_middleware_module, next_edge::entry::wrap_edge_entry, @@ -254,10 +254,18 @@ impl MiddlewareEndpoint { let node_root = this.project.node_root().owned().await?; let node_root_value = node_root.clone(); + let edge_chunk_group_ref = edge_chunk_group.await?; + let edge_assets = edge_chunk_group_ref.assets.await?; let file_paths_from_root = - get_js_paths_from_root(&node_root_value, &edge_chunk_group.await?.assets.await?) - .await?; + get_js_paths_from_root(&node_root_value, &edge_assets).await?; + let entrypoint_asset = *edge_assets + .last() + .context("expected assets for edge middleware endpoint")?; + let entrypoint = node_root_value + .get_path_to(&*entrypoint_asset.path().await?) + .context("expected edge middleware asset to be within node root")? + .into(); let mut output_assets = edge_chunk_group.all_assets().owned().await?; @@ -285,6 +293,7 @@ impl MiddlewareEndpoint { assets: paths_to_bindings(all_assets), name: rcstr!("middleware"), page: rcstr!("/"), + entrypoint, regions, matchers: matchers.clone(), env: this.project.edge_env().owned().await?, diff --git a/crates/next-api/src/pages.rs b/crates/next-api/src/pages.rs index b3d5204dba4f5..faa596e05f232 100644 --- a/crates/next-api/src/pages.rs +++ b/crates/next-api/src/pages.rs @@ -1511,6 +1511,13 @@ impl PageEndpoint { } else { None }; + let entrypoint_asset = *assets_ref + .last() + .context("expected assets for edge pages endpoint")?; + let entrypoint = node_root + .get_path_to(&*entrypoint_asset.path().await?) + .context("expected edge pages asset to be within node root")? + .into(); let edge_function_definition = EdgeFunctionDefinition { files: file_paths_from_root.into_iter().collect(), @@ -1518,6 +1525,7 @@ impl PageEndpoint { assets: paths_to_bindings(all_assets), name: pages_function_name(&this.original_name).into(), page: this.original_name.clone(), + entrypoint, regions, matchers: vec![matchers], env: this.pages_project.project().edge_env().owned().await?, diff --git a/crates/next-core/src/next_manifests/mod.rs b/crates/next-core/src/next_manifests/mod.rs index 4c5381083fb07..58c57eb5193cd 100644 --- a/crates/next-core/src/next_manifests/mod.rs +++ b/crates/next-core/src/next_manifests/mod.rs @@ -285,6 +285,7 @@ pub struct EdgeFunctionDefinition { pub files: Vec, pub name: RcStr, pub page: RcStr, + pub entrypoint: RcStr, pub matchers: Vec, pub wasm: Vec, pub assets: Vec, diff --git a/docs/01-app/03-api-reference/05-config/01-next-config-js/adapterPath.mdx b/docs/01-app/03-api-reference/05-config/01-next-config-js/adapterPath.mdx index dee5511d1d402..2d111ed6c6e70 100644 --- a/docs/01-app/03-api-reference/05-config/01-next-config-js/adapterPath.mdx +++ b/docs/01-app/03-api-reference/05-config/01-next-config-js/adapterPath.mdx @@ -635,6 +635,26 @@ handler( The shape is aligned around `handler(..., ctx)`, but Node.js and Edge runtimes use different request/response primitives. +For outputs with `runtime: 'edge'`, Next.js also provides `output.edgeRuntime` with the canonical metadata needed to invoke the entrypoint: + +```typescript +{ + modulePath: string // Absolute path to the module registered in the edge runtime + entryKey: string // Canonical key used by the edge entry registry + handlerExport: string // Export name to invoke, currently 'handler' +} +``` + +After your edge runtime loads and evaluates the chunks for `modulePath`, use `entryKey` to read the registered entry from the global edge entry registry (`globalThis._ENTRIES`), then invoke `handlerExport` from that entry: + +```ts +const entry = await globalThis._ENTRIES[output.edgeRuntime.entryKey] +const handler = entry[output.edgeRuntime.handlerExport] +await handler(request, ctx) +``` + +Use `edgeRuntime` instead of deriving registry keys or handler names from filenames. + Relevant files in the Next.js core: - [`packages/next/src/build/templates/edge-ssr.ts`](https://github.com/vercel/next.js/blob/canary/packages/next/src/build/templates/edge-ssr.ts) @@ -656,6 +676,8 @@ The `outputs` object contains arrays of build output types: > **Note:** When `config.output` is set to `'export'`, only `outputs.staticFiles` is populated. All other arrays (`pages`, `appPages`, `pagesApi`, `appRoutes`, `prerenders`) will be empty since the entire application is exported as static files. +For any route output with `runtime: 'edge'`, `edgeRuntime` is included and contains the canonical entry metadata for invoking that output in your edge runtime. + ### Pages (`outputs.pages`) React pages from the `pages/` directory: @@ -670,6 +692,11 @@ React pages from the `pages/` directory: runtime: 'nodejs' | 'edge' assets: Record // Traced dependencies (key: relative path from repo root, value: absolute path) wasmAssets?: Record // Bundled wasm files (key: name, value: absolute path) + edgeRuntime?: { + modulePath: string // Absolute path to the module registered in the edge runtime + entryKey: string // Canonical key used by the edge entry registry + handlerExport: string // Export name to invoke, currently 'handler' + } config: { maxDuration?: number // Maximum duration of the route in seconds preferredRegion?: string | string[] // Preferred deployment region @@ -692,6 +719,11 @@ API routes from `pages/api/`: runtime: 'nodejs' | 'edge' assets: Record // Traced dependencies (key: relative path from repo root, value: absolute path) wasmAssets?: Record // Bundled wasm files (key: name, value: absolute path) + edgeRuntime?: { + modulePath: string // Absolute path to the module registered in the edge runtime + entryKey: string // Canonical key used by the edge entry registry + handlerExport: string // Export name to invoke, currently 'handler' + } config: { maxDuration?: number // Maximum duration of the route in seconds preferredRegion?: string | string[] // Preferred deployment region @@ -714,6 +746,11 @@ React pages from the `app/` directory: runtime: 'nodejs' | 'edge' // Runtime the route is built for assets: Record // Traced dependencies (key: relative path from repo root, value: absolute path) wasmAssets?: Record // Bundled wasm files (key: name, value: absolute path) + edgeRuntime?: { + modulePath: string // Absolute path to the module registered in the edge runtime + entryKey: string // Canonical key used by the edge entry registry + handlerExport: string // Export name to invoke, currently 'handler' + } config: { maxDuration?: number // Maximum duration of the route in seconds preferredRegion?: string | string[] // Preferred deployment region @@ -736,6 +773,11 @@ API and metadata routes from the `app/` directory: runtime: 'nodejs' | 'edge' // Runtime the route is built for assets: Record // Traced dependencies (key: relative path from repo root, value: absolute path) wasmAssets?: Record // Bundled wasm files (key: name, value: absolute path) + edgeRuntime?: { + modulePath: string // Absolute path to the module registered in the edge runtime + entryKey: string // Canonical key used by the edge entry registry + handlerExport: string // Export name to invoke, currently 'handler' + } config: { maxDuration?: number // Maximum duration of the route in seconds preferredRegion?: string | string[] // Preferred deployment region @@ -806,6 +848,11 @@ Static assets and auto-statically optimized pages: runtime: 'nodejs' | 'edge' // Runtime the route is built for assets: Record // Traced dependencies (key: relative path from repo root, value: absolute path) wasmAssets?: Record // Bundled wasm files (key: name, value: absolute path) + edgeRuntime?: { + modulePath: string // Absolute path to the module registered in the edge runtime + entryKey: string // Canonical key used by the edge entry registry + handlerExport: string // Export name to invoke, currently 'handler' + } config: { maxDuration?: number // Maximum duration of the route in seconds preferredRegion?: string | string[] // Preferred deployment region diff --git a/packages/next/errors.json b/packages/next/errors.json index 95824d1877323..cfd6423250de2 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -1116,5 +1116,6 @@ "1115": "Route \"%s\" accessed cookie \"%s\" which is not defined in the \\`samples\\` of \\`unstable_instant\\`. Add it to the sample's \\`cookies\\` array, or \\`{ name: \"%s\", value: null }\\` if it should be absent.", "1116": "Route \"%s\" accessed header \"%s\" which is not defined in the \\`samples\\` of \\`unstable_instant\\`. Add it to the sample's \\`headers\\` array, or \\`[\"%s\", null]\\` if it should be absent.", "1117": "Instant validation boundaries should never appear in browser bundles.", - "1118": "An error occurred while attempting to validate instant UI. This error may be preventing the validation from completing." + "1118": "An error occurred while attempting to validate instant UI. This error may be preventing the validation from completing.", + "1119": "Expected edge function entrypoint to emit a JavaScript file" } diff --git a/packages/next/src/build/adapter/build-complete.ts b/packages/next/src/build/adapter/build-complete.ts index 55422a434f25d..963e76055e4d6 100644 --- a/packages/next/src/build/adapter/build-complete.ts +++ b/packages/next/src/build/adapter/build-complete.ts @@ -89,6 +89,26 @@ interface SharedRouteFields { */ wasmAssets?: Record + /** + * edgeRuntime contains canonical entry metadata for invoking + * this output in an edge runtime. + */ + edgeRuntime?: { + /** + * modulePath is the canonical module path that registers this + * output in the edge runtime. + */ + modulePath: string + /** + * entryKey is the canonical key used for the global edge entry registry. + */ + entryKey: string + /** + * handlerExport is the export name to invoke on the edge entry. + */ + handlerExport: string + } + /** * config related to the route */ @@ -709,6 +729,11 @@ export async function handleBuildComplete({ : route === '/index' ? '/' : route + const edgeEntrypointRelativePath = page.entrypoint + const edgeEntrypointPath = path.join( + distDir, + edgeEntrypointRelativePath + ) const output: Omit & { type: any @@ -718,19 +743,12 @@ export async function handleBuildComplete({ runtime: 'edge', sourcePage: route, pathname, - filePath: path.join( - distDir, - page.files.find( - (item) => - item.startsWith('server/app') || item.startsWith('server/pages') - ) || - // TODO: turbopack build doesn't name the main entry chunk - // identifiably so we don't know which to mark here but - // technically edge needs all chunks to load always so - // should this field even be provided? - page.files[0] || - '' - ), + filePath: edgeEntrypointPath, + edgeRuntime: { + modulePath: edgeEntrypointPath, + entryKey: `middleware_${page.name}`, + handlerExport: 'handler', + }, assets: {}, wasmAssets: {}, config: { diff --git a/packages/next/src/build/webpack/plugins/middleware-plugin.ts b/packages/next/src/build/webpack/plugins/middleware-plugin.ts index 63577fb37bcc1..9e1e8c6ccbb58 100644 --- a/packages/next/src/build/webpack/plugins/middleware-plugin.ts +++ b/packages/next/src/build/webpack/plugins/middleware-plugin.ts @@ -45,6 +45,10 @@ export interface EdgeFunctionDefinition { files: string[] name: string page: string + /** + * Canonical entrypoint module path (relative to distDir) for this edge function. + */ + entrypoint: string matchers: ProxyMatcher[] env: Record wasm?: AssetBinding[] @@ -153,6 +157,31 @@ function getEntryFiles( return files } +function getEntrypointFile(entrypoint: { + getEntrypointChunk(): { files: Iterable } + getFiles(): Iterable +}): string { + const getJsFile = (files: Iterable): string | undefined => { + for (const file of files) { + if (!file.endsWith('.hot-update.js') && /\.(?:js|mjs|cjs)$/i.test(file)) { + return `server/${file}` + } + } + } + + const file = + getJsFile(entrypoint.getEntrypointChunk().files) || + getJsFile(entrypoint.getFiles()) + + if (!file) { + throw new Error( + 'Expected edge function entrypoint to emit a JavaScript file' + ) + } + + return file +} + function getCreateAssets(params: { compilation: webpack.Compilation metadataByEntry: Map @@ -224,6 +253,7 @@ function getCreateAssets(params: { hasInstrumentationHook, opts ), + entrypoint: getEntrypointFile(entrypoint), name: entrypoint.name, page: page, matchers, diff --git a/test/e2e/middleware-general/test/index.test.ts b/test/e2e/middleware-general/test/index.test.ts index b6c40d54bf70b..d96b84854a378 100644 --- a/test/e2e/middleware-general/test/index.test.ts +++ b/test/e2e/middleware-general/test/index.test.ts @@ -218,21 +218,21 @@ describe('Middleware Runtime', () => { ...middlewareWithoutEnvs.env, } delete middlewareWithoutEnvs.env - expect(middlewareWithoutEnvs).toEqual({ - // Turbopack creates more files as it can do chunking. - files: process.env.IS_TURBOPACK_TEST - ? expect.toBeArray() - : expect.arrayContaining([ - 'server/edge-runtime-webpack.js', - 'server/middleware.js', - ]), + expect(middlewareWithoutEnvs).toMatchObject({ name: 'middleware', page: '/', matchers: [{ regexp: '^/.*$', originalSource: '/:path*' }], wasm: [], - assets: process.env.IS_TURBOPACK_TEST ? expect.toBeArray() : [], + assets: [], regions: 'auto', }) + expect(middlewareWithoutEnvs.files).toBeArray() + expect(middlewareWithoutEnvs.entrypoint).toMatch( + /^server\/.+\.(?:js|mjs|cjs)$/ + ) + expect(middlewareWithoutEnvs.files).toContain( + middlewareWithoutEnvs.entrypoint + ) expect(envs).toContainAllKeys([ 'NEXT_SERVER_ACTIONS_ENCRYPTION_KEY', '__NEXT_BUILD_ID', @@ -259,11 +259,8 @@ describe('Middleware Runtime', () => { ) for (const key of Object.keys(manifest.middleware)) { const middleware = manifest.middleware[key] - if (!process.env.IS_TURBOPACK_TEST) { - expect(middleware.files).toContainEqual( - expect.stringContaining('server/edge-runtime-webpack') - ) - } + expect(middleware.entrypoint).toMatch(/^server\/.+\.(?:js|mjs|cjs)$/) + expect(middleware.files).toContain(middleware.entrypoint) expect(middleware.files).not.toContainEqual( expect.stringContaining('static/chunks/') diff --git a/test/e2e/middleware-trailing-slash/test/index.test.ts b/test/e2e/middleware-trailing-slash/test/index.test.ts index ce643d18ab23e..5b535f3acb4d4 100644 --- a/test/e2e/middleware-trailing-slash/test/index.test.ts +++ b/test/e2e/middleware-trailing-slash/test/index.test.ts @@ -113,19 +113,20 @@ describe('Middleware Runtime trailing slash', () => { ...manifest.middleware['/'], } delete middlewareWithoutEnvs.env - expect(middlewareWithoutEnvs).toEqual({ - files: process.env.IS_TURBOPACK_TEST - ? expect.toBeArray() - : expect.arrayContaining([ - 'server/edge-runtime-webpack.js', - 'server/middleware.js', - ]), + expect(middlewareWithoutEnvs).toMatchObject({ name: 'middleware', page: '/', matchers: [{ regexp: '^/.*$', originalSource: '/:path*' }], wasm: [], - assets: process.env.IS_TURBOPACK_TEST ? expect.toBeArray() : [], + assets: [], }) + expect(middlewareWithoutEnvs.files).toBeArray() + expect(middlewareWithoutEnvs.entrypoint).toMatch( + /^server\/.+\.(?:js|mjs|cjs)$/ + ) + expect(middlewareWithoutEnvs.files).toContain( + middlewareWithoutEnvs.entrypoint + ) }) it('should have correct files in manifest', async () => { @@ -134,11 +135,8 @@ describe('Middleware Runtime trailing slash', () => { ) for (const key of Object.keys(manifest.middleware)) { const middleware = manifest.middleware[key] - if (!process.env.IS_TURBOPACK_TEST) { - expect(middleware.files).toContainEqual( - expect.stringContaining('server/edge-runtime-webpack') - ) - } + expect(middleware.entrypoint).toMatch(/^server\/.+\.(?:js|mjs|cjs)$/) + expect(middleware.files).toContain(middleware.entrypoint) expect(middleware.files).not.toContainEqual( expect.stringContaining('static/chunks/') ) diff --git a/test/production/adapter-config/adapter-config.test.ts b/test/production/adapter-config/adapter-config.test.ts index 27feaa5c3d4ad..599926993c730 100644 --- a/test/production/adapter-config/adapter-config.test.ts +++ b/test/production/adapter-config/adapter-config.test.ts @@ -340,6 +340,24 @@ describe('adapter-config', () => { __NEXT_PREVIEW_MODE_SIGNING_KEY: expect.toBeString(), }) ) + const edgeRuntime = ( + route as PageRoutesType & { + edgeRuntime?: { + modulePath: string + entryKey: string + handlerExport: string + } + } + ).edgeRuntime + expect(edgeRuntime).toEqual( + expect.objectContaining({ + modulePath: expect.toBeString(), + entryKey: expect.toBeString(), + handlerExport: 'handler', + }) + ) + expect(edgeRuntime?.entryKey.startsWith('middleware_')).toBe(true) + expect(edgeRuntime?.modulePath).toBe(route.filePath) const stats = await fs.promises.stat(route.filePath) expect(stats.isFile()).toBe(true) From 00067f40bdcf642ce69b8110819bf114d256c228 Mon Sep 17 00:00:00 2001 From: Tobias Koppers Date: Fri, 13 Mar 2026 19:51:24 +0100 Subject: [PATCH 15/16] Use keyed cells for used_exports and export_circuit_breakers in BindingUsageInfo (#91306) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What? Apply the `cell = "keyed"` pattern to `used_exports` (`FxHashMap`) and `export_circuit_breakers` (`FxHashSet`) in `BindingUsageInfo`, matching the existing pattern already used for `unused_references`. ## Why? Previously, `used_exports` and `export_circuit_breakers` were stored as plain inline collections within `BindingUsageInfo`. Any change to any module's export usage would invalidate **all** callers of `used_exports()`, even those querying unrelated modules. This causes unnecessary recomputation during incremental rebuilds. With keyed cells, lookups like `self.used_exports.get(&module)` and `self.export_circuit_breakers.contains_key(&module)` only invalidate callers that queried the specific module whose export usage changed, providing per-module invalidation granularity. ## How? 1. **New keyed transparent value types** — `UsedExportsMap` and `ExportCircuitBreakers` wrappers with `#[turbo_tasks::value(transparent, cell = "keyed")]`. 2. **Fields changed to `ResolvedVc`** — `used_exports` and `export_circuit_breakers` fields are now `ResolvedVc` and `ResolvedVc` instead of inline `FxHashMap`/`FxHashSet`. 3. **`used_exports()` becomes a `#[turbo_tasks::function]`** — Moved into a `#[turbo_tasks::value_impl]` block so it's a tracked task function. Callers (`BrowserChunkingContext`, `NodeJsChunkingContext`) no longer need `.await?` — they call it directly and get a `Vc`. 4. **Per-key lookups** — `contains_key(&module).await?` and `get(&module).await?` leverage the keyed cell pattern for fine-grained invalidation. ### Files changed - `turbopack/crates/turbopack-core/src/module_graph/binding_usage_info.rs` — Core changes: new keyed types, field type changes, `used_exports()` as tracked function - `turbopack/crates/turbopack-browser/src/chunking_context.rs` — Simplified call site (no `.await?`) - `turbopack/crates/turbopack-nodejs/src/chunking_context.rs` — Simplified call site (no `.await?`) --------- Co-authored-by: Tobias Koppers Co-authored-by: Claude --- .../src/module_graph/binding_usage_info.rs | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/turbopack/crates/turbopack-core/src/module_graph/binding_usage_info.rs b/turbopack/crates/turbopack-core/src/module_graph/binding_usage_info.rs index 2ae5202a284c9..a6892cafca75b 100644 --- a/turbopack/crates/turbopack-core/src/module_graph/binding_usage_info.rs +++ b/turbopack/crates/turbopack-core/src/module_graph/binding_usage_info.rs @@ -17,6 +17,12 @@ use crate::{ resolve::{ExportUsage, ImportUsage}, }; +#[turbo_tasks::value(transparent, cell = "keyed")] +pub struct UsedExportsMap(FxHashMap>, ModuleExportUsageInfo>); + +#[turbo_tasks::value(transparent, cell = "keyed")] +pub struct ExportCircuitBreakers(FxHashSet>>); + #[turbo_tasks::value] #[derive(Clone, Default, Debug)] pub struct BindingUsageInfo { @@ -24,8 +30,8 @@ pub struct BindingUsageInfo { #[turbo_tasks(trace_ignore)] unused_references_edges: FxHashSet, - used_exports: FxHashMap>, ModuleExportUsageInfo>, - export_circuit_breakers: FxHashSet>>, + used_exports: ResolvedVc, + export_circuit_breakers: ResolvedVc, } #[turbo_tasks::value(transparent)] @@ -58,8 +64,8 @@ impl BindingUsageInfo { &self, module: ResolvedVc>, ) -> Result> { - let is_circuit_breaker = self.export_circuit_breakers.contains(&module); - let Some(exports) = self.used_exports.get(&module) else { + let is_circuit_breaker = self.export_circuit_breakers.contains_key(&module).await?; + let Some(exports) = self.used_exports.get(&module).await? else { // There are some module that are codegened, but not referenced in the module graph, let ident = module.ident_string().await?; if ident.contains(".wasm_.loader.mjs") || ident.contains("/__nextjs-internal-proxy.") { @@ -73,7 +79,7 @@ impl BindingUsageInfo { bail!("export usage not found for module: {ident:?}"); }; Ok(ModuleExportUsage { - export_usage: exports.clone().resolved_cell(), + export_usage: (*exports).clone().resolved_cell(), is_circuit_breaker, } .cell()) @@ -291,8 +297,8 @@ pub async fn compute_binding_usage_info( Ok(BindingUsageInfo { unused_references: ResolvedVc::cell(unused_references), unused_references_edges, - used_exports, - export_circuit_breakers, + used_exports: ResolvedVc::cell(used_exports), + export_circuit_breakers: ResolvedVc::cell(export_circuit_breakers), } .cell()) } From 236a76dd0fb61196dfb4da1568c0eb42c5d17283 Mon Sep 17 00:00:00 2001 From: Luke Sandberg Date: Fri, 13 Mar 2026 11:54:56 -0700 Subject: [PATCH 16/16] [turbopack] Remove `turbo_tasks::function` from ModuleReference getters (#91229) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### What? Refactors the `ModuleReference` trait to make `chunking_type()` and `binding_usage()` methods return direct values instead of `Vc` wrapped values, removing the need for async task functions. Also removes the `get_referenced_asset` task from `EsmAssetReference`, inlining its logic into the callers. ### Why? This change simplifies the API by eliminating unnecessary async overhead for methods that typically return simple, computed values. The previous implementation required `#[turbo_tasks::function]` annotations and `Vc` wrappers even when the methods didn't need to perform async operations or benefit from caching. ### Impact | Metric | Base | Change | Delta | |--------|------|--------|-------| | Hits | 35,678,143 | 35,845,124 | **+166,981** | | Misses | 9,418,378 | 7,910,986 | **-1,507,392** | | Total | 45,096,521 | 43,756,110 | **-1,340,411** | | Task types | 1,306 | 1,277 | **-29** | 29 task types were removed, eliminating **2.6M total task invocations** (1.1M hits + 1.5M misses): - **`chunking_type`** — 21 task types removed across all `ModuleReference` implementors (~952k invocations) - **`binding_usage`** — 6 task types removed (~527k invocations) - **`BindingUsage::all`** — helper task removed (~36k invocations) - **`EsmAssetReference::get_referenced_asset`** — removed and inlined (~1.08M invocations: 628k hits + 451k misses) The removed `get_referenced_asset` hits reappear as +628k hits on `EsmAssetReference::resolve_reference` and `ReferencedAsset::from_resolve_result` (with zero increase in misses), confirming the work is now served from cache through the existing callers. No tasks had increased misses — the removal is clean with no cache invalidation spillover. I also ran some builds to measure latency ``` # This branch $ hyperfine -p 'rm -rf .next' -w 2 -r 10 'pnpm next build --turbopack --experimental-build-mode=compile' Benchmark 1: pnpm next build --turbopack --experimental-build-mode=compile Time (mean ± σ): 52.752 s ± 0.658 s [User: 376.575 s, System: 106.375 s] Range (min … max): 51.913 s … 54.161 s 10 runs # on canary $ hyperfine -p 'rm -rf .next' -w 2 -r 10 'pnpm next build --turbopack --experimental-build-mode=compile' Benchmark 1: pnpm next build --turbopack --experimental-build-mode=compile Time (mean ± σ): 54.675 s ± 1.394 s [User: 389.273 s, System: 114.642 s] Range (min … max): 53.434 s … 58.189 s 10 runs ``` so a solid win of almost 2 seconds MaxRSS also went from 16,474,324,992 bytes to 16,359,309,312 bytes (from one measurement) so a savings of ~100M of max heap size. ### How? - Changed `chunking_type()` method signature from `Vc` to `Option` - Changed `binding_usage()` method signature from `Vc` to `BindingUsage` - Removed `ChunkingTypeOption` type alias as it's no longer needed - Updated all implementations across the codebase to return direct values instead of wrapped ones - Removed `#[turbo_tasks::function]` annotations from these methods - Updated call sites to use `into_trait_ref().await?` pattern when accessing these methods from `Vc` - Removed `EsmAssetReference::get_referenced_asset`, inlining its logic into callers - Added validation for `turbopack-chunking-type` annotation values in import analysis - Fixed cache effectiveness analysis script --- crates/next-api/src/nft_json.rs | 4 +- crates/next-core/src/hmr_entry.rs | 9 +- .../css_client_reference_module.rs | 9 +- .../ecmascript_client_reference_module.rs | 9 +- .../server_component_reference.rs | 12 +- .../server_utility_reference.rs | 12 +- .../next/src/build/swc/generated-native.d.ts | 12 +- .../src/backend/operation/mod.rs | 131 ++++++++------- .../crates/turbopack-core/src/chunk/mod.rs | 3 - .../turbopack-core/src/introspect/utils.rs | 3 +- .../turbopack-core/src/reference/mod.rs | 51 +++--- .../turbopack-css/src/references/compose.rs | 9 +- .../turbopack-css/src/references/import.rs | 9 +- .../turbopack-css/src/references/internal.rs | 12 +- .../turbopack-css/src/references/url.rs | 9 +- .../src/analyzer/imports.rs | 48 +++++- .../src/references/amd.rs | 9 +- .../src/references/async_module.rs | 3 +- .../src/references/cjs.rs | 23 ++- .../src/references/esm/base.rs | 41 ++--- .../src/references/esm/dynamic.rs | 11 +- .../src/references/esm/module_id.rs | 15 +- .../src/references/esm/url.rs | 9 +- .../src/references/hot_module.rs | 9 +- .../src/references/mod.rs | 9 +- .../src/references/raw.rs | 12 +- .../src/references/require_context.rs | 18 +- .../src/references/worker.rs | 9 +- .../src/side_effect_optimization/reference.rs | 49 +++--- .../src/worker_chunk/module.rs | 9 +- .../scripts/analyze_cache_effectiveness.py | 154 ++++++------------ 31 files changed, 335 insertions(+), 387 deletions(-) diff --git a/crates/next-api/src/nft_json.rs b/crates/next-api/src/nft_json.rs index 5675b359e3588..c97ba9900c69e 100644 --- a/crates/next-api/src/nft_json.rs +++ b/crates/next-api/src/nft_json.rs @@ -7,7 +7,7 @@ use turbo_rcstr::{RcStr, rcstr}; use turbo_tasks::{ FxIndexMap, ReadRef, ResolvedVc, TryFlatJoinIterExt, TryJoinIterExt, ValueToString, Vc, graph::{AdjacencyMap, GraphTraversal, Visit}, - turbobail, turbofmt, + turbofmt, }; use turbo_tasks_fs::{ DirectoryEntry, File, FileContent, FileSystem, FileSystemPath, @@ -287,7 +287,7 @@ impl Asset for NftJsonAsset { &*current_path.get_type().await?, FileSystemEntryType::Symlink ) { - turbobail!( + turbo_tasks::turbobail!( "Encountered file inside of symlink in NFT list: {current_path} \ is a symlink, but {referenced_chunk_path} was created inside of \ it" diff --git a/crates/next-core/src/hmr_entry.rs b/crates/next-core/src/hmr_entry.rs index 21726bd0fd3b7..26f22b70b8efb 100644 --- a/crates/next-core/src/hmr_entry.rs +++ b/crates/next-core/src/hmr_entry.rs @@ -8,7 +8,7 @@ use turbopack_core::{ asset::{Asset, AssetContent}, chunk::{ AsyncModuleInfo, ChunkItem, ChunkableModule, ChunkingContext, ChunkingType, - ChunkingTypeOption, EvaluatableAsset, + EvaluatableAsset, }, ident::AssetIdent, module::{Module, ModuleSideEffects}, @@ -164,11 +164,10 @@ impl ModuleReference for HmrEntryModuleReference { *ModuleResolveResult::module(self.module) } - #[turbo_tasks::function] - fn chunking_type(&self) -> Vc { - Vc::cell(Some(ChunkingType::Parallel { + fn chunking_type(&self) -> Option { + Some(ChunkingType::Parallel { inherit_async: false, hoisted: false, - })) + }) } } diff --git a/crates/next-core/src/next_client_reference/css_client_reference/css_client_reference_module.rs b/crates/next-core/src/next_client_reference/css_client_reference/css_client_reference_module.rs index a62ad17198db2..7488405fe6cc5 100644 --- a/crates/next-core/src/next_client_reference/css_client_reference/css_client_reference_module.rs +++ b/crates/next-core/src/next_client_reference/css_client_reference/css_client_reference_module.rs @@ -4,7 +4,7 @@ use turbo_tasks::{ResolvedVc, ValueToString, Vc}; use turbo_tasks_fs::FileContent; use turbopack_core::{ asset::{Asset, AssetContent}, - chunk::{ChunkGroupType, ChunkingType, ChunkingTypeOption}, + chunk::{ChunkGroupType, ChunkingType}, ident::AssetIdent, module::{Module, ModuleSideEffects}, reference::{ModuleReference, ModuleReferences}, @@ -99,11 +99,10 @@ impl ModuleReference for CssClientReference { *ModuleResolveResult::module(self.module) } - #[turbo_tasks::function] - fn chunking_type(&self) -> Vc { - Vc::cell(Some(ChunkingType::Isolated { + fn chunking_type(&self) -> Option { + Some(ChunkingType::Isolated { _ty: ChunkGroupType::Evaluated, merge_tag: Some(rcstr!("client")), - })) + }) } } diff --git a/crates/next-core/src/next_client_reference/ecmascript_client_reference/ecmascript_client_reference_module.rs b/crates/next-core/src/next_client_reference/ecmascript_client_reference/ecmascript_client_reference_module.rs index 3e653dfbf3d84..bd07f1029d747 100644 --- a/crates/next-core/src/next_client_reference/ecmascript_client_reference/ecmascript_client_reference_module.rs +++ b/crates/next-core/src/next_client_reference/ecmascript_client_reference/ecmascript_client_reference_module.rs @@ -9,7 +9,7 @@ use turbopack_core::{ asset::AssetContent, chunk::{ AsyncModuleInfo, ChunkGroupType, ChunkItem, ChunkType, ChunkableModule, ChunkingContext, - ChunkingType, ChunkingTypeOption, + ChunkingType, }, code_builder::CodeBuilder, context::AssetContext, @@ -384,11 +384,10 @@ impl ModuleReference for EcmascriptClientReference { *ModuleResolveResult::module(self.module) } - #[turbo_tasks::function] - fn chunking_type(&self) -> Vc { - Vc::cell(Some(ChunkingType::Isolated { + fn chunking_type(&self) -> Option { + Some(ChunkingType::Isolated { _ty: self.ty, merge_tag: self.merge_tag.clone(), - })) + }) } } diff --git a/crates/next-core/src/next_server_component/server_component_reference.rs b/crates/next-core/src/next_server_component/server_component_reference.rs index 343bbe90c5d09..a4a91a2782c3d 100644 --- a/crates/next-core/src/next_server_component/server_component_reference.rs +++ b/crates/next-core/src/next_server_component/server_component_reference.rs @@ -1,9 +1,6 @@ use turbo_tasks::{ResolvedVc, ValueToString, Vc}; use turbopack_core::{ - chunk::{ChunkingType, ChunkingTypeOption}, - module::Module, - reference::ModuleReference, - resolve::ModuleResolveResult, + chunk::ChunkingType, module::Module, reference::ModuleReference, resolve::ModuleResolveResult, }; #[turbo_tasks::value] @@ -27,11 +24,10 @@ impl ModuleReference for NextServerComponentModuleReference { fn resolve_reference(&self) -> Vc { *ModuleResolveResult::module(self.asset) } - #[turbo_tasks::function] - fn chunking_type(&self) -> Vc { - Vc::cell(Some(ChunkingType::Shared { + fn chunking_type(&self) -> Option { + Some(ChunkingType::Shared { inherit_async: true, merge_tag: None, - })) + }) } } diff --git a/crates/next-core/src/next_server_utility/server_utility_reference.rs b/crates/next-core/src/next_server_utility/server_utility_reference.rs index aceb3d2a746a6..04ef535ac409d 100644 --- a/crates/next-core/src/next_server_utility/server_utility_reference.rs +++ b/crates/next-core/src/next_server_utility/server_utility_reference.rs @@ -2,10 +2,7 @@ use once_cell::sync::Lazy; use turbo_rcstr::{RcStr, rcstr}; use turbo_tasks::{ResolvedVc, ValueToString, Vc}; use turbopack_core::{ - chunk::{ChunkingType, ChunkingTypeOption}, - module::Module, - reference::ModuleReference, - resolve::ModuleResolveResult, + chunk::ChunkingType, module::Module, reference::ModuleReference, resolve::ModuleResolveResult, }; #[turbo_tasks::value] @@ -32,11 +29,10 @@ impl ModuleReference for NextServerUtilityModuleReference { *ModuleResolveResult::module(self.asset) } - #[turbo_tasks::function] - fn chunking_type(&self) -> Vc { - Vc::cell(Some(ChunkingType::Shared { + fn chunking_type(&self) -> Option { + Some(ChunkingType::Shared { inherit_async: true, merge_tag: Some(NEXT_SERVER_UTILITY_MERGE_TAG.clone()), - })) + }) } } diff --git a/packages/next/src/build/swc/generated-native.d.ts b/packages/next/src/build/swc/generated-native.d.ts index 4f8e3b15fc4ab..927d9bc8f8e08 100644 --- a/packages/next/src/build/swc/generated-native.d.ts +++ b/packages/next/src/build/swc/generated-native.d.ts @@ -111,6 +111,14 @@ export declare function codeFrameColumns( location: NapiCodeFrameLocation, options?: NapiCodeFrameOptions | undefined | null ): string | null +/** + * Convert an array of dash-case feature name strings to a lightningcss + * `Features` bitmask (u32). Called from the webpack lightningcss-loader to + * avoid duplicating the name-to-bit mapping in JavaScript. + */ +export declare function lightningcssFeatureNamesToMaskNapi( + names: Array +): number export declare function lockfileTryAcquireSync( path: string, content?: string | undefined | null @@ -248,7 +256,7 @@ export interface NapiProjectOptions { isPersistentCachingEnabled: boolean /** The version of Next.js that is running. */ nextVersion: RcStr - /** Whether server-side HMR is enabled (requires --experimental-server-fast-refresh). */ + /** Whether server-side HMR is enabled (--experimental-server-fast-refresh). */ serverHmr?: boolean } /** [NapiProjectOptions] with all fields optional. */ @@ -294,8 +302,6 @@ export interface NapiPartialProjectOptions { * debugging/profiling purposes. */ noMangling?: boolean - /** Whether server-side HMR is enabled (requires --experimental-server-fast-refresh). */ - serverHmr?: boolean } export interface NapiDefineEnv { client: Array diff --git a/turbopack/crates/turbo-tasks-backend/src/backend/operation/mod.rs b/turbopack/crates/turbo-tasks-backend/src/backend/operation/mod.rs index 3f5b91771cc0f..52d836ce21d95 100644 --- a/turbopack/crates/turbo-tasks-backend/src/backend/operation/mod.rs +++ b/turbopack/crates/turbo-tasks-backend/src/backend/operation/mod.rs @@ -9,7 +9,7 @@ mod update_cell; mod update_collectible; use std::{ fmt::{Debug, Display, Formatter}, - sync::{Arc, atomic::Ordering}, + sync::Arc, }; use bincode::{Decode, Encode}; @@ -98,12 +98,64 @@ pub trait ChildExecuteContext<'e>: Send + Sized { fn create(self) -> impl ExecuteContext<'e>; } +/// Counter that tracks how many task guards are alive, detecting concurrent access. +/// +/// In release builds all methods are no-ops and the struct is zero-sized, so there is no runtime +/// cost. + +#[derive(Clone)] +struct TaskLockCounter(#[cfg(debug_assertions)] std::sync::Arc); + +impl TaskLockCounter { + fn new() -> Self { + Self( + #[cfg(debug_assertions)] + std::sync::Arc::new(std::sync::atomic::AtomicU8::new(0)), + ) + } + + /// Increment the count by 1 and panic if concurrent access is detected. + fn acquire(&self) { + #[cfg(debug_assertions)] + if self.0.fetch_add(1, std::sync::atomic::Ordering::AcqRel) != 0 { + panic!( + "Concurrent task lock acquisition detected. This is not allowed and indicates a \ + bug. It can lead to deadlocks." + ); + } + } + + /// Increment the count by `n` and panic if concurrent access is detected. + fn acquire_multiple(&self, n: u8) { + let _ = n; // silence warning + #[cfg(debug_assertions)] + if self.0.fetch_add(n, std::sync::atomic::Ordering::AcqRel) != 0 { + panic!( + "Concurrent task lock acquisition detected. This is not allowed and indicates a \ + bug. It can lead to deadlocks." + ); + } + } + + /// Increment the count by 1 without checking for concurrent access. + /// Used when the caller knows another guard already validated exclusive access. + fn reacquire(&self) { + #[cfg(debug_assertions)] + self.0.fetch_add(1, std::sync::atomic::Ordering::AcqRel); + } + + /// Decrement the count by 1. + fn release(&self) { + #[cfg(debug_assertions)] + self.0.fetch_sub(1, std::sync::atomic::Ordering::AcqRel); + } +} + pub struct ExecuteContextImpl<'e, B: BackingStorage> { backend: &'e TurboTasksBackendInner, turbo_tasks: &'e dyn TurboTasksBackendApi>, _operation_guard: Option>, - #[cfg(debug_assertions)] - active_task_locks: std::sync::Arc, + task_lock_counter: TaskLockCounter, } impl<'e, B: BackingStorage> ExecuteContextImpl<'e, B> { @@ -115,8 +167,7 @@ impl<'e, B: BackingStorage> ExecuteContextImpl<'e, B> { backend, turbo_tasks, _operation_guard: Some(backend.start_operation()), - #[cfg(debug_assertions)] - active_task_locks: std::sync::Arc::new(std::sync::atomic::AtomicU8::new(0)), + task_lock_counter: TaskLockCounter::new(), } } @@ -228,13 +279,7 @@ impl<'e, B: BackingStorage> ExecuteContextImpl<'e, B> { let mut tasks_to_restore_for_meta = Vec::with_capacity(meta_count); let mut tasks_to_restore_for_meta_indicies = Vec::with_capacity(meta_count); for (i, &(task_id, category, _, _)) in tasks.iter().enumerate() { - #[cfg(debug_assertions)] - if self.active_task_locks.fetch_add(1, Ordering::AcqRel) != 0 { - panic!( - "Concurrent task lock acquisition detected. This is not allowed and indicates \ - a bug. It can lead to deadlocks." - ); - } + self.task_lock_counter.acquire(); let task = self.backend.storage.access_mut(task_id); let mut ready = true; @@ -255,8 +300,7 @@ impl<'e, B: BackingStorage> ExecuteContextImpl<'e, B> { if ready { prepared_task_callback(self, task_id, category, task); } - #[cfg(debug_assertions)] - self.active_task_locks.fetch_sub(1, Ordering::AcqRel); + self.task_lock_counter.release(); } if tasks_to_restore_for_meta.is_empty() && tasks_to_restore_for_data.is_empty() { return; @@ -317,13 +361,7 @@ impl<'e, B: BackingStorage> ExecuteContextImpl<'e, B> { if storage_for_data.is_none() && storage_for_meta.is_none() { continue; } - #[cfg(debug_assertions)] - if self.active_task_locks.fetch_add(1, Ordering::AcqRel) != 0 { - panic!( - "Concurrent task lock acquisition detected. This is not allowed and indicates \ - a bug. It can lead to deadlocks." - ); - } + self.task_lock_counter.acquire(); let mut task_type = None; let mut task = self.backend.storage.access_mut(task_id); @@ -341,8 +379,7 @@ impl<'e, B: BackingStorage> ExecuteContextImpl<'e, B> { task.flags.set_restored(TaskDataCategory::Meta); } prepared_task_callback(self, task_id, category, task); - #[cfg(debug_assertions)] - self.active_task_locks.fetch_sub(1, Ordering::AcqRel); + self.task_lock_counter.release(); if let Some(task_type) = task_type { // Insert into the task cache to avoid future lookups self.backend.task_cache.entry(task_type).or_insert(task_id); @@ -365,13 +402,7 @@ impl<'e, B: BackingStorage> ExecuteContext<'e> for ExecuteContextImpl<'e, B> { } fn task(&mut self, task_id: TaskId, category: TaskDataCategory) -> Self::TaskGuardImpl { - #[cfg(debug_assertions)] - if self.active_task_locks.fetch_add(1, Ordering::AcqRel) != 0 { - panic!( - "Concurrent task lock acquisition detected. This is not allowed and indicates a \ - bug. It can lead to deadlocks." - ); - } + self.task_lock_counter.acquire(); let mut task = self.backend.storage.access_mut(task_id); if !task.flags.is_restored(category) { @@ -417,8 +448,7 @@ impl<'e, B: BackingStorage> ExecuteContext<'e> for ExecuteContextImpl<'e, B> { task_id, #[cfg(debug_assertions)] category, - #[cfg(debug_assertions)] - active_task_locks: self.active_task_locks.clone(), + task_lock_counter: self.task_lock_counter.clone(), } } @@ -431,22 +461,18 @@ impl<'e, B: BackingStorage> ExecuteContext<'e> for ExecuteContextImpl<'e, B> { task_ids: impl IntoIterator, mut func: impl FnMut(Self::TaskGuardImpl, &mut Self), ) { - #[cfg(debug_assertions)] - let active_task_locks = self.active_task_locks.clone(); + let task_lock_counter = self.task_lock_counter.clone(); self.prepare_tasks_with_callback(task_ids, true, |this, task_id, _category, task| { - // The prepare_tasks_with_callback already increased the active_task_locks count and - // checked for concurrent access but it will also decrement it again, so we - // need to increase it again here as Drop will decrement it - #[cfg(debug_assertions)] - active_task_locks.fetch_add(1, Ordering::AcqRel); + // The prepare_tasks_with_callback already checked for concurrent access + // but will also decrement, so we re-increment here since Drop will decrement. + task_lock_counter.reacquire(); let guard = TaskGuardImpl { task, task_id, #[cfg(debug_assertions)] category: _category, - #[cfg(debug_assertions)] - active_task_locks: active_task_locks.clone(), + task_lock_counter: task_lock_counter.clone(), }; func(guard, this); }); @@ -458,13 +484,7 @@ impl<'e, B: BackingStorage> ExecuteContext<'e> for ExecuteContextImpl<'e, B> { task_id2: TaskId, category: TaskDataCategory, ) -> (Self::TaskGuardImpl, Self::TaskGuardImpl) { - #[cfg(debug_assertions)] - if self.active_task_locks.fetch_add(2, Ordering::AcqRel) != 0 { - panic!( - "Concurrent task lock acquisition detected. This is not allowed and indicates a \ - bug. It can lead to deadlocks." - ); - } + self.task_lock_counter.acquire_multiple(2); let (mut task1, mut task2) = self.backend.storage.access_pair_mut(task_id1, task_id2); @@ -529,16 +549,14 @@ impl<'e, B: BackingStorage> ExecuteContext<'e> for ExecuteContextImpl<'e, B> { task_id: task_id1, #[cfg(debug_assertions)] category, - #[cfg(debug_assertions)] - active_task_locks: self.active_task_locks.clone(), + task_lock_counter: self.task_lock_counter.clone(), }, TaskGuardImpl { task: task2, task_id: task_id2, #[cfg(debug_assertions)] category, - #[cfg(debug_assertions)] - active_task_locks: self.active_task_locks.clone(), + task_lock_counter: self.task_lock_counter.clone(), }, ) } @@ -624,8 +642,7 @@ impl<'e, B: BackingStorage> ChildExecuteContext<'e> for ChildExecuteContextImpl< backend: self.backend, turbo_tasks: self.turbo_tasks, _operation_guard: None, - #[cfg(debug_assertions)] - active_task_locks: std::sync::Arc::new(std::sync::atomic::AtomicU8::new(0)), + task_lock_counter: TaskLockCounter::new(), } } } @@ -898,14 +915,12 @@ pub struct TaskGuardImpl<'a> { task: StorageWriteGuard<'a>, #[cfg(debug_assertions)] category: TaskDataCategory, - #[cfg(debug_assertions)] - active_task_locks: std::sync::Arc, + task_lock_counter: TaskLockCounter, } -#[cfg(debug_assertions)] impl Drop for TaskGuardImpl<'_> { fn drop(&mut self) { - self.active_task_locks.fetch_sub(1, Ordering::AcqRel); + self.task_lock_counter.release(); } } diff --git a/turbopack/crates/turbopack-core/src/chunk/mod.rs b/turbopack/crates/turbopack-core/src/chunk/mod.rs index 4590f64b8eab2..2fec40decbd27 100644 --- a/turbopack/crates/turbopack-core/src/chunk/mod.rs +++ b/turbopack/crates/turbopack-core/src/chunk/mod.rs @@ -377,9 +377,6 @@ impl ChunkingType { } } -#[turbo_tasks::value(transparent)] -pub struct ChunkingTypeOption(Option); - pub struct ChunkGroupContent { pub chunkable_items: Vec, pub batch_groups: Vec>, diff --git a/turbopack/crates/turbopack-core/src/introspect/utils.rs b/turbopack/crates/turbopack-core/src/introspect/utils.rs index 2ed00dc8ceb4d..e728ed1621347 100644 --- a/turbopack/crates/turbopack-core/src/introspect/utils.rs +++ b/turbopack/crates/turbopack-core/src/introspect/utils.rs @@ -68,7 +68,8 @@ pub async fn children_from_module_references( let mut children = FxIndexSet::default(); let references = references.await?; for &reference in &*references { - let key = match &*reference.chunking_type().await? { + let trait_ref = reference.into_trait_ref().await?; + let key = match &trait_ref.chunking_type() { None => key.clone(), Some(ChunkingType::Parallel { inherit_async, .. }) => { if *inherit_async { diff --git a/turbopack/crates/turbopack-core/src/reference/mod.rs b/turbopack/crates/turbopack-core/src/reference/mod.rs index 4886c6ad0ccfc..8c0ee6bd304d0 100644 --- a/turbopack/crates/turbopack-core/src/reference/mod.rs +++ b/turbopack/crates/turbopack-core/src/reference/mod.rs @@ -9,7 +9,7 @@ use turbo_tasks::{ }; use crate::{ - chunk::{ChunkingType, ChunkingTypeOption}, + chunk::ChunkingType, module::{Module, Modules}, output::{ ExpandOutputAssetsInput, ExpandedOutputAssets, OutputAsset, OutputAssets, @@ -34,14 +34,12 @@ pub trait ModuleReference: ValueToString { // TODO think about different types // fn kind(&self) -> Vc; - #[turbo_tasks::function] - fn chunking_type(self: Vc) -> Vc { - Vc::cell(None) + fn chunking_type(&self) -> Option { + None } - #[turbo_tasks::function] - fn binding_usage(self: Vc) -> Vc { - BindingUsage::all() + fn binding_usage(&self) -> BindingUsage { + BindingUsage::default() } } @@ -97,22 +95,22 @@ impl SingleModuleReference { pub struct SingleChunkableModuleReference { asset: ResolvedVc>, description: RcStr, - export: ResolvedVc, + export: ExportUsage, } #[turbo_tasks::value_impl] impl SingleChunkableModuleReference { #[turbo_tasks::function] - pub fn new( + pub async fn new( asset: ResolvedVc>, description: RcStr, - export: ResolvedVc, - ) -> Vc { - Self::cell(SingleChunkableModuleReference { + export: Vc, + ) -> Result> { + Ok(Self::cell(SingleChunkableModuleReference { asset, description, - export, - }) + export: export.owned().await?, + })) } } @@ -123,21 +121,18 @@ impl ModuleReference for SingleChunkableModuleReference { *ModuleResolveResult::module(self.asset) } - #[turbo_tasks::function] - fn chunking_type(self: Vc) -> Vc { - Vc::cell(Some(ChunkingType::Parallel { + fn chunking_type(&self) -> Option { + Some(ChunkingType::Parallel { inherit_async: true, hoisted: false, - })) + }) } - #[turbo_tasks::function] - async fn binding_usage(&self) -> Result> { - Ok(BindingUsage { + fn binding_usage(&self) -> BindingUsage { + BindingUsage { import: ImportUsage::TopLevel, - export: self.export.owned().await?, + export: self.export.clone(), } - .cell()) } } @@ -225,9 +220,8 @@ impl ModuleReference for TracedModuleReference { *ModuleResolveResult::module(self.module) } - #[turbo_tasks::function] - fn chunking_type(&self) -> Vc { - Vc::cell(Some(ChunkingType::Traced)) + fn chunking_type(&self) -> Option { + Some(ChunkingType::Traced) } } @@ -295,7 +289,8 @@ pub async fn primary_chunkable_referenced_modules( .await? .iter() .map(|reference| async { - if let Some(chunking_type) = &*reference.chunking_type().await? { + let trait_ref = reference.into_trait_ref().await?; + if let Some(chunking_type) = &trait_ref.chunking_type() { if !include_traced && matches!(chunking_type, ChunkingType::Traced) { return Ok(None); } @@ -306,7 +301,7 @@ pub async fn primary_chunkable_referenced_modules( .primary_modules_ref() .await?; let binding_usage = if include_binding_usage { - reference.binding_usage().owned().await? + trait_ref.binding_usage() } else { BindingUsage::default() }; diff --git a/turbopack/crates/turbopack-css/src/references/compose.rs b/turbopack/crates/turbopack-css/src/references/compose.rs index 0e977e094cca9..ae83f1bf4ed86 100644 --- a/turbopack/crates/turbopack-css/src/references/compose.rs +++ b/turbopack/crates/turbopack-css/src/references/compose.rs @@ -1,6 +1,6 @@ use turbo_tasks::{ResolvedVc, ValueToString, Vc}; use turbopack_core::{ - chunk::{ChunkingType, ChunkingTypeOption}, + chunk::ChunkingType, reference::ModuleReference, reference_type::CssReferenceSubType, resolve::{ModuleResolveResult, origin::ResolveOrigin, parse::Request}, @@ -42,11 +42,10 @@ impl ModuleReference for CssModuleComposeReference { ) } - #[turbo_tasks::function] - fn chunking_type(self: Vc) -> Vc { - Vc::cell(Some(ChunkingType::Parallel { + fn chunking_type(&self) -> Option { + Some(ChunkingType::Parallel { inherit_async: false, hoisted: false, - })) + }) } } diff --git a/turbopack/crates/turbopack-css/src/references/import.rs b/turbopack/crates/turbopack-css/src/references/import.rs index cab4d49c83aa3..fb5db849c2f95 100644 --- a/turbopack/crates/turbopack-css/src/references/import.rs +++ b/turbopack/crates/turbopack-css/src/references/import.rs @@ -8,7 +8,7 @@ use lightningcss::{ }; use turbo_tasks::{ResolvedVc, ValueToString, Vc}; use turbopack_core::{ - chunk::{ChunkingContext, ChunkingType, ChunkingTypeOption}, + chunk::{ChunkingContext, ChunkingType}, issue::IssueSource, reference::ModuleReference, reference_type::{CssReferenceSubType, ImportContext}, @@ -142,12 +142,11 @@ impl ModuleReference for ImportAssetReference { )) } - #[turbo_tasks::function] - fn chunking_type(self: Vc) -> Vc { - Vc::cell(Some(ChunkingType::Parallel { + fn chunking_type(&self) -> Option { + Some(ChunkingType::Parallel { inherit_async: false, hoisted: false, - })) + }) } } diff --git a/turbopack/crates/turbopack-css/src/references/internal.rs b/turbopack/crates/turbopack-css/src/references/internal.rs index d4d86d6b2c4b0..6e6561f486795 100644 --- a/turbopack/crates/turbopack-css/src/references/internal.rs +++ b/turbopack/crates/turbopack-css/src/references/internal.rs @@ -1,9 +1,6 @@ use turbo_tasks::{ResolvedVc, ValueToString, Vc}; use turbopack_core::{ - chunk::{ChunkingType, ChunkingTypeOption}, - module::Module, - reference::ModuleReference, - resolve::ModuleResolveResult, + chunk::ChunkingType, module::Module, reference::ModuleReference, resolve::ModuleResolveResult, }; /// A reference to an internal CSS asset. @@ -30,11 +27,10 @@ impl ModuleReference for InternalCssAssetReference { *ModuleResolveResult::module(self.module) } - #[turbo_tasks::function] - fn chunking_type(self: Vc) -> Vc { - Vc::cell(Some(ChunkingType::Parallel { + fn chunking_type(&self) -> Option { + Some(ChunkingType::Parallel { inherit_async: false, hoisted: false, - })) + }) } } diff --git a/turbopack/crates/turbopack-css/src/references/url.rs b/turbopack/crates/turbopack-css/src/references/url.rs index da2166521e114..094b489cd3d8a 100644 --- a/turbopack/crates/turbopack-css/src/references/url.rs +++ b/turbopack/crates/turbopack-css/src/references/url.rs @@ -11,7 +11,7 @@ use rustc_hash::FxHashMap; use turbo_rcstr::RcStr; use turbo_tasks::{ResolvedVc, ValueToString, Vc}; use turbopack_core::{ - chunk::{ChunkingContext, ChunkingType, ChunkingTypeOption}, + chunk::{ChunkingContext, ChunkingType}, issue::IssueSource, output::OutputAsset, reference::ModuleReference, @@ -86,12 +86,11 @@ impl ModuleReference for UrlAssetReference { ) } - #[turbo_tasks::function] - fn chunking_type(self: Vc) -> Vc { - Vc::cell(Some(ChunkingType::Parallel { + fn chunking_type(&self) -> Option { + Some(ChunkingType::Parallel { inherit_async: false, hoisted: false, - })) + }) } } diff --git a/turbopack/crates/turbopack-ecmascript/src/analyzer/imports.rs b/turbopack/crates/turbopack-ecmascript/src/analyzer/imports.rs index 147c404785fc3..60e4332c26b05 100644 --- a/turbopack/crates/turbopack-ecmascript/src/analyzer/imports.rs +++ b/turbopack/crates/turbopack-ecmascript/src/analyzer/imports.rs @@ -5,7 +5,12 @@ use rustc_hash::{FxHashMap, FxHashSet}; use smallvec::SmallVec; use swc_core::{ atoms::Wtf8Atom, - common::{BytePos, Span, Spanned, SyntaxContext, comments::Comments, source_map::SmallPos}, + common::{ + BytePos, Span, Spanned, SyntaxContext, + comments::Comments, + errors::{DiagnosticId, HANDLER}, + source_map::SmallPos, + }, ecma::{ ast::*, atoms::{Atom, atom}, @@ -15,7 +20,9 @@ use swc_core::{ }; use turbo_rcstr::{RcStr, rcstr}; use turbo_tasks::{FxIndexMap, FxIndexSet, ResolvedVc}; -use turbopack_core::{issue::IssueSource, loader::WebpackLoaderItem, source::Source}; +use turbopack_core::{ + chunk::ChunkingType, issue::IssueSource, loader::WebpackLoaderItem, source::Source, +}; use super::{JsValue, ModuleValue, top_level_await::has_top_level_await}; use crate::{ @@ -113,6 +120,23 @@ impl ImportAnnotations { PropName::Str(s) => s.value.clone(), _ => continue, }; + // Validate known annotation values + if key == *ANNOTATION_CHUNKING_TYPE { + let value = str.value.to_string_lossy(); + if value != "parallel" && value != "none" { + HANDLER.with(|handler| { + handler.span_warn_with_code( + kv.value.span(), + &format!( + "unknown turbopack-chunking-type: \"{value}\", \ + expected \"parallel\" or \"none\"" + ), + DiagnosticId::Error("turbopack-chunking-type".into()), + ); + }); + continue; + } + } map.insert(key, str.value.clone()); } } @@ -171,9 +195,23 @@ impl ImportAnnotations { .map(|v| v.to_string_lossy()) } - /// Returns the content on the chunking-type annotation - pub fn chunking_type(&self) -> Option<&Wtf8Atom> { - self.get(&ANNOTATION_CHUNKING_TYPE) + /// Returns the chunking type override from the `turbopack-chunking-type` annotation. + /// + /// - `None` — no annotation present + /// - `Some(None)` — annotation is `"none"` (opt out of chunking) + /// - `Some(Some(..))` — explicit chunking type (e.g. `"parallel"`) + /// + /// Unknown values are rejected during [`ImportAnnotations::parse`] and omitted. + pub fn chunking_type(&self) -> Option> { + let chunking_type = self.get(&ANNOTATION_CHUNKING_TYPE)?; + if chunking_type == "none" { + Some(None) + } else { + Some(Some(ChunkingType::Parallel { + inherit_async: true, + hoisted: true, + })) + } } /// Returns the content on the type attribute diff --git a/turbopack/crates/turbopack-ecmascript/src/references/amd.rs b/turbopack/crates/turbopack-ecmascript/src/references/amd.rs index 4751d9850f73c..a14f2b9896096 100644 --- a/turbopack/crates/turbopack-ecmascript/src/references/amd.rs +++ b/turbopack/crates/turbopack-ecmascript/src/references/amd.rs @@ -15,7 +15,7 @@ use turbo_tasks::{ trace::TraceRawVcs, }; use turbopack_core::{ - chunk::{ChunkingContext, ChunkingType, ChunkingTypeOption}, + chunk::{ChunkingContext, ChunkingType}, issue::IssueSource, reference::ModuleReference, reference_type::CommonJsReferenceSubType, @@ -74,12 +74,11 @@ impl ModuleReference for AmdDefineAssetReference { ) } - #[turbo_tasks::function] - fn chunking_type(self: Vc) -> Vc { - Vc::cell(Some(ChunkingType::Parallel { + fn chunking_type(&self) -> Option { + Some(ChunkingType::Parallel { inherit_async: false, hoisted: false, - })) + }) } } diff --git a/turbopack/crates/turbopack-ecmascript/src/references/async_module.rs b/turbopack/crates/turbopack-ecmascript/src/references/async_module.rs index 5152f63f8cfd9..6983ab682d1a7 100644 --- a/turbopack/crates/turbopack-ecmascript/src/references/async_module.rs +++ b/turbopack/crates/turbopack-ecmascript/src/references/async_module.rs @@ -88,7 +88,8 @@ struct AsyncModuleIdents( async fn get_inherit_async_referenced_asset( r: ResolvedVc>, ) -> Result>> { - let Some(ty) = &*r.chunking_type().await? else { + let trait_ref = r.into_trait_ref().await?; + let Some(ty) = &trait_ref.chunking_type() else { return Ok(None); }; if !matches!( diff --git a/turbopack/crates/turbopack-ecmascript/src/references/cjs.rs b/turbopack/crates/turbopack-ecmascript/src/references/cjs.rs index ce5089ed9dca3..0f6e97278ecc5 100644 --- a/turbopack/crates/turbopack-ecmascript/src/references/cjs.rs +++ b/turbopack/crates/turbopack-ecmascript/src/references/cjs.rs @@ -9,7 +9,7 @@ use turbo_tasks::{ NonLocalValue, ResolvedVc, ValueToString, Vc, debug::ValueDebugFormat, trace::TraceRawVcs, }; use turbopack_core::{ - chunk::{ChunkingContext, ChunkingType, ChunkingTypeOption}, + chunk::{ChunkingContext, ChunkingType}, issue::IssueSource, reference::ModuleReference, reference_type::CommonJsReferenceSubType, @@ -68,12 +68,11 @@ impl ModuleReference for CjsAssetReference { ) } - #[turbo_tasks::function] - fn chunking_type(self: Vc) -> Vc { - Vc::cell(Some(ChunkingType::Parallel { + fn chunking_type(&self) -> Option { + Some(ChunkingType::Parallel { inherit_async: false, hoisted: false, - })) + }) } } @@ -116,12 +115,11 @@ impl ModuleReference for CjsRequireAssetReference { ) } - #[turbo_tasks::function] - fn chunking_type(self: Vc) -> Vc { - Vc::cell(Some(ChunkingType::Parallel { + fn chunking_type(&self) -> Option { + Some(ChunkingType::Parallel { inherit_async: false, hoisted: false, - })) + }) } } @@ -239,12 +237,11 @@ impl ModuleReference for CjsRequireResolveAssetReference { ) } - #[turbo_tasks::function] - fn chunking_type(self: Vc) -> Vc { - Vc::cell(Some(ChunkingType::Parallel { + fn chunking_type(&self) -> Option { + Some(ChunkingType::Parallel { inherit_async: false, hoisted: false, - })) + }) } } diff --git a/turbopack/crates/turbopack-ecmascript/src/references/esm/base.rs b/turbopack/crates/turbopack-ecmascript/src/references/esm/base.rs index 8652f005ed612..99088420619d9 100644 --- a/turbopack/crates/turbopack-ecmascript/src/references/esm/base.rs +++ b/turbopack/crates/turbopack-ecmascript/src/references/esm/base.rs @@ -13,7 +13,7 @@ use turbo_rcstr::{RcStr, rcstr}; use turbo_tasks::{ResolvedVc, ValueToString, Vc, turbobail}; use turbo_tasks_fs::FileSystemPath; use turbopack_core::{ - chunk::{ChunkingContext, ChunkingType, ChunkingTypeOption, ModuleChunkItemIdExt}, + chunk::{ChunkingContext, ChunkingType, ModuleChunkItemIdExt}, issue::{ Issue, IssueExt, IssueSeverity, IssueSource, IssueStage, OptionIssueSource, OptionStyledString, StyledString, @@ -397,11 +397,6 @@ impl EsmAssetReference { is_pure_import: true, } } -} - -#[turbo_tasks::value_impl] -impl EsmAssetReference { - #[turbo_tasks::function] pub(crate) fn get_referenced_asset(self: Vc) -> Vc { ReferencedAsset::from_resolve_result(self.resolve_reference()) } @@ -501,31 +496,16 @@ impl ModuleReference for EsmAssetReference { Ok(result) } - #[turbo_tasks::function] - fn chunking_type(&self) -> Result> { - Ok(Vc::cell( - if let Some(chunking_type) = self.annotations.chunking_type() { - if chunking_type == "parallel" { - Some(ChunkingType::Parallel { - inherit_async: true, - hoisted: true, - }) - } else if chunking_type == "none" { - None - } else { - bail!("unknown chunking_type: {}", chunking_type.to_string_lossy()); - } - } else { - Some(ChunkingType::Parallel { - inherit_async: true, - hoisted: true, - }) - }, - )) + fn chunking_type(&self) -> Option { + self.annotations + .chunking_type() + .unwrap_or(Some(ChunkingType::Parallel { + inherit_async: true, + hoisted: true, + })) } - #[turbo_tasks::function] - fn binding_usage(&self) -> Vc { + fn binding_usage(&self) -> BindingUsage { BindingUsage { import: self.import_usage.clone(), export: match &self.export_name { @@ -534,7 +514,6 @@ impl ModuleReference for EsmAssetReference { _ => ExportUsage::All, }, } - .cell() } } @@ -555,7 +534,7 @@ impl EsmAssetReference { } // only chunked references can be imported - if this.annotations.chunking_type().is_none_or(|v| v != "none") { + if !matches!(this.annotations.chunking_type(), Some(None)) { let import_externals = this.import_externals; let referenced_asset = self.get_referenced_asset().await?; diff --git a/turbopack/crates/turbopack-ecmascript/src/references/esm/dynamic.rs b/turbopack/crates/turbopack-ecmascript/src/references/esm/dynamic.rs index 0faa319915946..bb45fbc757e7d 100644 --- a/turbopack/crates/turbopack-ecmascript/src/references/esm/dynamic.rs +++ b/turbopack/crates/turbopack-ecmascript/src/references/esm/dynamic.rs @@ -9,7 +9,7 @@ use turbo_tasks::{ NonLocalValue, ResolvedVc, ValueToString, Vc, debug::ValueDebugFormat, trace::TraceRawVcs, }; use turbopack_core::{ - chunk::{ChunkingContext, ChunkingType, ChunkingTypeOption}, + chunk::{ChunkingContext, ChunkingType}, environment::ChunkLoading, issue::IssueSource, reference::ModuleReference, @@ -94,18 +94,15 @@ impl ModuleReference for EsmAsyncAssetReference { .await } - #[turbo_tasks::function] - fn chunking_type(&self) -> Vc { - Vc::cell(Some(ChunkingType::Async)) + fn chunking_type(&self) -> Option { + Some(ChunkingType::Async) } - #[turbo_tasks::function] - fn binding_usage(&self) -> Vc { + fn binding_usage(&self) -> BindingUsage { BindingUsage { import: Default::default(), export: self.export_usage.clone(), } - .cell() } } diff --git a/turbopack/crates/turbopack-ecmascript/src/references/esm/module_id.rs b/turbopack/crates/turbopack-ecmascript/src/references/esm/module_id.rs index 7ddb22f66f5e3..4ca4b830819e7 100644 --- a/turbopack/crates/turbopack-ecmascript/src/references/esm/module_id.rs +++ b/turbopack/crates/turbopack-ecmascript/src/references/esm/module_id.rs @@ -5,7 +5,7 @@ use turbo_tasks::{ NonLocalValue, ResolvedVc, ValueToString, Vc, debug::ValueDebugFormat, trace::TraceRawVcs, }; use turbopack_core::{ - chunk::{ChunkingContext, ChunkingTypeOption, ModuleChunkItemIdExt}, + chunk::{ChunkingContext, ChunkingType, ModuleChunkItemIdExt}, reference::ModuleReference, resolve::ModuleResolveResult, }; @@ -25,11 +25,15 @@ use crate::{ #[value_to_string("module id of {inner}")] pub struct EsmModuleIdAssetReference { inner: ResolvedVc, + chunking_type: Option, } impl EsmModuleIdAssetReference { - pub fn new(inner: ResolvedVc) -> Self { - EsmModuleIdAssetReference { inner } + pub fn new(inner: ResolvedVc, chunking_type: Option) -> Self { + EsmModuleIdAssetReference { + inner, + chunking_type, + } } } @@ -40,9 +44,8 @@ impl ModuleReference for EsmModuleIdAssetReference { self.inner.resolve_reference() } - #[turbo_tasks::function] - fn chunking_type(&self) -> Vc { - self.inner.chunking_type() + fn chunking_type(&self) -> Option { + self.chunking_type.clone() } } diff --git a/turbopack/crates/turbopack-ecmascript/src/references/esm/url.rs b/turbopack/crates/turbopack-ecmascript/src/references/esm/url.rs index 300df28410106..9900efb9d50cb 100644 --- a/turbopack/crates/turbopack-ecmascript/src/references/esm/url.rs +++ b/turbopack/crates/turbopack-ecmascript/src/references/esm/url.rs @@ -9,7 +9,7 @@ use turbo_tasks::{ trace::TraceRawVcs, }; use turbopack_core::{ - chunk::{ChunkingContext, ChunkingType, ChunkingTypeOption, ModuleChunkItemIdExt}, + chunk::{ChunkingContext, ChunkingType, ModuleChunkItemIdExt}, environment::Rendering, issue::IssueSource, reference::ModuleReference, @@ -99,12 +99,11 @@ impl ModuleReference for UrlAssetReference { ) } - #[turbo_tasks::function] - fn chunking_type(&self) -> Vc { - Vc::cell(Some(ChunkingType::Parallel { + fn chunking_type(&self) -> Option { + Some(ChunkingType::Parallel { inherit_async: false, hoisted: false, - })) + }) } } diff --git a/turbopack/crates/turbopack-ecmascript/src/references/hot_module.rs b/turbopack/crates/turbopack-ecmascript/src/references/hot_module.rs index 7cb98ad2536f4..8c5c340af68e1 100644 --- a/turbopack/crates/turbopack-ecmascript/src/references/hot_module.rs +++ b/turbopack/crates/turbopack-ecmascript/src/references/hot_module.rs @@ -16,7 +16,7 @@ use turbo_tasks::{ trace::TraceRawVcs, }; use turbopack_core::{ - chunk::{ChunkingContext, ChunkingType, ChunkingTypeOption, ModuleChunkItemIdExt}, + chunk::{ChunkingContext, ChunkingType, ModuleChunkItemIdExt}, issue::IssueSource, reference::ModuleReference, reference_type::{CommonJsReferenceSubType, EcmaScriptModulesReferenceSubType}, @@ -112,12 +112,11 @@ impl ModuleReference for ModuleHotReferenceAssetReference { self.resolve().await } - #[turbo_tasks::function] - fn chunking_type(self: Vc) -> Vc { - Vc::cell(Some(ChunkingType::Parallel { + fn chunking_type(&self) -> Option { + Some(ChunkingType::Parallel { inherit_async: false, hoisted: false, - })) + }) } } diff --git a/turbopack/crates/turbopack-ecmascript/src/references/mod.rs b/turbopack/crates/turbopack-ecmascript/src/references/mod.rs index 751b937f70c83..42be2c3673942 100644 --- a/turbopack/crates/turbopack-ecmascript/src/references/mod.rs +++ b/turbopack/crates/turbopack-ecmascript/src/references/mod.rs @@ -68,6 +68,7 @@ use turbo_tasks::{ }; use turbo_tasks_fs::FileSystemPath; use turbopack_core::{ + chunk::ChunkingType, compile_time_info::{ CompileTimeDefineValue, CompileTimeDefines, CompileTimeInfo, DefinableNameSegment, DefinableNameSegmentRef, FreeVarReference, FreeVarReferences, FreeVarReferencesMembers, @@ -1509,8 +1510,14 @@ async fn analyze_ecmascript_module_internal( }; if let Some("__turbopack_module_id__") = export.as_deref() { + let chunking_type = r.await?.annotations.chunking_type().unwrap_or(Some( + ChunkingType::Parallel { + inherit_async: true, + hoisted: true, + }, + )); analysis.add_reference_code_gen( - EsmModuleIdAssetReference::new(*r), + EsmModuleIdAssetReference::new(*r, chunking_type), ast_path.into(), ) } else { diff --git a/turbopack/crates/turbopack-ecmascript/src/references/raw.rs b/turbopack/crates/turbopack-ecmascript/src/references/raw.rs index 8fcadd4c8c3cc..5265b8b89148b 100644 --- a/turbopack/crates/turbopack-ecmascript/src/references/raw.rs +++ b/turbopack/crates/turbopack-ecmascript/src/references/raw.rs @@ -4,7 +4,7 @@ use turbo_rcstr::rcstr; use turbo_tasks::{ResolvedVc, ValueToString, Vc}; use turbo_tasks_fs::FileSystemPath; use turbopack_core::{ - chunk::{ChunkingType, ChunkingTypeOption}, + chunk::ChunkingType, file_source::FileSource, issue::IssueSource, raw_module::RawModule, @@ -78,9 +78,8 @@ impl ModuleReference for FileSourceReference { .await } - #[turbo_tasks::function] - fn chunking_type(&self) -> Vc { - Vc::cell(Some(ChunkingType::Traced)) + fn chunking_type(&self) -> Option { + Some(ChunkingType::Traced) } } @@ -207,8 +206,7 @@ impl ModuleReference for DirAssetReference { .await } - #[turbo_tasks::function] - fn chunking_type(&self) -> Vc { - Vc::cell(Some(ChunkingType::Traced)) + fn chunking_type(&self) -> Option { + Some(ChunkingType::Traced) } } diff --git a/turbopack/crates/turbopack-ecmascript/src/references/require_context.rs b/turbopack/crates/turbopack-ecmascript/src/references/require_context.rs index f387e45d8d9f4..19433b1547ce5 100644 --- a/turbopack/crates/turbopack-ecmascript/src/references/require_context.rs +++ b/turbopack/crates/turbopack-ecmascript/src/references/require_context.rs @@ -22,8 +22,8 @@ use turbo_tasks::{ use turbo_tasks_fs::{DirectoryContent, DirectoryEntry, FileSystemPath}; use turbopack_core::{ chunk::{ - AsyncModuleInfo, ChunkableModule, ChunkingContext, ChunkingType, ChunkingTypeOption, - MinifyType, ModuleChunkItemIdExt, + AsyncModuleInfo, ChunkableModule, ChunkingContext, ChunkingType, MinifyType, + ModuleChunkItemIdExt, }, ident::AssetIdent, issue::IssueSource, @@ -295,12 +295,11 @@ impl ModuleReference for RequireContextAssetReference { *ModuleResolveResult::module(ResolvedVc::upcast(self.inner)) } - #[turbo_tasks::function] - fn chunking_type(&self) -> Vc { - Vc::cell(Some(ChunkingType::Parallel { + fn chunking_type(&self) -> Option { + Some(ChunkingType::Parallel { inherit_async: false, hoisted: false, - })) + }) } } @@ -373,12 +372,11 @@ impl ModuleReference for ResolvedModuleReference { *self.0 } - #[turbo_tasks::function] - fn chunking_type(&self) -> Vc { - Vc::cell(Some(ChunkingType::Parallel { + fn chunking_type(&self) -> Option { + Some(ChunkingType::Parallel { inherit_async: false, hoisted: false, - })) + }) } } diff --git a/turbopack/crates/turbopack-ecmascript/src/references/worker.rs b/turbopack/crates/turbopack-ecmascript/src/references/worker.rs index 6621d6a091527..fc454257f847c 100644 --- a/turbopack/crates/turbopack-ecmascript/src/references/worker.rs +++ b/turbopack/crates/turbopack-ecmascript/src/references/worker.rs @@ -12,7 +12,7 @@ use turbo_tasks::{ }; use turbo_tasks_fs::FileSystemPath; use turbopack_core::{ - chunk::{ChunkableModule, ChunkingContext, ChunkingType, ChunkingTypeOption, EvaluatableAsset}, + chunk::{ChunkableModule, ChunkingContext, ChunkingType, EvaluatableAsset}, context::AssetContext, issue::{IssueExt, IssueSeverity, IssueSource, StyledString, code_gen::CodeGenerationIssue}, module::Module, @@ -256,12 +256,11 @@ impl ModuleReference for WorkerAssetReference { .cell()) } - #[turbo_tasks::function] - fn chunking_type(self: Vc) -> Vc { - Vc::cell(Some(ChunkingType::Parallel { + fn chunking_type(&self) -> Option { + Some(ChunkingType::Parallel { inherit_async: false, hoisted: false, - })) + }) } } diff --git a/turbopack/crates/turbopack-ecmascript/src/side_effect_optimization/reference.rs b/turbopack/crates/turbopack-ecmascript/src/side_effect_optimization/reference.rs index 33aec9357cf79..ce2cca9a1d9d4 100644 --- a/turbopack/crates/turbopack-ecmascript/src/side_effect_optimization/reference.rs +++ b/turbopack/crates/turbopack-ecmascript/src/side_effect_optimization/reference.rs @@ -7,7 +7,7 @@ use swc_core::{ }; use turbo_tasks::{NonLocalValue, ResolvedVc, ValueToString, Vc, trace::TraceRawVcs}; use turbopack_core::{ - chunk::{ChunkingContext, ChunkingType, ChunkingTypeOption, ModuleChunkItemIdExt}, + chunk::{ChunkingContext, ChunkingType, ModuleChunkItemIdExt}, module::Module, reference::ModuleReference, resolve::{BindingUsage, ExportUsage, ImportUsage, ModulePart, ModuleResolveResult}, @@ -40,7 +40,7 @@ enum EcmascriptModulePartReferenceMode { pub struct EcmascriptModulePartReference { module: ResolvedVc>, part: ModulePart, - export_usage: ResolvedVc, + export_usage: ExportUsage, mode: EcmascriptModulePartReferenceMode, } @@ -48,11 +48,11 @@ pub struct EcmascriptModulePartReference { impl EcmascriptModulePartReference { // Create new [EcmascriptModuleFacadeModule]s as necessary #[turbo_tasks::function] - pub fn new_part( + pub async fn new_part( module: ResolvedVc>, part: ModulePart, - export_usage: ResolvedVc, - ) -> Vc { + export_usage: Vc, + ) -> Result> { debug_assert!(matches!( part, ModulePart::Locals @@ -60,29 +60,29 @@ impl EcmascriptModulePartReference { | ModulePart::RenamedExport { .. } | ModulePart::RenamedNamespace { .. } )); - EcmascriptModulePartReference { + Ok(EcmascriptModulePartReference { module, part, - export_usage, + export_usage: export_usage.owned().await?, mode: EcmascriptModulePartReferenceMode::Synthesize, } - .cell() + .cell()) } // A reference to the given module, without any intermediary synthesized modules. #[turbo_tasks::function] - pub fn new_normal( + pub async fn new_normal( module: ResolvedVc>, part: ModulePart, - export_usage: ResolvedVc, - ) -> Vc { - EcmascriptModulePartReference { + export_usage: Vc, + ) -> Result> { + Ok(EcmascriptModulePartReference { module, part, - export_usage, + export_usage: export_usage.owned().await?, mode: EcmascriptModulePartReferenceMode::Normal, } - .cell() + .cell()) } } @@ -127,21 +127,18 @@ impl ModuleReference for EcmascriptModulePartReference { Ok(*ModuleResolveResult::module(module)) } - #[turbo_tasks::function] - fn chunking_type(self: Vc) -> Vc { - Vc::cell(Some(ChunkingType::Parallel { + fn chunking_type(&self) -> Option { + Some(ChunkingType::Parallel { inherit_async: true, hoisted: true, - })) + }) } - #[turbo_tasks::function] - async fn binding_usage(&self) -> Result> { - Ok(BindingUsage { + fn binding_usage(&self) -> BindingUsage { + BindingUsage { import: ImportUsage::TopLevel, - export: self.export_usage.owned().await?, + export: self.export_usage.clone(), } - .cell()) } } @@ -174,15 +171,15 @@ impl EcmascriptModulePartReference { )); } - let export_usage = this.export_usage.await?; - if merged_index.is_some() && matches!(*export_usage, ExportUsage::Evaluation) { + let export_usage = &this.export_usage; + if merged_index.is_some() && matches!(export_usage, ExportUsage::Evaluation) { // No need to import, the module was already executed and is available in the same scope // hoisting group (unless it's a namespace import) } else { let ident = referenced_asset .get_ident( chunking_context, - match &*export_usage { + match export_usage { ExportUsage::Named(export) => Some(export.clone()), ExportUsage::PartialNamespaceObject(_) | ExportUsage::All diff --git a/turbopack/crates/turbopack-ecmascript/src/worker_chunk/module.rs b/turbopack/crates/turbopack-ecmascript/src/worker_chunk/module.rs index 2368a1f193831..b4dc0945df5fd 100644 --- a/turbopack/crates/turbopack-ecmascript/src/worker_chunk/module.rs +++ b/turbopack/crates/turbopack-ecmascript/src/worker_chunk/module.rs @@ -5,7 +5,7 @@ use turbo_tasks::{ResolvedVc, TryJoinIterExt, ValueToString, Vc}; use turbopack_core::{ chunk::{ AsyncModuleInfo, ChunkData, ChunkGroupType, ChunkableModule, ChunkingContext, - ChunkingContextExt, ChunkingType, ChunkingTypeOption, ChunksData, EvaluatableAsset, + ChunkingContextExt, ChunkingType, ChunksData, EvaluatableAsset, availability_info::AvailabilityInfo, }, context::AssetContext, @@ -331,14 +331,13 @@ impl ModuleReference for WorkerModuleReference { *ModuleResolveResult::module(self.module) } - #[turbo_tasks::function] - fn chunking_type(&self) -> Vc { - Vc::cell(Some(ChunkingType::Isolated { + fn chunking_type(&self) -> Option { + Some(ChunkingType::Isolated { _ty: match self.worker_type { WorkerType::SharedWebWorker | WorkerType::WebWorker => ChunkGroupType::Evaluated, WorkerType::NodeWorkerThread => ChunkGroupType::Entry, }, merge_tag: None, - })) + }) } } diff --git a/turbopack/scripts/analyze_cache_effectiveness.py b/turbopack/scripts/analyze_cache_effectiveness.py index dedaba213cdfc..0ca29bcbb2a34 100644 --- a/turbopack/scripts/analyze_cache_effectiveness.py +++ b/turbopack/scripts/analyze_cache_effectiveness.py @@ -6,21 +6,17 @@ significant benefit from caching and would be candidates for removing the caching layer. -To use this script, run: a build with `NEXT_TURBOPACK_TASK_STATISTICS=path/to/stats.json` set +To use this script, run a build with `NEXT_TURBOPACK_TASK_STATISTICS=path/to/stats.json` set -Then run this script with the path to the stats.json file to get a report on optimization opportunities. +Then run this script with the path to the stats.json file to get a report on cache effectiveness. -Based on benchmarking data from the `turbopack/crates/turbo-tasks-backend/benches/overhead.rs` benchmark we have the following estimates: -- Cache hit cost: 200-500ns -- Execution overhead: 4-6us -- Measurement overhead: 260ns-750ns - -This script assumes the best case scenario and reports on the potential time savings from removing the caching layer. +The JSON format contains entries like: + { "task_name": { "cache_hit": N, "cache_miss": N } } """ import json import sys -from typing import Dict, List, Tuple +from typing import List, Tuple from dataclasses import dataclass @@ -29,8 +25,6 @@ class TaskStats: name: str cache_hit: int cache_miss: int - executions: int - duration_ns: int @property def total_operations(self) -> int: @@ -42,18 +36,6 @@ def cache_hit_rate(self) -> float: return 0.0 return self.cache_hit / self.total_operations - @property - def avg_execution_time_ns(self) -> int: - MEASUREMENT_OVERHEAD = 750 # OVerhead implicit in the reported duration - if self.executions == 0: - return 0 - return max(0, (self.duration_ns - MEASUREMENT_OVERHEAD * self.executions) // self.executions) - - -def parse_duration(duration_dict: Dict) -> int: - """Convert duration dict to nanoseconds.""" - return duration_dict.get("secs", 0) * 1_000_000_000 + duration_dict.get("nanos", 0) - def load_task_stats(file_path: str) -> List[TaskStats]: """Load and parse task statistics from JSON file.""" @@ -62,127 +44,87 @@ def load_task_stats(file_path: str) -> List[TaskStats]: tasks = [] for task_name, stats in data.items(): - duration_ns = parse_duration(stats["duration"]) task = TaskStats( name=task_name, cache_hit=stats["cache_hit"], cache_miss=stats["cache_miss"], - executions=stats["executions"], - duration_ns=duration_ns ) tasks.append(task) return tasks -def calculate_cache_effectiveness(task: TaskStats) -> float: - """ - Calculate the effectiveness of caching for a task. - - Returns: - Time savings from removing caching (negative means caching is beneficial) - """ - # Constants based on benchmarking - # These are optimistic estimates - CACHE_HIT_COST_NS = 500 # Average of 200-500ns - EXECUTION_OVERHEAD_NS = 6000 # Average of 4-6us (caching layer overhead) - MEASUREMENT_OVERHEAD = 750 # OVerhead implicit in the reported duration - - if task.total_operations == 0: - return 0.0 - - # Current cost with caching - # Cache hits: just the cache lookup cost - # Cache misses: cache overhead + actual execution time - cache_hit_cost = task.cache_hit * CACHE_HIT_COST_NS - cache_miss_cost = task.cache_miss * (EXECUTION_OVERHEAD_NS + task.avg_execution_time_ns) - current_total_cost = cache_hit_cost + cache_miss_cost - - # Cost without caching (all operations would be direct executions, no overhead) - no_cache_cost = task.total_operations * task.avg_execution_time_ns - - # Time savings from removing caching (positive means we save time by removing cache) - time_savings = current_total_cost - no_cache_cost - - return time_savings - +def analyze_tasks(tasks: List[TaskStats]) -> List[TaskStats]: + """Analyze all tasks and return sorted by wasted cache overhead. -def analyze_tasks(tasks: List[TaskStats]) -> List[Tuple[TaskStats, float]]: - """Analyze all tasks and return sorted by potential time savings.""" - results = [] + Tasks with the most wasted overhead are ranked first. Wasted overhead is + estimated as cache misses (each miss pays lookup cost but gets no benefit) + plus cache hits weighted by their relative cheapness compared to a miss. - for task in tasks: - results.append((task, calculate_cache_effectiveness(task))) - - # Sort by time savings (descending - highest savings first) - results.sort(key=lambda x: x[1], reverse=True) - - return results - - -def format_time(nanoseconds: float) -> str: - """Format time in appropriate units (ns, μs, ms, s).""" - sign = "-" if nanoseconds < 0 else "" - nanoseconds = abs(nanoseconds) - if nanoseconds >= 1_000_000_000: # >= 1 second - return f"{sign}{nanoseconds / 1_000_000_000:.2f}s" - elif nanoseconds >= 1_000_000: # >= 1 millisecond - return f"{sign}{nanoseconds / 1_000_000:.2f}ms" - elif nanoseconds >= 1_000: # >= 1 microsecond - return f"{sign}{nanoseconds / 1_000:.1f}μs" - else: # nanoseconds - return f"{sign}{nanoseconds:.0f}ns" + In practice this sorts by: most cache misses first, breaking ties by lower + hit rate. + """ + # Sort by cache_miss descending, then by hit rate ascending + tasks.sort(key=lambda t: (-t.cache_miss, t.cache_hit_rate)) + return tasks -def print_analysis(results: List[Tuple[TaskStats, float]]): +def print_analysis(tasks: List[TaskStats]): """Print the analysis results.""" - print("Tasks ranked by estimated time savings from removing caching layer") + print("Tasks ranked by cache effectiveness (worst first)") print() - if not results: - print("No tasks would benefit from removing caching.") + if not tasks: + print("No tasks found.") return + # Print header - header = (f"{'Savings':<10} {'Hit Rate':<8} {'Exec Time':<10} " - f"{'Operations':<10} {'Task Name'}") + header = (f"{'Hit Rate':<10} {'Hits':<10} {'Misses':<10} " + f"{'Total':<10} {'Task Name'}") print(header) print("-" * len(header)) + total_hits = 0 + total_misses = 0 + low_hit_rate_count = 0 + # Print results - for (task, time_savings) in results: - savings_str = format_time(time_savings) + for task in tasks: hit_rate_str = f"{task.cache_hit_rate:.1%}" - exec_time_str = format_time(task.avg_execution_time_ns) - operations_str = f"{task.total_operations:,}" + hits_str = f"{task.cache_hit:,}" + misses_str = f"{task.cache_miss:,}" + total_str = f"{task.total_operations:,}" + + print(f"{hit_rate_str:<10} {hits_str:<10} {misses_str:<10} " + f"{total_str:<10} {task.name}") - print(f"{savings_str:<10} {hit_rate_str:<8} {exec_time_str:<10} " - f"{operations_str:<10} {task.name}") + total_hits += task.cache_hit + total_misses += task.cache_miss + if task.cache_hit_rate < 0.5: + low_hit_rate_count += 1 + + total_ops = total_hits + total_misses + overall_hit_rate = total_hits / total_ops if total_ops > 0 else 0.0 # Print summary - total_savings = sum(time_savings if time_savings > 0 else 0 for _, time_savings in results) - print() - print(f"Summary: {sum(1 if time_savings > 0 else 0 for _, time_savings in results)} tasks would benefit from removing caching") - print(f"Total potential savings: {format_time(total_savings)}") print() - print("Legend:") - print("- Savings: Time saved by removing caching layer") - print("- Hit Rate: Percentage of operations that were cache hits") - print("- Exec Time: Average execution time per operation") - print("- Operations: Total number of cache hits + misses") - + print(f"Total tasks: {len(tasks)}") + print(f"Total cache misses: {total_misses:,}") + print(f"Overall cache hit rate: {overall_hit_rate:.1%} ({total_hits:,} hits / {total_ops:,} total)") + print(f"Tasks with <50% hit rate: {low_hit_rate_count}") def main(): if len(sys.argv) != 2: - print("Usage: python analyze_cache_effectiveness.py ") + print("Usage: python analyze_cache_effectiveness.py ") sys.exit(1) file_path = sys.argv[1] try: tasks = load_task_stats(file_path) - results = analyze_tasks(tasks) - print_analysis(results) + tasks = analyze_tasks(tasks) + print_analysis(tasks) except FileNotFoundError: print(f"Error: File '{file_path}' not found")