fix: App Router route-group and slot collision#447
fix: App Router route-group and slot collision#447james-elicx merged 5 commits intocloudflare:mainfrom
Conversation
commit: |
|
/bigbonk review |
There was a problem hiding this comment.
Good fix — the collision handling for route groups and parallel slot sub-pages is a real gap that needed closing. The test coverage is thorough and the error messages match Next.js conventions. A few items worth addressing before merge:
routeSegmentsbehavioral change (bug): Synthetic routes now strip route groups fromrouteSegments, contradicting the field's contract. This affectsuseSelectedLayoutSegments()at runtime.- Redundant
convertSegmentsToRoutePartscall: The storedrouteSegmentsare already visible-only segments, so the second conversion call is doing work that was already done. - Missing dedup check for
syntheticRoutes: The old code checkedsyntheticRoutes.some(...)to skip/merge duplicates. The new code relies onvalidateRoutePatternsto catch this, which means it will throw instead of merging — a different (arguably better) behavior, but worth being intentional about. ownerDircould be required: It's always set indiscoverParallelSlots, so the type should reflect that.
|
|
||
| if (!subPathEntry) { | ||
| subPathEntry = { | ||
| routeSegments: normalizeVisibleRouteSegments(subSegments), |
There was a problem hiding this comment.
Bug: normalizeVisibleRouteSegments strips route groups from subSegments, and this result is later spread into AppRoute.routeSegments at line 328:
routeSegments: [...parentRoute.routeSegments, ...subSegments],But routeSegments is documented (line 101-104) as:
"Includes route groups and dynamic segments (as template strings like
[id]). Used at render time to compute the child segments foruseSelectedLayoutSegments()."
The old code used subPath.split(path.sep) directly, which preserved route groups in routeSegments. The new code strips them. This could cause useSelectedLayoutSegments() to return incorrect results for synthetic slot sub-routes.
You should store the original subSegments (from relativePath.split(path.sep)) separately for use in routeSegments, and only use the normalized visible segments for dedup keying and URL pattern generation.
| // Convert sub-path segments to URL pattern parts | ||
| const subSegments = subPath.split(path.sep); | ||
| for (const { routeSegments: subSegments, slotPages } of subPathMap.values()) { | ||
| const convertedSubRoute = convertSegmentsToRouteParts(subSegments); |
There was a problem hiding this comment.
Minor: convertSegmentsToRouteParts(subSegments) is called here on segments that have already been filtered by normalizeVisibleRouteSegments (no route groups, no @ slots, no .). This means the route-group and slot-skip branches inside convertSegmentsToRouteParts will never trigger. Not a bug, but it's redundant work and slightly confusing — the reader has to trace back to realize these segments are already stripped.
Consider storing the convertedSubRoute result from the first call (line 250) alongside routeSegments in the map, so you don't need to re-derive it here.
| /** Slot name (e.g. "team" from @team) */ | ||
| name: string; | ||
| /** Absolute path to the @slot directory that owns this slot. Internal routing metadata. */ | ||
| ownerDir?: string; |
There was a problem hiding this comment.
ownerDir is always set when a ParallelSlot is created by discoverParallelSlots (line 697). The optional ? type means every consumer has to handle undefined, but it never actually is. Consider making it required (non-optional) and providing a meaningful default or assertion at the creation site. The slotKey helper already has a ?? "" fallback for the undefined case that would be dead code if the type were tightened.
| if (syntheticRoutes.some((r) => r.pattern === pattern)) continue; | ||
| const pattern = joinRoutePattern(parentRoute.pattern, subUrlPath); | ||
|
|
||
| const existingRoute = routes.find((route) => route.pattern === pattern); |
There was a problem hiding this comment.
Nit: routes.find(...) is O(n) per sub-path per parent route. The old code used existingPatterns = new Set(routes.map(r => r.pattern)) which was O(1) lookup. With many routes and many slots this could add up. Consider building a Map<string, AppRoute> keyed by pattern before the loop (similar to the old Set) for O(1) lookups:
| const existingRoute = routes.find((route) => route.pattern === pattern); | |
| const existingRoute = routesByPattern.get(pattern); |
(Where routesByPattern is built once before the outer loop as new Map(routes.map(r => [r.pattern, r])))
| return false; | ||
| } | ||
|
|
||
| function normalizeVisibleRouteSegments(segments: string[]): string[] { |
There was a problem hiding this comment.
This function is essentially a subset of convertSegmentsToRouteParts — both skip ., route groups, and @ slots. The only difference is that convertSegmentsToRouteParts also converts dynamic segments ([id] -> :id) while this one keeps them as-is.
Consider adding a comment noting the intentional difference, or refactoring to share the filtering logic. As-is, a future maintainer might not realize these two functions need to stay in sync if a new invisible segment type is added.
| .replace(/:([\w-]+)/g, "[$1]"); | ||
| } | ||
|
|
||
| function normalizeRoutePattern(pattern: string): string { |
There was a problem hiding this comment.
Minor: replace(/\/+$/g, "") — the g flag is unnecessary here since $ anchors the match to the end of the string, so there can only ever be one match. Not a bug, just noise:
| function normalizeRoutePattern(pattern: string): string { | |
| function normalizeRoutePattern(pattern: string): string { | |
| if (pattern === "/") return "/"; | |
| const normalized = pattern.replace(/\/+$/, ""); | |
| return normalized === "" ? "/" : normalized; | |
| } |
|
Review posted successfully. Here's a summary of the feedback: Key finding (bug): Synthetic routes now strip route groups from Other feedback:
|
…e-group-collisions
- Fix routeSegments bug: store raw filesystem segments (with route groups) instead of normalizeVisibleRouteSegments output, so useSelectedLayoutSegments() sees the correct segment list at runtime for synthetic slot sub-routes - Eliminate redundant convertSegmentsToRouteParts call: store the pre-computed result in subPathMap alongside rawSegments, reuse it in the merge loop instead of re-deriving from already-stripped segments - Make ownerDir required (non-optional) in ParallelSlot: it is always set by discoverParallelSlots; remove the dead ?? '' fallback in slotKey - Replace O(n) routes.find() with O(1) Map lookup: build routesByPattern before the outer loop and keep it updated as synthetic routes are added - Add comment to normalizeVisibleRouteSegments explaining the intentional difference from convertSegmentsToRouteParts (dynamic segments not converted) and that both functions must stay in sync - Remove spurious g flag from normalizeRoutePattern regex: $ anchors to end of string so there can only ever be one match
… to convertSegmentsToRouteParts
|
/bigbonk re-review |
There was a problem hiding this comment.
Solid fix. The collision detection for route groups and parallel slot sub-pages is well-structured, the test coverage is thorough, and the error messages match Next.js conventions. All three relevant test suites pass and typecheck is clean.
The previous automated review flagged a routeSegments bug (route groups being stripped), but that was incorrect — the current code correctly stores rawSegments (preserving route groups) for routeSegments and only uses urlSegments (groups stripped) for dedup keying and pattern generation. The __resolveChildSegments function in app-rsc-entry.ts will see the correct segment list.
A few minor observations below, none blocking.
| let subPathEntry = subPathMap.get(normalizedSubPath); | ||
|
|
||
| if (!subPathEntry) { | ||
| subPathEntry = { |
There was a problem hiding this comment.
Minor: When two different slots contribute sub-pages to the same normalized path (e.g., @team/(a)/members and @analytics/(b)/members), rawSegments is set from whichever slot is processed first. This means routeSegments for the synthesized route will contain route-group names from an arbitrary slot.
In practice this is harmless — route groups are invisible in URLs and __resolveChildSegments passes them through as-is — but it's worth a brief comment noting the first-writer-wins behavior so a future reader doesn't think it's a bug.
|
|
||
| function normalizeRoutePattern(pattern: string): string { | ||
| if (pattern === "/") return "/"; | ||
| const normalized = pattern.replace(/\/+$/, ""); |
There was a problem hiding this comment.
Ultra-minor: the g flag on this regex is unnecessary since $ anchors the match to the end of the string, so there can only ever be one match. Not a bug, just noise.
| const normalized = pattern.replace(/\/+$/, ""); | |
| const normalized = pattern.replace(/\/+$/, ""); |
|
|
||
| const existingRoute = routesByPattern.get(pattern); | ||
| if (existingRoute) { | ||
| if (existingRoute.routePath && !existingRoute.pagePath) { |
There was a problem hiding this comment.
This collision check catches the case where a slot sub-page targets a pattern that already has a route.ts handler (no pagePath). Good.
Worth noting: the inverse case — a route.ts handler discovered after a synthetic slot sub-page — is also caught because the synthetic route is added to routesByPattern (line 341), so the next call to fileToAppRoute for the route handler won't produce a duplicate pattern (it goes through the regular validateRoutePatterns call at line 181). The coverage is complete.
|
Review submitted. Here's a summary of my findings: Approved. The PR correctly addresses App Router collision handling for route groups and parallel slot sub-pages. Key points from my review:
|
Fix App Router collision handling when multiple filesystem entries resolve to the same visible URL.
This changes route discovery and validation to:
/fooand/foo/as the same route during validationroute.tshandlersDetails
In
app-router.ts:ownerDirmetadata(slot name, owner dir)instead of slot name aloneIn
route-validation.ts:Tests
Added regressions for:
(a)/aboutvs(b)/aboutroute.tsVerification
Passed:
pnpm test tests/route-sorting.test.tspnpm test tests/routing.test.tspnpm test tests/app-router.test.tspnpm run fmtpnpm run typecheck