Commit Graph

25 Commits

Author SHA1 Message Date
James Anderson 5146b1f47b fix(router): render slots beside grouped children (#2022) 2026-06-15 21:52:28 +01:00
Nathan Nguyen dabcb55e63 fix(app-router): expose active source page on window.next (#1995)
* fix(app-router): expose active source page on window.next

App Router pages did not update window.next.__internal_src_page, so Next.js-compatible tooling and deploy tests could not observe the active source page on initial render or client transitions.

The payload only exposed URL route IDs, which drop route groups and bracketed dynamic segment names. The fix carries a source-page metadata field from the app route's filesystem segments, reads it from committed browser router state, and mirrors it onto window.next like Next.js does.

Adds regression coverage for route-group and dynamic source-page encoding plus the window.next writer contract ported from the upstream app-dir test.

* fix(app-router): preserve source page across boundary renders

* fix(app-router): tolerate malformed source page metadata

* fix(app-router): preserve intercepted fallback source page

* fix(app-router): omit unknown boundary source page

---------

Co-authored-by: James <james@eli.cx>
2026-06-13 21:59:39 +01:00
Divanshu Chauhan cd921ab709 perf: cache fs probes and precompute route sort keys in scan/deploy paths (#1930)
* perf(routing): precompute route precedence before sorting

`compareRoutes` re-parsed every pattern on each comparison, so a sort
called `routePrecedence` ~2·log n times per route instead of once. Replace
it with `sortRoutes`, which scores each pattern once into a Map and sorts
by (score, pattern.localeCompare). The localeCompare tiebreaker already
guarantees a total order, so ordering is byte-identical; this just removes
the redundant re-parsing on every route scan (dev re-scans on file change,
plus the pages/api and app-graph builds).

* perf(app-router): cache convention-file probes per route-graph scan

`buildAppRouteGraph` walks the appDir→leaf chain separately for every
route (layouts, templates, errors, boundaries, slots), each step probing
up to 4 extensions per convention file via `existsSync`. Shared ancestor
directories — the `app/` root above all — were re-stat'd once per
descendant route, so a scan cost roughly routes × depth × conventions ×
extensions syscalls.

Memoize `findFile` through a `WeakMap<ValidFileMatcher, Map>` keyed by a
per-scan matcher clone created in `buildAppRouteGraph`. Results are
immutable within one scan, so no invalidation is needed: the cache lives
exactly as long as the scan and is GC'd afterward. The fresh per-scan key
also keeps overlapping scans isolated, and the `null` not-found outcome is
cached too. Probe results are identical to the previous direct `existsSync`
calls.

* perf(deploy): single tree walk for detection and reuse parsed package.json

`analyzeProject` walked the app/ tree twice — once in `detectISR` and again
in `detectMDX` — and `detectNativeModules` re-read and re-parsed
package.json that `detectProject` had already parsed into `allDeps`.

Merge the two walkers into one `scanTreeForDetection(dir, { isr, mdx })`
that evaluates both predicates per entry with each flag short-circuiting
independently, extract the next.config MDX check into `detectMDXFromConfig`,
and add `resolveProjectDir` for the shared root/`src` precedence. Pass the
already-merged `allDeps` into `detectNativeModules`. Detection semantics are
unchanged: same files visited, same patterns tested, same booleans out.
2026-06-12 11:51:43 +01:00
Nathan Nguyen dea4472c2f fix(app-router): include parallel slot params in useParams (#1904)
* fix(app-router): include parallel slot params in useParams

App Router navigation state previously used only the primary route match params when setting request navigation context, RSC param headers, render observations, and optimistic navigation payloads. That lost catch-all params from active parallel slot branches, so useParams() diverged from the slot page's params prop after SSR and client navigations.

Next.js derives params from the selected router tree across parallel route branches. Resolve active slot params from route metadata, merge them into the navigation params boundary, and feed that selected param set through server render and optimistic navigation payloads.

Port the upstream parallel-route-navigations fixture and cover the route graph, selected params helper, and optimistic payload boundary.

* fix(app-router): preserve canonical pathname in navigation context after middleware rewrite

* fix(app-router): resolve parallel slot params for static, revalidation, and action paths

* chore: retrigger CI

* fix(app-router): address ask-bonk review comments on parallel slot params

- Remove unused  parameter from
- Add clarifying comment on routeParams fallback being deliberate no-op
- Use  (full matched params) instead
  of narrowed  as base for slot param merge,
  preserving interception-specific extras on ISR regeneration path

* fix(app-router): use full navigationParams in server-action re-render path

The server-action re-render path was passing narrowed
actionRerenderTarget.params to resolveAppPageNavigationParams,
dropping interception-specific extras from source-route intercepts.
Use actionRerenderTarget.navigationParams (full matched params) instead,
matching the pattern used in the dispatch ISR path at
app-page-dispatch.ts:602.

Adds unit tests for the intercepted-slot and mixed intercepted +
non-intercepted slot branches of resolveAppPageNavigationParams.

* docs(router): clarify decode invariant and type cast rationale in parallel-slot params

- app-optimistic-routing.ts: add comment explaining the pre-existing
  double-decode asymmetry between route params and slot params, noting
  TODO to converge decode passes
- app-server-action-execution.ts: expand the comment on the interceptOpts
  cast to document why it's safe and what a follow-up would look like

* fix(app-router): merge slot params for fresh intercepts

---------

Co-authored-by: James <james@eli.cx>
2026-06-11 23:54:27 +01:00
Jerry Zhao 55815233c8 fix(routing): normalize route root to forward slashes, drop downstream path workarounds (#1896)
* routing: normalize the route root to forward slashes and drop downstream workarounds

* fix(routing): compare findOwnerRouteForDir paths in forward-slash space

With appDir now normalized to forward slashes, the ancestor walk's
current === appDir termination never fired on Windows (current descends
through native path.join/path.dirname and stays backslash), so the walk
overshot the app root and could attach a sibling intercept to the wrong
owner. Top-level markers had the inverse mismatch: a forward-slash dir
probed against backslash routesByDir keys.

Normalize dir, appDir, routesByDir keys, and route file paths to
forward slashes inside the lookup so all comparisons happen in one
separator space, and add POSIX-runnable unit tests simulating the
Windows path shapes.

* refactor(routing): use normalizePathSeparators in findOwnerRouteForDir

Swap the inline backslash replaces for the shared util. It is a
platform-gated no-op on POSIX, so the Windows-shape unit tests mock it
to keep exercising the normalization logic on POSIX CI.

---------

Co-authored-by: James <james@eli.cx>
2026-06-11 00:17:22 +00:00
James Anderson 30be2b330b fix(app-router): discover parallel-slot pages inside route groups for catch-all + parallel-routes-group (#1865)
When a parallel slot's page lives inside a route-group subdirectory of the
slot root (e.g. `[...catchAll]/@slot/(group)/page.tsx`), `discoverParallelSlots`
was looking only for a direct `page.tsx` at the slot root and skipping the slot
entirely when none was found. This caused the slot to be absent from the route
graph, so the route rendered without the expected slot content.

Two related fixes:

1. Add `findSlotRootPage` — a helper that searches for a page file at the
   slot root URL level, descending through transparent route-group subdirectories
   (directories whose name matches `(…)`). `discoverParallelSlots` now uses this
   instead of a bare `findFile` for the slot's page path.

2. In `discoverSlotSubRoutes`, skip the `applySlotSubPages` call when
   `urlParts.length === 0` (all sub-segments are URL-invisible). A zero-length
   visible sub-path means the slot page is at the same URL level as the parent
   route; `discoverParallelSlots` already assigned it with the correct empty
   `routeSegments`. Calling `applySlotSubPages` in this case would overwrite
   `routeSegments` with the raw filesystem segments (e.g. `["(group)"]`), making
   `useSelectedLayoutSegment` surface the route-group name rather than `null`.

Fixes the e2e test: `catchall-parallel-routes-group > should work without
throwing any errors about invalid pages`.
2026-06-09 14:20:25 +01:00
James Anderson cfc0f8146d fix(app-router): resolve explicit parallel slot with no page (#1535) (#1852)
* fix(app-router): resolve explicit parallel slot with no page (#1535)

When a parallel slot defines an explicit sub-page (e.g. `@slot/baz/page.tsx`)
but no corresponding children page (`baz/page.tsx`) and no `default.tsx` for
the children slot, vinext minted a synthetic `/baz` route with a null children
page. That route shadowed the sibling catch-all and rendered an empty children
prop, so the request hung (the upstream "explicit slot but no page" case timed
out).

Next.js serves such a slot-only sub-route's children from the nearest sibling
catch-all. Add `findCatchAllPage` and use it as the children fallback in
`discoverSlotSubRoutes` when no `default.tsx` exists, so `/baz` renders
`[...catchAll]/page.tsx` for children while `@slot/baz/page.tsx` fills the slot.
A children `default.tsx` still wins over the catch-all when both exist.

Finishes the explicit-slot-but-no-page case of #1535 (the children-slot
priority case landed in #1659).

Fixes #1535.

* test(app-router): document catch-all children params limitation (#1535)

Address ask-bonk review: the synthetic slot-only sub-route (e.g. /baz) has a
static URL pattern, so a catch-all children page reading params.catchAll gets
empty params (Next.js passes ["baz"]). Document the divergence inline in
discoverSlotSubRoutes and add a graph-level test locking in the current static
shape so a future param-population fix must update it.
2026-06-08 19:43:51 +01:00
Divanshu Chauhan (divkix) b4c829d6a2 feat(interception): sibling-style interception routes (#1364 Part C) (#1804)
* test(interception): add failing sibling-interception graph tests

* feat(routing): discover sibling-style interception markers

* feat(routing): emit sibling interception manifest facts

* feat(app-router): serialize sibling intercepts in route manifest

* feat(app-router): match sibling intercepts in route matcher

* feat(app-router): render sibling intercept as full page response

* test: add siblingIntercepts field to AppRoute test fixtures

* test(interception): add sibling interception fixtures

* fix(app-router): emit null proof for sibling intercepts to bypass slot-validation

* test(interception): add sibling interception e2e specs

* fix(interception): correct (...)→(..)(..) marker in interception-from-root fixture

* fix(interception): resolve sourceRouteIndex to slot-owner route in intercept lookup

When a route inherits a @slot from an ancestor via discoverInheritedParallelSlots,
the inherited slot's interceptingRoutes are copied from the ancestor. Previously,
createInterceptLookup used routeIndex (the inheriting descendant's index) as
sourceRouteIndex, causing resolveAppPageInterceptState to see sourceRoute ===
currentRoute → kind='current-route' instead of kind='source-route'.

Fix: build a patternToIndex map and resolve sourceRouteIndex via the intercept's
sourceMatchPattern (which names the actual slot-owner route), falling back to
routeIndex only when no match is found.

* fix(interception): use locale-prefixed href so middleware interception fires correctly

* fix(interception): address codex review issues in sibling intercept implementation

- Pass effectiveParams to resolveAppPageHead so generateMetadata/generateViewport
  sees the intercepted route's params instead of the source route's params
- Rename SIBLING_INTERCEPT_SLOT_NAME from __page to __vinext_sibling_intercept
  to prevent collision with a user @__page parallel route directory
- Restore middleware-rewrite fixture link without locale prefix so the
  interception-dynamic-segment-middleware spec exercises the intended
  middleware rewrite path

* fix(test): update slotId assertions and restore locale-prefixed fixture link

- Update test assertions from slot:__page to slot:__vinext_sibling_intercept
  following the rename in commit 2248b8fe
- Restore locale-prefixed href in interception-mw fixture so the
  interception-dynamic-segment-middleware E2E tests pass (reverts the
  accidental revert from 2248b8fe back to the working state from 02e80c26)

* fix(interception): address review findings on sibling interception PR

- Restore locale-less href in interception-mw fixture and fix the
  underlying middleware-rewrite interception gap: add a Referer-based
  fallback in app-rsc-handler so interception fires when middleware
  rewrites the URL before the server matches it (the client-side manifest
  check sees the pre-rewrite target and skips setting the interception
  context header; the Referer carries the source page the server needs)
- Apply interceptLayouts for sibling intercepts in buildPageElements so
  a layout.tsx under the interception marker dir wraps the intercepting
  page, matching Next.js segment-layout wrapping behaviour; add fixture
  layout and E2E assertion
- Add unit test proving findOwnerRouteForDir ancestor walk attaches a
  sibling intercept to the nearest ancestor route when the marker's
  parent dir has no page.tsx (not dropped silently)

* fix(interception): send current pathname as fallback interception context

The client-side manifest check compares the pre-middleware target URL
against declared interception patterns. When middleware rewrites the URL
(e.g. /foo/p/1 → /en/foo/p/1), the pre-rewrite URL has fewer segments
and doesn't match the pattern, so no X-Vinext-Interception-Context
header is sent and the server cannot fire interception.

Fix: when the manifest check yields no match and there is no prior
intercepted navigation in browser state, fall back to sending the
current page pathname as the interception context. This mirrors Next.js
sending Next-URL on every RSC navigation. The server's findIntercept is
double-gated on both source and target matching, so false positives
cannot occur. The previousNextUrl is also populated so back/forward
traversal can restore intercepted state.

Revert the Referer-based server-side fallback (unreliable across
environments) in favour of this explicit browser-side fix.

* fix(interception): gate middleware-rewrite fallback on declared source pattern

The previous fix sent window.location.pathname as fallback interception
context on every unmatched navigation, which broke prefetch cache reuse:
a prefetch stored with null context wouldn't be found when the navigation
used a non-null context key.

Refine the fallback to only fire when the current page URL matches at
least one sourcePatternParts declared in the route manifest — i.e., the
current page is a known interception source. This preserves prefetch
cache reuse for ordinary navigations while still enabling middleware-
rewrite interception for pages that declare an interception relationship.

* fix(interception): support middleware-rewrite targets in fallback context and planner

The previous fallback-context gate only checked whether the current page
is a declared interception source; it did not verify the target URL
could plausibly be a middleware-rewritten version of the declared target
pattern.  This meant the client sent interception context when it
shouldn't (pre-middleware target had no static anchors in common with the
pattern) and the planner hard-navigated when the proof target matched
only after the rewrite.

Three coordinated fixes:

1. Client fallback gate (app-browser-entry / app-browser-interception-context):
   replace the broad source-only check with matchRoutePatternWithOptionalDynamicSegments
   on the target side — the pre-middleware URL must still share every
   static anchor of the declared target pattern, in order, while allowing
   dynamic segments to be absent.

2. Planner interception proof (navigation-planner):
   findRouteManifestInterceptionForProof now accepts a proof whose target
   matched URL equals the pre-rewrite canonical pathname (shorter than the
   full pattern) when matchRoutePatternWithOptionalDynamicSegments passes.
   Exact pattern match still skips the targetRouteId guard; the relaxed
   path skips it too so the middleware-added locale segment does not
   invalidate an otherwise valid proof.

3. displayPathname threading (app-rsc-handler / app-rsc-entry / app-page-element-builder):
   the handler now passes canonicalPathname as displayPathname separately
   from cleanPathname.  buildPageElements uses cleanPathname for all
   internal routing work (slot param extraction, slot overrides) and
   displayPathname only for render identity — keeping the browser-visible
   URL in RSC metadata without affecting internal matching.

* fix(interception): guard siblingInterceptElement construction and document reserved slot name

* fix(interception): surface missing default export on sibling intercept pages

A sibling-style interception whose intercepting page module is missing
its `default` export previously fell back to the source route's page
component (`effectivePageModule?.default ?? PageComponent`), silently
rendering a *different* page than the one requested.

Resolve the effective component from the intercepting page only and
extend the existing no-default-export guard to cover sibling intercepts,
so a missing export surfaces the same explicit "Page has no default
export" error the source/slot paths already produce. For a normal
request this is identical to the previous behavior since
`effectivePageModule === pageModule`.

Addresses bonk review feedback on #1804.

* test(app-router): guard canonical pathname commit on soft-nav config rewrites

The `displayPathname = canonicalPathname` change feeds the RSC payload
identity the client planner uses to commit a navigation, so it affects
every rewritten route, not just interceptions. Existing coverage
(nextjs-compat/hooks.test.ts) only exercises the hard-nav (SSR) path.

Add an e2e that soft-navigates (client-side) to an afterFiles-rewritten
route and asserts the committed URL and `usePathname()` stay canonical
(pre-rewrite), with a marker check proving no full page reload occurred.

Addresses bonk review feedback on #1804.

* fix(interception): restore __pageLoader on slot intercept lookup entries

The main merge auto-resolved incorrectly: it moved the `__pageLoader`
field that #1738 added to the *slot* intercept push block onto the new
*sibling* push block instead (the two `interceptLookup.push({...})` calls
are textually similar). As a result slot intercept lookup entries lost
`__pageLoader`, so resolveAppPageInterceptState never loaded the lazy
intercepting page (`page` stayed null) and the modal slot fell back to
default.tsx — breaking every slot interception.

Restore `__pageLoader: intercept.__pageLoader` on the slot push block.
The sibling block already carries it (see merge commit), and both
intercept types declare the optional field.

Fixes the app-router-dev-server intercept tests and the interception
e2e suites that regressed after merging main.

---------

Co-authored-by: James Anderson <james@eli.cx>
2026-06-08 19:01:29 +01:00
Divanshu Chauhan (divkix) b25be5e167 fix(routing): correct (.) interception target for nested slot subdirectories (#1751)
* 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
2026-06-05 23:26:18 +01:00
James Anderson 7bb669c0e9 test(routing): regression for parallel slot page-over-default priority (#1339) (#1689)
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.
2026-05-31 09:29:30 +01:00
James Anderson c1a6adc19a fix(routing): treat @children as transparent so explicit page wins over catchall (#1535) (#1659)
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.
2026-05-28 20:54:32 +01:00
James Anderson f61de5d39a fix(parallel-routes): resolve nested slot pages over default.tsx (#1389)
* 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.
2026-05-21 10:30:34 +01:00
James Anderson 086c6e8b52 fix(app-router): parse interception route markers in route scanner (#1398)
* 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.
2026-05-21 10:29:02 +01:00
JamesbbBriz 7075d210e7 feat(cli): native App Router route type generation (#1144)
* Add native App Router route typegen

* Address route typegen review feedback

* Respect typegen target directory

* Require explicit LayoutProps route

* Serialize route type regeneration

* Derive layout params from route graph

* Scope layout slot typegen by route group

* Map typegen slots to owner layouts

* Preserve slot-local layout type keys

* Generate next-env.d.ts during typegen

* Address review feedback for route typegen

* Defer route sort and tighten typegen helpers

* Stabilize Windows create-next-app smoke check

* Address route typegen review fixes

* Match Next route type import

* Stabilize Windows create-next-app smoke

* Skip redundant route param shape writes

* Defer dev route type generation
2026-05-20 10:45:51 +01:00
Nathan Nguyen e0eabc122b feat(router): add manifest-backed interception topology (#1314)
* feat(router): add manifest-backed interception topology

* test(router): cover concrete dynamic interception proof ids

* feat(router): index interception facts by slot
2026-05-19 13:15:31 +01:00
James Anderson 0a77ebd46f fix(router): gate intercept matching on Next-URL source pattern (#1302)
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.
2026-05-18 11:55:31 +01:00
Nathan Nguyen a0737e971b feat(router): expose RouteManifest default and segment boundary facts (#1136)
* 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.
2026-05-11 08:21:22 +01:00
Nathan Nguyen 7b3fc49b92 fix(app-router): honor route group error boundaries (#1124) 2026-05-07 11:22:30 +01:00
Nathan Nguyen 4932b69b17 feat(router): expose RouteManifest semantic facts (#1113)
* 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.
2026-05-07 09:15:43 +01:00
Nathan Nguyen ebbf72cc8d feat(router): expose route graph manifest read model (#1089)
* 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
2026-05-06 17:44:20 +01:00
Nathan Nguyen c1a4c8da1a fix(routing): accept any non-] chars in dynamic segment names (Next.js parity) (#1054)
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#L175

Closes #7
2026-05-05 19:21:40 +01:00
Nathan Nguyen 834ccc6ce7 feat(app-router): mint semantic route graph ids (#1060)
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.
2026-05-05 19:08:00 +01:00
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
James Anderson 2cf21433cb fix(routing): mirror inherited parallel slots to descendant sub-pages (#1039)
* 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>
2026-05-03 18:02:20 +01:00
Nathan Nguyen c33f96a904 refactor(routing): extract App Router route graph builder (#998)
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.
2026-05-02 20:03:50 +01:00