diff --git a/crates/next-core/src/app_page_loader_tree.rs b/crates/next-core/src/app_page_loader_tree.rs index 8d608d32c073f..be6beef399472 100644 --- a/crates/next-core/src/app_page_loader_tree.rs +++ b/crates/next-core/src/app_page_loader_tree.rs @@ -328,6 +328,7 @@ impl AppPageLoaderTreeBuilder { parallel_routes, modules, global_metadata, + static_siblings, } = loader_tree; writeln!( @@ -399,7 +400,15 @@ impl AppPageLoaderTreeBuilder { self.loader_tree_code += &modules_code; - write!(self.loader_tree_code, "}}]")?; + // Add static siblings for dynamic segments. An empty array means "known + // to have no siblings" which is distinct from not outputting the field + // (unknown). Turbopack always knows all siblings since it builds the full + // directory tree. + write!( + self.loader_tree_code, + "}}, {}]", + StringifyJs(static_siblings) + )?; Ok(()) } diff --git a/crates/next-core/src/app_structure.rs b/crates/next-core/src/app_structure.rs index 4a680371474dc..5b9d1ce811dae 100644 --- a/crates/next-core/src/app_structure.rs +++ b/crates/next-core/src/app_structure.rs @@ -201,6 +201,84 @@ struct PlainDirectoryTree { /// key is e.g. "dashboard", "(dashboard)", "@slot" pub subdirectories: BTreeMap, pub modules: AppDirModules, + /// Flattened URL tree with route groups and parallel routes transparent. + pub url_tree: UrlSegmentTree, +} + +/// A tree representing the URL segment structure, with route groups and parallel +/// routes flattened out. This provides a unified view of all segments at each URL +/// level, regardless of which route group they're defined in. +/// +/// For example, given this directory structure: +/// +/// app/ +/// ├── (group1)/ +/// │ └── products/ +/// │ └── sale/ +/// └── (group2)/ +/// └── products/ +/// └── [id]/ +/// +/// The UrlSegmentTree would be: +/// +/// (root) +/// └── products/ +/// ├── sale/ +/// └── [id]/ +/// +/// This makes it easy to find all siblings at a given URL level. +#[derive(Clone, Debug, Default, PartialEq, Eq, TraceRawVcs, NonLocalValue, Encode, Decode)] +struct UrlSegmentTree { + pub children: BTreeMap, +} + +impl UrlSegmentTree { + fn static_children(&self) -> Vec { + self.children + .keys() + .filter(|name| !is_dynamic_segment(name)) + .cloned() + .collect() + } + + fn get_child(&self, segment: &str) -> Option<&UrlSegmentTree> { + self.children.get(segment) + } +} + +fn build_url_segment_tree_from_subdirs( + subdirs: &BTreeMap, +) -> UrlSegmentTree { + let mut result = UrlSegmentTree::default(); + build_url_segment_tree_recursive(subdirs, &mut result); + result +} + +/// Recursively builds the URL segment tree by accumulating children at each +/// URL level. Segments from different route groups that share the same URL path +/// are merged together. +/// +/// Example: `(group1)/products/sale/` and `(group2)/products/[id]/` both +/// contribute to a single `products/` node containing both `sale/` and `[id]/`. +fn build_url_segment_tree_recursive( + subdirs: &BTreeMap, + result: &mut UrlSegmentTree, +) { + for (name, subtree) in subdirs { + if is_url_transparent_segment(name) { + // Transparent segments (route groups, parallel routes) don't create + // a new URL level. Recurse with the same `result` so their children + // are accumulated at the current level. + build_url_segment_tree_recursive(&subtree.subdirectories, result); + } else { + // Non-transparent segments create a new URL level. Get or create a + // child node for this segment, then recurse to accumulate its children. + // Using `or_default()` ensures that if this segment was already added + // from a different route group, we merge into it rather than replace. + let child = result.children.entry(name.clone()).or_default(); + build_url_segment_tree_recursive(&subtree.subdirectories, child); + } + } } #[turbo_tasks::value_impl] @@ -213,9 +291,12 @@ impl DirectoryTree { subdirectories.insert(name.clone(), subdirectory.into_plain().owned().await?); } + let url_tree = build_url_segment_tree_from_subdirs(&subdirectories); + Ok(PlainDirectoryTree { subdirectories, modules: self.modules.clone(), + url_tree, } .cell()) } @@ -392,6 +473,10 @@ pub struct AppPageLoaderTree { pub parallel_routes: FxIndexMap, pub modules: AppDirModules, pub global_metadata: ResolvedVc, + /// For dynamic segments, contains the list of static sibling segments that + /// exist at the same URL path level. Used by the client router to determine + /// if a prefetch can be reused. + pub static_siblings: Vec, } impl AppPageLoaderTree { @@ -543,6 +628,17 @@ fn is_group_route(name: &str) -> bool { name.starts_with('(') && name.ends_with(')') } +/// Returns true if this segment is "transparent" from a URL perspective. +/// Route groups like `(marketing)` and parallel routes like `@modal` exist in +/// the file system but don't contribute to the URL path. +fn is_url_transparent_segment(name: &str) -> bool { + is_group_route(name) || is_parallel_route(name) +} + +fn is_dynamic_segment(name: &str) -> bool { + name.starts_with('[') && name.ends_with(']') +} + fn match_parallel_route(name: &str) -> Option<&str> { name.strip_prefix('@') } @@ -991,7 +1087,8 @@ async fn directory_tree_to_loader_tree( // the page this loader tree is constructed for for_app_path: AppPath, ) -> Result> { - let plain_tree = &*directory_tree.into_plain().await?; + let plain_tree_vc = directory_tree.into_plain(); + let plain_tree = &*plain_tree_vc.await?; let tree = directory_tree_to_loader_tree_internal( app_dir, @@ -1001,6 +1098,7 @@ async fn directory_tree_to_loader_tree( app_page, for_app_path, AppDirModules::default(), + Some(&plain_tree.url_tree), ) .await?; @@ -1080,6 +1178,7 @@ async fn directory_tree_to_loader_tree_internal( // the page this loader tree is constructed for for_app_path: AppPath, mut parent_modules: AppDirModules, + url_tree: Option<&UrlSegmentTree>, ) -> Result> { let app_path = AppPath::from(app_page.clone()); @@ -1139,12 +1238,30 @@ async fn directory_tree_to_loader_tree_internal( .await?; } + // For dynamic segments like [id], find all static siblings at the same URL level. + // This is used by the client to determine if a prefetch can be reused when + // navigating between routes that share the same parent layout. + let static_siblings: Vec = if is_dynamic_segment(&directory_name) { + url_tree + .map(|t| { + t.static_children() + .into_iter() + .filter(|s| s != &directory_name) + .collect() + }) + .unwrap_or_default() + } else { + // Static segments don't need sibling info - only dynamic segments use it + Vec::new() + }; + let mut tree = AppPageLoaderTree { page: app_page.clone(), segment: directory_name.clone(), parallel_routes: FxIndexMap::default(), modules: modules.without_leaves(), global_metadata: global_metadata.to_resolved().await?, + static_siblings, }; let current_level_is_parallel_route = is_parallel_route(&directory_name); @@ -1169,6 +1286,7 @@ async fn directory_tree_to_loader_tree_internal( ..Default::default() }, global_metadata: global_metadata.to_resolved().await?, + static_siblings: Vec::new(), }, ); } @@ -1188,6 +1306,14 @@ async fn directory_tree_to_loader_tree_internal( illegal_path_error = Some(e); } + // Root/transparent segments don't consume a URL level; others descend. + let child_url_tree: Option<&UrlSegmentTree> = + if directory_name.is_empty() || is_url_transparent_segment(&directory_name) { + url_tree + } else { + url_tree.and_then(|t| t.get_child(&directory_name)) + }; + let subtree = Box::pin(directory_tree_to_loader_tree_internal( app_dir.clone(), global_metadata, @@ -1196,6 +1322,7 @@ async fn directory_tree_to_loader_tree_internal( child_app_page.clone(), for_app_path.clone(), parent_modules.clone(), + child_url_tree, )) .await?; @@ -1420,6 +1547,7 @@ async fn default_route_tree( } }, global_metadata: global_metadata.to_resolved().await?, + static_siblings: Vec::new(), }) } @@ -1662,12 +1790,14 @@ async fn directory_tree_to_entrypoints_internal_untraced( } }, global_metadata, + static_siblings: Vec::new(), } }, modules: AppDirModules { ..Default::default() }, global_metadata, + static_siblings: Vec::new(), }, }, modules: AppDirModules { @@ -1690,6 +1820,7 @@ async fn directory_tree_to_entrypoints_internal_untraced( ..not_found_root_modules }, global_metadata, + static_siblings: Vec::new(), } .resolved_cell(); @@ -1728,10 +1859,12 @@ async fn directory_tree_to_entrypoints_internal_untraced( ..Default::default() }, global_metadata, + static_siblings: Vec::new(), } }, modules: AppDirModules::default(), global_metadata, + static_siblings: Vec::new(), } .resolved_cell(); diff --git a/packages/next/errors.json b/packages/next/errors.json index 85df1a3dbef8c..e9b2e658d8ff4 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -978,5 +978,6 @@ "977": "maxPostponedStateSize must be a valid number (bytes) or filesize format string (e.g., \"5mb\")", "978": "Next.js has blocked a javascript: URL as a security precaution.", "979": "invariant: expected %s bytes of postponed state but only received %s bytes", - "980": "Failed to load client middleware manifest" + "980": "Failed to load client middleware manifest", + "981": "resolvedPathname must be set in request metadata" } diff --git a/packages/next/src/build/entries.ts b/packages/next/src/build/entries.ts index 25258e16bd7ab..78709221f405f 100644 --- a/packages/next/src/build/entries.ts +++ b/packages/next/src/build/entries.ts @@ -941,6 +941,7 @@ export async function createEntrypoints( pagePath: absolutePagePath, appDir, appPaths: matchedAppPaths, + allNormalizedAppPaths: Object.keys(appPathsPerRoute), pageExtensions, basePath: config.basePath, assetPrefix: config.assetPrefix, @@ -1019,6 +1020,7 @@ export async function createEntrypoints( pagePath: absolutePagePath, appDir: appDir!, appPaths: matchedAppPaths, + allNormalizedAppPaths: Object.keys(appPathsPerRoute), pageExtensions, basePath: config.basePath, assetPrefix: config.assetPrefix, diff --git a/packages/next/src/build/static-paths/app/extract-pathname-route-param-segments-from-loader-tree.test.ts b/packages/next/src/build/static-paths/app/extract-pathname-route-param-segments-from-loader-tree.test.ts index 8793b5b78d442..52ca66cd0b8b5 100644 --- a/packages/next/src/build/static-paths/app/extract-pathname-route-param-segments-from-loader-tree.test.ts +++ b/packages/next/src/build/static-paths/app/extract-pathname-route-param-segments-from-loader-tree.test.ts @@ -6,6 +6,7 @@ type TestLoaderTree = [ segment: string, parallelRoutes: { [key: string]: TestLoaderTree }, modules: Record, + staticSiblings: readonly string[] | null, ] function createLoaderTree( @@ -14,7 +15,7 @@ function createLoaderTree( children?: TestLoaderTree ): TestLoaderTree { const routes = children ? { ...parallelRoutes, children } : parallelRoutes - return [segment, routes, {}] + return [segment, routes, {}, null] } describe('extractPathnameRouteParamSegmentsFromLoaderTree', () => { diff --git a/packages/next/src/build/static-paths/utils.test.ts b/packages/next/src/build/static-paths/utils.test.ts index 8733020221c49..b300f12467cef 100644 --- a/packages/next/src/build/static-paths/utils.test.ts +++ b/packages/next/src/build/static-paths/utils.test.ts @@ -8,6 +8,7 @@ type TestLoaderTree = [ segment: string, parallelRoutes: { [key: string]: TestLoaderTree }, modules: Record, + staticSiblings: readonly string[] | null, ] function createLoaderTree( @@ -16,7 +17,7 @@ function createLoaderTree( children?: TestLoaderTree ): TestLoaderTree { const routes = children ? { ...parallelRoutes, children } : parallelRoutes - return [segment, routes, {}] + return [segment, routes, {}, null] } describe('resolveRouteParamsFromTree', () => { diff --git a/packages/next/src/build/templates/app-page.ts b/packages/next/src/build/templates/app-page.ts index 701d9ee902ba1..8a33936a02215 100644 --- a/packages/next/src/build/templates/app-page.ts +++ b/packages/next/src/build/templates/app-page.ts @@ -686,6 +686,9 @@ export async function handler( expireTime: nextConfig.expireTime, staleTimes: nextConfig.experimental.staleTimes, dynamicOnHover: Boolean(nextConfig.experimental.dynamicOnHover), + optimisticRouting: Boolean( + nextConfig.experimental.optimisticRouting + ), inlineCss: Boolean(nextConfig.experimental.inlineCss), authInterrupts: Boolean(nextConfig.experimental.authInterrupts), clientTraceMetadata: diff --git a/packages/next/src/build/templates/edge-ssr-app.ts b/packages/next/src/build/templates/edge-ssr-app.ts index bce4aa65dfaa0..25a1e4bca3ed9 100644 --- a/packages/next/src/build/templates/edge-ssr-app.ts +++ b/packages/next/src/build/templates/edge-ssr-app.ts @@ -156,6 +156,7 @@ async function requestHandler( expireTime: nextConfig.expireTime, staleTimes: nextConfig.experimental.staleTimes, dynamicOnHover: Boolean(nextConfig.experimental.dynamicOnHover), + optimisticRouting: Boolean(nextConfig.experimental.optimisticRouting), inlineCss: Boolean(nextConfig.experimental.inlineCss), authInterrupts: Boolean(nextConfig.experimental.authInterrupts), clientTraceMetadata: diff --git a/packages/next/src/build/webpack/loaders/next-app-loader/index.ts b/packages/next/src/build/webpack/loaders/next-app-loader/index.ts index e9fe2cc607125..25aa324c621f7 100644 --- a/packages/next/src/build/webpack/loaders/next-app-loader/index.ts +++ b/packages/next/src/build/webpack/loaders/next-app-loader/index.ts @@ -40,6 +40,7 @@ import type { Compilation } from 'webpack' import { createAppRouteCode } from './create-app-route-code' import { MissingDefaultParallelRouteError } from '../../../../shared/lib/errors/missing-default-parallel-route-error' import { isInterceptionRouteAppPath } from '../../../../shared/lib/router/utils/interception-routes' +import { normalizeAppPath } from '../../../../shared/lib/router/utils/app-paths' import { normalizePathSep } from '../../../../shared/lib/page-path/normalize-path-sep' import { installBindings } from '../../../swc/install-bindings' @@ -50,6 +51,9 @@ export type AppLoaderOptions = { pagePath: string appDir: string appPaths: readonly string[] | null + // All normalized app paths across the entire app, used for computing + // static siblings for dynamic segments + allNormalizedAppPaths: readonly string[] | null preferredRegion: string | string[] | undefined pageExtensions: PageExtensions assetPrefix: string @@ -124,6 +128,9 @@ const normalizeParallelKey = (key: string) => const isCatchAllSegment = (segment: string) => segment.startsWith('[...') || segment.startsWith('[[...') +const isDynamicSegment = (segment: string) => + segment.startsWith('[') && segment.endsWith(']') + const isDirectory = async (pathname: string) => { try { const stat = await fs.stat(pathname) @@ -141,11 +148,13 @@ async function createTreeCodeFromPath( resolver, resolveParallelSegments, hasChildRoutesForSegment, + getStaticSiblingSegments, metadataResolver, pageExtensions, basePath, collectedDeclarations, isGlobalNotFoundEnabled, + isDev, }: { page: string resolveDir: DirResolver @@ -155,11 +164,13 @@ async function createTreeCodeFromPath( pathname: string ) => [key: string, segment: string | string[]][] hasChildRoutesForSegment: (segmentPath: string) => boolean + getStaticSiblingSegments: (segmentPath: string) => string[] loaderContext: webpack.LoaderContext pageExtensions: PageExtensions basePath: string collectedDeclarations: [string, string][] isGlobalNotFoundEnabled: boolean + isDev: boolean } ): Promise<{ treeCode: string @@ -535,10 +546,16 @@ async function createTreeCodeFromPath( subtreeCode = pageSubtreeCode } + // Compute static siblings for dynamic segments. In dev mode, routes are + // compiled on-demand so we don't know all siblings; pass null. + const staticSiblingsCode = isDev + ? 'null' + : `${JSON.stringify(getStaticSiblingSegments(parallelSegmentPath))}` props[normalizedParallelKey] = `[ '${parallelSegmentKey}', ${subtreeCode}, - ${modulesCode} + ${modulesCode}, + ${staticSiblingsCode} ]` } @@ -661,6 +678,7 @@ const nextAppLoader: AppLoader = async function nextAppLoader() { name, appDir, appPaths, + allNormalizedAppPaths: allNormalizedAppPathsOption, pagePath, pageExtensions, rootDir, @@ -709,6 +727,9 @@ const nextAppLoader: AppLoader = async function nextAppLoader() { const normalizedAppPaths = typeof appPaths === 'string' ? [appPaths] : appPaths || [] + // All normalized app paths for computing static siblings across route groups + const allNormalizedAppPaths = allNormalizedAppPathsOption ?? [] + const resolveParallelSegments = ( pathname: string ): [string, string | string[]][] => { @@ -810,6 +831,91 @@ const nextAppLoader: AppLoader = async function nextAppLoader() { return false } + /** + * For a given segment path (in file system space, e.g., "(group)/products/[id]"), + * find all static sibling segments at the same URL path level. + * + * This accounts for route groups - siblings may exist in different parts of the + * file system tree but at the same URL level. + * + * For example: + * /app/(marketing)/products/sale/page.tsx -> /products/sale + * /app/(shop)/products/[id]/page.tsx -> /products/[id] + * + * When called with "(shop)/products/[id]", this would return ['sale']. + * + * TODO: This function, along with resolveParallelSegments and + * hasChildRoutesForSegment, repeatedly scans normalizedAppPaths. A more + * optimal approach would build an intermediate tree structure first + * (representing the URL namespace with route groups collapsed), then derive + * all this information in a single pass. The Turbopack implementation + * already uses a more tree-oriented approach (DirectoryTree -> + * AppPageLoaderTree), so this is less urgent to refactor given Turbopack is + * the canonical implementation going forward. + */ + const getStaticSiblingSegments = (segmentPath: string): string[] => { + // Normalize the current path to URL space + // Add a trailing /page so normalizeAppPath strips it properly + const currentUrlPath = normalizeAppPath(segmentPath + '/page') + const currentUrlSegments = currentUrlPath.split('/').filter(Boolean) + + // If the path is empty (root level), there are no siblings + if (currentUrlSegments.length === 0) { + return [] + } + + const currentSegment = currentUrlSegments[currentUrlSegments.length - 1] + const parentUrlPath = + currentUrlSegments.length === 1 + ? '/' + : '/' + currentUrlSegments.slice(0, -1).join('/') + + // The URL level at which we're looking for siblings (0-indexed) + const siblingLevel = currentUrlSegments.length - 1 + + // Only compute siblings for dynamic segments + if (!isDynamicSegment(currentSegment)) { + return [] + } + + // Use a Set to avoid duplicates (multiple paths may share the same sibling segment) + const siblings = new Set() + + for (const appPath of allNormalizedAppPaths) { + // Normalize each path to URL space (strip route groups, parallel routes, and /page suffix) + const urlPath = normalizeAppPath(appPath) + const urlSegments = urlPath.split('/').filter(Boolean) + + // Path must have at least enough segments to reach the sibling level + if (urlSegments.length <= siblingLevel) { + continue + } + + // Check if the parent path matches (all segments before the sibling level) + const pathParent = + siblingLevel === 0 + ? '/' + : '/' + urlSegments.slice(0, siblingLevel).join('/') + + if (pathParent !== parentUrlPath) { + continue + } + + // Get the segment at the same level as the current segment + const segmentAtLevel = urlSegments[siblingLevel] + + // Check if this is a sibling: different segment and static + if ( + segmentAtLevel !== currentSegment && + !isDynamicSegment(segmentAtLevel) + ) { + siblings.add(segmentAtLevel) + } + } + + return Array.from(siblings) + } + const resolveDir: DirResolver = (pathToResolve) => { return createAbsolutePath(appDir, pathToResolve) } @@ -917,11 +1023,13 @@ const nextAppLoader: AppLoader = async function nextAppLoader() { metadataResolver, resolveParallelSegments, hasChildRoutesForSegment, + getStaticSiblingSegments, loaderContext: this, pageExtensions, basePath, collectedDeclarations, isGlobalNotFoundEnabled, + isDev: !!isDev, }) const isGlobalNotFoundPath = @@ -975,11 +1083,13 @@ const nextAppLoader: AppLoader = async function nextAppLoader() { metadataResolver, resolveParallelSegments, hasChildRoutesForSegment, + getStaticSiblingSegments, loaderContext: this, pageExtensions, basePath, collectedDeclarations, isGlobalNotFoundEnabled, + isDev: !!isDev, }) } } diff --git a/packages/next/src/client/components/router-reducer/compute-changed-path.test.ts b/packages/next/src/client/components/router-reducer/compute-changed-path.test.ts index 835cf9933b5d7..326ab095e0afb 100644 --- a/packages/next/src/client/components/router-reducer/compute-changed-path.test.ts +++ b/packages/next/src/client/components/router-reducer/compute-changed-path.test.ts @@ -15,7 +15,7 @@ describe('computeChangedPath', () => { '(...)stats', { children: [ - ['key', 'github', 'd'], + ['key', 'github', 'd', null], { children: ['__PAGE__', {}], }, @@ -40,7 +40,7 @@ describe('computeChangedPath', () => { '(...)stats', { children: [ - ['key', 'github', 'd'], + ['key', 'github', 'd', null], { children: ['__PAGE__', {}], }, diff --git a/packages/next/src/client/components/router-reducer/create-router-cache-key.test.ts b/packages/next/src/client/components/router-reducer/create-router-cache-key.test.ts index ac0fd7188aadd..3320be8cfab8d 100644 --- a/packages/next/src/client/components/router-reducer/create-router-cache-key.test.ts +++ b/packages/next/src/client/components/router-reducer/create-router-cache-key.test.ts @@ -6,14 +6,14 @@ describe('createRouterCacheKey', () => { }) it('should support dynamic segment', () => { - expect(createRouterCacheKey(['slug', 'hello-world', 'd'])).toEqual( + expect(createRouterCacheKey(['slug', 'hello-world', 'd', null])).toEqual( 'slug|hello-world|d' ) }) it('should support catch all segment', () => { - expect(createRouterCacheKey(['slug', 'blog/hello-world', 'c'])).toEqual( - 'slug|blog/hello-world|c' - ) + expect( + createRouterCacheKey(['slug', 'blog/hello-world', 'c', null]) + ).toEqual('slug|blog/hello-world|c') }) }) diff --git a/packages/next/src/client/components/router-reducer/ppr-navigations.ts b/packages/next/src/client/components/router-reducer/ppr-navigations.ts index 55c08e68dc331..ef4e34812a92c 100644 --- a/packages/next/src/client/components/router-reducer/ppr-navigations.ts +++ b/packages/next/src/client/components/router-reducer/ppr-navigations.ts @@ -41,7 +41,9 @@ import { readFromBFCache, readFromBFCacheDuringRegularNavigation, writeToBFCache, + writeHeadToBFCache, } from '../segment-cache/bfcache' +import { DYNAMIC_STALETIME_MS } from './reducers/navigate-reducer' // This is yet another tree type that is used to track pending promises that // need to be fulfilled once the dynamic data is received. The terminal nodes of @@ -881,20 +883,28 @@ function createCacheNodeForSegment( switch (freshness) { case FreshnessPolicy.Default: { // When experimental.staleTimes.dynamic config is set, we read from the - // BFCache even during regular navigations. - const bfcacheEntry = readFromBFCacheDuringRegularNavigation( - now, - tree.varyPath - ) - if (bfcacheEntry !== null) { - return { - cacheNode: createCacheNode( - bfcacheEntry.rsc, - bfcacheEntry.prefetchRsc, - bfcacheEntry.head, - bfcacheEntry.prefetchHead - ), - needsDynamicRequest: false, + // BFCache even during regular navigations. (This is not a recommended API + // with Cache Components, but it's supported for backwards compatibility. + // Use cacheLife instead.) + + // This outer check isn't semantically necessary; even if the configured + // stale time is 0, the bfcache will return null, because any entry would + // have immediately expired. Just an optimization. + if (DYNAMIC_STALETIME_MS > 0) { + const bfcacheEntry = readFromBFCacheDuringRegularNavigation( + now, + tree.varyPath + ) + if (bfcacheEntry !== null) { + return { + cacheNode: createCacheNode( + bfcacheEntry.rsc, + bfcacheEntry.prefetchRsc, + bfcacheEntry.head, + bfcacheEntry.prefetchHead + ), + needsDynamicRequest: false, + } } } break @@ -921,6 +931,9 @@ function createCacheNodeForSegment( const head = isPage ? seedHead : null const prefetchHead = null writeToBFCache(now, tree.varyPath, rsc, prefetchRsc, head, prefetchHead) + if (isPage && metadataVaryPath !== null) { + writeHeadToBFCache(now, metadataVaryPath, head, prefetchHead) + } return { cacheNode: createCacheNode(rsc, prefetchRsc, head, prefetchHead), needsDynamicRequest: false, @@ -1126,6 +1139,9 @@ function createCacheNodeForSegment( // and will be replaced by the canonical navigation. if (freshness !== FreshnessPolicy.Gesture) { writeToBFCache(now, tree.varyPath, rsc, prefetchRsc, head, prefetchHead) + if (isPage && metadataVaryPath !== null) { + writeHeadToBFCache(now, metadataVaryPath, head, prefetchHead) + } } return { diff --git a/packages/next/src/client/components/router-reducer/should-hard-navigate.test.tsx b/packages/next/src/client/components/router-reducer/should-hard-navigate.test.tsx index fa8099c958244..7282b8a10b236 100644 --- a/packages/next/src/client/components/router-reducer/should-hard-navigate.test.tsx +++ b/packages/next/src/client/components/router-reducer/should-hard-navigate.test.tsx @@ -68,7 +68,7 @@ describe('shouldHardNavigate', () => { 'link-hard-push', { children: [ - ['id', '123', 'd'], + ['id', '123', 'd', null], { children: ['', {}], }, @@ -87,14 +87,14 @@ describe('shouldHardNavigate', () => { 'children', 'link-hard-push', 'children', - ['id', '123', 'd'], + ['id', '123', 'd', null], [ - ['id', '123', 'd'], + ['id', '123', 'd', null], { children: ['', {}], }, ], - [['id', '123', 'd'], {}, null], + [['id', '123', 'd', null], {}, null], null, ], ] @@ -125,7 +125,7 @@ describe('shouldHardNavigate', () => { 'link-hard-push', { children: [ - ['id', '456', 'd'], + ['id', '456', 'd', null], { children: ['', {}], }, @@ -144,14 +144,14 @@ describe('shouldHardNavigate', () => { 'children', 'link-hard-push', 'children', - ['id', '123', 'd'], + ['id', '123', 'd', null], [ - ['id', '123', 'd'], + ['id', '123', 'd', null], { children: ['', {}], }, ], - [['id', '123', 'd'], {}, null], + [['id', '123', 'd', null], {}, null], null, false, ], diff --git a/packages/next/src/client/components/segment-cache/bfcache.ts b/packages/next/src/client/components/segment-cache/bfcache.ts index 36859c3028d4e..9def472d364f4 100644 --- a/packages/next/src/client/components/segment-cache/bfcache.ts +++ b/packages/next/src/client/components/segment-cache/bfcache.ts @@ -39,6 +39,9 @@ export function writeToBFCache( const entry: BFCacheEntry = { rsc, prefetchRsc, + + // TODO: These fields will be removed from both BFCacheEntry and + // SegmentCacheEntry. The head has its own separate cache entry. head, prefetchHead, @@ -59,6 +62,16 @@ export function writeToBFCache( setInCacheMap(bfcacheMap, varyPath, entry, isRevalidation) } +export function writeHeadToBFCache( + now: number, + varyPath: SegmentVaryPath, + head: React.ReactNode, + prefetchHead: React.ReactNode +): void { + // Read the special "segment" that represents the head data. + writeToBFCache(now, varyPath, head, prefetchHead, null, null) +} + export function readFromBFCache( varyPath: SegmentVaryPath ): BFCacheEntry | null { @@ -79,13 +92,6 @@ export function readFromBFCacheDuringRegularNavigation( now: number, varyPath: SegmentVaryPath ): BFCacheEntry | null { - if (DYNAMIC_STALETIME_MS <= 0) { - // Only reuse the dynamic data if experimental.staleTimes.dynamic config - // is set, and the data is not stale. (This is not a recommended API with - // Cache Components, but it's supported for backwards compatibility. Use - // cacheLife instead.) - return null - } const isRevalidation = false return getFromCacheMap( now, diff --git a/packages/next/src/client/components/segment-cache/cache.ts b/packages/next/src/client/components/segment-cache/cache.ts index a650d016cf47f..66d0e84bb6d42 100644 --- a/packages/next/src/client/components/segment-cache/cache.ts +++ b/packages/next/src/client/components/segment-cache/cache.ts @@ -86,11 +86,15 @@ import { normalizeFlightData, prepareFlightRouterStateForRequest, } from '../../flight-data-helpers' -import { STATIC_STALETIME_MS } from '../router-reducer/reducers/navigate-reducer' +import { + DYNAMIC_STALETIME_MS, + STATIC_STALETIME_MS, +} from '../router-reducer/reducers/navigate-reducer' import { pingVisibleLinks } from '../links' import { PAGE_SEGMENT_KEY } from '../../../shared/lib/segment' import { FetchStrategy } from './types' import { createPromiseWithResolvers } from '../../../shared/lib/promise-with-resolvers' +import { readFromBFCacheDuringRegularNavigation } from './bfcache' /** * Ensures a minimum stale time of 30s to avoid issues where the server sends a too @@ -864,6 +868,51 @@ export function upgradeToPendingSegment( return pendingEntry } +export function attemptToFulfillDynamicSegmentFromBFCache( + now: number, + segment: EmptySegmentCacheEntry, + tree: RouteTree +): FulfilledSegmentCacheEntry | null { + // Attempts to fulfill an empty segment cache entry using data from the + // bfcache. This is only valid during a Full prefetch (i.e. one that includes + // dynamic data), because the bfcache stores data from navigations which + // always include dynamic data. + + // We always use the canonical vary path when checking the bfcache. This is + // the same operation we'd use to access the cache during a + // regular navigation. + const varyPath = tree.varyPath + + // The stale time for dynamic prefetches (default: 5 mins) is different from + // the stale time for regular navigations (default: 0 secs). We adjust the + // current timestamp to account for the difference. + const adjustedCurrentTime = now - STATIC_STALETIME_MS + DYNAMIC_STALETIME_MS + const bfcacheEntry = readFromBFCacheDuringRegularNavigation( + adjustedCurrentTime, + varyPath + ) + if (bfcacheEntry !== null) { + // Fulfill the prefetch using the bfcache entry. + + // As explained above, the stale time of this prefetch entry is different + // than the one for the bfcache. Calculate when it was originally requested + // by subtracting the stale time used by the bfcache. + const requestedAt = bfcacheEntry.staleAt - DYNAMIC_STALETIME_MS + // Now add the stale time used by dynamic prefetches. + const dynamicPrefetchStaleAt = requestedAt + STATIC_STALETIME_MS + + const pendingSegment = upgradeToPendingSegment(segment, FetchStrategy.Full) + const isPartial = false + return fulfillSegmentCacheEntry( + pendingSegment, + bfcacheEntry.rsc, + dynamicPrefetchStaleAt, + isPartial + ) + } + return null +} + function pingBlockedTasks(entry: { blockedTasks: Set | null }): void { @@ -1015,17 +1064,16 @@ function convertTreePrefetchToRouteTree( slots = {} for (let parallelRouteKey in prefetchSlots) { const childPrefetch = prefetchSlots[parallelRouteKey] - const childParamName = childPrefetch.name - const childParamType = childPrefetch.paramType - const childServerSentParamKey = childPrefetch.paramKey + const childSegmentName = childPrefetch.name + const childParam = childPrefetch.param let childDoesAppearInURL: boolean let childSegment: FlightRouterStateSegment let childPartialVaryPath: PartialSegmentVaryPath | null - if (childParamType !== null) { + if (childParam !== null) { // This segment is parameterized. Get the param from the pathname. const childParamValue = parseDynamicParamFromURLPart( - childParamType, + childParam.type, pathnameParts, pathnamePartsIndex ) @@ -1043,8 +1091,8 @@ function convertTreePrefetchToRouteTree( const childParamKey = // The server omits this field from the prefetch response when // cacheComponents is enabled. - childServerSentParamKey !== null - ? childServerSentParamKey + childParam.key !== null + ? childParam.key : // If no param key was sent, use the value parsed on the client. getCacheKeyForDynamicParam( childParamValue, @@ -1055,14 +1103,19 @@ function convertTreePrefetchToRouteTree( partialVaryPath, childParamKey ) - childSegment = [childParamName, childParamKey, childParamType] + childSegment = [ + childSegmentName, + childParamKey, + childParam.type, + childParam.siblings, + ] childDoesAppearInURL = true } else { // This segment does not have a param. Inherit the partial vary path of // the parent. childPartialVaryPath = partialVaryPath - childSegment = childParamName - childDoesAppearInURL = doesStaticSegmentAppearInURL(childParamName) + childSegment = childSegmentName + childDoesAppearInURL = doesStaticSegmentAppearInURL(childSegmentName) } // Only increment the index if the segment appears in the URL. If it's a diff --git a/packages/next/src/client/components/segment-cache/scheduler.ts b/packages/next/src/client/components/segment-cache/scheduler.ts index c2fa13a9d664b..348d2a29c95a1 100644 --- a/packages/next/src/client/components/segment-cache/scheduler.ts +++ b/packages/next/src/client/components/segment-cache/scheduler.ts @@ -25,6 +25,7 @@ import { waitForSegmentCacheEntry, overwriteRevalidatingSegmentCacheEntry, canNewFetchStrategyProvideMoreContent, + attemptToFulfillDynamicSegmentFromBFCache, } from './cache' import { getSegmentVaryPathForRequest, type SegmentVaryPath } from './vary-path' import type { RouteCacheKey } from './cache-key' @@ -1269,7 +1270,20 @@ function pingRouteTreeAndIncludeDynamicData( switch (segment.status) { case EntryStatus.Empty: { - // This segment is not cached. Include it in the request. + // This segment is not cached. + if (fetchStrategy === FetchStrategy.Full) { + // Check if there's a matching entry in the bfcache. If so, fulfill the + // segment using the bfcache entry instead of issuing a new request. + const fulfilled = attemptToFulfillDynamicSegmentFromBFCache( + now, + segment, + tree + ) + if (fulfilled !== null) { + break + } + } + // Include it in the request. spawnedSegment = upgradeToPendingSegment(segment, fetchStrategy) break } diff --git a/packages/next/src/client/flight-data-helpers.test.ts b/packages/next/src/client/flight-data-helpers.test.ts index 84734cbaa195c..c82a515bf74db 100644 --- a/packages/next/src/client/flight-data-helpers.test.ts +++ b/packages/next/src/client/flight-data-helpers.test.ts @@ -46,7 +46,12 @@ describe('prepareFlightRouterStateForRequest', () => { }) it('should preserve dynamic segments', () => { - const dynamicSegment: [string, string, 'd'] = ['slug', 'test-value', 'd'] + const dynamicSegment: [string, string, 'd', null] = [ + 'slug', + 'test-value', + 'd', + null, + ] const flightRouterState: FlightRouterState = [dynamicSegment, {}] const result = prepareFlightRouterStateForRequest(flightRouterState) @@ -266,7 +271,12 @@ describe('prepareFlightRouterStateForRequest', () => { true, 1, ], - sidebar: [['slug', 'user-123', 'd'], {}, ['/sidebar/url', ''], null], + sidebar: [ + ['slug', 'user-123', 'd', null], + {}, + ['/sidebar/url', ''], + null, + ], }, ['/main/url', ''], 'inside-shared-layout', @@ -300,7 +310,7 @@ describe('prepareFlightRouterStateForRequest', () => { // Sidebar route (dynamic segment) checks const sidebarRoute = decoded[1].sidebar - expect(sidebarRoute[0]).toEqual(['slug', 'user-123', 'd']) // dynamic segment preserved + expect(sidebarRoute[0]).toEqual(['slug', 'user-123', 'd', null]) // dynamic segment preserved expect(sidebarRoute[2]).toBeUndefined() // URL stripped expect(sidebarRoute[3]).toBeUndefined() // null marker stripped }) diff --git a/packages/next/src/client/flight-data-helpers.ts b/packages/next/src/client/flight-data-helpers.ts index 2b36ba7141745..4adf90fd2ebf0 100644 --- a/packages/next/src/client/flight-data-helpers.ts +++ b/packages/next/src/client/flight-data-helpers.ts @@ -152,13 +152,14 @@ function fillInFallbackFlightRouterStateImpl( } else { const paramName = originalSegment[0] const paramType = originalSegment[2] + const staticSiblings = originalSegment[3] const paramValue = parseDynamicParamFromURLPart( paramType, pathnameParts, pathnamePartsIndex ) const cacheKey = getCacheKeyForDynamicParam(paramValue, renderedSearch) - newSegment = [paramName, cacheKey, paramType] + newSegment = [paramName, cacheKey, paramType, staticSiblings] doesAppearInURL = true } @@ -250,9 +251,8 @@ function stripClientOnlyDataFromFlightRouterState( hasLoadingBoundary, ] = flightRouterState - // __PAGE__ segments are always fetched from the server, so there's - // no need to send them up - const cleanedSegment = stripSearchParamsFromPageSegment(segment) + // Strip client-only data from the segment + const cleanedSegment = stripClientOnlyDataFromSegment(segment) // Recursively process parallel routes const cleanedParallelRoutes: { [key: string]: FlightRouterState } = {} @@ -280,15 +280,21 @@ function stripClientOnlyDataFromFlightRouterState( } /** - * Strips search parameters from __PAGE__ segments to prevent sensitive - * client-side data from being sent to the server. + * Strips client-only data from segments: + * - Search parameters from __PAGE__ segments + * - staticSiblings from dynamic segment tuples (only needed for client-side + * prefetch reuse decisions) */ -function stripSearchParamsFromPageSegment(segment: Segment): Segment { - if ( - typeof segment === 'string' && - segment.startsWith(PAGE_SEGMENT_KEY + '?') - ) { - return PAGE_SEGMENT_KEY +function stripClientOnlyDataFromSegment(segment: Segment): Segment { + if (typeof segment === 'string') { + // Strip search params from __PAGE__ segments + if (segment.startsWith(PAGE_SEGMENT_KEY + '?')) { + return PAGE_SEGMENT_KEY + } + return segment } - return segment + // Dynamic segment tuple: [paramName, paramCacheKey, paramType, staticSiblings] + // Strip staticSiblings (4th element) since server doesn't need it + const [paramName, paramCacheKey, paramType] = segment + return [paramName, paramCacheKey, paramType, null] } diff --git a/packages/next/src/export/index.ts b/packages/next/src/export/index.ts index 9495e3121716f..3d317196ddd30 100644 --- a/packages/next/src/export/index.ts +++ b/packages/next/src/export/index.ts @@ -395,6 +395,7 @@ async function exportAppImpl( clientParamParsingOrigins: nextConfig.experimental.clientParamParsingOrigins, dynamicOnHover: nextConfig.experimental.dynamicOnHover ?? false, + optimisticRouting: nextConfig.experimental.optimisticRouting ?? false, inlineCss: nextConfig.experimental.inlineCss ?? false, authInterrupts: !!nextConfig.experimental.authInterrupts, maxPostponedStateSizeBytes: parseMaxPostponedStateSize( diff --git a/packages/next/src/export/worker.ts b/packages/next/src/export/worker.ts index 1a514b3dac17f..427afc0fe04a0 100644 --- a/packages/next/src/export/worker.ts +++ b/packages/next/src/export/worker.ts @@ -24,6 +24,7 @@ import { trace } from '../trace' import { setHttpClientAndAgentOptions } from '../server/setup-http-agent-env' import { addRequestMeta } from '../server/request-meta' import { normalizeAppPath } from '../shared/lib/router/utils/app-paths' +import { removeTrailingSlash } from '../shared/lib/router/utils/remove-trailing-slash' import { createRequestResponseMocks } from '../server/lib/mock-request' import { isAppRouteRoute } from '../lib/is-app-route-route' @@ -176,6 +177,9 @@ async function exportPageImpl( req.url += '/' } + // Set the resolved pathname without trailing slash as request metadata. + addRequestMeta(req, 'resolvedPathname', removeTrailingSlash(updatedPath)) + if ( locale && buildExport && diff --git a/packages/next/src/lib/metadata/resolve-metadata.ts b/packages/next/src/lib/metadata/resolve-metadata.ts index b0116dcb30543..c379427ae4116 100644 --- a/packages/next/src/lib/metadata/resolve-metadata.ts +++ b/packages/next/src/lib/metadata/resolve-metadata.ts @@ -746,7 +746,7 @@ async function resolveMetadataItemsImpl( const isPage = typeof page !== 'undefined' // Handle dynamic segment params. - const segmentParam = getDynamicParamFromSegment(segment) + const segmentParam = getDynamicParamFromSegment(tree) /** * Create object holding the parent params and current params */ @@ -842,7 +842,7 @@ async function resolveViewportItemsImpl( const isPage = typeof page !== 'undefined' // Handle dynamic segment params. - const segmentParam = getDynamicParamFromSegment(segment) + const segmentParam = getDynamicParamFromSegment(tree) /** * Create object holding the parent params and current params */ diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 216e98d629281..314156cad84b8 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -3,8 +3,8 @@ import type { RenderOpts, PreloadCallbacks } from './types' import type { ActionResult, DynamicParamTypesShort, + DynamicSegmentTuple, FlightRouterState, - Segment, CacheNodeSeedData, RSCPayload, FlightData, @@ -219,14 +219,14 @@ import { anySegmentHasRuntimePrefetchEnabled } from './staged-validation' import { warnOnce } from '../../shared/lib/utils/warn-once' export type GetDynamicParamFromSegment = ( - // [slug] / [[slug]] / [...slug] - segment: string + // The LoaderTree to extract the dynamic param from + loaderTree: LoaderTree ) => DynamicParam | null export type DynamicParam = { param: string value: string | string[] | null - treeSegment: Segment + treeSegment: DynamicSegmentTuple type: DynamicParamTypesShort } @@ -385,10 +385,11 @@ function createNotFoundLoaderTree(loaderTree: LoaderTree): LoaderTree { return [ '', { - children: [PAGE_SEGMENT_KEY, {}, notFoundTreeComponents], + children: [PAGE_SEGMENT_KEY, {}, notFoundTreeComponents, null], }, // When global-not-found is present, skip layout from components hasGlobalNotFound ? components : {}, + null, // staticSiblings ] } @@ -397,23 +398,25 @@ function createNotFoundLoaderTree(loaderTree: LoaderTree): LoaderTree { */ function makeGetDynamicParamFromSegment( interpolatedParams: Params, - fallbackRouteParams: OpaqueFallbackRouteParams | null + fallbackRouteParams: OpaqueFallbackRouteParams | null, + optimisticRouting: boolean ): GetDynamicParamFromSegment { - return function getDynamicParamFromSegment( - // [slug] / [[slug]] / [...slug] - segment: string - ) { + return function getDynamicParamFromSegment(loaderTree: LoaderTree) { + const [segment, , , staticSiblings] = loaderTree const segmentParam = getSegmentParam(segment) if (!segmentParam) { return null } const segmentKey = segmentParam.paramName const dynamicParamType = dynamicParamTypes[segmentParam.paramType] + // Static siblings are only included when optimistic routing is enabled + const siblings = optimisticRouting ? staticSiblings : null return getDynamicParam( interpolatedParams, segmentKey, dynamicParamType, - fallbackRouteParams + fallbackRouteParams, + siblings ) } } @@ -2012,23 +2015,22 @@ async function renderToHTMLOrFlightImpl( const getDynamicParamFromSegment = makeGetDynamicParamFromSegment( interpolatedParams, - fallbackRouteParams + fallbackRouteParams, + renderOpts.experimental.optimisticRouting ) const isPossibleActionRequest = getIsPossibleServerAction(req) - // For implicit tags, we need to use the rewritten pathname (if a rewrite - // occurred) rather than the original request pathname. Implicit tags are used - // to check cache staleness on read (for 'use cache') and as soft tags for - // fetch cache. Using the destination path ensures that - // revalidatePath('/dest') invalidates cache entries for pages rewritten to - // that destination. - const implicitTagsPathname = - getRequestMeta(req, 'rewrittenPathname') || url.pathname + // For implicit tags, we use the resolved pathname which has dynamic params + // interpolated, is decoded, and has trailing slash removed. + const resolvedPathname = getRequestMeta(req, 'resolvedPathname') + if (!resolvedPathname) { + throw new InvariantError('resolvedPathname must be set in request metadata') + } const implicitTags = await getImplicitTags( workStore.page, - implicitTagsPathname, + resolvedPathname, fallbackRouteParams ) 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 52afb33b350b4..bec0d56055d4c 100644 --- a/packages/next/src/server/app-render/collect-segment-data.tsx +++ b/packages/next/src/server/app-render/collect-segment-data.tsx @@ -40,13 +40,25 @@ export type RootTreePrefetch = { staleTime: number } -export type TreePrefetch = { - name: string - paramType: DynamicParamTypesShort | null +export type TreePrefetchParam = { + type: DynamicParamTypesShort // When cacheComponents is enabled, this field is always null. // Instead we parse the param on the client, allowing us to omit it from // the prefetch response and increase its cacheability. - paramKey: string | null + key: string | null + // Static sibling segments at the same URL level. Used by the client + // router to determine if a prefetch can be reused when navigating to + // a static sibling of a dynamic route. For example, if the route is + // /products/[id] and there's also /products/sale, then siblings + // would be ['sale']. null means the siblings are unknown (e.g. in + // webpack dev mode). + siblings: readonly string[] | null +} + +export type TreePrefetch = { + name: string + // Only present for parameterized (dynamic) segments. + param: TreePrefetchParam | null // Child segments. slots: null | { @@ -320,27 +332,27 @@ function collectSegmentDataImpl( } const segment = route[0] - let name - let paramType: DynamicParamTypesShort | null = null - let paramKey: string | null = null + let name: string + let param: TreePrefetchParam | null if (typeof segment === 'string') { name = segment - paramKey = segment - paramType = null + param = null } else { name = segment[0] - paramKey = segment[1] - paramType = segment[2] as DynamicParamTypesShort + param = { + type: segment[2], + // This value is omitted from the prefetch response when cacheComponents + // is enabled. + key: isClientParamParsingEnabled ? null : segment[1], + siblings: segment[3], + } } // Metadata about the segment. Sent to the client as part of the // tree prefetch. return { name, - paramType, - // This value is ommitted from the prefetch response when cacheComponents - // is enabled. - paramKey: isClientParamParsingEnabled ? null : paramKey, + param, hasRuntimePrefetch, slots: slotMetadata, isRootLayout: route[4] === true, diff --git a/packages/next/src/server/app-render/create-component-tree.tsx b/packages/next/src/server/app-render/create-component-tree.tsx index 3cc532eb7b03d..c21d19a383eb6 100644 --- a/packages/next/src/server/app-render/create-component-tree.tsx +++ b/packages/next/src/server/app-render/create-component-tree.tsx @@ -403,7 +403,7 @@ async function createComponentTreeInternal( } // Handle dynamic segment params. - const segmentParam = getDynamicParamFromSegment(segment) + const segmentParam = getDynamicParamFromSegment(tree) // Create object holding the parent params and current params let currentParams: Params = parentParams @@ -1104,12 +1104,11 @@ function getRootParamsImpl( getDynamicParamFromSegment: GetDynamicParamFromSegment ): Params { const { - segment, modules: { layout }, parallelRoutes, } = parseLoaderTree(loaderTree) - const segmentParam = getDynamicParamFromSegment(segment) + const segmentParam = getDynamicParamFromSegment(loaderTree) let currentParams: Params = parentParams if (segmentParam && segmentParam.value !== null) { 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 ff998cdfad39f..f51789e762d66 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 @@ -7,13 +7,14 @@ import type { GetDynamicParamFromSegment } from './app-render' import { addSearchParamsIfPageSegment } from '../../shared/lib/segment' function createFlightRouterStateFromLoaderTreeImpl( - [segment, parallelRoutes, { layout, loading }]: LoaderTree, + loaderTree: LoaderTree, getDynamicParamFromSegment: GetDynamicParamFromSegment, searchParams: any, includeHasLoadingBoundary: boolean, didFindRootLayout: boolean ): FlightRouterState { - const dynamicParam = getDynamicParamFromSegment(segment) + const [segment, parallelRoutes, { layout, loading }] = loaderTree + const dynamicParam = getDynamicParamFromSegment(loaderTree) const treeSegment = dynamicParam ? dynamicParam.treeSegment : segment const segmentTree: FlightRouterState = [ diff --git a/packages/next/src/server/app-render/postponed-state.ts b/packages/next/src/server/app-render/postponed-state.ts index ffec29a44975e..2fd082a1fe215 100644 --- a/packages/next/src/server/app-render/postponed-state.ts +++ b/packages/next/src/server/app-render/postponed-state.ts @@ -181,7 +181,8 @@ export function parsePostponedState( interpolatedParams, segmentKey, dynamicParamType, - null + null, + null // staticSiblings not needed for postponed state ) postponed = postponed.replaceAll(searchValue, value) diff --git a/packages/next/src/server/app-render/types.test.ts b/packages/next/src/server/app-render/types.test.ts index 8452d988fa7ca..81467d8bd1746 100644 --- a/packages/next/src/server/app-render/types.test.ts +++ b/packages/next/src/server/app-render/types.test.ts @@ -3,27 +3,27 @@ import { assert } from 'next/dist/compiled/superstruct' const validFixtures = [ [ - ['a', 'b', 'c'], + ['a', 'b', 'c', null], { - a: [['a', 'b', 'c'], {}], - b: [['a', 'b', 'c'], {}], + a: [['a', 'b', 'c', null], {}], + b: [['a', 'b', 'c', null], {}], }, ], [ - ['a', 'b', 'c'], + ['a', 'b', 'c', ['sibling1', 'sibling2']], { - a: [['a', 'b', 'c'], {}], - b: [['a', 'b', 'c'], {}], + a: [['a', 'b', 'c', null], {}], + b: [['a', 'b', 'c', []], {}], }, null, null, true, ], [ - ['a', 'b', 'c'], + ['a', 'b', 'c', null], { - a: [['a', 'b', 'c'], {}], - b: [['a', 'b', 'c'], {}], + a: [['a', 'b', 'c', null], {}], + b: [['a', 'b', 'c', null], {}], }, null, 'refetch', @@ -33,14 +33,20 @@ const validFixtures = [ const invalidFixtures = [ // plain wrong ['1', 'b', 'c'], - // invalid enum + // invalid enum (missing 4th element) [['a', 'b', 'foo'], {}], + // invalid enum (with 4th element) + [['a', 'b', 'foo', null], {}], + // invalid staticSiblings (not an array) + [['a', 'b', 'c', 'not-an-array'], {}], + // invalid staticSiblings (array with non-strings) + [['a', 'b', 'c', [1, 2]], {}], // invalid url [ - ['a', 'b', 'c'], + ['a', 'b', 'c', null], { - a: [['a', 'b', 'c'], {}], - b: [['a', 'b', 'c'], {}], + a: [['a', 'b', 'c', null], {}], + b: [['a', 'b', 'c', null], {}], }, { invalid: 'invalid', @@ -48,10 +54,10 @@ const invalidFixtures = [ ], // invalid isRootLayout [ - ['a', 'b', 'c'], + ['a', 'b', 'c', null], { - a: [['a', 'b', 'c'], {}], - b: [['a', 'b', 'c'], {}], + a: [['a', 'b', 'c', null], {}], + b: [['a', 'b', 'c', null], {}], }, null, 1, diff --git a/packages/next/src/server/app-render/types.ts b/packages/next/src/server/app-render/types.ts index 9ca8268e67946..f4472eb276936 100644 --- a/packages/next/src/server/app-render/types.ts +++ b/packages/next/src/server/app-render/types.ts @@ -49,6 +49,10 @@ const segmentSchema = s.union([ s.string(), // Dynamic param type dynamicParamTypesSchema, + // Static siblings at the same URL level. Used by the client router to + // determine if a prefetch can be reused when navigating to a static + // sibling of a dynamic route. null means siblings are unknown. + s.nullable(s.array(s.string())), ]), ]) @@ -155,6 +159,7 @@ export interface RenderOptsPartial { */ clientParamParsingOrigins: string[] | undefined dynamicOnHover: boolean + optimisticRouting: boolean inlineCss: boolean authInterrupts: boolean diff --git a/packages/next/src/server/app-render/walk-tree-with-flight-router-state.tsx b/packages/next/src/server/app-render/walk-tree-with-flight-router-state.tsx index 0c9e6b791961a..a0efabce9d9d0 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 @@ -77,7 +77,7 @@ export async function walkTreeWithFlightRouterState({ rootLayoutIncluded || rootLayoutAtThisLevel // Because this function walks to a deeper point in the tree to start rendering we have to track the dynamic parameters up to the point where rendering starts - const segmentParam = getDynamicParamFromSegment(segment) + const segmentParam = getDynamicParamFromSegment(loaderTreeToFilter) const currentParams = // Handle null case where dynamic param is optional segmentParam && segmentParam.value !== null diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index ea47bcf28289c..16765f75f9caf 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -562,6 +562,8 @@ export default abstract class Server< clientParamParsingOrigins: this.nextConfig.experimental.clientParamParsingOrigins, dynamicOnHover: this.nextConfig.experimental.dynamicOnHover ?? false, + optimisticRouting: + this.nextConfig.experimental.optimisticRouting ?? false, inlineCss: this.nextConfig.experimental.inlineCss ?? false, authInterrupts: !!this.nextConfig.experimental.authInterrupts, maxPostponedStateSizeBytes: parseMaxPostponedStateSize( diff --git a/packages/next/src/server/config-schema.ts b/packages/next/src/server/config-schema.ts index 0f28d6b3b041a..c62ef896ffb77 100644 --- a/packages/next/src/server/config-schema.ts +++ b/packages/next/src/server/config-schema.ts @@ -195,6 +195,7 @@ export const experimentalSchema = { caseSensitiveRoutes: z.boolean().optional(), clientParamParsingOrigins: z.array(z.string()).optional(), dynamicOnHover: z.boolean().optional(), + optimisticRouting: z.boolean().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 e6f575f048092..6519f151636e1 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -287,6 +287,7 @@ export interface ExperimentalConfig { */ clientParamParsingOrigins?: string[] dynamicOnHover?: boolean + optimisticRouting?: boolean preloadEntriesOnStart?: boolean clientRouterFilter?: boolean clientRouterFilterRedirects?: boolean @@ -1669,6 +1670,7 @@ export interface NextConfigRuntime { | 'serverActions' | 'staleTimes' | 'dynamicOnHover' + | 'optimisticRouting' | 'inlineCss' | 'authInterrupts' | 'clientTraceMetadata' @@ -1730,6 +1732,7 @@ export function getNextConfigRuntime( serverActions: ex.serverActions, staleTimes: ex.staleTimes, dynamicOnHover: ex.dynamicOnHover, + optimisticRouting: ex.optimisticRouting, inlineCss: ex.inlineCss, authInterrupts: ex.authInterrupts, clientTraceMetadata: ex.clientTraceMetadata, diff --git a/packages/next/src/server/dev/hot-reloader-webpack.ts b/packages/next/src/server/dev/hot-reloader-webpack.ts index a821c78ccdcb8..bdcd7147c1166 100644 --- a/packages/next/src/server/dev/hot-reloader-webpack.ts +++ b/packages/next/src/server/dev/hot-reloader-webpack.ts @@ -1027,6 +1027,7 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface { name: bundlePath, page, appPaths: entryData.appPaths, + allNormalizedAppPaths: null, // Not available in dev mode pagePath: posix.join( APP_DIR_ALIAS, relative( @@ -1156,6 +1157,7 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface { name: bundlePath, page, appPaths: entryData.appPaths, + allNormalizedAppPaths: null, // Not available in dev mode pagePath, appDir: this.appDir!, pageExtensions: this.config.pageExtensions, diff --git a/packages/next/src/server/lib/app-dir-module.ts b/packages/next/src/server/lib/app-dir-module.ts index 3e5bc9dfcf582..609d0aead8e2b 100644 --- a/packages/next/src/server/lib/app-dir-module.ts +++ b/packages/next/src/server/lib/app-dir-module.ts @@ -8,6 +8,24 @@ export type LoaderTree = [ segment: string, parallelRoutes: { [parallelRouterKey: string]: LoaderTree }, modules: AppDirModules, + /** + * At build time, for each dynamic segment, we compute the list of static + * sibling segments that exist at the same URL path level. This is used by + * the client router to determine if a prefetch can be reused. + * + * For example, given the following file structure: + * /app/(group1)/products/sale/page.tsx -> /products/sale + * /app/(group2)/products/[id]/page.tsx -> /products/[id] + * + * The [id] segment would have staticSiblings: ['sale'] + * + * This accounts for route groups, which may place sibling routes in + * different parts of the file system tree but at the same URL level. + * + * A value of `null` means the static siblings are unknown (e.g., in webpack + * dev mode where routes are compiled on-demand). + */ + staticSiblings: readonly string[] | null, ] export async function getLayoutOrPageModule(loaderTree: LoaderTree) { diff --git a/packages/next/src/server/request-meta.ts b/packages/next/src/server/request-meta.ts index c72e4b67fef04..136a97742c02a 100644 --- a/packages/next/src/server/request-meta.ts +++ b/packages/next/src/server/request-meta.ts @@ -86,6 +86,12 @@ export interface RequestMeta { */ rewrittenPathname?: string + /** + * The resolved pathname for the request. Dynamic route params are + * interpolated, the pathname is decoded, and the trailing slash is removed. + */ + resolvedPathname?: string + /** * The cookies that were added by middleware and were added to the response. */ diff --git a/packages/next/src/server/request/fallback-params.test.ts b/packages/next/src/server/request/fallback-params.test.ts index c16eb0f18ea2e..9b1187be02cd4 100644 --- a/packages/next/src/server/request/fallback-params.test.ts +++ b/packages/next/src/server/request/fallback-params.test.ts @@ -11,6 +11,7 @@ type TestLoaderTree = [ segment: string, parallelRoutes: { [key: string]: TestLoaderTree }, modules: Record, + staticSiblings: readonly string[] | null, ] function createLoaderTree( @@ -19,7 +20,7 @@ function createLoaderTree( children?: TestLoaderTree ): TestLoaderTree { const routes = children ? { ...parallelRoutes, children } : parallelRoutes - return [segment, routes, {}] + return [segment, routes, {}, null] } /** diff --git a/packages/next/src/server/route-modules/route-module.ts b/packages/next/src/server/route-modules/route-module.ts index 7110a2fa6a861..b8229c52765c6 100644 --- a/packages/next/src/server/route-modules/route-module.ts +++ b/packages/next/src/server/route-modules/route-module.ts @@ -935,6 +935,7 @@ export abstract class RouteModule< } catch (_) {} resolvedPathname = removeTrailingSlash(resolvedPathname) + addRequestMeta(req, 'resolvedPathname', resolvedPathname) let deploymentId if (nextConfig.experimental?.runtimeServerDeploymentId) { diff --git a/packages/next/src/server/web/spec-extension/revalidate.ts b/packages/next/src/server/web/spec-extension/revalidate.ts index 93109275f21c2..ee7b9021a320b 100644 --- a/packages/next/src/server/web/spec-extension/revalidate.ts +++ b/packages/next/src/server/web/spec-extension/revalidate.ts @@ -15,6 +15,7 @@ import { ActionDidRevalidateDynamicOnly, ActionDidRevalidateStaticAndDynamic as ActionDidRevalidate, } from '../../../shared/lib/action-revalidation-kind' +import { removeTrailingSlash } from '../../../shared/lib/router/utils/remove-trailing-slash' type CacheLifeConfig = { expire?: number @@ -96,7 +97,7 @@ export function revalidatePath(originalPath: string, type?: 'layout' | 'page') { return } - let normalizedPath = `${NEXT_CACHE_IMPLICIT_TAG_ID}${originalPath || '/'}` + let normalizedPath = `${NEXT_CACHE_IMPLICIT_TAG_ID}${removeTrailingSlash(originalPath)}` if (type) { normalizedPath += `${normalizedPath.endsWith('/') ? '' : '/'}${type}` diff --git a/packages/next/src/shared/lib/app-router-types.ts b/packages/next/src/shared/lib/app-router-types.ts index dd46fbbfd0e92..751c97876b68c 100644 --- a/packages/next/src/shared/lib/app-router-types.ts +++ b/packages/next/src/shared/lib/app-router-types.ts @@ -75,21 +75,29 @@ export type DynamicParamTypesShort = | 'di(..)' | 'di(...)' -export type Segment = - | string - | [ - // Param name - paramName: string, - // Param cache key (almost the same as the value, but arrays are - // concatenated into strings) - // TODO: We should change this to just be the value. Currently we convert - // it back to a value when passing to useParams. It only needs to be - // a string when converted to a a cache key, but that doesn't mean we - // need to store it as that representation. - paramCacheKey: string, - // Dynamic param type - dynamicParamType: DynamicParamTypesShort, - ] +// The tuple form of a segment, used for dynamic route params +export type DynamicSegmentTuple = [ + // Param name + paramName: string, + // Param cache key (almost the same as the value, but arrays are + // concatenated into strings) + // TODO: We should change this to just be the value. Currently we convert + // it back to a value when passing to useParams. It only needs to be + // a string when converted to a a cache key, but that doesn't mean we + // need to store it as that representation. + paramCacheKey: string, + // Dynamic param type + dynamicParamType: DynamicParamTypesShort, + // Static sibling segments at the same URL level. Used by the client + // router to determine if a prefetch can be reused when navigating to + // a static sibling of a dynamic route. For example, if the route is + // /products/[id] and there's also /products/sale, then staticSiblings + // would be ['sale']. null means the siblings are unknown (e.g. in + // webpack dev mode). + staticSiblings: readonly string[] | null, +] + +export type Segment = string | DynamicSegmentTuple /** * Router state diff --git a/packages/next/src/shared/lib/router/utils/get-dynamic-param.test.ts b/packages/next/src/shared/lib/router/utils/get-dynamic-param.test.ts index 900d588062273..f6fe278a3b427 100644 --- a/packages/next/src/shared/lib/router/utils/get-dynamic-param.test.ts +++ b/packages/next/src/shared/lib/router/utils/get-dynamic-param.test.ts @@ -12,37 +12,37 @@ describe('getDynamicParam', () => { describe('basic dynamic parameters (d, di)', () => { it('should handle simple string parameter', () => { const params: Params = { slug: 'hello-world' } - const result = getDynamicParam(params, 'slug', 'd', null) + const result = getDynamicParam(params, 'slug', 'd', null, null) expect(result).toEqual({ param: 'slug', value: 'hello-world', type: 'd', - treeSegment: ['slug', 'hello-world', 'd'], + treeSegment: ['slug', 'hello-world', 'd', null], }) }) it('should encode special characters in string parameters', () => { const params: Params = { slug: 'hello world & stuff' } - const result = getDynamicParam(params, 'slug', 'd', null) + const result = getDynamicParam(params, 'slug', 'd', null, null) expect(result).toEqual({ param: 'slug', value: 'hello%20world%20%26%20stuff', type: 'd', - treeSegment: ['slug', 'hello%20world%20%26%20stuff', 'd'], + treeSegment: ['slug', 'hello%20world%20%26%20stuff', 'd', null], }) }) it('should handle unicode characters', () => { const params: Params = { slug: 'caf�-na�ve' } - const result = getDynamicParam(params, 'slug', 'd', null) + const result = getDynamicParam(params, 'slug', 'd', null, null) expect(result).toEqual({ param: 'slug', value: 'caf%EF%BF%BD-na%EF%BF%BDve', type: 'd', - treeSegment: ['slug', 'caf%EF%BF%BD-na%EF%BF%BDve', 'd'], + treeSegment: ['slug', 'caf%EF%BF%BD-na%EF%BF%BDve', 'd', null], }) }) @@ -50,10 +50,10 @@ describe('getDynamicParam', () => { const params: Params = {} expect(() => { - getDynamicParam(params, 'slug', 'd', null) + getDynamicParam(params, 'slug', 'd', null, null) }).toThrow(InvariantError) expect(() => { - getDynamicParam(params, 'slug', 'd', null) + getDynamicParam(params, 'slug', 'd', null, null) }).toThrow( `Invariant: Missing value for segment key: "slug" with dynamic param type: d. This is a bug in Next.js.` ) @@ -63,10 +63,10 @@ describe('getDynamicParam', () => { const params: Params = {} expect(() => { - getDynamicParam(params, 'slug', 'di(..)(..)', null) + getDynamicParam(params, 'slug', 'di(..)(..)', null, null) }).toThrow(InvariantError) expect(() => { - getDynamicParam(params, 'slug', 'di(..)(..)', null) + getDynamicParam(params, 'slug', 'di(..)(..)', null, null) }).toThrow( 'Invariant: Missing value for segment key: "slug" with dynamic param type: di(..)(..). This is a bug in Next.js.' ) @@ -76,49 +76,54 @@ describe('getDynamicParam', () => { describe('catchall parameters (c, ci)', () => { it('should handle array of values for catchall', () => { const params: Params = { slug: ['docs', 'getting-started'] } - const result = getDynamicParam(params, 'slug', 'c', null) + const result = getDynamicParam(params, 'slug', 'c', null, null) expect(result).toEqual({ param: 'slug', value: ['docs', 'getting-started'], type: 'c', - treeSegment: ['slug', 'docs/getting-started', 'c'], + treeSegment: ['slug', 'docs/getting-started', 'c', null], }) }) it('should encode array values for catchall', () => { const params: Params = { slug: ['docs & guides', 'getting started'] } - const result = getDynamicParam(params, 'slug', 'c', null) + const result = getDynamicParam(params, 'slug', 'c', null, null) expect(result).toEqual({ param: 'slug', value: ['docs%20%26%20guides', 'getting%20started'], type: 'c', - treeSegment: ['slug', 'docs%20%26%20guides/getting%20started', 'c'], + treeSegment: [ + 'slug', + 'docs%20%26%20guides/getting%20started', + 'c', + null, + ], }) }) it('should handle single string value for catchall', () => { const params: Params = { slug: 'single-page' } - const result = getDynamicParam(params, 'slug', 'c', null) + const result = getDynamicParam(params, 'slug', 'c', null, null) expect(result).toEqual({ param: 'slug', value: 'single-page', type: 'c', - treeSegment: ['slug', 'single-page', 'c'], + treeSegment: ['slug', 'single-page', 'c', null], }) }) it('should handle catchall intercepted (ci) with array values', () => { const params: Params = { path: ['photo', '123'] } - const result = getDynamicParam(params, 'path', 'ci(..)(..)', null) + const result = getDynamicParam(params, 'path', 'ci(..)(..)', null, null) expect(result).toEqual({ param: 'path', value: ['photo', '123'], type: 'ci(..)(..)', - treeSegment: ['path', 'photo/123', 'ci(..)(..)'], + treeSegment: ['path', 'photo/123', 'ci(..)(..)', null], }) }) @@ -127,13 +132,13 @@ describe('getDynamicParam', () => { const fallbackParams = createMockOpaqueFallbackRouteParams({ slug: ['%%drp:slug:parallel123%%', 'd'], }) - const result = getDynamicParam(params, 'slug', 'd', fallbackParams) + const result = getDynamicParam(params, 'slug', 'd', fallbackParams, null) expect(result).toEqual({ param: 'slug', value: '%%drp:slug:parallel123%%', type: 'd', - treeSegment: ['slug', '%%drp:slug:parallel123%%', 'd'], + treeSegment: ['slug', '%%drp:slug:parallel123%%', 'd', null], }) }) }) @@ -141,49 +146,49 @@ describe('getDynamicParam', () => { describe('optional catchall parameters (oc)', () => { it('should handle array of values for optional catchall', () => { const params: Params = { slug: ['api', 'users', 'create'] } - const result = getDynamicParam(params, 'slug', 'oc', null) + const result = getDynamicParam(params, 'slug', 'oc', null, null) expect(result).toEqual({ param: 'slug', value: ['api', 'users', 'create'], type: 'oc', - treeSegment: ['slug', 'api/users/create', 'oc'], + treeSegment: ['slug', 'api/users/create', 'oc', null], }) }) it('should return null value for optional catchall without value', () => { const params: Params = {} - const result = getDynamicParam(params, 'slug', 'oc', null) + const result = getDynamicParam(params, 'slug', 'oc', null, null) expect(result).toEqual({ param: 'slug', value: null, type: 'oc', - treeSegment: ['slug', '', 'oc'], + treeSegment: ['slug', '', 'oc', null], }) }) it('should encode array values for optional catchall', () => { const params: Params = { slug: ['hello world', 'caf�'] } - const result = getDynamicParam(params, 'slug', 'oc', null) + const result = getDynamicParam(params, 'slug', 'oc', null, null) expect(result).toEqual({ param: 'slug', value: ['hello%20world', 'caf%EF%BF%BD'], type: 'oc', - treeSegment: ['slug', 'hello%20world/caf%EF%BF%BD', 'oc'], + treeSegment: ['slug', 'hello%20world/caf%EF%BF%BD', 'oc', null], }) }) it('should handle single string value for optional catchall', () => { const params: Params = { slug: 'documentation' } - const result = getDynamicParam(params, 'slug', 'oc', null) + const result = getDynamicParam(params, 'slug', 'oc', null, null) expect(result).toEqual({ param: 'slug', value: 'documentation', type: 'oc', - treeSegment: ['slug', 'documentation', 'oc'], + treeSegment: ['slug', 'documentation', 'oc', null], }) }) }) @@ -195,19 +200,13 @@ describe('getDynamicParam', () => { slug: ['%%drp:slug:abc123%%', 'd'], }) - const result = getDynamicParam( - params, - 'slug', - 'd', - - fallbackParams - ) + const result = getDynamicParam(params, 'slug', 'd', fallbackParams, null) expect(result).toEqual({ param: 'slug', value: '%%drp:slug:abc123%%', type: 'd', - treeSegment: ['slug', '%%drp:slug:abc123%%', 'd'], + treeSegment: ['slug', '%%drp:slug:abc123%%', 'd', null], }) }) @@ -217,13 +216,7 @@ describe('getDynamicParam', () => { slug: ['%%drp:slug:xyz789%%', 'd'], }) - const result = getDynamicParam( - params, - 'slug', - 'd', - - fallbackParams - ) + const result = getDynamicParam(params, 'slug', 'd', fallbackParams, null) expect(result.value).toBe('%%drp:slug:xyz789%%') }) @@ -234,13 +227,13 @@ describe('getDynamicParam', () => { slug: ['%%drp:slug:def456%%', 'c'], }) - const result = getDynamicParam(params, 'slug', 'c', fallbackParams) + const result = getDynamicParam(params, 'slug', 'c', fallbackParams, null) expect(result).toEqual({ param: 'slug', value: '%%drp:slug:def456%%', type: 'c', - treeSegment: ['slug', '%%drp:slug:def456%%', 'c'], + treeSegment: ['slug', '%%drp:slug:def456%%', 'c', null], }) }) @@ -250,13 +243,13 @@ describe('getDynamicParam', () => { slug: ['%%drp:slug:ghi789%%', 'oc'], }) - const result = getDynamicParam(params, 'slug', 'oc', fallbackParams) + const result = getDynamicParam(params, 'slug', 'oc', fallbackParams, null) expect(result).toEqual({ param: 'slug', value: '%%drp:slug:ghi789%%', type: 'oc', - treeSegment: ['slug', '%%drp:slug:ghi789%%', 'oc'], + treeSegment: ['slug', '%%drp:slug:ghi789%%', 'oc', null], }) }) @@ -266,13 +259,7 @@ describe('getDynamicParam', () => { other: ['%%drp:other:abc123%%', 'd'], }) - const result = getDynamicParam( - params, - 'slug', - 'd', - - fallbackParams - ) + const result = getDynamicParam(params, 'slug', 'd', fallbackParams, null) expect(result.value).toBe('hello%20world') }) @@ -283,10 +270,10 @@ describe('getDynamicParam', () => { const params: Params = { slug: '' } expect(() => { - getDynamicParam(params, 'slug', 'd', null) + getDynamicParam(params, 'slug', 'd', null, null) }).toThrow(InvariantError) expect(() => { - getDynamicParam(params, 'slug', 'd', null) + getDynamicParam(params, 'slug', 'd', null, null) }).toThrow( `Invariant: Missing value for segment key: "slug" with dynamic param type: d. This is a bug in Next.js.` ) @@ -296,7 +283,7 @@ describe('getDynamicParam', () => { const params: Params = { slug: undefined } expect(() => { - getDynamicParam(params, 'slug', 'd', null) + getDynamicParam(params, 'slug', 'd', null, null) }).toThrow(InvariantError) }) }) @@ -407,6 +394,7 @@ type TestLoaderTree = [ segment: string, parallelRoutes: { [key: string]: TestLoaderTree }, modules: Record, + staticSiblings: readonly string[] | null, ] function createLoaderTree( @@ -415,7 +403,7 @@ function createLoaderTree( children?: TestLoaderTree ): TestLoaderTree { const routes = children ? { ...parallelRoutes, children } : parallelRoutes - return [segment, routes, {}] + return [segment, routes, {}, null] } describe('interpolateParallelRouteParams', () => { diff --git a/packages/next/src/shared/lib/router/utils/get-dynamic-param.ts b/packages/next/src/shared/lib/router/utils/get-dynamic-param.ts index 8521bac670bf6..c3ad95cdd5eae 100644 --- a/packages/next/src/shared/lib/router/utils/get-dynamic-param.ts +++ b/packages/next/src/shared/lib/router/utils/get-dynamic-param.ts @@ -121,7 +121,8 @@ export function getDynamicParam( interpolatedParams: Params, segmentKey: string, dynamicParamType: DynamicParamTypesShort, - fallbackRouteParams: OpaqueFallbackRouteParams | null + fallbackRouteParams: OpaqueFallbackRouteParams | null, + staticSiblings: readonly string[] | null ): DynamicParam { let value: string | string[] | undefined = getParamValue( interpolatedParams, @@ -137,7 +138,7 @@ export function getDynamicParam( param: segmentKey, value: null, type: dynamicParamType, - treeSegment: [segmentKey, '', dynamicParamType], + treeSegment: [segmentKey, '', dynamicParamType, staticSiblings], } } @@ -146,16 +147,18 @@ export function getDynamicParam( ) } + const paramCacheKey = Array.isArray(value) ? value.join('/') : value + return { param: segmentKey, // The value that is passed to user code. value, // The value that is rendered in the router tree. - treeSegment: [ - segmentKey, - Array.isArray(value) ? value.join('/') : value, - dynamicParamType, - ], + // TODO: If the number of static siblings exceeds some threshold (e.g., + // dozens or hundreds), consider sending a Bloom filter instead of the full + // array to reduce payload size. The client would then use the Bloom filter + // to check membership with a small false positive rate. + treeSegment: [segmentKey, paramCacheKey, dynamicParamType, staticSiblings], type: dynamicParamType, } } diff --git a/packages/next/src/shared/lib/router/utils/parse-loader-tree.ts b/packages/next/src/shared/lib/router/utils/parse-loader-tree.ts index 27280fee48959..e6b4951c29cf8 100644 --- a/packages/next/src/shared/lib/router/utils/parse-loader-tree.ts +++ b/packages/next/src/shared/lib/router/utils/parse-loader-tree.ts @@ -2,7 +2,7 @@ import { DEFAULT_SEGMENT_KEY } from '../../segment' import type { LoaderTree } from '../../../../server/lib/app-dir-module' export function parseLoaderTree(tree: LoaderTree) { - const [segment, parallelRoutes, modules] = tree + const [segment, parallelRoutes, modules, staticSiblings] = tree const { layout, template } = modules let { page } = modules // a __DEFAULT__ segment means that this route didn't match any of the @@ -18,5 +18,6 @@ export function parseLoaderTree(tree: LoaderTree) { /* it can be either layout / template / page */ conventionPath, parallelRoutes, + staticSiblings, } } diff --git a/packages/next/src/shared/lib/segment.test.ts b/packages/next/src/shared/lib/segment.test.ts index ec0c23317fe07..ebec44137b7fd 100644 --- a/packages/next/src/shared/lib/segment.test.ts +++ b/packages/next/src/shared/lib/segment.test.ts @@ -6,11 +6,13 @@ describe('getSegmentValue', () => { }) it('should support dynamic segment', () => { - expect(getSegmentValue(['slug', 'hello-world', 'd'])).toEqual('hello-world') + expect(getSegmentValue(['slug', 'hello-world', 'd', null])).toEqual( + 'hello-world' + ) }) it('should support catch all segment', () => { - expect(getSegmentValue(['slug', 'blog/hello-world', 'c'])).toEqual( + expect(getSegmentValue(['slug', 'blog/hello-world', 'c', null])).toEqual( 'blog/hello-world' ) }) diff --git a/test/e2e/app-dir/app-prefetch/prefetching.test.ts b/test/e2e/app-dir/app-prefetch/prefetching.test.ts index ef5d72e047f0a..726250485530c 100644 --- a/test/e2e/app-dir/app-prefetch/prefetching.test.ts +++ b/test/e2e/app-dir/app-prefetch/prefetching.test.ts @@ -247,7 +247,7 @@ describe('app dir - prefetching', () => { 'prefetch-auto', { children: [ - ['slug', 'vercel', 'd'], + ['slug', 'vercel', 'd', null], { children: ['__PAGE__', {}] }, ], }, diff --git a/test/e2e/app-dir/segment-cache/force-stale/app/page.tsx b/test/e2e/app-dir/segment-cache/force-stale/app/page.tsx index 7fdcafe0eec2d..187753f3c3f82 100644 --- a/test/e2e/app-dir/segment-cache/force-stale/app/page.tsx +++ b/test/e2e/app-dir/segment-cache/force-stale/app/page.tsx @@ -1,9 +1,19 @@ +import Link from 'next/link' import { LinkAccordion } from '../components/link-accordion' export default function Page() { return ( - - Dynamic page - +
    +
  • + + Dynamic page + +
  • +
  • + + Dynamic page (no prefetch) + +
  • +
) } diff --git a/test/e2e/app-dir/segment-cache/force-stale/force-stale.test.ts b/test/e2e/app-dir/segment-cache/force-stale/force-stale.test.ts index 447516b69f5fd..be18b22045df5 100644 --- a/test/e2e/app-dir/segment-cache/force-stale/force-stale.test.ts +++ b/test/e2e/app-dir/segment-cache/force-stale/force-stale.test.ts @@ -54,4 +54,46 @@ describe('force stale', () => { expect(await content.text()).toBe('Dynamic page content') } ) + + it( + 'during a "full" prefetch, read from bfcache before issuing new ' + + 'prefetch request', + async () => { + let act: ReturnType + const browser = await next.browser('/', { + beforePageLoad(p: Playwright.Page) { + act = createRouterAct(p) + }, + }) + + // Navigate to the dynamic page using the link without prefetch. + // This will fetch the page data and store it in the bfcache. + await act( + async () => { + const link = await browser.elementById('link-without-prefetch') + await link.click() + }, + { + includes: 'Dynamic page content', + } + ) + + // Navigate back to the home page + await browser.back() + + // Now reveal a link with prefetch={true} to the same page. Because we've + // already navigated to this page, the data should be in the bfcache. + // The prefetch should reuse the bfcache data instead of making a new + // request to the server. + await act( + async () => { + const toggleLinkVisibility = await browser.elementByCss( + 'input[data-link-accordion="/dynamic"]' + ) + await toggleLinkVisibility.click() + }, + { includes: 'Dynamic page content', block: 'reject' } + ) + } + ) }) diff --git a/test/e2e/app-dir/segment-cache/force-stale/next.config.js b/test/e2e/app-dir/segment-cache/force-stale/next.config.js index e64bae22d6580..25146d5137310 100644 --- a/test/e2e/app-dir/segment-cache/force-stale/next.config.js +++ b/test/e2e/app-dir/segment-cache/force-stale/next.config.js @@ -3,6 +3,7 @@ */ const nextConfig = { cacheComponents: true, + productionBrowserSourceMaps: true, } module.exports = nextConfig diff --git a/test/e2e/app-dir/segment-cache/revalidation/segment-cache-revalidation.test.ts b/test/e2e/app-dir/segment-cache/revalidation/segment-cache-revalidation.test.ts index 004f4a7d92daa..57a92994934ef 100644 --- a/test/e2e/app-dir/segment-cache/revalidation/segment-cache-revalidation.test.ts +++ b/test/e2e/app-dir/segment-cache/revalidation/segment-cache-revalidation.test.ts @@ -295,9 +295,17 @@ describe('segment cache (revalidation)', () => { // target route has changed. // // This time, the response does include the content for page A. - { - includes: 'Page A content', - } + // + // TODO: The request is actually skipped entirely because now reads from the bfcache before issuing a prefetch + // request, which wasn't true before the test was written. I'm leaving + // the test here for now, though, since we may want to re-write it in + // terms of runtime prefetching at some point. There's other coverage of + // this behavior though so it might be fine to just remove the whole test. + // { + // includes: 'Page A content', + // } + 'no-requests' ) // Navigate to page A diff --git a/test/e2e/app-dir/static-siblings/app/(group1)/products/sale/page.tsx b/test/e2e/app-dir/static-siblings/app/(group1)/products/sale/page.tsx new file mode 100644 index 0000000000000..2b67a3a03a945 --- /dev/null +++ b/test/e2e/app-dir/static-siblings/app/(group1)/products/sale/page.tsx @@ -0,0 +1,7 @@ +export default function SalePage() { + return ( +

+ Sale page (static sibling) +

+ ) +} diff --git a/test/e2e/app-dir/static-siblings/app/(group2)/products/[id]/loading.tsx b/test/e2e/app-dir/static-siblings/app/(group2)/products/[id]/loading.tsx new file mode 100644 index 0000000000000..09367d77b06ee --- /dev/null +++ b/test/e2e/app-dir/static-siblings/app/(group2)/products/[id]/loading.tsx @@ -0,0 +1,3 @@ +export default function Loading() { + return
Loading...
+} diff --git a/test/e2e/app-dir/static-siblings/app/(group2)/products/[id]/page.tsx b/test/e2e/app-dir/static-siblings/app/(group2)/products/[id]/page.tsx new file mode 100644 index 0000000000000..ed00865cb3571 --- /dev/null +++ b/test/e2e/app-dir/static-siblings/app/(group2)/products/[id]/page.tsx @@ -0,0 +1,11 @@ +export default function ProductPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + return ( +

+ Product page (dynamic route) +

+ ) +} diff --git a/test/e2e/app-dir/static-siblings/app/categories/[slug]/loading.tsx b/test/e2e/app-dir/static-siblings/app/categories/[slug]/loading.tsx new file mode 100644 index 0000000000000..09367d77b06ee --- /dev/null +++ b/test/e2e/app-dir/static-siblings/app/categories/[slug]/loading.tsx @@ -0,0 +1,3 @@ +export default function Loading() { + return
Loading...
+} diff --git a/test/e2e/app-dir/static-siblings/app/categories/[slug]/page.tsx b/test/e2e/app-dir/static-siblings/app/categories/[slug]/page.tsx new file mode 100644 index 0000000000000..ad5b3e3156b99 --- /dev/null +++ b/test/e2e/app-dir/static-siblings/app/categories/[slug]/page.tsx @@ -0,0 +1,13 @@ +export default async function Page({ + params, +}: { + params: Promise<{ slug: string }> +}) { + const { slug } = await params + return ( +
+

Category: {slug}

+

Dynamic category page

+
+ ) +} diff --git a/test/e2e/app-dir/static-siblings/app/categories/electronics/computers/laptops/layout.tsx b/test/e2e/app-dir/static-siblings/app/categories/electronics/computers/laptops/layout.tsx new file mode 100644 index 0000000000000..8df38a175b0b1 --- /dev/null +++ b/test/e2e/app-dir/static-siblings/app/categories/electronics/computers/laptops/layout.tsx @@ -0,0 +1,12 @@ +export default function LaptopsLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( +
+
Laptops Layout
+ {children} +
+ ) +} diff --git a/test/e2e/app-dir/static-siblings/app/categories/electronics/computers/laptops/page.tsx b/test/e2e/app-dir/static-siblings/app/categories/electronics/computers/laptops/page.tsx new file mode 100644 index 0000000000000..a2abc77ad4ea3 --- /dev/null +++ b/test/e2e/app-dir/static-siblings/app/categories/electronics/computers/laptops/page.tsx @@ -0,0 +1,8 @@ +export default function Page() { + return ( +
+

Laptops

+

Deeply nested static sibling with multiple layouts

+
+ ) +} diff --git a/test/e2e/app-dir/static-siblings/app/categories/electronics/computers/layout.tsx b/test/e2e/app-dir/static-siblings/app/categories/electronics/computers/layout.tsx new file mode 100644 index 0000000000000..ed87878955207 --- /dev/null +++ b/test/e2e/app-dir/static-siblings/app/categories/electronics/computers/layout.tsx @@ -0,0 +1,12 @@ +export default function ComputersLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( +
+
Computers Layout
+ {children} +
+ ) +} diff --git a/test/e2e/app-dir/static-siblings/app/categories/electronics/layout.tsx b/test/e2e/app-dir/static-siblings/app/categories/electronics/layout.tsx new file mode 100644 index 0000000000000..091d50ede90de --- /dev/null +++ b/test/e2e/app-dir/static-siblings/app/categories/electronics/layout.tsx @@ -0,0 +1,12 @@ +export default function ElectronicsLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( +
+
Electronics Layout
+ {children} +
+ ) +} diff --git a/test/e2e/app-dir/static-siblings/app/dashboard/@panel/[id]/loading.tsx b/test/e2e/app-dir/static-siblings/app/dashboard/@panel/[id]/loading.tsx new file mode 100644 index 0000000000000..09367d77b06ee --- /dev/null +++ b/test/e2e/app-dir/static-siblings/app/dashboard/@panel/[id]/loading.tsx @@ -0,0 +1,3 @@ +export default function Loading() { + return
Loading...
+} diff --git a/test/e2e/app-dir/static-siblings/app/dashboard/@panel/[id]/page.tsx b/test/e2e/app-dir/static-siblings/app/dashboard/@panel/[id]/page.tsx new file mode 100644 index 0000000000000..dba4744b951f4 --- /dev/null +++ b/test/e2e/app-dir/static-siblings/app/dashboard/@panel/[id]/page.tsx @@ -0,0 +1,11 @@ +export default function PanelItemPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + return ( +

+ Panel item (dynamic in parallel route) +

+ ) +} diff --git a/test/e2e/app-dir/static-siblings/app/dashboard/@panel/default.tsx b/test/e2e/app-dir/static-siblings/app/dashboard/@panel/default.tsx new file mode 100644 index 0000000000000..a111fad026e74 --- /dev/null +++ b/test/e2e/app-dir/static-siblings/app/dashboard/@panel/default.tsx @@ -0,0 +1,7 @@ +export default function PanelDefault() { + return ( +

+ Panel default +

+ ) +} diff --git a/test/e2e/app-dir/static-siblings/app/dashboard/@panel/settings/page.tsx b/test/e2e/app-dir/static-siblings/app/dashboard/@panel/settings/page.tsx new file mode 100644 index 0000000000000..2dc9510932392 --- /dev/null +++ b/test/e2e/app-dir/static-siblings/app/dashboard/@panel/settings/page.tsx @@ -0,0 +1,7 @@ +export default function PanelSettingsPage() { + return ( +

+ Panel settings (static sibling in parallel route) +

+ ) +} diff --git a/test/e2e/app-dir/static-siblings/app/dashboard/default.tsx b/test/e2e/app-dir/static-siblings/app/dashboard/default.tsx new file mode 100644 index 0000000000000..74c4b5a8c92bf --- /dev/null +++ b/test/e2e/app-dir/static-siblings/app/dashboard/default.tsx @@ -0,0 +1,3 @@ +export default function DashboardDefault() { + return

Dashboard default

+} diff --git a/test/e2e/app-dir/static-siblings/app/dashboard/layout.tsx b/test/e2e/app-dir/static-siblings/app/dashboard/layout.tsx new file mode 100644 index 0000000000000..9a93483c4d9dd --- /dev/null +++ b/test/e2e/app-dir/static-siblings/app/dashboard/layout.tsx @@ -0,0 +1,16 @@ +import { ReactNode } from 'react' + +export default function DashboardLayout({ + children, + panel, +}: { + children: ReactNode + panel: ReactNode +}) { + return ( +
+
{children}
+
{panel}
+
+ ) +} diff --git a/test/e2e/app-dir/static-siblings/app/dashboard/page.tsx b/test/e2e/app-dir/static-siblings/app/dashboard/page.tsx new file mode 100644 index 0000000000000..274c8d3736c7f --- /dev/null +++ b/test/e2e/app-dir/static-siblings/app/dashboard/page.tsx @@ -0,0 +1,3 @@ +export default function DashboardPage() { + return

Dashboard home

+} diff --git a/test/e2e/app-dir/static-siblings/app/items/[id]/loading.tsx b/test/e2e/app-dir/static-siblings/app/items/[id]/loading.tsx new file mode 100644 index 0000000000000..09367d77b06ee --- /dev/null +++ b/test/e2e/app-dir/static-siblings/app/items/[id]/loading.tsx @@ -0,0 +1,3 @@ +export default function Loading() { + return
Loading...
+} diff --git a/test/e2e/app-dir/static-siblings/app/items/[id]/page.tsx b/test/e2e/app-dir/static-siblings/app/items/[id]/page.tsx new file mode 100644 index 0000000000000..011722397f2c9 --- /dev/null +++ b/test/e2e/app-dir/static-siblings/app/items/[id]/page.tsx @@ -0,0 +1,11 @@ +export default function ItemPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + return ( +

+ Item page (dynamic route) +

+ ) +} diff --git a/test/e2e/app-dir/static-siblings/app/items/featured/page.tsx b/test/e2e/app-dir/static-siblings/app/items/featured/page.tsx new file mode 100644 index 0000000000000..6177aa2885761 --- /dev/null +++ b/test/e2e/app-dir/static-siblings/app/items/featured/page.tsx @@ -0,0 +1,7 @@ +export default function FeaturedPage() { + return ( + + ) +} diff --git a/test/e2e/app-dir/static-siblings/app/layout.tsx b/test/e2e/app-dir/static-siblings/app/layout.tsx new file mode 100644 index 0000000000000..888614deda3ba --- /dev/null +++ b/test/e2e/app-dir/static-siblings/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/static-siblings/app/page.tsx b/test/e2e/app-dir/static-siblings/app/page.tsx new file mode 100644 index 0000000000000..502783aff6481 --- /dev/null +++ b/test/e2e/app-dir/static-siblings/app/page.tsx @@ -0,0 +1,107 @@ +import Link from 'next/link' +import { LinkAccordion } from '../components/link-accordion' + +export default function Page() { + return ( +
+

Static Siblings Test

+ +
+

About

+

+ This test verifies that when a dynamic route has static siblings at + the same URL level, the client can correctly navigate to the static + sibling even after the dynamic route has been visited/prefetched. +

+

Manual testing instructions

+
    +
  1. Click the checkbox next to a dynamic route to reveal it
  2. +
  3. Click the revealed link to navigate to the dynamic route
  4. +
  5. Use the browser back button to return to this page
  6. +
  7. Click the static sibling link (which has prefetch=false)
  8. +
  9. Verify the static sibling page renders, not the dynamic route
  10. +
+

+ The key behavior being tested: when navigating to a URL that matches + both a dynamic route (e.g., /products/[id]) and a static route (e.g., + /products/sale), the static route should take precedence. The route + tree delivered by the server includes information about static + siblings to facilitate this behavior. +

+
+ +
+ +
+

Cross-Route-Group Siblings

+

/products/sale vs /products/[id] (in different route groups)

+
+ + Dynamic route: /products/123 + +
+
+ + Static sibling: /products/sale + +
+
+ +
+

Same-Directory Siblings

+

/items/featured vs /items/[id] (in the same directory)

+
+ + Dynamic route: /items/456 + +
+
+ + Static sibling: /items/featured + +
+
+ +
+

Parallel Route Siblings

+

/dashboard/settings vs /dashboard/[id] (in @panel parallel route)

+
+ + Dynamic route: /dashboard/789 + +
+
+ + Static sibling: /dashboard/settings + +
+
+ +
+

Deeply Nested Static Siblings

+

+ /categories/electronics/computers/laptops vs /categories/[slug] + (static sibling with multiple layouts along its path) +

+
+ + Dynamic route: /categories/phones + +
+
+ + Static sibling: /categories/electronics/computers/laptops + +
+
+
+ ) +} diff --git a/test/e2e/app-dir/static-siblings/components/link-accordion.tsx b/test/e2e/app-dir/static-siblings/components/link-accordion.tsx new file mode 100644 index 0000000000000..c735a55918b54 --- /dev/null +++ b/test/e2e/app-dir/static-siblings/components/link-accordion.tsx @@ -0,0 +1,33 @@ +'use client' + +import Link from 'next/link' +import { useState } from 'react' + +export function LinkAccordion({ + href, + children, + prefetch, +}: { + href: string + children: React.ReactNode + prefetch?: boolean +}) { + const [isVisible, setIsVisible] = useState(false) + return ( + <> + setIsVisible(!isVisible)} + data-link-accordion={href} + /> + {isVisible ? ( + + {children} + + ) : ( + <>{children} (link is hidden) + )} + + ) +} diff --git a/test/e2e/app-dir/static-siblings/next.config.js b/test/e2e/app-dir/static-siblings/next.config.js new file mode 100644 index 0000000000000..2381363710c4b --- /dev/null +++ b/test/e2e/app-dir/static-siblings/next.config.js @@ -0,0 +1,11 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + cacheComponents: true, + experimental: { + optimisticRouting: true, + }, +} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/static-siblings/static-siblings.test.ts b/test/e2e/app-dir/static-siblings/static-siblings.test.ts new file mode 100644 index 0000000000000..f3214f1ec9021 --- /dev/null +++ b/test/e2e/app-dir/static-siblings/static-siblings.test.ts @@ -0,0 +1,230 @@ +/** + * Static Siblings Tests + * + * When a dynamic route like /products/[id] exists alongside a static route + * like /products/sale at the same URL level, these are called "static + * siblings." The static route should always take precedence when navigating + * to its exact URL. + * + * Test approach: + * 1. RSC payload tests (prod only): Verify that information about static + * siblings is included in the server response. Skipped in dev because + * webpack compiles routes on-demand and only knows about visited routes. + * (Turbopack doesn't have this limitation since it builds the full + * directory tree from the file system.) + * TODO: Replace with end-to-end tests once client behavior is implemented. + * + * 2. Navigation tests (dev and prod): Navigate to the dynamic route first, + * then go back and click the static sibling link (with prefetch={false}). + * Verify the static page renders correctly. + * + * The navigation flow ensures the client has seen the dynamic route before + * attempting to navigate to the sibling. This simulates real-world usage + * where a user might visit or prefetch a dynamic route, then later navigate + * to a static sibling URL. + */ + +import { nextTestSetup } from 'e2e-utils' +// TODO: These imports are only needed for the temporary RSC payload tests. +// Remove once client behavior is implemented. +import { + NEXT_RSC_UNION_QUERY, + RSC_HEADER, +} from 'next/dist/client/components/app-router-headers' + +describe('static-siblings', () => { + const { next, isNextDev } = nextTestSetup({ + files: __dirname, + }) + + // TODO: This helper is only needed for the temporary RSC payload tests. + // Remove once client behavior is implemented. + async function fetchRscResponse(url: string): Promise { + const response = await next.fetch(`${url}?${NEXT_RSC_UNION_QUERY}`, { + headers: { + [RSC_HEADER]: '1', + }, + }) + return response.text() + } + + // RSC payload tests are skipped in dev because with webpack, routes are + // compiled on-demand, so sibling information may not be available until all + // routes have been visited. (Turbopack doesn't have this limitation.) + describe('cross-route-group siblings', () => { + // TODO: Replace with end-to-end test once client behavior is implemented + if (!isNextDev) { + it('should include static sibling info in the server response', async () => { + // The static sibling 'sale' is in a different route group than [id] + const rscPayload = await fetchRscResponse('/products/123') + expect(rscPayload).toContain('"sale"') + }) + } + + it('should navigate to static sibling after visiting dynamic route', async () => { + const browser = await next.browser('/') + + // Step 1: Navigate to the dynamic route first to "discover" it + const accordion = await browser.elementByCss( + 'input[data-link-accordion="/products/123"]' + ) + await accordion.click() + const dynamicLink = await browser.elementByCss('a[href="/products/123"]') + await dynamicLink.click() + + // Verify we're on the dynamic route + const dynamicText = await browser.elementByCss('#product-page').text() + expect(dynamicText).toBe('Product page (dynamic route)') + + // Step 2: Navigate back to the home page + await browser.back() + await browser.elementByCss('#home-page') + + // Step 3: Navigate to the static sibling with prefetch={false} + const staticLink = await browser.elementByCss('#link-to-sale') + await staticLink.click() + + // Verify the static sibling page rendered + const staticText = await browser.elementByCss('#sale-page').text() + expect(staticText).toBe('Sale page (static sibling)') + }) + }) + + describe('same-directory siblings', () => { + // TODO: Replace with end-to-end test once client behavior is implemented + if (!isNextDev) { + it('should include static sibling info in the server response', async () => { + // The static sibling 'featured' is in the same directory as [id] + const rscPayload = await fetchRscResponse('/items/123') + expect(rscPayload).toContain('"featured"') + }) + } + + it('should navigate to static sibling after visiting dynamic route', async () => { + const browser = await next.browser('/') + + // Step 1: Navigate to the dynamic route first to "discover" it + const accordion = await browser.elementByCss( + 'input[data-link-accordion="/items/456"]' + ) + await accordion.click() + const dynamicLink = await browser.elementByCss('a[href="/items/456"]') + await dynamicLink.click() + + // Verify we're on the dynamic route + const dynamicText = await browser.elementByCss('#item-page').text() + expect(dynamicText).toBe('Item page (dynamic route)') + + // Step 2: Navigate back to the home page + await browser.back() + await browser.elementByCss('#home-page') + + // Step 3: Navigate to the static sibling with prefetch={false} + const staticLink = await browser.elementByCss('#link-to-featured') + await staticLink.click() + + // Verify the static sibling page rendered + const staticText = await browser.elementByCss('#featured-page').text() + expect(staticText).toBe('Featured items (static sibling)') + }) + }) + + describe('parallel route siblings', () => { + // TODO: Replace with end-to-end test once client behavior is implemented + if (!isNextDev) { + it('should include static sibling info in the server response', async () => { + // The static sibling 'settings' is in a parallel route slot + const rscPayload = await fetchRscResponse('/dashboard/123') + expect(rscPayload).toContain('"settings"') + }) + } + + it('should navigate to static sibling after visiting dynamic route', async () => { + const browser = await next.browser('/') + + // Step 1: Navigate to the dynamic route first to "discover" it + const accordion = await browser.elementByCss( + 'input[data-link-accordion="/dashboard/789"]' + ) + await accordion.click() + const dynamicLink = await browser.elementByCss('a[href="/dashboard/789"]') + await dynamicLink.click() + + // Verify we're on the dynamic route + const dynamicText = await browser.elementByCss('#panel-item-page').text() + expect(dynamicText).toBe('Panel item (dynamic in parallel route)') + + // Step 2: Navigate back to the home page + await browser.back() + await browser.elementByCss('#home-page') + + // Step 3: Navigate to the static sibling with prefetch={false} + const staticLink = await browser.elementByCss('#link-to-settings') + await staticLink.click() + + // Verify the static sibling page rendered + const staticText = await browser + .elementByCss('#panel-settings-page') + .text() + expect(staticText).toBe( + 'Panel settings (static sibling in parallel route)' + ) + }) + }) + + describe('deeply nested siblings', () => { + // TODO: Replace with end-to-end test once client behavior is implemented + if (!isNextDev) { + it('should include static sibling info in the server response', async () => { + // The static sibling 'electronics' is deeply nested with multiple layouts + const rscPayload = await fetchRscResponse('/categories/phones') + expect(rscPayload).toContain('"electronics"') + // Nested segments inside 'electronics' should NOT be leaked as siblings + expect(rscPayload).not.toContain('"computers"') + expect(rscPayload).not.toContain('"laptops"') + }) + } + + it('should navigate to static sibling after visiting dynamic route', async () => { + const browser = await next.browser('/') + + // Step 1: Navigate to the dynamic route first to "discover" it + const accordion = await browser.elementByCss( + 'input[data-link-accordion="/categories/phones"]' + ) + await accordion.click() + const dynamicLink = await browser.elementByCss( + 'a[href="/categories/phones"]' + ) + await dynamicLink.click() + + // Verify we're on the dynamic route + const dynamicText = await browser.elementByCss('#category-page').text() + expect(dynamicText).toContain('Dynamic category page') + + // Step 2: Navigate back to the home page + await browser.back() + await browser.elementByCss('#home-page') + + // Step 3: Navigate to the deeply nested static sibling with prefetch={false} + const staticLink = await browser.elementByCss('#link-to-laptops') + await staticLink.click() + + // Verify the static sibling page rendered with all its layouts + const staticText = await browser.elementByCss('#laptops-page').text() + expect(staticText).toContain('Laptops') + + // Verify the nested layouts are present + const electronicsLayout = await browser.elementByCss( + '[data-electronics-layout]' + ) + expect(electronicsLayout).toBeTruthy() + const computersLayout = await browser.elementByCss( + '[data-computers-layout]' + ) + expect(computersLayout).toBeTruthy() + const laptopsLayout = await browser.elementByCss('[data-laptops-layout]') + expect(laptopsLayout).toBeTruthy() + }) + }) +}) diff --git a/test/e2e/app-dir/trailingslash/app/[lang]/cache-components/page.js b/test/e2e/app-dir/trailingslash/app/[lang]/cache-components/page.js new file mode 100644 index 0000000000000..9351de3ce3b12 --- /dev/null +++ b/test/e2e/app-dir/trailingslash/app/[lang]/cache-components/page.js @@ -0,0 +1,12 @@ +import SharedPage from '../shared-page' + +// This page is compatible with Cache Components. It does not define a +// `revalidate` route segment config, and uses 'use cache' instead. The path is +// rewritten to here from /:lang(en|es)/ via rewrites in next.config.js when +// __NEXT_CACHE_COMPONENTS is set to true. + +export default async function Page({ params }) { + 'use cache' + + return +} diff --git a/test/e2e/app-dir/trailingslash/app/[lang]/layout.js b/test/e2e/app-dir/trailingslash/app/[lang]/layout.js new file mode 100644 index 0000000000000..b92ce73739e56 --- /dev/null +++ b/test/e2e/app-dir/trailingslash/app/[lang]/layout.js @@ -0,0 +1,16 @@ +import Link from 'next/link' + +export function generateStaticParams() { + return [{ lang: 'en' }, { lang: 'es' }] +} + +export default function LangLayout({ children }) { + return ( + <> + +
{children}
+ + ) +} diff --git a/test/e2e/app-dir/trailingslash/app/[lang]/legacy/page.js b/test/e2e/app-dir/trailingslash/app/[lang]/legacy/page.js new file mode 100644 index 0000000000000..69ddcc5916dd3 --- /dev/null +++ b/test/e2e/app-dir/trailingslash/app/[lang]/legacy/page.js @@ -0,0 +1,9 @@ +import SharedPage from '../shared-page' + +// This page uses the legacy `revalidate` route segment config instead of 'use +// cache'. The path is rewritten to here from /:lang(en|es)/ via rewrites in +// next.config.js when __NEXT_CACHE_COMPONENTS is not set. + +export const revalidate = 900 + +export default SharedPage diff --git a/test/e2e/app-dir/trailingslash/app/[lang]/revalidate-button.js b/test/e2e/app-dir/trailingslash/app/[lang]/revalidate-button.js new file mode 100644 index 0000000000000..08ec1cc84232a --- /dev/null +++ b/test/e2e/app-dir/trailingslash/app/[lang]/revalidate-button.js @@ -0,0 +1,45 @@ +'use client' + +import { useState, useTransition } from 'react' + +export function RevalidateButton({ lang }) { + const [isPending, startTransition] = useTransition() + const [result, setResult] = useState(null) + + function handleRevalidate(withSlash) { + startTransition(async () => { + try { + const data = await fetch( + `/api/revalidate/?lang=${lang}&withSlash=${withSlash}` + ).then((res) => res.json()) + startTransition(() => { + setResult(`Revalidated at: ${data.timestamp}`) + }) + } catch (e) { + startTransition(() => { + setResult(`Error: ${e}`) + }) + } + }) + } + + return ( +
+ + + {result &&
{result}
} +
+ ) +} diff --git a/test/e2e/app-dir/trailingslash/app/[lang]/shared-page.js b/test/e2e/app-dir/trailingslash/app/[lang]/shared-page.js new file mode 100644 index 0000000000000..433e36c227f0a --- /dev/null +++ b/test/e2e/app-dir/trailingslash/app/[lang]/shared-page.js @@ -0,0 +1,16 @@ +import { RevalidateButton } from './revalidate-button' + +export default async function Page({ params }) { + const { lang } = await params + const generatedAt = new Date().toISOString() + + return ( +
+

Revalidation Test - {lang}

+
+        Page generated at: {generatedAt}
+      
+ +
+ ) +} diff --git a/test/e2e/app-dir/trailingslash/app/api/revalidate/route.js b/test/e2e/app-dir/trailingslash/app/api/revalidate/route.js new file mode 100644 index 0000000000000..28920f9a2d57a --- /dev/null +++ b/test/e2e/app-dir/trailingslash/app/api/revalidate/route.js @@ -0,0 +1,23 @@ +import { revalidatePath } from 'next/cache' +import { NextResponse } from 'next/server' + +const isCacheComponentsEnabled = !!process.env.__NEXT_CACHE_COMPONENTS + +export async function GET(request) { + const lang = request.nextUrl.searchParams.get('lang') || 'en' + const withSlash = request.nextUrl.searchParams.get('withSlash') !== 'false' + + // With rewrites, we need to revalidate the destination path (the actual + // page), not the source path that users visit. + let path = isCacheComponentsEnabled + ? `/${lang}/cache-components` + : `/${lang}/legacy` + + if (withSlash) { + path += '/' + } + + revalidatePath(path) + + return NextResponse.json({ timestamp: new Date().toISOString() }) +} diff --git a/test/e2e/app-dir/trailingslash/next.config.js b/test/e2e/app-dir/trailingslash/next.config.js index ce3f975d0eac1..d5112737a16cf 100644 --- a/test/e2e/app-dir/trailingslash/next.config.js +++ b/test/e2e/app-dir/trailingslash/next.config.js @@ -1,3 +1,19 @@ +/** + * @type {import('next').NextConfig} + */ module.exports = { trailingSlash: true, + rewrites: async () => { + const isCacheComponentsEnabled = + process.env.__NEXT_CACHE_COMPONENTS === 'true' + + return [ + { + source: '/:lang(en|es)/', + destination: isCacheComponentsEnabled + ? '/:lang/cache-components/' + : '/:lang/legacy/', + }, + ] + }, } diff --git a/test/e2e/app-dir/trailingslash/trailingslash.test.ts b/test/e2e/app-dir/trailingslash/trailingslash.test.ts index d53a1671ee150..6f7710ae45152 100644 --- a/test/e2e/app-dir/trailingslash/trailingslash.test.ts +++ b/test/e2e/app-dir/trailingslash/trailingslash.test.ts @@ -1,15 +1,19 @@ import { nextTestSetup } from 'e2e-utils' +import { retry } from 'next-test-utils' + +const isCacheComponentsEnabled = process.env.__NEXT_CACHE_COMPONENTS === 'true' describe('app-dir trailingSlash handling', () => { - const { next, skipped } = nextTestSetup({ + const { next, isNextDev } = nextTestSetup({ files: __dirname, - skipDeployment: true, + buildArgs: [ + '--debug-build-paths', + isCacheComponentsEnabled + ? '!app/[lang]/legacy/page.js' + : '!app/[lang]/cache-components/page.js', + ], }) - if (skipped) { - return - } - it('should redirect route when requesting it directly', async () => { const res = await next.fetch('/a', { redirect: 'manual', @@ -59,4 +63,45 @@ describe('app-dir trailingSlash handling', () => { 'http://trailingslash-another.com/metadata' ) }) + + it.each([{ withSlash: true }, { withSlash: false }])( + 'should revalidate a page with generated static params (withSlash=$withSlash)', + async ({ withSlash }) => { + const browser = await next.browser('/en') + const initialGeneratedAt = await browser + .elementById('generated-at') + .text() + + expect(initialGeneratedAt).toBeDateString() + + if (!isNextDev) { + await browser.refresh() + + const refreshedGeneratedAt = await browser + .elementById('generated-at') + .text() + + expect(refreshedGeneratedAt).toBe(initialGeneratedAt) + } + + await browser + .elementById( + withSlash + ? 'revalidate-button-with-slash' + : 'revalidate-button-no-slash' + ) + .click() + + expect(await browser.elementById('revalidate-result').text()).toInclude( + 'Revalidated' + ) + + await retry(async () => { + await browser.refresh() + const generatedAt = await browser.elementById('generated-at').text() + expect(generatedAt).toBeDateString() + expect(generatedAt).not.toBe(initialGeneratedAt) + }) + } + ) })