diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 1cfcc8aa53..10253e2015 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -8,6 +8,7 @@ export const SEGMENT_TYPE_PARAM = 1 export const SEGMENT_TYPE_WILDCARD = 2 export const SEGMENT_TYPE_OPTIONAL_PARAM = 3 const SEGMENT_TYPE_INDEX = 4 +const SEGMENT_TYPE_PATHLESS = 5 // only used in matching to represent pathless routes that need to carry more information /** * All the kinds of segments that can be present in a route path. @@ -21,7 +22,10 @@ export type SegmentKind = /** * All the kinds of segments that can be present in the segment tree. */ -type ExtendedSegmentKind = SegmentKind | typeof SEGMENT_TYPE_INDEX +type ExtendedSegmentKind = + | SegmentKind + | typeof SEGMENT_TYPE_INDEX + | typeof SEGMENT_TYPE_PATHLESS const PARAM_W_CURLY_BRACES_RE = /^([^{]*)\{\$([a-zA-Z_$][a-zA-Z0-9_$]*)\}([^}]*)$/ // prefix{$paramName}suffix @@ -183,6 +187,8 @@ function parseSegments( const path = route.fullPath ?? route.from const length = path.length const caseSensitive = route.options?.caseSensitive ?? defaultCaseSensitive + const parse = route.options?.params?.parse ?? null + const skipRouteOnParseError = !!route.options?.skipRouteOnParseError while (cursor < length) { const segment = parseSegment(path, cursor, data) let nextNode: AnySegmentNode @@ -241,12 +247,15 @@ function parseSegments( : actuallyCaseSensitive ? suffix_raw : suffix_raw.toLowerCase() - const existingNode = node.dynamic?.find( - (s) => - s.caseSensitive === actuallyCaseSensitive && - s.prefix === prefix && - s.suffix === suffix, - ) + const existingNode = + (!parse || !skipRouteOnParseError) && + node.dynamic?.find( + (s) => + (!s.parse || !s.skipRouteOnParseError) && + s.caseSensitive === actuallyCaseSensitive && + s.prefix === prefix && + s.suffix === suffix, + ) if (existingNode) { nextNode = existingNode } else { @@ -280,12 +289,15 @@ function parseSegments( : actuallyCaseSensitive ? suffix_raw : suffix_raw.toLowerCase() - const existingNode = node.optional?.find( - (s) => - s.caseSensitive === actuallyCaseSensitive && - s.prefix === prefix && - s.suffix === suffix, - ) + const existingNode = + (!parse || !skipRouteOnParseError) && + node.optional?.find( + (s) => + (!s.parse || !s.skipRouteOnParseError) && + s.caseSensitive === actuallyCaseSensitive && + s.prefix === prefix && + s.suffix === suffix, + ) if (existingNode) { nextNode = existingNode } else { @@ -336,8 +348,28 @@ function parseSegments( node = nextNode } - const isLeaf = (route.path || !route.children) && !route.isRoot + // create pathless node + if ( + parse && + skipRouteOnParseError && + route.children && + !route.isRoot && + route.id && + route.id.charCodeAt(route.id.lastIndexOf('/') + 1) === 95 /* '_' */ + ) { + const pathlessNode = createStaticNode( + route.fullPath ?? route.from, + ) + pathlessNode.kind = SEGMENT_TYPE_PATHLESS + pathlessNode.parent = node + depth++ + pathlessNode.depth = depth + node.pathless ??= [] + node.pathless.push(pathlessNode) + node = pathlessNode + } + const isLeaf = (route.path || !route.children) && !route.isRoot // create index node if (isLeaf && path.endsWith('/')) { const indexNode = createStaticNode( @@ -351,6 +383,9 @@ function parseSegments( node = indexNode } + node.parse = parse + node.skipRouteOnParseError = skipRouteOnParseError + // make node "matchable" if (isLeaf && !node.route) { node.route = route @@ -372,9 +407,33 @@ function parseSegments( } function sortDynamic( - a: { prefix?: string; suffix?: string; caseSensitive: boolean }, - b: { prefix?: string; suffix?: string; caseSensitive: boolean }, + a: { + prefix?: string + suffix?: string + caseSensitive: boolean + parse: null | ((params: Record) => any) + skipRouteOnParseError: boolean + }, + b: { + prefix?: string + suffix?: string + caseSensitive: boolean + parse: null | ((params: Record) => any) + skipRouteOnParseError: boolean + }, ) { + if ( + a.parse && + a.skipRouteOnParseError && + (!b.parse || !b.skipRouteOnParseError) + ) + return -1 + if ( + (!a.parse || !a.skipRouteOnParseError) && + b.parse && + b.skipRouteOnParseError + ) + return 1 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 @@ -396,6 +455,11 @@ function sortDynamic( } function sortTreeNodes(node: SegmentNode) { + if (node.pathless) { + for (const child of node.pathless) { + sortTreeNodes(child) + } + } if (node.static) { for (const child of node.static.values()) { sortTreeNodes(child) @@ -432,6 +496,7 @@ function createStaticNode( return { kind: SEGMENT_TYPE_PATHNAME, depth: 0, + pathless: null, index: null, static: null, staticInsensitive: null, @@ -441,6 +506,8 @@ function createStaticNode( route: null, fullPath, parent: null, + parse: null, + skipRouteOnParseError: false, } } @@ -461,6 +528,7 @@ function createDynamicNode( return { kind, depth: 0, + pathless: null, index: null, static: null, staticInsensitive: null, @@ -470,6 +538,8 @@ function createDynamicNode( route: null, fullPath, parent: null, + parse: null, + skipRouteOnParseError: false, caseSensitive, prefix, suffix, @@ -477,7 +547,10 @@ function createDynamicNode( } type StaticSegmentNode = SegmentNode & { - kind: typeof SEGMENT_TYPE_PATHNAME | typeof SEGMENT_TYPE_INDEX + kind: + | typeof SEGMENT_TYPE_PATHNAME + | typeof SEGMENT_TYPE_PATHLESS + | typeof SEGMENT_TYPE_INDEX } type DynamicSegmentNode = SegmentNode & { @@ -497,6 +570,8 @@ type AnySegmentNode = type SegmentNode = { kind: ExtendedSegmentKind + pathless: Array> | null + /** Exact index segment (highest priority) */ index: StaticSegmentNode | null @@ -524,15 +599,26 @@ type SegmentNode = { parent: AnySegmentNode | null depth: number + + /** route.options.params.parse function, set on the last node of the route */ + parse: null | ((params: Record) => any) + + /** If true, errors thrown during parsing will cause this route to be ignored as a match candidate */ + skipRouteOnParseError: boolean } type RouteLike = { + id?: string path?: string // relative path from the parent, children?: Array // child routes, parentRoute?: RouteLike // parent route, isRoot?: boolean options?: { + skipRouteOnParseError?: boolean caseSensitive?: boolean + params?: { + parse?: (params: Record) => any + } } } & // router tree @@ -623,6 +709,8 @@ type RouteMatch> = { route: T params: Record branch: ReadonlyArray + /** Parsed params from routes with skipRouteOnParseError, accumulated during matching */ + parsedParams?: Record } export function findRouteMatch< @@ -718,32 +806,50 @@ function findMatch( path: string, segmentTree: AnySegmentNode, fuzzy = false, -): { route: T; params: Record } | null { +): { + route: T + params: Record + parsedParams?: Record +} | null { const parts = path.split('/') const leaf = getNodeMatch(path, parts, segmentTree, fuzzy) if (!leaf) return null - const params = extractParams(path, parts, leaf) - if ('**' in leaf) params['**'] = leaf['**']! + const [params] = extractParams(path, parts, leaf) const route = leaf.node.route! return { route, params, + parsedParams: leaf.parsedParams, } } +/** + * This function is "resumable": + * - the `leaf` input can contain `extract` and `params` properties from a previous `extractParams` call + * - the returned `state` can be passed back as `extract` in a future call to continue extracting params from where we left off + * + * Inputs are *not* mutated. + */ function extractParams( path: string, parts: Array, - leaf: { node: AnySegmentNode; skipped: number }, -) { + leaf: { + node: AnySegmentNode + skipped: number + extract?: { part: number; node: number; path: number } + params?: Record + }, +): [ + params: Record, + state: { part: number; node: number; path: number }, +] { const list = buildBranch(leaf.node) let nodeParts: Array | null = null const params: Record = {} - for ( - let partIndex = 0, nodeIndex = 0, pathIndex = 0; - nodeIndex < list.length; - partIndex++, nodeIndex++, pathIndex++ - ) { + let partIndex = leaf.extract?.part ?? 0 + let nodeIndex = leaf.extract?.node ?? 0 + let pathIndex = leaf.extract?.path ?? 0 + for (; nodeIndex < list.length; partIndex++, nodeIndex++, pathIndex++) { const node = list[nodeIndex]! const part = parts[partIndex] const currentPathIndex = pathIndex @@ -798,7 +904,8 @@ function extractParams( break } } - return params + if (leaf.params) Object.assign(params, leaf.params) + return [params, { part: partIndex, node: nodeIndex, path: pathIndex }] } function buildRouteBranch(route: T) { @@ -836,6 +943,12 @@ type MatchStackFrame = { statics: number dynamics: number optionals: number + /** intermediary state for param extraction */ + extract?: { part: number; node: number; path: number } + /** intermediary raw string params from param extraction (for interpolatePath) */ + params?: Record + /** intermediary parsed params from routes with skipRouteOnParseError */ + parsedParams?: Record } function getNodeMatch( @@ -847,7 +960,7 @@ function getNodeMatch( // quick check for root index // this is an optimization, algorithm should work correctly without this block if (path === '/' && segmentTree.index) - return { node: segmentTree.index, skipped: 0 } + return { node: segmentTree.index, skipped: 0, parsedParams: undefined } const trailingSlash = !last(parts) const pathIsIndex = trailingSlash && path !== '/' @@ -880,8 +993,16 @@ function getNodeMatch( while (stack.length) { const frame = stack.pop()! - // eslint-disable-next-line prefer-const - let { node, index, skipped, depth, statics, dynamics, optionals } = frame + const { node, index, skipped, depth, statics, dynamics, optionals } = frame + let { extract, params, parsedParams } = frame + + if (node.skipRouteOnParseError && node.parse) { + const result = validateMatchParams(path, parts, frame) + if (!result) continue + params = result[0] + extract = result[1] + parsedParams = frame.parsedParams + } // In fuzzy mode, track the best partial match we've found so far if ( @@ -915,6 +1036,13 @@ function getNodeMatch( statics, dynamics, optionals, + extract, + params, + parsedParams, + } + if (node.index.skipRouteOnParseError && node.index.parse) { + const result = validateMatchParams(path, parts, indexFrame) + if (!result) continue } // perfect match, no need to continue // this is an optimization, algorithm should work correctly without this block @@ -946,7 +1074,7 @@ function getNodeMatch( } // the first wildcard match is the highest priority one // wildcard matches skip the stack because they cannot have children - wildcardMatch = { + const frame = { node: segment, index: partsLength, skipped, @@ -954,7 +1082,15 @@ function getNodeMatch( statics, dynamics, optionals, + extract, + params, + parsedParams, + } + if (segment.skipRouteOnParseError && segment.parse) { + const result = validateMatchParams(path, parts, frame) + if (!result) continue } + wildcardMatch = frame break } } @@ -974,6 +1110,9 @@ function getNodeMatch( statics, dynamics, optionals, + extract, + params, + parsedParams, }) // enqueue skipping the optional } if (!isBeyondPath) { @@ -995,6 +1134,9 @@ function getNodeMatch( statics, dynamics, optionals: optionals + 1, + extract, + params, + parsedParams, }) } } @@ -1020,6 +1162,9 @@ function getNodeMatch( statics, dynamics: dynamics + 1, optionals, + extract, + params, + parsedParams, }) } } @@ -1038,6 +1183,9 @@ function getNodeMatch( statics: statics + 1, dynamics, optionals, + extract, + params, + parsedParams, }) } } @@ -1054,6 +1202,29 @@ function getNodeMatch( statics: statics + 1, dynamics, optionals, + extract, + params, + parsedParams, + }) + } + } + + // 0. Try pathless match + if (node.pathless) { + const nextDepth = depth + 1 + for (let i = node.pathless.length - 1; i >= 0; i--) { + const segment = node.pathless[i]! + stack.push({ + node: segment, + index, + skipped, + depth: nextDepth, + statics, + dynamics, + optionals, + extract, + params, + parsedParams, }) } } @@ -1075,16 +1246,32 @@ function getNodeMatch( sliceIndex += parts[i]!.length } const splat = sliceIndex === path.length ? '/' : path.slice(sliceIndex) - return { - node: bestFuzzy.node, - skipped: bestFuzzy.skipped, - '**': decodeURIComponent(splat), - } + bestFuzzy.params ??= {} + bestFuzzy.params['**'] = decodeURIComponent(splat) + return bestFuzzy } return null } +function validateMatchParams( + path: string, + parts: Array, + frame: MatchStackFrame, +) { + try { + const result = extractParams(path, parts, frame) + frame.params = result[0] + frame.extract = result[1] + const parsedParams = frame.node.parse!(result[0]) + // Accumulate parsed params from this route + frame.parsedParams = { ...frame.parsedParams, ...parsedParams } + return result + } catch { + return null + } +} + function isFrameMoreSpecific( // the stack frame previously saved as "best match" prev: MatchStackFrame | null, diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts index 53d726ef05..b87b657da6 100644 --- a/packages/router-core/src/route.ts +++ b/packages/router-core/src/route.ts @@ -1188,6 +1188,8 @@ export interface UpdatableRouteOptions< in out TBeforeLoadFn, > extends UpdatableStaticRouteOption, UpdatableRouteOptionsExtensions { + /** If true, this route will be skipped during matching if a parse error occurs, and we'll look for another match */ + skipRouteOnParseError?: boolean // If true, this route will be matched as case-sensitive caseSensitive?: boolean // If true, this route will be forcefully wrapped in a suspense boundary diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 54da83a33e..bbc21f5113 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -700,6 +700,8 @@ export type GetMatchRoutesFn = (pathname: string) => { matchedRoutes: ReadonlyArray routeParams: Record foundRoute: AnyRoute | undefined + parseError?: unknown + parsedParams?: Record } export type EmitFn = (routerEvent: RouterEvent) => void @@ -1248,7 +1250,7 @@ export class RouterCore< opts?: MatchRoutesOpts, ): Array { const matchedRoutesResult = this.getMatchedRoutes(next.pathname) - const { foundRoute, routeParams } = matchedRoutesResult + const { foundRoute, routeParams, parsedParams } = matchedRoutesResult let { matchedRoutes } = matchedRoutesResult let isGlobalNotFound = false @@ -1392,7 +1394,13 @@ export class RouterCore< const strictParseParams = route.options.params?.parse ?? route.options.parseParams - if (strictParseParams) { + // Skip parsing if this route was already parsed during matching (skipRouteOnParseError) + const alreadyParsed = + route.options.skipRouteOnParseError && + strictParseParams && + parsedParams !== undefined + + if (strictParseParams && !alreadyParsed) { try { Object.assign( strictParams, @@ -1411,6 +1419,9 @@ export class RouterCore< throw paramsError } } + } else if (alreadyParsed) { + // Use the pre-parsed params from matching + Object.assign(strictParams, parsedParams) } } @@ -2692,15 +2703,17 @@ export function getMatchedRoutes({ const trimmedPath = trimPathRight(pathname) let foundRoute: TRouteLike | undefined = undefined + let parsedParams: Record | undefined = undefined const match = findRouteMatch(trimmedPath, processedTree, true) if (match) { foundRoute = match.route Object.assign(routeParams, match.params) // Copy params, because they're cached + parsedParams = match.parsedParams } const matchedRoutes = match?.branch || [routesById[rootRouteId]!] - return { matchedRoutes, routeParams, foundRoute } + return { matchedRoutes, routeParams, foundRoute, parsedParams } } function applySearchMiddleware({ 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 47083567e2..fd9ea9828d 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -863,6 +863,217 @@ describe('findRouteMatch', () => { }) }) }) + describe('pathless routes', () => { + it('builds segment tree correctly', () => { + const tree = { + path: '/', + isRoot: true, + id: '__root__', + fullPath: '/', + children: [ + { + path: '/', + id: '/', + fullPath: '/', + options: {}, + }, + { + id: '/$foo/_layout', + path: '$foo', + fullPath: '/$foo', + options: { + params: { parse: () => {} }, + skipRouteOnParseError: true, + }, + children: [ + { + id: '/$foo/_layout/bar', + path: 'bar', + fullPath: '/$foo/bar', + options: {}, + }, + { + id: '/$foo/_layout/', + path: '/', + fullPath: '/$foo/', + options: {}, + }, + ], + }, + { + id: '/$foo/hello', + path: '$foo/hello', + fullPath: '/$foo/hello', + options: {}, + }, + ], + } + const { processedTree } = processRouteTree(tree) + expect(processedTree.segmentTree).toMatchInlineSnapshot(` + { + "depth": 0, + "dynamic": [ + { + "caseSensitive": false, + "depth": 1, + "dynamic": null, + "fullPath": "/$foo", + "index": null, + "kind": 1, + "optional": null, + "parent": [Circular], + "parse": null, + "pathless": [ + { + "depth": 2, + "dynamic": null, + "fullPath": "/$foo", + "index": { + "depth": 3, + "dynamic": null, + "fullPath": "/$foo/", + "index": null, + "kind": 4, + "optional": null, + "parent": [Circular], + "parse": null, + "pathless": null, + "route": { + "fullPath": "/$foo/", + "id": "/$foo/_layout/", + "options": {}, + "path": "/", + }, + "skipRouteOnParseError": false, + "static": null, + "staticInsensitive": null, + "wildcard": null, + }, + "kind": 5, + "optional": null, + "parent": [Circular], + "parse": [Function], + "pathless": null, + "route": { + "children": [ + { + "fullPath": "/$foo/bar", + "id": "/$foo/_layout/bar", + "options": {}, + "path": "bar", + }, + { + "fullPath": "/$foo/", + "id": "/$foo/_layout/", + "options": {}, + "path": "/", + }, + ], + "fullPath": "/$foo", + "id": "/$foo/_layout", + "options": { + "params": { + "parse": [Function], + }, + "skipRouteOnParseError": true, + }, + "path": "$foo", + }, + "skipRouteOnParseError": true, + "static": null, + "staticInsensitive": Map { + "bar" => { + "depth": 3, + "dynamic": null, + "fullPath": "/$foo/bar", + "index": null, + "kind": 0, + "optional": null, + "parent": [Circular], + "parse": null, + "pathless": null, + "route": { + "fullPath": "/$foo/bar", + "id": "/$foo/_layout/bar", + "options": {}, + "path": "bar", + }, + "skipRouteOnParseError": false, + "static": null, + "staticInsensitive": null, + "wildcard": null, + }, + }, + "wildcard": null, + }, + ], + "prefix": undefined, + "route": null, + "skipRouteOnParseError": false, + "static": null, + "staticInsensitive": Map { + "hello" => { + "depth": 2, + "dynamic": null, + "fullPath": "/$foo/hello", + "index": null, + "kind": 0, + "optional": null, + "parent": [Circular], + "parse": null, + "pathless": null, + "route": { + "fullPath": "/$foo/hello", + "id": "/$foo/hello", + "options": {}, + "path": "$foo/hello", + }, + "skipRouteOnParseError": false, + "static": null, + "staticInsensitive": null, + "wildcard": null, + }, + }, + "suffix": undefined, + "wildcard": null, + }, + ], + "fullPath": "/", + "index": { + "depth": 1, + "dynamic": null, + "fullPath": "/", + "index": null, + "kind": 4, + "optional": null, + "parent": [Circular], + "parse": null, + "pathless": null, + "route": { + "fullPath": "/", + "id": "/", + "options": {}, + "path": "/", + }, + "skipRouteOnParseError": false, + "static": null, + "staticInsensitive": null, + "wildcard": null, + }, + "kind": 0, + "optional": null, + "parent": null, + "parse": null, + "pathless": null, + "route": null, + "skipRouteOnParseError": false, + "static": null, + "staticInsensitive": null, + "wildcard": null, + } + `) + }) + }) }) describe('processRouteMasks', { sequential: true }, () => { diff --git a/packages/router-core/tests/skipRouteOnParseError.test.ts b/packages/router-core/tests/skipRouteOnParseError.test.ts new file mode 100644 index 0000000000..ae124cad5e --- /dev/null +++ b/packages/router-core/tests/skipRouteOnParseError.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, it, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, RouterCore } from '../src' + +describe('skipRouteOnParseError optimization', () => { + it('should call params.parse only once for routes with skipRouteOnParseError', async () => { + const rootRoute = new BaseRootRoute() + + const parseSpy = vi.fn((params: { id: string }) => ({ + id: Number(params.id), + })) + + const route = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/posts/$id', + params: { + parse: parseSpy, + }, + skipRouteOnParseError: true, + }) + + const routeTree = rootRoute.addChildren([route]) + + const router = new RouterCore({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/posts/123'] }), + }) + + await router.load() + + // params.parse should be called exactly once during matching + expect(parseSpy).toHaveBeenCalledTimes(1) + expect(parseSpy).toHaveBeenCalledWith({ id: '123' }) + + // Verify the parsed params are available in the match + const match = router.state.matches.find((m) => m.routeId === '/posts/$id') + expect(match?.params).toEqual({ id: 123 }) + }) + + it('should call params.parse for nested routes with skipRouteOnParseError only once each', async () => { + const rootRoute = new BaseRootRoute() + + const parentParseSpy = vi.fn((params: { userId: string }) => ({ + userId: Number(params.userId), + })) + + const childParseSpy = vi.fn((params: { postId: string }) => ({ + postId: Number(params.postId), + })) + + const userRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/users/$userId', + params: { + parse: parentParseSpy, + }, + skipRouteOnParseError: true, + }) + + const postRoute = new BaseRoute({ + getParentRoute: () => userRoute, + path: 'posts/$postId', + params: { + parse: childParseSpy, + }, + skipRouteOnParseError: true, + }) + + const routeTree = rootRoute.addChildren([ + userRoute.addChildren([postRoute]), + ]) + + const router = new RouterCore({ + routeTree, + history: createMemoryHistory({ + initialEntries: ['/users/456/posts/789'], + }), + }) + + await router.load() + + // Each params.parse should be called exactly once during matching + expect(parentParseSpy).toHaveBeenCalledTimes(1) + expect(parentParseSpy).toHaveBeenCalledWith({ userId: '456' }) + + expect(childParseSpy).toHaveBeenCalledTimes(1) + expect(childParseSpy).toHaveBeenCalledWith({ userId: '456', postId: '789' }) + + // Verify the parsed params are available and accumulated + const userMatch = router.state.matches.find( + (m) => m.routeId === '/users/$userId', + ) + expect(userMatch?.params).toEqual({ userId: 456, postId: 789 }) + + const postMatch = router.state.matches.find( + (m) => m.routeId === '/users/$userId/posts/$postId', + ) + expect(postMatch?.params).toEqual({ userId: 456, postId: 789 }) + }) + + it('should still call params.parse for routes WITHOUT skipRouteOnParseError', async () => { + const rootRoute = new BaseRootRoute() + + const parseSpy = vi.fn((params: { id: string }) => ({ + id: Number(params.id), + })) + + const route = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/posts/$id', + params: { + parse: parseSpy, + }, + // skipRouteOnParseError is NOT set (defaults to false) + }) + + const routeTree = rootRoute.addChildren([route]) + + const router = new RouterCore({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/posts/123'] }), + }) + + await router.load() + + // params.parse should be called during matchRoutesInternal (not during matching) + expect(parseSpy).toHaveBeenCalledTimes(1) + // Note: receives parsed params because parent doesn't exist, so strictParams contains the parsed value + expect(parseSpy).toHaveBeenCalledWith({ id: 123 }) + + // Verify the parsed params are available + const match = router.state.matches.find((m) => m.routeId === '/posts/$id') + expect(match?.params).toEqual({ id: 123 }) + }) + + it('should skip route during matching if params.parse throws with skipRouteOnParseError', async () => { + const rootRoute = new BaseRootRoute() + + const strictParseSpy = vi.fn((params: { id: string }) => { + const num = Number(params.id) + if (isNaN(num)) { + throw new Error('Invalid ID') + } + return { id: num } + }) + + const fallbackParseSpy = vi.fn((params: { slug: string }) => ({ + slug: params.slug, + })) + + const strictRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/posts/$id', + params: { + parse: strictParseSpy, + }, + skipRouteOnParseError: true, + }) + + const fallbackRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/posts/$slug', + params: { + parse: fallbackParseSpy, + }, + }) + + const routeTree = rootRoute.addChildren([strictRoute, fallbackRoute]) + + const router = new RouterCore({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/posts/invalid'] }), + }) + + await router.load() + + // strictParseSpy should be called and throw, causing the route to be skipped + expect(strictParseSpy).toHaveBeenCalledTimes(1) + expect(strictParseSpy).toHaveBeenCalledWith({ id: 'invalid' }) + + // fallbackParseSpy should be called for the fallback route + expect(fallbackParseSpy).toHaveBeenCalledTimes(1) + expect(fallbackParseSpy).toHaveBeenCalledWith({ slug: 'invalid' }) + + // Verify we matched the fallback route, not the strict route + const matches = router.state.matches.map((m) => m.routeId) + expect(matches).toContain('/posts/$slug') + expect(matches).not.toContain('/posts/$id') + }) + + it('should handle mixed routes with and without skipRouteOnParseError', async () => { + const rootRoute = new BaseRootRoute() + + const skipParseSpy = vi.fn((params: { userId: string }) => ({ + userId: Number(params.userId), + })) + + const normalParseSpy = vi.fn((params: { postId: string }) => ({ + postId: Number(params.postId), + })) + + const userRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/users/$userId', + params: { + parse: skipParseSpy, + }, + skipRouteOnParseError: true, + }) + + const postRoute = new BaseRoute({ + getParentRoute: () => userRoute, + path: 'posts/$postId', + params: { + parse: normalParseSpy, + }, + // skipRouteOnParseError NOT set + }) + + const routeTree = rootRoute.addChildren([ + userRoute.addChildren([postRoute]), + ]) + + const router = new RouterCore({ + routeTree, + history: createMemoryHistory({ + initialEntries: ['/users/456/posts/789'], + }), + }) + + await router.load() + + // skipParseSpy should be called once during matching + expect(skipParseSpy).toHaveBeenCalledTimes(1) + + // normalParseSpy should be called once during matchRoutesInternal + expect(normalParseSpy).toHaveBeenCalledTimes(1) + + // Both should have correct params + const userMatch = router.state.matches.find( + (m) => m.routeId === '/users/$userId', + ) + expect(userMatch?.params).toEqual({ userId: 456, postId: 789 }) + + const postMatch = router.state.matches.find( + (m) => m.routeId === '/users/$userId/posts/$postId', + ) + expect(postMatch?.params).toEqual({ userId: 456, postId: 789 }) + }) +})