Skip to content

Commit

Permalink
support breadcrumb style catch-all parallel routes (#65063)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
ztanner committed Oct 4, 2024
1 parent 381d1f9 commit 1b72af9
Show file tree
Hide file tree
Showing 10 changed files with 163 additions and 4 deletions.
17 changes: 13 additions & 4 deletions packages/next/src/server/app-render/app-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -236,15 +236,24 @@ function makeGetDynamicParamFromSegment(
}

if (!value) {
// Handle case where optional catchall does not have a value, e.g. `/dashboard/[...slug]` when requesting `/dashboard`
if (segmentParam.type === 'optional-catchall') {
// Handle case where optional catchall does not have a value, e.g. `/dashboard/[[...slug]]` when requesting `/dashboard`
if (
segmentParam.type === 'optional-catchall' ||
segmentParam.type === 'catchall'
) {
// If we weren't able to match the segment to a URL param, and we have a catch-all route,
// provide all of the known params (in array format) to the route
// It should be safe to assume the order of these params is consistent with the order of the segments.
// However, if not, we could re-parse the `pagePath` with `getRouteRegex` and iterate over the positional order.
value = Object.values(params).map((i) => encodeURIComponent(i))
const hasValues = value.length > 0
const type = dynamicParamTypes[segmentParam.type]
return {
param: key,
value: null,
value: hasValues ? value : null,
type: type,
// This value always has to be a string.
treeSegment: [key, '', type],
treeSegment: [key, hasValues ? value.join('/') : '', type],
}
}
return findDynamicParamFromRouterState(flightRouterState, segment)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export default function Page({ params: { catchAll } }) {
return (
<div id="slot">
<h1>Parallel Route!</h1>
<ul>
<li>Artist: {catchAll[0]}</li>
<li>Album: {catchAll[1] ?? 'Select an album'}</li>
<li>Track: {catchAll[2] ?? 'Select a track'}</li>
</ul>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Page() {
return null
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import Link from 'next/link'

export default function Page({ params }) {
return (
<div>
<h2>Track: {params.track}</h2>
<Link href={`/${params.artist}/${params.album}`}>Back to album</Link>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import Link from 'next/link'

export default function Page({ params }) {
const tracks = ['track1', 'track2', 'track3']
return (
<div>
<h2>Album: {params.album}</h2>
<ul>
{tracks.map((track) => (
<li key={track}>
<Link href={`/${params.artist}/${params.album}/${track}`}>
{track}
</Link>
</li>
))}
</ul>
<Link href={`/${params.artist}`}>Back to artist</Link>
</div>
)
}
17 changes: 17 additions & 0 deletions test/e2e/app-dir/parallel-routes-breadcrumbs/app/[artist]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import Link from 'next/link'

export default function Page({ params }) {
const albums = ['album1', 'album2', 'album3']
return (
<div>
<h2>Artist: {params.artist}</h2>
<ul>
{albums.map((album) => (
<li key={album}>
<Link href={`/${params.artist}/${album}`}>{album}</Link>
</li>
))}
</ul>
</div>
)
}
18 changes: 18 additions & 0 deletions test/e2e/app-dir/parallel-routes-breadcrumbs/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import React from 'react'

export default function Root({
children,
slot,
}: {
children: React.ReactNode
slot: React.ReactNode
}) {
return (
<html>
<body>
<div id="slot">{slot}</div>
<div id="children">{children}</div>
</body>
</html>
)
}
17 changes: 17 additions & 0 deletions test/e2e/app-dir/parallel-routes-breadcrumbs/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import Link from 'next/link'

export default async function Home() {
const artists = ['artist1', 'artist2', 'artist3']
return (
<div>
<h1>Artists</h1>
<ul>
{artists.map((artist) => (
<li key={artist}>
<Link href={`/${artist}`}>{artist}</Link>
</li>
))}
</ul>
</div>
)
}
6 changes: 6 additions & 0 deletions test/e2e/app-dir/parallel-routes-breadcrumbs/next.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {}

module.exports = nextConfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { nextTestSetup } from 'e2e-utils'
import { retry } from 'next-test-utils'

describe('parallel-routes-breadcrumbs', () => {
const { next } = nextTestSetup({
files: __dirname,
})

it('should provide an unmatched catch-all route with params', async () => {
const browser = await next.browser('/')
await browser.elementByCss("[href='/artist1']").click()

const slot = await browser.waitForElementByCss('#slot')

// verify page is rendering the params
expect(await browser.elementByCss('h2').text()).toBe('Artist: artist1')

// verify slot is rendering the params
expect(await slot.text()).toContain('Artist: artist1')
expect(await slot.text()).toContain('Album: Select an album')
expect(await slot.text()).toContain('Track: Select a track')

await browser.elementByCss("[href='/artist1/album2']").click()

await retry(async () => {
// verify page is rendering the params
expect(await browser.elementByCss('h2').text()).toBe('Album: album2')
})

// verify slot is rendering the params
expect(await slot.text()).toContain('Artist: artist1')
expect(await slot.text()).toContain('Album: album2')
expect(await slot.text()).toContain('Track: Select a track')

await browser.elementByCss("[href='/artist1/album2/track3']").click()

await retry(async () => {
// verify page is rendering the params
expect(await browser.elementByCss('h2').text()).toBe('Track: track3')
})

// verify slot is rendering the params
expect(await slot.text()).toContain('Artist: artist1')
expect(await slot.text()).toContain('Album: album2')
expect(await slot.text()).toContain('Track: track3')
})
})

0 comments on commit 1b72af9

Please sign in to comment.