diff --git a/crates/next-api/src/app.rs b/crates/next-api/src/app.rs index 093cb5a83b28e..580b4ff700a08 100644 --- a/crates/next-api/src/app.rs +++ b/crates/next-api/src/app.rs @@ -3,8 +3,9 @@ use next_core::{ all_assets_from_entries, app_segment_config::NextSegmentConfig, app_structure::{ - AppPageLoaderTree, Entrypoint as AppEntrypoint, Entrypoints as AppEntrypoints, - FileSystemPathVec, MetadataItem, get_entrypoints, + AppPageLoaderTree, CollectedRootParams, Entrypoint as AppEntrypoint, + Entrypoints as AppEntrypoints, FileSystemPathVec, MetadataItem, collect_root_params, + get_entrypoints, }, get_edge_resolve_options_context, get_next_package, next_app::{ @@ -203,6 +204,11 @@ impl AppProject { ) } + #[turbo_tasks::function] + async fn collected_root_params(self: Vc) -> Result> { + Ok(collect_root_params(self.app_entrypoints())) + } + #[turbo_tasks::function] async fn client_module_options_context(self: Vc) -> Result> { Ok(get_client_module_options_context( @@ -297,6 +303,7 @@ impl AppProject { self.project().next_mode(), self.project().next_config(), self.project().execution_context(), + Some(self.collected_root_params()), )) } @@ -308,6 +315,7 @@ impl AppProject { self.project().next_mode(), self.project().next_config(), self.project().execution_context(), + Some(self.collected_root_params()), )) } @@ -319,6 +327,7 @@ impl AppProject { self.project().next_mode(), self.project().next_config(), self.project().execution_context(), + Some(self.collected_root_params()), )) } @@ -332,6 +341,7 @@ impl AppProject { self.project().next_mode(), self.project().next_config(), self.project().execution_context(), + Some(self.collected_root_params()), )) } @@ -629,6 +639,7 @@ impl AppProject { self.project().next_mode(), self.project().next_config(), self.project().execution_context(), + None, // root params are not available in client modules )) } @@ -640,6 +651,7 @@ impl AppProject { self.project().next_mode(), self.project().next_config(), self.project().execution_context(), + None, // root params are not available in client modules )) } @@ -978,7 +990,9 @@ pub fn app_entry_point_to_route( entrypoint: AppEntrypoint, ) -> Vc { match entrypoint { - AppEntrypoint::AppPage { pages, loader_tree } => Route::AppPage( + AppEntrypoint::AppPage { + pages, loader_tree, .. + } => Route::AppPage( pages .into_iter() .map(|page| AppPageRoute { @@ -1012,6 +1026,7 @@ pub fn app_entry_point_to_route( page, path, root_layouts, + .. } => Route::AppRoute { original_name: page.to_string().into(), endpoint: ResolvedVc::upcast( @@ -1023,7 +1038,7 @@ pub fn app_entry_point_to_route( .resolved_cell(), ), }, - AppEntrypoint::AppMetadata { page, metadata } => Route::AppRoute { + AppEntrypoint::AppMetadata { page, metadata, .. } => Route::AppRoute { original_name: page.to_string().into(), endpoint: ResolvedVc::upcast( AppEndpoint { diff --git a/crates/next-api/src/pages.rs b/crates/next-api/src/pages.rs index a74ce698ef70b..58e62496a3c3a 100644 --- a/crates/next-api/src/pages.rs +++ b/crates/next-api/src/pages.rs @@ -548,6 +548,7 @@ impl PagesProject { self.project().next_mode(), self.project().next_config(), self.project().execution_context(), + None, // root params are not available in pages dir )) } @@ -564,6 +565,7 @@ impl PagesProject { self.project().next_mode(), self.project().next_config(), self.project().execution_context(), + None, // root params are not available in pages dir )) } diff --git a/crates/next-api/src/project.rs b/crates/next-api/src/project.rs index 0b0a8399e4b96..87204aca42563 100644 --- a/crates/next-api/src/project.rs +++ b/crates/next-api/src/project.rs @@ -1339,6 +1339,7 @@ impl Project { self.next_mode(), self.next_config(), self.execution_context(), + None, // root params can't be used in middleware ), Layer::new_with_user_friendly_name( rcstr!("middleware-edge"), @@ -1399,6 +1400,7 @@ impl Project { self.next_mode(), self.next_config(), self.execution_context(), + None, // root params can't be used in middleware ), Layer::new_with_user_friendly_name(rcstr!("middleware"), rcstr!("Middleware")), ))) @@ -1516,6 +1518,7 @@ impl Project { self.next_mode(), self.next_config(), self.execution_context(), + None, // root params can't be used in instrumentation ), Layer::new_with_user_friendly_name( rcstr!("instrumentation"), @@ -1577,6 +1580,7 @@ impl Project { self.next_mode(), self.next_config(), self.execution_context(), + None, // root params can't be used in instrumentation ), Layer::new_with_user_friendly_name( rcstr!("instrumentation-edge"), diff --git a/crates/next-core/src/app_structure.rs b/crates/next-core/src/app_structure.rs index fe05195e36ff7..393b96e86efb5 100644 --- a/crates/next-core/src/app_structure.rs +++ b/crates/next-core/src/app_structure.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; use tracing::Instrument; use turbo_rcstr::{RcStr, rcstr}; use turbo_tasks::{ - FxIndexMap, NonLocalValue, ResolvedVc, TaskInput, TryJoinIterExt, ValueDefault, Vc, + FxIndexMap, FxIndexSet, NonLocalValue, ResolvedVc, TaskInput, TryJoinIterExt, ValueDefault, Vc, debug::ValueDebugFormat, fxindexmap, trace::TraceRawVcs, }; use turbo_tasks_fs::{DirectoryContent, DirectoryEntry, FileSystemEntryType, FileSystemPath}; @@ -480,6 +480,18 @@ impl AppPageLoaderTree { } } +#[turbo_tasks::value(transparent)] +#[derive(Default)] +pub struct RootParamVecOption(Option>); + +#[turbo_tasks::value_impl] +impl ValueDefault for RootParamVecOption { + #[turbo_tasks::function] + fn value_default() -> Vc { + Vc::cell(Default::default()) + } +} + #[turbo_tasks::value(transparent)] pub struct FileSystemPathVec(Vec); @@ -508,15 +520,18 @@ pub enum Entrypoint { AppPage { pages: Vec, loader_tree: ResolvedVc, + root_params: ResolvedVc, }, AppRoute { page: AppPage, path: FileSystemPath, root_layouts: ResolvedVc, + root_params: ResolvedVc, }, AppMetadata { page: AppPage, metadata: MetadataItem, + root_params: ResolvedVc, }, } @@ -528,6 +543,13 @@ impl Entrypoint { Entrypoint::AppMetadata { page, .. } => page, } } + pub fn root_params(&self) -> ResolvedVc { + match self { + Entrypoint::AppPage { root_params, .. } => *root_params, + Entrypoint::AppRoute { root_params, .. } => *root_params, + Entrypoint::AppMetadata { root_params, .. } => *root_params, + } + } } #[turbo_tasks::value(transparent)] @@ -581,6 +603,7 @@ fn add_app_page( result: &mut FxIndexMap, page: AppPage, loader_tree: ResolvedVc, + root_params: ResolvedVc, ) { let mut e = match result.entry(page.clone().into()) { Entry::Occupied(e) => e, @@ -588,6 +611,7 @@ fn add_app_page( e.insert(Entrypoint::AppPage { pages: vec![page], loader_tree, + root_params, }); return; } @@ -602,6 +626,7 @@ fn add_app_page( Entrypoint::AppPage { pages: existing_pages, loader_tree: existing_loader_tree, + .. } => { // loader trees should always match for the same path as they are generated by a // turbo tasks function @@ -641,6 +666,7 @@ fn add_app_route( page: AppPage, path: FileSystemPath, root_layouts: ResolvedVc, + root_params: ResolvedVc, ) { let e = match result.entry(page.clone().into()) { Entry::Occupied(e) => e, @@ -649,6 +675,7 @@ fn add_app_route( page, path, root_layouts, + root_params, }); return; } @@ -683,11 +710,16 @@ fn add_app_metadata_route( result: &mut FxIndexMap, page: AppPage, metadata: MetadataItem, + root_params: ResolvedVc, ) { let e = match result.entry(page.clone().into()) { Entry::Occupied(e) => e, Entry::Vacant(e) => { - e.insert(Entrypoint::AppMetadata { page, metadata }); + e.insert(Entrypoint::AppMetadata { + page, + metadata, + root_params, + }); return; } }; @@ -728,9 +760,26 @@ pub fn get_entrypoints( get_global_metadata(app_dir, page_extensions), is_global_not_found_enabled, Default::default(), + Default::default(), ) } +#[turbo_tasks::value(transparent)] +pub struct CollectedRootParams(FxIndexSet); + +#[turbo_tasks::function] +pub async fn collect_root_params( + entrypoints: ResolvedVc, +) -> Result> { + let mut collected_root_params = FxIndexSet::::default(); + for (_, entrypoint) in entrypoints.await?.iter() { + if let Some(ref root_params) = *entrypoint.root_params().await? { + collected_root_params.extend(root_params.iter().cloned()); + } + } + Ok(Vc::cell(collected_root_params)) +} + #[turbo_tasks::function] fn directory_tree_to_entrypoints( app_dir: FileSystemPath, @@ -738,6 +787,7 @@ fn directory_tree_to_entrypoints( global_metadata: Vc, is_global_not_found_enabled: Vc, root_layouts: Vc, + root_params: Vc, ) -> Vc { directory_tree_to_entrypoints_internal( app_dir, @@ -747,6 +797,7 @@ fn directory_tree_to_entrypoints( directory_tree, AppPage::new(), root_layouts, + root_params, ) } @@ -1166,6 +1217,7 @@ async fn directory_tree_to_entrypoints_internal( directory_tree: Vc, app_page: AppPage, root_layouts: ResolvedVc, + root_params: ResolvedVc, ) -> Result> { let span = tracing::info_span!("build layout trees", name = display(&app_page)); directory_tree_to_entrypoints_internal_untraced( @@ -1176,6 +1228,7 @@ async fn directory_tree_to_entrypoints_internal( directory_tree, app_page, root_layouts, + root_params, ) .instrument(span) .await @@ -1189,6 +1242,7 @@ async fn directory_tree_to_entrypoints_internal_untraced( directory_tree: Vc, app_page: AppPage, root_layouts: ResolvedVc, + root_params: ResolvedVc, ) -> Result> { let mut result = FxIndexMap::default(); @@ -1208,6 +1262,26 @@ async fn directory_tree_to_entrypoints_internal_untraced( root_layouts }; + // TODO: `root_layouts` is a misnomer, they're just parent layouts + let root_params = if root_params.await?.is_none() && (*root_layouts.await?).len() == 1 { + // found a root layout. the params up-to-and-including this point are the root params + // for all child segments + ResolvedVc::cell(Some( + app_page + .0 + .iter() + .filter_map(|segment| match segment { + PageSegment::Dynamic(param) + | PageSegment::CatchAll(param) + | PageSegment::OptionalCatchAll(param) => Some(param.clone()), + _ => None, + }) + .collect::>(), + )) + } else { + root_params + }; + if modules.page.is_some() { let app_path = AppPath::from(app_page.clone()); @@ -1226,6 +1300,7 @@ async fn directory_tree_to_entrypoints_internal_untraced( &mut result, app_page.complete(PageType::Page)?, loader_tree.context("loader tree should be created for a page/default")?, + root_params, ); } @@ -1236,6 +1311,7 @@ async fn directory_tree_to_entrypoints_internal_untraced( app_page.complete(PageType::Route)?, route.clone(), root_layouts, + root_params, ); } @@ -1263,6 +1339,7 @@ async fn directory_tree_to_entrypoints_internal_untraced( &mut result, normalize_metadata_route(app_page)?, meta, + root_params, ); } @@ -1283,6 +1360,7 @@ async fn directory_tree_to_entrypoints_internal_untraced( &mut result, normalize_metadata_route(app_page)?, meta.clone(), + root_params, ); } @@ -1398,7 +1476,13 @@ async fn directory_tree_to_entrypoints_internal_untraced( .clone_push_str("_not-found")? .complete(PageType::Page)?; - add_app_page(app_dir.clone(), &mut result, app_page, not_found_tree); + add_app_page( + app_dir.clone(), + &mut result, + app_page, + not_found_tree, + root_params, + ); } } @@ -1428,6 +1512,7 @@ async fn directory_tree_to_entrypoints_internal_untraced( *subdirectory, child_app_page.clone(), *root_layouts, + *root_params, ) .await?; @@ -1440,11 +1525,7 @@ async fn directory_tree_to_entrypoints_internal_untraced( let mut loader_trees = Vec::new(); for (_, entrypoint) in map.iter() { - if let Entrypoint::AppPage { - ref pages, - loader_tree: _, - } = *entrypoint - { + if let Entrypoint::AppPage { ref pages, .. } = *entrypoint { for page in pages { let app_path = AppPath::from(page.clone()); @@ -1473,6 +1554,7 @@ async fn directory_tree_to_entrypoints_internal_untraced( Entrypoint::AppPage { pages, loader_tree: _, + root_params, } => { for page in pages { let loader_tree = *loader_trees[i].await?; @@ -1484,6 +1566,7 @@ async fn directory_tree_to_entrypoints_internal_untraced( page.clone(), loader_tree .context("loader tree should be created for a page/default")?, + *root_params, ); } } @@ -1491,6 +1574,7 @@ async fn directory_tree_to_entrypoints_internal_untraced( page, path, root_layouts, + root_params, } => { add_app_route( app_dir.clone(), @@ -1498,14 +1582,20 @@ async fn directory_tree_to_entrypoints_internal_untraced( page.clone(), path.clone(), *root_layouts, + *root_params, ); } - Entrypoint::AppMetadata { page, metadata } => { + Entrypoint::AppMetadata { + page, + metadata, + root_params, + } => { add_app_metadata_route( app_dir.clone(), &mut result, page.clone(), metadata.clone(), + *root_params, ); } } diff --git a/crates/next-core/src/lib.rs b/crates/next-core/src/lib.rs index f8bdaa2230777..858744164de44 100644 --- a/crates/next-core/src/lib.rs +++ b/crates/next-core/src/lib.rs @@ -27,6 +27,7 @@ mod next_image; mod next_import_map; pub mod next_manifests; pub mod next_pages; +mod next_root_params; mod next_route_matcher; pub mod next_server; pub mod next_server_component; diff --git a/crates/next-core/src/next_config.rs b/crates/next-core/src/next_config.rs index 143684adf1d8b..9178b505531a8 100644 --- a/crates/next-core/src/next_config.rs +++ b/crates/next-core/src/next_config.rs @@ -735,6 +735,7 @@ pub struct ExperimentalConfig { react_compiler: Option, cache_components: Option, use_cache: Option, + root_params: Option, // --- // UNSUPPORTED // --- @@ -1605,6 +1606,16 @@ impl NextConfig { ) } + #[turbo_tasks::function] + pub fn enable_root_params(&self) -> Vc { + Vc::cell( + self.experimental + .root_params + // rootParams should be enabled implicitly in cacheComponents. + .unwrap_or(self.experimental.cache_components.unwrap_or(false)), + ) + } + #[turbo_tasks::function] pub fn cache_kinds(&self) -> Vc { let mut cache_kinds = CacheKinds::default(); diff --git a/crates/next-core/src/next_edge/context.rs b/crates/next-core/src/next_edge/context.rs index 8c884d71fd25e..87f7580fa1b4b 100644 --- a/crates/next-core/src/next_edge/context.rs +++ b/crates/next-core/src/next_edge/context.rs @@ -19,6 +19,7 @@ use turbopack_ecmascript::chunk::EcmascriptChunkType; use turbopack_node::execution_context::ExecutionContext; use crate::{ + app_structure::CollectedRootParams, mode::NextMode, next_config::NextConfig, next_font::local::NextFontLocalResolvePlugin, @@ -84,6 +85,7 @@ pub async fn get_edge_resolve_options_context( mode: Vc, next_config: Vc, execution_context: Vc, + collected_root_params: Option>, ) -> Result> { let next_edge_import_map = get_next_edge_import_map( project_path.clone(), @@ -91,6 +93,7 @@ pub async fn get_edge_resolve_options_context( next_config, mode, execution_context, + collected_root_params, ) .to_resolved() .await?; diff --git a/crates/next-core/src/next_import_map.rs b/crates/next-core/src/next_import_map.rs index 63178258f988d..a448fb2c9c6f6 100644 --- a/crates/next-core/src/next_import_map.rs +++ b/crates/next-core/src/next_import_map.rs @@ -1,6 +1,7 @@ use std::collections::BTreeMap; use anyhow::{Context, Result}; +use either::Either; use rustc_hash::FxHashMap; use turbo_rcstr::{RcStr, rcstr}; use turbo_tasks::{FxIndexMap, ResolvedVc, Vc, fxindexmap}; @@ -20,6 +21,7 @@ use turbopack_core::{ use turbopack_node::execution_context::ExecutionContext; use crate::{ + app_structure::CollectedRootParams, embed_js::{VIRTUAL_PACKAGE_NAME, next_js_fs}, mode::NextMode, next_client::context::ClientContextType, @@ -29,6 +31,7 @@ use crate::{ GOOGLE_FONTS_INTERNAL_PREFIX, NextFontGoogleCssModuleReplacer, NextFontGoogleFontFileReplacer, NextFontGoogleReplacer, }, + next_root_params::insert_next_root_params_mapping, next_server::context::ServerContextType, util::NextRuntime, }; @@ -238,6 +241,13 @@ pub async fn get_next_client_import_map( "next/dist/compiled/client-only" => "next/dist/compiled/client-only/index".to_string(), }, ); + insert_next_root_params_mapping( + &mut import_map, + next_config.enable_root_params(), + Either::Right(ty.clone()), + None, + ) + .await?; match ty { ClientContextType::Pages { .. } @@ -296,6 +306,7 @@ pub async fn get_next_server_import_map( next_config: Vc, next_mode: Vc, execution_context: Vc, + collected_root_params: Option>, ) -> Result> { let mut import_map = ImportMap::empty(); @@ -380,6 +391,7 @@ pub async fn get_next_server_import_map( ty, NextRuntime::NodeJs, next_config, + collected_root_params, ) .await?; @@ -394,6 +406,7 @@ pub async fn get_next_edge_import_map( next_config: Vc, next_mode: Vc, execution_context: Vc, + collected_root_params: Option>, ) -> Result> { let mut import_map = ImportMap::empty(); @@ -495,6 +508,7 @@ pub async fn get_next_edge_import_map( ty.clone(), NextRuntime::Edge, next_config, + collected_root_params, ) .await?; @@ -603,6 +617,7 @@ async fn insert_next_server_special_aliases( ty: ServerContextType, runtime: NextRuntime, next_config: Vc, + collected_root_params: Option>, ) -> Result<()> { let external_cjs_if_node = move |context_dir: FileSystemPath, request: &str| match runtime { NextRuntime::Edge => request_to_import_mapping(context_dir, request), @@ -717,6 +732,14 @@ async fn insert_next_server_special_aliases( } } + insert_next_root_params_mapping( + import_map, + next_config.enable_root_params(), + Either::Left(ty), + collected_root_params, + ) + .await?; + import_map.insert_exact_alias( "@vercel/og", external_cjs_if_node(project_path.clone(), "next/dist/server/og/image-response"), diff --git a/crates/next-core/src/next_root_params/mod.rs b/crates/next-core/src/next_root_params/mod.rs new file mode 100644 index 0000000000000..58bfcf34200e2 --- /dev/null +++ b/crates/next-core/src/next_root_params/mod.rs @@ -0,0 +1,244 @@ +use std::iter; + +use anyhow::{Result, anyhow}; +use either::Either; +use indoc::formatdoc; +use itertools::Itertools; +use turbo_rcstr::RcStr; +use turbo_tasks::{ResolvedVc, Vc}; +use turbo_tasks_fs::{FileContent, FileSystemPath}; +use turbopack_core::{ + asset::AssetContent, + issue::IssueExt, + resolve::{ + ResolveResult, + options::{ + ImportMap, ImportMapResult, ImportMapping, ImportMappingReplacement, + ReplacedImportMapping, + }, + parse::Request, + pattern::Pattern, + }, + virtual_source::VirtualSource, +}; + +use crate::{ + app_structure::CollectedRootParams, embed_js::next_js_file_path, + next_client::ClientContextType, next_server::ServerContextType, + next_shared::resolve::InvalidImportModuleIssue, +}; + +pub async fn insert_next_root_params_mapping( + import_map: &mut ImportMap, + is_root_params_enabled: Vc, + ty: Either, + collected_root_params: Option>, +) -> Result<()> { + import_map.insert_exact_alias( + "next/root-params", + get_next_root_params_mapping(is_root_params_enabled, ty, collected_root_params) + .to_resolved() + .await?, + ); + Ok(()) +} + +#[turbo_tasks::function] +async fn get_next_root_params_mapping( + is_root_params_enabled: Vc, + ty: Either, + collected_root_params: Option>, +) -> Result> { + // This mapping goes into the global resolve options, so we want to avoid invalidating it if + // value of `collected_root_params` changes (which would invalidate everything else compiled + // using those resolve options!). + // We can achieve this by using a dynamic import mapping + // which only reads `collected_root_params` when producing a mapping result. That way, if + // `collected_root_params` changes, the resolve options will remain the same, and + // only the mapping result will be invalidated. + let mapping = ImportMapping::Dynamic(ResolvedVc::upcast( + NextRootParamsMapper::new(is_root_params_enabled, ty, collected_root_params) + .to_resolved() + .await?, + )); + Ok(mapping.cell()) +} + +#[turbo_tasks::value] +struct NextRootParamsMapper { + is_root_params_enabled: ResolvedVc, + context_type: Either, + collected_root_params: Option>, +} + +#[turbo_tasks::value_impl] +impl NextRootParamsMapper { + #[turbo_tasks::function] + pub fn new( + is_root_params_enabled: ResolvedVc, + context_type: Either, + collected_root_params: Option>, + ) -> Vc { + NextRootParamsMapper { + is_root_params_enabled, + context_type, + collected_root_params, + } + .cell() + } + + #[turbo_tasks::function] + async fn import_map_result(self: Vc) -> Result> { + let this = self.await?; + Ok({ + if !(*this.is_root_params_enabled.await?) { + Self::invalid_import_map_result( + "'next/root-params' can only be imported when `experimental.rootParams` is \ + enabled." + .into(), + ) + } else { + match &this.context_type { + Either::Left(server_ty) => match &server_ty { + ServerContextType::AppRSC { .. } | ServerContextType::AppRoute { .. } => { + let collected_root_params = + *this.collected_root_params.ok_or_else(|| { + anyhow!( + "Invariant: Root params should have been collected for \ + context {:?}. This is a bug in Next.js.", + server_ty.clone() + ) + })?; + Self::valid_import_map_result(collected_root_params) + } + ServerContextType::PagesData { .. } + | ServerContextType::PagesApi { .. } + | ServerContextType::Instrumentation { .. } + | ServerContextType::Middleware { .. } => { + // There's no sensible way to use root params outside of the app + // directory. TODO: make sure this error is + // consistent with webpack + Self::invalid_import_map_result( + "'next/root-params' can only be used inside the App Directory." + .into(), + ) + } + _ => { + // In general, the compiler should prevent importing 'next/root-params' + // from client modules, but it doesn't catch + // everything. If an import slips through + // our validation, make it error. + Self::invalid_import_map_result( + "'next/root-params' cannot be imported from a Client Component \ + module. It should only be used from a Server Component." + .into(), + ) + } + }, + Either::Right(_) => { + // In general, the compiler should prevent importing 'next/root-params' from + // client modules, but it doesn't catch everything. If an + // import slips through our validation, make it error. + Self::invalid_import_map_result( + "'next/root-params' cannot be imported from a Client Component \ + module. It should only be used from a Server Component." + .into(), + ) + } + } + } + }) + } + + #[turbo_tasks::function] + async fn valid_import_map_result( + collected_root_params: ResolvedVc, + ) -> Result> { + let collected_root_params = collected_root_params.await?; + + // Generate a virtual 'next/root-params' module based on the root params we collected. + let module_content = + // If there's no root params, export nothing. + if collected_root_params.is_empty() { + "export {}".to_string() + } else { + iter::once(formatdoc!( + r#" + import {{ getRootParam }} from 'next/dist/server/request/root-params'; + "#, + )) + .chain(collected_root_params.iter().map(|param_name| { + formatdoc!( + r#" + export function {PARAM_NAME}() {{ + return getRootParam('{PARAM_NAME}'); + }} + "#, + PARAM_NAME = param_name, + ) + })) + .join("\n") + }; + + let virtual_source = VirtualSource::new( + next_js_file_path("root-params.js".into()).owned().await?, + AssetContent::file(FileContent::Content(module_content.into()).cell()), + ) + .to_resolved() + .await?; + + let import_map_result = + ImportMapResult::Result(ResolveResult::source(ResolvedVc::upcast(virtual_source))); + Ok(import_map_result.cell()) + } + + #[turbo_tasks::function] + async fn invalid_import_map_result(message: RcStr) -> Result> { + let path: FileSystemPath = next_js_file_path("root-params.js".into()).owned().await?; + + // error the compilation. + InvalidImportModuleIssue { + file_path: path.clone(), + messages: vec![message.clone()], + skip_context_message: false, + } + .resolved_cell() + .emit(); + + // map to a dummy module that rethrows the error at runtime. + let virtual_source = VirtualSource::new( + path.clone(), + AssetContent::file( + FileContent::Content( + format!("throw new Error({})", serde_json::to_string(&message)?).into(), + ) + .cell(), + ), + ) + .to_resolved() + .await?; + + let import_map_result = + ImportMapResult::Result(ResolveResult::source(ResolvedVc::upcast(virtual_source))); + Ok(import_map_result.cell()) + } +} + +#[turbo_tasks::value_impl] +impl ImportMappingReplacement for NextRootParamsMapper { + #[turbo_tasks::function] + fn replace(&self, _capture: Vc) -> Vc { + ReplacedImportMapping::Ignore.cell() + } + + #[turbo_tasks::function] + async fn result( + self: Vc, + _lookup_path: FileSystemPath, + _request: Vc, + ) -> Vc { + // Delegate to an inner function that only depends on `self` -- + // we want to return the same cell regardless of the arguments we received here. + self.import_map_result() + } +} diff --git a/crates/next-core/src/next_server/context.rs b/crates/next-core/src/next_server/context.rs index 4a3b58642a0cf..cdbd39e29d985 100644 --- a/crates/next-core/src/next_server/context.rs +++ b/crates/next-core/src/next_server/context.rs @@ -42,6 +42,7 @@ use super::{ transforms::{get_next_server_internal_transforms_rules, get_next_server_transforms_rules}, }; use crate::{ + app_structure::CollectedRootParams, mode::NextMode, next_build::get_postcss_package_mapping, next_client::RuntimeEntries, @@ -129,6 +130,7 @@ pub async fn get_server_resolve_options_context( mode: Vc, next_config: Vc, execution_context: Vc, + collected_root_params: Option>, ) -> Result> { let next_server_import_map = get_next_server_import_map( project_path.clone(), @@ -136,6 +138,7 @@ pub async fn get_server_resolve_options_context( next_config, mode, execution_context, + collected_root_params, ) .to_resolved() .await?; diff --git a/crates/next-custom-transforms/src/transforms/react_server_components.rs b/crates/next-custom-transforms/src/transforms/react_server_components.rs index b692d5e077fbd..21ff8110faf13 100644 --- a/crates/next-custom-transforms/src/transforms/react_server_components.rs +++ b/crates/next-custom-transforms/src/transforms/react_server_components.rs @@ -637,7 +637,11 @@ impl ReactServerComponentValidator { Atom::from("next/router"), ], - invalid_client_imports: vec![Atom::from("server-only"), Atom::from("next/headers")], + invalid_client_imports: vec![ + Atom::from("server-only"), + Atom::from("next/headers"), + Atom::from("next/root-params"), + ], invalid_client_lib_apis_mapping: FxHashMap::from_iter([ ("next/server", vec!["after", "unstable_rootParams"]), diff --git a/packages/next/errors.json b/packages/next/errors.json index eb85dec68b4c0..ed3dbad606597 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -757,5 +757,12 @@ "756": "Route %s not found", "757": "Unknown styled string type: %s", "758": "Missing workStore in useDynamicRouteParams", - "759": "Invariant: missing __PAGE__ segmentPath" + "759": "Invariant: missing __PAGE__ segmentPath", + "760": "Route %s used %s inside \\`\"use cache\"\\` or \\`unstable_cache\\`. Support for this API inside cache scopes is planned for a future version of Next.js.", + "761": "%s must not be used within a client component. Next.js should be preventing it from being included in client components statically, but did not in this case.", + "762": "Route %s used %s in Pages Router. This API is only available within App Router.", + "763": "\\`unstable_rootParams\\` must not be used within a client component. Next.js should be preventing it from being included in client components statically, but did not in this case.", + "764": "Missing workStore in %s", + "765": "Route %s used %s inside a Route Handler. Support for this API in Route Handlers is planned for a future version of Next.js.", + "766": "%s was used inside a Server Action. This is not supported. Functions from 'next/root-params' can only be called in the context of a route." } diff --git a/packages/next/package.json b/packages/next/package.json index 6324885780cd3..9127e21e82849 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -51,6 +51,8 @@ "amp.d.ts", "og.js", "og.d.ts", + "root-params.js", + "root-params.d.ts", "types.d.ts", "types.js", "index.d.ts", diff --git a/packages/next/root-params.d.ts b/packages/next/root-params.d.ts new file mode 100644 index 0000000000000..3290a4e85c23a --- /dev/null +++ b/packages/next/root-params.d.ts @@ -0,0 +1,2 @@ +// The actual types need to be generated. Avoid type errors for non-existent exports before that happens. +declare module 'next/root-params' diff --git a/packages/next/root-params.js b/packages/next/root-params.js new file mode 100644 index 0000000000000..fc818a4980b40 --- /dev/null +++ b/packages/next/root-params.js @@ -0,0 +1,3 @@ +throw new Error( + "This module is a placeholder for 'next/root-params' and should be replaced by the compiler. This is a bug in Next.js." +) diff --git a/packages/next/src/build/webpack-config.ts b/packages/next/src/build/webpack-config.ts index d33f4029b86c2..a570bc51dc2c5 100644 --- a/packages/next/src/build/webpack-config.ts +++ b/packages/next/src/build/webpack-config.ts @@ -98,6 +98,8 @@ import { getRspackCore, getRspackReactRefresh } from '../shared/lib/get-rspack' import { RspackProfilingPlugin } from './webpack/plugins/rspack-profiling-plugin' import getWebpackBundler from '../shared/lib/get-webpack-bundler' import type { NextBuildContext } from './build-context' +import type { RootParamsLoaderOpts } from './webpack/loaders/next-root-params-loader' +import type { InvalidImportLoaderOpts } from './webpack/loaders/next-invalid-import-error-loader' type ExcludesFalse = (x: T | false) => x is T type ClientEntries = { @@ -1364,6 +1366,7 @@ export default async function getBaseWebpackConfig( 'modularize-import-loader', 'next-barrel-loader', 'next-error-browser-binary-loader', + 'next-root-params-loader', ].reduce( (alias, loader) => { // using multiple aliases to replace `resolveLoader.modules` @@ -1543,6 +1546,18 @@ export default async function getBaseWebpackConfig( }, ] : []), + + ...getNextRootParamsRules({ + isRootParamsEnabled: + config.experimental.rootParams ?? + // `experimental.dynamicIO` implies `experimental.rootParams`. + config.experimental.cacheComponents ?? + false, + isClient, + appDir, + pageExtensions, + }), + // TODO: FIXME: do NOT webpack 5 support with this // x-ref: https://github.com/webpack/webpack/issues/11467 ...(!config.experimental.fullySpecified @@ -2754,3 +2769,78 @@ export default async function getBaseWebpackConfig( return webpackConfig } + +function getNextRootParamsRules({ + isRootParamsEnabled, + isClient, + appDir, + pageExtensions, +}: { + isRootParamsEnabled: boolean + isClient: boolean + appDir: string | undefined + pageExtensions: string[] +}): webpack.RuleSetRule[] { + // Match resolved import of 'next/root-params' + const nextRootParamsModule = path.join(NEXT_PROJECT_ROOT, 'root-params.js') + + const createInvalidImportRule = (message: string) => { + return { + resource: nextRootParamsModule, + loader: 'next-invalid-import-error-loader', + options: { + message, + } satisfies InvalidImportLoaderOpts, + } satisfies webpack.RuleSetRule + } + + // Hard-error if the flag is not enabled, regardless of if we're on the server or on the client. + if (!isRootParamsEnabled) { + return [ + createInvalidImportRule( + "'next/root-params' can only be imported when `experimental.rootParams` is enabled." + ), + ] + } + + // If there's no app-dir (and thus no layouts), there's no sensible way to use 'next/root-params', + // because we wouldn't generate any getters. + if (!appDir) { + return [ + createInvalidImportRule( + "'next/root-params' can only be used with the App Directory." + ), + ] + } + + // In general, the compiler should prevent importing 'next/root-params' from client modules, but it doesn't catch everything. + // If an import slips through our validation, make it error. + const invalidClientImportRule = createInvalidImportRule( + "'next/root-params' cannot be imported from a Client Component module. It should only be used from a Server Component." + ) + + // in the browser compilation we can skip the server rules, because we know all imports will be invalid. + if (isClient) { + return [invalidClientImportRule] + } + + return [ + { + oneOf: [ + { + resource: nextRootParamsModule, + issuerLayer: shouldUseReactServerCondition as ( + layer: string + ) => boolean, + loader: 'next-root-params-loader', + options: { + appDir, + pageExtensions, + } satisfies RootParamsLoaderOpts, + }, + // if the rule above didn't match, we're in the SSR layer (or something else that isn't server-only). + invalidClientImportRule, + ], + }, + ] +} diff --git a/packages/next/src/build/webpack/loaders/next-invalid-import-error-loader.ts b/packages/next/src/build/webpack/loaders/next-invalid-import-error-loader.ts index 7ba4034a9ad83..0d5d76e983a2c 100644 --- a/packages/next/src/build/webpack/loaders/next-invalid-import-error-loader.ts +++ b/packages/next/src/build/webpack/loaders/next-invalid-import-error-loader.ts @@ -1,4 +1,11 @@ -export default function nextInvalidImportErrorLoader(this: any) { - const { message } = this.getOptions() - throw new Error(message) -} +import type webpack from 'webpack' + +export type InvalidImportLoaderOpts = { message: string } + +const nextInvalidImportErrorLoader: webpack.LoaderDefinitionFunction = + function () { + const { message } = this.getOptions() + throw new Error(message) + } + +export default nextInvalidImportErrorLoader diff --git a/packages/next/src/build/webpack/loaders/next-root-params-loader.ts b/packages/next/src/build/webpack/loaders/next-root-params-loader.ts new file mode 100644 index 0000000000000..dc2c820b6dc4d --- /dev/null +++ b/packages/next/src/build/webpack/loaders/next-root-params-loader.ts @@ -0,0 +1,166 @@ +import type { webpack } from 'next/dist/compiled/webpack/webpack' +import * as path from 'node:path' +import * as fs from 'node:fs/promises' +import { normalizeAppPath } from '../../../shared/lib/router/utils/app-paths' +import { ensureLeadingSlash } from '../../../shared/lib/page-path/ensure-leading-slash' +import { getSegmentParam } from '../../../server/app-render/get-segment-param' + +export type RootParamsLoaderOpts = { + appDir: string + pageExtensions: string[] +} + +type CollectedRootParams = Set + +const rootParamsLoader: webpack.LoaderDefinitionFunction = + async function () { + const { appDir, pageExtensions } = this.getOptions() + + const allRootParams = await collectRootParamsFromFileSystem({ + appDir, + pageExtensions, + }) + // invalidate the result whenever a file/directory is added/removed inside the app dir or its subdirectories, + // because that might mean that a root layout has been moved. + this.addContextDependency(appDir) + + // If there's no root params, there's nothing to generate. + if (allRootParams.size === 0) { + return 'export {}' + } + + // Generate a getter for each root param we found. + const sortedRootParamNames = Array.from(allRootParams).sort() + const content = [ + `import { getRootParam } from 'next/dist/server/request/root-params';`, + ...sortedRootParamNames.map((paramName) => { + return `export function ${paramName}() { return getRootParam('${paramName}'); }` + }), + ].join('\n') + + return content + } + +export default rootParamsLoader + +async function collectRootParamsFromFileSystem( + opts: Parameters[0] +) { + return collectRootParams({ + appDir: opts.appDir, + rootLayoutFilePaths: await findRootLayouts(opts), + }) +} + +function collectRootParams({ + rootLayoutFilePaths, + appDir, +}: { + rootLayoutFilePaths: string[] + appDir: string +}): CollectedRootParams { + const allRootParams: CollectedRootParams = new Set() + + for (const rootLayoutFilePath of rootLayoutFilePaths) { + const params = getParamsFromLayoutFilePath({ + appDir, + layoutFilePath: rootLayoutFilePath, + }) + for (const param of params) { + allRootParams.add(param) + } + } + + return allRootParams +} + +async function findRootLayouts({ + appDir, + pageExtensions, +}: { + appDir: string + pageExtensions: string[] +}) { + const layoutFilenameRegex = new RegExp( + `^layout\\.(?:${pageExtensions.join('|')})$` + ) + + async function visit(directory: string): Promise { + let dir: Awaited> + try { + dir = await fs.readdir(directory, { withFileTypes: true }) + } catch (err) { + // If the directory was removed before we managed to read it, just ignore it. + if ( + err && + typeof err === 'object' && + 'code' in err && + err.code === 'ENOENT' + ) { + return [] + } + + throw err + } + + const subdirectories: string[] = [] + for (const entry of dir) { + if (entry.isDirectory()) { + // Directories that start with an underscore are excluded from routing, so we shouldn't look for layouts inside. + if (entry.name[0] === '_') { + continue + } + // Parallel routes cannot occur above a layout, so they can't contain a root layout. + if (entry.name[0] === '@') { + continue + } + + const absolutePathname = path.join(directory, entry.name) + subdirectories.push(absolutePathname) + } else if (entry.isFile()) { + if (layoutFilenameRegex.test(entry.name)) { + // We found a root layout, so we're not going to recurse into subdirectories, + // meaning that we can skip the rest of the entries. + // Note that we don't need to track any of the subdirectories as dependencies -- + // changes in the subdirectories will only become relevant if this root layout is (re)moved, + // in which case the loader will re-run, traverse deeper (because it no longer stops at this root layout) + // and then track those directories as needed. + const rootLayoutPath = path.join(directory, entry.name) + return [rootLayoutPath] + } + } + } + + if (subdirectories.length === 0) { + return [] + } + + const subdirectoryRootLayouts = await Promise.all( + subdirectories.map((subdirectory) => visit(subdirectory)) + ) + return subdirectoryRootLayouts.flat(1) + } + + return visit(appDir) +} + +function getParamsFromLayoutFilePath({ + appDir, + layoutFilePath, +}: { + appDir: string + layoutFilePath: string +}): string[] { + const rootLayoutPath = normalizeAppPath( + ensureLeadingSlash(path.dirname(path.relative(appDir, layoutFilePath))) + ) + const segments = rootLayoutPath.split('/') + const paramNames: string[] = [] + for (const segment of segments) { + const param = getSegmentParam(segment) + if (param !== null) { + paramNames.push(param.param) + } + } + return paramNames +} diff --git a/packages/next/src/server/config-schema.ts b/packages/next/src/server/config-schema.ts index 6a559c2f41b40..ad16ebf69c4ee 100644 --- a/packages/next/src/server/config-schema.ts +++ b/packages/next/src/server/config-schema.ts @@ -404,6 +404,7 @@ export const configSchema: zod.ZodType = z.lazy(() => taint: z.boolean().optional(), prerenderEarlyExit: z.boolean().optional(), proxyTimeout: z.number().gte(0).optional(), + rootParams: z.boolean().optional(), routerBFCache: z.boolean().optional(), removeUncaughtErrorAndRejectionListeners: z.boolean().optional(), validateRSCRequestHeaders: z.boolean().optional(), diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index 2f66665c1add6..0eee820fa0522 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -775,6 +775,11 @@ export interface ExperimentalConfig { * @default false */ optimizeRouterScrolling?: boolean + + /** + * Enable accessing root params via the `next/root-params` module. + */ + rootParams?: boolean } export type ExportPathMap = { diff --git a/packages/next/src/server/request/root-params.ts b/packages/next/src/server/request/root-params.ts index 9a083c56b3657..207f299b54852 100644 --- a/packages/next/src/server/request/root-params.ts +++ b/packages/next/src/server/request/root-params.ts @@ -15,11 +15,12 @@ import { } from '../app-render/work-unit-async-storage.external' import { makeHangingPromise } from '../dynamic-rendering-utils' import type { FallbackRouteParams } from './fallback-params' -import type { Params } from './params' +import type { Params, ParamValue } from './params' import { describeStringPropertyAccess, wellKnownProperties, } from '../../shared/lib/utils/reflect-utils' +import { actionAsyncStorage } from '../app-render/action-async-storage.external' interface CacheLifetime {} const CachedParams = new WeakMap>() @@ -187,3 +188,166 @@ function makeErroringRootParams( return promise } + +/** + * Used for the compiler-generated `next/root-params` module. + * @internal + */ +export function getRootParam(paramName: string): Promise { + const apiName = `\`import('next/root-params').${paramName}()\`` + + const workStore = workAsyncStorage.getStore() + if (!workStore) { + throw new InvariantError(`Missing workStore in ${apiName}`) + } + + const actionStore = actionAsyncStorage.getStore() + if (actionStore) { + if (actionStore.isAppRoute) { + // TODO(root-params): add support for route handlers + throw new Error( + `Route ${workStore.route} used ${apiName} inside a Route Handler. Support for this API in Route Handlers is planned for a future version of Next.js.` + ) + } + if (actionStore.isAction) { + // Actions are not fundamentally tied to a route (even if they're always submitted from some page), + // so root params would be inconsistent if an action is called from multiple roots. + throw new Error( + `${apiName} was used inside a Server Action. This is not supported. Functions from 'next/root-params' can only be called in the context of a route.` + ) + } + } + + const workUnitStore = workUnitAsyncStorage.getStore() + + if (!workUnitStore) { + throw new Error( + `Route ${workStore.route} used ${apiName} in Pages Router. This API is only available within App Router.` + ) + } + + switch (workUnitStore.type) { + case 'unstable-cache': + case 'private-cache': + case 'cache': { + throw new Error( + `Route ${workStore.route} used ${apiName} inside \`"use cache"\` or \`unstable_cache\`. Support for this API inside cache scopes is planned for a future version of Next.js.` + ) + } + case 'prerender': + case 'prerender-client': + case 'prerender-ppr': + case 'prerender-legacy': { + return createPrerenderRootParamPromise( + paramName, + workStore, + workUnitStore, + apiName + ) + } + case 'request': { + break + } + default: { + workUnitStore satisfies never + } + } + return Promise.resolve(workUnitStore.rootParams[paramName]) +} + +function createPrerenderRootParamPromise( + paramName: string, + workStore: WorkStore, + prerenderStore: PrerenderStore, + apiName: string +): Promise { + switch (prerenderStore.type) { + case 'prerender-client': { + throw new InvariantError( + `${apiName} must not be used within a client component. Next.js should be preventing ${apiName} from being included in client components statically, but did not in this case.` + ) + } + case 'prerender': + case 'prerender-legacy': + case 'prerender-ppr': + default: + } + + const underlyingParams = prerenderStore.rootParams + + switch (prerenderStore.type) { + case 'prerender': { + // We are in a dynamicIO prerender. + // The param is a fallback, so it should be treated as dynamic. + if ( + prerenderStore.fallbackRouteParams && + prerenderStore.fallbackRouteParams.has(paramName) + ) { + return makeHangingPromise( + prerenderStore.renderSignal, + apiName + ) + } + break + } + case 'prerender-ppr': { + // We aren't in a dynamicIO prerender, but the param is a fallback, + // so we need to make an erroring params object which will postpone/error if you access it + if ( + prerenderStore.fallbackRouteParams && + prerenderStore.fallbackRouteParams.has(paramName) + ) { + return makeErroringRootParamPromise( + paramName, + workStore, + prerenderStore, + apiName + ) + } + break + } + case 'prerender-legacy': { + // legacy prerenders can't have fallback params + break + } + default: { + prerenderStore satisfies never + } + } + + // If the param is not a fallback param, we just return the statically available value. + return Promise.resolve(underlyingParams[paramName]) +} + +/** Deliberately async -- we want to create a rejected promise, not error synchronously. */ +async function makeErroringRootParamPromise( + paramName: string, + workStore: WorkStore, + prerenderStore: PrerenderStorePPR | PrerenderStoreLegacy, + apiName: string +): Promise { + const expression = describeStringPropertyAccess(apiName, paramName) + // In most dynamic APIs, we also throw if `dynamic = "error"`. + // However, root params are only dynamic when we're generating a fallback shell, + // and even with `dynamic = "error"` we still support generating dynamic fallback shells. + // TODO: remove this comment when dynamicIO is the default since there will be no `dynamic = "error"` + switch (prerenderStore.type) { + case 'prerender-ppr': { + return postponeWithTracking( + workStore.route, + expression, + prerenderStore.dynamicTracking + ) + } + case 'prerender-legacy': { + return throwToInterruptStaticGeneration( + expression, + workStore, + prerenderStore + ) + } + default: { + prerenderStore satisfies never + } + } +} diff --git a/test/development/acceptance-app/fixtures/rsc-build-errors/app/server-with-errors/next-root-params/in-client-await-import/page.js b/test/development/acceptance-app/fixtures/rsc-build-errors/app/server-with-errors/next-root-params/in-client-await-import/page.js new file mode 100644 index 0000000000000..1cecbaf3c30d7 --- /dev/null +++ b/test/development/acceptance-app/fixtures/rsc-build-errors/app/server-with-errors/next-root-params/in-client-await-import/page.js @@ -0,0 +1,10 @@ +'use client' +// This currently bypasses the compile time import checks from `react_server_components.rs`, +// but we should still catch it during import resolution. +const { whatever } = await import('next/root-params') + +console.log({ whatever }) + +export default function Page() { + return null +} diff --git a/test/development/acceptance-app/fixtures/rsc-build-errors/app/server-with-errors/next-root-params/in-client/page.js b/test/development/acceptance-app/fixtures/rsc-build-errors/app/server-with-errors/next-root-params/in-client/page.js new file mode 100644 index 0000000000000..28c578062c95f --- /dev/null +++ b/test/development/acceptance-app/fixtures/rsc-build-errors/app/server-with-errors/next-root-params/in-client/page.js @@ -0,0 +1,8 @@ +'use client' +import { whatever } from 'next/root-params' + +console.log({ whatever }) + +export default function Page() { + return null +} diff --git a/test/development/acceptance-app/fixtures/rsc-build-errors/app/server-with-errors/next-root-params/without-flag/page.js b/test/development/acceptance-app/fixtures/rsc-build-errors/app/server-with-errors/next-root-params/without-flag/page.js new file mode 100644 index 0000000000000..3d79d65d3fddf --- /dev/null +++ b/test/development/acceptance-app/fixtures/rsc-build-errors/app/server-with-errors/next-root-params/without-flag/page.js @@ -0,0 +1,5 @@ +import { whatever } from 'next/root-params' + +export default async function Page() { + return <>{await whatever()} +} diff --git a/test/development/acceptance-app/rsc-build-errors.test.ts b/test/development/acceptance-app/rsc-build-errors.test.ts index 843dc0b7f03df..5104550c4cb46 100644 --- a/test/development/acceptance-app/rsc-build-errors.test.ts +++ b/test/development/acceptance-app/rsc-build-errors.test.ts @@ -313,6 +313,78 @@ describe('Error overlay - RSC build errors', () => { }) }) + describe('next/root-params', () => { + const isCacheComponentsEnabled = + process.env.__NEXT_EXPERIMENTAL_CACHE_COMPONENTS === 'true' + it("importing 'next/root-params' when experimental.rootParams is not enabled", async () => { + await using sandbox = await createSandbox( + next, + undefined, + `/server-with-errors/next-root-params/without-flag` + ) + const { session } = sandbox + await session.assertHasRedbox() + if (!isCacheComponentsEnabled) { + expect(await session.getRedboxSource()).toInclude( + `'next/root-params' can only be imported when \`experimental.rootParams\` is enabled.` + ) + } else { + // in cacheComponents we auto-enable 'next/root-params', so we should get an error about using a non-existent getter instead. + expect(await session.getRedboxSource()).toInclude( + isTurbopack + ? `Export whatever doesn't exist in target module` + : `Attempted import error: 'whatever' is not exported from 'next/root-params' (imported as 'whatever').` + ) + } + }) + + it("importing 'next/root-params' in a client component", async () => { + await using sandbox = await createSandbox( + next, + // if cacheComponents is not enabled, the import is guarded behind an experimental flag + isCacheComponentsEnabled + ? new Map() + : new Map([ + [ + 'next.config.js', + outdent` + module.exports = { experimental: { rootParams: true } } + `, + ], + ]), + `/server-with-errors/next-root-params/in-client` + ) + const { session } = sandbox + await session.assertHasRedbox() + expect(await session.getRedboxSource()).toInclude( + `You're importing a component that needs "next/root-params". That only works in a Server Component but one of its parents is marked with "use client", so it's a Client Component.` + ) + }) + + it("importing 'next/root-params' in a client component in a way that bypasses import analysis", async () => { + await using sandbox = await createSandbox( + next, + // if cacheComponents is not enabled, the import is guarded behind an experimental flag + isCacheComponentsEnabled + ? new Map() + : new Map([ + [ + 'next.config.js', + outdent` + module.exports = { experimental: { rootParams: true } } + `, + ], + ]), + `/server-with-errors/next-root-params/in-client-await-import` + ) + const { session } = sandbox + await session.assertHasRedbox() + expect(await session.getRedboxSource()).toInclude( + `'next/root-params' cannot be imported from a Client Component module. It should only be used from a Server Component.` + ) + }) + }) + it('should error for invalid undefined module retuning from next dynamic', async () => { await using sandbox = await createSandbox( next, diff --git a/test/development/acceptance/server-component-compiler-errors-in-pages.test.ts b/test/development/acceptance/server-component-compiler-errors-in-pages.test.ts index 4013fa115aa69..33ca2362571d4 100644 --- a/test/development/acceptance/server-component-compiler-errors-in-pages.test.ts +++ b/test/development/acceptance/server-component-compiler-errors-in-pages.test.ts @@ -290,6 +290,99 @@ describe('Error Overlay for server components compiler errors in pages', () => { } }) + test("importing 'next/root-params' in pages", async () => { + const files = new Map([ + ...initialFiles, + [ + 'components/Comp.js', + outdent` + import { foo } from 'next/root-params' + + export default function Page() { + return 'hello world' + } + `, + ], + [ + // the import is guarded behind an experimental flag + 'next.config.js', + outdent` + module.exports = { experimental: { rootParams: true } } + `, + ], + ]) + await using sandbox = await createSandbox(next, files) + const { session } = sandbox + + await session.assertHasRedbox() + await expect(session.getRedboxSource()).resolves.toMatch( + /That only works in a Server Component/ + ) + + if (process.env.IS_TURBOPACK_TEST) { + expect(next.normalizeTestDirContent(await session.getRedboxSource())) + .toMatchInlineSnapshot(` + "./components/Comp.js (1:1) + Ecmascript file had an error + > 1 | import { foo } from 'next/root-params' + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 2 | + 3 | export default function Page() { + 4 | return 'hello world' + + You're importing a component that needs "next/root-params". That only works in a Server Component which is not supported in the pages/ directory. Read more: https://nextjs.org/docs/app/building-your-application/rendering/server-components + + Import traces: + Browser: + ./components/Comp.js + ./pages/index.js + + SSR: + ./components/Comp.js + ./pages/index.js" + `) + } else if (isRspack) { + expect( + takeUpToString( + next.normalizeTestDirContent(await session.getRedboxSource()), + '----' + ) + ).toMatchInlineSnapshot(` + "./components/Comp.js + × Module build failed: + ╰─▶ × Error: x You're importing a component that needs "next/root-params". That only works in a Server Component which is not supported in the pages/ directory. Read more: https://nextjs.org/docs/app/building-your-application/rendering/server-components + │ | + │ + │ ,-[1:1] + │ 1 | import { foo } from 'next/root-params' + │ : ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + │ 2 | + │ 3 | export default function Page() { + │ 4 | return 'hello world' + │ \`----" + `) + } else { + expect( + takeUpToString( + next.normalizeTestDirContent(await session.getRedboxSource()), + 'Import trace for requested module:' + ) + ).toMatchInlineSnapshot(` + "./components/Comp.js + Error: x You're importing a component that needs "next/root-params". That only works in a Server Component which is not supported in the pages/ directory. Read more: https://nextjs.org/docs/app/building-your-application/rendering/server-components + | + + ,-[1:1] + 1 | import { foo } from 'next/root-params' + : ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 2 | + 3 | export default function Page() { + 4 | return 'hello world' + \`----" + `) + } + }) + describe("importing 'next/cache' APIs in pages", () => { test.each([ 'revalidatePath', diff --git a/test/e2e/app-dir/app-root-params-getters/fixtures/generate-static-params/app/[lang]/[locale]/layout.tsx b/test/e2e/app-dir/app-root-params-getters/fixtures/generate-static-params/app/[lang]/[locale]/layout.tsx new file mode 100644 index 0000000000000..1ce0c2a801eab --- /dev/null +++ b/test/e2e/app-dir/app-root-params-getters/fixtures/generate-static-params/app/[lang]/[locale]/layout.tsx @@ -0,0 +1,17 @@ +import { lang, locale } from 'next/root-params' +import { ReactNode } from 'react' + +export default async function Root({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} + +export async function generateStaticParams() { + return [ + { lang: 'en', locale: 'us' }, + { lang: 'es', locale: 'es' }, + ] +} diff --git a/test/e2e/app-dir/app-root-params-getters/fixtures/generate-static-params/app/[lang]/[locale]/other/[slug]/layout.tsx b/test/e2e/app-dir/app-root-params-getters/fixtures/generate-static-params/app/[lang]/[locale]/other/[slug]/layout.tsx new file mode 100644 index 0000000000000..d75c116266dca --- /dev/null +++ b/test/e2e/app-dir/app-root-params-getters/fixtures/generate-static-params/app/[lang]/[locale]/other/[slug]/layout.tsx @@ -0,0 +1,3 @@ +export default function Layout({ children }) { + return
{children}
+} diff --git a/test/e2e/app-dir/app-root-params-getters/fixtures/generate-static-params/app/[lang]/[locale]/other/[slug]/page.tsx b/test/e2e/app-dir/app-root-params-getters/fixtures/generate-static-params/app/[lang]/[locale]/other/[slug]/page.tsx new file mode 100644 index 0000000000000..51a350cbc510e --- /dev/null +++ b/test/e2e/app-dir/app-root-params-getters/fixtures/generate-static-params/app/[lang]/[locale]/other/[slug]/page.tsx @@ -0,0 +1,24 @@ +import { lang, locale } from 'next/root-params' +import { Suspense } from 'react' + +export default async function Page({ params }) { + return ( +
+

+ {JSON.stringify({ lang: await lang(), locale: await locale() })} +

+ + + +
+ ) +} + +async function DynamicParams({ + params, +}: { + params: Promise<{ [key: string]: string }> +}) { + const { slug } = await params + return

{slug}

+} diff --git a/test/e2e/app-dir/app-root-params-getters/fixtures/generate-static-params/app/[lang]/[locale]/other/layout.tsx b/test/e2e/app-dir/app-root-params-getters/fixtures/generate-static-params/app/[lang]/[locale]/other/layout.tsx new file mode 100644 index 0000000000000..d75c116266dca --- /dev/null +++ b/test/e2e/app-dir/app-root-params-getters/fixtures/generate-static-params/app/[lang]/[locale]/other/layout.tsx @@ -0,0 +1,3 @@ +export default function Layout({ children }) { + return
{children}
+} diff --git a/test/e2e/app-dir/app-root-params-getters/fixtures/generate-static-params/app/[lang]/[locale]/other/page.tsx b/test/e2e/app-dir/app-root-params-getters/fixtures/generate-static-params/app/[lang]/[locale]/other/page.tsx new file mode 100644 index 0000000000000..187b865111066 --- /dev/null +++ b/test/e2e/app-dir/app-root-params-getters/fixtures/generate-static-params/app/[lang]/[locale]/other/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return
Other Page
+} diff --git a/test/e2e/app-dir/app-root-params-getters/fixtures/generate-static-params/app/[lang]/[locale]/page.tsx b/test/e2e/app-dir/app-root-params-getters/fixtures/generate-static-params/app/[lang]/[locale]/page.tsx new file mode 100644 index 0000000000000..03a3cb12ff0b2 --- /dev/null +++ b/test/e2e/app-dir/app-root-params-getters/fixtures/generate-static-params/app/[lang]/[locale]/page.tsx @@ -0,0 +1,10 @@ +import { lang, locale } from 'next/root-params' + +export default async function Page() { + return ( +

+ hello world{' '} + {JSON.stringify({ lang: await lang(), locale: await locale() })} +

+ ) +} diff --git a/test/e2e/app-dir/app-root-params-getters/fixtures/generate-static-params/next.config.ts b/test/e2e/app-dir/app-root-params-getters/fixtures/generate-static-params/next.config.ts new file mode 100644 index 0000000000000..08122de286e65 --- /dev/null +++ b/test/e2e/app-dir/app-root-params-getters/fixtures/generate-static-params/next.config.ts @@ -0,0 +1,9 @@ +import type { NextConfig } from 'next' + +const nextConfig: NextConfig = { + experimental: { + cacheComponents: true, // implies `rootParams: true`. + }, +} + +export default nextConfig diff --git a/test/e2e/app-dir/app-root-params-getters/fixtures/multiple-roots/app/dashboard/[id]/layout.tsx b/test/e2e/app-dir/app-root-params-getters/fixtures/multiple-roots/app/dashboard/[id]/layout.tsx new file mode 100644 index 0000000000000..9408e24b35491 --- /dev/null +++ b/test/e2e/app-dir/app-root-params-getters/fixtures/multiple-roots/app/dashboard/[id]/layout.tsx @@ -0,0 +1,13 @@ +import { ReactNode } from 'react' + +export default function Root({ children }: { children: ReactNode }) { + return ( + + Dashboard Root: {children} + + ) +} + +export async function generateStaticParams() { + return [{ id: '1' }] +} diff --git a/test/e2e/app-dir/app-root-params-getters/fixtures/multiple-roots/app/dashboard/[id]/page.tsx b/test/e2e/app-dir/app-root-params-getters/fixtures/multiple-roots/app/dashboard/[id]/page.tsx new file mode 100644 index 0000000000000..375d5d6897add --- /dev/null +++ b/test/e2e/app-dir/app-root-params-getters/fixtures/multiple-roots/app/dashboard/[id]/page.tsx @@ -0,0 +1,5 @@ +import { id } from 'next/root-params' + +export default async function Page() { + return

hello world {JSON.stringify({ id: await id() })}

+} diff --git a/test/e2e/app-dir/app-root-params-getters/fixtures/multiple-roots/app/landing/layout.tsx b/test/e2e/app-dir/app-root-params-getters/fixtures/multiple-roots/app/landing/layout.tsx new file mode 100644 index 0000000000000..ab9238c0e14de --- /dev/null +++ b/test/e2e/app-dir/app-root-params-getters/fixtures/multiple-roots/app/landing/layout.tsx @@ -0,0 +1,9 @@ +import { ReactNode } from 'react' + +export default function Root({ children }: { children: ReactNode }) { + return ( + + Marketing Root: {children} + + ) +} diff --git a/test/e2e/app-dir/app-root-params-getters/fixtures/multiple-roots/app/landing/page.tsx b/test/e2e/app-dir/app-root-params-getters/fixtures/multiple-roots/app/landing/page.tsx new file mode 100644 index 0000000000000..375d5d6897add --- /dev/null +++ b/test/e2e/app-dir/app-root-params-getters/fixtures/multiple-roots/app/landing/page.tsx @@ -0,0 +1,5 @@ +import { id } from 'next/root-params' + +export default async function Page() { + return

hello world {JSON.stringify({ id: await id() })}

+} diff --git a/test/e2e/app-dir/app-root-params-getters/fixtures/multiple-roots/next.config.ts b/test/e2e/app-dir/app-root-params-getters/fixtures/multiple-roots/next.config.ts new file mode 100644 index 0000000000000..6993a945b3c63 --- /dev/null +++ b/test/e2e/app-dir/app-root-params-getters/fixtures/multiple-roots/next.config.ts @@ -0,0 +1,9 @@ +import type { NextConfig } from 'next' + +const nextConfig: NextConfig = { + experimental: { + rootParams: true, + }, +} + +export default nextConfig diff --git a/test/e2e/app-dir/app-root-params-getters/fixtures/simple/app/[lang]/[locale]/layout.tsx b/test/e2e/app-dir/app-root-params-getters/fixtures/simple/app/[lang]/[locale]/layout.tsx new file mode 100644 index 0000000000000..1d56ad46fca0e --- /dev/null +++ b/test/e2e/app-dir/app-root-params-getters/fixtures/simple/app/[lang]/[locale]/layout.tsx @@ -0,0 +1,14 @@ +import { lang, locale } from 'next/root-params' +import type { ReactNode } from 'react' + +export default async function Root({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} + +export async function generateStaticParams() { + return [{ lang: 'en', locale: 'us' }] +} diff --git a/test/e2e/app-dir/app-root-params-getters/fixtures/simple/app/[lang]/[locale]/other/[slug]/layout.tsx b/test/e2e/app-dir/app-root-params-getters/fixtures/simple/app/[lang]/[locale]/other/[slug]/layout.tsx new file mode 100644 index 0000000000000..d75c116266dca --- /dev/null +++ b/test/e2e/app-dir/app-root-params-getters/fixtures/simple/app/[lang]/[locale]/other/[slug]/layout.tsx @@ -0,0 +1,3 @@ +export default function Layout({ children }) { + return
{children}
+} diff --git a/test/e2e/app-dir/app-root-params-getters/fixtures/simple/app/[lang]/[locale]/other/[slug]/page.tsx b/test/e2e/app-dir/app-root-params-getters/fixtures/simple/app/[lang]/[locale]/other/[slug]/page.tsx new file mode 100644 index 0000000000000..d827b8326f6f2 --- /dev/null +++ b/test/e2e/app-dir/app-root-params-getters/fixtures/simple/app/[lang]/[locale]/other/[slug]/page.tsx @@ -0,0 +1,30 @@ +import { lang, locale } from 'next/root-params' +import { Suspense } from 'react' + +export default async function Page({ params }) { + return ( +
+

+ root params shouldn't need a suspense a suspense boundary + + {JSON.stringify({ + lang: await lang(), + locale: await locale(), + })} + +

+

+ in cacheComponents, dynamic params need a suspense boundary + (because we didn't provide a value in generateStaticParams) + + + +

+
+ ) +} + +async function DynamicSlug({ params }) { + const { slug } = await params + return {slug} +} diff --git a/test/e2e/app-dir/app-root-params-getters/fixtures/simple/app/[lang]/[locale]/other/layout.tsx b/test/e2e/app-dir/app-root-params-getters/fixtures/simple/app/[lang]/[locale]/other/layout.tsx new file mode 100644 index 0000000000000..d75c116266dca --- /dev/null +++ b/test/e2e/app-dir/app-root-params-getters/fixtures/simple/app/[lang]/[locale]/other/layout.tsx @@ -0,0 +1,3 @@ +export default function Layout({ children }) { + return
{children}
+} diff --git a/test/e2e/app-dir/app-root-params-getters/fixtures/simple/app/[lang]/[locale]/other/page.tsx b/test/e2e/app-dir/app-root-params-getters/fixtures/simple/app/[lang]/[locale]/other/page.tsx new file mode 100644 index 0000000000000..187b865111066 --- /dev/null +++ b/test/e2e/app-dir/app-root-params-getters/fixtures/simple/app/[lang]/[locale]/other/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return
Other Page
+} diff --git a/test/e2e/app-dir/app-root-params-getters/fixtures/simple/app/[lang]/[locale]/page.tsx b/test/e2e/app-dir/app-root-params-getters/fixtures/simple/app/[lang]/[locale]/page.tsx new file mode 100644 index 0000000000000..03a3cb12ff0b2 --- /dev/null +++ b/test/e2e/app-dir/app-root-params-getters/fixtures/simple/app/[lang]/[locale]/page.tsx @@ -0,0 +1,10 @@ +import { lang, locale } from 'next/root-params' + +export default async function Page() { + return ( +

+ hello world{' '} + {JSON.stringify({ lang: await lang(), locale: await locale() })} +

+ ) +} diff --git a/test/e2e/app-dir/app-root-params-getters/fixtures/simple/app/[lang]/[locale]/route-handler/route.tsx b/test/e2e/app-dir/app-root-params-getters/fixtures/simple/app/[lang]/[locale]/route-handler/route.tsx new file mode 100644 index 0000000000000..464aceeb4f30e --- /dev/null +++ b/test/e2e/app-dir/app-root-params-getters/fixtures/simple/app/[lang]/[locale]/route-handler/route.tsx @@ -0,0 +1,9 @@ +import { lang, locale } from 'next/root-params' + +export async function GET() { + return Response.json( + // TODO(root-params): We're missing some wiring to set `requestStore.rootParams`, + // so both of these will currently return undefined + { lang: await lang(), locale: await locale() } + ) +} diff --git a/test/e2e/app-dir/app-root-params-getters/fixtures/simple/app/[lang]/[locale]/server-action/page.tsx b/test/e2e/app-dir/app-root-params-getters/fixtures/simple/app/[lang]/[locale]/server-action/page.tsx new file mode 100644 index 0000000000000..3843d884c9633 --- /dev/null +++ b/test/e2e/app-dir/app-root-params-getters/fixtures/simple/app/[lang]/[locale]/server-action/page.tsx @@ -0,0 +1,15 @@ +import { lang } from 'next/root-params' + +export default function Page() { + return ( +
{ + 'use server' + // not allowed, should error + await lang() + }} + > + +
+ ) +} diff --git a/test/e2e/app-dir/app-root-params-getters/fixtures/simple/app/catch-all/[...path]/layout.tsx b/test/e2e/app-dir/app-root-params-getters/fixtures/simple/app/catch-all/[...path]/layout.tsx new file mode 100644 index 0000000000000..8e6036fae6912 --- /dev/null +++ b/test/e2e/app-dir/app-root-params-getters/fixtures/simple/app/catch-all/[...path]/layout.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from 'react' + +export default function Root({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} + +export async function generateStaticParams() { + return [{ path: ['foo'] }] +} diff --git a/test/e2e/app-dir/app-root-params-getters/fixtures/simple/app/catch-all/[...path]/page.tsx b/test/e2e/app-dir/app-root-params-getters/fixtures/simple/app/catch-all/[...path]/page.tsx new file mode 100644 index 0000000000000..26e3128b1ac53 --- /dev/null +++ b/test/e2e/app-dir/app-root-params-getters/fixtures/simple/app/catch-all/[...path]/page.tsx @@ -0,0 +1,5 @@ +import { path } from 'next/root-params' + +export default async function Page() { + return

{JSON.stringify({ path: await path() })}

+} diff --git a/test/e2e/app-dir/app-root-params-getters/fixtures/simple/app/optional-catch-all/[[...path]]/layout.tsx b/test/e2e/app-dir/app-root-params-getters/fixtures/simple/app/optional-catch-all/[[...path]]/layout.tsx new file mode 100644 index 0000000000000..ad95ca0626ec7 --- /dev/null +++ b/test/e2e/app-dir/app-root-params-getters/fixtures/simple/app/optional-catch-all/[[...path]]/layout.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from 'react' + +export default function Root({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} + +export async function generateStaticParams() { + return [{ path: [] }] +} diff --git a/test/e2e/app-dir/app-root-params-getters/fixtures/simple/app/optional-catch-all/[[...path]]/page.tsx b/test/e2e/app-dir/app-root-params-getters/fixtures/simple/app/optional-catch-all/[[...path]]/page.tsx new file mode 100644 index 0000000000000..26e3128b1ac53 --- /dev/null +++ b/test/e2e/app-dir/app-root-params-getters/fixtures/simple/app/optional-catch-all/[[...path]]/page.tsx @@ -0,0 +1,5 @@ +import { path } from 'next/root-params' + +export default async function Page() { + return

{JSON.stringify({ path: await path() })}

+} diff --git a/test/e2e/app-dir/app-root-params-getters/fixtures/simple/next.config.ts b/test/e2e/app-dir/app-root-params-getters/fixtures/simple/next.config.ts new file mode 100644 index 0000000000000..6993a945b3c63 --- /dev/null +++ b/test/e2e/app-dir/app-root-params-getters/fixtures/simple/next.config.ts @@ -0,0 +1,9 @@ +import type { NextConfig } from 'next' + +const nextConfig: NextConfig = { + experimental: { + rootParams: true, + }, +} + +export default nextConfig diff --git a/test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-build/app/[lang]/[locale]/layout.tsx b/test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-build/app/[lang]/[locale]/layout.tsx new file mode 100644 index 0000000000000..3a15cf1400fec --- /dev/null +++ b/test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-build/app/[lang]/[locale]/layout.tsx @@ -0,0 +1,16 @@ +import { ReactNode } from 'react' + +export default function Root({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} + +export async function generateStaticParams() { + return [ + { lang: 'en', locale: 'us' }, + { lang: 'es', locale: 'es' }, + ] +} diff --git a/test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-build/app/[lang]/[locale]/use-cache/page.tsx b/test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-build/app/[lang]/[locale]/use-cache/page.tsx new file mode 100644 index 0000000000000..04b1b13ba03df --- /dev/null +++ b/test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-build/app/[lang]/[locale]/use-cache/page.tsx @@ -0,0 +1,22 @@ +import { lang, locale } from 'next/root-params' + +export default async function Page() { + const rootParams = await getCachedParams() + const data = await fetch( + 'https://next-data-api-endpoint.vercel.app/api/random' + ).then((res) => res.text()) + + return ( +

+ + {rootParams.lang} {rootParams.locale} + {' '} + {data} +

+ ) +} + +async function getCachedParams() { + 'use cache' + return { lang: await lang(), locale: await locale() } +} diff --git a/test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-build/next.config.ts b/test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-build/next.config.ts new file mode 100644 index 0000000000000..f9ec19251f03a --- /dev/null +++ b/test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-build/next.config.ts @@ -0,0 +1,10 @@ +import type { NextConfig } from 'next' + +const nextConfig: NextConfig = { + experimental: { + useCache: true, + rootParams: true, + }, +} + +export default nextConfig diff --git a/test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-runtime/app/[lang]/[locale]/layout.tsx b/test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-runtime/app/[lang]/[locale]/layout.tsx new file mode 100644 index 0000000000000..3a3c2e2a67e27 --- /dev/null +++ b/test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-runtime/app/[lang]/[locale]/layout.tsx @@ -0,0 +1,16 @@ +import { ReactNode } from 'react' + +export default function Root({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} + +export function generateStaticParams() { + // the param values are not accessed in tests, + // we just need a value here to avoid errors in PPR/cacheComponents + // where we need to provide at least one set of values for root params + return [{ lang: 'foo', locale: 'bar' }] +} diff --git a/test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-runtime/app/[lang]/[locale]/unstable_cache/page.tsx b/test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-runtime/app/[lang]/[locale]/unstable_cache/page.tsx new file mode 100644 index 0000000000000..976352d5f58b4 --- /dev/null +++ b/test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-runtime/app/[lang]/[locale]/unstable_cache/page.tsx @@ -0,0 +1,34 @@ +import { Suspense } from 'react' +import { lang, locale } from 'next/root-params' +import { connection } from 'next/server' +import { unstable_cache } from 'next/cache' + +export default async function Page() { + return ( + + + + ) +} + +async function Runtime() { + await connection() + + const rootParams = await getCachedParams() + const data = await fetch( + 'https://next-data-api-endpoint.vercel.app/api/random' + ).then((res) => res.text()) + + return ( +

+ + {rootParams.lang} {rootParams.locale} + {' '} + {data} +

+ ) +} + +const getCachedParams = unstable_cache(async () => { + return { lang: await lang(), locale: await locale() } +}) diff --git a/test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-runtime/app/[lang]/[locale]/use-cache/page.tsx b/test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-runtime/app/[lang]/[locale]/use-cache/page.tsx new file mode 100644 index 0000000000000..1652b55a34487 --- /dev/null +++ b/test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-runtime/app/[lang]/[locale]/use-cache/page.tsx @@ -0,0 +1,34 @@ +import { lang, locale } from 'next/root-params' +import { connection } from 'next/server' +import { Suspense } from 'react' + +export default async function Page() { + return ( + + + + ) +} + +async function Runtime() { + await connection() + + const rootParams = await getCachedParams() + const data = await fetch( + 'https://next-data-api-endpoint.vercel.app/api/random' + ).then((res) => res.text()) + + return ( +

+ + {rootParams.lang} {rootParams.locale} + {' '} + {data} +

+ ) +} + +async function getCachedParams() { + 'use cache' + return { lang: await lang(), locale: await locale() } +} diff --git a/test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-runtime/next.config.ts b/test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-runtime/next.config.ts new file mode 100644 index 0000000000000..f9ec19251f03a --- /dev/null +++ b/test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-runtime/next.config.ts @@ -0,0 +1,10 @@ +import type { NextConfig } from 'next' + +const nextConfig: NextConfig = { + experimental: { + useCache: true, + rootParams: true, + }, +} + +export default nextConfig diff --git a/test/e2e/app-dir/app-root-params-getters/generate-static-params.test.ts b/test/e2e/app-dir/app-root-params-getters/generate-static-params.test.ts new file mode 100644 index 0000000000000..9e1eec4a8ffb0 --- /dev/null +++ b/test/e2e/app-dir/app-root-params-getters/generate-static-params.test.ts @@ -0,0 +1,42 @@ +import { nextTestSetup } from 'e2e-utils' +import cheerio from 'cheerio' +import { join } from 'path' + +describe('app-root-param-getters - generateStaticParams', () => { + const { next, isNextDeploy } = nextTestSetup({ + files: join(__dirname, 'fixtures', 'generate-static-params'), + }) + + it('should be statically prerenderable', async () => { + const params = { lang: 'en', locale: 'us' } + const response = await next.fetch(`/${params.lang}/${params.locale}`) + expect(response.status).toBe(200) + if (isNextDeploy) { + expect(response.headers.get('x-vercel-cache')).toBe('PRERENDER') + } else { + expect(response.headers.get('x-nextjs-cache')).toBe('HIT') + } + const $ = cheerio.load(await response.text()) + expect($('p').text()).toBe(`hello world ${JSON.stringify(params)}`) + }) + + it('should be part of the static shell', async () => { + const params = { lang: 'en', locale: 'us' } + const browser = await next.browser( + `/${params.lang}/${params.locale}/other/1`, + { + // prevent streaming (dynamic) content from being inserted into the DOM + disableJavaScript: true, + } + ) + expect(await browser.elementByCss('main > p#root-params').text()).toBe( + JSON.stringify(params) + ) + }) + + it('should allow reading root params that were not prerendered', async () => { + const params = { lang: 'sth', locale: 'else' } + const $ = await next.render$(`/${params.lang}/${params.locale}`) + expect($('p').text()).toBe(`hello world ${JSON.stringify(params)}`) + }) +}) diff --git a/test/e2e/app-dir/app-root-params-getters/multiple-roots.test.ts b/test/e2e/app-dir/app-root-params-getters/multiple-roots.test.ts new file mode 100644 index 0000000000000..6cae531ccd77c --- /dev/null +++ b/test/e2e/app-dir/app-root-params-getters/multiple-roots.test.ts @@ -0,0 +1,133 @@ +import { nextTestSetup } from 'e2e-utils' +import { join } from 'path' +import { createSandbox } from 'development-sandbox' +import { outdent } from 'outdent' +import { retry } from '../../../lib/next-test-utils' + +describe('app-root-param-getters - multiple roots', () => { + const { next, isNextDev, isTurbopack } = nextTestSetup({ + files: join(__dirname, 'fixtures', 'multiple-roots'), + }) + + it('should have root params on dashboard pages', async () => { + const $ = await next.render$('/dashboard/1') + expect($('body').text()).toContain('Dashboard Root') + expect($('p').text()).toBe(`hello world ${JSON.stringify({ id: '1' })}`) + }) + + it('should not have root params on marketing pages', async () => { + const $ = await next.render$('/landing') + expect($('body').text()).toContain('Marketing Root') + expect($('p').text()).toBe('hello world {}') + }) + + if (isNextDev) { + it('should add getters when new root layouts are added or renamed', async () => { + // Start on the dashboard page, which uses root param getters. + // This forces the bundler to generate 'next/root-params'. + await using sandbox = await createSandbox(next, undefined, `/dashboard/1`) + const { browser, session } = sandbox + + expect(await browser.elementByCss('p').text()).toBe( + `hello world ${JSON.stringify({ id: '1' })}` + ) + + // Add a new root layout with a root param. + // This should make the bundler re-generate 'next/root-params' with a new getter for `stuff`. + const newRootLayoutFiles = new Map([ + [ + 'app/new-root/[stuff]/layout.tsx', + outdent` + export default function Root({ children }) { + return ( + + {children} + + ) + } + `, + ], + [ + 'app/new-root/[stuff]/page.tsx', + // Note that we're also importing the `id` getter just to see if it's still there + // (we expect it to return undefined, because we don't have that param on this route) + outdent` + import { id, stuff } from 'next/root-params' + export default async function Page() { + return ( +

hello new root: {JSON.stringify({ id: await id(), stuff: await stuff() })}

+ ) + } + + export async function generateStaticParams() { + return [{ stuff: '123' }] + } + `, + ], + ]) + for (const [filePath, fileContent] of newRootLayoutFiles) { + await session.write(filePath, fileContent) + } + + // The page should call the getter and get the correct param value. + await retry(async () => { + const params = { stuff: '123' } + await browser.get(new URL(`/new-root/${params.stuff}`, next.url).href) + expect(await browser.elementByCss('p').text()).toBe( + `hello new root: ${JSON.stringify(params)}` + ) + }) + + // Change the name of the root param + // This should make the bundler re-generate 'next/root-params' again, with `things` instead of `stuff`. + if (isTurbopack) { + // FIXME(turbopack): Something in our routing logic doesn't handle renaming a route param in turbopack mode. + // I haven't found the cause for this, but `DefaultRouteMatcherManager.reload` calls + // `getSortedRoutes(['/dashboard/[id]', '/new-root/[stuff]', '/new-root/[things]'])` + // which makes it error because it looks like we have two overlapping routes. + // I'm not sure why the previous route doesn't get removed and couldn't find a workaround, + // so we're skipping the rest of the test for now. + return + } + await session.renameFolder( + 'app/new-root/[stuff]', + 'app/new-root/[things]' + ) + + // The page code we added should now be erroring, because the root param getter is called `things` now + await retry(() => { + expect(next.cliOutput).toContain( + isTurbopack + ? `Export stuff doesn't exist in target module` + : `Attempted import error: 'stuff' is not exported from 'next/root-params' (imported as 'stuff').` + ) + }) + + // Update the page to use the new root param name + await session.write( + 'app/new-root/[things]/page.tsx', + outdent` + import { id, things } from 'next/root-params' + export default async function Page() { + return ( +

hello new root: {JSON.stringify({ id: await id(), things: await things() })}

+ ) + } + + export async function generateStaticParams() { + return [{ things: '123' }] + } + ` + ) + + // The page should call the getter and get the correct param value. + await retry(async () => { + const params = { things: '123' } + await browser.get(new URL(`/new-root/${params.things}`, next.url).href) + expect(await browser.elementByCss('p').text()).toBe( + `hello new root: ${JSON.stringify(params)}` + ) + }) + }) + } +}) diff --git a/test/e2e/app-dir/app-root-params-getters/simple.test.ts b/test/e2e/app-dir/app-root-params-getters/simple.test.ts new file mode 100644 index 0000000000000..6bd705fed8fb7 --- /dev/null +++ b/test/e2e/app-dir/app-root-params-getters/simple.test.ts @@ -0,0 +1,154 @@ +import { nextTestSetup } from 'e2e-utils' +import { assertNoRedbox, retry } from 'next-test-utils' +import { join } from 'path' +import { createSandbox } from 'development-sandbox' +import { outdent } from 'outdent' +import { createRequestTracker } from '../../../lib/e2e-utils/request-tracker' + +describe('app-root-param-getters - simple', () => { + const { next, isNextDev, isTurbopack, isNextDeploy } = nextTestSetup({ + files: join(__dirname, 'fixtures', 'simple'), + }) + + it('should allow reading root params', async () => { + const params = { lang: 'en', locale: 'us' } + const $ = await next.render$(`/${params.lang}/${params.locale}`) + expect($('p').text()).toBe(`hello world ${JSON.stringify(params)}`) + }) + + it('should allow reading root params in nested pages', async () => { + const rootParams = { lang: 'en', locale: 'us' } + const dynamicParams = { slug: '1' } + const $ = await next.render$( + `/${rootParams.lang}/${rootParams.locale}/other/${dynamicParams.slug}` + ) + expect($('#dynamic-params').text()).toBe(dynamicParams.slug) + expect($('#root-params').text()).toBe(JSON.stringify(rootParams)) + }) + + it('should allow reading catch-all root params', async () => { + const params = { path: ['foo', 'bar'] } + const $ = await next.render$(`/catch-all/${params.path.join('/')}`) + expect($('p').text()).toBe(JSON.stringify(params)) + }) + + it('should allow reading optional catch-all root params', async () => { + { + const params = { path: undefined } + const $ = await next.render$(`/optional-catch-all`) + expect($('p').text()).toBe(JSON.stringify(params)) + } + { + const params = { path: ['foo', 'bar'] } + const $ = await next.render$( + `/optional-catch-all/${params.path.join('/')}` + ) + expect($('p').text()).toBe(JSON.stringify(params)) + } + }) + + it('should render the not found page without errors', async () => { + const browser = await next.browser('/') + expect(await browser.elementByCss('h2').text()).toBe( + 'This page could not be found.' + ) + if (isNextDev) { + await assertNoRedbox(browser) + } + }) + + if (isNextDev) { + it('should not generate getters for non-root params', async () => { + const rootParams = { lang: 'en', locale: 'us' } + const dynamicParams = { slug: 'foo' } + + await using _sandbox = await createSandbox( + next, + new Map([ + [ + 'app/[lang]/[locale]/other/[slug]/page.tsx', + outdent` + import { lang, locale, slug } from 'next/root-params'; + export default async function Page() { + return JSON.stringify({ lang: await lang(), locale: await locale(), slug: await slug() }); + } + `, + ], + ]), + `/${rootParams.lang}/${rootParams.locale}/other/${dynamicParams.slug}` + ) + // Workaround: `createSandbox` stops next and does not restart it, so subsequent tests would fail + afterCurrentTest(() => next.start()) + + await retry(() => { + expect(next.cliOutput).toContain( + isTurbopack + ? `Export slug doesn't exist in target module` + : `Attempted import error: 'slug' is not exported from 'next/root-params' (imported as 'slug').` + ) + }) + }) + } + + it('should error when used in a server action', async () => { + const params = { lang: 'en', locale: 'us' } + const browser = await next.browser( + `/${params.lang}/${params.locale}/server-action` + ) + const tracker = createRequestTracker(browser) + const [, response] = await tracker.captureResponse( + async () => { + await browser.elementByCss('button[type="submit"]').click() + }, + { + request: { + method: 'POST', + pathname: `/${params.lang}/${params.locale}/server-action`, + }, + } + ) + expect(response.status()).toBe(500) + if (!isNextDeploy) { + expect(next.cliOutput).toInclude( + "`import('next/root-params').lang()` was used inside a Server Action. This is not supported. Functions from 'next/root-params' can only be called in the context of a route." + ) + } + }) + + // TODO(root-params): add support for route handlers + it('should error when used in a route handler (until we implement it)', async () => { + const params = { lang: 'en', locale: 'us' } + const response = await next.fetch( + `/${params.lang}/${params.locale}/route-handler` + ) + expect(response.status).toBe(500) + if (!isNextDeploy) { + expect(next.cliOutput).toInclude( + "Route /[lang]/[locale]/route-handler used `import('next/root-params').lang()` inside a Route Handler. Support for this API in Route Handlers is planned for a future version of Next.js." + ) + } + }) +}) + +/** Run cleanup after the current test. */ +const createAfterCurrentTest = () => { + type Callback = () => void | Promise + let callbacks: Callback[] = [] + + afterEach(async () => { + if (!callbacks.length) { + return + } + const currentCallbacks = callbacks + callbacks = [] + for (const callback of currentCallbacks) { + await callback() + } + }) + + return function afterCurrentTest(cb: () => void | Promise) { + callbacks.push(cb) + } +} + +const afterCurrentTest = createAfterCurrentTest() diff --git a/test/e2e/app-dir/app-root-params-getters/use-cache.test.ts b/test/e2e/app-dir/app-root-params-getters/use-cache.test.ts new file mode 100644 index 0000000000000..3b3dcdf6cfbf3 --- /dev/null +++ b/test/e2e/app-dir/app-root-params-getters/use-cache.test.ts @@ -0,0 +1,92 @@ +import { nextTestSetup } from 'e2e-utils' +import { join } from 'path' +import { createSandbox } from 'development-sandbox' + +describe('app-root-param-getters - cache - at runtime', () => { + const { next, isNextDev, skipped } = nextTestSetup({ + files: join(__dirname, 'fixtures', 'use-cache-runtime'), + skipStart: true, + // this test asserts on build failure logs, which aren't currently observable in `next.cliOutput`. + skipDeployment: true, + }) + + if (skipped) return + + if (isNextDev) { + it('should error when using root params within a "use cache" - dev', async () => { + await using sandbox = await createSandbox( + next, + undefined, + '/en/us/use-cache' + ) + const { session } = sandbox + await session.assertHasRedbox() + expect(await session.getRedboxDescription()).toInclude( + 'Route /[lang]/[locale]/use-cache used `import(\'next/root-params\').lang()` inside `"use cache"` or `unstable_cache`' + ) + }) + + it('should error when using root params within `unstable_cache` - dev', async () => { + await using sandbox = await createSandbox( + next, + undefined, + '/en/us/unstable_cache' + ) + const { session } = sandbox + await session.assertHasRedbox() + expect(await session.getRedboxDescription()).toInclude( + 'Route /[lang]/[locale]/unstable_cache used `import(\'next/root-params\').lang()` inside `"use cache"` or `unstable_cache`' + ) + }) + } else { + beforeAll(async () => { + try { + await next.start() + } catch (err) { + // if (isPPREnabled) { + // throw err + // } else { + // // in PPR/cacheComponents, we expect the build to fail, + // // so we swallow the error and let the tests assert on the logs + // } + } + }) + + it('should error when using root params within a "use cache" - start', async () => { + await next.render$('/en/us/use-cache') + expect(next.cliOutput).toInclude( + 'Error: Route /[lang]/[locale]/use-cache used `import(\'next/root-params\').lang()` inside `"use cache"` or `unstable_cache`' + ) + }) + + it('should error when using root params within `unstable_cache` - start', async () => { + await next.render$('/en/us/unstable_cache') + expect(next.cliOutput).toInclude( + 'Error: Route /[lang]/[locale]/unstable_cache used `import(\'next/root-params\').lang()` inside `"use cache"` or `unstable_cache`' + ) + }) + } +}) + +describe('app-root-param-getters - cache - at build', () => { + const { next, isNextDev } = nextTestSetup({ + files: join(__dirname, 'fixtures', 'use-cache-build'), + skipStart: true, + }) + + if (isNextDev) { + // we omit these tests in dev because they are duplicates semantically to the runtime fixture tested above + it('noop in dev', () => {}) + } else { + it('should error when building a project that uses root params within `"use cache"`', async () => { + try { + await next.start() + } catch { + // we expect the build to fail + } + expect(next.cliOutput).toInclude( + 'Error: Route /[lang]/[locale]/use-cache used `import(\'next/root-params\').lang()` inside `"use cache"` or `unstable_cache`' + ) + }) + } +}) diff --git a/test/lib/development-sandbox.ts b/test/lib/development-sandbox.ts index cdef64581f964..c6308081c55a1 100644 --- a/test/lib/development-sandbox.ts +++ b/test/lib/development-sandbox.ts @@ -127,6 +127,9 @@ export async function createSandbox( async remove(filename) { await next.deleteFile(filename) }, + async renameFolder(...args: Parameters<(typeof next)['renameFolder']>) { + await next.renameFolder(...args) + }, evaluate, async assertHasRedbox() { return assertHasRedbox(browser)