diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx
index f72112b8ecaf5..7b8248491a20b 100644
--- a/packages/next/src/server/app-render/app-render.tsx
+++ b/packages/next/src/server/app-render/app-render.tsx
@@ -111,6 +111,7 @@ import {
} from '../client-component-renderer-logger'
import { createServerModuleMap } from './action-utils'
import type { DeepReadonly } from '../../shared/lib/deep-readonly'
+import { parseParameter } from '../../shared/lib/router/utils/route-regex'
export type GetDynamicParamFromSegment = (
// [slug] / [[slug]] / [...slug]
@@ -209,6 +210,7 @@ export type CreateSegmentPath = (child: FlightSegmentPath) => FlightSegmentPath
*/
function makeGetDynamicParamFromSegment(
params: { [key: string]: any },
+ pagePath: string,
flightRouterState: FlightRouterState | undefined
): GetDynamicParamFromSegment {
return function getDynamicParamFromSegment(
@@ -236,17 +238,46 @@ 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') {
- const type = dynamicParamTypes[segmentParam.type]
+ const isCatchall = segmentParam.type === 'catchall'
+ const isOptionalCatchall = segmentParam.type === 'optional-catchall'
+
+ if (isCatchall || isOptionalCatchall) {
+ const dynamicParamType = dynamicParamTypes[segmentParam.type]
+ // handle the case where an optional catchall does not have a value,
+ // e.g. `/dashboard/[[...slug]]` when requesting `/dashboard`
+ if (isOptionalCatchall) {
+ return {
+ param: key,
+ value: null,
+ type: dynamicParamType,
+ treeSegment: [key, '', dynamicParamType],
+ }
+ }
+
+ // handle the case where a catchall or optional catchall does not have a value,
+ // e.g. `/foo/bar/hello` and `@slot/[...catchall]` or `@slot/[[...catchall]]` is matched
+ value = pagePath
+ .split('/')
+ // remove the first empty string
+ .slice(1)
+ // replace any dynamic params with the actual values
+ .map((pathSegment) => {
+ const param = parseParameter(pathSegment)
+
+ // if the segment matches a param, return the param value
+ // otherwise, it's a static segment, so just return that
+ return params[param.key] ?? param.key
+ })
+
return {
param: key,
- value: null,
- type: type,
+ value,
+ type: dynamicParamType,
// This value always has to be a string.
- treeSegment: [key, '', type],
+ treeSegment: [key, value.join('/'), dynamicParamType],
}
}
+
return findDynamicParamFromRouterState(flightRouterState, segment)
}
@@ -809,6 +840,7 @@ async function renderToHTMLOrFlightImpl(
const getDynamicParamFromSegment = makeGetDynamicParamFromSegment(
params,
+ pagePath,
// `FlightRouterState` is unconditionally provided here because this method uses it
// to extract dynamic params as a fallback if they're not present in the path.
parsedFlightRouterState
diff --git a/packages/next/src/shared/lib/router/utils/route-regex.ts b/packages/next/src/shared/lib/router/utils/route-regex.ts
index 5ec7668fd90f6..a556a5295ebc1 100644
--- a/packages/next/src/shared/lib/router/utils/route-regex.ts
+++ b/packages/next/src/shared/lib/router/utils/route-regex.ts
@@ -24,7 +24,7 @@ export interface RouteRegex {
* - `[foo]` -> `{ key: 'foo', repeat: false, optional: true }`
* - `bar` -> `{ key: 'bar', repeat: false, optional: false }`
*/
-function parseParameter(param: string) {
+export function parseParameter(param: string) {
const optional = param.startsWith('[') && param.endsWith(']')
if (optional) {
param = param.slice(1, -1)
diff --git a/test/e2e/app-dir/parallel-routes-breadcrumbs/app/@slot/[...catchAll]/page.tsx b/test/e2e/app-dir/parallel-routes-breadcrumbs/app/@slot/[...catchAll]/page.tsx
new file mode 100644
index 0000000000000..bb230623f6c6a
--- /dev/null
+++ b/test/e2e/app-dir/parallel-routes-breadcrumbs/app/@slot/[...catchAll]/page.tsx
@@ -0,0 +1,12 @@
+export default function Page({ params: { catchAll = [] } }) {
+ return (
+
+
Parallel Route!
+
+ - Artist: {catchAll[0]}
+ - Album: {catchAll[1] ?? 'Select an album'}
+ - Track: {catchAll[2] ?? 'Select a track'}
+
+
+ )
+}
diff --git a/test/e2e/app-dir/parallel-routes-breadcrumbs/app/@slot/default.tsx b/test/e2e/app-dir/parallel-routes-breadcrumbs/app/@slot/default.tsx
new file mode 100644
index 0000000000000..c17431379f962
--- /dev/null
+++ b/test/e2e/app-dir/parallel-routes-breadcrumbs/app/@slot/default.tsx
@@ -0,0 +1,3 @@
+export default function Page() {
+ return null
+}
diff --git a/test/e2e/app-dir/parallel-routes-breadcrumbs/app/[artist]/[album]/[track]/page.tsx b/test/e2e/app-dir/parallel-routes-breadcrumbs/app/[artist]/[album]/[track]/page.tsx
new file mode 100644
index 0000000000000..c957176c3daf6
--- /dev/null
+++ b/test/e2e/app-dir/parallel-routes-breadcrumbs/app/[artist]/[album]/[track]/page.tsx
@@ -0,0 +1,10 @@
+import Link from 'next/link'
+
+export default function Page({ params }) {
+ return (
+
+
Track: {params.track}
+ Back to album
+
+ )
+}
diff --git a/test/e2e/app-dir/parallel-routes-breadcrumbs/app/[artist]/[album]/page.tsx b/test/e2e/app-dir/parallel-routes-breadcrumbs/app/[artist]/[album]/page.tsx
new file mode 100644
index 0000000000000..29184ed8c515f
--- /dev/null
+++ b/test/e2e/app-dir/parallel-routes-breadcrumbs/app/[artist]/[album]/page.tsx
@@ -0,0 +1,20 @@
+import Link from 'next/link'
+
+export default function Page({ params }) {
+ const tracks = ['track1', 'track2', 'track3']
+ return (
+
+
Album: {params.album}
+
+ {tracks.map((track) => (
+ -
+
+ {track}
+
+
+ ))}
+
+
Back to artist
+
+ )
+}
diff --git a/test/e2e/app-dir/parallel-routes-breadcrumbs/app/[artist]/page.tsx b/test/e2e/app-dir/parallel-routes-breadcrumbs/app/[artist]/page.tsx
new file mode 100644
index 0000000000000..7396f3914b7fd
--- /dev/null
+++ b/test/e2e/app-dir/parallel-routes-breadcrumbs/app/[artist]/page.tsx
@@ -0,0 +1,17 @@
+import Link from 'next/link'
+
+export default function Page({ params }) {
+ const albums = ['album1', 'album2', 'album3']
+ return (
+
+
Artist: {params.artist}
+
+ {albums.map((album) => (
+ -
+ {album}
+
+ ))}
+
+
+ )
+}
diff --git a/test/e2e/app-dir/parallel-routes-breadcrumbs/app/foo/[lang]/bar/page.tsx b/test/e2e/app-dir/parallel-routes-breadcrumbs/app/foo/[lang]/bar/page.tsx
new file mode 100644
index 0000000000000..63c35624b21cd
--- /dev/null
+++ b/test/e2e/app-dir/parallel-routes-breadcrumbs/app/foo/[lang]/bar/page.tsx
@@ -0,0 +1,7 @@
+export default function StaticPage() {
+ return (
+
+
/foo/[lang]/bar Page!
+
+ )
+}
diff --git a/test/e2e/app-dir/parallel-routes-breadcrumbs/app/layout.tsx b/test/e2e/app-dir/parallel-routes-breadcrumbs/app/layout.tsx
new file mode 100644
index 0000000000000..83d72bedde130
--- /dev/null
+++ b/test/e2e/app-dir/parallel-routes-breadcrumbs/app/layout.tsx
@@ -0,0 +1,18 @@
+import React from 'react'
+
+export default function Root({
+ children,
+ slot,
+}: {
+ children: React.ReactNode
+ slot: React.ReactNode
+}) {
+ return (
+
+
+ {slot}
+ {children}
+
+
+ )
+}
diff --git a/test/e2e/app-dir/parallel-routes-breadcrumbs/app/page.tsx b/test/e2e/app-dir/parallel-routes-breadcrumbs/app/page.tsx
new file mode 100644
index 0000000000000..5c3feea5964d4
--- /dev/null
+++ b/test/e2e/app-dir/parallel-routes-breadcrumbs/app/page.tsx
@@ -0,0 +1,17 @@
+import Link from 'next/link'
+
+export default async function Home() {
+ const artists = ['artist1', 'artist2', 'artist3']
+ return (
+
+
Artists
+
+ {artists.map((artist) => (
+ -
+ {artist}
+
+ ))}
+
+
+ )
+}
diff --git a/test/e2e/app-dir/parallel-routes-breadcrumbs/next.config.js b/test/e2e/app-dir/parallel-routes-breadcrumbs/next.config.js
new file mode 100644
index 0000000000000..807126e4cf0bf
--- /dev/null
+++ b/test/e2e/app-dir/parallel-routes-breadcrumbs/next.config.js
@@ -0,0 +1,6 @@
+/**
+ * @type {import('next').NextConfig}
+ */
+const nextConfig = {}
+
+module.exports = nextConfig
diff --git a/test/e2e/app-dir/parallel-routes-breadcrumbs/parallel-routes-breadcrumbs.test.ts b/test/e2e/app-dir/parallel-routes-breadcrumbs/parallel-routes-breadcrumbs.test.ts
new file mode 100644
index 0000000000000..e1e09ab7f1825
--- /dev/null
+++ b/test/e2e/app-dir/parallel-routes-breadcrumbs/parallel-routes-breadcrumbs.test.ts
@@ -0,0 +1,62 @@
+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')
+ })
+
+ it('should render the breadcrumbs correctly with the non-dynamic route segments', async () => {
+ const browser = await next.browser('/foo/en/bar')
+ const slot = await browser.waitForElementByCss('#slot')
+
+ expect(await browser.elementByCss('h1').text()).toBe('Parallel Route!')
+ expect(await browser.elementByCss('h2').text()).toBe(
+ '/foo/[lang]/bar Page!'
+ )
+
+ // verify slot is rendering the params
+ expect(await slot.text()).toContain('Artist: foo')
+ expect(await slot.text()).toContain('Album: en')
+ expect(await slot.text()).toContain('Track: bar')
+ })
+})