fix: Reject non-terminal catch-all routes in pages and app routers#401
Conversation
commit: |
|
/bigbonk review |
There was a problem hiding this comment.
Good fix overall. The bug is real and the approach is sound: reject non-terminal catch-all segments at discovery time, add defensive guards in the matchers, and consolidate the duplicate segment-to-route-part conversion into a shared convertSegmentsToRouteParts helper. The test coverage is thorough and well-structured.
Parity gap: pages-server-entry.ts was missed
packages/vinext/src/entries/pages-server-entry.ts has its own copy of matchPattern (line 318) that was not patched with the same non-terminal catch-all guard. This is the Pages Router production server entry. Without the fix there, production Pages Router builds can still overmatch on malformed non-terminal catch-all patterns.
The fix is the same one-liner in both the + branch (line 324) and the * branch (line 331):
if (i !== patternParts.length - 1) return null;The corresponding snapshot at line ~15015 in entry-templates.test.ts.snap will also need updating.
Per AGENTS.md:
When fixing a bug in any of these files, check whether the same bug exists in the others. Do not leave known bugs as "follow-ups" — fix them in the same PR.
|
Review submitted successfully. Here's a summary of what I found: Review SummaryVerdict: Request changes - one parity gap needs fixing. What's good
What needs fixing
|
|
/bigbonk re-review |
There was a problem hiding this comment.
Review Summary
This is a clean, well-structured fix for a real routing correctness bug. All CI checks pass. The previous review's concern about pages-server-entry.ts being missed has been addressed — the guard is present at lines 325 and 333, and the snapshot is updated.
What's good
- Root cause addressed at both layers. Non-terminal catch-all segments are rejected at discovery time (preventing malformed patterns from ever being created) and also guarded in the matcher (defense-in-depth against malformed patterns reaching the matcher through other paths).
convertSegmentsToRoutePartsconsolidation. Three copies of the same segment-to-route-part logic inapp-router.ts(infileToAppRoute,discoverSlotSubRoutes, andcomputeInterceptTarget) are now a single shared function. This eliminates the code duplication that allowed the bug to exist — a fix in one copy would have left the others vulnerable.hasRemainingVisibleSegmentsis the right abstraction for the App Router. It correctly treats route groups(group)and parallel slots@slotas transparent, so[...slug]/(admin)/page.tsxis accepted (catch-all is terminal in URL-space) while[...slug]/(admin)/edit/page.tsxis rejected.- All four
matchPatterncopies are patched.pages-router.ts,app-router.ts,app-rsc-entry.ts, andpages-server-entry.tsall have the guard. The entry template snapshots are updated to match. - Thorough test coverage — 380 new lines covering Pages Router, App Router, API routes, parallel slots, intercept targets, route groups, and matcher fallback behavior. Good edge case coverage for the transparent-segment logic.
Minor observations (non-blocking)
-
patternPartsin test helpers is dead data. TheRouteinterface doesn't have apatternPartsfield (the matcher derives it frompattern.split("/")), andmakeTestAppRouteadds it via intersection type but the matcher never reads it. This is harmless but slightly misleading — a reader might think the matcher uses the pre-split array. Not worth blocking on. -
isDynamic/paramslocals infileToAppRouteare now redundant. After the refactor,paramsandisDynamicare declared, then immediately overwritten fromconvertedRoute. You could destructure directly instead of the declare-then-spread pattern. Again, purely cosmetic.
LGTM — nice fix.
| it("rejects malformed non-terminal catch-all patterns in the matcher", () => { | ||
| const malformedRoute = { | ||
| pattern: "/:slug+/edit", | ||
| patternParts: [":slug+", "edit"], |
There was a problem hiding this comment.
Nit: patternParts isn't a field on the Route interface and isn't used by matchRoute / matchPattern (which derives pattern parts from pattern.split("/") internally). It's harmless since you're casting via as Route, but it adds noise that might mislead a reader into thinking the matcher consumes a pre-split array.
Same applies to the other test route objects at lines 224, 237, 244.
| urlSegments.push(segment); | ||
| } | ||
| } | ||
| const { urlSegments, params: routeParams, isDynamic: routeIsDynamic } = convertedRoute; |
There was a problem hiding this comment.
Nit: the params and isDynamic locals declared at lines 334-335 are now immediately overwritten from convertedRoute. You could simplify to direct destructuring:
| const { urlSegments, params: routeParams, isDynamic: routeIsDynamic } = convertedRoute; | |
| const { urlSegments, params, isDynamic } = convertedRoute; |
(and remove the declarations at lines 334-335)
|
Review submitted. Here's a summary: Approved — this is a solid fix. The bug was real (non-terminal catch-all segments like
Key strengths:
I left two minor non-blocking nits about dead |
Fixes a routing bug where non-terminal catch-all segments were accepted during route discovery and then overmatched at runtime.
Before this change, paths like
pages/[...slug]/edit/index.tsxwere converted into the invalid internal pattern/:slug+/edit, and the matcher would return as soon as it saw:slug+or:slug*. That caused the route to match URLs it should never match, including both/fooand/foo/edit.This change fixes that in both the Pages Router and App Router.
What changed
/:slug+/editand/:slug*/editdo not overmatchDetails
Pages Router
fileToRoute()now rejects[...param]and[[...param]]unless they are terminalmatchPattern()now treats non-terminal:param+and:param*as invalid instead of returning earlyApp Router
fileToAppRoute()now rejects non-terminal catch-all segments based on visible URL segments, not raw filesystem depthapp/[...slug]/(admin)/page.tsxstill resolve to valid/:slug+matchPattern()now rejects malformed non-terminal internal catch-all patternsTests
Added regression coverage for:
/:slug+/edit/:slug*/edit