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
72 changes: 61 additions & 11 deletions crates/next-core/src/app_structure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -896,15 +896,24 @@ impl Issue for MissingDefaultParallelRouteIssue {
#[turbo_tasks::function]
async fn description(&self) -> Vc<OptionStyledString> {
Vc::cell(Some(
StyledString::Text(
format!(
"The parallel route slot \"@{}\" is missing a default.js file. When using \
parallel routes, each slot must have a default.js file to serve as a \
fallback.\n\nCreate a default.js file at: {}/@{}/default.js",
self.slot_name, self.app_page, self.slot_name
)
.into(),
)
StyledString::Stack(vec![
StyledString::Text(
format!(
"The parallel route slot \"@{}\" is missing a default.js file. When using \
parallel routes, each slot must have a default.js file to serve as a \
fallback.",
self.slot_name
)
.into(),
),
StyledString::Text(
format!(
"Create a default.js file at: {}/@{}/default.js",
self.app_page, self.slot_name
)
.into(),
),
])
.resolved_cell(),
))
}
Expand Down Expand Up @@ -940,6 +949,32 @@ fn page_path_except_parallel(loader_tree: &AppPageLoaderTree) -> Option<AppPage>
None
}

/// Checks if a directory tree has child routes (non-parallel, non-group routes).
/// Leaf segments don't need default.js because there are no child routes
/// that could cause the parallel slot to unmatch.
Comment on lines +952 to +954
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's technically valid to have only named parallel routes, eg @foo and @bar without necessarily having a children page slot.

Does this account for that possibility?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think you could provide an example file/folder tree of the app structure you're envisioning?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

eg:

app/
├── layout.tsx
├── default.tsx
├── @slotA/
│   └── other/
│       └── page.tsx
└── @slotB/
    └── other/
        └── page.tsx

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes it does! I've added this as a test case as well to this PR.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fn has_child_routes(directory_tree: &PlainDirectoryTree) -> bool {
for (name, subdirectory) in &directory_tree.subdirectories {
// Skip parallel routes (start with '@')
if is_parallel_route(name) {
continue;
}

// Skip route groups, but check if they have pages inside
if is_group_route(name) {
// Recursively check if the group has child routes
if has_child_routes(subdirectory) {
return true;
}
continue;
}

// If we get here, it's a regular route segment (child route)
return true;
}

false
}

