Skip to content

Commit 291aefd

Browse files
authored
Fix 404 responses for interception routes with missing children slots (#85779)
### What? Introduces a null-rendering default component for interception routes to prevent 404 responses when children slots are missing. ### Why? When rendering interception routes like `(.)[id]`, there's a segment mismatch where the interception route lacks a children slot that the base `[id]` route has. **The Bug:** Previously, the framework injected a 404 page into the missing default slot for the children route. On platforms like Vercel, this caused full 404 RSC responses because the HTTP status code has semantic meaning for route behavior when using cache components. This resulted in the "unknown segment value fallback page" issue. **Impact:** - RSC requests for interception routes would return 404 status codes - Platform routing behavior was incorrectly triggered by these 404 responses - Only affects pages rendering with cache components ### How? - Created new `default-null.tsx` builtin component that renders `null` - Updated Rust code (`app_structure.rs`) to detect interception routes and use the null default - Updated webpack loader (`next-app-loader/index.ts`) to check `isInterceptionRouteAppPath()` and inject the null default - Removed test-specific default component that was working around this bug - The old `page.tsx` is still used during client navigation, so there's no visible change to users This ensures minimal RSC payload changes and prevents 404 status codes while maintaining existing client-side behavior. NAR-483
1 parent 1f0322f commit 291aefd

File tree

35 files changed

+707
-28
lines changed

35 files changed

+707
-28
lines changed

crates/next-core/src/app_structure.rs

Lines changed: 47 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1226,17 +1226,19 @@ async fn directory_tree_to_loader_tree_internal(
12261226

12271227
if let Some(subtree) = subtree {
12281228
if let Some(key) = parallel_route_key {
1229-
let is_inside_catchall = app_page.is_catchall();
1230-
12311229
// Validate that parallel routes (except "children") have a default.js file.
1232-
// Skip this validation if the slot is UNDER a catch-all route (i.e., the
1233-
// parallel route is a child of a catch-all segment).
1230+
// This validation matches the webpack loader's logic but is implemented
1231+
// differently due to Turbopack's single-pass recursive processing.
1232+
1233+
// Check if we're inside a catch-all route (i.e., the parallel route is a child
1234+
// of a catch-all segment). Only skip validation if the slot is UNDER a catch-all.
12341235
// For example:
12351236
// /[...catchAll]/@slot - is_inside_catchall = true (skip validation) ✓
12361237
// /@slot/[...catchAll] - is_inside_catchall = false (require default) ✓
12371238
// 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).
1239+
let is_inside_catchall = app_page.is_catchall();
1240+
1241+
// Check if this is a leaf segment (no child routes).
12401242
// Leaf segments don't need default.js because there are no child routes
12411243
// that could cause the parallel slot to unmatch. For example:
12421244
// /repo-overview/@slot/page with no child routes - is_leaf_segment = true (skip
@@ -1245,10 +1247,23 @@ async fn directory_tree_to_loader_tree_internal(
12451247
// This also handles route groups correctly by filtering them out.
12461248
let is_leaf_segment = !has_child_routes(directory_tree);
12471249

1250+
// Turbopack-specific: Check if the parallel slot has matching child routes.
1251+
// In webpack, this is checked implicitly via the two-phase processing:
1252+
// slots with content are processed first and skip validation in the second phase.
1253+
// In Turbopack's single-pass approach, we check directly if the slot has child
1254+
// routes. If the slot has child routes that match the parent's
1255+
// child routes, it can render content for those routes and doesn't
1256+
// need a default. For example:
1257+
// /parent/@slot/page + /parent/@slot/child + /parent/child - slot_has_children =
1258+
// true (skip validation) ✓ /parent/@slot/page + /parent/child (no
1259+
// @slot/child) - slot_has_children = false (require default) ✓
1260+
let slot_has_children = has_child_routes(subdirectory);
1261+
12481262
if key != "children"
12491263
&& subdirectory.modules.default.is_none()
12501264
&& !is_inside_catchall
12511265
&& !is_leaf_segment
1266+
&& !slot_has_children
12521267
{
12531268
missing_default_parallel_route_issue(
12541269
app_dir.clone(),
@@ -1341,8 +1356,15 @@ async fn directory_tree_to_loader_tree_internal(
13411356

13421357
tree.parallel_routes.insert(
13431358
key.clone(),
1344-
default_route_tree(app_dir.clone(), global_metadata, app_page.clone(), default)
1345-
.await?,
1359+
default_route_tree(
1360+
app_dir.clone(),
1361+
global_metadata,
1362+
app_page.clone(),
1363+
default,
1364+
key.clone(),
1365+
for_app_path.clone(),
1366+
)
1367+
.await?,
13461368
);
13471369
}
13481370
}
@@ -1352,8 +1374,10 @@ async fn directory_tree_to_loader_tree_internal(
13521374
tree = default_route_tree(
13531375
app_dir.clone(),
13541376
global_metadata,
1355-
app_page,
1377+
app_page.clone(),
13561378
modules.default.clone(),
1379+
rcstr!("children"),
1380+
for_app_path.clone(),
13571381
)
13581382
.await?;
13591383
} else {
@@ -1365,8 +1389,10 @@ async fn directory_tree_to_loader_tree_internal(
13651389
default_route_tree(
13661390
app_dir.clone(),
13671391
global_metadata,
1368-
app_page,
1392+
app_page.clone(),
13691393
modules.default.clone(),
1394+
rcstr!("children"),
1395+
for_app_path.clone(),
13701396
)
13711397
.await?,
13721398
);
@@ -1388,6 +1414,8 @@ async fn default_route_tree(
13881414
global_metadata: Vc<GlobalMetadata>,
13891415
app_page: AppPage,
13901416
default_component: Option<FileSystemPath>,
1417+
slot_name: RcStr,
1418+
for_app_path: AppPath,
13911419
) -> Result<AppPageLoaderTree> {
13921420
Ok(AppPageLoaderTree {
13931421
page: app_page.clone(),
@@ -1399,13 +1427,16 @@ async fn default_route_tree(
13991427
..Default::default()
14001428
}
14011429
} else {
1402-
// default fallback component
1430+
let contains_interception = for_app_path.contains_interception();
1431+
1432+
let default_file = if contains_interception && slot_name == "children" {
1433+
"dist/client/components/builtin/default-null.js"
1434+
} else {
1435+
"dist/client/components/builtin/default.js"
1436+
};
1437+
14031438
AppDirModules {
1404-
default: Some(
1405-
get_next_package(app_dir)
1406-
.await?
1407-
.join("dist/client/components/builtin/default.js")?,
1408-
),
1439+
default: Some(get_next_package(app_dir).await?.join(default_file)?),
14091440
..Default::default()
14101441
}
14111442
},

crates/next-core/src/next_app/mod.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,18 @@ impl AppPath {
460460

461461
true
462462
}
463+
464+
/// Returns true if ANY segment in the entire path is an interception route.
465+
/// This is different from `is_intercepting()` which only checks the last
466+
/// segment.
467+
pub fn contains_interception(&self) -> bool {
468+
self.iter().any(|segment| {
469+
matches!(
470+
segment,
471+
PathSegment::Static(s) if s.starts_with("(.)") || s.starts_with("(..)") || s.starts_with("(...)")
472+
)
473+
})
474+
}
463475
}
464476

465477
impl Deref for AppPath {

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,11 @@ import {
3535
import { getFilesInDir } from '../../../../lib/get-files-in-dir'
3636
import type { PageExtensions } from '../../../page-extensions-type'
3737
import { PARALLEL_ROUTE_DEFAULT_PATH } from '../../../../client/components/builtin/default'
38+
import { PARALLEL_ROUTE_DEFAULT_NULL_PATH } from '../../../../client/components/builtin/default-null'
3839
import type { Compilation } from 'webpack'
3940
import { createAppRouteCode } from './create-app-route-code'
4041
import { MissingDefaultParallelRouteError } from '../../../../shared/lib/errors/missing-default-parallel-route-error'
42+
import { isInterceptionRouteAppPath } from '../../../../shared/lib/router/utils/interception-routes'
4143

4244
import { normalizePathSep } from '../../../../shared/lib/page-path/normalize-path-sep'
4345
import { installBindings } from '../../../swc/install-bindings'
@@ -558,7 +560,18 @@ async function createTreeCodeFromPath(
558560
let defaultPath = await resolver(`${fullSegmentPath}/default`)
559561
if (!defaultPath) {
560562
if (adjacentParallelSegment === 'children') {
561-
defaultPath = PARALLEL_ROUTE_DEFAULT_PATH
563+
// When we host applications on Vercel, the status code affects the
564+
// underlying behavior of the route, which when we are missing the
565+
// children slot of an interception route, will yield a full 404
566+
// response for the RSC request instead. For this reason, we expect
567+
// that if a default file is missing when we're rendering an
568+
// interception route, we instead always render null for the default
569+
// slot to avoid the full 404 response.
570+
if (isInterceptionRouteAppPath(page)) {
571+
defaultPath = PARALLEL_ROUTE_DEFAULT_NULL_PATH
572+
} else {
573+
defaultPath = PARALLEL_ROUTE_DEFAULT_PATH
574+
}
562575
} else {
563576
// Check if we're inside a catch-all route (i.e., the parallel route is a child
564577
// of a catch-all segment). Only skip validation if the slot is UNDER a catch-all.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export const PARALLEL_ROUTE_DEFAULT_NULL_PATH =
2+
'next/dist/client/components/builtin/default-null.js'
3+
4+
export default function ParallelRouteDefaultNull() {
5+
return null
6+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export default function Page() {
2+
return (
3+
<div>
4+
<h3>Deeper page under explicit layout</h3>
5+
<p>This page is nested under the explicit layout</p>
6+
</div>
7+
)
8+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* TEST CASE: Interception route WITH explicit layout.tsx but NO parallel routes
3+
*
4+
* This is the KEY test to determine if we need to check for:
5+
* - Implicit layout (from parallel routes) OR
6+
* - Explicit layout (layout.tsx file)
7+
*/
8+
export default function Layout({ children }: { children: React.ReactNode }) {
9+
return (
10+
<div>
11+
<div>Explicit layout at (.)explicit-layout</div>
12+
<div>
13+
<div>children:</div>
14+
{children}
15+
</div>
16+
</div>
17+
)
18+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export default function Page() {
2+
return (
3+
<div>
4+
<strong>@sidebar content</strong>
5+
</div>
6+
)
7+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* TEST CASE 3: Interception route WITH parallel routes AND page.tsx
3+
*
4+
* Expected: NO default.tsx needed for children slot
5+
* Reason: Has page.tsx which fills the children slot
6+
* The @sidebar slot exists but children slot has content
7+
*/
8+
export default function Page() {
9+
return (
10+
<div>
11+
<h3>✅ TEST CASE 3: Has both @sidebar AND page.tsx</h3>
12+
<p>This level has @sidebar parallel route AND a page.tsx</p>
13+
<p>The page.tsx fills the children slot.</p>
14+
<p>NO default.tsx needed!</p>
15+
</div>
16+
)
17+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* TEST CASE 1: Interception route WITH page.tsx
3+
*
4+
* Expected: NO default.tsx needed
5+
* Reason: Has a page at this level, so no children slot exists
6+
*/
7+
export default function Page() {
8+
return (
9+
<div>
10+
<h3>✅ TEST CASE 1: Has page.tsx</h3>
11+
<p>This interception route has a page.tsx at this level.</p>
12+
<p>No children slot exists, so NO default.tsx needed!</p>
13+
</div>
14+
)
15+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* TEST CASE 2: Interception route WITHOUT parallel routes
3+
*
4+
* Expected: NO default.tsx needed at (.)no-parallel-routes level
5+
* Reason: No @parallel routes exist, so no implicit layout created
6+
* Only has a nested page, no children slot at root level
7+
*/
8+
export default function Page() {
9+
return (
10+
<div>
11+
<h3>✅ TEST CASE 2: No parallel routes</h3>
12+
<p>This is a nested page under (.)no-parallel-routes/deeper</p>
13+
<p>No @sidebar or other parallel routes exist at parent level.</p>
14+
<p>NO default.tsx needed at (.)no-parallel-routes!</p>
15+
</div>
16+
)
17+
}

0 commit comments

Comments
 (0)