From d9e403b8b32867f9f84e33120467e0d1bcc13a11 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 21 Nov 2025 21:44:41 +0100 Subject: [PATCH 01/11] feat(router-core): validate params while matching --- .../router-core/src/new-process-route-tree.ts | 118 ++++++++++++++---- 1 file changed, 93 insertions(+), 25 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 7dda425f227..1996bd7afb5 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -174,6 +174,7 @@ 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 while (cursor < length) { const segment = parseSegment(path, cursor, data) let nextNode: AnySegmentNode @@ -232,12 +233,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 && + node.dynamic?.find( + (s) => + !s.parse && + s.caseSensitive === actuallyCaseSensitive && + s.prefix === prefix && + s.suffix === suffix, + ) if (existingNode) { nextNode = existingNode } else { @@ -271,12 +275,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 && + node.optional?.find( + (s) => + !s.parse && + s.caseSensitive === actuallyCaseSensitive && + s.prefix === prefix && + s.suffix === suffix, + ) if (existingNode) { nextNode = existingNode } else { @@ -326,6 +333,7 @@ function parseSegments( } node = nextNode } + node.parse = parse if ((route.path || !route.children) && !route.isRoot) { const isIndex = path.endsWith('/') // we cannot fuzzy match an index route, @@ -351,9 +359,21 @@ 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) + }, + b: { + prefix?: string + suffix?: string + caseSensitive: boolean + parse: null | ((params: Record) => any) + }, ) { + if (a.parse && !b.parse) return -1 + if (!a.parse && b.parse) 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 @@ -421,6 +441,7 @@ function createStaticNode( parent: null, isIndex: false, notFound: null, + parse: null, } } @@ -451,6 +472,7 @@ function createDynamicNode( parent: null, isIndex: false, notFound: null, + parse: null, caseSensitive, prefix, suffix, @@ -508,6 +530,9 @@ type SegmentNode = { /** Same as `route`, but only present if both an "index route" and a "layout route" exist at this path */ notFound: T | null + + /** route.options.params.parse function, set on the last node of the route */ + parse: null | ((params: Record) => any) } type RouteLike = { @@ -517,6 +542,9 @@ type RouteLike = { isRoot?: boolean options?: { caseSensitive?: boolean + params?: { + parse?: (params: Record) => any + } } } & // router tree @@ -706,7 +734,7 @@ function findMatch( const parts = path.split('/') const leaf = getNodeMatch(path, parts, segmentTree, fuzzy) if (!leaf) return null - const params = extractParams(path, parts, leaf) + const [params] = extractParams(path, parts, leaf) const isFuzzyMatch = '**' in leaf if (isFuzzyMatch) params['**'] = leaf['**'] const route = isFuzzyMatch @@ -721,16 +749,23 @@ function findMatch( 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 @@ -785,7 +820,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) { @@ -823,6 +859,10 @@ type MatchStackFrame = { statics: number dynamics: number optionals: number + /** intermediary state for param extraction */ + extract?: { part: number; node: number; path: number } + /** intermediary params from param extraction */ + params?: Record } function getNodeMatch( @@ -862,8 +902,22 @@ 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 } = frame + + if (node.parse) { + // if there is a parse function, we need to extract the params that we have so far and run it. + // if this function throws, we cannot consider this a valid match + try { + ;[params, extract] = extractParams(path, parts, frame) + // TODO: can we store the parsed value somewhere to avoid re-parsing later? + node.parse(params) + frame.extract = extract + frame.params = params + } catch { + continue + } + } // In fuzzy mode, track the best partial match we've found so far if (fuzzy && node.notFound && isFrameMoreSpecific(bestFuzzy, frame)) { @@ -913,6 +967,8 @@ function getNodeMatch( statics, dynamics, optionals, + extract, + params, } break } @@ -933,6 +989,8 @@ function getNodeMatch( statics, dynamics, optionals, + extract, + params, }) // enqueue skipping the optional } if (!isBeyondPath) { @@ -954,6 +1012,8 @@ function getNodeMatch( statics, dynamics, optionals: optionals + 1, + extract, + params, }) } } @@ -979,6 +1039,8 @@ function getNodeMatch( statics, dynamics: dynamics + 1, optionals, + extract, + params, }) } } @@ -997,6 +1059,8 @@ function getNodeMatch( statics: statics + 1, dynamics, optionals, + extract, + params, }) } } @@ -1013,6 +1077,8 @@ function getNodeMatch( statics: statics + 1, dynamics, optionals, + extract, + params, }) } } @@ -1031,6 +1097,8 @@ function getNodeMatch( return { node: bestFuzzy.node, skipped: bestFuzzy.skipped, + extract: bestFuzzy.extract, + params: bestFuzzy.params, '**': decodeURIComponent(splat), } } From 2fd300956a9be572dbe106a0df85cd137b8c8873 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 30 Nov 2025 12:29:27 +0100 Subject: [PATCH 02/11] more wip --- .../router-core/src/new-process-route-tree.ts | 83 ++++++++++++++----- packages/router-core/src/route.ts | 2 + packages/router-core/src/router.ts | 5 +- 3 files changed, 69 insertions(+), 21 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index b5254498851..682eea5ec6d 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -175,6 +175,7 @@ function parseSegments( 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 @@ -234,10 +235,10 @@ function parseSegments( ? suffix_raw : suffix_raw.toLowerCase() const existingNode = - !parse && + (!parse || !skipRouteOnParseError) && node.dynamic?.find( (s) => - !s.parse && + (!s.parse || !s.skipRouteOnParseError) && s.caseSensitive === actuallyCaseSensitive && s.prefix === prefix && s.suffix === suffix, @@ -276,10 +277,10 @@ function parseSegments( ? suffix_raw : suffix_raw.toLowerCase() const existingNode = - !parse && + (!parse || !skipRouteOnParseError) && node.optional?.find( (s) => - !s.parse && + (!s.parse || !s.skipRouteOnParseError) && s.caseSensitive === actuallyCaseSensitive && s.prefix === prefix && s.suffix === suffix, @@ -334,6 +335,7 @@ function parseSegments( node = nextNode } node.parse = parse + node.skipRouteOnParseError = skipRouteOnParseError if ((route.path || !route.children) && !route.isRoot) { const isIndex = path.endsWith('/') // we cannot fuzzy match an index route, @@ -442,6 +444,7 @@ function createStaticNode( isIndex: false, notFound: null, parse: null, + skipRouteOnParseError: false, } } @@ -473,6 +476,7 @@ function createDynamicNode( isIndex: false, notFound: null, parse: null, + skipRouteOnParseError: false, caseSensitive, prefix, suffix, @@ -533,6 +537,9 @@ type SegmentNode = { /** 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 = { @@ -541,6 +548,7 @@ type RouteLike = { parentRoute?: RouteLike // parent route, isRoot?: boolean options?: { + skipRouteOnParseError?: boolean caseSensitive?: boolean params?: { parse?: (params: Record) => any @@ -635,6 +643,7 @@ type RouteMatch> = { route: T params: Record branch: ReadonlyArray + error?: unknown } export function findRouteMatch< @@ -730,22 +739,29 @@ function findMatch( path: string, segmentTree: AnySegmentNode, fuzzy = false, -): { route: T; params: Record } | null { +): { route: T; params: Record; error?: unknown } | null { const parts = path.split('/') const leaf = getNodeMatch(path, parts, segmentTree, fuzzy) if (!leaf) return null const [params] = extractParams(path, parts, leaf) - const isFuzzyMatch = '**' in leaf - if (isFuzzyMatch) params['**'] = leaf['**'] + const isFuzzyMatch = '**' in params const route = isFuzzyMatch ? (leaf.node.notFound ?? leaf.node.route!) : leaf.node.route! return { route, params, + error: leaf.error, } } +/** + * 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, @@ -862,7 +878,12 @@ type MatchStackFrame = { /** intermediary state for param extraction */ extract?: { part: number; node: number; path: number } /** intermediary params from param extraction */ + // TODO: I'm not sure, but I think we need both the raw strings for `interpolatePath` and the parsed values for the final match object + // I think they can still be accumulated (separately) in a single object (each) because `interpolatePath` returns the `usedParams` anyway params?: Record + /** capture error from parse function */ + // TODO: we might need to get a Map instead, so that matches can be built correctly + error?: unknown } function getNodeMatch( @@ -903,19 +924,25 @@ function getNodeMatch( while (stack.length) { const frame = stack.pop()! const { node, index, skipped, depth, statics, dynamics, optionals } = frame - let { extract, params } = frame + let { extract, params, error } = frame if (node.parse) { // if there is a parse function, we need to extract the params that we have so far and run it. // if this function throws, we cannot consider this a valid match try { ;[params, extract] = extractParams(path, parts, frame) - // TODO: can we store the parsed value somewhere to avoid re-parsing later? - node.parse(params) frame.extract = extract frame.params = params - } catch { - continue + params = node.parse(params) + frame.params = params + } catch (e) { + if (!error) { + error = e + frame.error = e + } + if (node.skipRouteOnParseError) continue + // TODO: when *not* continuing, we need to accumulate all errors so we can assign them to the + // corresponding match objects in `matchRoutesInternal`? } } @@ -959,7 +986,7 @@ function getNodeMatch( if (casePart !== suffix) continue } // the first wildcard match is the highest priority one - wildcardMatch = { + const frame = { node: segment, index, skipped, @@ -969,7 +996,22 @@ function getNodeMatch( optionals, extract, params, + error, } + // TODO: should we handle wildcard candidates like any other frame? + // then we wouldn't need to duplicate the parsing logic here + if (segment.parse) { + try { + const [params, extract] = extractParams(path, parts, frame) + frame.extract = extract + frame.params = params + frame.params = segment.parse(params) + } catch (e) { + frame.error = e + if (segment.skipRouteOnParseError) continue + } + } + wildcardMatch = frame break } } @@ -991,6 +1033,7 @@ function getNodeMatch( optionals, extract, params, + error, }) // enqueue skipping the optional } if (!isBeyondPath) { @@ -1014,6 +1057,7 @@ function getNodeMatch( optionals: optionals + 1, extract, params, + error, }) } } @@ -1041,6 +1085,7 @@ function getNodeMatch( optionals, extract, params, + error, }) } } @@ -1061,6 +1106,7 @@ function getNodeMatch( optionals, extract, params, + error, }) } } @@ -1079,6 +1125,7 @@ function getNodeMatch( optionals, extract, params, + error, }) } } @@ -1100,13 +1147,9 @@ function getNodeMatch( sliceIndex += parts[i]!.length } const splat = sliceIndex === path.length ? '/' : path.slice(sliceIndex) - return { - node: bestFuzzy.node, - skipped: bestFuzzy.skipped, - extract: bestFuzzy.extract, - params: bestFuzzy.params, - '**': decodeURIComponent(splat), - } + bestFuzzy.params ??= {} + bestFuzzy.params['**'] = decodeURIComponent(splat) + return bestFuzzy } return null diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts index 53d726ef05a..b87b657da64 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 4efaa61b7b7..ceb0c0cecbd 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -700,6 +700,7 @@ export type GetMatchRoutesFn = (pathname: string) => { matchedRoutes: ReadonlyArray routeParams: Record foundRoute: AnyRoute | undefined + parseError?: unknown } export type EmitFn = (routerEvent: RouterEvent) => void @@ -2680,15 +2681,17 @@ export function getMatchedRoutes({ const trimmedPath = trimPathRight(pathname) let foundRoute: TRouteLike | undefined = undefined + let parseError: unknown = undefined const match = findRouteMatch(trimmedPath, processedTree, true) if (match) { foundRoute = match.route Object.assign(routeParams, match.params) // Copy params, because they're cached + parseError = match.error } const matchedRoutes = match?.branch || [routesById[rootRouteId]!] - return { matchedRoutes, routeParams, foundRoute } + return { matchedRoutes, routeParams, foundRoute, parseError } } function applySearchMiddleware({ From b9c416a62790b22890b5ac061657886fbad9281f Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 21 Dec 2025 10:35:04 +0100 Subject: [PATCH 03/11] introduce index node and pathless node --- .../router-core/src/new-process-route-tree.ts | 152 +++++++++---- .../tests/new-process-route-tree.test.ts | 211 ++++++++++++++++++ 2 files changed, 321 insertions(+), 42 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index cf8f911d349..47afccc0312 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -7,12 +7,16 @@ export const SEGMENT_TYPE_PATHNAME = 0 export const SEGMENT_TYPE_PARAM = 1 export const SEGMENT_TYPE_WILDCARD = 2 export const SEGMENT_TYPE_OPTIONAL_PARAM = 3 +const SEGMENT_TYPE_PATHLESS = 4 // only used in matching to represent pathless routes that need to carry more information +const SEGMENT_TYPE_INDEX = 5 export type SegmentKind = | typeof SEGMENT_TYPE_PATHNAME | typeof SEGMENT_TYPE_PARAM | typeof SEGMENT_TYPE_WILDCARD | typeof SEGMENT_TYPE_OPTIONAL_PARAM + | typeof SEGMENT_TYPE_PATHLESS + | typeof SEGMENT_TYPE_INDEX const PARAM_W_CURLY_BRACES_RE = /^([^{]*)\{\$([a-zA-Z_$][a-zA-Z0-9_$]*)\}([^}]*)$/ // prefix{$paramName}suffix @@ -332,24 +336,48 @@ function parseSegments( node.wildcard.push(next) } } - node = nextNode + node = nextNode! } + + 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 + if (isLeaf && path.endsWith('/')) { + const indexNode = createStaticNode( + route.fullPath ?? route.from, + ) + indexNode.kind = SEGMENT_TYPE_INDEX + indexNode.parent = node + depth++ + indexNode.depth = depth + node.index = indexNode + node = indexNode + } + node.parse = parse node.skipRouteOnParseError = skipRouteOnParseError - if ((route.path || !route.children) && !route.isRoot) { - const isIndex = path.endsWith('/') - // we cannot fuzzy match an index route, - // but if there is *also* a layout route at this path, save it as notFound - // we can use it when fuzzy matching to display the NotFound component in the layout route - if (!isIndex) node.notFound = route - // does the new route take precedence over an existing one? - // yes if previous is not an index route and new one is an index route - if (!node.route || (!node.isIndex && isIndex)) { - node.route = route - // when replacing, replace all attributes that are route-specific (`fullPath` only at the moment) - node.fullPath = route.fullPath ?? route.from - } - node.isIndex ||= isIndex + if (isLeaf && !node.route) { + node.route = route + // when replacing, replace all attributes that are route-specific (`fullPath` only at the moment) + node.fullPath = route.fullPath ?? route.from } } if (route.children) @@ -403,6 +431,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) @@ -439,6 +472,8 @@ function createStaticNode( return { kind: SEGMENT_TYPE_PATHNAME, depth: 0, + pathless: null, + index: null, static: null, staticInsensitive: null, dynamic: null, @@ -447,8 +482,6 @@ function createStaticNode( route: null, fullPath, parent: null, - isIndex: false, - notFound: null, parse: null, skipRouteOnParseError: false, } @@ -471,6 +504,8 @@ function createDynamicNode( return { kind, depth: 0, + pathless: null, + index: null, static: null, staticInsensitive: null, dynamic: null, @@ -479,8 +514,6 @@ function createDynamicNode( route: null, fullPath, parent: null, - isIndex: false, - notFound: null, parse: null, skipRouteOnParseError: false, caseSensitive, @@ -490,14 +523,17 @@ function createDynamicNode( } type StaticSegmentNode = SegmentNode & { - kind: typeof SEGMENT_TYPE_PATHNAME + kind: + | typeof SEGMENT_TYPE_PATHNAME + | typeof SEGMENT_TYPE_PATHLESS + | typeof SEGMENT_TYPE_INDEX } type DynamicSegmentNode = SegmentNode & { kind: - | typeof SEGMENT_TYPE_PARAM - | typeof SEGMENT_TYPE_WILDCARD - | typeof SEGMENT_TYPE_OPTIONAL_PARAM + | typeof SEGMENT_TYPE_PARAM + | typeof SEGMENT_TYPE_WILDCARD + | typeof SEGMENT_TYPE_OPTIONAL_PARAM prefix?: string suffix?: string caseSensitive: boolean @@ -510,6 +546,10 @@ type AnySegmentNode = type SegmentNode = { kind: SegmentKind + pathless: Array> | null + + index: StaticSegmentNode | null + /** Static segments (highest priority) */ static: Map> | null @@ -535,12 +575,6 @@ type SegmentNode = { depth: number - /** is it an index route (trailing / path), only valid for nodes with a `route` */ - isIndex: boolean - - /** Same as `route`, but only present if both an "index route" and a "layout route" exist at this path */ - notFound: T | null - /** route.options.params.parse function, set on the last node of the route */ parse: null | ((params: Record) => any) @@ -549,6 +583,7 @@ type SegmentNode = { } type RouteLike = { + id?: string path?: string // relative path from the parent, children?: Array // child routes, parentRoute?: RouteLike // parent route, @@ -750,10 +785,7 @@ function findMatch( const leaf = getNodeMatch(path, parts, segmentTree, fuzzy) if (!leaf) return null const [params] = extractParams(path, parts, leaf) - const isFuzzyMatch = '**' in params - const route = isFuzzyMatch - ? (leaf.node.notFound ?? leaf.node.route!) - : leaf.node.route! + const route = leaf.node.route! return { route, params, @@ -778,9 +810,9 @@ function extractParams( params?: Record }, ): [ - params: Record, - state: { part: number; node: number; path: number }, -] { + params: Record, + state: { part: number; node: number; path: number }, + ] { const list = buildBranch(leaf.node) let nodeParts: Array | null = null const params: Record = {} @@ -900,7 +932,7 @@ function getNodeMatch( ) { const trailingSlash = !last(parts) const pathIsIndex = trailingSlash && path !== '/' - const partsLength = parts.length - (trailingSlash ? 1 : 0) + const partsLength = parts.length type Frame = MatchStackFrame @@ -953,19 +985,19 @@ function getNodeMatch( } // In fuzzy mode, track the best partial match we've found so far - if (fuzzy && node.notFound && isFrameMoreSpecific(bestFuzzy, frame)) { + if (fuzzy && node.kind !== SEGMENT_TYPE_INDEX && isFrameMoreSpecific(bestFuzzy, frame)) { bestFuzzy = frame } const isBeyondPath = index === partsLength if (isBeyondPath) { - if (node.route && (!pathIsIndex || node.isIndex)) { + if (node.route && (!pathIsIndex || node.kind === SEGMENT_TYPE_INDEX)) { if (isFrameMoreSpecific(bestMatch, frame)) { bestMatch = frame } // perfect match, no need to continue - if (statics === partsLength && node.isIndex) return bestMatch + if (statics === partsLength && node.kind === SEGMENT_TYPE_INDEX) return bestMatch } // beyond the length of the path parts, only skipped optional segments or wildcard segments can match if (!node.optional && !node.wildcard) continue @@ -1135,6 +1167,42 @@ function getNodeMatch( }) } } + + // 0. Try index match + if (node.index) { + stack.push({ + node: node.index, + index: index + 1, + skipped, + depth: depth + 1, + statics: statics + 1, + dynamics, + optionals, + extract, + params, + error, + }) + } + + // 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, + error, + }) + } + } } if (bestMatch && wildcardMatch) { @@ -1175,8 +1243,8 @@ function isFrameMoreSpecific( (next.dynamics === prev.dynamics && (next.optionals > prev.optionals || (next.optionals === prev.optionals && - (next.node.isIndex > prev.node.isIndex || - (next.node.isIndex === prev.node.isIndex && + ((next.node.kind === SEGMENT_TYPE_INDEX) > (prev.node.kind === SEGMENT_TYPE_INDEX) || + ((next.node.kind === SEGMENT_TYPE_INDEX) === (prev.node.kind === SEGMENT_TYPE_INDEX) && next.depth > prev.depth))))))) ) } 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 746cd470928..06a70d26eb7 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": 5, + "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": 4, + "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": 5, + "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 }, () => { From 5e0ea276ee524225197947cb19040f8d61351263 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 21 Dec 2025 17:10:46 +0100 Subject: [PATCH 04/11] merge typo --- packages/router-core/src/new-process-route-tree.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 85681246244..33ce245c0b2 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -18,13 +18,14 @@ export type SegmentKind = | typeof SEGMENT_TYPE_PARAM | typeof SEGMENT_TYPE_WILDCARD | typeof SEGMENT_TYPE_OPTIONAL_PARAM - | typeof SEGMENT_TYPE_PATHLESS - | typeof SEGMENT_TYPE_INDEX /** * 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 From fca20cefc249bb4ec4ec201823c564c6cccb3952 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 21 Dec 2025 17:22:46 +0100 Subject: [PATCH 05/11] more post-merge fixes --- .../router-core/src/new-process-route-tree.ts | 27 ++++++------------- .../tests/new-process-route-tree.test.ts | 6 ++--- 2 files changed, 11 insertions(+), 22 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 33ce245c0b2..1f8fb35dc75 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -345,9 +345,10 @@ function parseSegments( node.wildcard.push(next) } } - node = nextNode! + node = nextNode } + // create pathless node if ( parse && skipRouteOnParseError && @@ -384,9 +385,10 @@ function parseSegments( node.parse = parse node.skipRouteOnParseError = skipRouteOnParseError + + // make node "matchable" if (isLeaf && !node.route) { node.route = route - // when replacing, replace all attributes that are route-specific (`fullPath` only at the moment) node.fullPath = route.fullPath ?? route.from } } @@ -948,7 +950,7 @@ function getNodeMatch( const trailingSlash = !last(parts) const pathIsIndex = trailingSlash && path !== '/' - const partsLength = parts.length + const partsLength = parts.length - (trailingSlash ? 1 : 0) type Frame = MatchStackFrame @@ -1032,6 +1034,9 @@ function getNodeMatch( statics, dynamics, optionals, + extract, + params, + error, } // perfect match, no need to continue // this is an optimization, algorithm should work correctly without this block @@ -1207,22 +1212,6 @@ function getNodeMatch( } } - // 0. Try index match - if (node.index) { - stack.push({ - node: node.index, - index: index + 1, - skipped, - depth: depth + 1, - statics: statics + 1, - dynamics, - optionals, - extract, - params, - error, - }) - } - // 0. Try pathless match if (node.pathless) { const nextDepth = depth + 1 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 9e96704043f..fd9ea9828d5 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -933,7 +933,7 @@ describe('findRouteMatch', () => { "dynamic": null, "fullPath": "/$foo/", "index": null, - "kind": 5, + "kind": 4, "optional": null, "parent": [Circular], "parse": null, @@ -949,7 +949,7 @@ describe('findRouteMatch', () => { "staticInsensitive": null, "wildcard": null, }, - "kind": 4, + "kind": 5, "optional": null, "parent": [Circular], "parse": [Function], @@ -1044,7 +1044,7 @@ describe('findRouteMatch', () => { "dynamic": null, "fullPath": "/", "index": null, - "kind": 5, + "kind": 4, "optional": null, "parent": [Circular], "parse": null, From c577ce9d3f9f9ec40ce716a0e63d7984faf3b57d Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 21 Dec 2025 17:57:40 +0100 Subject: [PATCH 06/11] don't handle regular parsing, only skip parsing --- .../router-core/src/new-process-route-tree.ts | 89 ++++++++----------- packages/router-core/src/router.ts | 4 +- 2 files changed, 37 insertions(+), 56 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 1f8fb35dc75..afdf56346d1 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -536,16 +536,16 @@ function createDynamicNode( type StaticSegmentNode = SegmentNode & { kind: - | typeof SEGMENT_TYPE_PATHNAME - | typeof SEGMENT_TYPE_PATHLESS - | typeof SEGMENT_TYPE_INDEX + | typeof SEGMENT_TYPE_PATHNAME + | typeof SEGMENT_TYPE_PATHLESS + | typeof SEGMENT_TYPE_INDEX } type DynamicSegmentNode = SegmentNode & { kind: - | typeof SEGMENT_TYPE_PARAM - | typeof SEGMENT_TYPE_WILDCARD - | typeof SEGMENT_TYPE_OPTIONAL_PARAM + | typeof SEGMENT_TYPE_PARAM + | typeof SEGMENT_TYPE_WILDCARD + | typeof SEGMENT_TYPE_OPTIONAL_PARAM prefix?: string suffix?: string caseSensitive: boolean @@ -802,7 +802,6 @@ function findMatch( return { route, params, - error: leaf.error, } } @@ -823,9 +822,9 @@ function extractParams( params?: Record }, ): [ - params: Record, - state: { part: number; node: number; path: number }, -] { + params: Record, + state: { part: number; node: number; path: number }, + ] { const list = buildBranch(leaf.node) let nodeParts: Array | null = null const params: Record = {} @@ -932,9 +931,6 @@ type MatchStackFrame = { // TODO: I'm not sure, but I think we need both the raw strings for `interpolatePath` and the parsed values for the final match object // I think they can still be accumulated (separately) in a single object (each) because `interpolatePath` returns the `usedParams` anyway params?: Record - /** capture error from parse function */ - // TODO: we might need to get a Map instead, so that matches can be built correctly - error?: unknown } function getNodeMatch( @@ -980,26 +976,13 @@ function getNodeMatch( while (stack.length) { const frame = stack.pop()! const { node, index, skipped, depth, statics, dynamics, optionals } = frame - let { extract, params, error } = frame - - if (node.parse) { - // if there is a parse function, we need to extract the params that we have so far and run it. - // if this function throws, we cannot consider this a valid match - try { - ;[params, extract] = extractParams(path, parts, frame) - frame.extract = extract - frame.params = params - params = node.parse(params) - frame.params = params - } catch (e) { - if (!error) { - error = e - frame.error = e - } - if (node.skipRouteOnParseError) continue - // TODO: when *not* continuing, we need to accumulate all errors so we can assign them to the - // corresponding match objects in `matchRoutesInternal`? - } + let { extract, params } = frame + + if (node.skipRouteOnParseError && node.parse) { + const result = validateMatchParams(path, parts, frame) + if (!result) continue + params = result[0] + extract = result[1] } // In fuzzy mode, track the best partial match we've found so far @@ -1036,7 +1019,10 @@ function getNodeMatch( optionals, extract, params, - error, + } + 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 @@ -1078,20 +1064,10 @@ function getNodeMatch( optionals, extract, params, - error, } - // TODO: should we handle wildcard candidates like any other frame? - // then we wouldn't need to duplicate the parsing logic here - if (segment.parse) { - try { - const [params, extract] = extractParams(path, parts, frame) - frame.extract = extract - frame.params = params - frame.params = segment.parse(params) - } catch (e) { - frame.error = e - if (segment.skipRouteOnParseError) continue - } + if (segment.skipRouteOnParseError && segment.parse) { + const result = validateMatchParams(path, parts, frame) + if (!result) continue } wildcardMatch = frame break @@ -1115,7 +1091,6 @@ function getNodeMatch( optionals, extract, params, - error, }) // enqueue skipping the optional } if (!isBeyondPath) { @@ -1139,7 +1114,6 @@ function getNodeMatch( optionals: optionals + 1, extract, params, - error, }) } } @@ -1167,7 +1141,6 @@ function getNodeMatch( optionals, extract, params, - error, }) } } @@ -1188,7 +1161,6 @@ function getNodeMatch( optionals, extract, params, - error, }) } } @@ -1207,7 +1179,6 @@ function getNodeMatch( optionals, extract, params, - error, }) } } @@ -1227,7 +1198,6 @@ function getNodeMatch( optionals, extract, params, - error, }) } } @@ -1257,6 +1227,19 @@ function getNodeMatch( 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] + result[0] = frame.node.parse!(result[0]) + frame.params = result[0] + 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/router.ts b/packages/router-core/src/router.ts index 874fd679cb9..ffd4de77aa5 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2693,17 +2693,15 @@ export function getMatchedRoutes({ const trimmedPath = trimPathRight(pathname) let foundRoute: TRouteLike | undefined = undefined - let parseError: unknown = undefined const match = findRouteMatch(trimmedPath, processedTree, true) if (match) { foundRoute = match.route Object.assign(routeParams, match.params) // Copy params, because they're cached - parseError = match.error } const matchedRoutes = match?.branch || [routesById[rootRouteId]!] - return { matchedRoutes, routeParams, foundRoute, parseError } + return { matchedRoutes, routeParams, foundRoute } } function applySearchMiddleware({ From 035305935aa121568f1cb26bfc05f1a182e1bc3d Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 22 Dec 2025 00:52:21 +0100 Subject: [PATCH 07/11] fix sorting --- packages/router-core/src/new-process-route-tree.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index afdf56346d1..d773e0b3c1a 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -412,16 +412,18 @@ function sortDynamic( 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 && !b.parse) return -1 - if (!a.parse && b.parse) return 1 + 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 From c67704e3a1c832c57b778fcb4f0387e77c2fb67c Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 22 Dec 2025 00:52:48 +0100 Subject: [PATCH 08/11] format --- .../router-core/src/new-process-route-tree.ts | 38 +++++++++++++------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index d773e0b3c1a..0703c9bf447 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -422,8 +422,18 @@ function sortDynamic( 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.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 @@ -538,16 +548,16 @@ function createDynamicNode( type StaticSegmentNode = SegmentNode & { kind: - | typeof SEGMENT_TYPE_PATHNAME - | typeof SEGMENT_TYPE_PATHLESS - | typeof SEGMENT_TYPE_INDEX + | typeof SEGMENT_TYPE_PATHNAME + | typeof SEGMENT_TYPE_PATHLESS + | typeof SEGMENT_TYPE_INDEX } type DynamicSegmentNode = SegmentNode & { kind: - | typeof SEGMENT_TYPE_PARAM - | typeof SEGMENT_TYPE_WILDCARD - | typeof SEGMENT_TYPE_OPTIONAL_PARAM + | typeof SEGMENT_TYPE_PARAM + | typeof SEGMENT_TYPE_WILDCARD + | typeof SEGMENT_TYPE_OPTIONAL_PARAM prefix?: string suffix?: string caseSensitive: boolean @@ -824,9 +834,9 @@ function extractParams( params?: Record }, ): [ - params: Record, - state: { part: number; node: number; path: number }, - ] { + params: Record, + state: { part: number; node: number; path: number }, +] { const list = buildBranch(leaf.node) let nodeParts: Array | null = null const params: Record = {} @@ -1229,7 +1239,11 @@ function getNodeMatch( return null } -function validateMatchParams(path: string, parts: Array, frame: MatchStackFrame) { +function validateMatchParams( + path: string, + parts: Array, + frame: MatchStackFrame, +) { try { const result = extractParams(path, parts, frame) frame.params = result[0] From c221f3ec2e912a1fb6125570beab42ec07e01c19 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 22 Dec 2025 01:21:03 +0100 Subject: [PATCH 09/11] remove error from types, its currently unused --- packages/router-core/src/new-process-route-tree.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 0703c9bf447..1785e31101a 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -709,7 +709,6 @@ type RouteMatch> = { route: T params: Record branch: ReadonlyArray - error?: unknown } export function findRouteMatch< @@ -805,7 +804,7 @@ function findMatch( path: string, segmentTree: AnySegmentNode, fuzzy = false, -): { route: T; params: Record; error?: unknown } | null { +): { route: T; params: Record; } | null { const parts = path.split('/') const leaf = getNodeMatch(path, parts, segmentTree, fuzzy) if (!leaf) return null From 1a3df20ba125d5132a401ab7ce30581677a02054 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 22 Dec 2025 01:21:20 +0100 Subject: [PATCH 10/11] format --- packages/router-core/src/new-process-route-tree.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 1785e31101a..b2eb99ab017 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -804,7 +804,7 @@ function findMatch( path: string, segmentTree: AnySegmentNode, fuzzy = false, -): { route: T; params: Record; } | null { +): { route: T; params: Record } | null { const parts = path.split('/') const leaf = getNodeMatch(path, parts, segmentTree, fuzzy) if (!leaf) return null From b1ca538968e5dc641b85de5ba8f740e125e3a663 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 22 Dec 2025 09:53:42 +0100 Subject: [PATCH 11/11] feat(router-core): validate params while matching (extraction w/ sonnet WIP) --- .../router-core/src/new-process-route-tree.ts | 33 ++- packages/router-core/src/router.ts | 18 +- .../tests/skipRouteOnParseError.test.ts | 250 ++++++++++++++++++ 3 files changed, 290 insertions(+), 11 deletions(-) create mode 100644 packages/router-core/tests/skipRouteOnParseError.test.ts diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index b2eb99ab017..10253e20150 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -709,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< @@ -804,7 +806,11 @@ 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 @@ -813,6 +819,7 @@ function findMatch( return { route, params, + parsedParams: leaf.parsedParams, } } @@ -938,10 +945,10 @@ type MatchStackFrame = { optionals: number /** intermediary state for param extraction */ extract?: { part: number; node: number; path: number } - /** intermediary params from param extraction */ - // TODO: I'm not sure, but I think we need both the raw strings for `interpolatePath` and the parsed values for the final match object - // I think they can still be accumulated (separately) in a single object (each) because `interpolatePath` returns the `usedParams` anyway + /** intermediary raw string params from param extraction (for interpolatePath) */ params?: Record + /** intermediary parsed params from routes with skipRouteOnParseError */ + parsedParams?: Record } function getNodeMatch( @@ -953,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 !== '/' @@ -987,13 +994,14 @@ function getNodeMatch( while (stack.length) { const frame = stack.pop()! const { node, index, skipped, depth, statics, dynamics, optionals } = frame - let { extract, params } = 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 @@ -1030,6 +1038,7 @@ function getNodeMatch( optionals, extract, params, + parsedParams, } if (node.index.skipRouteOnParseError && node.index.parse) { const result = validateMatchParams(path, parts, indexFrame) @@ -1075,6 +1084,7 @@ function getNodeMatch( optionals, extract, params, + parsedParams, } if (segment.skipRouteOnParseError && segment.parse) { const result = validateMatchParams(path, parts, frame) @@ -1102,6 +1112,7 @@ function getNodeMatch( optionals, extract, params, + parsedParams, }) // enqueue skipping the optional } if (!isBeyondPath) { @@ -1125,6 +1136,7 @@ function getNodeMatch( optionals: optionals + 1, extract, params, + parsedParams, }) } } @@ -1152,6 +1164,7 @@ function getNodeMatch( optionals, extract, params, + parsedParams, }) } } @@ -1172,6 +1185,7 @@ function getNodeMatch( optionals, extract, params, + parsedParams, }) } } @@ -1190,6 +1204,7 @@ function getNodeMatch( optionals, extract, params, + parsedParams, }) } } @@ -1209,6 +1224,7 @@ function getNodeMatch( optionals, extract, params, + parsedParams, }) } } @@ -1247,8 +1263,9 @@ function validateMatchParams( const result = extractParams(path, parts, frame) frame.params = result[0] frame.extract = result[1] - result[0] = frame.node.parse!(result[0]) - frame.params = result[0] + const parsedParams = frame.node.parse!(result[0]) + // Accumulate parsed params from this route + frame.parsedParams = { ...frame.parsedParams, ...parsedParams } return result } catch { return null diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index ffd4de77aa5..bbc21f5113b 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -701,6 +701,7 @@ export type GetMatchRoutesFn = (pathname: string) => { routeParams: Record foundRoute: AnyRoute | undefined parseError?: unknown + parsedParams?: Record } export type EmitFn = (routerEvent: RouterEvent) => void @@ -1249,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 @@ -1393,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, @@ -1412,6 +1419,9 @@ export class RouterCore< throw paramsError } } + } else if (alreadyParsed) { + // Use the pre-parsed params from matching + Object.assign(strictParams, parsedParams) } } @@ -2693,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/skipRouteOnParseError.test.ts b/packages/router-core/tests/skipRouteOnParseError.test.ts new file mode 100644 index 00000000000..ae124cad5e9 --- /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 }) + }) +})