-
Notifications
You must be signed in to change notification settings - Fork 282
fix: allow inherited intercepting routes to share target patterns #721
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ dist/ | |
| out/ | ||
| *.tsbuildinfo | ||
| .vite/ | ||
| .vinext/ | ||
| .turbo/ | ||
| .ecosystem-test/ | ||
| .next/ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <div className="pointer-events-none fixed inset-0 z-20 flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm"> | ||
| <Boundary | ||
| label="@modal/(.)photo/[id]/page.tsx" | ||
| color="cyan" | ||
| kind="solid" | ||
| animateRerendering={false} | ||
| className="pointer-events-auto flex w-full max-w-3xl flex-col gap-6 rounded-2xl bg-gray-950" | ||
| > | ||
| <div className="flex items-start justify-between gap-4"> | ||
| <div className="flex flex-col gap-2"> | ||
| <div className="text-sm uppercase tracking-[0.2em] text-cyan-300"> | ||
| Intercepted in modal | ||
| </div> | ||
| <h2 className="text-2xl font-semibold text-white">{product.name}</h2> | ||
| </div> | ||
|
|
||
| <Link | ||
| href="/intercepting-routes" | ||
| className="rounded-full border border-gray-800 p-2 text-gray-400 hover:border-gray-700 hover:text-white" | ||
| aria-label="Close modal" | ||
| > | ||
| <XMarkIcon className="size-5" /> | ||
| </Link> | ||
| </div> | ||
|
|
||
| <div className="grid gap-6 lg:grid-cols-[minmax(0,16rem)_1fr]"> | ||
| <div className="overflow-hidden rounded-2xl bg-gray-900/60 p-6"> | ||
| <Image | ||
| src={`/shop/${product.image}`} | ||
| alt={product.name} | ||
| width={320} | ||
| height={320} | ||
| className="mx-auto brightness-150" | ||
| /> | ||
| </div> | ||
|
|
||
| <div className="flex flex-col gap-4"> | ||
| <p className="text-sm leading-6 text-gray-400"> | ||
| 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. | ||
| </p> | ||
| <div className="font-mono text-sm text-cyan-300"> | ||
| ${product.price.toFixed(2)} | ||
| </div> | ||
| <div className="text-xs text-gray-500"> | ||
| Refresh this URL to see the standalone detail page instead. | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </Boundary> | ||
| </div> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| export default function Default() { | ||
| return null; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| 'use cache'; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is Other layouts in the playground (e.g. |
||
|
|
||
| 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<Metadata> { | ||
| 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 ( | ||
| <> | ||
| <Boundary label="Demo" kind="solid" animateRerendering={false}> | ||
| <Mdx source={readme} collapsed={true} /> | ||
| </Boundary> | ||
|
|
||
| <div className="relative flex flex-col gap-6"> | ||
| <Boundary | ||
| label="layout.tsx" | ||
| kind="solid" | ||
| animateRerendering={false} | ||
| className="flex flex-col gap-6" | ||
| > | ||
| {children} | ||
| </Boundary> | ||
|
|
||
| {modal} | ||
| </div> | ||
| </> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <Boundary label="page.tsx" size="small" className="flex flex-col gap-5"> | ||
| <div className="flex flex-col gap-2"> | ||
| <h1 className="text-xl font-semibold text-gray-200"> | ||
| Product gallery with modal interception | ||
| </h1> | ||
| <p className="max-w-2xl text-sm text-gray-400"> | ||
| 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. | ||
| </p> | ||
| </div> | ||
|
|
||
| <ProductList title="Products" count={products.length}> | ||
| {products.map((product) => ( | ||
| <Link key={product.id} href={`/intercepting-routes/photo/${product.id}`}> | ||
| <ProductCard | ||
| product={product} | ||
| className="rounded-xl border border-transparent p-2 transition hover:border-gray-800" | ||
| /> | ||
| </Link> | ||
| ))} | ||
| </ProductList> | ||
| </Boundary> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -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 ( | ||||||
| <Boundary label="photo/[id]/page.tsx" className="flex flex-col gap-6"> | ||||||
| <Link | ||||||
| href="/intercepting-routes" | ||||||
| className="inline-flex items-center gap-2 text-sm font-medium text-gray-400 hover:text-white" | ||||||
| > | ||||||
| <ChevronLeftIcon className="size-4" /> | ||||||
| Back to gallery | ||||||
| </Link> | ||||||
|
|
||||||
| <div className="grid gap-6 lg:grid-cols-[minmax(0,20rem)_1fr]"> | ||||||
| <div className="overflow-hidden rounded-2xl bg-gray-900/60 p-8"> | ||||||
| <Image | ||||||
| src={`/shop/${product.image}`} | ||||||
| alt={product.name} | ||||||
| width={400} | ||||||
| height={400} | ||||||
| className="mx-auto brightness-150" | ||||||
| /> | ||||||
| </div> | ||||||
|
|
||||||
| <div className="flex flex-col gap-4"> | ||||||
| <div className="text-sm uppercase tracking-[0.2em] text-gray-500"> | ||||||
| Direct visit | ||||||
| </div> | ||||||
| <h1 className="text-3xl font-semibold text-white">{product.name}</h1> | ||||||
| <p className="max-w-xl text-sm leading-6 text-gray-400"> | ||||||
| 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. | ||||||
| </p> | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same nit as the modal page — this JSX
Suggested change
|
||||||
| <div className="font-mono text-sm text-cyan-300"> | ||||||
| ${product.price.toFixed(2)} | ||||||
| </div> | ||||||
| </div> | ||||||
| </div> | ||||||
| </Boundary> | ||||||
| ); | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -131,6 +131,18 @@ export function invalidateAppRouteCache(): void { | |
| cachedPageExtensionsKey = null; | ||
| } | ||
|
|
||
| export function collectInterceptTargetPatterns(routes: readonly AppRoute[]): string[] { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Exporting this is good — it makes the de-duplication logic directly unit-testable (which the new test exercises). One thing to consider: the function name says "collect" but it also de-duplicates. A name like |
||
| 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<string, string>; | ||
| } | ||
| >(); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: This renders a literal
$followed by the price. In JSX,$is not interpolated, so this renders correctly as$1.99etc. — but it reads confusingly like a template literal that's missing backticks. Consider wrapping in braces for clarity: