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!

+ +
+ ) +} 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}

+ + 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}

+ +
+ ) +} 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

+ +
+ ) +} 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') + }) +})