Skip to content

fix(app-router): match interception route precedence#2231

Merged
james-elicx merged 4 commits into
mainfrom
codex/fix-interception-dynamic-segment-27858251903
Jun 22, 2026
Merged

fix(app-router): match interception route precedence#2231
james-elicx merged 4 commits into
mainfrom
codex/fix-interception-dynamic-segment-27858251903

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Summary

  • match interception targets segment by segment instead of applying whole-route sorting to normalized matcher patterns
  • preserve Next.js specificity at each segment: static, dynamic, catch-all, then optional catch-all
  • cover overlapping static/dynamic prefixes and dynamic/catch-all target selection in the App RSC matcher

Next.js parity

Pinned against Next.js commit ee6e79b1792a4d401ddf2480f40a83549fe8e722.

Targeted validation

  • vp test run tests/app-rsc-route-matching.test.ts --maxWorkers=1 — 22/22 passed
  • An earlier exact fixture browser assertion passed before the comparator refinement; the later Playwright rerun was stopped when the fixture configuration started multiple servers to avoid unnecessary local CPU use
  • Unit coverage was expanded after that refinement to directly exercise segment-by-segment static, dynamic, catch-all, and optional catch-all precedence

No broad or raw Next.js E2E suite was run locally; CI is left to perform broader validation.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@pkg-pr-new

pkg-pr-new Bot commented Jun 21, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 725abf3

@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

@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: interception route precedence

Reviewed the segment-by-segment comparator against the pinned Next.js sources. The core change is correct and the precedence ordering (static < dynamic < catch-all < optional catch-all) faithfully matches getSegmentSpecificity in sortable-routes.ts. Targeted tests pass (22/22) and vp check is clean.

A few observations, none blocking:

1. Comparator diverges from Next.js compareRouteSegments on dynamic-segment tiebreaks (minor fidelity gap)

packages/vinext/src/server/app-rsc-route-matching.ts:222

Next.js compareRouteSegments applies localeCompare to any non-identical segments of equal specificity, including dynamic ones (e.g. [id] vs [slug]). The PR restricts localeCompare to the case where both segments are static:

if (aSegment !== bSegment && !aSegment.startsWith(":") && !bSegment.startsWith(":")) {
  return aSegment.localeCompare(bSegment);
}

This produces a different ordering than Next.js for patterns like /:id/foo vs /:name/bar:

  • Next.js: ties on seg0 (both dynamic), [id].localeCompare([name]) < 0 → /:id/foo first.
  • This PR: skips seg0 (dynamic), compares seg1 foo vs bar/:name/bar first.

