diff --git a/.changeset/kind-rivers-joke.md b/.changeset/kind-rivers-joke.md new file mode 100644 index 0000000000..98b2a453f7 --- /dev/null +++ b/.changeset/kind-rivers-joke.md @@ -0,0 +1,5 @@ +--- +'@tanstack/router-core': minor +--- + +params.priority route option as tie breaker in route matching algorithm diff --git a/docs/router/api/router/RouteOptionsType.md b/docs/router/api/router/RouteOptionsType.md index b4dececc5c..1fb4c4eb30 100644 --- a/docs/router/api/router/RouteOptionsType.md +++ b/docs/router/api/router/RouteOptionsType.md @@ -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` diff --git a/docs/router/guide/path-params.md b/docs/router/guide/path-params.md index 31b7bb8374..b1ae4f5d2f 100644 --- a/docs/router/guide/path-params.md +++ b/docs/router/guide/path-params.md @@ -131,6 +131,29 @@ function PostComponent() { +## 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. diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 73bb76875c..6978b071ce 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -403,6 +403,7 @@ function parseSegments( } node.parse = parseParams ?? null + node.priority = route.options?.params?.priority ?? 0 // make node "matchable" if (isLeaf && !node.route) { @@ -430,16 +431,20 @@ function sortDynamic( suffix?: string caseSensitive: boolean parse: null | ((params: Record) => unknown) + priority: number }, b: { prefix?: string suffix?: string caseSensitive: boolean parse: null | ((params: Record) => 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 @@ -512,6 +517,7 @@ function createStaticNode( fullPath, parent: null, parse: null, + priority: 0, } } @@ -543,6 +549,7 @@ function createDynamicNode( fullPath, parent: null, parse: null, + priority: 0, caseSensitive, prefix, suffix, @@ -605,6 +612,9 @@ type SegmentNode = { /** route.options.params.parse function, set on the last node of the route */ parse: null | ((params: Record) => unknown) + + /** route.options.params.priority ?? 0 */ + priority: number } type RouteLike = { @@ -618,6 +628,7 @@ type RouteLike = { parseParams?: (params: Record) => unknown params?: { parse?: (params: Record) => unknown + priority?: number } } } & diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts index b92401b652..d7288e4104 100644 --- a/packages/router-core/src/route.ts +++ b/packages/router-core/src/route.ts @@ -188,6 +188,13 @@ export type StringifyParamsFn = ( export type ParamsOptions = { params?: { parse?: ParseParamsFn & ValidateParsedParams + /** + * When multiple route candidates use `params.parse` during matching, + * higher priorities are tried first. + * + * @default 0 + */ + priority?: number stringify?: StringifyParamsFn } diff --git a/packages/router-core/tests/match-params.test.ts b/packages/router-core/tests/match-params.test.ts index fd5c36cb4b..fe05808bd4 100644 --- a/packages/router-core/tests/match-params.test.ts +++ b/packages/router-core/tests/match-params.test.ts @@ -12,6 +12,7 @@ type TestRoute = { options?: { params?: { parse?: (params: Record) => unknown + priority?: number } } children?: Array @@ -266,6 +267,7 @@ describe('params.parse route selection', () => { options: { params: { parse: (params) => params, + priority: 999, }, }, }, @@ -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) => { + return params + }) + const priorityNegativeOneParse = vi.fn( + (params: Record) => { + 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) => { + 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([ { diff --git a/packages/router-core/tests/new-process-route-tree.test.ts b/packages/router-core/tests/new-process-route-tree.test.ts index 3989ea0697..d93581355e 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -1419,6 +1419,7 @@ describe('findRouteMatch', () => { "parent": [Circular], "parse": null, "pathless": null, + "priority": 0, "route": { "fullPath": "/$foo/", "id": "/$foo/_layout/", @@ -1434,6 +1435,7 @@ describe('findRouteMatch', () => { "parent": [Circular], "parse": [Function], "pathless": null, + "priority": 0, "route": { "children": [ { @@ -1470,6 +1472,7 @@ describe('findRouteMatch', () => { "parent": [Circular], "parse": null, "pathless": null, + "priority": 0, "route": { "fullPath": "/$foo/bar", "id": "/$foo/_layout/bar", @@ -1485,6 +1488,7 @@ describe('findRouteMatch', () => { }, ], "prefix": undefined, + "priority": 0, "route": null, "static": null, "staticInsensitive": Map { @@ -1498,6 +1502,7 @@ describe('findRouteMatch', () => { "parent": [Circular], "parse": null, "pathless": null, + "priority": 0, "route": { "fullPath": "/$foo/hello", "id": "/$foo/hello", @@ -1524,6 +1529,7 @@ describe('findRouteMatch', () => { "parent": [Circular], "parse": null, "pathless": null, + "priority": 0, "route": { "fullPath": "/", "id": "/", @@ -1539,6 +1545,7 @@ describe('findRouteMatch', () => { "parent": null, "parse": null, "pathless": null, + "priority": 0, "route": null, "static": null, "staticInsensitive": null,