Skip to content

Commit c6cd1f4

Browse files
authored
Relax default.tsx validation for parallel routes leaf segments (#84767)
### What? Relaxes the overly strict `default.tsx` validation for parallel routes to only require the file when it's actually needed (non-leaf segments with routable children). ### Why? The initial validation for parallel routes required `default.tsx` files for all parallel route slots to prevent a specific issue: when navigating to child routes, missing `default.tsx` files could cause silent 404s in slot content, creating a confusing user experience. However, this validation was **too strict** - it required `default.tsx` even in cases where it wasn't actually necessary: 1. **Leaf segments** (routes with no child routes) - There are no child routes to navigate to, so the 404 issue cannot occur 2. **Route groups** - These are organizational folders and aren't routable themselves 3. **Catch-all parameters** - The matched segments are dynamic parameter values, not actual child route segments This caused false positive build errors for valid parallel route configurations that don't have the underlying navigational issue. NAR-448
1 parent c2863c8 commit c6cd1f4

File tree

36 files changed

+784
-13
lines changed

36 files changed

+784
-13
lines changed

crates/next-core/src/app_structure.rs

Lines changed: 61 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -896,15 +896,24 @@ impl Issue for MissingDefaultParallelRouteIssue {
896896
#[turbo_tasks::function]
897897
async fn description(&self) -> Vc<OptionStyledString> {
898898
Vc::cell(Some(
899-
StyledString::Text(
900-
format!(
901-
"The parallel route slot \"@{}\" is missing a default.js file. When using \
902-
parallel routes, each slot must have a default.js file to serve as a \
903-
fallback.\n\nCreate a default.js file at: {}/@{}/default.js",
904-
self.slot_name, self.app_page, self.slot_name
905-
)
906-
.into(),
907-
)
899+
StyledString::Stack(vec![
900+
StyledString::Text(
901+
format!(
902+
"The parallel route slot \"@{}\" is missing a default.js file. When using \
903+
parallel routes, each slot must have a default.js file to serve as a \
904+
fallback.",
905+
self.slot_name
906+
)
907+
.into(),
908+
),
909+
StyledString::Text(
910+
format!(
911+
"Create a default.js file at: {}/@{}/default.js",
912+
self.app_page, self.slot_name
913+
)
914+
.into(),
915+
),
916+
])
908917
.resolved_cell(),
909918
))
910919
}
@@ -940,6 +949,32 @@ fn page_path_except_parallel(loader_tree: &AppPageLoaderTree) -> Option<AppPage>
940949
None
941950
}
942951

952+
/// Checks if a directory tree has child routes (non-parallel, non-group routes).
953+
/// Leaf segments don't need default.js because there are no child routes
954+
/// that could cause the parallel slot to unmatch.
955+
fn has_child_routes(directory_tree: &PlainDirectoryTree) -> bool {
956+
for (name, subdirectory) in &directory_tree.subdirectories {
957+
// Skip parallel routes (start with '@')
958+
if is_parallel_route(name) {
959+
continue;
960+
}
961+
962+
// Skip route groups, but check if they have pages inside
963+
if is_group_route(name) {
964+
// Recursively check if the group has child routes
965+
if has_child_routes(subdirectory) {
966+
return true;
967+
}
968+
continue;
969+
}
970+
971+
// If we get here, it's a regular route segment (child route)
972+
return true;
973+
}
974+
975+
false
976+
}
977+
943978
async fn check_duplicate(
944979
duplicate: &mut FxHashMap<AppPath, AppPage>,
945980
loader_tree: &AppPageLoaderTree,
@@ -1200,9 +1235,20 @@ async fn directory_tree_to_loader_tree_internal(
12001235
// /[...catchAll]/@slot - is_inside_catchall = true (skip validation) ✓
12011236
// /@slot/[...catchAll] - is_inside_catchall = false (require default) ✓
12021237
// The catch-all provides fallback behavior, so default.js is not required.
1238+
//
1239+
// Also skip validation if this is a leaf segment (no child routes).
1240+
// Leaf segments don't need default.js because there are no child routes
1241+
// that could cause the parallel slot to unmatch. For example:
1242+
// /repo-overview/@slot/page with no child routes - is_leaf_segment = true (skip
1243+
// validation) ✓ /repo-overview/@slot/page with
1244+
// /repo-overview/child/page - is_leaf_segment = false (require default) ✓
1245+
// This also handles route groups correctly by filtering them out.
1246+
let is_leaf_segment = !has_child_routes(directory_tree);
1247+
12031248
if key != "children"
12041249
&& subdirectory.modules.default.is_none()
12051250
&& !is_inside_catchall
1251+
&& !is_leaf_segment
12061252
{
12071253
missing_default_parallel_route_issue(
12081254
app_dir.clone(),
@@ -1275,10 +1321,14 @@ async fn directory_tree_to_loader_tree_internal(
12751321

12761322
let is_inside_catchall = app_page.is_catchall();
12771323

1324+
// Check if this is a leaf segment (no child routes).
1325+
let is_leaf_segment = !has_child_routes(directory_tree);
1326+
12781327
// Only emit the issue if this is not the children slot and there's no default
12791328
// component. The children slot is implicit and doesn't require a default.js
1280-
// file. Also skip validation if the slot is UNDER a catch-all route.
1281-
if default.is_none() && key != "children" && !is_inside_catchall {
1329+
// file. Also skip validation if the slot is UNDER a catch-all route or if
1330+
// this is a leaf segment (no child routes).
1331+
if default.is_none() && key != "children" && !is_inside_catchall && !is_leaf_segment {
12821332
missing_default_parallel_route_issue(
12831333
app_dir.clone(),
12841334
app_page.clone(),

packages/next/src/build/webpack/loaders/next-app-loader/index.ts

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ async function createTreeCodeFromPath(
135135
resolveDir,
136136
resolver,
137137
resolveParallelSegments,
138+
hasChildRoutesForSegment,
138139
metadataResolver,
139140
pageExtensions,
140141
basePath,
@@ -148,6 +149,7 @@ async function createTreeCodeFromPath(
148149
resolveParallelSegments: (
149150
pathname: string
150151
) => [key: string, segment: string | string[]][]
152+
hasChildRoutesForSegment: (segmentPath: string) => boolean
151153
loaderContext: webpack.LoaderContext<AppLoaderOptions>
152154
pageExtensions: PageExtensions
153155
basePath: string
@@ -560,9 +562,23 @@ async function createTreeCodeFromPath(
560562
// /@slot/[...catchAll] - isInsideCatchAll = false (require default) ✓
561563
// The catch-all provides fallback behavior, so default.js is not required.
562564
const isInsideCatchAll = segments.some(isCatchAllSegment)
563-
if (!isInsideCatchAll) {
565+
566+
// Check if this is a leaf segment (no child routes).
567+
// Leaf segments don't need default.js because there are no child routes
568+
// that could cause the parallel slot to unmatch. For example:
569+
// /repo-overview/@slot/page with no child routes - isLeafSegment = true (skip validation) ✓
570+
// /repo-overview/@slot/page with /repo-overview/child/page - isLeafSegment = false (require default) ✓
571+
// This also handles route groups correctly by filtering them out.
572+
const isLeafSegment = !hasChildRoutesForSegment(segmentPath)
573+
574+
if (!isInsideCatchAll && !isLeafSegment) {
575+
// Replace internal webpack alias with user-facing directory name
576+
const userFacingPath = fullSegmentPath.replace(
577+
APP_DIR_ALIAS,
578+
'app'
579+
)
564580
throw new MissingDefaultParallelRouteError(
565-
fullSegmentPath,
581+
userFacingPath,
566582
adjacentParallelSegment
567583
)
568584
}
@@ -726,6 +742,44 @@ const nextAppLoader: AppLoader = async function nextAppLoader() {
726742
return Object.entries(matched)
727743
}
728744

745+
const hasChildRoutesForSegment = (segmentPath: string): boolean => {
746+
const pathPrefix = segmentPath ? `${segmentPath}/` : ''
747+
748+
for (const appPath of normalizedAppPaths) {
749+
if (appPath.startsWith(pathPrefix)) {
750+
const rest = appPath.slice(pathPrefix.length).split('/')
751+
752+
// Filter out route groups to get the actual route segments
753+
// Route groups (e.g., "(group)") don't contribute to the URL path
754+
const routeSegments = rest.filter((segment) => !isGroupSegment(segment))
755+
756+
// If it's just 'page' at this level, skip (not a child route)
757+
if (routeSegments.length === 1 && routeSegments[0] === 'page') {
758+
continue
759+
}
760+
761+
// If the first segment (after filtering route groups) is a parallel route, skip
762+
if (routeSegments[0]?.startsWith('@')) {
763+
continue
764+
}
765+
766+
// If we have more than just 'page', then there are child routes
767+
// Examples:
768+
// ['child', 'page'] -> true (has child route)
769+
// ['page'] -> false (already filtered above)
770+
// ['grandchild', 'deeper', 'page'] -> true (has nested child routes)
771+
if (
772+
routeSegments.length > 1 ||
773+
(routeSegments.length === 1 && routeSegments[0] !== 'page')
774+
) {
775+
return true
776+
}
777+
}
778+
}
779+
780+
return false
781+
}
782+
729783
const resolveDir: DirResolver = (pathToResolve) => {
730784
return createAbsolutePath(appDir, pathToResolve)
731785
}
@@ -832,6 +886,7 @@ const nextAppLoader: AppLoader = async function nextAppLoader() {
832886
resolver,
833887
metadataResolver,
834888
resolveParallelSegments,
889+
hasChildRoutesForSegment,
835890
loaderContext: this,
836891
pageExtensions,
837892
basePath,
@@ -889,6 +944,7 @@ const nextAppLoader: AppLoader = async function nextAppLoader() {
889944
resolver,
890945
metadataResolver,
891946
resolveParallelSegments,
947+
hasChildRoutesForSegment,
892948
loaderContext: this,
893949
pageExtensions,
894950
basePath,

packages/next/src/shared/lib/errors/missing-default-parallel-route-error.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,9 @@ export class MissingDefaultParallelRouteError extends Error {
88
)
99

1010
this.name = 'MissingDefaultParallelRouteError'
11+
12+
// This error is meant to interrupt the server start/build process
13+
// but the stack trace isn't meaningful, as it points to internal code.
14+
this.stack = undefined
1115
}
1216
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# Build Error Scenarios
2+
3+
This fixture contains scenarios that **SHOULD throw `MissingDefaultParallelRouteError`** during build.
4+
5+
## Why These Should Error
6+
7+
All scenarios in this fixture have:
8+
1. ✅ Parallel routes (slots starting with `@`)
9+
2.**NO** `default.tsx` files for those parallel routes
10+
3.**Child routes** that make these **non-leaf segments**
11+
12+
The presence of child routes means `default.tsx` files are required for the parallel slots.
13+
14+
---
15+
16+
## Scenario 1: Non-Leaf Segment with Children
17+
18+
**Path:** `/with-children`
19+
20+
```
21+
app/with-children/
22+
├── @header/
23+
│ └── page.tsx ← Has page.tsx
24+
│ ❌ NO default.tsx! ← Missing default.tsx
25+
├── @sidebar/
26+
│ └── page.tsx ← Has page.tsx
27+
│ ❌ NO default.tsx! ← Missing default.tsx
28+
├── layout.tsx ← Uses @header and @sidebar
29+
├── page.tsx ← Parent page
30+
└── child/
31+
└── page.tsx ← ⚠️ CHILD ROUTE EXISTS!
32+
```
33+
34+
**Expected Error:**
35+
```
36+
MissingDefaultParallelRouteError:
37+
Missing required default.js file for parallel route at /with-children/@header
38+
The parallel route slot "@header" is missing a default.js file.
39+
```
40+
41+
**Why it errors:**
42+
- When navigating from `/with-children` to `/with-children/child`, the routing system needs to know what to render for the `@header` and `@sidebar` slots
43+
- Since `/with-children/child` doesn't define these parallel routes, Next.js looks for `default.tsx` files
44+
- No `default.tsx` files exist → ERROR!
45+
46+
---
47+
48+
## Scenario 2: Non-Leaf with Route Groups and Children
49+
50+
**Path:** `/with-groups-and-children`
51+
52+
```
53+
app/with-groups-and-children/(dashboard)/(overview)/
54+
├── @analytics/
55+
│ └── page.tsx ← Has page.tsx
56+
│ ❌ NO default.tsx! ← Missing default.tsx
57+
├── @metrics/
58+
│ └── page.tsx ← Has page.tsx
59+
│ ❌ NO default.tsx! ← Missing default.tsx
60+
├── layout.tsx ← Uses @analytics and @metrics
61+
├── page.tsx ← Parent page
62+
└── nested/
63+
└── page.tsx ← ⚠️ CHILD ROUTE EXISTS!
64+
```
65+
66+
**Route Groups:** `(dashboard)` and `(overview)` don't affect the URL
67+
68+
**Expected Error:**
69+
```
70+
MissingDefaultParallelRouteError:
71+
Missing required default.js file for parallel route at /with-groups-and-children/(dashboard)/(overview)/@analytics
72+
The parallel route slot "@analytics" is missing a default.js file.
73+
```
74+
75+
**Why it errors:**
76+
- Even with route groups, the segment has a child route (`/nested`)
77+
- The `hasChildRoutesForSegment()` helper correctly:
78+
1. Filters out route groups `(dashboard)` and `(overview)`
79+
2. Detects the `nested/page.tsx` child route
80+
3. Identifies this as a **non-leaf segment**
81+
- No `default.tsx` files exist → ERROR!
82+
83+
---
84+
85+
## How to Fix These Errors
86+
87+
To make these scenarios build successfully, add `default.tsx` files:
88+
89+
### For Scenario 1:
90+
```tsx
91+
// app/with-children/@header/default.tsx
92+
export default function HeaderDefault() {
93+
return <div>Header Fallback</div>
94+
}
95+
96+
// app/with-children/@sidebar/default.tsx
97+
export default function SidebarDefault() {
98+
return <div>Sidebar Fallback</div>
99+
}
100+
```
101+
102+
### For Scenario 2:
103+
```tsx
104+
// app/with-groups-and-children/(dashboard)/(overview)/@analytics/default.tsx
105+
export default function AnalyticsDefault() {
106+
return <div>Analytics Fallback</div>
107+
}
108+
109+
// app/with-groups-and-children/(dashboard)/(overview)/@metrics/default.tsx
110+
export default function MetricsDefault() {
111+
return <div>Metrics Fallback</div>
112+
}
113+
```
114+
115+
---
116+
117+
## Contrast with `no-build-error` Fixture
118+
119+
The `no-build-error` fixture has similar parallel routes but:
120+
-**NO child routes** (leaf segments)
121+
-`default.tsx` files are **NOT required**
122+
123+
This fixture (build-error) has:
124+
-**Child routes exist** (non-leaf segments)
125+
-`default.tsx` files **ARE required** but missing → **ERROR!**
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export default function RootLayout({
2+
children,
3+
}: {
4+
children: React.ReactNode
5+
}) {
6+
return (
7+
<html>
8+
<body>{children}</body>
9+
</html>
10+
)
11+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import Link from 'next/link'
2+
3+
export default function HomePage() {
4+
return (
5+
<div>
6+
<h1>Build Error Scenarios Test</h1>
7+
<p>
8+
These routes SHOULD cause MissingDefaultParallelRouteError because they
9+
have parallel routes without default.tsx files AND have child routes.
10+
</p>
11+
<nav>
12+
<ul>
13+
<li>
14+
<Link href="/with-children">
15+
Non-Leaf Segment with Children (SHOULD ERROR)
16+
</Link>
17+
</li>
18+
<li>
19+
<Link href="/with-groups-and-children">
20+
Non-Leaf with Route Groups and Children (SHOULD ERROR)
21+
</Link>
22+
</li>
23+
</ul>
24+
</nav>
25+
</div>
26+
)
27+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export default function HeaderSlot() {
2+
return (
3+
<div>
4+
<h3>Header Slot (Parent)</h3>
5+
<p>This is the @header parallel route</p>
6+
</div>
7+
)
8+
}

0 commit comments

Comments
 (0)