async fn check_duplicate(
duplicate: &mut FxHashMap<AppPath, AppPage>,
loader_tree: &AppPageLoaderTree,
Expand Down Expand Up @@ -1200,9 +1235,20 @@ async fn directory_tree_to_loader_tree_internal(
// /[...catchAll]/@slot - is_inside_catchall = true (skip validation) ✓
// /@slot/[...catchAll] - is_inside_catchall = false (require default) ✓
// The catch-all provides fallback behavior, so default.js is not required.
//
// Also skip validation if this is a leaf segment (no child routes).
// Leaf segments don't need default.js because there are no child routes
// that could cause the parallel slot to unmatch. For example:
// /repo-overview/@slot/page with no child routes - is_leaf_segment = true (skip
// validation) ✓ /repo-overview/@slot/page with
// /repo-overview/child/page - is_leaf_segment = false (require default) ✓
// This also handles route groups correctly by filtering them out.
let is_leaf_segment = !has_child_routes(directory_tree);

if key != "children"
&& subdirectory.modules.default.is_none()
&& !is_inside_catchall
&& !is_leaf_segment
{
missing_default_parallel_route_issue(
app_dir.clone(),
Expand Down Expand Up @@ -1275,10 +1321,14 @@ async fn directory_tree_to_loader_tree_internal(

let is_inside_catchall = app_page.is_catchall();

// Check if this is a leaf segment (no child routes).
let is_leaf_segment = !has_child_routes(directory_tree);

// Only emit the issue if this is not the children slot and there's no default
// component. The children slot is implicit and doesn't require a default.js
// file. Also skip validation if the slot is UNDER a catch-all route.
if default.is_none() && key != "children" && !is_inside_catchall {
// file. Also skip validation if the slot is UNDER a catch-all route or if
// this is a leaf segment (no child routes).
if default.is_none() && key != "children" && !is_inside_catchall && !is_leaf_segment {
missing_default_parallel_route_issue(
app_dir.clone(),
app_page.clone(),
Expand Down
60 changes: 58 additions & 2 deletions packages/next/src/build/webpack/loaders/next-app-loader/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ async function createTreeCodeFromPath(
resolveDir,
resolver,
resolveParallelSegments,
hasChildRoutesForSegment,
metadataResolver,
pageExtensions,
basePath,
Expand All @@ -148,6 +149,7 @@ async function createTreeCodeFromPath(
resolveParallelSegments: (
pathname: string
) => [key: string, segment: string | string[]][]
hasChildRoutesForSegment: (segmentPath: string) => boolean
loaderContext: webpack.LoaderContext<AppLoaderOptions>
pageExtensions: PageExtensions
basePath: string
Expand Down Expand Up @@ -560,9 +562,23 @@ async function createTreeCodeFromPath(
// /@slot/[...catchAll] - isInsideCatchAll = false (require default) ✓
// The catch-all provides fallback behavior, so default.js is not required.
const isInsideCatchAll = segments.some(isCatchAllSegment)
if (!isInsideCatchAll) {

// Check if this is a leaf segment (no child routes).
// Leaf segments don't need default.js because there are no child routes
// that could cause the parallel slot to unmatch. For example:
// /repo-overview/@slot/page with no child routes - isLeafSegment = true (skip validation) ✓
// /repo-overview/@slot/page with /repo-overview/child/page - isLeafSegment = false (require default) ✓
// This also handles route groups correctly by filtering them out.
const isLeafSegment = !hasChildRoutesForSegment(segmentPath)

if (!isInsideCatchAll && !isLeafSegment) {
// Replace internal webpack alias with user-facing directory name
const userFacingPath = fullSegmentPath.replace(
APP_DIR_ALIAS,
'app'
)
throw new MissingDefaultParallelRouteError(
fullSegmentPath,
userFacingPath,
adjacentParallelSegment
)
}
Expand Down Expand Up @@ -726,6 +742,44 @@ const nextAppLoader: AppLoader = async function nextAppLoader() {
return Object.entries(matched)
}

const hasChildRoutesForSegment = (segmentPath: string): boolean => {
const pathPrefix = segmentPath ? `${segmentPath}/` : ''

for (const appPath of normalizedAppPaths) {
if (appPath.startsWith(pathPrefix)) {
const rest = appPath.slice(pathPrefix.length).split('/')

// Filter out route groups to get the actual route segments
// Route groups (e.g., "(group)") don't contribute to the URL path
const routeSegments = rest.filter((segment) => !isGroupSegment(segment))

// If it's just 'page' at this level, skip (not a child route)
if (routeSegments.length === 1 && routeSegments[0] === 'page') {
continue
}

// If the first segment (after filtering route groups) is a parallel route, skip
if (routeSegments[0]?.startsWith('@')) {
continue
}

// If we have more than just 'page', then there are child routes
// Examples:
// ['child', 'page'] -> true (has child route)
// ['page'] -> false (already filtered above)
// ['grandchild', 'deeper', 'page'] -> true (has nested child routes)
if (
routeSegments.length > 1 ||
(routeSegments.length === 1 && routeSegments[0] !== 'page')
) {
return true
}
}
}

return false
}

const resolveDir: DirResolver = (pathToResolve) => {
return createAbsolutePath(appDir, pathToResolve)
}
Expand Down Expand Up @@ -832,6 +886,7 @@ const nextAppLoader: AppLoader = async function nextAppLoader() {
resolver,
metadataResolver,
resolveParallelSegments,
hasChildRoutesForSegment,
loaderContext: this,
pageExtensions,
basePath,
Expand Down Expand Up @@ -889,6 +944,7 @@ const nextAppLoader: AppLoader = async function nextAppLoader() {
resolver,
metadataResolver,
resolveParallelSegments,
hasChildRoutesForSegment,
loaderContext: this,
pageExtensions,
basePath,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,9 @@ export class MissingDefaultParallelRouteError extends Error {
)

this.name = 'MissingDefaultParallelRouteError'

// This error is meant to interrupt the server start/build process
// but the stack trace isn't meaningful, as it points to internal code.
this.stack = undefined
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# Build Error Scenarios

This fixture contains scenarios that **SHOULD throw `MissingDefaultParallelRouteError`** during build.

## Why These Should Error

All scenarios in this fixture have:
1. ✅ Parallel routes (slots starting with `@`)
2. ❌ **NO** `default.tsx` files for those parallel routes
3. ✅ **Child routes** that make these **non-leaf segments**

The presence of child routes means `default.tsx` files are required for the parallel slots.

---

## Scenario 1: Non-Leaf Segment with Children

**Path:** `/with-children`

```
app/with-children/
├── @header/
│ └── page.tsx ← Has page.tsx
│ ❌ NO default.tsx! ← Missing default.tsx
├── @sidebar/
│ └── page.tsx ← Has page.tsx
│ ❌ NO default.tsx! ← Missing default.tsx
├── layout.tsx ← Uses @header and @sidebar
├── page.tsx ← Parent page
└── child/
└── page.tsx ← ⚠️ CHILD ROUTE EXISTS!
```

**Expected Error:**
```
MissingDefaultParallelRouteError:
Missing required default.js file for parallel route at /with-children/@header
The parallel route slot "@header" is missing a default.js file.
```

**Why it errors:**
- When navigating from `/with-children` to `/with-children/child`, the routing system needs to know what to render for the `@header` and `@sidebar` slots
- Since `/with-children/child` doesn't define these parallel routes, Next.js looks for `default.tsx` files
- No `default.tsx` files exist → ERROR!

---

## Scenario 2: Non-Leaf with Route Groups and Children

**Path:** `/with-groups-and-children`

```
app/with-groups-and-children/(dashboard)/(overview)/
├── @analytics/
│ └── page.tsx ← Has page.tsx
│ ❌ NO default.tsx! ← Missing default.tsx
├── @metrics/
│ └── page.tsx ← Has page.tsx
│ ❌ NO default.tsx! ← Missing default.tsx
├── layout.tsx ← Uses @analytics and @metrics
├── page.tsx ← Parent page
└── nested/
└── page.tsx ← ⚠️ CHILD ROUTE EXISTS!
```

**Route Groups:** `(dashboard)` and `(overview)` don't affect the URL

**Expected Error:**
```
MissingDefaultParallelRouteError:
Missing required default.js file for parallel route at /with-groups-and-children/(dashboard)/(overview)/@analytics
The parallel route slot "@analytics" is missing a default.js file.
```

**Why it errors:**
- Even with route groups, the segment has a child route (`/nested`)
- The `hasChildRoutesForSegment()` helper correctly:
1. Filters out route groups `(dashboard)` and `(overview)`
2. Detects the `nested/page.tsx` child route
3. Identifies this as a **non-leaf segment**
- No `default.tsx` files exist → ERROR!

---

## How to Fix These Errors

To make these scenarios build successfully, add `default.tsx` files:

### For Scenario 1:
```tsx
// app/with-children/@header/default.tsx
export default function HeaderDefault() {
return <div>Header Fallback</div>
}

// app/with-children/@sidebar/default.tsx
export default function SidebarDefault() {
return <div>Sidebar Fallback</div>
}
```

### For Scenario 2:
```tsx
// app/with-groups-and-children/(dashboard)/(overview)/@analytics/default.tsx
export default function AnalyticsDefault() {
return <div>Analytics Fallback</div>
}

// app/with-groups-and-children/(dashboard)/(overview)/@metrics/default.tsx
export default function MetricsDefault() {
return <div>Metrics Fallback</div>
}
```

---

## Contrast with `no-build-error` Fixture

The `no-build-error` fixture has similar parallel routes but:
- ❌ **NO child routes** (leaf segments)
- ✅ `default.tsx` files are **NOT required**

This fixture (build-error) has:
- ✅ **Child routes exist** (non-leaf segments)
- ❌ `default.tsx` files **ARE required** but missing → **ERROR!**
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html>
<body>{children}</body>
</html>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import Link from 'next/link'

export default function HomePage() {
return (
<div>
<h1>Build Error Scenarios Test</h1>
<p>
These routes SHOULD cause MissingDefaultParallelRouteError because they
have parallel routes without default.tsx files AND have child routes.
</p>
<nav>
<ul>
<li>
<Link href="/with-children">
Non-Leaf Segment with Children (SHOULD ERROR)
</Link>
</li>
<li>
<Link href="/with-groups-and-children">
Non-Leaf with Route Groups and Children (SHOULD ERROR)
</Link>
</li>
</ul>
</nav>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default function HeaderSlot() {
return (
<div>
<h3>Header Slot (Parent)</h3>
<p>This is the @header parallel route</p>
</div>
)
}
Loading
Loading