diff --git a/packages/vinext/src/routing/app-router.ts b/packages/vinext/src/routing/app-router.ts index fce569f3..78914b70 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,19 @@ function discoverSlotSubRoutes( matcher: ValidFileMatcher, ): AppRoute[] { const syntheticRoutes: AppRoute[] = []; - const existingPatterns = new Set(routes.map((r) => r.pattern)); + + // 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) => ({ + ...slot, + pagePath: slotPages.get(slotKey(slot.name, slot.ownerDir)) ?? slot.pagePath, + })); + }; for (const parentRoute of routes) { if (parentRoute.parallelSlots.length === 0) continue; @@ -220,8 +234,19 @@ 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, 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, + { + // 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; + } + >(); for (const slot of parentRoute.parallelSlots) { const slotDir = path.join(parentPageDir, `@${slot.name}`); @@ -229,10 +254,33 @@ 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 = { + rawSegments: subSegments, + converted: convertedSubRoute, + slotPages: new Map(), + }; + subPathMap.set(normalizedSubPath, subPathEntry); } - subPathMap.get(relativePath)!.set(slot.name, pagePath); + + 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}").`, + ); + } + + subPathEntry.slotPages.set(slotId, pagePath); } } @@ -241,12 +289,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); - const convertedSubRoute = convertSegmentsToRouteParts(subSegments); - if (!convertedSubRoute) continue; - + for (const { rawSegments, converted: convertedSubRoute, slotPages } of subPathMap.values()) { const { urlSegments: urlParts, params: subParams, @@ -254,21 +297,27 @@ 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 = routesByPattern.get(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({ + const newRoute: AppRoute = { pattern, pagePath: childrenDefault, // children slot uses parent's default.tsx as page routePath: null, @@ -282,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); } } @@ -652,6 +703,7 @@ function discoverParallelSlots( slots.push({ name: slotName, + ownerDir: slotDir, pagePath, defaultPath, layoutPath: findFile(slotDir, "layout", matcher), @@ -881,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 { @@ -947,6 +1008,11 @@ function hasRemainingVisibleSegments(segments: string[], startIndex: number): bo return false; } +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..087deef1 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(/\/+$/, ""); + 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 ─────────────────────────────────────────────────────────