fix(app-router): match interception route precedence#2231
Conversation
|
/bigbonk review for issues |
commit: |
|
There was a problem hiding this comment.
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/foofirst. - This PR: skips seg0 (dynamic), compares seg1
foovsbar→/:name/barfirst.
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).
Performance benchmarksCompared 4 improved · 0 regressed · 2 within ±1.5%
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>
|
Review posted to PR #2231. SummaryI reviewed the interception route precedence fix against the pinned Next.js sources ( Findings (none blocking):
I also confirmed the global sort correctly sits above the per-entry source gate in |
|
/bigbonk review for issues |
There was a problem hiding this comment.
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.jsgetSegmentSpecificitycorrectly for the normalized:paramform: static=0, dynamic=1, catch-all (+)=2, optional catch-all (*)=3. ThestartsWith(":")guard is checked before theendsWithchecks, so literal segments likec++and bracket literals[literal]are correctly classified as static (0).- Length handling uses
Math.min+ post-loopa.length - b.lengthvs Next.js'sMath.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./apivs/api/[...slug]both yield -1,/apivs/[...slug]both yield -2. - Source gating is preserved. The global sort sits above the per-entry
matchInterceptSourcegate infindIntercept. 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
- The stable-sort comment at
:314correctly documents the reliance onArray.prototype.sortstability for identical target patterns, and the "preserves declaration order for identical interception targets" test guards it. Good. - Consider a one-line comment on
compareInterceptTargetPatternslinking tocompareRouteSegmentsso the equivalence (and theMath.minvsMath.maxdeviation) is documented for future readers — optional.
Verification
vp test run tests/app-rsc-route-matching.test.ts --maxWorkers=1→ 24/24 passed (PR body's 22 predates the two new tiebreak tests).vp checkon the changed source + test files → clean (format, lint, types).
LGTM.
|
Review posted to PR #2231. SummaryI re-reviewed PR #2231 (interception route precedence fix) against the pinned Next.js source Key findings:
Verified locally: 24/24 tests pass, |
Summary
Next.js parity
Pinned against Next.js commit
ee6e79b1792a4d401ddf2480f40a83549fe8e722.test/e2e/app-dir/interception-dynamic-single-segment/interception-dynamic-single-segment.test.tspackages/next/src/shared/lib/router/utils/sortable-routes.tspackages/next/src/shared/lib/router/utils/sortable-routes.test.tsTargeted validation
vp test run tests/app-rsc-route-matching.test.ts --maxWorkers=1— 22/22 passedNo broad or raw Next.js E2E suite was run locally; CI is left to perform broader validation.