From d9e403b8b32867f9f84e33120467e0d1bcc13a11 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 21 Nov 2025 21:44:41 +0100 Subject: [PATCH 1/2] 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 2/2] 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({