From 4f222c69ce0b447046afb8ff8b6b8e44e5dfd487 Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Tue, 10 Mar 2026 23:19:39 -0500 Subject: [PATCH 1/4] Fix app router route-group conflicts --- packages/vinext/src/routing/app-router.ts | 94 +++++-- .../vinext/src/routing/route-validation.ts | 16 +- tests/route-sorting.test.ts | 231 ++++++++++++++++++ 3 files changed, 324 insertions(+), 17 deletions(-) diff --git a/packages/vinext/src/routing/app-router.ts b/packages/vinext/src/routing/app-router.ts index fce569f3..671f907f 100644 --- a/packages/vinext/src/routing/app-router.ts +++ b/packages/vinext/src/routing/app-router.ts @@ -37,6 +37,8 @@ export interface InterceptingRoute { export interface ParallelSlot { /** Slot name (e.g. "team" from @team) */ name: string; + /** Absolute path to the @slot directory that owns this slot. Internal routing metadata. */ + ownerDir?: string; /** Absolute path to the slot's page component */ pagePath: string | null; /** Absolute path to the slot's default.tsx fallback */ @@ -211,7 +213,16 @@ function discoverSlotSubRoutes( matcher: ValidFileMatcher, ): AppRoute[] { const syntheticRoutes: AppRoute[] = []; - const existingPatterns = new Set(routes.map((r) => r.pattern)); + + const slotKey = (slotName: string, ownerDir: string | undefined): string => + `${slotName}\u0000${ownerDir ?? ""}`; + + const applySlotSubPages = (route: AppRoute, slotPages: Map): void => { + route.parallelSlots = route.parallelSlots.map((slot) => ({ + ...slot, + pagePath: slotPages.get(slotKey(slot.name, slot.ownerDir)) ?? slot.pagePath, + })); + }; for (const parentRoute of routes) { if (parentRoute.parallelSlots.length === 0) continue; @@ -220,8 +231,14 @@ function discoverSlotSubRoutes( const parentPageDir = path.dirname(parentRoute.pagePath); // Collect sub-paths from all slots. - // Map: relative sub-path (e.g., "demographics") -> Map - const subPathMap = new Map>(); + // Map: normalized visible sub-path -> slot pages and visible route segments. + const subPathMap = new Map< + string, + { + routeSegments: string[]; + slotPages: Map; + } + >(); for (const slot of parentRoute.parallelSlots) { const slotDir = path.join(parentPageDir, `@${slot.name}`); @@ -229,10 +246,32 @@ function discoverSlotSubRoutes( const subPages = findSlotSubPages(slotDir, matcher); for (const { relativePath, pagePath } of subPages) { - if (!subPathMap.has(relativePath)) { - subPathMap.set(relativePath, new Map()); + const subSegments = relativePath.split(path.sep); + const convertedSubRoute = convertSegmentsToRouteParts(subSegments); + if (!convertedSubRoute) continue; + + const { urlSegments } = convertedSubRoute; + const normalizedSubPath = urlSegments.join("/"); + let subPathEntry = subPathMap.get(normalizedSubPath); + + if (!subPathEntry) { + subPathEntry = { + routeSegments: normalizeVisibleRouteSegments(subSegments), + slotPages: new Map(), + }; + subPathMap.set(normalizedSubPath, subPathEntry); + } + + const slotId = slotKey(slot.name, slot.ownerDir); + const existingSlotPage = subPathEntry.slotPages.get(slotId); + if (existingSlotPage) { + const pattern = joinRoutePattern(parentRoute.pattern, normalizedSubPath); + throw new Error( + `You cannot have two routes that resolve to the same path ("${pattern}").`, + ); } - subPathMap.get(relativePath)!.set(slot.name, pagePath); + + subPathEntry.slotPages.set(slotId, pagePath); } } @@ -241,9 +280,7 @@ function discoverSlotSubRoutes( // Find the default.tsx for the children slot at the parent directory const childrenDefault = findFile(parentPageDir, "default", matcher); - for (const [subPath, slotPages] of subPathMap) { - // Convert sub-path segments to URL pattern parts - const subSegments = subPath.split(path.sep); + for (const [subPath, { routeSegments: subSegments, slotPages }] of subPathMap) { const convertedSubRoute = convertSegmentsToRouteParts(subSegments); if (!convertedSubRoute) continue; @@ -254,18 +291,24 @@ function discoverSlotSubRoutes( } = convertedSubRoute; const subUrlPath = urlParts.join("/"); - const pattern = - parentRoute.pattern === "/" ? "/" + subUrlPath : parentRoute.pattern + "/" + subUrlPath; - - // Skip if this pattern already exists as a regular route - if (existingPatterns.has(pattern)) continue; - if (syntheticRoutes.some((r) => r.pattern === pattern)) continue; + const pattern = joinRoutePattern(parentRoute.pattern, subUrlPath); + + const existingRoute = routes.find((route) => route.pattern === pattern); + if (existingRoute) { + if (existingRoute.routePath && !existingRoute.pagePath) { + throw new Error( + `You cannot have two routes that resolve to the same path ("${pattern}").`, + ); + } + applySlotSubPages(existingRoute, slotPages); + continue; + } // Build parallel slots for this sub-route: matching slots get the sub-page, // non-matching slots get null pagePath (rendering falls back to defaultPath) const subSlots: ParallelSlot[] = parentRoute.parallelSlots.map((slot) => ({ ...slot, - pagePath: slotPages.get(slot.name) || null, + pagePath: slotPages.get(slotKey(slot.name, slot.ownerDir)) || null, })); syntheticRoutes.push({ @@ -652,6 +695,7 @@ function discoverParallelSlots( slots.push({ name: slotName, + ownerDir: slotDir, pagePath, defaultPath, layoutPath: findFile(slotDir, "layout", matcher), @@ -947,6 +991,24 @@ function hasRemainingVisibleSegments(segments: string[], startIndex: number): bo return false; } +function normalizeVisibleRouteSegments(segments: string[]): string[] { + const visibleSegments: string[] = []; + + for (const segment of segments) { + if (segment === ".") continue; + if (segment.startsWith("(") && segment.endsWith(")")) continue; + if (segment.startsWith("@")) continue; + visibleSegments.push(segment); + } + + return visibleSegments; +} + +function joinRoutePattern(basePattern: string, subPath: string): string { + if (!subPath) return basePattern; + return basePattern === "/" ? `/${subPath}` : `${basePattern}/${subPath}`; +} + /** * Match a URL against App Router routes. */ diff --git a/packages/vinext/src/routing/route-validation.ts b/packages/vinext/src/routing/route-validation.ts index 52de5743..0710c594 100644 --- a/packages/vinext/src/routing/route-validation.ts +++ b/packages/vinext/src/routing/route-validation.ts @@ -154,10 +154,24 @@ export function patternToNextFormat(pattern: string): string { .replace(/:([\w-]+)/g, "[$1]"); } +function normalizeRoutePattern(pattern: string): string { + if (pattern === "/") return "/"; + const normalized = pattern.replace(/\/+$/g, ""); + return normalized === "" ? "/" : normalized; +} + export function validateRoutePatterns(patterns: readonly string[]): void { const root = new UrlNode(); + const seenPatterns = new Set(); for (const pattern of patterns) { - root.insert(patternToNextFormat(pattern)); + const normalizedPattern = normalizeRoutePattern(pattern); + if (seenPatterns.has(normalizedPattern)) { + throw new Error( + `You cannot have two routes that resolve to the same path ("${normalizedPattern}").`, + ); + } + seenPatterns.add(normalizedPattern); + root.insert(patternToNextFormat(normalizedPattern)); } root.assertOptionalCatchAllSpecificity(); } diff --git a/tests/route-sorting.test.ts b/tests/route-sorting.test.ts index d828cb10..bf8a44f6 100644 --- a/tests/route-sorting.test.ts +++ b/tests/route-sorting.test.ts @@ -285,6 +285,14 @@ describe("validateRoutePatterns", () => { ).toThrow(/differ only by non-word/); }); + it("rejects duplicate normalized patterns", () => { + expect(() => validateRoutePatterns(["/about", "/about"])).toThrow(/same path/); + }); + + it("rejects slash-equivalent patterns", () => { + expect(() => validateRoutePatterns(["/about", "/about/"])).toThrow(/same path/); + }); + it("rejects the Unicode ellipsis in catch-all syntax", () => { expect(() => validateRoutePatterns(["/[…three-dots]"])).toThrow( /Detected a three-dot character/, @@ -336,6 +344,229 @@ describe("App Router route sorting (additional)", () => { invalidateAppRouteCache(); } }); + + it("rejects route groups that resolve to the same URL path", async () => { + // Next.js validates normalized app paths after route groups are stripped: + // https://github.com/vercel/next.js/blob/canary/packages/next/src/build/validate-app-paths.test.ts + // Route-group conflicts are also documented here: + // https://github.com/vercel/next.js/blob/canary/docs/01-app/03-api-reference/03-file-conventions/route-groups.mdx + const tmpRoot = await makeTempDir("vinext-app-route-group-conflict-"); + const appDir = path.join(tmpRoot, "app"); + + try { + await fs.mkdir(path.join(appDir, "(a)", "about"), { recursive: true }); + await fs.mkdir(path.join(appDir, "(b)", "about"), { recursive: true }); + await fs.writeFile( + path.join(appDir, "layout.tsx"), + "export default function Layout({ children }: { children: React.ReactNode }) { return {children}; }", + ); + await fs.writeFile( + path.join(appDir, "(a)", "about", "page.tsx"), + "export default function Page() { return null; }", + ); + await fs.writeFile( + path.join(appDir, "(b)", "about", "page.tsx"), + "export default function Page() { return null; }", + ); + + invalidateAppRouteCache(); + await expect(appRouter(appDir)).rejects.toThrow(/same path.*\/about/); + } finally { + await fs.rm(tmpRoot, { recursive: true, force: true }); + invalidateAppRouteCache(); + } + }); + + it("rejects grouped slot sub-pages that resolve to the same URL path within one slot", async () => { + const tmpRoot = await makeTempDir("vinext-app-slot-route-group-conflict-"); + const appDir = path.join(tmpRoot, "app"); + + try { + await fs.mkdir(path.join(appDir, "dashboard", "@team", "(a)", "members"), { + recursive: true, + }); + await fs.mkdir(path.join(appDir, "dashboard", "@team", "(b)", "members"), { + recursive: true, + }); + await fs.writeFile( + path.join(appDir, "layout.tsx"), + "export default function Layout({ children }: { children: React.ReactNode }) { return {children}; }", + ); + await fs.writeFile( + path.join(appDir, "dashboard", "page.tsx"), + "export default function Page() { return null; }", + ); + await fs.writeFile( + path.join(appDir, "dashboard", "default.tsx"), + "export default function Default() { return null; }", + ); + await fs.writeFile( + path.join(appDir, "dashboard", "@team", "default.tsx"), + "export default function Default() { return null; }", + ); + await fs.writeFile( + path.join(appDir, "dashboard", "@team", "(a)", "members", "page.tsx"), + "export default function Page() { return null; }", + ); + await fs.writeFile( + path.join(appDir, "dashboard", "@team", "(b)", "members", "page.tsx"), + "export default function Page() { return null; }", + ); + + invalidateAppRouteCache(); + await expect(appRouter(appDir)).rejects.toThrow(/same path.*\/dashboard\/members/); + } finally { + await fs.rm(tmpRoot, { recursive: true, force: true }); + invalidateAppRouteCache(); + } + }); + + it("merges grouped slot sub-pages from different slots onto the same synthesized route", async () => { + const tmpRoot = await makeTempDir("vinext-app-slot-route-group-merge-"); + const appDir = path.join(tmpRoot, "app"); + + try { + await fs.mkdir(path.join(appDir, "dashboard", "@team", "(a)", "members"), { + recursive: true, + }); + await fs.mkdir(path.join(appDir, "dashboard", "@analytics", "(b)", "members"), { + recursive: true, + }); + await fs.writeFile( + path.join(appDir, "layout.tsx"), + "export default function Layout({ children }: { children: React.ReactNode }) { return {children}; }", + ); + await fs.writeFile( + path.join(appDir, "dashboard", "page.tsx"), + "export default function Page() { return null; }", + ); + await fs.writeFile( + path.join(appDir, "dashboard", "default.tsx"), + "export default function Default() { return null; }", + ); + await fs.writeFile( + path.join(appDir, "dashboard", "@team", "default.tsx"), + "export default function Default() { return null; }", + ); + await fs.writeFile( + path.join(appDir, "dashboard", "@analytics", "default.tsx"), + "export default function Default() { return null; }", + ); + await fs.writeFile( + path.join(appDir, "dashboard", "@team", "(a)", "members", "page.tsx"), + "export default function TeamMembers() { return null; }", + ); + await fs.writeFile( + path.join(appDir, "dashboard", "@analytics", "(b)", "members", "page.tsx"), + "export default function AnalyticsMembers() { return null; }", + ); + + invalidateAppRouteCache(); + const routes = await appRouter(appDir); + const membersRoute = routes.find((route) => route.pattern === "/dashboard/members"); + + expect(membersRoute).toBeDefined(); + expect(membersRoute!.parallelSlots.find((slot) => slot.name === "team")!.pagePath).toContain( + path.join("@team", "(a)", "members"), + ); + expect( + membersRoute!.parallelSlots.find((slot) => slot.name === "analytics")!.pagePath, + ).toContain(path.join("@analytics", "(b)", "members")); + } finally { + await fs.rm(tmpRoot, { recursive: true, force: true }); + invalidateAppRouteCache(); + } + }); + + it("rejects slot sub-pages that collide with route handlers at the same URL", async () => { + const tmpRoot = await makeTempDir("vinext-app-slot-route-handler-conflict-"); + const appDir = path.join(tmpRoot, "app"); + + try { + await fs.mkdir(path.join(appDir, "dashboard", "@team", "members"), { recursive: true }); + await fs.mkdir(path.join(appDir, "dashboard", "members"), { recursive: true }); + await fs.writeFile( + path.join(appDir, "layout.tsx"), + "export default function Layout({ children }: { children: React.ReactNode }) { return {children}; }", + ); + await fs.writeFile( + path.join(appDir, "dashboard", "page.tsx"), + "export default function Page() { return null; }", + ); + await fs.writeFile( + path.join(appDir, "dashboard", "default.tsx"), + "export default function Default() { return null; }", + ); + await fs.writeFile( + path.join(appDir, "dashboard", "@team", "default.tsx"), + "export default function Default() { return null; }", + ); + await fs.writeFile( + path.join(appDir, "dashboard", "@team", "members", "page.tsx"), + "export default function TeamMembers() { return null; }", + ); + await fs.writeFile( + path.join(appDir, "dashboard", "members", "route.ts"), + "export async function GET() { return new Response('ok'); }", + ); + + invalidateAppRouteCache(); + await expect(appRouter(appDir)).rejects.toThrow(/same path.*\/dashboard\/members/); + } finally { + await fs.rm(tmpRoot, { recursive: true, force: true }); + invalidateAppRouteCache(); + } + }); + + it("does not overwrite a child slot with a parent slot sub-page that shares the same name", async () => { + const tmpRoot = await makeTempDir("vinext-app-slot-shadowing-"); + const appDir = path.join(tmpRoot, "app"); + + try { + await fs.mkdir(path.join(appDir, "dashboard", "@team", "settings"), { recursive: true }); + await fs.mkdir(path.join(appDir, "dashboard", "settings", "@team"), { recursive: true }); + await fs.writeFile( + path.join(appDir, "layout.tsx"), + "export default function Layout({ children }: { children: React.ReactNode }) { return {children}; }", + ); + await fs.writeFile( + path.join(appDir, "dashboard", "page.tsx"), + "export default function DashboardPage() { return null; }", + ); + await fs.writeFile( + path.join(appDir, "dashboard", "default.tsx"), + "export default function DashboardDefault() { return null; }", + ); + await fs.writeFile( + path.join(appDir, "dashboard", "@team", "default.tsx"), + "export default function ParentTeamDefault() { return null; }", + ); + await fs.writeFile( + path.join(appDir, "dashboard", "@team", "settings", "page.tsx"), + "export default function ParentTeamSettings() { return null; }", + ); + await fs.writeFile( + path.join(appDir, "dashboard", "settings", "page.tsx"), + "export default function SettingsPage() { return null; }", + ); + await fs.writeFile( + path.join(appDir, "dashboard", "settings", "@team", "page.tsx"), + "export default function ChildTeamPage() { return null; }", + ); + + invalidateAppRouteCache(); + const routes = await appRouter(appDir); + const settingsRoute = routes.find((route) => route.pattern === "/dashboard/settings"); + + expect(settingsRoute).toBeDefined(); + expect(settingsRoute!.parallelSlots.find((slot) => slot.name === "team")!.pagePath).toContain( + path.join("settings", "@team", "page.tsx"), + ); + } finally { + await fs.rm(tmpRoot, { recursive: true, force: true }); + invalidateAppRouteCache(); + } + }); }); // ─── API Router ───────────────────────────────────────────────────────── From c86efe8ce9e60846c0e7d3e8c8436d153d6fb70e Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Tue, 10 Mar 2026 23:33:27 -0500 Subject: [PATCH 2/4] Review app router slot discovery --- packages/vinext/src/routing/app-router.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vinext/src/routing/app-router.ts b/packages/vinext/src/routing/app-router.ts index 671f907f..30ab83ba 100644 --- a/packages/vinext/src/routing/app-router.ts +++ b/packages/vinext/src/routing/app-router.ts @@ -280,7 +280,7 @@ function discoverSlotSubRoutes( // Find the default.tsx for the children slot at the parent directory const childrenDefault = findFile(parentPageDir, "default", matcher); - for (const [subPath, { routeSegments: subSegments, slotPages }] of subPathMap) { + for (const { routeSegments: subSegments, slotPages } of subPathMap.values()) { const convertedSubRoute = convertSegmentsToRouteParts(subSegments); if (!convertedSubRoute) continue; From 25db7f7cb879e8b0084c6ff97d9c2d7ec73d02e3 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 11 Mar 2026 13:04:01 +0000 Subject: [PATCH 3/4] fix: address review feedback from bonk - 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 --- packages/vinext/src/routing/app-router.ts | 46 +++++++++++++------ .../vinext/src/routing/route-validation.ts | 2 +- 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/packages/vinext/src/routing/app-router.ts b/packages/vinext/src/routing/app-router.ts index 30ab83ba..f1653caa 100644 --- a/packages/vinext/src/routing/app-router.ts +++ b/packages/vinext/src/routing/app-router.ts @@ -38,7 +38,7 @@ export interface ParallelSlot { /** Slot name (e.g. "team" from @team) */ name: string; /** Absolute path to the @slot directory that owns this slot. Internal routing metadata. */ - ownerDir?: string; + ownerDir: string; /** Absolute path to the slot's page component */ pagePath: string | null; /** Absolute path to the slot's default.tsx fallback */ @@ -214,8 +214,11 @@ function discoverSlotSubRoutes( ): AppRoute[] { const syntheticRoutes: AppRoute[] = []; - const slotKey = (slotName: string, ownerDir: string | undefined): string => - `${slotName}\u0000${ownerDir ?? ""}`; + // O(1) lookup for existing routes by pattern — avoids O(n) routes.find() per sub-path per parent. + // Updated as new synthetic routes are pushed so that later parents can see earlier synthetic entries. + const routesByPattern = new Map(routes.map((r) => [r.pattern, r])); + + const slotKey = (slotName: string, ownerDir: string): string => `${slotName}\u0000${ownerDir}`; const applySlotSubPages = (route: AppRoute, slotPages: Map): void => { route.parallelSlots = route.parallelSlots.map((slot) => ({ @@ -231,11 +234,16 @@ function discoverSlotSubRoutes( const parentPageDir = path.dirname(parentRoute.pagePath); // Collect sub-paths from all slots. - // Map: normalized visible sub-path -> slot pages and visible route segments. + // Map: normalized visible sub-path -> slot pages, raw filesystem segments (for routeSegments), + // and the pre-computed convertedSubRoute (to avoid a redundant re-conversion in the merge loop). const subPathMap = new Map< string, { - routeSegments: string[]; + // Raw filesystem segments (with route groups, @slots, etc.) used for routeSegments so + // that useSelectedLayoutSegments() sees the correct segment list at runtime. + rawSegments: string[]; + // Pre-computed URL parts, params, isDynamic from convertSegmentsToRouteParts. + converted: { urlSegments: string[]; params: string[]; isDynamic: boolean }; slotPages: Map; } >(); @@ -256,7 +264,8 @@ function discoverSlotSubRoutes( if (!subPathEntry) { subPathEntry = { - routeSegments: normalizeVisibleRouteSegments(subSegments), + rawSegments: subSegments, + converted: convertedSubRoute, slotPages: new Map(), }; subPathMap.set(normalizedSubPath, subPathEntry); @@ -280,10 +289,7 @@ function discoverSlotSubRoutes( // Find the default.tsx for the children slot at the parent directory const childrenDefault = findFile(parentPageDir, "default", matcher); - for (const { routeSegments: subSegments, slotPages } of subPathMap.values()) { - const convertedSubRoute = convertSegmentsToRouteParts(subSegments); - if (!convertedSubRoute) continue; - + for (const { rawSegments, converted: convertedSubRoute, slotPages } of subPathMap.values()) { const { urlSegments: urlParts, params: subParams, @@ -293,7 +299,7 @@ function discoverSlotSubRoutes( const subUrlPath = urlParts.join("/"); const pattern = joinRoutePattern(parentRoute.pattern, subUrlPath); - const existingRoute = routes.find((route) => route.pattern === pattern); + const existingRoute = routesByPattern.get(pattern); if (existingRoute) { if (existingRoute.routePath && !existingRoute.pagePath) { throw new Error( @@ -311,7 +317,7 @@ function discoverSlotSubRoutes( pagePath: slotPages.get(slotKey(slot.name, slot.ownerDir)) || null, })); - syntheticRoutes.push({ + const newRoute: AppRoute = { pattern, pagePath: childrenDefault, // children slot uses parent's default.tsx as page routePath: null, @@ -325,12 +331,14 @@ function discoverSlotSubRoutes( notFoundPaths: parentRoute.notFoundPaths, forbiddenPath: parentRoute.forbiddenPath, unauthorizedPath: parentRoute.unauthorizedPath, - routeSegments: [...parentRoute.routeSegments, ...subSegments], + routeSegments: [...parentRoute.routeSegments, ...rawSegments], layoutTreePositions: parentRoute.layoutTreePositions, isDynamic: parentRoute.isDynamic || subIsDynamic, params: [...parentRoute.params, ...subParams], patternParts: [...parentRoute.patternParts, ...urlParts], - }); + }; + syntheticRoutes.push(newRoute); + routesByPattern.set(pattern, newRoute); } } @@ -991,6 +999,16 @@ function hasRemainingVisibleSegments(segments: string[], startIndex: number): bo return false; } +/** + * Filter filesystem path segments down to only the segments that are visible in the URL + * (i.e. strip route groups, @slots, and "." entries) while preserving dynamic segment + * syntax (e.g. "[id]" stays as "[id]", not converted to ":id"). + * + * This is intentionally different from convertSegmentsToRouteParts, which additionally + * converts dynamic segments to ":id" / ":id+" / ":id*" Express-style patterns. Both + * functions skip the same invisible segment types (route groups, @slots, "."), so they + * must be kept in sync if new invisible segment types are introduced. + */ function normalizeVisibleRouteSegments(segments: string[]): string[] { const visibleSegments: string[] = []; diff --git a/packages/vinext/src/routing/route-validation.ts b/packages/vinext/src/routing/route-validation.ts index 0710c594..087deef1 100644 --- a/packages/vinext/src/routing/route-validation.ts +++ b/packages/vinext/src/routing/route-validation.ts @@ -156,7 +156,7 @@ export function patternToNextFormat(pattern: string): string { function normalizeRoutePattern(pattern: string): string { if (pattern === "/") return "/"; - const normalized = pattern.replace(/\/+$/g, ""); + const normalized = pattern.replace(/\/+$/, ""); return normalized === "" ? "/" : normalized; } From 91be782ee948331faa4c24a4ec20186e9089fcb7 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 11 Mar 2026 13:06:32 +0000 Subject: [PATCH 4/4] fix: remove now-unused normalizeVisibleRouteSegments, add doc comment to convertSegmentsToRouteParts --- packages/vinext/src/routing/app-router.ts | 32 +++++++---------------- 1 file changed, 9 insertions(+), 23 deletions(-) diff --git a/packages/vinext/src/routing/app-router.ts b/packages/vinext/src/routing/app-router.ts index f1653caa..78914b70 100644 --- a/packages/vinext/src/routing/app-router.ts +++ b/packages/vinext/src/routing/app-router.ts @@ -933,6 +933,15 @@ function findFile(dir: string, name: string, matcher: ValidFileMatcher): string return null; } +/** + * Convert filesystem path segments to URL route parts, skipping invisible segments + * (route groups, @slots, ".") and converting dynamic segment syntax to Express-style + * patterns (e.g. "[id]" → ":id", "[...slug]" → ":slug+"). + * + * Note: the invisible-segment filtering logic here is also applied manually in + * discoverSlotSubRoutes when building the dedup key from urlSegments. If a new + * invisible segment type is added, both locations need updating. + */ function convertSegmentsToRouteParts( segments: string[], ): { urlSegments: string[]; params: string[]; isDynamic: boolean } | null { @@ -999,29 +1008,6 @@ function hasRemainingVisibleSegments(segments: string[], startIndex: number): bo return false; } -/** - * Filter filesystem path segments down to only the segments that are visible in the URL - * (i.e. strip route groups, @slots, and "." entries) while preserving dynamic segment - * syntax (e.g. "[id]" stays as "[id]", not converted to ":id"). - * - * This is intentionally different from convertSegmentsToRouteParts, which additionally - * converts dynamic segments to ":id" / ":id+" / ":id*" Express-style patterns. Both - * functions skip the same invisible segment types (route groups, @slots, "."), so they - * must be kept in sync if new invisible segment types are introduced. - */ -function normalizeVisibleRouteSegments(segments: string[]): string[] { - const visibleSegments: string[] = []; - - for (const segment of segments) { - if (segment === ".") continue; - if (segment.startsWith("(") && segment.endsWith(")")) continue; - if (segment.startsWith("@")) continue; - visibleSegments.push(segment); - } - - return visibleSegments; -} - function joinRoutePattern(basePattern: string, subPath: string): string { if (!subPath) return basePattern; return basePattern === "/" ? `/${subPath}` : `${basePattern}/${subPath}`;