Skip to content

Commit e674165

Browse files
authored
Report incompatible parallel route slots (#97430)
With strict route matching a layout can have sibling slots whose pages can never form a complete route. For example: ``` app/ layout.tsx @left/ foo/ page.tsx @right/ bar/ page.tsx ``` `/foo` is always missing `@right`, and `/bar` is always missing `@left`. The pages were authored as route targets, but neither URL can construct the complete slot tree owned by `app/layout.tsx`. Catchall pruning removes broad matchers that cannot render and the unmatched-page validation reports dangling files, but neither explains this project-level mistake. This validates the finalized ordinary matcher set and reports each incomplete URL at the layout that owns the conflicting slots. Adding the corresponding pages or an explicit `default.tsx` makes the topology valid. Framework-owned defaults do not invent a `children` slot, and interception matchers remain exempt because they intentionally describe partial router state updates. <!-- NEXT_JS_LLM -->
1 parent dea15ec commit e674165

13 files changed

Lines changed: 903 additions & 124 deletions

File tree

‎crates/next-core/src/app_structure.rs‎

Lines changed: 210 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,14 @@ impl AppPageLoaderTree {
527527
true
528528
}
529529

530+
fn contains_catchall_page(&self) -> bool {
531+
(&*self.segment == "__PAGE__" && self.page.is_catchall())
532+
|| self
533+
.parallel_routes
534+
.values()
535+
.any(AppPageLoaderTree::contains_catchall_page)
536+
}
537+
530538
fn is_builtin_not_found_default(&self, builtin_default: &FileSystemPath) -> bool {
531539
&*self.segment == "__DEFAULT__"
532540
&& self.modules.default.as_ref().is_some_and(|default| {
@@ -562,6 +570,62 @@ impl AppPageLoaderTree {
562570
})
563571
}
564572

573+
fn collect_builtin_not_found_defaults(
574+
&self,
575+
builtin_default: &FileSystemPath,
576+
declared_slots: &FxIndexMap<FileSystemPath, FxIndexSet<RcStr>>,
577+
parent_layout: Option<&FileSystemPath>,
578+
missing_slots: &mut FxIndexMap<FileSystemPath, FxIndexSet<RcStr>>,
579+
) {
580+
let owner_layout = self.modules.layout.as_ref().or(parent_layout);
581+
582+
for (slot, tree) in &self.parallel_routes {
583+
if tree.is_builtin_not_found_default(builtin_default) {
584+
if let Some(owner_layout) = owner_layout
585+
&& declared_slots
586+
.get(owner_layout)
587+
.is_some_and(|slots| slots.contains(slot))
588+
{
589+
missing_slots
590+
.entry(owner_layout.clone())
591+
.or_default()
592+
.insert(slot.clone());
593+
}
594+
} else {
595+
tree.collect_builtin_not_found_defaults(
596+
builtin_default,
597+
declared_slots,
598+
owner_layout,
599+
missing_slots,
600+
);
601+
}
602+
}
603+
}
604+
605+
/// Returns true when one slot matches through a catch-all while a different slot at the same
606+
/// level can only render Next.js' built-in not-found default.
607+
fn has_unmatched_parallel_route(
608+
&self,
609+
builtin_default: &FileSystemPath,
610+
declared_slots: &FxIndexMap<FileSystemPath, FxIndexSet<RcStr>>,
611+
parent_layout: Option<&FileSystemPath>,
612+
) -> bool {
613+
let owner_layout = self.modules.layout.as_ref().or(parent_layout);
614+
let slots_at_level = owner_layout.and_then(|layout| declared_slots.get(layout));
615+
616+
self.parallel_routes.iter().any(|(catchall_key, tree)| {
617+
slots_at_level.is_some_and(|slots| slots.contains(catchall_key))
618+
&& tree.contains_catchall_page()
619+
&& self.parallel_routes.iter().any(|(default_key, tree)| {
620+
default_key != catchall_key
621+
&& slots_at_level.is_some_and(|slots| slots.contains(default_key))
622+
&& tree.is_builtin_not_found_default(builtin_default)
623+
})
624+
}) || self.parallel_routes.values().any(|tree| {
625+
tree.has_unmatched_parallel_route(builtin_default, declared_slots, owner_layout)
626+
})
627+
}
628+
565629
/// Returns true if this loader tree contains an intercepting route match.
566630
pub fn is_intercepting(&self) -> bool {
567631
if self.page.is_intercepting() && self.has_page() {
@@ -948,26 +1012,77 @@ async fn directory_tree_to_entrypoints(
9481012
let plain_tree = directory_tree.into_plain().await?;
9491013
let mut declared_slots = FxIndexMap::default();
9501014
collect_declared_parallel_route_slots(&plain_tree, &mut declared_slots);
951-
let mut retained_entrypoints = FxIndexMap::default();
1015+
let mut candidate_entrypoints = FxIndexMap::default();
9521016

9531017
// Loader trees built while walking a subtree may still contain temporary synthesized
954-
// defaults that disappear when sibling pages are combined. Prune only the finalized root
955-
// entrypoints so complete routes are never discarded based on an intermediate tree.
1018+
// defaults that disappear when sibling pages are combined. Inspect only the finalized root
1019+
// entrypoints so complete routes are never discarded based on an intermediate tree. This
1020+
// layer retains incomplete static matchers long enough to report their exact slot topology;
1021+
// incomplete catch-all matchers preserve the pruning behavior from the lower layer.
9561022
for (app_path, entrypoint) in entrypoints_ref.iter() {
9571023
let is_incomplete = match entrypoint {
9581024
Entrypoint::AppPage { loader_tree, .. } => loader_tree
9591025
.await?
960-
.contains_declared_builtin_not_found_default(
961-
&builtin_default,
962-
&declared_slots,
963-
None,
964-
),
1026+
.has_unmatched_parallel_route(&builtin_default, &declared_slots, None),
9651027
_ => false,
9661028
};
9671029

9681030
if !is_incomplete {
1031+
candidate_entrypoints.insert(app_path.clone(), entrypoint.clone());
1032+
}
1033+
}
1034+
1035+
let mut incompatible_parallel_route_slots = Vec::new();
1036+
let mut retained_entrypoints = FxIndexMap::default();
1037+
1038+
// Report incomplete static matchers from their finalized loader trees, but still prune them
1039+
// from the matcher set. Interception routes intentionally use synthetic retain markers and
1040+
// therefore follow different reporting rules.
1041+
for (app_path, entrypoint) in &candidate_entrypoints {
1042+
let Entrypoint::AppPage { loader_tree, .. } = entrypoint else {
1043+
retained_entrypoints.insert(app_path.clone(), entrypoint.clone());
1044+
continue;
1045+
};
1046+
let loader_tree = loader_tree.await?;
1047+
if !loader_tree.contains_declared_builtin_not_found_default(
1048+
&builtin_default,
1049+
&declared_slots,
1050+
None,
1051+
) {
9691052
retained_entrypoints.insert(app_path.clone(), entrypoint.clone());
1053+
continue;
1054+
}
1055+
if app_path.intercepted_path().is_some() {
1056+
continue;
1057+
}
1058+
1059+
let mut missing_slots = FxIndexMap::default();
1060+
loader_tree.collect_builtin_not_found_defaults(
1061+
&builtin_default,
1062+
&declared_slots,
1063+
None,
1064+
&mut missing_slots,
1065+
);
1066+
if missing_slots.is_empty() {
1067+
bail!(
1068+
"Invariant: strict route matching retained the incomplete route matcher \
1069+
`{app_path}`"
1070+
);
9701071
}
1072+
incompatible_parallel_route_slots.extend(
1073+
missing_slots
1074+
.into_iter()
1075+
.map(|(layout, slots)| (layout, app_path.clone(), slots.into_iter().collect())),
1076+
);
1077+
}
1078+
1079+
if !incompatible_parallel_route_slots.is_empty() {
1080+
IncompatibleParallelRouteSlotsIssue {
1081+
app_dir: app_dir.clone(),
1082+
routes: incompatible_parallel_route_slots,
1083+
}
1084+
.resolved_cell()
1085+
.emit();
9711086
}
9721087

9731088
// This assertion is intentionally separate from the filtering condition above. It guards
@@ -1063,6 +1178,93 @@ struct MissingCanonicalInterceptionRoutesIssue {
10631178
routes: Vec<(AppPath, AppPath, FileSystemPath)>,
10641179
}
10651180

1181+
#[turbo_tasks::value]
1182+
struct IncompatibleParallelRouteSlotsIssue {
1183+
app_dir: FileSystemPath,
1184+
routes: Vec<(FileSystemPath, AppPath, Vec<RcStr>)>,
1185+
}
1186+
1187+
#[async_trait]
1188+
#[turbo_tasks::value_impl]
1189+
impl Issue for IncompatibleParallelRouteSlotsIssue {
1190+
async fn file_path(&self) -> Result<FileSystemPath> {
1191+
Ok(self.routes[0].0.clone())
1192+
}
1193+
1194+
fn stage(&self) -> IssueStage {
1195+
IssueStage::AppStructure
1196+
}
1197+
1198+
fn severity(&self) -> IssueSeverity {
1199+
IssueSeverity::Error
1200+
}
1201+
1202+
async fn title(&self) -> Result<StyledString> {
1203+
Ok(StyledString::Text(rcstr!(
1204+
"Parallel route slots cannot render the same URLs"
1205+
)))
1206+
}
1207+
1208+
async fn description(&self) -> Result<Option<StyledString>> {
1209+
let mut routes_by_layout = FxIndexMap::<FileSystemPath, Vec<_>>::default();
1210+
for (layout, route, missing_slots) in &self.routes {
1211+
routes_by_layout
1212+
.entry(layout.clone())
1213+
.or_default()
1214+
.push((route.clone(), missing_slots.clone()));
1215+
}
1216+
1217+
let mut layouts = routes_by_layout
1218+
.into_iter()
1219+
.map(|(layout, mut routes)| {
1220+
routes.sort_by_cached_key(|(route, _)| route.to_string());
1221+
let layout_path = self
1222+
.app_dir
1223+
.get_path_to(&layout)
1224+
.expect("parallel route layout should be within the app directory")
1225+
.to_string();
1226+
let routes = routes
1227+
.into_iter()
1228+
.map(|(route, missing_slots)| {
1229+
let missing_slots = missing_slots
1230+
.iter()
1231+
.map(|slot| {
1232+
if &**slot == "children" {
1233+
slot.to_string()
1234+
} else {
1235+
format!("@{slot}")
1236+
}
1237+
})
1238+
.collect::<Vec<_>>()
1239+
.join(", ");
1240+
format!(
1241+
"- {route} is missing a matching page or default.tsx in \
1242+
{missing_slots}"
1243+
)
1244+
})
1245+
.collect::<Vec<_>>()
1246+
.join("\n");
1247+
(layout_path, routes)
1248+
})
1249+
.collect::<Vec<_>>();
1250+
layouts.sort_by(|a, b| a.0.cmp(&b.0));
1251+
let layouts = layouts
1252+
.into_iter()
1253+
.map(|(layout_path, routes)| format!("app/{layout_path}\n{routes}"))
1254+
.collect::<Vec<_>>()
1255+
.join("\n\n");
1256+
1257+
Ok(Some(StyledString::Text(
1258+
format!(
1259+
"The following layouts have parallel route slots that cannot render the same \
1260+
URLs:\n{layouts}\n\nEvery URL matched by one slot must have a matching page or \
1261+
default.tsx in every sibling slot."
1262+
)
1263+
.into(),
1264+
)))
1265+
}
1266+
}
1267+
10661268
#[async_trait]
10671269
#[turbo_tasks::value_impl]
10681270
impl Issue for MissingCanonicalInterceptionRoutesIssue {
@@ -1449,7 +1651,6 @@ async fn directory_tree_to_loader_tree(
14491651
let plain_tree_vc = directory_tree.into_plain();
14501652
let plain_tree = &*plain_tree_vc.await?;
14511653
let strict_route_matching = *strict_route_matching.await?;
1452-
14531654
let mut missing_defaults = Vec::new();
14541655
let tree = directory_tree_to_loader_tree_internal(
14551656
app_dir.clone(),

‎packages/next/src/build/entries.ts‎

Lines changed: 66 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,9 @@ import type { MappedPages } from './build-context'
6161
import { PAGE_TYPES } from '../lib/page-types'
6262
import { UnmatchedAppPagesError } from '../shared/lib/errors/unmatched-app-pages-error'
6363
import { MissingCanonicalInterceptionRoutesError } from '../shared/lib/errors/missing-canonical-interception-routes-error'
64+
import { IncompatibleParallelRouteSlotsError } from '../shared/lib/errors/incompatible-parallel-route-slots-error'
6465
import { findMissingCanonicalInterceptionRoutes } from '../shared/lib/router/utils/interception-routes'
66+
import { findPageFile } from '../server/lib/find-page-file'
6567

6668
type ObjectValue<T> = T extends { [key: string]: infer V } ? V : never
6769
import { getStaticInfoIncludingLayouts } from './get-static-info-including-layouts'
@@ -430,10 +432,11 @@ export async function createEntrypoints(
430432
}
431433

432434
// TODO: find a better place to do this
433-
const unmatchedAppPages = normalizeCatchAllRoutes(appPathsPerRoute, {
434-
strictRouteMatching: config.experimental.strictRouteMatching,
435-
defaultAppPaths: Object.keys(appDefaultPaths ?? {}),
436-
})
435+
const { unmatchedAppPages, incompatibleParallelRouteSlots } =
436+
normalizeCatchAllRoutes(appPathsPerRoute, {
437+
strictRouteMatching: config.experimental.strictRouteMatching,
438+
defaultAppPaths: Object.keys(appDefaultPaths ?? {}),
439+
})
437440
// Only App Router pages can make an intercepted URL directly renderable.
438441
// Route handlers and metadata routes may share the pathname, but they
439442
// cannot provide the canonical page shown by an initial request.
@@ -447,27 +450,70 @@ export async function createEntrypoints(
447450
.strictRouteMatching
448451
? findMissingCanonicalInterceptionRoutes(appPagePathsPerRoute)
449452
: []
453+
const routeMatchingErrors: Error[] = []
450454
if (missingCanonicalInterceptionRoutes.length > 0) {
451-
throw new MissingCanonicalInterceptionRoutesError(
452-
missingCanonicalInterceptionRoutes
455+
routeMatchingErrors.push(
456+
new MissingCanonicalInterceptionRoutesError(
457+
missingCanonicalInterceptionRoutes
458+
)
453459
)
454460
}
455-
if (unmatchedAppPages.length > 0) {
456-
throw new UnmatchedAppPagesError(
457-
unmatchedAppPages.map((appPath) => {
458-
const absolutePagePath = appPageFiles.get(appPath)
459-
if (!absolutePagePath) return appPath
460-
461-
return relative(
462-
rootDir,
463-
getPageFilePath({
464-
absolutePagePath,
465-
pagesDir,
466-
appDir,
467-
rootDir,
461+
if (incompatibleParallelRouteSlots.length > 0) {
462+
routeMatchingErrors.push(
463+
new IncompatibleParallelRouteSlotsError(
464+
await Promise.all(
465+
incompatibleParallelRouteSlots.map(async (incompatibleRoute) => {
466+
const layoutPagePath = posix.join(
467+
incompatibleRoute.layoutPath,
468+
'layout'
469+
)
470+
const layoutFile = await findPageFile(
471+
appDir,
472+
layoutPagePath,
473+
pageExtensions,
474+
true
475+
)
476+
477+
return {
478+
...incompatibleRoute,
479+
layoutFile: relative(
480+
rootDir,
481+
layoutFile
482+
? join(appDir, layoutFile)
483+
: join(appDir, layoutPagePath)
484+
),
485+
}
468486
})
469487
)
470-
})
488+
)
489+
)
490+
}
491+
if (unmatchedAppPages.length > 0) {
492+
routeMatchingErrors.push(
493+
new UnmatchedAppPagesError(
494+
unmatchedAppPages.map((appPath) => {
495+
const absolutePagePath = appPageFiles.get(appPath)
496+
if (!absolutePagePath) return appPath
497+
498+
return relative(
499+
rootDir,
500+
getPageFilePath({
501+
absolutePagePath,
502+
pagesDir,
503+
appDir,
504+
rootDir,
505+
})
506+
)
507+
})
508+
)
509+
)
510+
}
511+
if (routeMatchingErrors.length === 1) {
512+
throw routeMatchingErrors[0]
513+
}
514+
if (routeMatchingErrors.length > 1) {
515+
throw new Error(
516+
routeMatchingErrors.map((error) => error.message).join('\n\n')
471517
)
472518
}
473519

0 commit comments

Comments
 (0)