@@ -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 \n Every 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]
10681270impl 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 ( ) ,
0 commit comments