From d9e403b8b32867f9f84e33120467e0d1bcc13a11 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 21 Nov 2025 21:44:41 +0100 Subject: [PATCH 01/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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 152a6bfab538fabfef618ef29a9bcd4cd72d383d Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 22 Dec 2025 21:05:43 +0100 Subject: [PATCH 11/21] collect rawParams and parsedParams instead of just 'params' --- .../router-core/src/new-process-route-tree.ts | 101 +++++++++------ packages/router-core/src/router.ts | 63 ++++++---- packages/router-core/tests/foo.test.ts | 118 ++++++++++++++++++ 3 files changed, 219 insertions(+), 63 deletions(-) create mode 100644 packages/router-core/tests/foo.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..1c30be7552b 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -707,7 +707,8 @@ export function findSingleMatch( type RouteMatch> = { route: T - params: Record + rawParams: Record + parsedParams?: Record branch: ReadonlyArray } @@ -804,21 +805,33 @@ function findMatch( path: string, segmentTree: AnySegmentNode, fuzzy = false, -): { route: T; params: Record } | null { +): { + route: T + /** + * The raw (unparsed) params extracted from the path. + * This will be the exhaustive list of all params defined in the route's path. + */ + rawParams: Record + /** + * The accumlulated parsed params of each route in the branch that had `skipRouteOnParseError` enabled. + * Will not contain all params defined in the route's path. Those w/ a `params.parse` but no `skipRouteOnParseError` will need to be parsed separately. + */ + parsedParams?: Record +} | null { const parts = path.split('/') const leaf = getNodeMatch(path, parts, segmentTree, fuzzy) if (!leaf) return null - const [params] = extractParams(path, parts, leaf) - const route = leaf.node.route! + const [rawParams] = extractParams(path, parts, leaf) return { - route, - params, + route: leaf.node.route!, + rawParams, + parsedParams: leaf.parsedParams, } } /** * This function is "resumable": - * - the `leaf` input can contain `extract` and `params` properties from a previous `extractParams` call + * - the `leaf` input can contain `extract` and `rawParams` 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. @@ -830,15 +843,15 @@ function extractParams( node: AnySegmentNode skipped: number extract?: { part: number; node: number; path: number } - params?: Record + rawParams?: Record }, ): [ - params: Record, + rawParams: Record, state: { part: number; node: number; path: number }, ] { const list = buildBranch(leaf.node) let nodeParts: Array | null = null - const params: Record = {} + const rawParams: Record = {} let partIndex = leaf.extract?.part ?? 0 let nodeIndex = leaf.extract?.node ?? 0 let pathIndex = leaf.extract?.path ?? 0 @@ -861,10 +874,10 @@ function extractParams( nodePart.length - sufLength - 1, ) const value = part!.substring(preLength, part!.length - sufLength) - params[name] = decodeURIComponent(value) + rawParams[name] = decodeURIComponent(value) } else { const name = nodePart.substring(1) - params[name] = decodeURIComponent(part!) + rawParams[name] = decodeURIComponent(part!) } } else if (node.kind === SEGMENT_TYPE_OPTIONAL_PARAM) { if (leaf.skipped & (1 << nodeIndex)) { @@ -883,7 +896,7 @@ function extractParams( node.suffix || node.prefix ? part!.substring(preLength, part!.length - sufLength) : part - if (value) params[name] = decodeURIComponent(value) + if (value) rawParams[name] = decodeURIComponent(value) } else if (node.kind === SEGMENT_TYPE_WILDCARD) { const n = node const value = path.substring( @@ -892,13 +905,13 @@ function extractParams( ) const splat = decodeURIComponent(value) // TODO: Deprecate * - params['*'] = splat - params._splat = splat + rawParams['*'] = splat + rawParams._splat = splat break } } - if (leaf.params) Object.assign(params, leaf.params) - return [params, { part: partIndex, node: nodeIndex, path: pathIndex }] + if (leaf.rawParams) Object.assign(rawParams, leaf.rawParams) + return [rawParams, { part: partIndex, node: nodeIndex, path: pathIndex }] } function buildRouteBranch(route: T) { @@ -939,9 +952,8 @@ 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 + rawParams?: Record + parsedParams?: Record } function getNodeMatch( @@ -953,7 +965,10 @@ 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 } as Pick< + Frame, + 'node' | 'skipped' | 'parsedParams' + > const trailingSlash = !last(parts) const pathIsIndex = trailingSlash && path !== '/' @@ -987,13 +1002,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, rawParams, parsedParams } = frame if (node.skipRouteOnParseError && node.parse) { const result = validateMatchParams(path, parts, frame) if (!result) continue - params = result[0] - extract = result[1] + rawParams = frame.rawParams + extract = frame.extract + parsedParams = frame.parsedParams } // In fuzzy mode, track the best partial match we've found so far @@ -1029,7 +1045,8 @@ function getNodeMatch( dynamics, optionals, extract, - params, + rawParams, + parsedParams, } if (node.index.skipRouteOnParseError && node.index.parse) { const result = validateMatchParams(path, parts, indexFrame) @@ -1074,7 +1091,8 @@ function getNodeMatch( dynamics, optionals, extract, - params, + rawParams, + parsedParams, } if (segment.skipRouteOnParseError && segment.parse) { const result = validateMatchParams(path, parts, frame) @@ -1101,7 +1119,8 @@ function getNodeMatch( dynamics, optionals, extract, - params, + rawParams, + parsedParams, }) // enqueue skipping the optional } if (!isBeyondPath) { @@ -1124,7 +1143,8 @@ function getNodeMatch( dynamics, optionals: optionals + 1, extract, - params, + rawParams, + parsedParams, }) } } @@ -1151,7 +1171,8 @@ function getNodeMatch( dynamics: dynamics + 1, optionals, extract, - params, + rawParams, + parsedParams, }) } } @@ -1171,7 +1192,8 @@ function getNodeMatch( dynamics, optionals, extract, - params, + rawParams, + parsedParams, }) } } @@ -1189,7 +1211,8 @@ function getNodeMatch( dynamics, optionals, extract, - params, + rawParams, + parsedParams, }) } } @@ -1208,7 +1231,8 @@ function getNodeMatch( dynamics, optionals, extract, - params, + rawParams, + parsedParams, }) } } @@ -1230,8 +1254,8 @@ function getNodeMatch( sliceIndex += parts[i]!.length } const splat = sliceIndex === path.length ? '/' : path.slice(sliceIndex) - bestFuzzy.params ??= {} - bestFuzzy.params['**'] = decodeURIComponent(splat) + bestFuzzy.rawParams ??= {} + bestFuzzy.rawParams['**'] = decodeURIComponent(splat) return bestFuzzy } @@ -1245,11 +1269,12 @@ function validateMatchParams( ) { try { const result = extractParams(path, parts, frame) - frame.params = result[0] + frame.rawParams = result[0] frame.extract = result[1] - result[0] = frame.node.parse!(result[0]) - frame.params = result[0] - return result + const parsed = frame.node.parse!(result[0]) + frame.parsedParams ??= {} + Object.assign(frame.parsedParams, parsed) + return true } catch { return null } diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index ffd4de77aa5..6d59661ee99 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -698,7 +698,10 @@ export type ParseLocationFn = ( export type GetMatchRoutesFn = (pathname: string) => { matchedRoutes: ReadonlyArray + /** exhaustive params, still in their string form */ routeParams: Record + /** partial params, parsed from routeParams during matching */ + parsedParams: Record | undefined foundRoute: AnyRoute | undefined parseError?: unknown } @@ -1249,7 +1252,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 @@ -1390,26 +1393,34 @@ export class RouterCore< let paramsError: unknown = undefined if (!existingMatch) { - const strictParseParams = - route.options.params?.parse ?? route.options.parseParams - - if (strictParseParams) { - try { - Object.assign( - strictParams, - strictParseParams(strictParams as Record), - ) - } catch (err: any) { - if (isNotFound(err) || isRedirect(err)) { - paramsError = err - } else { - paramsError = new PathParamError(err.message, { - cause: err, - }) + if (route.options.skipRouteOnParseError) { + for (const key in usedParams) { + if (key in parsedParams!) { + strictParams[key] = parsedParams![key] } + } + } else { + const strictParseParams = + route.options.params?.parse ?? route.options.parseParams - if (opts?.throwOnError) { - throw paramsError + if (strictParseParams) { + try { + Object.assign( + strictParams, + strictParseParams(strictParams as Record), + ) + } catch (err: any) { + if (isNotFound(err) || isRedirect(err)) { + paramsError = err + } else { + paramsError = new PathParamError(err.message, { + cause: err, + }) + } + + if (opts?.throwOnError) { + throw paramsError + } } } } @@ -1791,7 +1802,7 @@ export class RouterCore< this.processedTree, ) if (match) { - Object.assign(params, match.params) // Copy params, because they're cached + Object.assign(params, match.rawParams) // Copy params, because they're cached const { from: _from, params: maskParams, @@ -2575,18 +2586,18 @@ export class RouterCore< } if (location.params) { - if (!deepEqual(match.params, location.params, { partial: true })) { + if (!deepEqual(match.rawParams, location.params, { partial: true })) { return false } } if (opts?.includeSearch ?? true) { return deepEqual(baseLocation.search, next.search, { partial: true }) - ? match.params + ? match.rawParams : false } - return match.params + return match.rawParams } ssr?: { @@ -2693,15 +2704,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 + Object.assign(routeParams, match.rawParams) // Copy params, because they're cached + parsedParams = Object.assign({}, 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/foo.test.ts b/packages/router-core/tests/foo.test.ts new file mode 100644 index 00000000000..4e52f37e704 --- /dev/null +++ b/packages/router-core/tests/foo.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, RouterCore } from '../src' + +describe('params extract viz', () => { + const rootRoute = new BaseRootRoute() + + const order: Array = [] + const args: Record = {} + + const parseA = vi.fn((params: { a: string }) => { + order.push('a') + args.a = { ...params } + if (params.a !== 'one') throw new Error('Invalid param a') + return { a: 1 } + }) + const a = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/$a', + params: { + parse: parseA, + }, + }) + + const parseB = vi.fn((params: { b: string }) => { + order.push('b') + args.b = { ...params } + if (params.b !== 'two') throw new Error('Invalid param b') + return { b: 2 } + }) + const b = new BaseRoute({ + getParentRoute: () => a, + path: '/$b', + params: { + parse: parseB, + }, + skipRouteOnParseError: true, + }) + a.addChildren([b]) + + const parseC = vi.fn((params: { c: string }) => { + order.push('c') + args.c = { ...params } + if (params.c !== 'three') throw new Error('Invalid param c') + return { c: 3 } + }) + const c = new BaseRoute({ + getParentRoute: () => b, + path: '/$c', + params: { + parse: parseC, + }, + }) + b.addChildren([c]) + + const routeTree = rootRoute.addChildren([a]) + + const router = new RouterCore({ + routeTree, + history: createMemoryHistory(), + }) + + it('should extract params correctly', () => { + const matches = router.matchRoutes('/one/two/three') + + expect(matches).toHaveLength(4) + + // B is called first, because if's called in the matching phase because of `skipRouteOnParseError` + expect(order).toEqual(['b', 'a', 'c']) + + // A is called only once + expect(parseA).toHaveBeenCalledTimes(1) + // since it's the first, it only gets its own raw params + expect(args.a).toEqual({ a: 'one' }) + + // B is called only once + expect(parseB).toHaveBeenCalledTimes(1) + // since it's called in the matching phase, it gets all parent raw params + expect(args.b).toEqual({ a: 'one', b: 'two' }) + + // C is called only once + expect(parseC).toHaveBeenCalledTimes(1) + // since it's called last, after the matching phase, it gets parsed parent params, and its own raw param + expect(args.c).toEqual({ a: 1, b: 2, c: 'three' }) + + expect(matches[0]).toEqual( + expect.objectContaining({ + routeId: '__root__', + params: { a: 1, b: 2, c: 3 }, + _strictParams: {}, + }), + ) + + expect(matches[1]).toEqual( + expect.objectContaining({ + routeId: '/$a', + params: { a: 1, b: 2, c: 3 }, + _strictParams: { a: 1 }, + }), + ) + + expect(matches[2]).toEqual( + expect.objectContaining({ + routeId: '/$a/$b', + params: { a: 1, b: 2, c: 3 }, + _strictParams: { a: 1, b: 2 }, + }), + ) + + expect(matches[3]).toEqual( + expect.objectContaining({ + routeId: '/$a/$b/$c', + params: { a: 1, b: 2, c: 3 }, + _strictParams: { a: 1, b: 2, c: 3 }, + }), + ) + }) +}) From 725b764e1071dc0b477f63d5a4d7fa37b0597658 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 22 Dec 2025 21:11:38 +0100 Subject: [PATCH 12/21] accumulating parsed params shouldn't mutate the branch, shallow copy instead --- 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 1c30be7552b..64b23b29487 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -1272,8 +1272,7 @@ function validateMatchParams( frame.rawParams = result[0] frame.extract = result[1] const parsed = frame.node.parse!(result[0]) - frame.parsedParams ??= {} - Object.assign(frame.parsedParams, parsed) + frame.parsedParams = Object.assign({}, frame.parsedParams, parsed) return true } catch { return null From 7f821bfb2f25536919d0ae0e86e00c580e749eea Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 22 Dec 2025 21:40:41 +0100 Subject: [PATCH 13/21] fix renaming --- .../tests/curly-params-smoke.test.ts | 2 +- .../router-core/tests/match-by-path.test.ts | 28 +++++++------- .../tests/new-process-route-tree.test.ts | 37 ++++++++++--------- .../tests/optional-path-params-clean.test.ts | 2 +- .../tests/optional-path-params.test.ts | 2 +- packages/router-core/tests/path.test.ts | 2 +- 6 files changed, 38 insertions(+), 35 deletions(-) diff --git a/packages/router-core/tests/curly-params-smoke.test.ts b/packages/router-core/tests/curly-params-smoke.test.ts index 6184a205e7f..c52a6b33ec6 100644 --- a/packages/router-core/tests/curly-params-smoke.test.ts +++ b/packages/router-core/tests/curly-params-smoke.test.ts @@ -136,6 +136,6 @@ describe('curly params smoke tests', () => { } const processed = processRouteTree(tree) const res = findRouteMatch(nav, processed.processedTree) - expect(res?.params).toEqual(params) + expect(res?.rawParams).toEqual(params) }) }) diff --git a/packages/router-core/tests/match-by-path.test.ts b/packages/router-core/tests/match-by-path.test.ts index ad108080381..f742c22df9a 100644 --- a/packages/router-core/tests/match-by-path.test.ts +++ b/packages/router-core/tests/match-by-path.test.ts @@ -26,7 +26,7 @@ describe('default path matching', () => { ['/b', '/a', undefined], ])('static %s %s => %s', (path, pattern, result) => { const res = findSingleMatch(pattern, true, false, path, processedTree) - expect(res?.params).toEqual(result) + expect(res?.rawParams).toEqual(result) }) it.each([ @@ -37,7 +37,7 @@ describe('default path matching', () => { ['/a/1/b/2', '/a/$id/b/$id', { id: '2' }], ])('params %s => %s', (path, pattern, result) => { const res = findSingleMatch(pattern, true, false, path, processedTree) - expect(res?.params).toEqual(result) + expect(res?.rawParams).toEqual(result) }) it('params support more than alphanumeric characters', () => { @@ -49,7 +49,7 @@ describe('default path matching', () => { '/a/@&é"\'(§è!çà)-_°^¨$*€£`ù=+:;.,?~<>|î©#0123456789\\😀}{', processedTree, ) - expect(anyValueResult?.params).toEqual({ + expect(anyValueResult?.rawParams).toEqual({ id: '@&é"\'(§è!çà)-_°^¨$*€£`ù=+:;.,?~<>|î©#0123456789\\😀}{', }) // in the key: basically everything except / and % and $ @@ -60,7 +60,7 @@ describe('default path matching', () => { '/a/1', processedTree, ) - expect(anyKeyResult?.params).toEqual({ + expect(anyKeyResult?.rawParams).toEqual({ '@&é"\'(§è!çà)-_°^¨*€£`ù=+:;.,?~<>|î©#0123456789\\😀}{': '1', }) }) @@ -77,7 +77,7 @@ describe('default path matching', () => { ['/a/1/b/2', '/a/{-$id}/b/{-$id}', { id: '2' }], ])('optional %s => %s', (path, pattern, result) => { const res = findSingleMatch(pattern, true, false, path, processedTree) - expect(res?.params).toEqual(result) + expect(res?.rawParams).toEqual(result) }) it.each([ @@ -87,7 +87,7 @@ describe('default path matching', () => { ['/a/b/c', '/a/$/foo', { _splat: 'b/c', '*': 'b/c' }], ])('wildcard %s => %s', (path, pattern, result) => { const res = findSingleMatch(pattern, true, false, path, processedTree) - expect(res?.params).toEqual(result) + expect(res?.rawParams).toEqual(result) }) }) @@ -106,7 +106,7 @@ describe('case insensitive path matching', () => { ['/', '/b', '/A', undefined], ])('static %s %s => %s', (base, path, pattern, result) => { const res = findSingleMatch(pattern, false, false, path, processedTree) - expect(res?.params).toEqual(result) + expect(res?.rawParams).toEqual(result) }) it.each([ @@ -116,7 +116,7 @@ describe('case insensitive path matching', () => { ['/a/1/b/2', '/A/$id/B/$id', { id: '2' }], ])('params %s => %s', (path, pattern, result) => { const res = findSingleMatch(pattern, false, false, path, processedTree) - expect(res?.params).toEqual(result) + expect(res?.rawParams).toEqual(result) }) it.each([ @@ -133,7 +133,7 @@ describe('case insensitive path matching', () => { ['/a/1/b/2_', '/A/{-$id}/B/{-$id}', { id: '2_' }], ])('optional %s => %s', (path, pattern, result) => { const res = findSingleMatch(pattern, false, false, path, processedTree) - expect(res?.params).toEqual(result) + expect(res?.rawParams).toEqual(result) }) it.each([ @@ -143,7 +143,7 @@ describe('case insensitive path matching', () => { ['/a/b/c', '/A/$/foo', { _splat: 'b/c', '*': 'b/c' }], ])('wildcard %s => %s', (path, pattern, result) => { const res = findSingleMatch(pattern, false, false, path, processedTree) - expect(res?.params).toEqual(result) + expect(res?.rawParams).toEqual(result) }) }) @@ -167,7 +167,7 @@ describe('fuzzy path matching', () => { ['/', '/a', '/b', undefined], ])('static %s %s => %s', (base, path, pattern, result) => { const res = findSingleMatch(pattern, true, true, path, processedTree) - expect(res?.params).toEqual(result) + expect(res?.rawParams).toEqual(result) }) it.each([ @@ -178,7 +178,7 @@ describe('fuzzy path matching', () => { ['/a/1/b/2/c', '/a/$id/b/$other', { id: '1', other: '2', '**': 'c' }], ])('params %s => %s', (path, pattern, result) => { const res = findSingleMatch(pattern, true, true, path, processedTree) - expect(res?.params).toEqual(result) + expect(res?.rawParams).toEqual(result) }) it.each([ @@ -193,7 +193,7 @@ describe('fuzzy path matching', () => { ['/a/1/b/2/c', '/a/{-$id}/b/{-$other}', { id: '1', other: '2', '**': 'c' }], ])('optional %s => %s', (path, pattern, result) => { const res = findSingleMatch(pattern, true, true, path, processedTree) - expect(res?.params).toEqual(result) + expect(res?.rawParams).toEqual(result) }) it.each([ @@ -203,6 +203,6 @@ describe('fuzzy path matching', () => { ['/a/b/c/d', '/a/$/foo', { _splat: 'b/c/d', '*': 'b/c/d' }], ])('wildcard %s => %s', (path, pattern, result) => { const res = findSingleMatch(pattern, true, true, path, processedTree) - expect(res?.params).toEqual(result) + expect(res?.rawParams).toEqual(result) }) }) 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 fd9ea9828d5..1a290df9d63 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -111,13 +111,13 @@ describe('findRouteMatch', () => { const tree = makeTree(['/{-$id}']) const res = findRouteMatch('/', tree) expect(res?.route.id).toBe('/{-$id}') - expect(res?.params).toEqual({}) + expect(res?.rawParams).toEqual({}) }) it('wildcard at the root matches /', () => { const tree = makeTree(['/$']) const res = findRouteMatch('/', tree) expect(res?.route.id).toBe('/$') - expect(res?.params).toEqual({ '*': '', _splat: '' }) + expect(res?.rawParams).toEqual({ '*': '', _splat: '' }) }) it('dynamic at the root DOES NOT match /', () => { const tree = makeTree(['/$id']) @@ -457,13 +457,16 @@ describe('findRouteMatch', () => { }) it('multiple optionals at the end -> favor earlier segments', () => { const tree = makeTree(['/a/{-$b}/{-$c}/{-$d}/{-$e}']) - expect(findRouteMatch('/a/b/c', tree)?.params).toEqual({ b: 'b', c: 'c' }) + expect(findRouteMatch('/a/b/c', tree)?.rawParams).toEqual({ + b: 'b', + c: 'c', + }) }) it('optional and wildcard at the end can still be omitted', () => { const tree = makeTree(['/a/{-$id}/$']) const result = findRouteMatch('/a', tree) expect(result?.route.id).toBe('/a/{-$id}/$') - expect(result?.params).toEqual({ '*': '', _splat: '' }) + expect(result?.rawParams).toEqual({ '*': '', _splat: '' }) }) it('multi-segment wildcard w/ prefix', () => { const tree = makeTree(['/file{$}']) @@ -544,7 +547,7 @@ describe('findRouteMatch', () => { const { processedTree } = processRouteTree(tree) const res = findRouteMatch('/a/b/foo', processedTree, true) expect(res?.route.id).toBe('/a/b/$') - expect(res?.params).toEqual({ _splat: 'foo', '*': 'foo' }) + expect(res?.rawParams).toEqual({ _splat: 'foo', '*': 'foo' }) }) describe('edge-case #5969: trailing empty wildcard should match', () => { it('basic', () => { @@ -636,7 +639,7 @@ describe('findRouteMatch', () => { const tree = makeTree(['/a/b/c', '/a/b', '/a']) const match = findRouteMatch('/a/b/x/y', tree, true) expect(match?.route?.id).toBe('/a/b') - expect(match?.params).toMatchInlineSnapshot(` + expect(match?.rawParams).toMatchInlineSnapshot(` { "**": "x/y", } @@ -697,7 +700,7 @@ describe('findRouteMatch', () => { true, ) expect(match?.route.id).toBe('/dashboard') - expect(match?.params).toEqual({ '**': 'foo' }) + expect(match?.rawParams).toEqual({ '**': 'foo' }) }) it('cannot use an index route as a fuzzy match', () => { @@ -769,7 +772,7 @@ describe('findRouteMatch', () => { true, ) expect(match?.route.id).toBe('/dashboard') - expect(match?.params).toEqual({ '**': 'foo' }) + expect(match?.rawParams).toEqual({ '**': 'foo' }) const actualMatch = findRouteMatch('/dashboard', processed.processedTree) expect(actualMatch?.route.id).toBe('/dashboard/') }) @@ -797,7 +800,7 @@ describe('findRouteMatch', () => { (char, encoded) => { const tree = makeTree([`/a/$id`]) const result = findRouteMatch(`/a/${encoded}`, tree) - expect(result?.params).toEqual({ id: char }) + expect(result?.rawParams).toEqual({ id: char }) }, ) it.each(URISyntaxCharacters)( @@ -805,7 +808,7 @@ describe('findRouteMatch', () => { (char, encoded) => { const tree = makeTree([`/a/{-$id}`]) const result = findRouteMatch(`/a/${encoded}`, tree) - expect(result?.params).toEqual({ id: char }) + expect(result?.rawParams).toEqual({ id: char }) }, ) it.each(URISyntaxCharacters)( @@ -813,7 +816,7 @@ describe('findRouteMatch', () => { (char, encoded) => { const tree = makeTree([`/a/$`]) const result = findRouteMatch(`/a/${encoded}`, tree) - expect(result?.params).toEqual({ '*': char, _splat: char }) + expect(result?.rawParams).toEqual({ '*': char, _splat: char }) }, ) it('wildcard splat supports multiple URI encoded characters in multiple URL segments', () => { @@ -821,14 +824,14 @@ describe('findRouteMatch', () => { const path = URISyntaxCharacters.map(([, encoded]) => encoded).join('/') const decoded = URISyntaxCharacters.map(([char]) => char).join('/') const result = findRouteMatch(`/a/${path}`, tree) - expect(result?.params).toEqual({ '*': decoded, _splat: decoded }) + expect(result?.rawParams).toEqual({ '*': decoded, _splat: decoded }) }) it('fuzzy splat supports multiple URI encoded characters in multiple URL segments', () => { const tree = makeTree(['/a']) const path = URISyntaxCharacters.map(([, encoded]) => encoded).join('/') const decoded = URISyntaxCharacters.map(([char]) => char).join('/') const result = findRouteMatch(`/a/${path}`, tree, true) - expect(result?.params).toEqual({ '**': decoded }) + expect(result?.rawParams).toEqual({ '**': decoded }) }) }) describe('edge-cases', () => { @@ -859,7 +862,7 @@ describe('findRouteMatch', () => { const { processedTree } = processRouteTree(tree) const result = findRouteMatch(`/sv`, processedTree) expect(result?.route.id).toBe('/_pathless/{-$language}/') - expect(result?.params).toEqual({ language: 'sv' }) + expect(result?.rawParams).toEqual({ language: 'sv' }) }) }) }) @@ -1105,16 +1108,16 @@ describe('processRouteMasks', { sequential: true }, () => { it('can match dynamic route masks w/ `findFlatMatch`', () => { const res = findFlatMatch('/a/123/d', processedTree) expect(res?.route.from).toBe('/a/$param/d') - expect(res?.params).toEqual({ param: '123' }) + expect(res?.rawParams).toEqual({ param: '123' }) }) it('can match optional route masks w/ `findFlatMatch`', () => { const res = findFlatMatch('/a/d', processedTree) expect(res?.route.from).toBe('/a/{-$optional}/d') - expect(res?.params).toEqual({}) + expect(res?.rawParams).toEqual({}) }) it('can match prefix/suffix wildcard route masks w/ `findFlatMatch`', () => { const res = findFlatMatch('/a/b/file/path.txt', processedTree) expect(res?.route.from).toBe('/a/b/{$}.txt') - expect(res?.params).toEqual({ '*': 'file/path', _splat: 'file/path' }) + expect(res?.rawParams).toEqual({ '*': 'file/path', _splat: 'file/path' }) }) }) diff --git a/packages/router-core/tests/optional-path-params-clean.test.ts b/packages/router-core/tests/optional-path-params-clean.test.ts index d8c1411e96b..e3a09c2cf25 100644 --- a/packages/router-core/tests/optional-path-params-clean.test.ts +++ b/packages/router-core/tests/optional-path-params-clean.test.ts @@ -155,7 +155,7 @@ describe('Optional Path Parameters - Clean Comprehensive Tests', () => { from, processedTree, ) - const result = match ? match.params : undefined + const result = match ? match.rawParams : undefined if (options.to && !result) return return result ?? {} } diff --git a/packages/router-core/tests/optional-path-params.test.ts b/packages/router-core/tests/optional-path-params.test.ts index 304e9aebc08..9969cb90c0a 100644 --- a/packages/router-core/tests/optional-path-params.test.ts +++ b/packages/router-core/tests/optional-path-params.test.ts @@ -366,7 +366,7 @@ describe('Optional Path Parameters', () => { from, processedTree, ) - const result = match ? match.params : undefined + const result = match ? match.rawParams : undefined if (options.to && !result) return return result ?? {} } diff --git a/packages/router-core/tests/path.test.ts b/packages/router-core/tests/path.test.ts index 6503eb0b755..2d47c1bd76a 100644 --- a/packages/router-core/tests/path.test.ts +++ b/packages/router-core/tests/path.test.ts @@ -581,7 +581,7 @@ describe('matchPathname', () => { from, processedTree, ) - const result = match ? match.params : undefined + const result = match ? match.rawParams : undefined if (options.to && !result) return return result ?? {} } From c96fdc74c4cf39b8e1063e2e7765bfd2bd1e1fbb Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 23 Dec 2025 10:50:17 +0100 Subject: [PATCH 14/21] new skip API options --- .../router-core/src/new-process-route-tree.ts | 79 ++++++++++--------- packages/router-core/src/route.ts | 45 ++++++++++- packages/router-core/tests/foo.test.ts | 4 +- .../tests/new-process-route-tree.test.ts | 4 +- 4 files changed, 90 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 64b23b29487..44f3f6e693e 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -187,8 +187,10 @@ 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 + const skipOnParamError = !!( + route.options?.params?.parse && + route.options?.skipRouteOnParseError?.params + ) while (cursor < length) { const segment = parseSegment(path, cursor, data) let nextNode: AnySegmentNode @@ -248,10 +250,10 @@ function parseSegments( ? suffix_raw : suffix_raw.toLowerCase() const existingNode = - (!parse || !skipRouteOnParseError) && + !skipOnParamError && node.dynamic?.find( (s) => - (!s.parse || !s.skipRouteOnParseError) && + !s.skipOnParamError && s.caseSensitive === actuallyCaseSensitive && s.prefix === prefix && s.suffix === suffix, @@ -290,10 +292,10 @@ function parseSegments( ? suffix_raw : suffix_raw.toLowerCase() const existingNode = - (!parse || !skipRouteOnParseError) && + !skipOnParamError && node.optional?.find( (s) => - (!s.parse || !s.skipRouteOnParseError) && + !s.skipOnParamError && s.caseSensitive === actuallyCaseSensitive && s.prefix === prefix && s.suffix === suffix, @@ -350,8 +352,7 @@ function parseSegments( // create pathless node if ( - parse && - skipRouteOnParseError && + skipOnParamError && route.children && !route.isRoot && route.id && @@ -383,8 +384,9 @@ function parseSegments( node = indexNode } - node.parse = parse - node.skipRouteOnParseError = skipRouteOnParseError + node.parse = route.options?.params?.parse ?? null + node.skipOnParamError = skipOnParamError + node.parsingPriority = route.options?.skipRouteOnParseError?.priority ?? 0 // make node "matchable" if (isLeaf && !node.route) { @@ -411,29 +413,26 @@ function sortDynamic( prefix?: string suffix?: string caseSensitive: boolean - parse: null | ((params: Record) => any) - skipRouteOnParseError: boolean + skipOnParamError: boolean + parsingPriority: number }, b: { prefix?: string suffix?: string caseSensitive: boolean - parse: null | ((params: Record) => any) - skipRouteOnParseError: boolean + skipOnParamError: boolean + parsingPriority: number }, ) { + if (a.skipOnParamError && !b.skipOnParamError) return -1 + if (!a.skipOnParamError && b.skipOnParamError) return 1 if ( - a.parse && - a.skipRouteOnParseError && - (!b.parse || !b.skipRouteOnParseError) - ) - return -1 - if ( - (!a.parse || !a.skipRouteOnParseError) && - b.parse && - b.skipRouteOnParseError + a.skipOnParamError && + b.skipOnParamError && + a.parsingPriority && + b.parsingPriority ) - return 1 + return b.parsingPriority - a.parsingPriority 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 @@ -507,7 +506,8 @@ function createStaticNode( fullPath, parent: null, parse: null, - skipRouteOnParseError: false, + skipOnParamError: false, + parsingPriority: 0, } } @@ -539,7 +539,8 @@ function createDynamicNode( fullPath, parent: null, parse: null, - skipRouteOnParseError: false, + skipOnParamError: false, + parsingPriority: 0, caseSensitive, prefix, suffix, @@ -603,8 +604,11 @@ 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 + /** options.skipRouteOnParseError.params ?? false */ + skipOnParamError: boolean + + /** options.skipRouteOnParseError.priority ?? 0 */ + parsingPriority: number } type RouteLike = { @@ -614,7 +618,10 @@ type RouteLike = { parentRoute?: RouteLike // parent route, isRoot?: boolean options?: { - skipRouteOnParseError?: boolean + skipRouteOnParseError?: { + params?: boolean + priority?: number + } caseSensitive?: boolean params?: { parse?: (params: Record) => any @@ -1004,7 +1011,7 @@ function getNodeMatch( const { node, index, skipped, depth, statics, dynamics, optionals } = frame let { extract, rawParams, parsedParams } = frame - if (node.skipRouteOnParseError && node.parse) { + if (node.skipOnParamError) { const result = validateMatchParams(path, parts, frame) if (!result) continue rawParams = frame.rawParams @@ -1048,7 +1055,7 @@ function getNodeMatch( rawParams, parsedParams, } - if (node.index.skipRouteOnParseError && node.index.parse) { + if (node.index.skipOnParamError) { const result = validateMatchParams(path, parts, indexFrame) if (!result) continue } @@ -1094,7 +1101,7 @@ function getNodeMatch( rawParams, parsedParams, } - if (segment.skipRouteOnParseError && segment.parse) { + if (segment.skipOnParamError) { const result = validateMatchParams(path, parts, frame) if (!result) continue } @@ -1268,10 +1275,10 @@ function validateMatchParams( frame: MatchStackFrame, ) { try { - const result = extractParams(path, parts, frame) - frame.rawParams = result[0] - frame.extract = result[1] - const parsed = frame.node.parse!(result[0]) + const [rawParams, state] = extractParams(path, parts, frame) + frame.rawParams = rawParams + frame.extract = state + const parsed = frame.node.parse!(rawParams) frame.parsedParams = Object.assign({}, frame.parsedParams, parsed) return true } catch { diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts index b87b657da64..908a501baed 100644 --- a/packages/router-core/src/route.ts +++ b/packages/router-core/src/route.ts @@ -1188,11 +1188,48 @@ 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 + /** + * Options to control route matching behavior with runtime code. + */ + skipRouteOnParseError?: { + /** + * @default false + * + * If `true`, skip this route during matching if `params.parse` fails. + * + * Without this option, a `/$param` route could match *any* value for `param`, + * and only later during the route lifecycle would `params.parse` run and potentially + * show the `errorComponent` if validation failed. + * + * With this option enabled, the route will only match if `params.parse` succeeds. + * If it fails, the router will continue trying to match other routes, potentially + * finding a different route that works, or ultimately showing the `notFoundComponent`. + */ + params?: boolean + /** + * @default false + * + * If `true`, skip this route during matching if `validateSearch` fails. + */ + search?: boolean // future option for search params + /** + * @default 0 + * + * In cases where multiple routes would need to run `params.parse` during matching + * to determine which route to pick, this priority number can be used as a tie-breaker + * for which route to try first. Higher number = higher priority. + */ + priority?: number + } + /** + * If true, this route will be matched as case-sensitive + * + * @default false + */ caseSensitive?: boolean - // If true, this route will be forcefully wrapped in a suspense boundary + /** + * If true, this route will be forcefully wrapped in a suspense boundary + */ wrapInSuspense?: boolean // The content to be rendered when the route is matched. If no component is provided, defaults to `` diff --git a/packages/router-core/tests/foo.test.ts b/packages/router-core/tests/foo.test.ts index 4e52f37e704..5ba3b59b04b 100644 --- a/packages/router-core/tests/foo.test.ts +++ b/packages/router-core/tests/foo.test.ts @@ -34,7 +34,9 @@ describe('params extract viz', () => { params: { parse: parseB, }, - skipRouteOnParseError: true, + skipRouteOnParseError: { + params: true, + }, }) a.addChildren([b]) 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 1a290df9d63..7ad78388936 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -886,7 +886,9 @@ describe('findRouteMatch', () => { fullPath: '/$foo', options: { params: { parse: () => {} }, - skipRouteOnParseError: true, + skipRouteOnParseError: { + params: true, + }, }, children: [ { From 8b787306336eb36ff82ba68c208fef6137da1d6a Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 23 Dec 2025 11:43:16 +0100 Subject: [PATCH 15/21] update snapshot --- .../tests/new-process-route-tree.test.ts | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) 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 7ad78388936..f3c1ac82fc5 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -928,6 +928,7 @@ describe('findRouteMatch', () => { "optional": null, "parent": [Circular], "parse": null, + "parsingPriority": 0, "pathless": [ { "depth": 2, @@ -942,6 +943,7 @@ describe('findRouteMatch', () => { "optional": null, "parent": [Circular], "parse": null, + "parsingPriority": 0, "pathless": null, "route": { "fullPath": "/$foo/", @@ -949,7 +951,7 @@ describe('findRouteMatch', () => { "options": {}, "path": "/", }, - "skipRouteOnParseError": false, + "skipOnParamError": false, "static": null, "staticInsensitive": null, "wildcard": null, @@ -958,6 +960,7 @@ describe('findRouteMatch', () => { "optional": null, "parent": [Circular], "parse": [Function], + "parsingPriority": 0, "pathless": null, "route": { "children": [ @@ -980,11 +983,13 @@ describe('findRouteMatch', () => { "params": { "parse": [Function], }, - "skipRouteOnParseError": true, + "skipRouteOnParseError": { + "params": true, + }, }, "path": "$foo", }, - "skipRouteOnParseError": true, + "skipOnParamError": true, "static": null, "staticInsensitive": Map { "bar" => { @@ -996,6 +1001,7 @@ describe('findRouteMatch', () => { "optional": null, "parent": [Circular], "parse": null, + "parsingPriority": 0, "pathless": null, "route": { "fullPath": "/$foo/bar", @@ -1003,7 +1009,7 @@ describe('findRouteMatch', () => { "options": {}, "path": "bar", }, - "skipRouteOnParseError": false, + "skipOnParamError": false, "static": null, "staticInsensitive": null, "wildcard": null, @@ -1014,7 +1020,7 @@ describe('findRouteMatch', () => { ], "prefix": undefined, "route": null, - "skipRouteOnParseError": false, + "skipOnParamError": false, "static": null, "staticInsensitive": Map { "hello" => { @@ -1026,6 +1032,7 @@ describe('findRouteMatch', () => { "optional": null, "parent": [Circular], "parse": null, + "parsingPriority": 0, "pathless": null, "route": { "fullPath": "/$foo/hello", @@ -1033,7 +1040,7 @@ describe('findRouteMatch', () => { "options": {}, "path": "$foo/hello", }, - "skipRouteOnParseError": false, + "skipOnParamError": false, "static": null, "staticInsensitive": null, "wildcard": null, @@ -1053,6 +1060,7 @@ describe('findRouteMatch', () => { "optional": null, "parent": [Circular], "parse": null, + "parsingPriority": 0, "pathless": null, "route": { "fullPath": "/", @@ -1060,7 +1068,7 @@ describe('findRouteMatch', () => { "options": {}, "path": "/", }, - "skipRouteOnParseError": false, + "skipOnParamError": false, "static": null, "staticInsensitive": null, "wildcard": null, @@ -1069,9 +1077,10 @@ describe('findRouteMatch', () => { "optional": null, "parent": null, "parse": null, + "parsingPriority": 0, "pathless": null, "route": null, - "skipRouteOnParseError": false, + "skipOnParamError": false, "static": null, "staticInsensitive": null, "wildcard": null, From 6b9d31b946994c5c1b5e009509e57400aa81f323 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 23 Dec 2025 12:32:43 +0100 Subject: [PATCH 16/21] pathless nodes can match beyond path length --- packages/router-core/src/new-process-route-tree.ts | 5 +++-- 1 file changed, 3 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 44f3f6e693e..ca9d75d61be 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -1034,8 +1034,9 @@ function getNodeMatch( if (node.route && !pathIsIndex && isFrameMoreSpecific(bestMatch, frame)) { bestMatch = frame } - // beyond the length of the path parts, only index segments, or skipped optional segments, or wildcard segments can match - if (!node.optional && !node.wildcard && !node.index) continue + // beyond the length of the path parts, only some segment types can match + if (!node.optional && !node.wildcard && !node.index && !node.pathless) + continue } const part = isBeyondPath ? undefined : parts[index]! From c35ac005c0f7109fd08b7e12624ce7169c3a6332 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 23 Dec 2025 12:33:12 +0100 Subject: [PATCH 17/21] ai generated tests, seem good, but should review more deeply --- .../tests/skip-route-on-parse-error.test.ts | 774 ++++++++++++++++++ 1 file changed, 774 insertions(+) create mode 100644 packages/router-core/tests/skip-route-on-parse-error.test.ts diff --git a/packages/router-core/tests/skip-route-on-parse-error.test.ts b/packages/router-core/tests/skip-route-on-parse-error.test.ts new file mode 100644 index 00000000000..275416b8400 --- /dev/null +++ b/packages/router-core/tests/skip-route-on-parse-error.test.ts @@ -0,0 +1,774 @@ +import { describe, expect, it } from 'vitest' +import { findRouteMatch, processRouteTree } from '../src/new-process-route-tree' + +describe('skipRouteOnParseError', () => { + describe('basic matching with parse validation', () => { + it('matches route when parse succeeds', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/$id', + fullPath: '/$id', + path: '$id', + options: { + params: { + parse: (params: Record) => ({ + id: parseInt(params.id!, 10), + }), + }, + skipRouteOnParseError: { params: true }, + }, + }, + ], + } + const { processedTree } = processRouteTree(tree) + const result = findRouteMatch('/123', processedTree) + expect(result?.route.id).toBe('/$id') + // params contains raw string values for interpolatePath + expect(result?.rawParams).toEqual({ id: '123' }) + // parsedParams contains the transformed values from parse + expect(result?.parsedParams).toEqual({ id: 123 }) + }) + + it('skips route when parse throws and finds no alternative', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/$id', + fullPath: '/$id', + path: '$id', + options: { + params: { + parse: (params: Record) => { + const num = parseInt(params.id!, 10) + if (isNaN(num)) throw new Error('Not a number') + return { id: num } + }, + }, + skipRouteOnParseError: { params: true }, + }, + }, + ], + } + const { processedTree } = processRouteTree(tree) + const result = findRouteMatch('/abc', processedTree) + expect(result).toBeNull() + }) + + it('skips route when parse throws and finds alternative match', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/$id', + fullPath: '/$id', + path: '$id', + options: { + params: { + parse: (params: Record) => { + const num = parseInt(params.id!, 10) + if (isNaN(num)) throw new Error('Not a number') + return { id: num } + }, + }, + skipRouteOnParseError: { params: true }, + }, + }, + { + id: '/$slug', + fullPath: '/$slug', + path: '$slug', + options: {}, + }, + ], + } + const { processedTree } = processRouteTree(tree) + // numeric should match the validated route + const numericResult = findRouteMatch('/123', processedTree) + expect(numericResult?.route.id).toBe('/$id') + // params contains raw string values for interpolatePath + expect(numericResult?.rawParams).toEqual({ id: '123' }) + // parsedParams contains the transformed values from parse + expect(numericResult?.parsedParams).toEqual({ id: 123 }) + + // non-numeric should fall through to the non-validated route + const slugResult = findRouteMatch('/hello-world', processedTree) + expect(slugResult?.route.id).toBe('/$slug') + expect(slugResult?.rawParams).toEqual({ slug: 'hello-world' }) + }) + }) + + describe('priority: validated routes take precedence', () => { + it('validated dynamic route has priority over non-validated dynamic route', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/$slug', + fullPath: '/$slug', + path: '$slug', + options: {}, + }, + { + id: '/$id', + fullPath: '/$id', + path: '$id', + options: { + params: { + parse: (params: Record) => { + const num = parseInt(params.id!, 10) + if (isNaN(num)) throw new Error('Not a number') + return { id: num } + }, + }, + skipRouteOnParseError: { params: true }, + }, + }, + ], + } + const { processedTree } = processRouteTree(tree) + // validated route should be tried first + const numericResult = findRouteMatch('/123', processedTree) + expect(numericResult?.route.id).toBe('/$id') + }) + + it('static route still has priority over validated dynamic route', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/settings', + fullPath: '/settings', + path: 'settings', + options: {}, + }, + { + id: '/$id', + fullPath: '/$id', + path: '$id', + options: { + params: { + parse: (params: Record) => { + const num = parseInt(params.id!, 10) + if (isNaN(num)) throw new Error('Not a number') + return { id: num } + }, + }, + skipRouteOnParseError: { params: true }, + }, + }, + ], + } + const { processedTree } = processRouteTree(tree) + const result = findRouteMatch('/settings', processedTree) + expect(result?.route.id).toBe('/settings') + }) + }) + + describe('regex-like validation patterns', () => { + it('uuid validation pattern', () => { + const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/$uuid', + fullPath: '/$uuid', + path: '$uuid', + options: { + params: { + parse: (params: Record) => { + if (!uuidRegex.test(params.uuid!)) + throw new Error('Not a UUID') + return params + }, + }, + skipRouteOnParseError: { params: true }, + }, + }, + { + id: '/$slug', + fullPath: '/$slug', + path: '$slug', + options: {}, + }, + ], + } + const { processedTree } = processRouteTree(tree) + + const uuidResult = findRouteMatch( + '/550e8400-e29b-41d4-a716-446655440000', + processedTree, + ) + expect(uuidResult?.route.id).toBe('/$uuid') + + const slugResult = findRouteMatch('/my-blog-post', processedTree) + expect(slugResult?.route.id).toBe('/$slug') + }) + + it('date validation pattern (YYYY-MM-DD)', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/posts/$date', + fullPath: '/posts/$date', + path: 'posts/$date', + options: { + params: { + parse: (params: Record) => { + const date = new Date(params.date!) + if (date.toString() === 'Invalid Date') + throw new Error('Not a date') + return { date } + }, + }, + skipRouteOnParseError: { params: true }, + }, + }, + { + id: '/posts/$slug', + fullPath: '/posts/$slug', + path: 'posts/$slug', + options: {}, + }, + ], + } + const { processedTree } = processRouteTree(tree) + + const dateResult = findRouteMatch('/posts/2024-01-15', processedTree) + expect(dateResult?.route.id).toBe('/posts/$date') + // params contains raw string values for interpolatePath + expect(dateResult?.rawParams.date).toBe('2024-01-15') + // parsedParams contains the transformed values from parse + expect(dateResult?.parsedParams?.date).toBeInstanceOf(Date) + + const slugResult = findRouteMatch('/posts/my-first-post', processedTree) + expect(slugResult?.route.id).toBe('/posts/$slug') + }) + }) + + describe('nested routes with skipRouteOnParseError', () => { + it('parent validation failure prevents child matching', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/$orgId', + fullPath: '/$orgId', + path: '$orgId', + options: { + params: { + parse: (params: Record) => { + const num = parseInt(params.orgId!, 10) + if (isNaN(num)) throw new Error('Not a number') + return { orgId: num } + }, + }, + skipRouteOnParseError: { params: true }, + }, + children: [ + { + id: '/$orgId/settings', + fullPath: '/$orgId/settings', + path: 'settings', + options: {}, + }, + ], + }, + { + id: '/$slug/about', + fullPath: '/$slug/about', + path: '$slug/about', + options: {}, + }, + ], + } + const { processedTree } = processRouteTree(tree) + + // numeric org should match the validated route + const numericResult = findRouteMatch('/123/settings', processedTree) + expect(numericResult?.route.id).toBe('/$orgId/settings') + + // non-numeric should not match /$orgId/settings, should match /$slug/about + const slugResult = findRouteMatch('/my-org/about', processedTree) + expect(slugResult?.route.id).toBe('/$slug/about') + }) + + it('child validation failure falls back to sibling', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/users', + fullPath: '/users', + path: 'users', + options: {}, + children: [ + { + id: '/users/$userId', + fullPath: '/users/$userId', + path: '$userId', + options: { + params: { + parse: (params: Record) => { + const num = parseInt(params.userId!, 10) + if (isNaN(num)) throw new Error('Not a number') + return { userId: num } + }, + }, + skipRouteOnParseError: { params: true }, + }, + }, + { + id: '/users/$username', + fullPath: '/users/$username', + path: '$username', + options: {}, + }, + ], + }, + ], + } + const { processedTree } = processRouteTree(tree) + + const numericResult = findRouteMatch('/users/42', processedTree) + expect(numericResult?.route.id).toBe('/users/$userId') + // params contains raw string values for interpolatePath + expect(numericResult?.rawParams).toEqual({ userId: '42' }) + // parsedParams contains the transformed values from parse + expect(numericResult?.parsedParams).toEqual({ userId: 42 }) + + const usernameResult = findRouteMatch('/users/johndoe', processedTree) + expect(usernameResult?.route.id).toBe('/users/$username') + // Non-validated route: params are raw strings, parsedParams is undefined + expect(usernameResult?.rawParams).toEqual({ username: 'johndoe' }) + expect(usernameResult?.parsedParams).toBeUndefined() + }) + }) + + describe('pathless routes with skipRouteOnParseError', () => { + // Pathless layouts with skipRouteOnParseError should gate their children + it('pathless layout with validation gates children', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/', + fullPath: '/', + path: '/', + options: {}, + }, + { + id: '/$foo/_layout', + fullPath: '/$foo', + path: '$foo', + options: { + params: { + parse: (params: Record) => { + const num = parseInt(params.foo!, 10) + if (isNaN(num)) throw new Error('Not a number') + return { foo: num } + }, + }, + skipRouteOnParseError: { params: true }, + }, + children: [ + { + id: '/$foo/_layout/bar', + fullPath: '/$foo/bar', + path: 'bar', + options: {}, + }, + { + id: '/$foo/_layout/', + fullPath: '/$foo/', + path: '/', + options: {}, + }, + ], + }, + { + id: '/$foo/hello', + fullPath: '/$foo/hello', + path: '$foo/hello', + options: {}, + }, + ], + } + const { processedTree } = processRouteTree(tree) + + // numeric foo should match through the validated layout + const numericBarResult = findRouteMatch('/123/bar', processedTree) + expect(numericBarResult?.route.id).toBe('/$foo/_layout/bar') + + const numericIndexResult = findRouteMatch('/123', processedTree) + expect(numericIndexResult?.route.id).toBe('/$foo/_layout/') + expect(numericIndexResult?.rawParams).toEqual({ foo: '123' }) + expect(numericIndexResult?.parsedParams).toEqual({ foo: 123 }) + + // non-numeric foo should fall through to the non-validated route + const helloResult = findRouteMatch('/abc/hello', processedTree) + expect(helloResult?.route.id).toBe('/$foo/hello') + expect(helloResult?.rawParams).toEqual({ foo: 'abc' }) + }) + }) + + describe('optional params with skipRouteOnParseError', () => { + it('optional param with static fallback', () => { + // Optional param with validation, with a static fallback + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/{-$lang}/home', + fullPath: '/{-$lang}/home', + path: '{-$lang}/home', + options: { + params: { + parse: (params: Record) => { + const validLangs = ['en', 'es', 'fr', 'de'] + if (params.lang && !validLangs.includes(params.lang)) { + throw new Error('Invalid language') + } + return params + }, + }, + skipRouteOnParseError: { params: true }, + }, + }, + { + id: '/home', + fullPath: '/home', + path: 'home', + options: {}, + }, + ], + } + const { processedTree } = processRouteTree(tree) + + // valid language should match the validated route + const enResult = findRouteMatch('/en/home', processedTree) + expect(enResult?.route.id).toBe('/{-$lang}/home') + expect(enResult?.parsedParams).toEqual({ lang: 'en' }) + + // root path + home - both routes can match + // The optional route (with skipped param) has greater depth, so it wins + // This is the expected behavior per the priority system + const rootResult = findRouteMatch('/home', processedTree) + expect(rootResult?.route.id).toBe('/{-$lang}/home') + + // invalid language should NOT match the validated optional route + // and since there's no dynamic fallback, it should return null + const invalidResult = findRouteMatch('/about/home', processedTree) + expect(invalidResult).toBeNull() + }) + + it('optional param at root with validation', () => { + // Optional param that validates and allows skipping + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/{-$lang}', + fullPath: '/{-$lang}', + path: '{-$lang}', + options: { + params: { + parse: (params: Record) => { + const validLangs = ['en', 'es', 'fr', 'de'] + if (params.lang && !validLangs.includes(params.lang)) { + throw new Error('Invalid language') + } + return params + }, + }, + skipRouteOnParseError: { params: true }, + }, + }, + ], + } + const { processedTree } = processRouteTree(tree) + + // valid language should match + const enResult = findRouteMatch('/en', processedTree) + expect(enResult?.route.id).toBe('/{-$lang}') + expect(enResult?.parsedParams).toEqual({ lang: 'en' }) + + // root path should match (optional skipped) + const rootResult = findRouteMatch('/', processedTree) + expect(rootResult?.route.id).toBe('/{-$lang}') + expect(rootResult?.parsedParams).toEqual({}) + + // invalid language should NOT match (no fallback route) + const invalidResult = findRouteMatch('/about', processedTree) + expect(invalidResult).toBeNull() + }) + }) + + describe('wildcard routes with skipRouteOnParseError', () => { + it('wildcard with validation', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/files/$', + fullPath: '/files/$', + path: 'files/$', + options: { + params: { + parse: (params: Record) => { + if (params._splat!.includes('..')) { + throw new Error('Upward navigation not allowed') + } + return params + }, + }, + skipRouteOnParseError: { params: true }, + }, + }, + { + id: '/files', + fullPath: '/files', + path: 'files', + options: {}, + }, + ], + } + const { processedTree } = processRouteTree(tree) + + // path should match the validated wildcard route + const txtResult = findRouteMatch('/files/docs/readme.txt', processedTree) + expect(txtResult?.route.id).toBe('/files/$') + + // path with upward navigation should fall through to the static /files route + const otherResult = findRouteMatch( + '/files/../../secret/photo.jpg', + processedTree, + true, + ) + expect(otherResult?.route.id).toBe('/files') + expect(otherResult?.rawParams['**']).toBe('../../secret/photo.jpg') + }) + }) + + describe('multiple validated routes competing', () => { + it('first matching validated route wins', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/$uuid', + fullPath: '/$uuid', + path: '$uuid', + options: { + params: { + parse: (params: Record) => { + const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + if (!uuidRegex.test(params.uuid!)) + throw new Error('Not a UUID') + return params + }, + }, + skipRouteOnParseError: { params: true }, + }, + }, + { + id: '/$number', + fullPath: '/$number', + path: '$number', + options: { + params: { + parse: (params: Record) => { + const num = parseInt(params.number!, 10) + if (isNaN(num)) throw new Error('Not a number') + return { number: num } + }, + }, + skipRouteOnParseError: { params: true }, + }, + }, + { + id: '/$slug', + fullPath: '/$slug', + path: '$slug', + options: {}, + }, + ], + } + const { processedTree } = processRouteTree(tree) + + const uuidResult = findRouteMatch( + '/550e8400-e29b-41d4-a716-446655440000', + processedTree, + ) + expect(uuidResult?.route.id).toBe('/$uuid') + + const numberResult = findRouteMatch('/42', processedTree) + expect(numberResult?.route.id).toBe('/$number') + // params contains raw string values for interpolatePath + expect(numberResult?.rawParams).toEqual({ number: '42' }) + // parsedParams contains the transformed values from parse + expect(numberResult?.parsedParams).toEqual({ number: 42 }) + + const slugResult = findRouteMatch('/hello-world', processedTree) + expect(slugResult?.route.id).toBe('/$slug') + }) + }) + + describe('params.parse without skipRouteOnParseError', () => { + it('params.parse is NOT called during matching when skipRouteOnParseError is false', () => { + let parseCalled = false + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/$id', + fullPath: '/$id', + path: '$id', + options: { + params: { + parse: (params: Record) => { + parseCalled = true + return { id: parseInt(params.id!, 10) } + }, + }, + // skipRouteOnParseError is NOT set + }, + }, + ], + } + const { processedTree } = processRouteTree(tree) + const result = findRouteMatch('/123', processedTree) + expect(result?.route.id).toBe('/$id') + // parse should NOT be called during matching + expect(parseCalled).toBe(false) + // params should be raw strings + expect(result?.rawParams).toEqual({ id: '123' }) + }) + }) + + describe('edge cases', () => { + it('empty param value still goes through validation', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/prefix{$id}suffix', + fullPath: '/prefix{$id}suffix', + path: 'prefix{$id}suffix', + options: { + params: { + parse: (params: Record) => { + if (params.id === '') throw new Error('Empty not allowed') + return params + }, + }, + skipRouteOnParseError: { params: true }, + }, + }, + { + id: '/prefixsuffix', + fullPath: '/prefixsuffix', + path: 'prefixsuffix', + options: {}, + }, + ], + } + const { processedTree } = processRouteTree(tree) + + // with value should match validated route + const withValue = findRouteMatch('/prefixFOOsuffix', processedTree) + expect(withValue?.route.id).toBe('/prefix{$id}suffix') + + // empty value should fall through to static route + const empty = findRouteMatch('/prefixsuffix', processedTree) + expect(empty?.route.id).toBe('/prefixsuffix') + }) + + it('validation error type does not matter', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/$id', + fullPath: '/$id', + path: '$id', + options: { + params: { + parse: () => { + throw 'string error' // not an Error object + }, + }, + skipRouteOnParseError: { params: true }, + }, + }, + { + id: '/$fallback', + fullPath: '/$fallback', + path: '$fallback', + options: {}, + }, + ], + } + const { processedTree } = processRouteTree(tree) + const result = findRouteMatch('/test', processedTree) + expect(result?.route.id).toBe('/$fallback') + }) + }) +}) From cac7a3b471423f33c31e4f063a37c5f910caed6d Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 23 Dec 2025 12:50:30 +0100 Subject: [PATCH 18/21] improve tests --- .../tests/skip-route-on-parse-error.test.ts | 181 +++++++++++++----- 1 file changed, 132 insertions(+), 49 deletions(-) diff --git a/packages/router-core/tests/skip-route-on-parse-error.test.ts b/packages/router-core/tests/skip-route-on-parse-error.test.ts index 275416b8400..9c40af7c996 100644 --- a/packages/router-core/tests/skip-route-on-parse-error.test.ts +++ b/packages/router-core/tests/skip-route-on-parse-error.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { findRouteMatch, processRouteTree } from '../src/new-process-route-tree' describe('skipRouteOnParseError', () => { @@ -180,6 +180,46 @@ describe('skipRouteOnParseError', () => { const result = findRouteMatch('/settings', processedTree) expect(result?.route.id).toBe('/settings') }) + + it('deep validated route can still fallback to sibling', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + children: [ + { + id: '/$a/$b/$c', + fullPath: '/$a/$b/$c', + path: '$a/$b/$c', + options: { + params: { + parse: (params: Record) => { + // if (params.a !== 'one') throw new Error('Invalid a') + // if (params.b !== 'two') throw new Error('Invalid b') + if (params.c !== 'three') throw new Error('Invalid c') + return params + }, + }, + skipRouteOnParseError: { params: true }, + }, + }, + { + id: '/$x/$y/$z', + fullPath: '/$x/$y/$z', + path: '$x/$y/$z', + }, + ], + } + const { processedTree } = processRouteTree(tree) + { + const result = findRouteMatch('/one/two/three', processedTree) + expect(result?.route.id).toBe('/$a/$b/$c') + } + { + const result = findRouteMatch('/one/two/wrong', processedTree) + expect(result?.route.id).toBe('/$x/$y/$z') + } + }) }) describe('regex-like validation patterns', () => { @@ -444,6 +484,10 @@ describe('skipRouteOnParseError', () => { const helloResult = findRouteMatch('/abc/hello', processedTree) expect(helloResult?.route.id).toBe('/$foo/hello') expect(helloResult?.rawParams).toEqual({ foo: 'abc' }) + + // non-numeric foo should NOT match the children of the validated layout + const barResult = findRouteMatch('/abc/bar', processedTree) + expect(barResult).toBeNull() }) }) @@ -496,7 +540,7 @@ describe('skipRouteOnParseError', () => { // invalid language should NOT match the validated optional route // and since there's no dynamic fallback, it should return null - const invalidResult = findRouteMatch('/about/home', processedTree) + const invalidResult = findRouteMatch('/it/home', processedTree) expect(invalidResult).toBeNull() }) @@ -660,84 +704,123 @@ describe('skipRouteOnParseError', () => { const slugResult = findRouteMatch('/hello-world', processedTree) expect(slugResult?.route.id).toBe('/$slug') }) - }) - - describe('params.parse without skipRouteOnParseError', () => { - it('params.parse is NOT called during matching when skipRouteOnParseError is false', () => { - let parseCalled = false - const tree = { + it('priority option can be used to influence order', () => { + const alphabetical = { id: '__root__', isRoot: true, fullPath: '/', path: '/', children: [ { - id: '/$id', - fullPath: '/$id', - path: '$id', + id: '/$a', + fullPath: '/$a', + path: '$a', options: { params: { - parse: (params: Record) => { - parseCalled = true - return { id: parseInt(params.id!, 10) } - }, + parse: (params: Record) => params, + }, + skipRouteOnParseError: { + params: true, + priority: 1, // higher priority than /$z + }, + }, + }, + { + id: '/$z', + fullPath: '/$z', + path: '$z', + options: { + params: { + parse: (params: Record) => params, + }, + skipRouteOnParseError: { + params: true, + priority: -1, // lower priority than /$a }, - // skipRouteOnParseError is NOT set }, }, ], } - const { processedTree } = processRouteTree(tree) - const result = findRouteMatch('/123', processedTree) - expect(result?.route.id).toBe('/$id') - // parse should NOT be called during matching - expect(parseCalled).toBe(false) - // params should be raw strings - expect(result?.rawParams).toEqual({ id: '123' }) - }) - }) - - describe('edge cases', () => { - it('empty param value still goes through validation', () => { - const tree = { + { + const { processedTree } = processRouteTree(alphabetical) + const result = findRouteMatch('/123', processedTree) + expect(result?.route.id).toBe('/$a') + } + const reverse = { id: '__root__', isRoot: true, fullPath: '/', path: '/', children: [ { - id: '/prefix{$id}suffix', - fullPath: '/prefix{$id}suffix', - path: 'prefix{$id}suffix', + id: '/$a', + fullPath: '/$a', + path: '$a', options: { params: { - parse: (params: Record) => { - if (params.id === '') throw new Error('Empty not allowed') - return params - }, + parse: (params: Record) => params, + }, + skipRouteOnParseError: { + params: true, + priority: -1, // lower priority than /$z }, - skipRouteOnParseError: { params: true }, }, }, { - id: '/prefixsuffix', - fullPath: '/prefixsuffix', - path: 'prefixsuffix', - options: {}, + id: '/$z', + fullPath: '/$z', + path: '$z', + options: { + params: { + parse: (params: Record) => params, + }, + skipRouteOnParseError: { + params: true, + priority: 1, // higher priority than /$a + }, + }, }, ], } - const { processedTree } = processRouteTree(tree) - - // with value should match validated route - const withValue = findRouteMatch('/prefixFOOsuffix', processedTree) - expect(withValue?.route.id).toBe('/prefix{$id}suffix') + { + const { processedTree } = processRouteTree(reverse) + const result = findRouteMatch('/123', processedTree) + expect(result?.route.id).toBe('/$z') + } + }) + }) - // empty value should fall through to static route - const empty = findRouteMatch('/prefixsuffix', processedTree) - expect(empty?.route.id).toBe('/prefixsuffix') + describe('params.parse without skipRouteOnParseError', () => { + it('params.parse is NOT called during matching when skipRouteOnParseError is false', () => { + const parse = vi.fn() + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/$id', + fullPath: '/$id', + path: '$id', + options: { + params: { parse }, + // skipRouteOnParseError is NOT set + }, + }, + ], + } + const { processedTree } = processRouteTree(tree) + const result = findRouteMatch('/123', processedTree) + expect(result?.route.id).toBe('/$id') + // parse should NOT be called during matching + expect(parse).not.toHaveBeenCalled() + // params should be raw strings + expect(result?.rawParams).toEqual({ id: '123' }) }) + }) + describe('edge cases', () => { it('validation error type does not matter', () => { const tree = { id: '__root__', From eed4b0b00f61d7cd44ca0640ed4adc56d0b6619f Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 24 Dec 2025 10:30:10 +0100 Subject: [PATCH 19/21] docs --- .../react/api/router/RouteOptionsType.md | 21 ++ .../framework/react/guide/path-params.md | 297 ++++++++++++++++++ 2 files changed, 318 insertions(+) diff --git a/docs/router/framework/react/api/router/RouteOptionsType.md b/docs/router/framework/react/api/router/RouteOptionsType.md index 215e21c9ef4..d25402386e7 100644 --- a/docs/router/framework/react/api/router/RouteOptionsType.md +++ b/docs/router/framework/react/api/router/RouteOptionsType.md @@ -88,6 +88,27 @@ The `RouteOptions` type accepts an object with the following properties: - Type: `(params: TParams) => Record` - A function that will be called when this route's parsed params are being used to build a location. This function should return a valid object of `Record` mapping. +### `skipRouteOnParseError` property (⚠️ experimental) + +> [!WARNING] +> The `skipRouteOnParseError` option is currently **experimental** and may change in future releases. + +- Type: + +```tsx +type skipRouteOnParseError = { + params?: boolean + priority?: number +} +``` + +- Optional +- By default, when a route's `params.parse` function throws an error, the route will match and then show an error state during render. With `skipRouteOnParseError.params` enabled, the router will skip routes whose `params.parse` function throws and continue searching for alternative matching routes. +- See [Guides > Path Params > Validating path parameters during matching](../../guide/path-params#validating-path-parameters-during-matching) for detailed usage examples. + +> [!IMPORTANT] +> **Performance impact**: This option has a **non-negligible performance cost** and should not be used indiscriminately. Routes with `skipRouteOnParseError` are placed on separate branches in the route matching tree instead of sharing nodes with other dynamic routes. This reduces the tree's ability to efficiently narrow down matches and requires testing more route, even for routes that wouldn't match the path structure alone. + ### `beforeLoad` method - Type: diff --git a/docs/router/framework/react/guide/path-params.md b/docs/router/framework/react/guide/path-params.md index 4ebd477ace2..1413381708e 100644 --- a/docs/router/framework/react/guide/path-params.md +++ b/docs/router/framework/react/guide/path-params.md @@ -738,6 +738,303 @@ function ShopComponent() { Optional path parameters provide a powerful and flexible foundation for implementing internationalization in your TanStack Router applications. Whether you prefer prefix-based or combined approaches, you can create clean, SEO-friendly URLs while maintaining excellent developer experience and type safety. +## Validating Path Parameters During Matching + +> [!WARNING] +> The `skipRouteOnParseError` option is currently **experimental** and may change in future releases. + +> [!IMPORTANT] +> **Performance cost**: This feature has a **non-negligible performance cost** and should not be used indiscriminately. It creates additional branches in the route matching tree, reducing matching efficiency and requiring more route evaluations. Use it only when you genuinely need type-specific routes at the same path level. + +By default, TanStack Router matches routes based purely on URL structure. A route with `/$param` will match any value for that parameter, and validation via `params.parse` happens later in the route lifecycle. If validation fails, the route shows an error state. + +However, sometimes you want routes to match only when parameters meet specific criteria, with the router automatically falling back to alternative routes when validation fails. This is where `skipRouteOnParseError` comes in. + +### When to Use This Feature + +Use `skipRouteOnParseError.params` when you need: + +- **Type-specific routes**: Different routes for UUIDs vs. slugs at the same path (e.g., `/$uuid` and `/$slug`) +- **Format-specific routes**: Date-formatted paths vs. regular slugs (e.g., `/posts/2024-01-15` vs. `/posts/my-post`) +- **Numeric vs. string routes**: Different behavior for numeric IDs vs. usernames (e.g., `/users/123` vs. `/users/johndoe`) +- **Pattern-based routing**: Complex validation patterns where you want automatic fallback to other routes + +Before using `skipRouteOnParseError.params`, consider whether you can achieve your goals with standard route matching: + +- Using a static route prefix (e.g., `/id/$id` vs. `/username/$username`) +- Using a prefix or suffix in the path (e.g., `/user-{$id}` vs. `/$username`) + +### How It Works + +The `skipRouteOnParseError` option changes when `params.parse` runs, but not what it does: + +**Without `skipRouteOnParseError`**: + +- Route matches based on URL structure alone +- `params.parse` runs during route lifecycle (after match) +- Errors from `params.parse` cause error state, showing `errorComponent` + +**With `skipRouteOnParseError.params`**: + +- Route matching includes running `params.parse` +- Errors from `params.parse` cause route to be skipped, continuing to find other matches +- If no route matches, shows `notFoundComponent` + +Both modes still use `params.parse` for validation and transformation—the difference is timing and error handling. + +### Basic Example: Numeric IDs with String Fallback + +```tsx +// routes/$id.tsx - Only matches numeric IDs +export const Route = createFileRoute('/$id')({ + params: { + parse: (params) => { + const id = parseInt(params.id, 10) + if (isNaN(id)) throw new Error('ID must be numeric') + return { id } + }, + }, + skipRouteOnParseError: { params: true }, + component: UserByIdComponent, +}) + +function UserByIdComponent() { + const { id } = Route.useParams() // id is number (from parsed params) + const loaderData = Route.useLoaderData() + return
User ID: {id}
+} + +// routes/$username.tsx - Matches any string +export const UsernameRoute = createFileRoute('/$username')({ + // No params.parse - accepts any string + component: UserByUsernameComponent, +}) + +function UserByUsernameComponent() { + const { username } = Route.useParams() // username is string + return
Username: {username}
+} +``` + +With this setup: + +- `/123` → Matches `/$id` route (validation passes) +- `/johndoe` → Skips `/$id` (validation fails), matches `/$username` route + +### Pattern-Based Validation Examples + +#### UUID vs. Slug Routes + +```tsx +// routes/$uuid.tsx - Only matches valid UUIDs +export const Route = createFileRoute('/$uuid')({ + params: { + parse: (params) => { + const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + if (!uuidRegex.test(params.uuid)) { + throw new Error('Not a valid UUID') + } + return { uuid: params.uuid } + }, + }, + skipRouteOnParseError: { params: true }, + loader: async ({ params }) => fetchByUuid(params.uuid), + component: UuidResourceComponent, +}) + +// routes/$slug.tsx - Matches any string +export const SlugRoute = createFileRoute('/$slug')({ + loader: async ({ params }) => fetchBySlug(params.slug), + component: SlugResourceComponent, +}) +``` + +Results: + +- `/550e8400-e29b-41d4-a716-446655440000` → Matches UUID route +- `/my-blog-post` → Matches slug route + +#### Date-Formatted Posts + +```tsx +// routes/posts/$date.tsx - Only matches YYYY-MM-DD format +export const Route = createFileRoute('/posts/$date')({ + params: { + parse: (params) => { + const date = new Date(params.date) + if (isNaN(date.getTime())) { + throw new Error('Invalid date format') + } + return { date } + }, + }, + skipRouteOnParseError: { params: true }, + loader: async ({ params }) => fetchPostsByDate(params.date), + component: DatePostsComponent, +}) + +// routes/posts/$slug.tsx - Matches any string +export const PostSlugRoute = createFileRoute('/posts/$slug')({ + loader: async ({ params }) => fetchPostBySlug(params.slug), + component: PostComponent, +}) +``` + +Results: + +- `/posts/2024-01-15` → Matches date route, `params.date` is a Date object +- `/posts/my-first-post` → Matches slug route + +### Understanding Route Priority + +When multiple routes could match the same URL, TanStack Router uses this priority order: + +1. **Static routes** (highest priority) - e.g., `/settings` +2. **Dynamic routes** - e.g., `/$slug` +3. **Optional routes** - e.g., `/{-$lang}` +4. **Wildcard routes** (lowest priority) - e.g., `/$` + +When `skipRouteOnParseError` is used, validated routes are treated as having higher priority than non-validated routes _of the same category_. For example, a validated optional route has higher priority than a non-validated optional route, but lower priority than any dynamic route. + +Example demonstrating priority: + +```tsx +// Static route - always matches /settings first +export const SettingsRoute = createFileRoute('/settings')({ + component: SettingsComponent, +}) + +// Validated route - matches numeric IDs +export const IdRoute = createFileRoute('/$id')({ + params: { + parse: (params) => ({ id: parseInt(params.id, 10) }), + }, + skipRouteOnParseError: { params: true }, + component: IdComponent, +}) + +// Non-validated route - fallback for any string +export const SlugRoute = createFileRoute('/$slug')({ + component: SlugComponent, +}) +``` + +Matching results: + +- `/settings` → Static route (highest priority, even though ID route would validate) +- `/123` → Validated dynamic route (`/$id` validation passes) +- `/hello` → Non-validated dynamic route (`/$id` validation fails) + +### Custom Priority Between Validated Routes + +When you have multiple validated routes at the same level, and because `params.parse` can be any arbitrary code, you may have situations where multiple routes could potentially validate successfully. In these cases you can provide a custom priority as a tie-breaker using `skipRouteOnParseError.priority`. + +Higher numbers mean higher priority, and no priority defaults to 0. + +```tsx +// routes/$uuid.tsx +export const UuidRoute = createFileRoute('/$uuid')({ + params: { + parse: (params) => { + if (!isUuid(params.uuid)) throw new Error('Not a UUID') + return params + }, + }, + skipRouteOnParseError: { + params: true, + priority: 10, // Try this first + }, + component: UuidComponent, +}) + +// routes/$number.tsx +export const NumberRoute = createFileRoute('/$number')({ + params: { + parse: (params) => ({ + number: parseInt(params.number, 10), + }), + }, + skipRouteOnParseError: { + params: true, + priority: 5, // Try this second + }, + component: NumberComponent, +}) + +// routes/$slug.tsx +export const SlugRoute = createFileRoute('/$slug')({ + // No validation - lowest priority by default + component: SlugComponent, +}) +``` + +Matching order: + +1. Check UUID validation (priority 10) +2. Check number validation (priority 5) +3. Fall back to slug route (no validation) + +### Nested Routes with Validation + +Parent route validation gates access to child routes: + +```tsx +// routes/$orgId.tsx - Parent route, only matches numeric org IDs +export const OrgRoute = createFileRoute('/$orgId')({ + params: { + parse: (params) => ({ + orgId: parseInt(params.orgId, 10), + }), + }, + skipRouteOnParseError: { params: true }, + component: OrgLayoutComponent, +}) + +// routes/$orgId/settings.tsx - Child route +export const OrgSettingsRoute = createFileRoute('/$orgId/settings')({ + component: OrgSettingsComponent, +}) + +// routes/$slug/settings.tsx - Alternative route +export const SlugSettingsRoute = createFileRoute('/$slug/settings')({ + component: SettingsComponent, +}) +``` + +Results: + +- `/123/settings` → Matches `/$orgId/settings` (parent validation passes) +- `/my-org/settings` → Matches `/$slug/settings` (`/$orgId` validation fails) + +### Working with Optional Parameters + +`skipRouteOnParseError` works with optional parameters: + +```tsx +// routes/{-$lang}/home.tsx - Validates language codes +export const Route = createFileRoute('/{-$lang}/home')({ + params: { + parse: (params) => { + const validLangs = ['en', 'fr', 'es', 'de'] + if (params.lang && !validLangs.includes(params.lang)) { + throw new Error('Invalid language code') + } + return { lang: params.lang || 'en' } + }, + }, + skipRouteOnParseError: { params: true }, + component: HomeComponent, +}) +``` + +Results: + +- `/home` → Matches (optional param skipped, defaults to 'en') +- `/en/home` → Matches (validation passes) +- `/fr/home` → Matches (validation passes) +- `/it/home` → No match (validation fails, 'it' not in valid list) + ## Allowed Characters By default, path params are escaped with `encodeURIComponent`. If you want to allow other valid URI characters (e.g. `@` or `+`), you can specify that in your [RouterOptions](../api/router/RouterOptionsType.md#pathparamsallowedcharacters-property). From 40b9428df4c43e579910221adf1df1c1b0cc9253 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 24 Dec 2025 13:03:45 +0100 Subject: [PATCH 20/21] fix jsdoc --- packages/router-core/src/route.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts index 908a501baed..b77e1bf55b4 100644 --- a/packages/router-core/src/route.ts +++ b/packages/router-core/src/route.ts @@ -1193,8 +1193,6 @@ export interface UpdatableRouteOptions< */ skipRouteOnParseError?: { /** - * @default false - * * If `true`, skip this route during matching if `params.parse` fails. * * Without this option, a `/$param` route could match *any* value for `param`, @@ -1204,20 +1202,16 @@ export interface UpdatableRouteOptions< * With this option enabled, the route will only match if `params.parse` succeeds. * If it fails, the router will continue trying to match other routes, potentially * finding a different route that works, or ultimately showing the `notFoundComponent`. - */ - params?: boolean - /** - * @default false * - * If `true`, skip this route during matching if `validateSearch` fails. + * @default false */ - search?: boolean // future option for search params + params?: boolean /** - * @default 0 - * * In cases where multiple routes would need to run `params.parse` during matching * to determine which route to pick, this priority number can be used as a tie-breaker * for which route to try first. Higher number = higher priority. + * + * @default 0 */ priority?: number } From 5e9ffbc83c7d31dc591e2ecf9eb610de527dacca Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 7 Jan 2026 21:48:15 +0100 Subject: [PATCH 21/21] docs: skipRouteOnParseError docs WIP --- docs/router/config.json | 8 + .../react/api/router/RouteOptionsType.md | 2 +- .../framework/react/guide/path-params.md | 299 +--------- .../react/guide/validating-path-params.md | 547 ++++++++++++++++++ .../framework/solid/guide/path-params.md | 12 + .../solid/guide/validating-path-params.md | 547 ++++++++++++++++++ 6 files changed, 1122 insertions(+), 293 deletions(-) create mode 100644 docs/router/framework/react/guide/validating-path-params.md create mode 100644 docs/router/framework/solid/guide/validating-path-params.md diff --git a/docs/router/config.json b/docs/router/config.json index 808c3fe38f9..d8d6419a53b 100644 --- a/docs/router/config.json +++ b/docs/router/config.json @@ -237,6 +237,10 @@ "label": "Path Params", "to": "framework/react/guide/path-params" }, + { + "label": "Validating Path Params", + "to": "framework/react/guide/validating-path-params" + }, { "label": "Search Params", "to": "framework/react/guide/search-params" @@ -354,6 +358,10 @@ "label": "Path Params", "to": "framework/solid/guide/path-params" }, + { + "label": "Validating Path Params", + "to": "framework/solid/guide/validating-path-params" + }, { "label": "Search Params", "to": "framework/solid/guide/search-params" diff --git a/docs/router/framework/react/api/router/RouteOptionsType.md b/docs/router/framework/react/api/router/RouteOptionsType.md index d25402386e7..11737707770 100644 --- a/docs/router/framework/react/api/router/RouteOptionsType.md +++ b/docs/router/framework/react/api/router/RouteOptionsType.md @@ -107,7 +107,7 @@ type skipRouteOnParseError = { - See [Guides > Path Params > Validating path parameters during matching](../../guide/path-params#validating-path-parameters-during-matching) for detailed usage examples. > [!IMPORTANT] -> **Performance impact**: This option has a **non-negligible performance cost** and should not be used indiscriminately. Routes with `skipRouteOnParseError` are placed on separate branches in the route matching tree instead of sharing nodes with other dynamic routes. This reduces the tree's ability to efficiently narrow down matches and requires testing more route, even for routes that wouldn't match the path structure alone. +> **Performance impact**: This option has a **non-negligible performance cost** and should only be enabled when needed. Routes with `skipRouteOnParseError` are placed on separate branches in the route matching tree instead of sharing nodes with other dynamic routes. This reduces the tree's ability to efficiently narrow down matches and requires testing more routes, even for routes that wouldn't match the path structure alone. ### `beforeLoad` method diff --git a/docs/router/framework/react/guide/path-params.md b/docs/router/framework/react/guide/path-params.md index 1413381708e..2feb6b5e040 100644 --- a/docs/router/framework/react/guide/path-params.md +++ b/docs/router/framework/react/guide/path-params.md @@ -738,302 +738,17 @@ function ShopComponent() { Optional path parameters provide a powerful and flexible foundation for implementing internationalization in your TanStack Router applications. Whether you prefer prefix-based or combined approaches, you can create clean, SEO-friendly URLs while maintaining excellent developer experience and type safety. -## Validating Path Parameters During Matching +## Validating and Transforming Path Parameters -> [!WARNING] -> The `skipRouteOnParseError` option is currently **experimental** and may change in future releases. +Path parameters are captured from URLs as strings, but you often need to transform them to other types (numbers, dates) or validate they meet specific criteria (UUIDs, patterns). TanStack Router provides `params.parse` and `params.stringify` options for this purpose. -> [!IMPORTANT] -> **Performance cost**: This feature has a **non-negligible performance cost** and should not be used indiscriminately. It creates additional branches in the route matching tree, reducing matching efficiency and requiring more route evaluations. Use it only when you genuinely need type-specific routes at the same path level. +For a comprehensive guide on validating and transforming path parameters, including: -By default, TanStack Router matches routes based purely on URL structure. A route with `/$param` will match any value for that parameter, and validation via `params.parse` happens later in the route lifecycle. If validation fails, the route shows an error state. +- Using `params.parse` to transform and validate parameters +- Understanding error handling with `errorComponent` +- Using the experimental `skipRouteOnParseError` feature for type-specific routes -However, sometimes you want routes to match only when parameters meet specific criteria, with the router automatically falling back to alternative routes when validation fails. This is where `skipRouteOnParseError` comes in. - -### When to Use This Feature - -Use `skipRouteOnParseError.params` when you need: - -- **Type-specific routes**: Different routes for UUIDs vs. slugs at the same path (e.g., `/$uuid` and `/$slug`) -- **Format-specific routes**: Date-formatted paths vs. regular slugs (e.g., `/posts/2024-01-15` vs. `/posts/my-post`) -- **Numeric vs. string routes**: Different behavior for numeric IDs vs. usernames (e.g., `/users/123` vs. `/users/johndoe`) -- **Pattern-based routing**: Complex validation patterns where you want automatic fallback to other routes - -Before using `skipRouteOnParseError.params`, consider whether you can achieve your goals with standard route matching: - -- Using a static route prefix (e.g., `/id/$id` vs. `/username/$username`) -- Using a prefix or suffix in the path (e.g., `/user-{$id}` vs. `/$username`) - -### How It Works - -The `skipRouteOnParseError` option changes when `params.parse` runs, but not what it does: - -**Without `skipRouteOnParseError`**: - -- Route matches based on URL structure alone -- `params.parse` runs during route lifecycle (after match) -- Errors from `params.parse` cause error state, showing `errorComponent` - -**With `skipRouteOnParseError.params`**: - -- Route matching includes running `params.parse` -- Errors from `params.parse` cause route to be skipped, continuing to find other matches -- If no route matches, shows `notFoundComponent` - -Both modes still use `params.parse` for validation and transformation—the difference is timing and error handling. - -### Basic Example: Numeric IDs with String Fallback - -```tsx -// routes/$id.tsx - Only matches numeric IDs -export const Route = createFileRoute('/$id')({ - params: { - parse: (params) => { - const id = parseInt(params.id, 10) - if (isNaN(id)) throw new Error('ID must be numeric') - return { id } - }, - }, - skipRouteOnParseError: { params: true }, - component: UserByIdComponent, -}) - -function UserByIdComponent() { - const { id } = Route.useParams() // id is number (from parsed params) - const loaderData = Route.useLoaderData() - return
User ID: {id}
-} - -// routes/$username.tsx - Matches any string -export const UsernameRoute = createFileRoute('/$username')({ - // No params.parse - accepts any string - component: UserByUsernameComponent, -}) - -function UserByUsernameComponent() { - const { username } = Route.useParams() // username is string - return
Username: {username}
-} -``` - -With this setup: - -- `/123` → Matches `/$id` route (validation passes) -- `/johndoe` → Skips `/$id` (validation fails), matches `/$username` route - -### Pattern-Based Validation Examples - -#### UUID vs. Slug Routes - -```tsx -// routes/$uuid.tsx - Only matches valid UUIDs -export const Route = createFileRoute('/$uuid')({ - params: { - parse: (params) => { - const uuidRegex = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i - if (!uuidRegex.test(params.uuid)) { - throw new Error('Not a valid UUID') - } - return { uuid: params.uuid } - }, - }, - skipRouteOnParseError: { params: true }, - loader: async ({ params }) => fetchByUuid(params.uuid), - component: UuidResourceComponent, -}) - -// routes/$slug.tsx - Matches any string -export const SlugRoute = createFileRoute('/$slug')({ - loader: async ({ params }) => fetchBySlug(params.slug), - component: SlugResourceComponent, -}) -``` - -Results: - -- `/550e8400-e29b-41d4-a716-446655440000` → Matches UUID route -- `/my-blog-post` → Matches slug route - -#### Date-Formatted Posts - -```tsx -// routes/posts/$date.tsx - Only matches YYYY-MM-DD format -export const Route = createFileRoute('/posts/$date')({ - params: { - parse: (params) => { - const date = new Date(params.date) - if (isNaN(date.getTime())) { - throw new Error('Invalid date format') - } - return { date } - }, - }, - skipRouteOnParseError: { params: true }, - loader: async ({ params }) => fetchPostsByDate(params.date), - component: DatePostsComponent, -}) - -// routes/posts/$slug.tsx - Matches any string -export const PostSlugRoute = createFileRoute('/posts/$slug')({ - loader: async ({ params }) => fetchPostBySlug(params.slug), - component: PostComponent, -}) -``` - -Results: - -- `/posts/2024-01-15` → Matches date route, `params.date` is a Date object -- `/posts/my-first-post` → Matches slug route - -### Understanding Route Priority - -When multiple routes could match the same URL, TanStack Router uses this priority order: - -1. **Static routes** (highest priority) - e.g., `/settings` -2. **Dynamic routes** - e.g., `/$slug` -3. **Optional routes** - e.g., `/{-$lang}` -4. **Wildcard routes** (lowest priority) - e.g., `/$` - -When `skipRouteOnParseError` is used, validated routes are treated as having higher priority than non-validated routes _of the same category_. For example, a validated optional route has higher priority than a non-validated optional route, but lower priority than any dynamic route. - -Example demonstrating priority: - -```tsx -// Static route - always matches /settings first -export const SettingsRoute = createFileRoute('/settings')({ - component: SettingsComponent, -}) - -// Validated route - matches numeric IDs -export const IdRoute = createFileRoute('/$id')({ - params: { - parse: (params) => ({ id: parseInt(params.id, 10) }), - }, - skipRouteOnParseError: { params: true }, - component: IdComponent, -}) - -// Non-validated route - fallback for any string -export const SlugRoute = createFileRoute('/$slug')({ - component: SlugComponent, -}) -``` - -Matching results: - -- `/settings` → Static route (highest priority, even though ID route would validate) -- `/123` → Validated dynamic route (`/$id` validation passes) -- `/hello` → Non-validated dynamic route (`/$id` validation fails) - -### Custom Priority Between Validated Routes - -When you have multiple validated routes at the same level, and because `params.parse` can be any arbitrary code, you may have situations where multiple routes could potentially validate successfully. In these cases you can provide a custom priority as a tie-breaker using `skipRouteOnParseError.priority`. - -Higher numbers mean higher priority, and no priority defaults to 0. - -```tsx -// routes/$uuid.tsx -export const UuidRoute = createFileRoute('/$uuid')({ - params: { - parse: (params) => { - if (!isUuid(params.uuid)) throw new Error('Not a UUID') - return params - }, - }, - skipRouteOnParseError: { - params: true, - priority: 10, // Try this first - }, - component: UuidComponent, -}) - -// routes/$number.tsx -export const NumberRoute = createFileRoute('/$number')({ - params: { - parse: (params) => ({ - number: parseInt(params.number, 10), - }), - }, - skipRouteOnParseError: { - params: true, - priority: 5, // Try this second - }, - component: NumberComponent, -}) - -// routes/$slug.tsx -export const SlugRoute = createFileRoute('/$slug')({ - // No validation - lowest priority by default - component: SlugComponent, -}) -``` - -Matching order: - -1. Check UUID validation (priority 10) -2. Check number validation (priority 5) -3. Fall back to slug route (no validation) - -### Nested Routes with Validation - -Parent route validation gates access to child routes: - -```tsx -// routes/$orgId.tsx - Parent route, only matches numeric org IDs -export const OrgRoute = createFileRoute('/$orgId')({ - params: { - parse: (params) => ({ - orgId: parseInt(params.orgId, 10), - }), - }, - skipRouteOnParseError: { params: true }, - component: OrgLayoutComponent, -}) - -// routes/$orgId/settings.tsx - Child route -export const OrgSettingsRoute = createFileRoute('/$orgId/settings')({ - component: OrgSettingsComponent, -}) - -// routes/$slug/settings.tsx - Alternative route -export const SlugSettingsRoute = createFileRoute('/$slug/settings')({ - component: SettingsComponent, -}) -``` - -Results: - -- `/123/settings` → Matches `/$orgId/settings` (parent validation passes) -- `/my-org/settings` → Matches `/$slug/settings` (`/$orgId` validation fails) - -### Working with Optional Parameters - -`skipRouteOnParseError` works with optional parameters: - -```tsx -// routes/{-$lang}/home.tsx - Validates language codes -export const Route = createFileRoute('/{-$lang}/home')({ - params: { - parse: (params) => { - const validLangs = ['en', 'fr', 'es', 'de'] - if (params.lang && !validLangs.includes(params.lang)) { - throw new Error('Invalid language code') - } - return { lang: params.lang || 'en' } - }, - }, - skipRouteOnParseError: { params: true }, - component: HomeComponent, -}) -``` - -Results: - -- `/home` → Matches (optional param skipped, defaults to 'en') -- `/en/home` → Matches (validation passes) -- `/fr/home` → Matches (validation passes) -- `/it/home` → No match (validation fails, 'it' not in valid list) +See the dedicated [Validating Path Params](./validating-path-params.md) guide. ## Allowed Characters diff --git a/docs/router/framework/react/guide/validating-path-params.md b/docs/router/framework/react/guide/validating-path-params.md new file mode 100644 index 00000000000..bf9e1c79972 --- /dev/null +++ b/docs/router/framework/react/guide/validating-path-params.md @@ -0,0 +1,547 @@ +--- +title: Validating Path Params +--- + +Path parameters are captured from URLs as strings. Often, you need to transform or validate these strings before using them in your application - converting them to numbers, parsing dates, validating UUIDs, or ensuring they meet specific criteria. + +TanStack Router provides `params.parse` and `params.stringify` options for transforming and validating path parameters, with flexible error handling strategies to suit different use cases. + +## Parsing Path Parameters + +The `params.parse` function transforms and validates path parameters as they're extracted from the URL. This is useful for: + +- **Type conversion**: Converting string parameters to numbers, dates, or other types +- **Validation**: Ensuring parameters meet specific criteria (e.g., UUIDs, email formats) +- **Normalization**: Cleaning or standardizing parameter values + +### Basic Example + +```tsx +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/users/$id')({ + params: { + parse: (params) => ({ + id: parseInt(params.id, 10), + }), + }, + loader: async ({ params }) => { + // params.id is now a number + return fetchUser(params.id) + }, + component: UserComponent, +}) + +function UserComponent() { + const { id } = Route.useParams() + // TypeScript knows id is a number + return
User ID: {id}
+} +``` + +### Validation with Error Handling + +When `params.parse` throws an error, the route's `errorComponent` is displayed by default: + +```tsx +export const Route = createFileRoute('/posts/$postId')({ + params: { + parse: (params) => { + const postId = parseInt(params.postId, 10) + if (isNaN(postId) || postId <= 0) { + throw new Error('Post ID must be a positive number') + } + return { postId } + }, + }, + errorComponent: ({ error }) => { + return
Invalid post ID: {error.message}
+ }, + component: PostComponent, +}) +``` + +With this setup: + +- `/posts/123` → Renders `PostComponent` with `params.postId = 123` +- `/posts/abc` → Renders `errorComponent` with the validation error + +### Complex Validation Examples + +#### UUID Validation + +```tsx +export const Route = createFileRoute('/resources/$uuid')({ + params: { + parse: (params) => { + const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + + if (!uuidRegex.test(params.uuid)) { + throw new Error('Invalid UUID format') + } + + return { uuid: params.uuid } + }, + }, + loader: async ({ params }) => fetchByUuid(params.uuid), +}) +``` + +#### Date Parsing + +```tsx +export const Route = createFileRoute('/events/$date')({ + params: { + parse: (params) => { + const date = new Date(params.date) + + if (isNaN(date.getTime())) { + throw new Error('Invalid date format. Use YYYY-MM-DD') + } + + return { date } + }, + }, + loader: async ({ params }) => { + // params.date is a Date object + return fetchEventsByDate(params.date) + }, +}) +``` + +#### Using Validation Libraries + +You can integrate validation libraries like Zod, Valibot, or ArkType: + +```tsx +import { z } from 'zod' + +const paramsSchema = z.object({ + userId: z.coerce.number().positive(), +}) + +export const Route = createFileRoute('/users/$userId')({ + params: { + parse: (params) => paramsSchema.parse(params), + }, +}) +``` + +## Stringifying Path Parameters + +The `params.stringify` function is the inverse of `params.parse` - it transforms your typed parameters back into URL-safe strings for navigation. + +### Basic Example + +```tsx +export const Route = createFileRoute('/users/$id')({ + params: { + parse: (params) => ({ + id: parseInt(params.id, 10), + }), + stringify: (params) => ({ + id: String(params.id), + }), + }, +}) + +// When navigating +function Component() { + return ( + + {/* Router calls stringify to convert 123 to "123" */} + Go to User + + ) +} +``` + +### Date Stringification + +```tsx +export const Route = createFileRoute('/events/$date')({ + params: { + parse: (params) => ({ + date: new Date(params.date), + }), + stringify: (params) => ({ + date: params.date.toISOString().split('T')[0], // YYYY-MM-DD + }), + }, +}) + +function Component() { + return ( + + {/* Converts to /events/2024-01-15 */} + View Event + + ) +} +``` + +## Error Handling Strategies + +When parameter validation fails, TanStack Router offers two error handling strategies: + +### Default Behavior: Show Error Component + +By default, when `params.parse` throws: + +1. The route matches based on URL structure +2. `params.parse` runs during the route lifecycle +3. If parsing fails, the route enters an error state +4. The route's `errorComponent` is displayed + +This is useful when: + +- You have a single route handling all variations of a parameter +- You want to show error UI for invalid parameters +- The route structure is clear and you don't need fallbacks + +### Alternative: Skip Route on Parse Error (⚠️ Experimental) + +Sometimes you want the router to try alternative routes when validation fails. For example, you might have: + +- Different routes for numeric IDs vs. string slugs at the same URL path +- Routes that match only specific parameter formats (UUIDs, dates, etc.) + +This is where `skipRouteOnParseError` comes in. + +> [!WARNING] +> The `skipRouteOnParseError` option is currently **experimental** and may change in future releases. +> +> **Performance cost**: This feature has a **non-negligible performance cost** and should only be enabled when needed. It creates additional branches in the route matching tree, reducing matching efficiency and requiring more route evaluations. Use it only when you genuinely need type-specific routes at the same path level. + +## Validating During Route Matching + +With `skipRouteOnParseError.params` enabled, parameter validation becomes part of the route matching process: + +1. Route structure matches the URL path +2. `params.parse` runs immediately during matching +3. If parsing fails, the route is skipped +4. The router continues searching for other matching routes +5. If no routes match, `notFoundComponent` is shown + +### When to Use This Feature + +Use `skipRouteOnParseError.params` when you need: + +- **Type-specific routes**: Different routes for UUIDs vs. slugs at the same path (e.g., `/$uuid` and `/$slug`) +- **Format-specific routes**: Date-formatted paths vs. regular slugs (e.g., `/posts/2024-01-15` vs. `/posts/my-post`) +- **Numeric vs. string routes**: Different behavior for numeric IDs vs. usernames (e.g., `/users/123` vs. `/users/johndoe`) + +Before using `skipRouteOnParseError.params`, consider whether you can achieve your goals with standard route matching: + +- Using a static route prefix (e.g., `/id/$id` vs. `/username/$username`) +- Using a prefix or suffix in the path (e.g., `/user-{$id}` vs. `/$username`) + +### Basic Example: Numeric IDs with String Fallback + +```tsx +// routes/$id.tsx - Only matches numeric IDs +export const Route = createFileRoute('/$id')({ + params: { + parse: (params) => { + const id = parseInt(params.id, 10) + if (isNaN(id)) { + throw new Error('ID must be numeric') + } + return { id } + }, + }, + skipRouteOnParseError: { params: true }, + loader: async ({ params }) => fetchUserById(params.id), + component: UserByIdComponent, +}) + +// routes/$username.tsx - Matches any string +export const UsernameRoute = createFileRoute('/$username')({ + // No params.parse - accepts any string + loader: async ({ params }) => fetchUserByUsername(params.username), + component: UserByUsernameComponent, +}) +``` + +Results: + +- `/123` → Matches `/$id` route (validation passes), `params.id` is a number +- `/johndoe` → Skips `/$id` (validation fails), matches `/$username` route + +### Pattern-Based Validation + +#### UUID vs. Slug Routes + +```tsx +// routes/$uuid.tsx - Only matches valid UUIDs +export const Route = createFileRoute('/$uuid')({ + params: { + parse: (params) => { + const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + if (!uuidRegex.test(params.uuid)) { + throw new Error('Not a valid UUID') + } + return { uuid: params.uuid } + }, + }, + skipRouteOnParseError: { params: true }, + loader: async ({ params }) => fetchByUuid(params.uuid), + component: UuidResourceComponent, +}) + +// routes/$slug.tsx - Matches any string +export const SlugRoute = createFileRoute('/$slug')({ + loader: async ({ params }) => fetchBySlug(params.slug), + component: SlugResourceComponent, +}) +``` + +Results: + +- `/550e8400-e29b-41d4-a716-446655440000` → Matches UUID route +- `/my-blog-post` → Matches slug route + +#### Date-Formatted Posts + +```tsx +// routes/posts/$date.tsx - Only matches YYYY-MM-DD format +export const Route = createFileRoute('/posts/$date')({ + params: { + parse: (params) => { + const date = new Date(params.date) + if (isNaN(date.getTime())) { + throw new Error('Invalid date format') + } + return { date } + }, + }, + skipRouteOnParseError: { params: true }, + loader: async ({ params }) => fetchPostsByDate(params.date), + component: DatePostsComponent, +}) + +// routes/posts/$slug.tsx - Matches any string +export const PostSlugRoute = createFileRoute('/posts/$slug')({ + loader: async ({ params }) => fetchPostBySlug(params.slug), + component: PostComponent, +}) +``` + +Results: + +- `/posts/2024-01-15` → Matches date route, `params.date` is a Date object +- `/posts/my-first-post` → Matches slug route + +### Route Priority + +When multiple routes could match the same URL, TanStack Router uses this priority order: + +1. **Static routes** (highest priority) - e.g., `/settings` +2. **Dynamic routes** - e.g., `/$slug` +3. **Optional routes** - e.g., `/{-$lang}` +4. **Wildcard routes** (lowest priority) - e.g., `/$` + +When `skipRouteOnParseError` is used, validated routes are treated as having higher priority than non-validated routes _of the same category_. + +Example demonstrating priority: + +```tsx +// Static route - always matches /settings first +export const SettingsRoute = createFileRoute('/settings')({ + component: SettingsComponent, +}) + +// Validated route - matches numeric IDs +export const IdRoute = createFileRoute('/$id')({ + params: { + parse: (params) => { + const id = parseInt(params.id, 10) + if (isNaN(id)) throw new Error('Not numeric') + return { id } + }, + }, + skipRouteOnParseError: { params: true }, + component: IdComponent, +}) + +// Non-validated route - fallback for any string +export const SlugRoute = createFileRoute('/$slug')({ + component: SlugComponent, +}) +``` + +Matching results: + +- `/settings` → Static route (highest priority) +- `/123` → Validated dynamic route (`/$id`) +- `/hello` → Non-validated dynamic route (`/$slug`) + +### Custom Priority Between Validated Routes + +When you have multiple validated routes at the same level, use `skipRouteOnParseError.priority` as a tie-breaker. Higher numbers mean higher priority (default is 0). + +```tsx +// routes/$uuid.tsx +export const UuidRoute = createFileRoute('/$uuid')({ + params: { + parse: (params) => { + if (!isUuid(params.uuid)) throw new Error('Not a UUID') + return params + }, + }, + skipRouteOnParseError: { + params: true, + priority: 10, // Try this first + }, + component: UuidComponent, +}) + +// routes/$number.tsx +export const NumberRoute = createFileRoute('/$number')({ + params: { + parse: (params) => ({ + number: parseInt(params.number, 10), + }), + }, + skipRouteOnParseError: { + params: true, + priority: 5, // Try this second + }, + component: NumberComponent, +}) + +// routes/$slug.tsx +export const SlugRoute = createFileRoute('/$slug')({ + // No validation - lowest priority by default + component: SlugComponent, +}) +``` + +Matching order: + +1. Check UUID validation (priority 10) +2. Check number validation (priority 5) +3. Fall back to slug route (no validation) + +### Nested Routes with Validation + +Parent route validation gates access to child routes: + +```tsx +// routes/$orgId.tsx - Parent route, only matches numeric org IDs +export const OrgRoute = createFileRoute('/$orgId')({ + params: { + parse: (params) => ({ + orgId: parseInt(params.orgId, 10), + }), + }, + skipRouteOnParseError: { params: true }, + component: OrgLayoutComponent, +}) + +// routes/$orgId/settings.tsx - Child route +export const OrgSettingsRoute = createFileRoute('/$orgId/settings')({ + component: OrgSettingsComponent, +}) + +// routes/$slug/settings.tsx - Alternative route +export const SlugSettingsRoute = createFileRoute('/$slug/settings')({ + component: SettingsComponent, +}) +``` + +Results: + +- `/123/settings` → Matches `/$orgId/settings` (parent validation passes) +- `/my-org/settings` → Matches `/$slug/settings` (`/$orgId` validation fails) + +### Working with Optional Parameters + +`skipRouteOnParseError` works with optional parameters too: + +```tsx +// routes/{-$lang}/home.tsx - Validates language codes +export const Route = createFileRoute('/{-$lang}/home')({ + params: { + parse: (params) => { + const validLangs = ['en', 'fr', 'es', 'de'] + if (params.lang && !validLangs.includes(params.lang)) { + throw new Error('Invalid language code') + } + return { lang: params.lang || 'en' } + }, + }, + skipRouteOnParseError: { params: true }, + component: HomeComponent, +}) +``` + +Results: + +- `/home` → Matches (optional param skipped, defaults to 'en') +- `/en/home` → Matches (validation passes) +- `/fr/home` → Matches (validation passes) +- `/it/home` → No match (validation fails, 'it' not in valid list) + +## Best Practices + +### When to Use params.parse + +Use `params.parse` for: + +- Converting string parameters to appropriate types (numbers, dates, booleans) +- Validating parameter formats (UUIDs, emails, patterns) +- Normalizing parameter values +- Applying business logic constraints + +### When to Add skipRouteOnParseError + +Only use `skipRouteOnParseError.params` when you need: + +- Multiple routes at the same URL path with different parameter requirements +- Automatic fallback to alternative routes when validation fails + +Consider simpler alternatives first: + +- Static prefixes or suffixes in route paths +- Separate URL paths for different parameter types +- Client-side validation without route-level enforcement + +### Performance Considerations + +Be aware that `skipRouteOnParseError`: + +- Adds overhead to route matching +- Creates additional branches in the routing tree +- Can slow down navigation when you have many validated routes + +Use it judiciously and only when the routing flexibility justifies the performance cost. + +### Type Safety + +TanStack Router infers types from your `params.parse` return value: + +```tsx +export const Route = createFileRoute('/users/$id')({ + params: { + parse: (params) => ({ + id: parseInt(params.id, 10), // Returns number + }), + }, + loader: ({ params }) => { + // params.id is typed as number + return fetchUser(params.id) + }, +}) +``` + +The types automatically flow through to: + +- `loader` functions +- `beforeLoad` functions +- `useParams()` hooks +- `Link` components (when navigating) + +This ensures type safety throughout your application. diff --git a/docs/router/framework/solid/guide/path-params.md b/docs/router/framework/solid/guide/path-params.md index 7851e94bd8d..e08a9269460 100644 --- a/docs/router/framework/solid/guide/path-params.md +++ b/docs/router/framework/solid/guide/path-params.md @@ -744,6 +744,18 @@ function ShopComponent() { Optional path parameters provide a powerful and flexible foundation for implementing internationalization in your TanStack Router applications. Whether you prefer prefix-based or combined approaches, you can create clean, SEO-friendly URLs while maintaining excellent developer experience and type safety. +## Validating and Transforming Path Parameters + +Path parameters are captured from URLs as strings, but you often need to transform them to other types (numbers, dates) or validate they meet specific criteria (UUIDs, patterns). TanStack Router provides `params.parse` and `params.stringify` options for this purpose. + +For a comprehensive guide on validating and transforming path parameters, including: + +- Using `params.parse` to transform and validate parameters +- Understanding error handling with `errorComponent` +- Using the experimental `skipRouteOnParseError` feature for type-specific routes + +See the dedicated [Validating Path Params](./validating-path-params.md) guide. + ## Allowed Characters By default, path params are escaped with `encodeURIComponent`. If you want to allow other valid URI characters (e.g. `@` or `+`), you can specify that in your [RouterOptions](../api/router/RouterOptionsType.md#pathparamsallowedcharacters-property). diff --git a/docs/router/framework/solid/guide/validating-path-params.md b/docs/router/framework/solid/guide/validating-path-params.md new file mode 100644 index 00000000000..7ec52c310b2 --- /dev/null +++ b/docs/router/framework/solid/guide/validating-path-params.md @@ -0,0 +1,547 @@ +--- +title: Validating Path Params +--- + +Path parameters are captured from URLs as strings. Often, you need to transform or validate these strings before using them in your application - converting them to numbers, parsing dates, validating UUIDs, or ensuring they meet specific criteria. + +TanStack Router provides `params.parse` and `params.stringify` options for transforming and validating path parameters, with flexible error handling strategies to suit different use cases. + +## Parsing Path Parameters + +The `params.parse` function transforms and validates path parameters as they're extracted from the URL. This is useful for: + +- **Type conversion**: Converting string parameters to numbers, dates, or other types +- **Validation**: Ensuring parameters meet specific criteria (e.g., UUIDs, email formats) +- **Normalization**: Cleaning or standardizing parameter values + +### Basic Example + +```tsx +import { createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/users/$id')({ + params: { + parse: (params) => ({ + id: parseInt(params.id, 10), + }), + }, + loader: async ({ params }) => { + // params.id is now a number + return fetchUser(params.id) + }, + component: UserComponent, +}) + +function UserComponent() { + const { id } = Route.useParams() + // TypeScript knows id is a number + return
User ID: {id}
+} +``` + +### Validation with Error Handling + +When `params.parse` throws an error, the route's `errorComponent` is displayed by default: + +```tsx +export const Route = createFileRoute('/posts/$postId')({ + params: { + parse: (params) => { + const postId = parseInt(params.postId, 10) + if (isNaN(postId) || postId <= 0) { + throw new Error('Post ID must be a positive number') + } + return { postId } + }, + }, + errorComponent: ({ error }) => { + return
Invalid post ID: {error.message}
+ }, + component: PostComponent, +}) +``` + +With this setup: + +- `/posts/123` → Renders `PostComponent` with `params.postId = 123` +- `/posts/abc` → Renders `errorComponent` with the validation error + +### Complex Validation Examples + +#### UUID Validation + +```tsx +export const Route = createFileRoute('/resources/$uuid')({ + params: { + parse: (params) => { + const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + + if (!uuidRegex.test(params.uuid)) { + throw new Error('Invalid UUID format') + } + + return { uuid: params.uuid } + }, + }, + loader: async ({ params }) => fetchByUuid(params.uuid), +}) +``` + +#### Date Parsing + +```tsx +export const Route = createFileRoute('/events/$date')({ + params: { + parse: (params) => { + const date = new Date(params.date) + + if (isNaN(date.getTime())) { + throw new Error('Invalid date format. Use YYYY-MM-DD') + } + + return { date } + }, + }, + loader: async ({ params }) => { + // params.date is a Date object + return fetchEventsByDate(params.date) + }, +}) +``` + +#### Using Validation Libraries + +You can integrate validation libraries like Zod, Valibot, or ArkType: + +```tsx +import { z } from 'zod' + +const paramsSchema = z.object({ + userId: z.coerce.number().positive(), +}) + +export const Route = createFileRoute('/users/$userId')({ + params: { + parse: (params) => paramsSchema.parse(params), + }, +}) +``` + +## Stringifying Path Parameters + +The `params.stringify` function is the inverse of `params.parse` - it transforms your typed parameters back into URL-safe strings for navigation. + +### Basic Example + +```tsx +export const Route = createFileRoute('/users/$id')({ + params: { + parse: (params) => ({ + id: parseInt(params.id, 10), + }), + stringify: (params) => ({ + id: String(params.id), + }), + }, +}) + +// When navigating +function Component() { + return ( + + {/* Router calls stringify to convert 123 to "123" */} + Go to User + + ) +} +``` + +### Date Stringification + +```tsx +export const Route = createFileRoute('/events/$date')({ + params: { + parse: (params) => ({ + date: new Date(params.date), + }), + stringify: (params) => ({ + date: params.date.toISOString().split('T')[0], // YYYY-MM-DD + }), + }, +}) + +function Component() { + return ( + + {/* Converts to /events/2024-01-15 */} + View Event + + ) +} +``` + +## Error Handling Strategies + +When parameter validation fails, TanStack Router offers two error handling strategies: + +### Default Behavior: Show Error Component + +By default, when `params.parse` throws: + +1. The route matches based on URL structure +2. `params.parse` runs during the route lifecycle +3. If parsing fails, the route enters an error state +4. The route's `errorComponent` is displayed + +This is useful when: + +- You have a single route handling all variations of a parameter +- You want to show error UI for invalid parameters +- The route structure is clear and you don't need fallbacks + +### Alternative: Skip Route on Parse Error (⚠️ Experimental) + +Sometimes you want the router to try alternative routes when validation fails. For example, you might have: + +- Different routes for numeric IDs vs. string slugs at the same URL path +- Routes that match only specific parameter formats (UUIDs, dates, etc.) + +This is where `skipRouteOnParseError` comes in. + +> [!WARNING] +> The `skipRouteOnParseError` option is currently **experimental** and may change in future releases. +> +> **Performance cost**: This feature has a **non-negligible performance cost** and should only be enabled when needed. It creates additional branches in the route matching tree, reducing matching efficiency and requiring more route evaluations. Use it only when you genuinely need type-specific routes at the same path level. + +## Validating During Route Matching + +With `skipRouteOnParseError.params` enabled, parameter validation becomes part of the route matching process: + +1. Route structure matches the URL path +2. `params.parse` runs immediately during matching +3. If parsing fails, the route is skipped +4. The router continues searching for other matching routes +5. If no routes match, `notFoundComponent` is shown + +### When to Use This Feature + +Use `skipRouteOnParseError.params` when you need: + +- **Type-specific routes**: Different routes for UUIDs vs. slugs at the same path (e.g., `/$uuid` and `/$slug`) +- **Format-specific routes**: Date-formatted paths vs. regular slugs (e.g., `/posts/2024-01-15` vs. `/posts/my-post`) +- **Numeric vs. string routes**: Different behavior for numeric IDs vs. usernames (e.g., `/users/123` vs. `/users/johndoe`) + +Before using `skipRouteOnParseError.params`, consider whether you can achieve your goals with standard route matching: + +- Using a static route prefix (e.g., `/id/$id` vs. `/username/$username`) +- Using a prefix or suffix in the path (e.g., `/user-{$id}` vs. `/$username`) + +### Basic Example: Numeric IDs with String Fallback + +```tsx +// routes/$id.tsx - Only matches numeric IDs +export const Route = createFileRoute('/$id')({ + params: { + parse: (params) => { + const id = parseInt(params.id, 10) + if (isNaN(id)) { + throw new Error('ID must be numeric') + } + return { id } + }, + }, + skipRouteOnParseError: { params: true }, + loader: async ({ params }) => fetchUserById(params.id), + component: UserByIdComponent, +}) + +// routes/$username.tsx - Matches any string +export const UsernameRoute = createFileRoute('/$username')({ + // No params.parse - accepts any string + loader: async ({ params }) => fetchUserByUsername(params.username), + component: UserByUsernameComponent, +}) +``` + +Results: + +- `/123` → Matches `/$id` route (validation passes), `params.id` is a number +- `/johndoe` → Skips `/$id` (validation fails), matches `/$username` route + +### Pattern-Based Validation + +#### UUID vs. Slug Routes + +```tsx +// routes/$uuid.tsx - Only matches valid UUIDs +export const Route = createFileRoute('/$uuid')({ + params: { + parse: (params) => { + const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + if (!uuidRegex.test(params.uuid)) { + throw new Error('Not a valid UUID') + } + return { uuid: params.uuid } + }, + }, + skipRouteOnParseError: { params: true }, + loader: async ({ params }) => fetchByUuid(params.uuid), + component: UuidResourceComponent, +}) + +// routes/$slug.tsx - Matches any string +export const SlugRoute = createFileRoute('/$slug')({ + loader: async ({ params }) => fetchBySlug(params.slug), + component: SlugResourceComponent, +}) +``` + +Results: + +- `/550e8400-e29b-41d4-a716-446655440000` → Matches UUID route +- `/my-blog-post` → Matches slug route + +#### Date-Formatted Posts + +```tsx +// routes/posts/$date.tsx - Only matches YYYY-MM-DD format +export const Route = createFileRoute('/posts/$date')({ + params: { + parse: (params) => { + const date = new Date(params.date) + if (isNaN(date.getTime())) { + throw new Error('Invalid date format') + } + return { date } + }, + }, + skipRouteOnParseError: { params: true }, + loader: async ({ params }) => fetchPostsByDate(params.date), + component: DatePostsComponent, +}) + +// routes/posts/$slug.tsx - Matches any string +export const PostSlugRoute = createFileRoute('/posts/$slug')({ + loader: async ({ params }) => fetchPostBySlug(params.slug), + component: PostComponent, +}) +``` + +Results: + +- `/posts/2024-01-15` → Matches date route, `params.date` is a Date object +- `/posts/my-first-post` → Matches slug route + +### Route Priority + +When multiple routes could match the same URL, TanStack Router uses this priority order: + +1. **Static routes** (highest priority) - e.g., `/settings` +2. **Dynamic routes** - e.g., `/$slug` +3. **Optional routes** - e.g., `/{-$lang}` +4. **Wildcard routes** (lowest priority) - e.g., `/$` + +When `skipRouteOnParseError` is used, validated routes are treated as having higher priority than non-validated routes _of the same category_. + +Example demonstrating priority: + +```tsx +// Static route - always matches /settings first +export const SettingsRoute = createFileRoute('/settings')({ + component: SettingsComponent, +}) + +// Validated route - matches numeric IDs +export const IdRoute = createFileRoute('/$id')({ + params: { + parse: (params) => { + const id = parseInt(params.id, 10) + if (isNaN(id)) throw new Error('Not numeric') + return { id } + }, + }, + skipRouteOnParseError: { params: true }, + component: IdComponent, +}) + +// Non-validated route - fallback for any string +export const SlugRoute = createFileRoute('/$slug')({ + component: SlugComponent, +}) +``` + +Matching results: + +- `/settings` → Static route (highest priority) +- `/123` → Validated dynamic route (`/$id`) +- `/hello` → Non-validated dynamic route (`/$slug`) + +### Custom Priority Between Validated Routes + +When you have multiple validated routes at the same level, use `skipRouteOnParseError.priority` as a tie-breaker. Higher numbers mean higher priority (default is 0). + +```tsx +// routes/$uuid.tsx +export const UuidRoute = createFileRoute('/$uuid')({ + params: { + parse: (params) => { + if (!isUuid(params.uuid)) throw new Error('Not a UUID') + return params + }, + }, + skipRouteOnParseError: { + params: true, + priority: 10, // Try this first + }, + component: UuidComponent, +}) + +// routes/$number.tsx +export const NumberRoute = createFileRoute('/$number')({ + params: { + parse: (params) => ({ + number: parseInt(params.number, 10), + }), + }, + skipRouteOnParseError: { + params: true, + priority: 5, // Try this second + }, + component: NumberComponent, +}) + +// routes/$slug.tsx +export const SlugRoute = createFileRoute('/$slug')({ + // No validation - lowest priority by default + component: SlugComponent, +}) +``` + +Matching order: + +1. Check UUID validation (priority 10) +2. Check number validation (priority 5) +3. Fall back to slug route (no validation) + +### Nested Routes with Validation + +Parent route validation gates access to child routes: + +```tsx +// routes/$orgId.tsx - Parent route, only matches numeric org IDs +export const OrgRoute = createFileRoute('/$orgId')({ + params: { + parse: (params) => ({ + orgId: parseInt(params.orgId, 10), + }), + }, + skipRouteOnParseError: { params: true }, + component: OrgLayoutComponent, +}) + +// routes/$orgId/settings.tsx - Child route +export const OrgSettingsRoute = createFileRoute('/$orgId/settings')({ + component: OrgSettingsComponent, +}) + +// routes/$slug/settings.tsx - Alternative route +export const SlugSettingsRoute = createFileRoute('/$slug/settings')({ + component: SettingsComponent, +}) +``` + +Results: + +- `/123/settings` → Matches `/$orgId/settings` (parent validation passes) +- `/my-org/settings` → Matches `/$slug/settings` (`/$orgId` validation fails) + +### Working with Optional Parameters + +`skipRouteOnParseError` works with optional parameters too: + +```tsx +// routes/{-$lang}/home.tsx - Validates language codes +export const Route = createFileRoute('/{-$lang}/home')({ + params: { + parse: (params) => { + const validLangs = ['en', 'fr', 'es', 'de'] + if (params.lang && !validLangs.includes(params.lang)) { + throw new Error('Invalid language code') + } + return { lang: params.lang || 'en' } + }, + }, + skipRouteOnParseError: { params: true }, + component: HomeComponent, +}) +``` + +Results: + +- `/home` → Matches (optional param skipped, defaults to 'en') +- `/en/home` → Matches (validation passes) +- `/fr/home` → Matches (validation passes) +- `/it/home` → No match (validation fails, 'it' not in valid list) + +## Best Practices + +### When to Use params.parse + +Use `params.parse` for: + +- Converting string parameters to appropriate types (numbers, dates, booleans) +- Validating parameter formats (UUIDs, emails, patterns) +- Normalizing parameter values +- Applying business logic constraints + +### When to Add skipRouteOnParseError + +Only use `skipRouteOnParseError.params` when you need: + +- Multiple routes at the same URL path with different parameter requirements +- Automatic fallback to alternative routes when validation fails + +Consider simpler alternatives first: + +- Static prefixes or suffixes in route paths +- Separate URL paths for different parameter types +- Client-side validation without route-level enforcement + +### Performance Considerations + +Be aware that `skipRouteOnParseError`: + +- Adds overhead to route matching +- Creates additional branches in the routing tree +- Can slow down navigation when you have many validated routes + +Use it judiciously and only when the routing flexibility justifies the performance cost. + +### Type Safety + +TanStack Router infers types from your `params.parse` return value: + +```tsx +export const Route = createFileRoute('/users/$id')({ + params: { + parse: (params) => ({ + id: parseInt(params.id, 10), // Returns number + }), + }, + loader: ({ params }) => { + // params.id is typed as number + return fetchUser(params.id) + }, +}) +``` + +The types automatically flow through to: + +- `loader` functions +- `beforeLoad` functions +- `useParams()` hooks +- `` components (when navigating) + +This ensures type safety throughout your application.