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 c7a49679f3dcf..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, @@ -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) { @@ -253,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?; @@ -284,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/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-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-api/src/pages.rs b/crates/next-api/src/pages.rs index 9be7b8ced0105..faa596e05f232 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?; @@ -1510,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(), @@ -1517,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-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/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/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_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 6f8b7c7d36180..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 @@ -3,13 +3,13 @@ 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, 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_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_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/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-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/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/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/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..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 @@ -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: @@ -621,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) @@ -642,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: @@ -656,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 @@ -678,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 @@ -700,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 @@ -722,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 @@ -792,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/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/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/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-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 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/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/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/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/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/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/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/packages/next/src/build/templates/app-page.ts b/packages/next/src/build/templates/app-page.ts index 13d8590b0f338..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 @@ -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/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 3dbfc809b29b1..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') || '') @@ -163,7 +165,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/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/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/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/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]) ) 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/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/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/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/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/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/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/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 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": { 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..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 @@ -1,96 +1,64 @@ 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 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 clickInstantNavMenuItem(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]')?.click() + document.cookie = 'next-instant-navigation-testing=; path=/; max-age=0' }) } - async function clickStartClientNav(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-client]')?.click() - }) + async function clickInstantNavMenuItem(browser: Playwright) { + await browser.elementByCss('[data-instant-nav]').click() } - 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 clickStartClientNav(browser: Playwright) { + await browser.elementByCssInstant('[data-instant-nav-client]').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 getInstantNavPanelText(browser: Playwright): Promise { + return browser.elementByCssInstant('.instant-nav-panel').text() } - 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 closePanelViaHeader(browser: Playwright) { + return browser.elementByCss('#_next-devtools-panel-close').click() } - 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 hasInstantNavPanelOpen(browser: Playwright): Promise { + await browser.elementByCssInstant('.instant-nav-panel') } - 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 hasInstantNavPanelOpen(browser) + }, + 5_000, + 500 + ) + await waitForPanelRouterTransition() } it('should open panel in waiting state without setting cookie', async () => { @@ -98,17 +66,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 +88,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 +101,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 +113,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 +130,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) @@ -216,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 @@ -226,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 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"] +} 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') + }) + }) +}) 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) }) }) 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 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) + }) + }) + } + ) +}) 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) 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(()) +} 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/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/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/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/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()) } 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-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-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 596f40db3fd94..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}, @@ -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..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}, @@ -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-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-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/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/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-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/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/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..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}, @@ -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, }), 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")