Skip to content

Commit 540ea2d

Browse files
ztanneragadzik
andauthored
backport: support breadcrumb style catch-all parallel routes (#65063) (#70794)
Backports: - #65063 - #65233 --------- Co-authored-by: Andrew Gadzik <[email protected]>
1 parent 0d0448b commit 540ea2d

File tree

12 files changed

+211
-7
lines changed

12 files changed

+211
-7
lines changed

packages/next/src/server/app-render/app-render.tsx

+38-6
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ import {
111111
} from '../client-component-renderer-logger'
112112
import { createServerModuleMap } from './action-utils'
113113
import type { DeepReadonly } from '../../shared/lib/deep-readonly'
114+
import { parseParameter } from '../../shared/lib/router/utils/route-regex'
114115

115116
export type GetDynamicParamFromSegment = (
116117
// [slug] / [[slug]] / [...slug]
@@ -209,6 +210,7 @@ export type CreateSegmentPath = (child: FlightSegmentPath) => FlightSegmentPath
209210
*/
210211
function makeGetDynamicParamFromSegment(
211212
params: { [key: string]: any },
213+
pagePath: string,
212214
flightRouterState: FlightRouterState | undefined
213215
): GetDynamicParamFromSegment {
214216
return function getDynamicParamFromSegment(
@@ -236,17 +238,46 @@ function makeGetDynamicParamFromSegment(
236238
}
237239

238240
if (!value) {
239-
// Handle case where optional catchall does not have a value, e.g. `/dashboard/[...slug]` when requesting `/dashboard`
240-
if (segmentParam.type === 'optional-catchall') {
241-
const type = dynamicParamTypes[segmentParam.type]
241+
const isCatchall = segmentParam.type === 'catchall'
242+
const isOptionalCatchall = segmentParam.type === 'optional-catchall'
243+
244+
if (isCatchall || isOptionalCatchall) {
245+
const dynamicParamType = dynamicParamTypes[segmentParam.type]
246+
// handle the case where an optional catchall does not have a value,
247+
// e.g. `/dashboard/[[...slug]]` when requesting `/dashboard`
248+
if (isOptionalCatchall) {
249+
return {
250+
param: key,
251+
value: null,
252+
type: dynamicParamType,
253+
treeSegment: [key, '', dynamicParamType],
254+
}
255+
}
256+
257+
// handle the case where a catchall or optional catchall does not have a value,
258+
// e.g. `/foo/bar/hello` and `@slot/[...catchall]` or `@slot/[[...catchall]]` is matched
259+
value = pagePath
260+
.split('/')
261+
// remove the first empty string
262+
.slice(1)
263+
// replace any dynamic params with the actual values
264+
.map((pathSegment) => {
265+
const param = parseParameter(pathSegment)
266+
267+
// if the segment matches a param, return the param value
268+
// otherwise, it's a static segment, so just return that
269+
return params[param.key] ?? param.key
270+
})
271+
242272
return {
243273
param: key,
244-
value: null,
245-
type: type,
274+
value,
275+
type: dynamicParamType,
246276
// This value always has to be a string.
247-
treeSegment: [key, '', type],
277+
treeSegment: [key, value.join('/'), dynamicParamType],
248278
}
249279
}
280+
250281
return findDynamicParamFromRouterState(flightRouterState, segment)
251282
}
252283

@@ -809,6 +840,7 @@ async function renderToHTMLOrFlightImpl(
809840

810841
const getDynamicParamFromSegment = makeGetDynamicParamFromSegment(
811842
params,
843+
pagePath,
812844
// `FlightRouterState` is unconditionally provided here because this method uses it
813845
// to extract dynamic params as a fallback if they're not present in the path.
814846
parsedFlightRouterState

packages/next/src/shared/lib/router/utils/route-regex.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export interface RouteRegex {
2424
* - `[foo]` -> `{ key: 'foo', repeat: false, optional: true }`
2525
* - `bar` -> `{ key: 'bar', repeat: false, optional: false }`
2626
*/
27-
function parseParameter(param: string) {
27+
export function parseParameter(param: string) {
2828
const optional = param.startsWith('[') && param.endsWith(']')
2929
if (optional) {
3030
param = param.slice(1, -1)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export default function Page({ params: { catchAll = [] } }) {
2+
return (
3+
<div id="slot">
4+
<h1>Parallel Route!</h1>
5+
<ul>
6+
<li>Artist: {catchAll[0]}</li>
7+
<li>Album: {catchAll[1] ?? 'Select an album'}</li>
8+
<li>Track: {catchAll[2] ?? 'Select a track'}</li>
9+
</ul>
10+
</div>
11+
)
12+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function Page() {
2+
return null
3+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import Link from 'next/link'
2+
3+
export default function Page({ params }) {
4+
return (
5+
<div>
6+
<h2>Track: {params.track}</h2>
7+
<Link href={`/${params.artist}/${params.album}`}>Back to album</Link>
8+
</div>
9+
)
10+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import Link from 'next/link'
2+
3+
export default function Page({ params }) {
4+
const tracks = ['track1', 'track2', 'track3']
5+
return (
6+
<div>
7+
<h2>Album: {params.album}</h2>
8+
<ul>
9+
{tracks.map((track) => (
10+
<li key={track}>
11+
<Link href={`/${params.artist}/${params.album}/${track}`}>
12+
{track}
13+
</Link>
14+
</li>
15+
))}
16+
</ul>
17+
<Link href={`/${params.artist}`}>Back to artist</Link>
18+
</div>
19+
)
20+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import Link from 'next/link'
2+
3+
export default function Page({ params }) {
4+
const albums = ['album1', 'album2', 'album3']
5+
return (
6+
<div>
7+
<h2>Artist: {params.artist}</h2>
8+
<ul>
9+
{albums.map((album) => (
10+
<li key={album}>
11+
<Link href={`/${params.artist}/${album}`}>{album}</Link>
12+
</li>
13+
))}
14+
</ul>
15+
</div>
16+
)
17+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export default function StaticPage() {
2+
return (
3+
<div>
4+
<h2>/foo/[lang]/bar Page!</h2>
5+
</div>
6+
)
7+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import React from 'react'
2+
3+
export default function Root({
4+
children,
5+
slot,
6+
}: {
7+
children: React.ReactNode
8+
slot: React.ReactNode
9+
}) {
10+
return (
11+
<html>
12+
<body>
13+
<div id="slot">{slot}</div>
14+
<div id="children">{children}</div>
15+
</body>
16+
</html>
17+
)
18+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import Link from 'next/link'
2+
3+
export default async function Home() {
4+
const artists = ['artist1', 'artist2', 'artist3']
5+
return (
6+
<div>
7+
<h1>Artists</h1>
8+
<ul>
9+
{artists.map((artist) => (
10+
<li key={artist}>
11+
<Link href={`/${artist}`}>{artist}</Link>
12+
</li>
13+
))}
14+
</ul>
15+
</div>
16+
)
17+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/**
2+
* @type {import('next').NextConfig}
3+
*/
4+
const nextConfig = {}
5+
6+
module.exports = nextConfig
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { nextTestSetup } from 'e2e-utils'
2+
import { retry } from 'next-test-utils'
3+
4+
describe('parallel-routes-breadcrumbs', () => {
5+
const { next } = nextTestSetup({
6+
files: __dirname,
7+
})
8+
9+
it('should provide an unmatched catch-all route with params', async () => {
10+
const browser = await next.browser('/')
11+
await browser.elementByCss("[href='/artist1']").click()
12+
13+
const slot = await browser.waitForElementByCss('#slot')
14+
15+
// verify page is rendering the params
16+
expect(await browser.elementByCss('h2').text()).toBe('Artist: artist1')
17+
18+
// verify slot is rendering the params
19+
expect(await slot.text()).toContain('Artist: artist1')
20+
expect(await slot.text()).toContain('Album: Select an album')
21+
expect(await slot.text()).toContain('Track: Select a track')
22+
23+
await browser.elementByCss("[href='/artist1/album2']").click()
24+
25+
await retry(async () => {
26+
// verify page is rendering the params
27+
expect(await browser.elementByCss('h2').text()).toBe('Album: album2')
28+
})
29+
30+
// verify slot is rendering the params
31+
expect(await slot.text()).toContain('Artist: artist1')
32+
expect(await slot.text()).toContain('Album: album2')
33+
expect(await slot.text()).toContain('Track: Select a track')
34+
35+
await browser.elementByCss("[href='/artist1/album2/track3']").click()
36+
37+
await retry(async () => {
38+
// verify page is rendering the params
39+
expect(await browser.elementByCss('h2').text()).toBe('Track: track3')
40+
})
41+
42+
// verify slot is rendering the params
43+
expect(await slot.text()).toContain('Artist: artist1')
44+
expect(await slot.text()).toContain('Album: album2')
45+
expect(await slot.text()).toContain('Track: track3')
46+
})
47+
48+
it('should render the breadcrumbs correctly with the non-dynamic route segments', async () => {
49+
const browser = await next.browser('/foo/en/bar')
50+
const slot = await browser.waitForElementByCss('#slot')
51+
52+
expect(await browser.elementByCss('h1').text()).toBe('Parallel Route!')
53+
expect(await browser.elementByCss('h2').text()).toBe(
54+
'/foo/[lang]/bar Page!'
55+
)
56+
57+
// verify slot is rendering the params
58+
expect(await slot.text()).toContain('Artist: foo')
59+
expect(await slot.text()).toContain('Album: en')
60+
expect(await slot.text()).toContain('Track: bar')
61+
})
62+
})

0 commit comments

Comments
 (0)