Skip to content

fix(app-router): bind layout-only children defaults - #2335

Merged
james-elicx merged 3 commits into
mainfrom
codex/fix-parallel-slot-specificity-28143992598
Jun 26, 2026
Merged

james-elicx merged 3 commits into
mainfrom
codex/fix-parallel-slot-specificity-28143992598

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Summary

  • bind default.tsx as the implicit children page for layout-only App Router routes
  • keep default-less parallel-slot owners as graph metadata instead of competing URL routes
  • add graph and browser fixture regressions for nested slot defaults and optional catch-all ownership

Next.js parity

Targets failures from run 28143992598:

  • parallel-routes-catchall-default: 1 test now passing
  • parallel-routes-catchall-dynamic-segment: 1 test now passing
  • parallel-routes-catchall-slotted-non-catchalls: 1 test now passing

The related specificity, layout soft-navigation, and no-children-slot failures still fail and are separate follow-up tracks.

Validation

  • vp check packages/vinext/src/routing/app-route-graph.ts tests/app-route-graph.test.ts tests/e2e/app-router/parallel-slot-default.spec.ts
  • vp test run tests/app-route-graph.test.ts — 59 passed
  • PLAYWRIGHT_PROJECT=app-router vp exec playwright test tests/e2e/app-router/parallel-slot-default.spec.ts --workers=1 — 1 passed
  • exact Next.js v16.2.6 targeted deploy suites — 3 passed / 0 failed

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@pkg-pr-new

pkg-pr-new Bot commented Jun 25, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2335
npm i https://pkg.pr.new/vinext@2335

commit: 8085354

@github-actions

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
workers-cache preview production
web preview production

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review: regression in layout-only parallel-slot parents

I ran the routing test suites against this branch. This PR introduces a regression in an existing, already-ported Next.js parity test that is not addressed in the diff.

Failing test

tests/routing.test.ts:1586 — "discovers nested parallel slot sub-routes from layout-only parent" fails on this branch and passes on main.

FAIL tests/routing.test.ts > ... > discovers nested parallel slot sub-routes from layout-only parent
AssertionError: expected [ '/parallel-nested/home/nested' ] to include '/parallel-nested/home'
  tests/routing.test.ts:1613

I confirmed this by checking out main's app-route-graph.ts (test passes) vs. this branch's version (test fails). The test file itself is not modified by this PR, so this is an unintended break, and CI's Vitest check will fail.

Root cause

The new gate in the layout-scan loop (packages/vinext/src/routing/app-route-graph.ts:964-970) demotes any layout-only parent whose owned slots have no page.tsx to a ghost route — unconditionally, before the existing routePatterns.has(route.pattern) collision check:

const hasOwnedSlotPage = route.parallelSlots.some(
  (slot) => path.dirname(slot.ownerDir) === routeDir && slot.pagePath !== null,
);
if (!route.pagePath && !hasOwnedSlotPage) {
  ghostParentRoutes.push(route);
  continue;
}

The two scenarios are nearly identical in shape:

  • PR target (correctly dropped): [locale]/layout.tsx + [locale]/@slot/default.tsx + [locale]/@slot/other/page.tsx + sibling [locale]/[[...catchAll]]/page.tsx. The optional catch-all already covers /:locale, so demoting the layout-only owner is correct.
  • Existing test (incorrectly dropped): parallel-nested/home/layout.tsx + home/@parallelB/default.tsx + home/@parallelB/nested/page.tsx, with no sibling route covering /parallel-nested/home. Next.js keeps this as a real layout-only route (pagePath: null) and still discovers the nested slot sub-route /parallel-nested/home/nested.

The distinguishing factor is whether a competing real route already covers the pattern — which is exactly what the pre-existing routePatterns.has(route.pattern) check (line 971) handles. The new unconditional gate is too aggressive: it drops the owner even when nothing else covers the URL, so the parent route disappears and its nested slot sub-route is orphaned.

Suggested direction

Only demote the default-less layout-only owner to a ghost when a competing route actually covers its pattern. Folding the !route.pagePath && !hasOwnedSlotPage condition into the existing routePatterns.has(route.pattern) branch (rather than as a separate unconditional continue) would drop the owner in the PR's catch-all scenario while preserving the standalone layout-only parent in the existing test. Please re-run vp test run tests/routing.test.ts tests/app-route-graph.test.ts and confirm both the new and pre-existing parallel-slot tests pass.

