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..1937f6604 100644 --- a/packages/vinext/src/routing/app-router.ts +++ b/packages/vinext/src/routing/app-router.ts @@ -131,6 +131,18 @@ export function invalidateAppRouteCache(): void { cachedPageExtensionsKey = null; } +export function collectInterceptTargetPatterns(routes: readonly AppRoute[]): string[] { + return [ + ...new Set( + routes.flatMap((route) => + route.parallelSlots.flatMap((slot) => + slot.interceptingRoutes.map((intercept) => intercept.targetPattern), + ), + ), + ), + ]; +} + /** * Scan the app/ directory and return a list of routes. */ @@ -173,13 +185,7 @@ 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), - ), - ), - ); + validateRoutePatterns(collectInterceptTargetPatterns(routes)); // Sort: static routes first, then dynamic, then catch-all routes.sort(compareRoutes); @@ -237,7 +243,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 new file mode 100644 index 000000000..cc2b98ad4 --- /dev/null +++ b/tests/intercepting-routes-build.test.ts @@ -0,0 +1,153 @@ +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 () => { + // 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); + + 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..e4711b0d1 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, @@ -1076,6 +1077,109 @@ 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("dedupes repeated intercepting target patterns", () => { + const firstRoute = makeTestAppRoute("/feed", ["feed"]); + 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 = [ + { + name: "modal", + ownerDir: "/tmp/app/profile/@modal", + pagePath: null, + defaultPath: null, + layoutPath: null, + loadingPath: null, + errorPath: null, + interceptingRoutes: [ + { + convention: "..", + targetPattern: "/photo/:id", + pagePath: "/tmp/app/profile/@modal/(..)photo/[id]/page.tsx", + params: ["id"], + }, + ], + layoutIndex: -1, + }, + ]; + + expect(collectInterceptTargetPatterns([firstRoute, secondRoute])).toEqual(["/photo/:id"]); + }); + it("intercepting route pages are not standalone routes", async () => { invalidateAppRouteCache(); const routes = await appRouter(APP_FIXTURE_DIR);