Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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/kind-rivers-joke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/router-core': minor
---

params.priority route option as tie breaker in route matching algorithm
8 changes: 8 additions & 0 deletions docs/router/api/router/RouteOptionsType.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,14 @@ The `RouteOptions` type accepts an object with the following properties:
- A function that will be called when this route is matched and passed the raw params from the current location and return valid parsed params. If this function throws, the route will be put into an error state and the error will be thrown during render. If this function returns parsed params, its return value will be used as the route's params and the return type will be inferred into the rest of the router.
- Experimental: returning `false` during incoming route matching skips this route and allows matching to continue to another candidate route.

### `params.priority` property

- Type: `number`
- Optional
- Defaults to `0`
- Controls the matching order when multiple route candidates with `params.parse` can match the same URL segment. Higher numbers are tried first. If a higher-priority route's `params.parse` returns `false`, matching continues to the next candidate.
- This only affects competing candidates that use `params.parse`; normal route specificity still applies, so static routes continue to match before dynamic, optional, or wildcard routes.

### `params.stringify` method

- Type: `(params: TParams) => Record<string, string>`
Expand Down
23 changes: 23 additions & 0 deletions docs/router/guide/path-params.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,29 @@ function PostComponent() {

<!-- ::end:framework -->

## Prioritizing Parsed Path Param Routes

When multiple dynamic, optional, or wildcard routes can match the same URL, routes with `params.parse` are tried before equivalent routes without it. If multiple matching candidates use `params.parse`, you can use `params.priority` to control which candidate is tried first.

Higher `params.priority` values are tried first. The default priority is `0`, and if a higher-priority route's `params.parse` returns `false`, matching continues to the next candidate route.

```tsx title="src/routes/posts.$postId.tsx"
export const Route = createFileRoute('/posts/$postId')({
params: {
priority: 10,
parse: ({ postId }) => {
if (!/^\d+$/.test(postId)) return false
return { postId: Number(postId) }
},
stringify: ({ postId }) => ({ postId: String(postId) }),
},
})
```

With a fallback `/posts/$slug` route, `/posts/123` can match the parsed numeric route first, while `/posts/hello-world` can fall through to the slug route when `params.parse` returns `false`.

`params.priority` only affects competing candidates that use `params.parse`. It does not override normal route specificity, so static routes still match before dynamic, optional, or wildcard routes.

## Navigating with Path Params

When navigating to a route with path params, TypeScript will require you to pass the params either as an object or as a function that returns an object of params.
Expand Down
11 changes: 11 additions & 0 deletions packages/router-core/src/new-process-route-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,7 @@ function parseSegments<TRouteLike extends RouteLike>(
}

node.parse = parseParams ?? null
node.priority = route.options?.params?.priority ?? 0

// make node "matchable"
if (isLeaf && !node.route) {
Expand Down Expand Up @@ -430,16 +431,20 @@ function sortDynamic(
suffix?: string
caseSensitive: boolean
parse: null | ((params: Record<string, string>) => unknown)
priority: number
},
b: {
prefix?: string
suffix?: string
caseSensitive: boolean
parse: null | ((params: Record<string, string>) => unknown)
priority: number
},
) {
if (a.parse && !b.parse) return -1
if (!a.parse && b.parse) return 1
if (a.parse && b.parse && (a.priority || b.priority))
return b.priority - a.priority
if (a.prefix && b.prefix && a.prefix !== b.prefix) {
if (a.prefix.startsWith(b.prefix)) return -1
if (b.prefix.startsWith(a.prefix)) return 1
Expand Down Expand Up @@ -512,6 +517,7 @@ function createStaticNode<T extends RouteLike>(
fullPath,
parent: null,
parse: null,
priority: 0,
}
}

Expand Down Expand Up @@ -543,6 +549,7 @@ function createDynamicNode<T extends RouteLike>(
fullPath,
parent: null,
parse: null,
priority: 0,
caseSensitive,
prefix,
suffix,
Expand Down Expand Up @@ -605,6 +612,9 @@ type SegmentNode<T extends RouteLike> = {

/** route.options.params.parse function, set on the last node of the route */
parse: null | ((params: Record<string, string>) => unknown)

/** route.options.params.priority ?? 0 */
priority: number
}

type RouteLike = {
Expand All @@ -618,6 +628,7 @@ type RouteLike = {
parseParams?: (params: Record<string, string>) => unknown
params?: {
parse?: (params: Record<string, string>) => unknown
priority?: number
}
}
} &
Expand Down
7 changes: 7 additions & 0 deletions packages/router-core/src/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,13 @@ export type StringifyParamsFn<in out TPath extends string, in out TParams> = (
export type ParamsOptions<in out TPath extends string, in out TParams> = {
params?: {
parse?: ParseParamsFn<TPath, TParams> & ValidateParsedParams<TPath, TParams>
/**
* When multiple route candidates use `params.parse` during matching,
* higher priorities are tried first.
*
* @default 0
*/
priority?: number
stringify?: StringifyParamsFn<TPath, TParams>
}

Expand Down
134 changes: 134 additions & 0 deletions packages/router-core/tests/match-params.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ type TestRoute = {
options?: {
params?: {
parse?: (params: Record<string, string>) => unknown
priority?: number
}
}
children?: Array<TestRoute>
Expand Down Expand Up @@ -266,6 +267,7 @@ describe('params.parse route selection', () => {
options: {
params: {
parse: (params) => params,
priority: 999,
},
},
},
Expand Down Expand Up @@ -306,6 +308,138 @@ describe('params.parse route selection', () => {
)
})

it('params.priority breaks ties between params.parse routes', () => {
const { processedTree } = processRouteTree(
root([
{
id: '/$a',
fullPath: '/$a',
path: '$a',
options: {
params: {
parse: (params) => params,
priority: -1,
},
},
},
{
id: '/$z',
fullPath: '/$z',
path: '$z',
options: {
params: {
parse: (params) => params,
priority: 1,
},
},
},
]),
)

expect(findRouteMatch('/123', processedTree)?.route.id).toBe('/$z')
})

it('treats missing params.priority as 0', () => {
const priorityOneParse = vi.fn(() => false)
const defaultPriorityParse = vi.fn((params: Record<string, string>) => {
return params
})
const priorityNegativeOneParse = vi.fn(
(params: Record<string, string>) => {
return params
},
)

const { processedTree } = processRouteTree(
root([
{
id: '/$default',
fullPath: '/$default',
path: '$default',
options: {
params: {
parse: defaultPriorityParse,
},
},
},
{
id: '/$low',
fullPath: '/$low',
path: '$low',
options: {
params: {
parse: priorityNegativeOneParse,
priority: -1,
},
},
},
{
id: '/$high',
fullPath: '/$high',
path: '$high',
options: {
params: {
parse: priorityOneParse,
priority: 1,
},
},
},
]),
)

expect(findRouteMatch('/123', processedTree)?.route.id).toBe('/$default')
expect(priorityOneParse).toHaveBeenCalledWith({ high: '123' })
expect(defaultPriorityParse).toHaveBeenCalledWith({ default: '123' })
expect(priorityNegativeOneParse).toHaveBeenCalledWith({ low: '123' })
expect(priorityOneParse.mock.invocationCallOrder[0]!).toBeLessThan(
defaultPriorityParse.mock.invocationCallOrder[0]!,
)
expect(defaultPriorityParse.mock.invocationCallOrder[0]!).toBeLessThan(
priorityNegativeOneParse.mock.invocationCallOrder[0]!,
)
})

it('falls through to the next params.parse route by params.priority', () => {
const lowerPriorityParse = vi.fn((params: Record<string, string>) => {
return params
})
const higherPriorityParse = vi.fn(() => false)

const { processedTree } = processRouteTree(
root([
{
id: '/$number',
fullPath: '/$number',
path: '$number',
options: {
params: {
parse: lowerPriorityParse,
priority: 1,
},
},
},
{
id: '/$uuid',
fullPath: '/$uuid',
path: '$uuid',
options: {
params: {
parse: higherPriorityParse,
priority: 10,
},
},
},
]),
)

expect(findRouteMatch('/42', processedTree)?.route.id).toBe('/$number')
expect(higherPriorityParse).toHaveBeenCalledWith({ uuid: '42' })
expect(lowerPriorityParse).toHaveBeenCalledWith({ number: '42' })
expect(higherPriorityParse.mock.invocationCallOrder[0]!).toBeLessThan(
lowerPriorityParse.mock.invocationCallOrder[0]!,
)
})

it('declaration order breaks ties between params.parse routes', () => {
const routeTree = root([
{
Expand Down
7 changes: 7 additions & 0 deletions packages/router-core/tests/new-process-route-tree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1419,6 +1419,7 @@ describe('findRouteMatch', () => {
"parent": [Circular],
"parse": null,
"pathless": null,
"priority": 0,
"route": {
"fullPath": "/$foo/",
"id": "/$foo/_layout/",
Expand All @@ -1434,6 +1435,7 @@ describe('findRouteMatch', () => {
"parent": [Circular],
"parse": [Function],
"pathless": null,
"priority": 0,
"route": {
"children": [
{
Expand Down Expand Up @@ -1470,6 +1472,7 @@ describe('findRouteMatch', () => {
"parent": [Circular],
"parse": null,
"pathless": null,
"priority": 0,
"route": {
"fullPath": "/$foo/bar",
"id": "/$foo/_layout/bar",
Expand All @@ -1485,6 +1488,7 @@ describe('findRouteMatch', () => {
},
],
"prefix": undefined,
"priority": 0,
"route": null,
"static": null,
"staticInsensitive": Map {
Expand All @@ -1498,6 +1502,7 @@ describe('findRouteMatch', () => {
"parent": [Circular],
"parse": null,
"pathless": null,
"priority": 0,
"route": {
"fullPath": "/$foo/hello",
"id": "/$foo/hello",
Expand All @@ -1524,6 +1529,7 @@ describe('findRouteMatch', () => {
"parent": [Circular],
"parse": null,
"pathless": null,
"priority": 0,
"route": {
"fullPath": "/",
"id": "/",
Expand All @@ -1539,6 +1545,7 @@ describe('findRouteMatch', () => {
"parent": null,
"parse": null,
"pathless": null,
"priority": 0,
"route": null,
"static": null,
"staticInsensitive": null,
Expand Down
Loading