Skip to content

Commit 270a9db

Browse files
authored
support breadcrumb style catch-all parallel routes (#65063)
A common pattern for parallel routes is breadcrumbs. For example, if I have a lot of dynamic pages, and I want to render a parallel route that renders as a breadcrumb to enumerate those dynamic params, intuitively I'd reach for something like `app/@slot/[...allTheThings]/page.tsx`. Currently however, `[...allTheThings]` would only match params to a corresponding `app/[allTheThings]/page.tsx`. This makes it difficult to build the breadcrumbs use-case unless you re-create every single dynamic page in the parallel route as well. This adds handling to provide unmatched catch-all routes with all of the params that are known. For example, if I was on `/app/[artist]/[album]/[track]`, and I visited `/zack/greatest-hits/1`, the parallel `@slot` params would receive: `{ allTheThings: ['zack', 'greatest-hits', '1'] }` Fixes #62539 Closes NEXT-3230
1 parent aa086b8 commit 270a9db

File tree

10 files changed

+163
-4
lines changed

10 files changed

+163
-4
lines changed

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

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -234,15 +234,24 @@ function makeGetDynamicParamFromSegment(
234234
}
235235

236236
if (!value) {
237-
// Handle case where optional catchall does not have a value, e.g. `/dashboard/[...slug]` when requesting `/dashboard`
238-
if (segmentParam.type === 'optional-catchall') {
237+
// Handle case where optional catchall does not have a value, e.g. `/dashboard/[[...slug]]` when requesting `/dashboard`
238+
if (
239+
segmentParam.type === 'optional-catchall' ||
240+
segmentParam.type === 'catchall'
241+
) {
242+
// If we weren't able to match the segment to a URL param, and we have a catch-all route,
243+
// provide all of the known params (in array format) to the route
244+
// It should be safe to assume the order of these params is consistent with the order of the segments.
245+
// However, if not, we could re-parse the `pagePath` with `getRouteRegex` and iterate over the positional order.
246+
value = Object.values(params).map((i) => encodeURIComponent(i))
247+
const hasValues = value.length > 0
239248
const type = dynamicParamTypes[segmentParam.type]
240249
return {
241250
param: key,
242-
value: null,
251+
value: hasValues ? value : null,
243252
type: type,
244253
// This value always has to be a string.
245-
treeSegment: [key, '', type],
254+
treeSegment: [key, hasValues ? value.join('/') : '', type],
246255
}
247256
}
248257
return findDynamicParamFromRouterState(flightRouterState, segment)
Lines changed: 12 additions & 0 deletions
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+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function Page() {
2+
return null
3+
}
Lines changed: 10 additions & 0 deletions
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+
}
Lines changed: 20 additions & 0 deletions
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+
}
Lines changed: 17 additions & 0 deletions
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+
}
Lines changed: 18 additions & 0 deletions
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+
}
Lines changed: 17 additions & 0 deletions
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+
}
Lines changed: 6 additions & 0 deletions
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
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
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+
})

0 commit comments

Comments
 (0)