-
Notifications
You must be signed in to change notification settings - Fork 298
test: regression tests for inherited intercepting route slot deduplication #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
Changes from 2 commits
3fa9df7
60b3796
3b05f17
97882cb
e988757
90dcd9e
9479f3d
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'; | ||
|
||
|
|
||
| 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,34 @@ export function invalidateAppRouteCache(): void { | |||||
| cachedPageExtensionsKey = null; | ||||||
| } | ||||||
|
|
||||||
| export function collectInterceptTargetPatterns(routes: readonly AppRoute[]): string[] { | ||||||
|
||||||
| const uniqueInterceptTargetPatterns = new Map<string, string>(); | ||||||
|
||||||
| const uniqueInterceptTargetPatterns = new Map<string, string>(); | |
| const uniqueInterceptTargetPatterns = new Map<string, string>(); |
Actually the Map type is fine as-is — but consider de-duping the return value at line 159:
return [...new Set(uniqueInterceptTargetPatterns.values())];
This would handle the case where two different physical pages legitimately intercept the same target pattern (e.g. via (...) from different subtrees). Not a blocker for this PR, but a follow-up improvement.
Outdated
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.
Consider de-duplicating the returned values to handle the case where two different physical pages (different pagePath) legitimately intercept the same targetPattern:
| return Array.from(uniqueInterceptTargetPatterns.values()); | |
| return Array.from(new Set(uniqueInterceptTargetPatterns.values())); |
Without this, two different @modal slots in sibling subtrees both using (...) to intercept the same root-level route would still trigger a false positive from validateRoutePatterns.
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.
This is a formatting-only change (expanding the inline type literal to multi-line). It's fine stylistically but unrelated to the regression tests. Since the maintainer already noted the Set dedupe is merging in another PR, this formatting change may cause a merge conflict with that work. Consider dropping it to keep this PR focused on tests only.
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: