Files
Nathan Nguyen 68c90808e0 fix(routing): discover nested parallel slot sub-routes from layout-only parents (#1051)
* fix(routing): discover nested parallel slot sub-routes from layout-only parents

Nested parallel routes failed when the parent segment had a layout but no
page.tsx. discoverSlotSubRoutes skipped any parent route without pagePath,
so slots like @parallelB/nested/page.tsx under home/layout.tsx (but no
home/page.tsx) were never discovered.

Next.js explicitly tests this shape in parallel-routes-and-interception:
app/parallel-nested/home/layout.tsx + @parallelB/default.tsx +
@parallelB/nested/page.tsx creates a valid /parallel-nested/home/nested
route.

The fix makes three targeted changes to discoverSlotSubRoutes:

1. Remove the hard pagePath requirement. Layout-only routes with parallel
   slots can still own nested slot sub-routes.

2. Compute parentPageDir from the innermost layout when pagePath is null,
   so slot ownership checks still work for layout-only routes.

3. Relax the children default.tsx requirement, but only for layout-only
   parent routes. When a parent HAS a children page, default.tsx is still
   required as a fallback for the synthetic sub-route. Layout-only parents
   do not need this fallback because the children slot was never occupied.

4. Add structural-conflict detection before creating synthetic routes. This
   prevents layout-only root routes from generating synthetic routes that
   collide with existing page routes differing only by param name (e.g.
   /shop/:id vs /shop/:name), which validateRoutePatterns rejects.

Tests ported from the Next.js parallel-routes-and-interception suite cover
both route-discovery and end-to-end rendering.

Refs: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/parallel-routes-and-interception/parallel-routes-and-interception.test.ts

* fix(routing): correct patternsStructurallyEquivalent tree-node comparison

The initial implementation of patternsStructurallyEquivalent used a naive
dynamic-segment check (startsWith(':')) that produced two categories of
false positives:

1. Literal vs dynamic segments (e.g. 'about' vs ':id') were considered
   equivalent, incorrectly suppressing valid synthetic routes.

2. Single dynamic vs catch-all segments (e.g. ':id' vs ':slug+') were
   considered equivalent, even though they map to different validator tree
   nodes ([] vs [...]).

Replace the ad-hoc boolean logic with segmentTreeNodeType, which classifies
each segment by its Next.js validator tree node (literal, dynamic, catchAll,
optionalCatchAll). Two patterns are structurally equivalent only when every
segment maps to the same tree node type.

Also add a clarifying comment to the parentPageDir computation so the next
developer understands why the innermost layout proxies the route directory
for layout-only routes.

* fix(routing): scan synthetic routes in structural conflict check

Copilot review identified that patternsStructurallyEquivalent only scanned
the original routes array, missing conflicts between synthetic routes created
earlier in the same loop. Fixed by scanning routesByPattern.values() which
includes both original and synthetic routes.

Also adds a TODO comment documenting the known prerender limitation for
layout-only synthetic routes (skipped at build/prerender.ts:849). These
routes work in dev/SSR but won't be statically exported — this is
pre-existing behaviour for all layout-only routes.

* test(routing): cover structural conflict skip for synthetic parallel routes

Adds a unit test proving that discoverSlotSubRoutes skips synthetic routes
when an existing page route has the same structural shape but a different
param name (e.g. /shop/:id page + @feed/[name]/page.tsx slot).

Without this coverage the structural-conflict safeguard is easy to regress.

* fix(routing): guard discoverSlotSubRoutes to layout-only UI routes

Review identified that removing the old !parentRoute.pagePath guard also
let route-handler-only AppRoutes enter discoverSlotSubRoutes. Route
handlers have pagePath: null but are not layout-only UI routes. A shape
like app/api/route.ts + app/@feed/foo/page.tsx could make the /api route
handler scan the ancestor slot and materialise a synthetic UI route under
/api/foo.

Add an explicit isLayoutOnlyUiRoute check:
  !parentRoute.pagePath && !parentRoute.routePath && parentRoute.layouts.length > 0

Only page-bearing routes or layout-only UI routes can own nested parallel-
slot sub-routes. Route handlers and layout-less routes are skipped.

Also adds a regression test: a route.ts handler with an ancestor parallel
slot must not emit synthetic routes under the handler pattern.

* fix(routing): scan synthetic routes in structural conflict check + regression test

The structural-conflict guard had regressed back to scanning the original
routes array, missing conflicts against synthetic routes created earlier in
the same discoverSlotSubRoutes pass. Fixed by scanning
Array.from(routesByPattern.values()) instead.

Also adds a regression test proving that two slot sub-pages with different
param names under the same parent do not both materialise (e.g. @a/[id] and
@b/[name] under /shop — only one of /shop/:id or /shop/:name is emitted).
2026-05-05 18:30:50 +01:00
..