Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ dist/
out/
*.tsbuildinfo
.vite/
.vinext/
.turbo/
.ecosystem-test/
.next/
Expand Down
6 changes: 6 additions & 0 deletions examples/app-router-playground/app/_internal/_data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
],
},
{
Expand Down
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)}
Copy link
Copy Markdown
Contributor

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.99 etc. — but it reads confusingly like a template literal that's missing backticks. Consider wrapping in braces for clarity:

Suggested change
${product.price.toFixed(2)}
{`$${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;
}
46 changes: 46 additions & 0 deletions examples/app-router-playground/app/intercepting-routes/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
'use cache';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is 'use cache' intentional here? This layout receives modal as a prop (a React node from a parallel slot). Caching a layout that renders dynamic slot content could produce stale modal content or cache key explosions depending on how the cache boundary interacts with RSC streaming.

Other layouts in the playground (e.g. parallel-routes/layout.tsx) don't use 'use cache'. If this was copied from another layout template, consider removing it.


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>
</>
);
}
33 changes: 33 additions & 0 deletions examples/app-router-playground/app/intercepting-routes/page.tsx
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>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same nit as the modal page — this JSX $ works but reads like a broken template literal:

Suggested change
</p>
{`$${product.price.toFixed(2)}`}

<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)
26 changes: 18 additions & 8 deletions packages/vinext/src/routing/app-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,18 @@ export function invalidateAppRouteCache(): void {
cachedPageExtensionsKey = null;
}

export function collectInterceptTargetPatterns(routes: readonly AppRoute[]): string[] {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 collectUniqueInterceptTargetPatterns would make the contract clearer, though this is a minor nit.

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.
*/
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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>;
}
>();
Expand Down
Loading
Loading