diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index bbdbde56e2..682eea5ec6 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -174,6 +174,8 @@ function parseSegments( const path = route.fullPath ?? route.from const length = path.length const caseSensitive = route.options?.caseSensitive ?? defaultCaseSensitive + const parse = route.options?.params?.parse ?? null + const skipRouteOnParseError = !!route.options?.skipRouteOnParseError while (cursor < length) { const segment = parseSegment(path, cursor, data) let nextNode: AnySegmentNode @@ -232,12 +234,15 @@ function parseSegments( : actuallyCaseSensitive ? suffix_raw : suffix_raw.toLowerCase() - const existingNode = node.dynamic?.find( - (s) => - s.caseSensitive === actuallyCaseSensitive && - s.prefix === prefix && - s.suffix === suffix, - ) + const existingNode = + (!parse || !skipRouteOnParseError) && + node.dynamic?.find( + (s) => + (!s.parse || !s.skipRouteOnParseError) && + s.caseSensitive === actuallyCaseSensitive && + s.prefix === prefix && + s.suffix === suffix, + ) if (existingNode) { nextNode = existingNode } else { @@ -271,12 +276,15 @@ function parseSegments( : actuallyCaseSensitive ? suffix_raw : suffix_raw.toLowerCase() - const existingNode = node.optional?.find( - (s) => - s.caseSensitive === actuallyCaseSensitive && - s.prefix === prefix && - s.suffix === suffix, - ) + const existingNode = + (!parse || !skipRouteOnParseError) && + node.optional?.find( + (s) => + (!s.parse || !s.skipRouteOnParseError) && + s.caseSensitive === actuallyCaseSensitive && + s.prefix === prefix && + s.suffix === suffix, + ) if (existingNode) { nextNode = existingNode } else { @@ -326,6 +334,8 @@ 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, @@ -351,9 +361,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 +443,8 @@ function createStaticNode( parent: null, isIndex: false, notFound: null, + parse: null, + skipRouteOnParseError: false, } } @@ -451,6 +475,8 @@ function createDynamicNode( parent: null, isIndex: false, notFound: null, + parse: null, + skipRouteOnParseError: false, caseSensitive, prefix, suffix, @@ -508,6 +534,12 @@ 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) + + /** If true, errors thrown during parsing will cause this route to be ignored as a match candidate */ + skipRouteOnParseError: boolean } type RouteLike = { @@ -516,7 +548,11 @@ type RouteLike = { parentRoute?: RouteLike // parent route, isRoot?: boolean options?: { + skipRouteOnParseError?: boolean caseSensitive?: boolean + params?: { + parse?: (params: Record) => any + } } } & // router tree @@ -607,6 +643,7 @@ type RouteMatch> = { route: T params: Record branch: ReadonlyArray + error?: unknown } export function findRouteMatch< @@ -702,35 +739,49 @@ 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 [params] = extractParams(path, parts, 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, - 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 +836,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 +875,15 @@ 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 */ + // 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( @@ -862,8 +923,28 @@ 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, 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`? + } + } // In fuzzy mode, track the best partial match we've found so far if (fuzzy && node.notFound && isFrameMoreSpecific(bestFuzzy, frame)) { @@ -905,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, @@ -913,7 +994,24 @@ function getNodeMatch( statics, dynamics, 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 } } @@ -933,6 +1031,9 @@ function getNodeMatch( statics, dynamics, optionals, + extract, + params, + error, }) // enqueue skipping the optional } if (!isBeyondPath) { @@ -954,6 +1055,9 @@ function getNodeMatch( statics, dynamics, optionals: optionals + 1, + extract, + params, + error, }) } } @@ -979,6 +1083,9 @@ function getNodeMatch( statics, dynamics: dynamics + 1, optionals, + extract, + params, + error, }) } } @@ -997,6 +1104,9 @@ function getNodeMatch( statics: statics + 1, dynamics, optionals, + extract, + params, + error, }) } } @@ -1013,6 +1123,9 @@ function getNodeMatch( statics: statics + 1, dynamics, optionals, + extract, + params, + error, }) } } @@ -1034,11 +1147,9 @@ function getNodeMatch( sliceIndex += parts[i]!.length } const splat = sliceIndex === path.length ? '/' : path.slice(sliceIndex) - return { - node: bestFuzzy.node, - skipped: bestFuzzy.skipped, - '**': decodeURIComponent(splat), - } + bestFuzzy.params ??= {} + bestFuzzy.params['**'] = decodeURIComponent(splat) + return bestFuzzy } return null diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts index 53d726ef05..b87b657da6 100644 --- a/packages/router-core/src/route.ts +++ b/packages/router-core/src/route.ts @@ -1188,6 +1188,8 @@ export interface UpdatableRouteOptions< in out TBeforeLoadFn, > extends UpdatableStaticRouteOption, UpdatableRouteOptionsExtensions { + /** If true, this route will be skipped during matching if a parse error occurs, and we'll look for another match */ + skipRouteOnParseError?: boolean // If true, this route will be matched as case-sensitive caseSensitive?: boolean // If true, this route will be forcefully wrapped in a suspense boundary diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 4efaa61b7b..ceb0c0cecb 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({