Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion crates/next-core/src/app_page_loader_tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,7 @@ impl AppPageLoaderTreeBuilder {
parallel_routes,
modules,
global_metadata,
static_siblings,
} = loader_tree;

writeln!(
Expand Down Expand Up @@ -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(())
}

Expand Down
135 changes: 134 additions & 1 deletion crates/next-core/src/app_structure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,84 @@ struct PlainDirectoryTree {
/// key is e.g. "dashboard", "(dashboard)", "@slot"
pub subdirectories: BTreeMap<RcStr, PlainDirectoryTree>,
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<RcStr, UrlSegmentTree>,
}

impl UrlSegmentTree {
fn static_children(&self) -> Vec<RcStr> {
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<RcStr, PlainDirectoryTree>,
) -> 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<RcStr, PlainDirectoryTree>,
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]
Expand All @@ -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())
}
Expand Down Expand Up @@ -392,6 +473,10 @@ pub struct AppPageLoaderTree {
pub parallel_routes: FxIndexMap<RcStr, AppPageLoaderTree>,
pub modules: AppDirModules,
pub global_metadata: ResolvedVc<GlobalMetadata>,
/// 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<RcStr>,
}

impl AppPageLoaderTree {
Expand Down Expand Up @@ -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('@')
}
Expand Down Expand Up @@ -991,7 +1087,8 @@ async fn directory_tree_to_loader_tree(
// the page this loader tree is constructed for
for_app_path: AppPath,
) -> Result<Vc<AppPageLoaderTreeOption>> {
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,
Expand All @@ -1001,6 +1098,7 @@ async fn directory_tree_to_loader_tree(
app_page,
for_app_path,
AppDirModules::default(),
Some(&plain_tree.url_tree),
)
.await?;

Expand Down Expand Up @@ -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<Option<AppPageLoaderTree>> {
let app_path = AppPath::from(app_page.clone());

Expand Down Expand Up @@ -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<RcStr> = 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);
Expand All @@ -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(),
},
);
}
Expand All @@ -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,
Expand All @@ -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?;

Expand Down Expand Up @@ -1420,6 +1547,7 @@ async fn default_route_tree(
}
},
global_metadata: global_metadata.to_resolved().await?,
static_siblings: Vec::new(),
})
}

Expand Down Expand Up @@ -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 {
Expand All @@ -1690,6 +1820,7 @@ async fn directory_tree_to_entrypoints_internal_untraced(
..not_found_root_modules
},
global_metadata,
static_siblings: Vec::new(),
}
.resolved_cell();

Expand Down Expand Up @@ -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();

Expand Down
3 changes: 2 additions & 1 deletion packages/next/errors.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
2 changes: 2 additions & 0 deletions packages/next/src/build/entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -941,6 +941,7 @@ export async function createEntrypoints(
pagePath: absolutePagePath,
appDir,
appPaths: matchedAppPaths,
allNormalizedAppPaths: Object.keys(appPathsPerRoute),
pageExtensions,
basePath: config.basePath,
assetPrefix: config.assetPrefix,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ type TestLoaderTree = [
segment: string,
parallelRoutes: { [key: string]: TestLoaderTree },
modules: Record<string, unknown>,
staticSiblings: readonly string[] | null,
]

function createLoaderTree(
Expand All @@ -14,7 +15,7 @@ function createLoaderTree(
children?: TestLoaderTree
): TestLoaderTree {
const routes = children ? { ...parallelRoutes, children } : parallelRoutes
return [segment, routes, {}]
return [segment, routes, {}, null]
}

describe('extractPathnameRouteParamSegmentsFromLoaderTree', () => {
Expand Down
3 changes: 2 additions & 1 deletion packages/next/src/build/static-paths/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ type TestLoaderTree = [
segment: string,
parallelRoutes: { [key: string]: TestLoaderTree },
modules: Record<string, unknown>,
staticSiblings: readonly string[] | null,
]

function createLoaderTree(
Expand All @@ -16,7 +17,7 @@ function createLoaderTree(
children?: TestLoaderTree
): TestLoaderTree {
const routes = children ? { ...parallelRoutes, children } : parallelRoutes
return [segment, routes, {}]
return [segment, routes, {}, null]
}

describe('resolveRouteParamsFromTree', () => {
Expand Down
3 changes: 3 additions & 0 deletions packages/next/src/build/templates/app-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions packages/next/src/build/templates/edge-ssr-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading