Skip to content
217 changes: 215 additions & 2 deletions packages/react-router/tests/Matches.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { expect, test } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, test } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { createMemoryHistory } from '@tanstack/history'
import {
Link,
Outlet,
Expand All @@ -8,6 +9,7 @@ import {
createRoute,
createRouter,
isMatch,
useMatchRoute,
useMatches,
} from '../src'

Expand Down Expand Up @@ -130,6 +132,7 @@ test('should show pendingComponent of root route', async () => {
},
component: () => <div data-testId="root-content" />,
})

const router = createRouter({
routeTree: root,
defaultPendingMs: 0,
Expand All @@ -141,3 +144,213 @@ test('should show pendingComponent of root route', async () => {
expect(await rendered.findByTestId('root-pending')).toBeInTheDocument()
expect(await rendered.findByTestId('root-content')).toBeInTheDocument()
})

describe('matching on different param types', () => {
const testCases = [
{
name: 'param with braces',
path: '/$id',
nav: '/1',
params: { id: '1' },
matchParams: { id: '1' },
},
{
name: 'param without braces',
path: '/{$id}',
nav: '/2',
params: { id: '2' },
matchParams: { id: '2' },
},
{
name: 'param with prefix',
path: '/prefix-{$id}',
nav: '/prefix-3',
params: { id: '3' },
matchParams: { id: '3' },
},
{
name: 'param with suffix',
path: '/{$id}-suffix',
nav: '/4-suffix',
params: { id: '4' },
matchParams: { id: '4' },
},
{
name: 'param with prefix and suffix',
path: '/prefix-{$id}-suffix',
nav: '/prefix-5-suffix',
params: { id: '5' },
matchParams: { id: '5' },
},
{
name: 'wildcard with no braces',
path: '/abc/$',
nav: '/abc/6',
params: { '*': '6', _splat: '6' },
matchParams: { '*': '6', _splat: '6' },
},
{
name: 'wildcard with braces',
path: '/abc/{$}',
nav: '/abc/7',
params: { '*': '7', _splat: '7' },
matchParams: { '*': '7', _splat: '7' },
},
{
name: 'wildcard with prefix',
path: '/abc/prefix{$}',
nav: '/abc/prefix/8',
params: { '*': '/8', _splat: '/8' },
matchParams: { '*': '/8', _splat: '/8' },
},
{
name: 'wildcard with suffix',
path: '/abc/{$}suffix',
nav: '/abc/9/suffix',
params: { _splat: '9/', '*': '9/' },
matchParams: { _splat: '9/', '*': '9/' },
},
{
name: 'optional param with no prefix/suffix and value',
path: '/abc/{-$id}/def',
nav: '/abc/10/def',
params: { id: '10' },
matchParams: { id: '10' },
},
{
name: 'optional param with no prefix/suffix and requiredParam and no value',
path: '/abc/{-$id}/$foo/def',
nav: '/abc/bar/def',
params: { foo: 'bar' },
matchParams: { foo: 'bar' },
},
{
name: 'optional param with no prefix/suffix and requiredParam and value',
path: '/abc/{-$id}/$foo/def',
nav: '/abc/10/bar/def',
params: { id: '10', foo: 'bar' },
matchParams: { id: '10', foo: 'bar' },
},
{
name: 'optional param with no prefix/suffix and no value',
path: '/abc/{-$id}/def',
nav: '/abc/def',
params: {},
matchParams: {},
},
{
name: 'multiple optional params with no prefix/suffix and no value',
path: '/{-$a}/{-$b}/{-$c}',
nav: '/',
params: {},
matchParams: {},
},
{
name: 'multiple optional params with no prefix/suffix and values',
path: '/{-$a}/{-$b}/{-$c}',
nav: '/foo/bar/qux',
params: { a: 'foo', b: 'bar', c: 'qux' },
matchParams: { a: 'foo', b: 'bar', c: 'qux' },
},
{
name: 'multiple optional params with no prefix/suffix and mixed values',
path: '/{-$a}/{-$b}/{-$c}',
nav: '/foo/qux',
params: { a: 'foo', b: 'qux' },
matchParams: { a: 'foo', b: 'qux' },
},
{
name: 'optional param with prefix and value',
path: '/optional-{-$id}',
nav: '/optional-12',
params: { id: '12' },
matchParams: { id: '12' },
},
{
name: 'optional param with prefix and no value',
path: '/optional-{-$id}',
nav: '/optional-',
params: {},
matchParams: { id: '' },
},
{
name: 'optional param with suffix and value',
path: '/{-$id}-optional',
nav: '/13-optional',
params: { id: '13' },
matchParams: { id: '13' },
},
{
name: 'optional param with suffix and no value',
path: '/{-$id}-optional',
nav: '/-optional',
params: {},
matchParams: { id: '' },
},
{
name: 'optional param with required param, prefix, suffix, wildcard and no value',
path: `/$foo/a{-$id}-optional/$`,
nav: '/bar/a-optional/qux',
params: { foo: 'bar', id: '', _splat: 'qux', '*': 'qux' },
matchParams: { foo: 'bar', id: '', _splat: 'qux', '*': 'qux' },
},
{
name: 'optional param with required param, prefix, suffix, wildcard and value',
path: `/$foo/a{-$id}-optional/$`,
nav: '/bar/a14-optional/qux',
params: { foo: 'bar', id: '14', _splat: 'qux', '*': 'qux' },
matchParams: { foo: 'bar', id: '14', _splat: 'qux', '*': 'qux' },
},
]

afterEach(() => cleanup())
test.each(testCases)(
'$name',
async ({ name, path, params, matchParams, nav }) => {
const rootRoute = createRootRoute()

const Route = createRoute({
getParentRoute: () => rootRoute,
path,
component: RouteComponent,
})

function RouteComponent() {
const routeParams = Route.useParams()
const matchRoute = useMatchRoute()
const matchRouteMatch = matchRoute({
to: path,
})

return (
<div>
<h1 data-testid="heading">{name}</h1>
<div>
Params{' '}
<span data-testid="params">{JSON.stringify(routeParams)}</span>
Matches{' '}
<span data-testid="matches">
{JSON.stringify(matchRouteMatch)}
</span>
</div>
</div>
)
}

const router = createRouter({
routeTree: rootRoute.addChildren([Route]),
history: createMemoryHistory({ initialEntries: ['/'] }),
})

await act(() => render(<RouterProvider router={router} />))

act(() => router.history.push(nav))

const paramsToCheck = await screen.findByTestId('params')
const matchesToCheck = await screen.findByTestId('matches')

expect(JSON.parse(paramsToCheck.textContent)).toEqual(params)
expect(JSON.parse(matchesToCheck.textContent)).toEqual(matchParams)
},
)
})
13 changes: 2 additions & 11 deletions packages/router-core/src/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,6 @@ function baseParsePathname(pathname: string): ReadonlyArray<Segment> {
interface InterpolatePathOptions {
path?: string
params: Record<string, unknown>
leaveParams?: boolean
// Map of encoded chars to decoded chars (e.g. '%40' -> '@') that should remain decoded in path params
decodeCharMap?: Map<string, string>
parseCache?: ParsePathnameCache
Expand All @@ -393,7 +392,6 @@ type InterPolatePathResult = {
*
* - Encodes params safely (configurable allowed characters)
* - Supports `{-$optional}` segments, `{prefix{$id}suffix}` and `{$}` wildcards
* - Optionally leaves placeholders or wildcards in place
*/
/**
* Interpolate params and wildcards into a route path template.
Expand All @@ -402,7 +400,6 @@ type InterPolatePathResult = {
export function interpolatePath({
path,
params,
leaveParams,
decodeCharMap,
parseCache,
}: InterpolatePathOptions): InterPolatePathResult {
Expand Down Expand Up @@ -452,6 +449,7 @@ export function interpolatePath({
}

const value = encodeParam('_splat')

return `${segmentPrefix}${value}${segmentSuffix}`
}

Expand All @@ -464,10 +462,7 @@ export function interpolatePath({

const segmentPrefix = segment.prefixSegment || ''
const segmentSuffix = segment.suffixSegment || ''
if (leaveParams) {
const value = encodeParam(segment.value)
return `${segmentPrefix}${segment.value}${value ?? ''}${segmentSuffix}`
}

return `${segmentPrefix}${encodeParam(key) ?? 'undefined'}${segmentSuffix}`
}

Expand All @@ -489,10 +484,6 @@ export function interpolatePath({

usedParams[key] = params[key]

if (leaveParams) {
const value = encodeParam(segment.value)
return `${segmentPrefix}${segment.value}${value ?? ''}${segmentSuffix}`
}
return `${segmentPrefix}${encodeParam(key) ?? ''}${segmentSuffix}`
}

Expand Down
21 changes: 11 additions & 10 deletions packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1683,17 +1683,18 @@ export class RouterCore<
}
}

const nextPathname = decodePath(
interpolatePath({
// Use the original template path for interpolation
const nextPathname = opts.leaveParams
? // Use the original template path for interpolation
// This preserves the original parameter syntax including optional parameters
path: nextTo,
params: nextParams,
leaveParams: opts.leaveParams,
decodeCharMap: this.pathParamsDecodeCharMap,
parseCache: this.parsePathnameCache,
}).interpolatedPath,
)
nextTo
: decodePath(
interpolatePath({
path: nextTo,
params: nextParams,
decodeCharMap: this.pathParamsDecodeCharMap,
parseCache: this.parsePathnameCache,
}).interpolatedPath,
)

// Resolve the next search
let nextSearch = fromSearch
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,6 @@ function RouteComp({
const interpolated = interpolatePath({
path: route.fullPath,
params: allParams,
leaveParams: false,
decodeCharMap: router().pathParamsDecodeCharMap,
})

Expand Down
Loading
Loading