Fix an issue parsing catchall params (#65277)

This should fix the following scenarios,

- Given a page defined like `app/foo/[...bar]/page.tsx`
- Given a page defined like `app/bar/[[...foo]]/page.tsx`
- Given a parallel route defined like `app/@slot/[...catchall]/page.tsx`

If you navigate to `/foo/bar` the `params` prop in the parallel route
would be

```js
params: {
  catchall: [
    'foo',
    [ 'bar' ]
  ]
}
```

And if you navigate to `/bar/foo` the `params` prop in the parallel
route would be

```js
params: {
  catchall: [
    'bar',
    '[ ...foo ]'
  ]
}
```

With the fix in place, the `params` prop in the parallel route will be,

```js
params: {
  catchall: [
    'foo',
    'bar',
  ]
}
```

And

```js
params: {
  catchall: [
    'bar',
    'foo',
  ]
}
```

Respectively
This commit is contained in:
Andrew Gadzik 2024-05-02 14:31:39 -04:00 committed by GitHub
parent 06c5ea4f16
commit 89ad612165
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 130 additions and 9 deletions

View file

@ -259,9 +259,8 @@ function makeGetDynamicParamFromSegment(
// remove the first empty string
.slice(1)
// replace any dynamic params with the actual values
.map((pathSegment) => {
.flatMap((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

View file

@ -1,4 +1,5 @@
import { getNamedRouteRegex } from './route-regex'
import { parseParameter } from './route-regex'
describe('getNamedRouteRegex', () => {
it('should handle interception markers adjacent to dynamic path segments', () => {
@ -109,3 +110,40 @@ describe('getNamedRouteRegex', () => {
expect(regex.re.test('/photos')).toBe(true)
})
})
describe('parseParameter', () => {
it('should parse a optional catchall parameter', () => {
const param = '[[...slug]]'
const expected = { key: 'slug', repeat: true, optional: true }
const result = parseParameter(param)
expect(result).toEqual(expected)
})
it('should parse a catchall parameter', () => {
const param = '[...slug]'
const expected = { key: 'slug', repeat: true, optional: false }
const result = parseParameter(param)
expect(result).toEqual(expected)
})
it('should parse a optional parameter', () => {
const param = '[[foo]]'
const expected = { key: 'foo', repeat: false, optional: true }
const result = parseParameter(param)
expect(result).toEqual(expected)
})
it('should parse a dynamic parameter', () => {
const param = '[bar]'
const expected = { key: 'bar', repeat: false, optional: false }
const result = parseParameter(param)
expect(result).toEqual(expected)
})
it('should parse non-dynamic parameter', () => {
const param = 'fizz'
const expected = { key: 'fizz', repeat: false, optional: false }
const result = parseParameter(param)
expect(result).toEqual(expected)
})
})

View file

@ -17,15 +17,51 @@ export interface RouteRegex {
re: RegExp
}
/**
* Regular expression pattern used to match route parameters.
* Matches both single parameters and parameter groups.
* Examples:
* - `[[...slug]]` matches parameter group with key 'slug', repeat: true, optional: true
* - `[...slug]` matches parameter group with key 'slug', repeat: true, optional: false
* - `[[foo]]` matches parameter with key 'foo', repeat: false, optional: true
* - `[bar]` matches parameter with key 'bar', repeat: false, optional: false
*/
const PARAMETER_PATTERN = /\[((?:\[.*\])|.+)\]/
/**
* Parses a given parameter from a route to a data structure that can be used
* to generate the parametrized route. Examples:
* to generate the parametrized route.
* Examples:
* - `[[...slug]]` -> `{ key: 'slug', repeat: true, optional: true }`
* - `[...slug]` -> `{ key: 'slug', repeat: true, optional: false }`
* - `[[foo]]` -> `{ key: 'foo', repeat: false, optional: true }`
* - `[bar]` -> `{ key: 'bar', repeat: false, optional: false }`
* - `fizz` -> `{ key: 'fizz', repeat: false, optional: false }`
* @param param - The parameter to parse.
* @returns The parsed parameter as a data structure.
*/
export function parseParameter(param: string) {
const match = param.match(PARAMETER_PATTERN)
if (!match) {
return parseMatchedParameter(param)
}
return parseMatchedParameter(match[1])
}
/**
* Parses a matched parameter from the PARAMETER_PATTERN regex to a data structure that can be used
* to generate the parametrized route.
* Examples:
* - `[...slug]` -> `{ key: 'slug', repeat: true, optional: true }`
* - `...slug` -> `{ key: 'slug', repeat: true, optional: false }`
* - `[foo]` -> `{ key: 'foo', repeat: false, optional: true }`
* - `bar` -> `{ key: 'bar', repeat: false, optional: false }`
* @param param - The matched parameter to parse.
* @returns The parsed parameter as a data structure.
*/
export function parseParameter(param: string) {
function parseMatchedParameter(param: string) {
const optional = param.startsWith('[') && param.endsWith(']')
if (optional) {
param = param.slice(1, -1)
@ -47,14 +83,18 @@ function getParametrizedRoute(route: string) {
const markerMatch = INTERCEPTION_ROUTE_MARKERS.find((m) =>
segment.startsWith(m)
)
const paramMatches = segment.match(/\[((?:\[.*\])|.+)\]/) // Check for parameters
const paramMatches = segment.match(PARAMETER_PATTERN) // Check for parameters
if (markerMatch && paramMatches) {
const { key, optional, repeat } = parseParameter(paramMatches[1])
const { key, optional, repeat } = parseMatchedParameter(
paramMatches[1]
)
groups[key] = { pos: groupIndex++, repeat, optional }
return `/${escapeStringRegexp(markerMatch)}([^/]+?)`
} else if (paramMatches) {
const { key, repeat, optional } = parseParameter(paramMatches[1])
const { key, repeat, optional } = parseMatchedParameter(
paramMatches[1]
)
groups[key] = { pos: groupIndex++, repeat, optional }
return repeat ? (optional ? '(?:/(.+?))?' : '/(.+?)') : '/([^/]+?)'
} else {
@ -110,7 +150,7 @@ function getSafeKeyFromSegment({
routeKeys: Record<string, string>
keyPrefix?: string
}) {
const { key, optional, repeat } = parseParameter(segment)
const { key, optional, repeat } = parseMatchedParameter(segment)
// replace any non-word characters since they can break
// the named regex

View file

@ -0,0 +1,7 @@
export default function Page() {
return (
<div>
<h2>/buzz/[[...fizz]] Page!</h2>
</div>
)
}

View file

@ -0,0 +1,7 @@
export default function Page() {
return (
<div>
<h2>/fizz/[...buzz] Page!</h2>
</div>
)
}

View file

@ -1,4 +1,4 @@
export default function StaticPage() {
export default function Page() {
return (
<div>
<h2>/foo/[lang]/bar Page!</h2>

View file

@ -59,4 +59,34 @@ describe('parallel-routes-breadcrumbs', () => {
expect(await slot.text()).toContain('Album: en')
expect(await slot.text()).toContain('Track: bar')
})
it('should render the breadcrumbs correctly with catchall route segments', async () => {
const browser = await next.browser('/fizz/a/b')
const slot = await browser.waitForElementByCss('#slot')
expect(await browser.elementByCss('h1').text()).toBe('Parallel Route!')
expect(await browser.elementByCss('h2').text()).toBe(
'/fizz/[...buzz] Page!'
)
// verify slot is rendering the params
expect(await slot.text()).toContain('Artist: fizz')
expect(await slot.text()).toContain('Album: a')
expect(await slot.text()).toContain('Track: b')
})
it('should render the breadcrumbs correctly with optional catchall route segments', async () => {
const browser = await next.browser('/buzz/a/b')
const slot = await browser.waitForElementByCss('#slot')
expect(await browser.elementByCss('h1').text()).toBe('Parallel Route!')
expect(await browser.elementByCss('h2').text()).toBe(
'/buzz/[[...fizz]] Page!'
)
// verify slot is rendering the params
expect(await slot.text()).toContain('Artist: buzz')
expect(await slot.text()).toContain('Album: a')
expect(await slot.text()).toContain('Track: b')
})
})