I verified this is ordering-only between non-overlapping patterns (such patterns can't match the same URL, since the differing segments are concrete statics), so there is no observable match-result difference today. But since the PR explicitly pins compareRouteSegments as the parity source, matching its logic exactly (compare aSegment.localeCompare(bSegment) whenever aSegment !== bSegment and specificity is equal) would remove the divergence and avoid surprises if this comparator is reused elsewhere. Worth a code comment at minimum if the deviation is intentional.

2. Length handling differs from Next.js but is equivalent (no action needed)

Next.js iterates to Math.max(len) and decides on missing segments inside the loop; this PR iterates to Math.min(len) and applies a.length - b.length after the loop. I checked these are equivalent for all specificity-tie cases (shorter route = more specific in both), so this is fine.

3. Sort stability for identical target patterns

interceptLookup.sort(...) final tiebreaker is a.targetPattern.localeCompare(b.targetPattern), which is 0 for entries sharing the same target pattern across different slots/sources. This relies on V8's stable sort to preserve insertion order for those entries. That holds, but a brief comment noting the reliance would help future readers.

Parity check on findIntercept

The global sort sits above the per-entry matchInterceptSource source gate, and findIntercept returns the first entry passing both the source gate and target match. Sorting by target specificity is a strict improvement over the previous arbitrary insertion order — when two entries both pass the source gate and match the URL, the more specific target now wins, which is the intended behavior. No regression in the source-gating logic.

Overall this is a solid, well-tested fix. The only substantive note is #1 (intentional? if so, comment it; otherwise align with compareRouteSegments).

@github-actions

github-actions Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 725abf3 against base 82d5f76 using alternating same-runner rounds. Next.js was unchanged and skipped.

4 improved · 0 regressed · 2 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 132.8 KB 117.2 KB 🟢 -11.7%
Client entry size (gzip) vinext 118.7 KB 112.6 KB 🟢 -5.1%
Dev server cold start vinext 2.25 s 2.24 s ⚫ -0.3%
Production build time vinext 2.68 s 2.54 s 🟢 -5.1%
RSC entry closure size (gzip) vinext 92.2 KB 92.8 KB ⚫ +0.6%
Server bundle size (gzip) vinext 166.9 KB 156.6 KB 🟢 -6.2%

View detailed results and traces

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

Co-authored-by: james-elicx <james-elicx@users.noreply.github.com>
@ask-bonk

ask-bonk Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2231.

Summary

I reviewed the interception route precedence fix against the pinned Next.js sources (sortable-routes.ts). The change is correct and well-tested — the precedence ordering matches Next.js's getSegmentSpecificity exactly (static < dynamic < catch-all < optional catch-all), tests pass 22/22, and vp check is clean.

Findings (none blocking):

  1. Minor parity gap (app-rsc-route-matching.ts:222): The comparator restricts localeCompare tiebreaks to static-only segments, whereas Next.js compareRouteSegments applies it to dynamic segments too. I verified this only reorders non-overlapping patterns (no observable match-result difference), but since the PR pins compareRouteSegments as the parity source, it should either match exactly or carry a comment explaining the deviation.

  2. Length handling uses Math.min + post-loop diff vs Next.js's Math.max + in-loop — I confirmed these are functionally equivalent.

  3. Sort stability for identical target patterns relies on V8's stable sort (holds; worth a comment).

I also confirmed the global sort correctly sits above the per-entry source gate in findIntercept, making it a strict improvement over the prior arbitrary insertion order with no regression to source gating.

github run

@james-elicx james-elicx marked this pull request as ready for review June 21, 2026 18:01
@james-elicx

Copy link
Copy Markdown
Member Author

/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: interception route precedence (re-review after 725abf3)

I re-reviewed the segment-by-segment comparator against the pinned Next.js source sortable-routes.ts@ee6e79b. The change is correct, well-tested, and ready to merge.

Prior review finding #1 is resolved

The earlier review flagged that localeCompare was restricted to static-only segments, diverging from Next.js compareRouteSegments (which applies it to any non-identical equal-specificity segments, including dynamic). Commit 725abf3 fixes this — app-rsc-route-matching.ts:222 now reads:

if (aSegment !== bSegment) {
  return aSegment.localeCompare(bSegment);
}

This matches compareRouteSegments exactly, and the new test "orders equally specific dynamic interception segments lexicographically" (tests/app-rsc-route-matching.test.ts:223) locks in the [:id] < [:name] behavior. Good.

Parity verification

  • interceptSegmentPrecedence (:412→ line 204) maps to Next.js getSegmentSpecificity correctly for the normalized :param form: static=0, dynamic=1, catch-all (+)=2, optional catch-all (*)=3. The startsWith(":") guard is checked before the endsWith checks, so literal segments like c++ and bracket literals [literal] are correctly classified as static (0).
  • Length handling uses Math.min + post-loop a.length - b.length vs Next.js's Math.max + in-loop missing-segment branch. I re-confirmed these are functionally equivalent for all specificity-tie cases (shorter route is more specific in both): e.g. /api vs /api/[...slug] both yield -1, /api vs /[...slug] both yield -2.
  • Source gating is preserved. The global sort sits above the per-entry matchInterceptSource gate in findIntercept. Each entry's source gate and owner resolution (concreteSourceRouteIndex) are computed independently of list order, so sorting only changes which of multiple equally-valid matches wins — and the more specific target now wins, which is the intended fix. No regression to source gating.

Single matcher = no dev/prod parity gap

All interception matching flows through the one createAppRscRouteMatcher/createInterceptLookup path; there is no second copy of this logic in dev-server.ts / prod-server.ts to keep in sync. The AGENTS.md dev/prod parity concern does not apply here.

Minor (non-blocking) notes

  1. The stable-sort comment at :314 correctly documents the reliance on Array.prototype.sort stability for identical target patterns, and the "preserves declaration order for identical interception targets" test guards it. Good.
  2. Consider a one-line comment on compareInterceptTargetPatterns linking to compareRouteSegments so the equivalence (and the Math.min vs Math.max deviation) is documented for future readers — optional.

Verification

  • vp test run tests/app-rsc-route-matching.test.ts --maxWorkers=124/24 passed (PR body's 22 predates the two new tiebreak tests).
  • vp check on the changed source + test files → clean (format, lint, types).

LGTM.

@ask-bonk

ask-bonk Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2231.

Summary

I re-reviewed PR #2231 (interception route precedence fix) against the pinned Next.js source sortable-routes.ts@ee6e79b. The change is correct, well-tested, and ready to merge — no blocking issues.

Key findings:

  1. Prior review finding Virtual module imports break esbuild dependency optimization when vinext is installed from npm #1 is resolved. Commit 725abf3 ("align interception route tiebreaks") removed the static-only restriction on localeCompare at app-rsc-route-matching.ts:222. It now compares any non-identical equal-specificity segments, matching Next.js compareRouteSegments exactly. A new test (tests/app-rsc-route-matching.test.ts:223) locks in the dynamic-segment lexicographic ordering.

  2. Precedence mapping is faithful. interceptSegmentPrecedence maps to Next.js getSegmentSpecificity (static=0, dynamic=1, catch-all=2, optional catch-all=3), and the startsWith(":") guard correctly classifies literals like c++ and [literal] as static.

  3. Math.min + post-loop length diff is equivalent to Next.js's Math.max + in-loop branch (re-verified the tie cases).

  4. Source gating preserved. The global sort sits above the per-entry matchInterceptSource gate; sorting only picks the more specific target among equally-valid matches — the intended fix, no regression.

  5. No dev/prod parity gap — all interception matching flows through the single createAppRscRouteMatcher.

Verified locally: 24/24 tests pass, vp check clean. Left two optional non-blocking suggestions (a doc comment linking to compareRouteSegments).

github run

@james-elicx james-elicx merged commit a34c083 into main Jun 22, 2026
58 checks passed
@james-elicx james-elicx deleted the codex/fix-interception-dynamic-segment-27858251903 branch June 22, 2026 22:37
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