* fix(routing): correct (.) interception target for nested slot subdirectories
When the (.) interception marker lives inside a subdirectory of the @slot
dir (e.g. app/@modal/sub/(.)target/[id]/page.tsx), baseParts was computed
from routeDir (the slot owner) instead of the directory containing the
marker. This caused visible subdirectory segments to be dropped, producing
/target/:id instead of /sub/target/:id.
Fix: derive baseParts from path.dirname(interceptRoot) relative to appDir,
stripping invisible segments via convertSegmentsToRouteParts — the same
pattern used by computeInterceptSourceMatchPattern and the (..) / (...)
convention branches.
* ci: retrigger to clear flaky test failures
* fix(routing): drop double-conversion of (.) interception baseParts to preserve dynamic ancestor params
Issue #1339 reported parallel route slots falling back to default.tsx
instead of the matched page. That gap was already closed on main by
#1389 (resolve nested slot pages over default.tsx) and #1659/#1535
(treat @children as transparent). This adds a regression test mirroring
the Next.js parallel-routes-layouts fixture — the canonical
"Hello from Nested" vs "default page" case — asserting that:
- the children slot at /nested resolves nested/page.tsx, not
nested/default.tsx;
- sibling @foo/@bar slots that each own both a page and a default pick
their page; and
- at /nested/subroute (only @bar matches) the children slot falls back
to default, @bar mirrors its subroute page, and @foo keeps its default.
Locks in page-over-default priority across sibling slots so the
behavior cannot silently regress.
In Next.js, the `@children` directory is special: it provides the
layout's `children` prop rather than acting as a parallel slot.
`app/foo/@children/page.tsx` should therefore register a real page
route at `/foo` and take priority over a sibling `[...catchAll]`
catch-all. Previously vinext excluded all `@`-prefixed directories
from the page scan, so `@children/page.tsx` was hidden and `/` (or
`/nested`) was either served by the catch-all or failed to resolve
when no `default.tsx` existed.
Three small changes restore Next.js parity:
- Let `@children` directories through the page-scan exclusion so the
page file at `@children/page.tsx` registers as a route.
- Strip a trailing `@children` segment in `fileToAppRoute` so the
route is anchored at the parent directory — sibling `@slot`
directories remain owned (not inherited) and layout/boundary
discovery is sourced from the parent.
- Skip `@children` in `discoverParallelSlots` and
`hasParallelSlotDirectory` so it never appears in
`parallelSlots` (matching Next.js' types-plugin behaviour).
Ported from Next.js: test/e2e/app-dir/parallel-routes-catchall-children-slot/
https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/parallel-routes-catchall-children-slot/parallel-routes-catchall-children-slot.test.ts
References Next.js' `normalizeAppPath` and `normalize-catchall-routes`
which both treat `@children` as a non-matchable slot:
- packages/next/src/shared/lib/router/utils/app-paths.ts
- packages/next/src/build/normalize-catchall-routes.ts
- packages/next/src/build/webpack/plugins/next-types-plugin/index.ts
Fixes#1535.
* fix(parallel-routes): resolve nested slot pages over default.tsx
When two sibling route groups occupy the same URL pattern (e.g.
`(group-a)/layout.tsx` next to `(group-b)/page.tsx`), the layout-only
sibling was dropped entirely because its pattern collided with the page
route, taking its parallel slots with it. Nested pages inside its
`@slot` directories were never materialized, so URLs that only the
catch-all slot could handle returned 404 / not-found instead of
rendering the matched slot page.
This change keeps the dropped layout-only routes as "ghost parents"
that participate in `discoverSlotSubRoutes`. Their slot sub-pages still
mint synthetic sub-routes (carrying the ghost's layout chain and slot
metadata), but the ghost itself is not added to the route table to
preserve the existing single-route-per-pattern invariant.
Closes#1339
Ported from Next.js tests:
- test/e2e/app-dir/parallel-routes-catchall-groups
* test(parallel-routes): cover shared-root URL of sibling route groups
Adds an e2e assertion that /parallel-group-catchall (the URL shared by
both (group-a) and (group-b)) renders (group-b)/page.tsx, not the
(group-a) ghost or its catch-all slot. Closes the coverage gap called
out in code review for the existing /foo and /bar tests.
* fix(app-router): parse interception route markers in route scanner
Refs #1364
* test(app-router): cover (.) and (...) markers, fix JSDoc typo
Addresses Bonk review feedback on #1398: add explicit coverage for the
remaining two interception marker shapes when used outside `@slot`, and
remove the stray quote in `isInterceptionMarkerDir`'s JSDoc.
Vinext's app-rsc route matcher previously fired interception on any RSC
request whose URL matched an intercept target, regardless of the source
pathname. Next.js implements interception as a rewrite that is only
activated when the Next-URL header matches the *intercepting route*
regex (`^<interceptingRoute>(?:/.*)?$`). Without that gate, a direct
`.rsc` fetch to an intercept target would incorrectly render the modal
page instead of the underlying route, and unrelated sources could pull
in modals they have no slot for.
This change:
1. Captures the *intercepting route* URL pattern (`sourceMatchPattern`)
alongside each `InterceptingRoute` at scan time. The pattern is the
slot owner's path with route groups and `@slot` segments stripped,
matching Next.js' `extractInterceptionRouteInformation`. The value
is propagated through the App RSC manifest.
2. Adds source-pathname filtering to `createAppRscRouteMatcher`'s
`findIntercept`: an entry only matches when the provided source
pathname satisfies its `sourceMatchPattern` (with descendants
allowed). When no source pathname is provided (no Next-URL /
X-Vinext-Interception-Context header), no intercept fires.
3. Adds unit coverage in `app-rsc-route-matching.test.ts` and
`app-route-graph.test.ts` for the new behavior, including the
patterns used by the failing Next.js deploy suite cases
(`parallel-routes-and-interception-basepath`,
`parallel-routes-and-interception-from-root`, multi-catchall, and
two-levels-up).
4. Updates `app-router.test.ts` integration tests that previously
relied on the buggy behavior; intercept fires only when the test
sends `X-Vinext-Interception-Context` and that context matches the
slot's intercepting-route prefix.
Cross-references:
- https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/router/utils/interception-routes.ts
- https://github.com/vercel/next.js/blob/canary/packages/next/src/lib/generate-interception-routes-rewrites.ts
PR #1249 is orthogonal: it carries proof of interception through the
planner/lifecycle when the matcher has already fired. This change
restricts when the matcher fires in the first place.
* feat(router): expose RouteManifest default and segment boundary facts
RouteManifest slot facts only exposed whether a default existed, and boundary facts were limited to layout-aligned outcomes. That left later #726 planner work without graph-owned IDs for default-rendering slot bindings or layoutless route-group error segments.
Add explicit default entries keyed by semantic slot ID, thread default IDs through slot declarations and default-rendering bindings, and materialize segment-level error boundaries even when no sibling layout owns that segment.
Validation: vp test run tests/app-route-graph.test.ts; vp test run tests/app-route-graph.test.ts tests/routing.test.ts; vp check packages/vinext/src/routing/app-route-graph.ts tests/app-route-graph.test.ts; vp run vinext#build.
* ci: rerun flaked checks
* ci: rerun flaked checks again
* fix(router): avoid duplicate RouteManifest error boundary writes
RouteManifest now exposes layoutless segment error boundaries, but the segment pass also revisited layout-bearing error.tsx files that the layout pass had already written.
Skip segment error entries with an owning layout so layout-owned boundaries remain single-sourced, while layoutless route-group boundaries still materialize as ownerless facts.
Validation: vp test run tests/app-route-graph.test.ts; vp check packages/vinext/src/routing/app-route-graph.ts tests/app-route-graph.test.ts.
* feat(router): expose RouteManifest semantic facts
RouteManifest only exposed minimal route, layout, template, and slot lookup maps. That left later #726 planner and cache-proof work without stable graph-owned facts for slot ownership, slot defaults, template reset scope, or boundary outcomes.
The graph builder now derives declaration-level slot facts, per-route slot bindings, template remount facts, and layout-boundary outcome facts from existing App Router metadata. The manifest graphVersion includes the new maps so downstream compatibility checks can detect semantic topology changes.
Tests cover route groups, slots, defaults, templates, and boundaries in one fixture.
* test(router): align CI fixtures with graph facts
Full CI checks more App Router fixtures than the targeted RouteManifest test run. The new ParallelSlot declaration fields must be present in hand-built fixtures, otherwise typecheck fails even though production graph scanning populates them.
The app browser trace tests also still asserted the stale reason at entry zero, but no-commit transaction tracing is prepended before the reason entry. Update those assertions to inspect the stale reason entry without changing runtime behavior.
* feat(router): expose route graph manifest read model
The App Router graph now exposes root-boundary identity and a stable RouteManifest read model instead of leaving these facts implicit inside per-route arrays. Future #726 planner and compatibility work needs a semantic lookup surface for routes, layouts, pages, templates, slots, and root boundaries before it can stop reading meaning from transport keys.
This adds RootBoundaryId, graphVersion, StaticSegmentGraph maps, and fail-fast invariant checks for mismatched layout/template positions while preserving the existing routes array for current callers.
* chore: retrigger ci
Next.js PARAMETER_PATTERN accepts any non-] characters inside brackets:
[repo:name], [variant.id], [user@domain]
vinext was restricting param names to [\w-]+, silently treating segments
with dots, colons, or @ as literal URLs. This fix aligns the regexes with
Next.js:
- app-route-graph.ts: ^\[([^\]]+)\]$ (was [\w-]+)
- pages-router.ts: same
- route-validation.ts: patternToNextFormat now backtracks over +/* suffixes
- shims/router.ts: extractRouteParamNames now accepts dotted/colon names
Also corrects a misleading comment in routePrecedence: dynamic routes CAN
score negative, but the trie matcher (route-trie.ts) always checks static
children before dynamic children at each node, so purely-static routes still
win at request time.
Next.js reference:
https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/router/utils/get-dynamic-param.ts#L175Closes#7
App Router route graph entries previously left route, page, layout, template, and slot identity to later render and wire layers. That kept topology meaning coupled to reconstructed payload keys instead of a graph-owned fact.
Mint stable semantic IDs during graph construction, preserve them in generated RSC route metadata, and cover route-group, dynamic-segment, slot-owner, and filesystem-root stability cases.
* 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).
* fix(routing): mirror inherited parallel slots to descendant sub-pages
discoverInheritedParallelSlots was hard-coding pagePath to null for any
ancestor slot, so inherited slots only ever rendered default.tsx — even
when an obvious mirror like @breadcrumbs/about/page.tsx existed for the
matching route. Track each ancestor's segment index, then look up a
mirror via either a literal filesystem match or, when route groups appear
on either side, by URL-parts comparison through convertSegmentsToRouteParts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(routing): extract slot-specific params for inherited mirrors
When an inherited parallel slot's mirrored sub-page declares dynamic
markers under different names than the route's (e.g. slot uses
@breadcrumbs/distinct/[name] for a route at distinct/[id]), the runtime
was passing the route's matchedParams through unchanged — so the slot
page received {id} instead of {name} and rendered with an empty value.
Three pieces:
* `findMirroredSlotPage` now also accepts pattern-compatible matches and
returns the slot's URL parts and param names. Specificity scoring
picks the most-specific compatible sub-page (literal > dynamic >
catch-all).
* Each ParallelSlot carries optional `slotPatternParts` and
`slotParamNames`, serialized through the manifest.
* `buildPageElements` extracts a per-request `slotOverrides[key].params`
by matching the request URL against `slotPatternParts` whenever a
slot's param names aren't a subset of the route's. `pageModule` on
`AppPageSlotOverride` becomes optional so a params-only override
doesn't have to swap the slot's page.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(routing): address bonk review on inherited parallel slot mirror
* Swap inverted scoring for required vs optional catch-alls — `:name+`
(matches ≥1 segments) is more constrained than `:name*` (≥0), so it
now scores higher, preventing a less-specific optional catch-all from
shadowing a required one when both compete for the same route.
* Tighten findMirroredSlotPage docstring: the implementation collapses
exact URL-parts equality and pattern compatibility into one scored
loop, where literals naturally win. Rewrite the comment to reflect
what the code actually does.
* Document patternsCompatible's conservative rejection of literal slot
segments paired with dynamic route segments (and the consequence that
a literal slot sub-page never matches a catch-all route).
* Note in buildSlotOverrides why an empty slotParamNames also skips the
override (no dynamic markers means nothing to extract).
* Add unit tests for two cases that were only covered indirectly:
graph-level slotPatternParts/slotParamNames when route and slot use
different param names, and a slot owned at an intermediate ancestor
rather than appDir.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
App Router route discovery currently shares a single module with route caching and request-time matching. That makes discovery changes harder to review and increases the chance that graph construction work collides with generated entry changes.
The route graph construction types and discovery helpers now live in app-route-graph.ts, while app-router.ts remains the public cache and trie matching wrapper. A direct graph-builder test covers pages, route handlers, layouts, and inherited parallel slots at the new boundary.