From a4070516a92dae1dd473d6df1565005f07c8cbd3 Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Wed, 11 Mar 2026 01:15:39 -0500 Subject: [PATCH 1/3] Fix nested parallel slot routes --- packages/vinext/src/routing/app-router.ts | 31 ++++++++++++++++++++--- tests/routing.test.ts | 31 +++++++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/packages/vinext/src/routing/app-router.ts b/packages/vinext/src/routing/app-router.ts index fce569f3..f5f8491b 100644 --- a/packages/vinext/src/routing/app-router.ts +++ b/packages/vinext/src/routing/app-router.ts @@ -331,6 +331,25 @@ function findSlotSubPages( return results; } +function hasNestedSlotSubPages(slotDir: string, matcher: ValidFileMatcher): boolean { + function scan(dir: string): boolean { + if (!fs.existsSync(dir)) return false; + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (matchInterceptConvention(entry.name)) continue; + if (entry.name.startsWith("_")) continue; + + const subDir = path.join(dir, entry.name); + if (findFile(subDir, "page", matcher)) return true; + if (scan(subDir)) return true; + } + return false; + } + + return scan(slotDir); +} + /** * Convert a file path relative to app/ into an AppRoute. */ @@ -625,7 +644,9 @@ function discoverInheritedParallelSlots( /** * Discover parallel route slots (@team, @analytics, etc.) in a directory. - * Returns a ParallelSlot for each @-prefixed subdirectory that has a page or default component. + * Returns a ParallelSlot for each @-prefixed subdirectory that participates in routing. + * A slot can be routable via its own page/default, intercepting routes, or nested pages + * that create synthetic sub-routes like /inbox/profile from @modal/profile/page.tsx. */ function discoverParallelSlots( dir: string, @@ -646,9 +667,13 @@ function discoverParallelSlots( const pagePath = findFile(slotDir, "page", matcher); const defaultPath = findFile(slotDir, "default", matcher); const interceptingRoutes = discoverInterceptingRoutes(slotDir, dir, appDir, matcher); + const hasNestedSubPages = hasNestedSlotSubPages(slotDir, matcher); - // Only include slots that have at least a page, default, or intercepting route - if (!pagePath && !defaultPath && interceptingRoutes.length === 0) continue; + // Keep slots that only define nested pages. Those nested pages create + // additional URL routes even when the slot root itself has no page/default. + if (!pagePath && !defaultPath && interceptingRoutes.length === 0 && !hasNestedSubPages) { + continue; + } slots.push({ name: slotName, diff --git a/tests/routing.test.ts b/tests/routing.test.ts index 792f7608..7eab1233 100644 --- a/tests/routing.test.ts +++ b/tests/routing.test.ts @@ -476,6 +476,37 @@ describe("appRouter - route discovery", () => { }); }); + it("discovers nested @slot sub-routes even when the slot root has no own page or default", async () => { + await withTempDir("vinext-app-slot-nested-only-", async (tmpDir) => { + const appDir = path.join(tmpDir, "app"); + await mkdir(path.join(appDir, "inbox", "@modal", "profile"), { recursive: true }); + await writeFile(path.join(appDir, "inbox", "page.tsx"), EMPTY_PAGE); + await writeFile(path.join(appDir, "inbox", "@modal", "profile", "page.tsx"), EMPTY_PAGE); + + invalidateAppRouteCache(); + const routes = await appRouter(appDir); + const patterns = routes.map((route) => route.pattern); + + expect(patterns).toContain("/inbox"); + expect(patterns).toContain("/inbox/profile"); + + const inboxRoute = routes.find((route) => route.pattern === "/inbox"); + expect(inboxRoute).toBeDefined(); + expect(inboxRoute!.parallelSlots).toHaveLength(1); + expect(inboxRoute!.parallelSlots[0].name).toBe("modal"); + expect(inboxRoute!.parallelSlots[0].pagePath).toBeNull(); + expect(inboxRoute!.parallelSlots[0].defaultPath).toBeNull(); + + const match = matchAppRoute("/inbox/profile", routes); + expect(match).not.toBeNull(); + expect(match!.route.pattern).toBe("/inbox/profile"); + + const modalSlot = match!.route.parallelSlots.find((slot) => slot.name === "modal"); + expect(modalSlot).toBeDefined(); + expect(modalSlot!.pagePath).toContain(path.join("inbox", "@modal", "profile", "page.tsx")); + }); + }); + it("rejects non-terminal catch-all intercept targets", async () => { await withTempDir("vinext-app-intercept-nonterminal-catchall-", async (tmpDir) => { const appDir = path.join(tmpDir, "app"); From a1d4c0292c69fd396c2dca16da66ebebd5c673a1 Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Wed, 11 Mar 2026 01:26:05 -0500 Subject: [PATCH 2/3] Fix nested parallel slot discovery --- packages/vinext/src/routing/app-router.ts | 34 +++-------------------- tests/routing.test.ts | 23 ++++----------- 2 files changed, 9 insertions(+), 48 deletions(-) diff --git a/packages/vinext/src/routing/app-router.ts b/packages/vinext/src/routing/app-router.ts index f5f8491b..b1525f50 100644 --- a/packages/vinext/src/routing/app-router.ts +++ b/packages/vinext/src/routing/app-router.ts @@ -236,10 +236,9 @@ function discoverSlotSubRoutes( } } - if (subPathMap.size === 0) continue; - // Find the default.tsx for the children slot at the parent directory const childrenDefault = findFile(parentPageDir, "default", matcher); + if (subPathMap.size === 0 || !childrenDefault) continue; for (const [subPath, slotPages] of subPathMap) { // Convert sub-path segments to URL pattern parts @@ -331,25 +330,6 @@ function findSlotSubPages( return results; } -function hasNestedSlotSubPages(slotDir: string, matcher: ValidFileMatcher): boolean { - function scan(dir: string): boolean { - if (!fs.existsSync(dir)) return false; - const entries = fs.readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (matchInterceptConvention(entry.name)) continue; - if (entry.name.startsWith("_")) continue; - - const subDir = path.join(dir, entry.name); - if (findFile(subDir, "page", matcher)) return true; - if (scan(subDir)) return true; - } - return false; - } - - return scan(slotDir); -} - /** * Convert a file path relative to app/ into an AppRoute. */ @@ -644,9 +624,7 @@ function discoverInheritedParallelSlots( /** * Discover parallel route slots (@team, @analytics, etc.) in a directory. - * Returns a ParallelSlot for each @-prefixed subdirectory that participates in routing. - * A slot can be routable via its own page/default, intercepting routes, or nested pages - * that create synthetic sub-routes like /inbox/profile from @modal/profile/page.tsx. + * Returns a ParallelSlot for each @-prefixed subdirectory that has a page or default component. */ function discoverParallelSlots( dir: string, @@ -667,13 +645,9 @@ function discoverParallelSlots( const pagePath = findFile(slotDir, "page", matcher); const defaultPath = findFile(slotDir, "default", matcher); const interceptingRoutes = discoverInterceptingRoutes(slotDir, dir, appDir, matcher); - const hasNestedSubPages = hasNestedSlotSubPages(slotDir, matcher); - // Keep slots that only define nested pages. Those nested pages create - // additional URL routes even when the slot root itself has no page/default. - if (!pagePath && !defaultPath && interceptingRoutes.length === 0 && !hasNestedSubPages) { - continue; - } + // Only include slots that have at least a page, default, or intercepting route + if (!pagePath && !defaultPath && interceptingRoutes.length === 0) continue; slots.push({ name: slotName, diff --git a/tests/routing.test.ts b/tests/routing.test.ts index 7eab1233..85a5393e 100644 --- a/tests/routing.test.ts +++ b/tests/routing.test.ts @@ -476,11 +476,12 @@ describe("appRouter - route discovery", () => { }); }); - it("discovers nested @slot sub-routes even when the slot root has no own page or default", async () => { - await withTempDir("vinext-app-slot-nested-only-", async (tmpDir) => { + it("does not create nested @slot sub-routes without a children default fallback", async () => { + await withTempDir("vinext-app-slot-missing-children-default-", async (tmpDir) => { const appDir = path.join(tmpDir, "app"); await mkdir(path.join(appDir, "inbox", "@modal", "profile"), { recursive: true }); await writeFile(path.join(appDir, "inbox", "page.tsx"), EMPTY_PAGE); + await writeFile(path.join(appDir, "inbox", "@modal", "default.tsx"), EMPTY_PAGE); await writeFile(path.join(appDir, "inbox", "@modal", "profile", "page.tsx"), EMPTY_PAGE); invalidateAppRouteCache(); @@ -488,22 +489,8 @@ describe("appRouter - route discovery", () => { const patterns = routes.map((route) => route.pattern); expect(patterns).toContain("/inbox"); - expect(patterns).toContain("/inbox/profile"); - - const inboxRoute = routes.find((route) => route.pattern === "/inbox"); - expect(inboxRoute).toBeDefined(); - expect(inboxRoute!.parallelSlots).toHaveLength(1); - expect(inboxRoute!.parallelSlots[0].name).toBe("modal"); - expect(inboxRoute!.parallelSlots[0].pagePath).toBeNull(); - expect(inboxRoute!.parallelSlots[0].defaultPath).toBeNull(); - - const match = matchAppRoute("/inbox/profile", routes); - expect(match).not.toBeNull(); - expect(match!.route.pattern).toBe("/inbox/profile"); - - const modalSlot = match!.route.parallelSlots.find((slot) => slot.name === "modal"); - expect(modalSlot).toBeDefined(); - expect(modalSlot!.pagePath).toContain(path.join("inbox", "@modal", "profile", "page.tsx")); + expect(patterns).not.toContain("/inbox/profile"); + expect(matchAppRoute("/inbox/profile", routes)).toBeNull(); }); }); From 151082ca82cfafd08542f8acd148ef3618426731 Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Wed, 11 Mar 2026 18:48:05 -0500 Subject: [PATCH 3/3] Fix parallel slot routing parity --- packages/vinext/src/routing/app-router.ts | 4 +++- tests/routing.test.ts | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/vinext/src/routing/app-router.ts b/packages/vinext/src/routing/app-router.ts index b1525f50..7df1afc6 100644 --- a/packages/vinext/src/routing/app-router.ts +++ b/packages/vinext/src/routing/app-router.ts @@ -236,9 +236,11 @@ function discoverSlotSubRoutes( } } + if (subPathMap.size === 0) continue; + // Find the default.tsx for the children slot at the parent directory const childrenDefault = findFile(parentPageDir, "default", matcher); - if (subPathMap.size === 0 || !childrenDefault) continue; + if (!childrenDefault) continue; for (const [subPath, slotPages] of subPathMap) { // Convert sub-path segments to URL pattern parts diff --git a/tests/routing.test.ts b/tests/routing.test.ts index 85a5393e..82433382 100644 --- a/tests/routing.test.ts +++ b/tests/routing.test.ts @@ -494,6 +494,24 @@ describe("appRouter - route discovery", () => { }); }); + it("does not discover nested @slot sub-routes when the slot root has no page or default", async () => { + await withTempDir("vinext-app-slot-nested-only-rootless-", async (tmpDir) => { + const appDir = path.join(tmpDir, "app"); + await mkdir(path.join(appDir, "inbox", "@modal", "profile"), { recursive: true }); + await writeFile(path.join(appDir, "inbox", "page.tsx"), EMPTY_PAGE); + await writeFile(path.join(appDir, "inbox", "default.tsx"), EMPTY_PAGE); + await writeFile(path.join(appDir, "inbox", "@modal", "profile", "page.tsx"), EMPTY_PAGE); + + invalidateAppRouteCache(); + const routes = await appRouter(appDir); + const patterns = routes.map((route) => route.pattern); + + expect(patterns).toContain("/inbox"); + expect(patterns).not.toContain("/inbox/profile"); + expect(matchAppRoute("/inbox/profile", routes)).toBeNull(); + }); + }); + it("rejects non-terminal catch-all intercept targets", async () => { await withTempDir("vinext-app-intercept-nonterminal-catchall-", async (tmpDir) => { const appDir = path.join(tmpDir, "app");