Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/plenty-jokes-listen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/router-core': patch
---

Fix matchRoute matching for custom parsed path parameters
35 changes: 35 additions & 0 deletions packages/react-router/tests/Matches.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,41 @@ test('should show pendingComponent of root route', async () => {
expect(await rendered.findByTestId('root-content')).toBeInTheDocument()
})

test('useMatchRoute matches typed params from routes with custom parse and stringify functions', async () => {
const rootRoute = createRootRoute()
const invoiceRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/invoices/$invoiceId',
params: {
parse: ({ invoiceId }: { invoiceId: string }) => ({
invoiceId: Number(invoiceId),
}),
stringify: ({ invoiceId }: { invoiceId: number }) => ({
invoiceId: String(invoiceId),
}),
},
component: () => {
const matchRoute = useMatchRoute()
const match = matchRoute({
to: '/invoices/$invoiceId',
params: { invoiceId: 123 },
})

return <div data-testid="match">{JSON.stringify(match)}</div>
},
})
const router = createRouter({
routeTree: rootRoute.addChildren([invoiceRoute]),
history: createMemoryHistory({ initialEntries: ['/invoices/123'] }),
})

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

expect(await screen.findByTestId('match')).toHaveTextContent(
'{"invoiceId":123}',
)
})

describe('matching on different param types', () => {
const testCases = [
{
Expand Down
30 changes: 27 additions & 3 deletions packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2994,19 +2994,43 @@ export class RouterCore<
return false
}

const params = Object.assign(Object.create(null), match.rawParams)
const destinationPath = trimPathRight(next.pathname)
const destinationRoute = this.routesByPath[
destinationPath as keyof typeof this.routesByPath
] as AnyRoute | undefined
if (destinationRoute) {
try {
for (const matchedRoute of this.getRouteBranch(destinationRoute)) {
const parseParams =
matchedRoute.options.params?.parse ??
matchedRoute.options.parseParams
if (parseParams) {
const parsedParams = parseParams(params as Record<string, string>)
if (parsedParams === false) {
return false
}
Object.assign(params, parsedParams)
}
}
} catch {
return false
}
Comment thread
LadyBluenotes marked this conversation as resolved.
Outdated
}

if (location.params) {
if (!deepEqual(match.rawParams, location.params, { partial: true })) {
if (!deepEqual(params, location.params, { partial: true })) {
return false
}
}

if (opts?.includeSearch ?? true) {
return deepEqual(baseLocation.search, next.search, { partial: true })
? match.rawParams
? params
: false
}

return match.rawParams
return params
}

ssr?: {
Expand Down
249 changes: 249 additions & 0 deletions packages/router-core/tests/match-route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
import { describe, expect, it } from 'vitest'
import { createMemoryHistory } from '@tanstack/history'
import { BaseRootRoute, BaseRoute } from '../src'
import { createTestRouter } from './routerTestUtils'

describe('matchRoute', () => {
function createInvoiceRouter(initialEntry = '/invoices/123') {
const rootRoute = new BaseRootRoute({})
const invoiceRoute = new BaseRoute({
getParentRoute: () => rootRoute,
path: '/invoices/$invoiceId',
params: {
parse: ({ invoiceId }: { invoiceId: string }) => {
const parsed = Number(invoiceId)
return Number.isInteger(parsed) ? { invoiceId: parsed } : false
},
stringify: ({ invoiceId }: { invoiceId: number }) => ({
invoiceId: String(invoiceId),
}),
},
})

return createTestRouter({
routeTree: rootRoute.addChildren([invoiceRoute]),
history: createMemoryHistory({ initialEntries: [initialEntry] }),
})
}

it('matches typed params from routes with custom parse and stringify functions', async () => {
const router = createInvoiceRouter()

await router.load()

expect(
router.matchRoute({
to: '/invoices/$invoiceId',
params: { invoiceId: 123 },
}),
).toEqual({ invoiceId: 123 })
})

it('does not match a different typed param', async () => {
const router = createInvoiceRouter()

await router.load()

expect(
router.matchRoute({
to: '/invoices/$invoiceId',
params: { invoiceId: 456 },
}),
).toBe(false)
})

it('does not match params rejected by the route parser', async () => {
const router = createInvoiceRouter('/invoices/not-a-number')

await router.load()

expect(
router.matchRoute({
to: '/invoices/$invoiceId',
params: { invoiceId: 123 },
}),
).toBe(false)
})

it('does not throw parser errors while checking a match', async () => {
const rootRoute = new BaseRootRoute({})
const invoiceRoute = new BaseRoute({
getParentRoute: () => rootRoute,
path: '/invoices/$invoiceId',
params: {
parse: () => {
throw new Error('invalid invoice')
},
stringify: ({ invoiceId }: { invoiceId: number }) => ({
invoiceId: String(invoiceId),
}),
},
})
const router = createTestRouter({
routeTree: rootRoute.addChildren([invoiceRoute]),
history: createMemoryHistory({ initialEntries: ['/invoices/123'] }),
})

await router.load()

expect(
router.matchRoute({
to: '/invoices/$invoiceId',
params: { invoiceId: 123 },
}),
).toBe(false)
})

it('matches an internal ID parsed from a non-canonical public slug', async () => {
const rootRoute = new BaseRootRoute({})
const thingRoute = new BaseRoute({
getParentRoute: () => rootRoute,
path: '/things/$thingId',
params: {
parse: ({ thingId }: { thingId: string }) => ({
thingId: thingId.slice(thingId.lastIndexOf('-') + 1),
}),
stringify: ({ thingId }: { thingId: string }) => ({
thingId,
}),
},
})
const router = createTestRouter({
routeTree: rootRoute.addChildren([thingRoute]),
history: createMemoryHistory({
initialEntries: ['/things/Title-of-the-thing-abc123'],
}),
})

await router.load()

expect(
router.matchRoute({
to: '/things/$thingId',
params: { thingId: 'abc123' },
}),
).toEqual({ thingId: 'abc123' })
})

it('passes parsed parent params to child route parsers', async () => {
const rootRoute = new BaseRootRoute({})
const orgRoute = new BaseRoute({
getParentRoute: () => rootRoute,
path: '/orgs/$orgId',
params: {
parse: ({ orgId }: { orgId: string }) => ({
orgId: Number(orgId),
}),
},
})
const projectRoute = new BaseRoute({
getParentRoute: () => orgRoute,
path: '/projects/$projectId',
params: {
parse: (params: { projectId: string }) => {
if (typeof (params as Record<string, unknown>).orgId !== 'number') {
return false
}
return { projectId: Number(params.projectId) }
},
},
})
const router = createTestRouter({
routeTree: rootRoute.addChildren([orgRoute.addChildren([projectRoute])]),
history: createMemoryHistory({
initialEntries: ['/orgs/42/projects/7'],
}),
})

await router.load()

expect(
router.matchRoute({
to: '/orgs/$orgId/projects/$projectId',
params: { orgId: 42, projectId: 7 },
}),
).toEqual({ orgId: 42, projectId: 7 })
})

it('keeps generic route templates working with string params', async () => {
const router = createInvoiceRouter()

await router.load()

expect(
router.matchRoute({
to: '/invoices/$id',
params: { id: '123' },
} as any),
).toEqual({ id: '123' })
})

it('keeps fuzzy matching behavior with string params', async () => {
const router = createInvoiceRouter('/invoices/123/details')

await router.load()

expect(
router.matchRoute(
{
to: '/invoices/$invoiceId',
params: { invoiceId: 123 },
},
{ fuzzy: true },
),
).toEqual({ invoiceId: 123, '**': 'details' })
})

it('keeps search matching behavior', async () => {
const router = createInvoiceRouter('/invoices/123?tab=details')

await router.load()

expect(
router.matchRoute({
to: '/invoices/$invoiceId',
params: { invoiceId: 123 },
search: { tab: 'details' },
}),
).toEqual({ invoiceId: 123 })
expect(
router.matchRoute({
to: '/invoices/$invoiceId',
params: { invoiceId: 123 },
search: { tab: 'other' },
}),
).toBe(false)
})

it('matches typed params with a router basepath', async () => {
const rootRoute = new BaseRootRoute({})
const invoiceRoute = new BaseRoute({
getParentRoute: () => rootRoute,
path: '/invoices/$invoiceId',
params: {
parse: ({ invoiceId }: { invoiceId: string }) => ({
invoiceId: Number(invoiceId),
}),
stringify: ({ invoiceId }: { invoiceId: number }) => ({
invoiceId: String(invoiceId),
}),
},
})
const router = createTestRouter({
routeTree: rootRoute.addChildren([invoiceRoute]),
basepath: '/app',
history: createMemoryHistory({
initialEntries: ['/app/invoices/123'],
}),
})

await router.load()

expect(
router.matchRoute({
to: '/invoices/$invoiceId',
params: { invoiceId: 123 },
}),
).toEqual({ invoiceId: 123 })
})
})
35 changes: 35 additions & 0 deletions packages/solid-router/tests/Matches.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,41 @@ test('should show pendingComponent of root route', async () => {
expect(await rendered.findByTestId('root-content')).toBeInTheDocument()
})

test('useMatchRoute matches typed params from routes with custom parse and stringify functions', async () => {
const rootRoute = createRootRoute()
const invoiceRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/invoices/$invoiceId',
params: {
parse: ({ invoiceId }: { invoiceId: string }) => ({
invoiceId: Number(invoiceId),
}),
stringify: ({ invoiceId }: { invoiceId: number }) => ({
invoiceId: String(invoiceId),
}),
},
component: () => {
const matchRoute = useMatchRoute()
const match = matchRoute({
to: '/invoices/$invoiceId',
params: { invoiceId: 123 },
})

return <div data-testid="match">{JSON.stringify(match())}</div>
},
})
const router = createRouter({
routeTree: rootRoute.addChildren([invoiceRoute]),
history: createMemoryHistory({ initialEntries: ['/invoices/123'] }),
})

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

expect(await screen.findByTestId('match')).toHaveTextContent(
'{"invoiceId":123}',
)
})

test('MatchRoute updates for navigation and reactive params changes', async () => {
function Layout() {
const [postId, setPostId] = createSignal('123')
Expand Down
Loading
Loading