From 3fa9df7c2cd2cef238c079224c056e945d8125c0 Mon Sep 17 00:00:00 2001 From: Brendan Dash Date: Mon, 30 Mar 2026 20:27:44 +0800 Subject: [PATCH 1/3] fix: allow inherited intercepting routes to share target patterns --- .gitignore | 1 + .../app/_internal/_data.ts | 6 + .../@modal/(.)photo/[id]/page.tsx | 73 +++++++++ .../intercepting-routes/@modal/default.tsx | 3 + .../app/intercepting-routes/layout.tsx | 46 ++++++ .../app/intercepting-routes/page.tsx | 33 ++++ .../intercepting-routes/photo/[id]/page.tsx | 57 +++++++ .../app/intercepting-routes/readme.mdx | 7 + packages/vinext/src/routing/app-router.ts | 19 ++- tests/intercepting-routes-build.test.ts | 150 ++++++++++++++++++ tests/routing.test.ts | 54 +++++++ 11 files changed, 442 insertions(+), 7 deletions(-) create mode 100644 examples/app-router-playground/app/intercepting-routes/@modal/(.)photo/[id]/page.tsx create mode 100644 examples/app-router-playground/app/intercepting-routes/@modal/default.tsx create mode 100644 examples/app-router-playground/app/intercepting-routes/layout.tsx create mode 100644 examples/app-router-playground/app/intercepting-routes/page.tsx create mode 100644 examples/app-router-playground/app/intercepting-routes/photo/[id]/page.tsx create mode 100644 examples/app-router-playground/app/intercepting-routes/readme.mdx create mode 100644 tests/intercepting-routes-build.test.ts diff --git a/.gitignore b/.gitignore index 91fde374f..9d843637b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ dist/ out/ *.tsbuildinfo .vite/ +.vinext/ .turbo/ .ecosystem-test/ .next/ diff --git a/examples/app-router-playground/app/_internal/_data.ts b/examples/app-router-playground/app/_internal/_data.ts index 5006bbf48..9e736d334 100644 --- a/examples/app-router-playground/app/_internal/_data.ts +++ b/examples/app-router-playground/app/_internal/_data.ts @@ -126,6 +126,12 @@ const demos = [ name: 'Parallel Routes', description: 'Render multiple pages in the same layout', }, + { + slug: 'intercepting-routes', + name: 'Intercepting Routes', + description: + 'Open a detail route as a modal during navigation while preserving the shareable URL', + }, ], }, { diff --git a/examples/app-router-playground/app/intercepting-routes/@modal/(.)photo/[id]/page.tsx b/examples/app-router-playground/app/intercepting-routes/@modal/(.)photo/[id]/page.tsx new file mode 100644 index 000000000..3ca45f5a4 --- /dev/null +++ b/examples/app-router-playground/app/intercepting-routes/@modal/(.)photo/[id]/page.tsx @@ -0,0 +1,73 @@ +import db from '#/lib/db'; +import { Boundary } from '#/ui/boundary'; +import { XMarkIcon } from '@heroicons/react/24/solid'; +import Image from 'next/image'; +import Link from 'next/link'; +import { notFound } from 'next/navigation'; + +export default async function Page({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + const product = db.product.find({ where: { id } }); + if (!product) { + notFound(); + } + + return ( +
+ +
+
+
+ Intercepted in modal +
+

{product.name}

+
+ + + + +
+ +
+
+ {product.name} +
+ +
+

+ The browser URL is already pointing at the product detail page, but + the source gallery stays mounted underneath because the navigation + was intercepted by the parallel slot. +

+
+ ${product.price.toFixed(2)} +
+
+ Refresh this URL to see the standalone detail page instead. +
+
+
+
+
+ ); +} diff --git a/examples/app-router-playground/app/intercepting-routes/@modal/default.tsx b/examples/app-router-playground/app/intercepting-routes/@modal/default.tsx new file mode 100644 index 000000000..6ddf1b76f --- /dev/null +++ b/examples/app-router-playground/app/intercepting-routes/@modal/default.tsx @@ -0,0 +1,3 @@ +export default function Default() { + return null; +} diff --git a/examples/app-router-playground/app/intercepting-routes/layout.tsx b/examples/app-router-playground/app/intercepting-routes/layout.tsx new file mode 100644 index 000000000..c526da23d --- /dev/null +++ b/examples/app-router-playground/app/intercepting-routes/layout.tsx @@ -0,0 +1,46 @@ +'use cache'; + +import db from '#/lib/db'; +import { Mdx } from '#/ui/codehike'; +import { Boundary } from '#/ui/boundary'; +import { type Metadata } from 'next'; +import React from 'react'; +import readme from './readme.mdx'; + +export async function generateMetadata(): Promise { + const demo = db.demo.find({ where: { slug: 'intercepting-routes' } }); + + return { + title: demo.name, + openGraph: { title: demo.name, images: [`/api/og?title=${demo.name}`] }, + }; +} + +export default function Layout({ + children, + modal, +}: { + children: React.ReactNode; + modal: React.ReactNode; +}) { + return ( + <> + + + + +
+ + {children} + + + {modal} +
+ + ); +} diff --git a/examples/app-router-playground/app/intercepting-routes/page.tsx b/examples/app-router-playground/app/intercepting-routes/page.tsx new file mode 100644 index 000000000..f4c11b13d --- /dev/null +++ b/examples/app-router-playground/app/intercepting-routes/page.tsx @@ -0,0 +1,33 @@ +import db from '#/lib/db'; +import { Boundary } from '#/ui/boundary'; +import { ProductCard, ProductList } from '#/ui/product-card'; +import Link from 'next/link'; + +export default function Page() { + const products = db.product.findMany({ limit: 6 }); + + return ( + +
+

+ Product gallery with modal interception +

+

+ This route stays visible while the target URL updates to a nested detail + page. A direct load of the same URL renders the standalone detail page. +

+
+ + + {products.map((product) => ( + + + + ))} + +
+ ); +} diff --git a/examples/app-router-playground/app/intercepting-routes/photo/[id]/page.tsx b/examples/app-router-playground/app/intercepting-routes/photo/[id]/page.tsx new file mode 100644 index 000000000..245a421a9 --- /dev/null +++ b/examples/app-router-playground/app/intercepting-routes/photo/[id]/page.tsx @@ -0,0 +1,57 @@ +import db from '#/lib/db'; +import { Boundary } from '#/ui/boundary'; +import { ChevronLeftIcon } from '@heroicons/react/24/solid'; +import Image from 'next/image'; +import Link from 'next/link'; +import { notFound } from 'next/navigation'; + +export default async function Page({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + const product = db.product.find({ where: { id } }); + if (!product) { + notFound(); + } + + return ( + + + + Back to gallery + + +
+
+ {product.name} +
+ +
+
+ Direct visit +
+

{product.name}

+

+ Loading this URL directly should render the standalone page. Navigating + from the gallery should keep the gallery visible and render this content + in the parallel modal slot instead. +

+
+ ${product.price.toFixed(2)} +
+
+
+
+ ); +} diff --git a/examples/app-router-playground/app/intercepting-routes/readme.mdx b/examples/app-router-playground/app/intercepting-routes/readme.mdx new file mode 100644 index 000000000..abee6bbd3 --- /dev/null +++ b/examples/app-router-playground/app/intercepting-routes/readme.mdx @@ -0,0 +1,7 @@ +Intercepting Routes let you mask a target URL with the current layout during navigation. + +- Click a product card below to navigate to its detail URL. +- During in-app navigation, `@modal/(.)photo/[id]` should render inside the modal slot. +- If you load the detail URL directly, `photo/[id]/page.tsx` renders as the full page instead. + +- [Docs](https://nextjs.org/docs/app/api-reference/file-conventions/intercepting-routes) diff --git a/packages/vinext/src/routing/app-router.ts b/packages/vinext/src/routing/app-router.ts index c9142a36a..d5ac86ab6 100644 --- a/packages/vinext/src/routing/app-router.ts +++ b/packages/vinext/src/routing/app-router.ts @@ -173,13 +173,18 @@ export async function appRouter( routes.push(...slotSubRoutes); validateRoutePatterns(routes.map((route) => route.pattern)); - validateRoutePatterns( - routes.flatMap((route) => - route.parallelSlots.flatMap((slot) => - slot.interceptingRoutes.map((intercept) => intercept.targetPattern), - ), - ), - ); + const uniqueInterceptTargetPatterns = new Map(); + for (const route of routes) { + for (const slot of route.parallelSlots) { + for (const intercept of slot.interceptingRoutes) { + // Inherited slots can surface the same intercepting page on multiple + // child routes. De-dupe by page path so we only validate distinct + // physical intercept definitions while still rejecting real conflicts. + uniqueInterceptTargetPatterns.set(intercept.pagePath, intercept.targetPattern); + } + } + } + validateRoutePatterns(Array.from(uniqueInterceptTargetPatterns.values())); // Sort: static routes first, then dynamic, then catch-all routes.sort(compareRoutes); diff --git a/tests/intercepting-routes-build.test.ts b/tests/intercepting-routes-build.test.ts new file mode 100644 index 000000000..68205a110 --- /dev/null +++ b/tests/intercepting-routes-build.test.ts @@ -0,0 +1,150 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createBuilder } from "vite"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import vinext from "../packages/vinext/src/index.js"; + +const tmpDirs: string[] = []; + +function writeFixtureFile(root: string, filePath: string, content: string) { + const absPath = path.join(root, filePath); + fs.mkdirSync(path.dirname(absPath), { recursive: true }); + fs.writeFileSync(absPath, content); +} + +async function buildApp(root: string) { + const builder = await createBuilder({ + root, + configFile: false, + plugins: [vinext({ appDir: root })], + logLevel: "silent", + }); + await builder.buildApp(); +} + +describe("App Router intercepting routes in production builds", () => { + afterEach(() => { + for (const dir of tmpDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("builds when an inherited modal slot intercepts the same target route as a standalone page", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-intercept-build-")); + tmpDirs.push(root); + + fs.symlinkSync( + path.resolve(import.meta.dirname, "../node_modules"), + path.join(root, "node_modules"), + "junction", + ); + + writeFixtureFile( + root, + "package.json", + JSON.stringify({ name: "vinext-intercept-build", private: true, type: "module" }, null, 2), + ); + writeFixtureFile( + root, + "tsconfig.json", + JSON.stringify( + { + compilerOptions: { + target: "ES2022", + module: "ESNext", + moduleResolution: "bundler", + jsx: "react-jsx", + strict: true, + skipLibCheck: true, + types: ["vite/client", "@vitejs/plugin-rsc/types"], + }, + include: ["app", "*.ts", "*.tsx"], + }, + null, + 2, + ), + ); + writeFixtureFile( + root, + "app/layout.tsx", + `import type { ReactNode } from "react"; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} +`, + ); + writeFixtureFile( + root, + "app/page.tsx", + `export default function HomePage() { + return
home
; +} +`, + ); + writeFixtureFile( + root, + "app/intercepting-routes/layout.tsx", + `import type { ReactNode } from "react"; + +export default function InterceptingLayout({ + children, + modal, +}: { + children: ReactNode; + modal: ReactNode; +}) { + return ( + <> +
{children}
+ {modal} + + ); +} +`, + ); + writeFixtureFile( + root, + "app/intercepting-routes/page.tsx", + `export default function GalleryPage() { + return
gallery
; +} +`, + ); + writeFixtureFile( + root, + "app/intercepting-routes/photo/[id]/page.tsx", + `export default function PhotoPage() { + return
standalone photo page
; +} +`, + ); + writeFixtureFile( + root, + "app/intercepting-routes/@modal/default.tsx", + `export default function ModalDefault() { + return null; +} +`, + ); + writeFixtureFile( + root, + "app/intercepting-routes/@modal/(.)photo/[id]/page.tsx", + `export default function PhotoModalPage() { + return
photo modal
; +} +`, + ); + + await buildApp(root); + + expect(fs.existsSync(path.join(root, "dist", "server", "index.js"))).toBe(true); + expect(fs.existsSync(path.join(root, "dist", "server", "ssr", "index.js"))).toBe(true); + expect(fs.existsSync(path.join(root, "dist", "client"))).toBe(true); + }, 60_000); +}); diff --git a/tests/routing.test.ts b/tests/routing.test.ts index b9edfa0f9..39f34b4b7 100644 --- a/tests/routing.test.ts +++ b/tests/routing.test.ts @@ -1076,6 +1076,60 @@ describe("matchAppRoute - URL matching", () => { expect(intercept.pagePath).toContain("(...)photos"); }); + it("allows inherited intercepting slots to reuse the same target pattern", async () => { + await withTempDir("vinext-app-intercept-inherited-slot-", async (tmpDir) => { + const appDir = path.join(tmpDir, "app"); + + await mkdir(path.join(appDir, "intercepting-routes", "@modal", "(.)photo", "[id]"), { + recursive: true, + }); + await mkdir(path.join(appDir, "intercepting-routes", "photo", "[id]"), { + recursive: true, + }); + + await writeFile(path.join(appDir, "layout.tsx"), EMPTY_PAGE); + await writeFile(path.join(appDir, "page.tsx"), EMPTY_PAGE); + await writeFile(path.join(appDir, "intercepting-routes", "layout.tsx"), EMPTY_PAGE); + await writeFile(path.join(appDir, "intercepting-routes", "page.tsx"), EMPTY_PAGE); + await writeFile( + path.join(appDir, "intercepting-routes", "photo", "[id]", "page.tsx"), + EMPTY_PAGE, + ); + await writeFile( + path.join(appDir, "intercepting-routes", "@modal", "default.tsx"), + EMPTY_PAGE, + ); + await writeFile( + path.join(appDir, "intercepting-routes", "@modal", "(.)photo", "[id]", "page.tsx"), + EMPTY_PAGE, + ); + + invalidateAppRouteCache(); + const routes = await appRouter(appDir); + + const galleryRoute = routes.find((route) => route.pattern === "/intercepting-routes"); + const detailRoute = routes.find( + (route) => route.pattern === "/intercepting-routes/photo/:id", + ); + + expect(galleryRoute).toBeDefined(); + expect(detailRoute).toBeDefined(); + + const galleryModal = galleryRoute!.parallelSlots.find((slot) => slot.name === "modal"); + const detailModal = detailRoute!.parallelSlots.find((slot) => slot.name === "modal"); + + expect(galleryModal?.interceptingRoutes[0]?.targetPattern).toBe( + "/intercepting-routes/photo/:id", + ); + expect(detailModal?.interceptingRoutes[0]?.targetPattern).toBe( + "/intercepting-routes/photo/:id", + ); + expect(detailModal?.interceptingRoutes[0]?.pagePath).toBe( + galleryModal?.interceptingRoutes[0]?.pagePath, + ); + }); + }); + it("intercepting route pages are not standalone routes", async () => { invalidateAppRouteCache(); const routes = await appRouter(APP_FIXTURE_DIR); From 60b37960fcd45cc3fa365ebf6a3be9fc367fa24c Mon Sep 17 00:00:00 2001 From: Brendan Dash Date: Mon, 30 Mar 2026 21:31:47 +0800 Subject: [PATCH 2/3] fix: validate intercepting route target consistency per page --- packages/vinext/src/routing/app-router.ts | 47 ++++++++++++++++------- tests/intercepting-routes-build.test.ts | 3 ++ tests/routing.test.ts | 42 ++++++++++++++++++++ 3 files changed, 79 insertions(+), 13 deletions(-) diff --git a/packages/vinext/src/routing/app-router.ts b/packages/vinext/src/routing/app-router.ts index d5ac86ab6..84df44f1b 100644 --- a/packages/vinext/src/routing/app-router.ts +++ b/packages/vinext/src/routing/app-router.ts @@ -131,6 +131,34 @@ export function invalidateAppRouteCache(): void { cachedPageExtensionsKey = null; } +export function collectInterceptTargetPatterns(routes: readonly AppRoute[]): string[] { + const uniqueInterceptTargetPatterns = new Map(); + + for (const route of routes) { + for (const slot of route.parallelSlots) { + for (const intercept of slot.interceptingRoutes) { + const existingTargetPattern = uniqueInterceptTargetPatterns.get(intercept.pagePath); + + if ( + existingTargetPattern !== undefined && + existingTargetPattern !== intercept.targetPattern + ) { + throw new Error( + `Intercepting route ${intercept.pagePath} resolves to multiple target patterns (${existingTargetPattern} and ${intercept.targetPattern}).`, + ); + } + + // Inherited slots can surface the same intercepting page on multiple + // child routes. De-dupe by page path so we only validate distinct + // physical intercept definitions while still rejecting real conflicts. + uniqueInterceptTargetPatterns.set(intercept.pagePath, intercept.targetPattern); + } + } + } + + return Array.from(uniqueInterceptTargetPatterns.values()); +} + /** * Scan the app/ directory and return a list of routes. */ @@ -173,18 +201,7 @@ export async function appRouter( routes.push(...slotSubRoutes); validateRoutePatterns(routes.map((route) => route.pattern)); - const uniqueInterceptTargetPatterns = new Map(); - for (const route of routes) { - for (const slot of route.parallelSlots) { - for (const intercept of slot.interceptingRoutes) { - // Inherited slots can surface the same intercepting page on multiple - // child routes. De-dupe by page path so we only validate distinct - // physical intercept definitions while still rejecting real conflicts. - uniqueInterceptTargetPatterns.set(intercept.pagePath, intercept.targetPattern); - } - } - } - validateRoutePatterns(Array.from(uniqueInterceptTargetPatterns.values())); + validateRoutePatterns(collectInterceptTargetPatterns(routes)); // Sort: static routes first, then dynamic, then catch-all routes.sort(compareRoutes); @@ -242,7 +259,11 @@ function discoverSlotSubRoutes( // 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 }; + converted: { + urlSegments: string[]; + params: string[]; + isDynamic: boolean; + }; slotPages: Map; } >(); diff --git a/tests/intercepting-routes-build.test.ts b/tests/intercepting-routes-build.test.ts index 68205a110..cc2b98ad4 100644 --- a/tests/intercepting-routes-build.test.ts +++ b/tests/intercepting-routes-build.test.ts @@ -31,6 +31,9 @@ describe("App Router intercepting routes in production builds", () => { }); it("builds when an inherited modal slot intercepts the same target route as a standalone page", async () => { + // Ported from Next.js route interception behavior: + // test/e2e/app-dir/parallel-routes-and-interception/parallel-routes-and-interception.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/parallel-routes-and-interception/parallel-routes-and-interception.test.ts const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-intercept-build-")); tmpDirs.push(root); diff --git a/tests/routing.test.ts b/tests/routing.test.ts index 39f34b4b7..c7714ad21 100644 --- a/tests/routing.test.ts +++ b/tests/routing.test.ts @@ -10,6 +10,7 @@ import { } from "../packages/vinext/src/routing/pages-router.js"; import { appRouter, + collectInterceptTargetPatterns, matchAppRoute, invalidateAppRouteCache, type AppRoute, @@ -1130,6 +1131,47 @@ describe("matchAppRoute - URL matching", () => { }); }); + it("rejects a single intercepting page resolving to multiple target patterns", () => { + const firstRoute = makeTestAppRoute("/feed", ["feed"]); + const secondRoute = makeTestAppRoute("/feed/nested", ["feed", "nested"]); + + const slot = { + name: "modal", + ownerDir: "/tmp/app/feed/@modal", + pagePath: null, + defaultPath: null, + layoutPath: null, + loadingPath: null, + errorPath: null, + interceptingRoutes: [ + { + convention: ".", + targetPattern: "/photos/:id", + pagePath: "/tmp/app/feed/@modal/(.)photo/[id]/page.tsx", + params: ["id"], + }, + ], + layoutIndex: -1, + } satisfies AppRoute["parallelSlots"][number]; + + firstRoute.parallelSlots = [slot]; + secondRoute.parallelSlots = [ + { + ...slot, + interceptingRoutes: [ + { + ...slot.interceptingRoutes[0], + targetPattern: "/photos/:slug", + }, + ], + }, + ]; + + expect(() => collectInterceptTargetPatterns([firstRoute, secondRoute])).toThrow( + /resolves to multiple target patterns/, + ); + }); + it("intercepting route pages are not standalone routes", async () => { invalidateAppRouteCache(); const routes = await appRouter(APP_FIXTURE_DIR); From 3b05f17aae6e94b83b326d1f9b1edb0ce1abc23f Mon Sep 17 00:00:00 2001 From: Brendan Dash Date: Mon, 30 Mar 2026 23:26:47 +0800 Subject: [PATCH 3/3] refactor: simplify intercept target pattern collection by using Set for deduplication --- packages/vinext/src/routing/app-router.ts | 34 ++++-------- tests/routing.test.ts | 66 +++++++++++++---------- 2 files changed, 46 insertions(+), 54 deletions(-) diff --git a/packages/vinext/src/routing/app-router.ts b/packages/vinext/src/routing/app-router.ts index 84df44f1b..1937f6604 100644 --- a/packages/vinext/src/routing/app-router.ts +++ b/packages/vinext/src/routing/app-router.ts @@ -132,31 +132,15 @@ export function invalidateAppRouteCache(): void { } export function collectInterceptTargetPatterns(routes: readonly AppRoute[]): string[] { - const uniqueInterceptTargetPatterns = new Map(); - - for (const route of routes) { - for (const slot of route.parallelSlots) { - for (const intercept of slot.interceptingRoutes) { - const existingTargetPattern = uniqueInterceptTargetPatterns.get(intercept.pagePath); - - if ( - existingTargetPattern !== undefined && - existingTargetPattern !== intercept.targetPattern - ) { - throw new Error( - `Intercepting route ${intercept.pagePath} resolves to multiple target patterns (${existingTargetPattern} and ${intercept.targetPattern}).`, - ); - } - - // Inherited slots can surface the same intercepting page on multiple - // child routes. De-dupe by page path so we only validate distinct - // physical intercept definitions while still rejecting real conflicts. - uniqueInterceptTargetPatterns.set(intercept.pagePath, intercept.targetPattern); - } - } - } - - return Array.from(uniqueInterceptTargetPatterns.values()); + return [ + ...new Set( + routes.flatMap((route) => + route.parallelSlots.flatMap((slot) => + slot.interceptingRoutes.map((intercept) => intercept.targetPattern), + ), + ), + ), + ]; } /** diff --git a/tests/routing.test.ts b/tests/routing.test.ts index c7714ad21..e4711b0d1 100644 --- a/tests/routing.test.ts +++ b/tests/routing.test.ts @@ -1131,45 +1131,53 @@ describe("matchAppRoute - URL matching", () => { }); }); - it("rejects a single intercepting page resolving to multiple target patterns", () => { + it("dedupes repeated intercepting target patterns", () => { const firstRoute = makeTestAppRoute("/feed", ["feed"]); - const secondRoute = makeTestAppRoute("/feed/nested", ["feed", "nested"]); - - const slot = { - name: "modal", - ownerDir: "/tmp/app/feed/@modal", - pagePath: null, - defaultPath: null, - layoutPath: null, - loadingPath: null, - errorPath: null, - interceptingRoutes: [ - { - convention: ".", - targetPattern: "/photos/:id", - pagePath: "/tmp/app/feed/@modal/(.)photo/[id]/page.tsx", - params: ["id"], - }, - ], - layoutIndex: -1, - } satisfies AppRoute["parallelSlots"][number]; - - firstRoute.parallelSlots = [slot]; + const secondRoute = makeTestAppRoute("/profile", ["profile"]); + + firstRoute.parallelSlots = [ + { + name: "modal", + ownerDir: "/tmp/app/feed/@modal", + pagePath: null, + defaultPath: null, + layoutPath: null, + loadingPath: null, + errorPath: null, + interceptingRoutes: [ + { + convention: "..", + targetPattern: "/photo/:id", + pagePath: "/tmp/app/feed/@modal/(..)photo/[id]/page.tsx", + params: ["id"], + }, + ], + layoutIndex: -1, + }, + ]; + secondRoute.parallelSlots = [ { - ...slot, + name: "modal", + ownerDir: "/tmp/app/profile/@modal", + pagePath: null, + defaultPath: null, + layoutPath: null, + loadingPath: null, + errorPath: null, interceptingRoutes: [ { - ...slot.interceptingRoutes[0], - targetPattern: "/photos/:slug", + convention: "..", + targetPattern: "/photo/:id", + pagePath: "/tmp/app/profile/@modal/(..)photo/[id]/page.tsx", + params: ["id"], }, ], + layoutIndex: -1, }, ]; - expect(() => collectInterceptTargetPatterns([firstRoute, secondRoute])).toThrow( - /resolves to multiple target patterns/, - ); + expect(collectInterceptTargetPatterns([firstRoute, secondRoute])).toEqual(["/photo/:id"]); }); it("intercepting route pages are not standalone routes", async () => {