Minor

  • The PR validation section lists vp test run tests/app-route-graph.test.ts (59 passed) but not tests/routing.test.ts. Per the test-mapping table in AGENTS.md, changes to routing/*.ts should run tests/routing.test.ts and tests/route-sorting.test.ts — running those would have caught this.

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Posted the review on PR #2335.

Summary

I found one blocking regression in this PR.

Regression: tests/routing.test.ts:1586 breaks. The pre-existing, Next.js-ported test "discovers nested parallel slot sub-routes from layout-only parent" passes on main but fails on this branch. I verified this directly by swapping app-route-graph.ts between the two versions. The test file is not touched by the PR, so this is an unaddressed break — and CI's Vitest check will fail.

Root cause. The new gate at app-route-graph.ts:964-970 unconditionally demotes any layout-only parent whose owned slots have no page.tsx to a ghost route, before the existing routePatterns.has(route.pattern) collision check. That's correct for the PR's target scenario (a sibling [[...catchAll]] already covers /:locale), but wrong when no competing route exists — it drops a route Next.js expects to keep (/parallel-nested/home), orphaning its nested slot sub-route.

Suggested fix. Gate the demotion on an actual pattern collision (fold the condition into the existing routePatterns.has branch) so the catch-all case is dropped while the standalone layout-only parent survives.

I also noted that the PR's validation didn't run tests/routing.test.ts, which the AGENTS.md test-mapping table requires for routing/*.ts changes and which would have caught this.

The targeted tests the PR aimed to fix (tests/app-route-graph.test.ts) and the broader tests/app-router.test.ts / tests/features.test.ts all pass — the issue is narrowly the one orphaned-route regression.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

Addressed the blocking layout-only parent regression in 5d1f599:

  • narrowed ghosting to exact optional-catch-all collisions only
  • added a standalone layout-only slot-owner graph regression
  • tests/routing.test.ts -t "discovers nested parallel slot sub-routes from layout-only parent" passes
  • tests/build-report.test.ts -t "classifies layout-only parallel-slot app routes" passes
  • all 60 focused graph tests pass
  • all three targeted Next.js suites still pass

/bigbonk review for issues

@github-actions

github-actions Bot commented Jun 25, 2026 •

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 8085354 against base de014d4 using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 117.1 KB 117.1 KB ⚫ -0.0%
Client entry size (gzip) vinext 112.5 KB 112.5 KB ⚫ -0.0%
Dev server cold start vinext 2.58 s 2.54 s ⚫ -1.4%
Production build time vinext 3.08 s 3.12 s ⚫ +1.3%
RSC entry closure size (gzip) vinext 93.0 KB 93.0 KB ⚫ -0.0%
Server bundle size (gzip) vinext 156.9 KB 156.9 KB ⚫ -0.0%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Re-review: layout-only parent regression is fixed

Commit 5d1f599 directly resolves the blocking regression I flagged earlier. The fix matches the suggested direction precisely.

What changed

The old gate ghosted any default-less layout-only owner:

const hasOwnedSlotPage = route.parallelSlots.some(
  (slot) => path.dirname(slot.ownerDir) === routeDir && slot.pagePath !== null,
);
if (!route.pagePath && !hasOwnedSlotPage) { ghostParentRoutes.push(route); continue; }

The new gate only ghosts when an optional catch-all sibling structurally covers the same pattern (app-route-graph.ts:964-973):

const optionalCatchAllOwnsPattern = routes.some(
  (candidate) =>
    candidate.patternParts.length === route.patternParts.length + 1 &&
    candidate.patternParts.at(-1)?.endsWith("*") &&
    patternsStructurallyEquivalent(candidate.patternParts.slice(0, -1), route.patternParts),
);
if (!route.pagePath && optionalCatchAllOwnsPattern) { ghostParentRoutes.push(route); continue; }

This is correct:

  • Restricting to * (optional catch-all) and not + (required catch-all) is right — only [[...slug]] matches zero segments and thus covers the parent URL.
  • patternsStructurallyEquivalent enforces literal-value equality for literal segments, so no false positives across unrelated siblings.
  • The pre-existing routePatterns.has(route.pattern) route-group collision check (line 974) is preserved, so the (group-a)/layout.tsx + (group-b)/page.tsx ghosting still works.

Verification

I confirmed locally on this branch:

  • tests/routing.test.ts -t "discovers nested parallel slot sub-routes from layout-only parent" — passes (this was the regression)
  • tests/build-report.test.ts -t "classifies layout-only parallel-slot app routes" — passes
  • tests/routing.test.ts + tests/app-route-graph.test.ts + tests/route-sorting.test.ts — 219 passed
  • tests/app-route-graph.test.ts — 60 passed (including the new standalone-owner regression at line 639 and the optional-catch-all owner at line 619, which are nicely complementary)
  • tests/app-router.test.ts + tests/features.test.ts — 338 passed
  • vp check on both modified files — clean (format, lint, types)

The new tests/app-route-graph.test.ts:639 regression locks in the standalone layout-only owner case so it can't silently break again.

Nits (non-blocking)

  • The comment block at app-route-graph.ts:945-949 still only documents the route-group collision flavor of ghosting. Consider extending it to mention the optional-catch-all collision case now handled directly above by optionalCatchAllOwnsPattern, so the two continue branches are both explained.
  • candidate.patternParts.at(-1)?.endsWith("*") matches any segment ending in *. In practice only optional catch-alls produce a trailing *, so this is fine, but a segmentTreeNodeType(...) === "optionalCatchAll" check would be more explicit and consistent with the helper used on the next line.

LGTM — the regression is resolved and well-covered.

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Posted the re-review on PR #2335.

Summary

The blocking regression I flagged earlier is fixed by commit 5d1f599. The fix matches the suggested direction exactly.

The fix. The old gate unconditionally ghosted any default-less layout-only slot owner. The new gate (app-route-graph.ts:964-973) only ghosts when an optional catch-all sibling structurally covers the same pattern:

  • restricts to * (optional catch-all), not + (required) — correct, since only [[...slug]] matches zero segments and covers the parent URL
  • uses patternsStructurallyEquivalent which enforces literal-value equality, avoiding false positives
  • preserves the pre-existing routePatterns.has route-group collision check

Verification (all passing on this branch):

  • tests/routing.test.ts regression test "discovers nested parallel slot sub-routes from layout-only parent" — passes (was failing)
  • tests/build-report.test.ts layout-only classification — passes
  • routing + app-route-graph + route-sorting — 219 passed
  • app-route-graph — 60 passed (new standalone-owner test + optional-catch-all owner test are complementary)
  • app-router + features — 338 passed
  • vp check — clean

Non-blocking nits: the comment block at lines 945-949 still only documents the route-group collision case (not the new optional-catch-all branch), and at(-1)?.endsWith("*") could be more explicit as segmentTreeNodeType(...) === "optionalCatchAll".

LGTM.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

Addressed fresh independent review finding at 80853544d5ece1ee2ff823a569ed6decb0c7ad13:

  • optional-catch-all collision ghosting no longer mistakes a bound children default.tsx for an explicit page
  • added the default.tsx topology to the regression
  • 60 graph tests pass
  • standalone layout-owner routing and build-report regressions pass
  • exact Next.js targeted suites remain 3/3

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

@james-elicx Bonk workflow was cancelled.

View workflow run · To retry, trigger Bonk again.

@james-elicx

Copy link
Copy Markdown
Member Author

Final-head CI is fully green and an independent review returned NO FINDINGS for 80853544d5ece1ee2ff823a569ed6decb0c7ad13. The prior Bonk run timed out at the workflow 10-minute limit without a verdict.

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: layout-only children defaults at 8085354 — LGTM, no blocking issues

The prior Bonk runs reviewed earlier commits. This review covers the new combined diff at 80853544d5ece1ee2ff823a569ed6decb0c7ad13, which differs from 5d1f599 in two coupled ways that I verified are mutually consistent and correct.

What changed since 5d1f599

1. directoryToAppRoute now binds default.tsx as the implicit children page (app-route-graph.ts:1523):

const effectivePagePath = pagePath ?? (routePath ? null : findFile(routeDir, "default", matcher));

I confirmed this only affects the layout-only parallel-slot scan loop (the only caller passing pagePath=null, routePath=null, line 962). The other caller (fileToAppRoute, line 1467) always passes either a real page path (→ effectivePagePath = pagePath) or a route path (→ routePath truthy → null, so API routes never pick up default.tsx). Good.

2. The optional-catch-all gate dropped its !route.pagePath guard (app-route-graph.ts:964-973). This is the necessary companion to change 1: now that a layout-only owner with a default.tsx gets a non-null pagePath, the old !route.pagePath && optionalCatchAllOwnsPattern condition would have stopped ghosting it, leaving /:locale as a competing route. Removing !route.pagePath keeps the default-backed owner ghosted, which is the parity target (parallel-routes-catchall-slotted-non-catchalls).

Why this is safe

  • The gate only operates on routes constructed inside the layout loop. Real page.tsx routes are scanned first (lines 918-928) and pushed to routes before the loop runs; they are never re-evaluated by the gate, so no explicit page route can be incorrectly ghosted.
  • In the layout loop, effectivePagePath can only ever resolve a default.tsx (never page.tsx), so making the gate topology-only (rather than pagePath-gated) is correct — there is no explicit-page case to protect here.
  • The standalone layout-only owner (no sibling catch-all) is preserved: optionalCatchAllOwnsPattern is false and routePatterns.has is false, so it stays a real route. Covered by tests/app-route-graph.test.ts "keeps a standalone layout-only slot owner as a route" and tests/routing.test.ts "discovers nested parallel slot sub-routes from layout-only parent" (the previously-regressed test).
  • patternsStructurallyEquivalent compares segmentTreeNodeType per segment — literals require exact value match (literal:${seg}), dynamics collapse to "dynamic". This matches Next.js's tree-node comparison and avoids false positives across unrelated siblings.

Verification (all green on this branch at 8085354)

  • tests/app-route-graph.test.ts — 60 passed (incl. the 3 new ported parity tests + standalone-owner regression)
  • tests/routing.test.ts + tests/route-sorting.test.ts + tests/build-report.test.ts — 254 passed (incl. the previously-regressed nested-slot test)
  • tests/app-router.test.ts + tests/features.test.ts — 338 passed
  • vp check packages/vinext/src/routing/app-route-graph.ts tests/app-route-graph.test.ts — clean (format, lint, types)

The new e2e fixture (parallel-slot-default/nested/[foo]/[bar]) has no sibling catch-all, so it is intentionally not ghosted and renders its default.tsx as children — directly exercising change 1 end-to-end.

Non-blocking nits (carried over, still applicable)

  • The comment block at app-route-graph.ts:945-949 still documents only the route-group collision flavor of ghosting. Now there are two continue branches (optional-catch-all at 970, route-group at 974); extending the comment to mention optionalCatchAllOwnsPattern would help the next reader.
  • candidate.patternParts.at(-1)?.endsWith("*") (line 967) matches any segment ending in *. In practice only optional catch-alls produce that, but segmentTreeNodeType(candidate.patternParts.at(-1)) === "optionalCatchAll" would be more explicit and consistent with the helper used on the very next line.

LGTM — the two coupled changes are correct, the earlier regression stays fixed, and coverage is solid.

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Posted the review on PR #2335.

Summary

I reviewed the combined diff at 80853544d5ece1ee2ff823a569ed6decb0c7ad13 (HEAD), which differs from what the timed-out and prior Bonk runs saw. No blocking issues found.

The latest commit introduced two coupled changes that I verified are correct and mutually consistent:

  1. effectivePagePath binding (app-route-graph.ts:1523) — binds default.tsx as the implicit children page, but only for layout-only routes in the parallel-slot scan loop. API routes (routePath set) correctly never pick up default.tsx.

  2. Dropped !route.pagePath from the optional-catch-all gate (app-route-graph.ts:964-973) — the necessary companion to change 1. Without it, a default-backed layout owner would stop being ghosted, re-introducing a competing /:locale route.

Why it's safe: the gate only operates on routes built inside the layout loop; real page.tsx routes are scanned and pushed earlier and are never re-evaluated, so no explicit page route can be wrongly ghosted. The previously-regressed standalone layout-only owner test stays green.

Verification (all green on this branch):

  • tests/app-route-graph.test.ts — 60 passed
  • tests/routing.test.ts + tests/route-sorting.test.ts + tests/build-report.test.ts — 254 passed
  • tests/app-router.test.ts + tests/features.test.ts — 338 passed
  • vp check on modified files — clean

Two non-blocking nits (carried over): the comment block at lines 945-949 doesn't document the new optional-catch-all continue branch, and at(-1)?.endsWith("*") could be more explicit as segmentTreeNodeType(...) === "optionalCatchAll".

LGTM.

github run

@james-elicx
james-elicx marked this pull request as ready for review June 25, 2026 13:45
@james-elicx
james-elicx merged commit 859a0aa into main Jun 26, 2026
58 checks passed
@james-elicx
james-elicx deleted the codex/fix-parallel-slot-specificity-28143992598 branch June 26, 2026 20:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant