diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 1cfcc8aa536..2eef6426404 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -8,6 +8,7 @@ export const SEGMENT_TYPE_PARAM = 1 export const SEGMENT_TYPE_WILDCARD = 2 export const SEGMENT_TYPE_OPTIONAL_PARAM = 3 const SEGMENT_TYPE_INDEX = 4 +const SEGMENT_TYPE_PATHLESS = 5 // only used in matching to represent pathless routes that need to carry more information /** * All the kinds of segments that can be present in a route path. @@ -21,7 +22,10 @@ export type SegmentKind = /** * All the kinds of segments that can be present in the segment tree. */ -type ExtendedSegmentKind = SegmentKind | typeof SEGMENT_TYPE_INDEX +type ExtendedSegmentKind = + | SegmentKind + | typeof SEGMENT_TYPE_INDEX + | typeof SEGMENT_TYPE_PATHLESS const PARAM_W_CURLY_BRACES_RE = /^([^{]*)\{\$([a-zA-Z_$][a-zA-Z0-9_$]*)\}([^}]*)$/ // prefix{$paramName}suffix @@ -183,6 +187,10 @@ function parseSegments( const path = route.fullPath ?? route.from const length = path.length const caseSensitive = route.options?.caseSensitive ?? defaultCaseSensitive + const skipOnParamError = !!( + route.options?.params?.parse && + route.options?.skipRouteOnParseError?.params + ) while (cursor < length) { const segment = parseSegment(path, cursor, data) let nextNode: AnySegmentNode @@ -241,12 +249,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 = + !skipOnParamError && + node.dynamic?.find( + (s) => + !s.skipOnParamError && + s.caseSensitive === actuallyCaseSensitive && + s.prefix === prefix && + s.suffix === suffix, + ) if (existingNode) { nextNode = existingNode } else { @@ -280,12 +291,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 = + !skipOnParamError && + node.optional?.find( + (s) => + !s.skipOnParamError && + s.caseSensitive === actuallyCaseSensitive && + s.prefix === prefix && + s.suffix === suffix, + ) if (existingNode) { nextNode = existingNode } else { @@ -336,8 +350,27 @@ function parseSegments( node = nextNode } - const isLeaf = (route.path || !route.children) && !route.isRoot + // create pathless node + if ( + skipOnParamError && + route.children && + !route.isRoot && + route.id && + route.id.charCodeAt(route.id.lastIndexOf('/') + 1) === 95 /* '_' */ + ) { + const pathlessNode = createStaticNode( + route.fullPath ?? route.from, + ) + pathlessNode.kind = SEGMENT_TYPE_PATHLESS + pathlessNode.parent = node + depth++ + pathlessNode.depth = depth + node.pathless ??= [] + node.pathless.push(pathlessNode) + node = pathlessNode + } + const isLeaf = (route.path || !route.children) && !route.isRoot // create index node if (isLeaf && path.endsWith('/')) { const indexNode = createStaticNode( @@ -351,6 +384,10 @@ function parseSegments( node = indexNode } + node.parse = route.options?.params?.parse ?? null + node.skipOnParamError = skipOnParamError + node.parsingPriority = route.options?.skipRouteOnParseError?.priority ?? 0 + // make node "matchable" if (isLeaf && !node.route) { node.route = route @@ -372,9 +409,29 @@ 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 + skipOnParamError: boolean + parsingPriority: number + }, + b: { + prefix?: string + suffix?: string + caseSensitive: boolean + skipOnParamError: boolean + parsingPriority: number + }, ) { + if (a.skipOnParamError && !b.skipOnParamError) return -1 + if (!a.skipOnParamError && b.skipOnParamError) return 1 + if ( + a.skipOnParamError && + b.skipOnParamError && + (a.parsingPriority || b.parsingPriority) + ) + 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 @@ -396,6 +453,11 @@ function sortDynamic( } function sortTreeNodes(node: SegmentNode) { + if (node.pathless) { + for (const child of node.pathless) { + sortTreeNodes(child) + } + } if (node.static) { for (const child of node.static.values()) { sortTreeNodes(child) @@ -432,6 +494,7 @@ function createStaticNode( return { kind: SEGMENT_TYPE_PATHNAME, depth: 0, + pathless: null, index: null, static: null, staticInsensitive: null, @@ -441,6 +504,9 @@ function createStaticNode( route: null, fullPath, parent: null, + parse: null, + skipOnParamError: false, + parsingPriority: 0, } } @@ -461,6 +527,7 @@ function createDynamicNode( return { kind, depth: 0, + pathless: null, index: null, static: null, staticInsensitive: null, @@ -470,6 +537,9 @@ function createDynamicNode( route: null, fullPath, parent: null, + parse: null, + skipOnParamError: false, + parsingPriority: 0, caseSensitive, prefix, suffix, @@ -477,7 +547,10 @@ function createDynamicNode( } type StaticSegmentNode = SegmentNode & { - kind: typeof SEGMENT_TYPE_PATHNAME | typeof SEGMENT_TYPE_INDEX + kind: + | typeof SEGMENT_TYPE_PATHNAME + | typeof SEGMENT_TYPE_PATHLESS + | typeof SEGMENT_TYPE_INDEX } type DynamicSegmentNode = SegmentNode & { @@ -497,6 +570,8 @@ type AnySegmentNode = type SegmentNode = { kind: ExtendedSegmentKind + pathless: Array> | null + /** Exact index segment (highest priority) */ index: StaticSegmentNode | null @@ -524,15 +599,32 @@ type SegmentNode = { parent: AnySegmentNode | null depth: number + + /** route.options.params.parse function, set on the last node of the route */ + parse: null | ((params: Record) => any) + + /** options.skipRouteOnParseError.params ?? false */ + skipOnParamError: boolean + + /** options.skipRouteOnParseError.priority ?? 0 */ + parsingPriority: number } type RouteLike = { + id?: string path?: string // relative path from the parent, children?: Array // child routes, parentRoute?: RouteLike // parent route, isRoot?: boolean options?: { + skipRouteOnParseError?: { + params?: boolean + priority?: number + } caseSensitive?: boolean + params?: { + parse?: (params: Record) => any + } } } & // router tree @@ -621,7 +713,8 @@ export function findSingleMatch( type RouteMatch> = { route: T - params: Record + rawParams: Record + parsedParams?: Record branch: ReadonlyArray } @@ -718,32 +811,57 @@ 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) - if ('**' in leaf) params['**'] = 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 `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. + */ function extractParams( path: string, parts: Array, - leaf: { node: AnySegmentNode; skipped: number }, -) { + leaf: { + node: AnySegmentNode + skipped: number + extract?: { part: number; node: number; path: number } + rawParams?: Record + }, +): [ + rawParams: 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++ - ) { + const rawParams: Record = {} + 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 @@ -762,10 +880,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)) { @@ -784,7 +902,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( @@ -793,12 +911,13 @@ function extractParams( ) const splat = decodeURIComponent(value) // TODO: Deprecate * - params['*'] = splat - params._splat = splat + rawParams['*'] = splat + rawParams._splat = splat break } } - return params + if (leaf.rawParams) Object.assign(rawParams, leaf.rawParams) + return [rawParams, { part: partIndex, node: nodeIndex, path: pathIndex }] } function buildRouteBranch(route: T) { @@ -836,6 +955,11 @@ 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 */ + rawParams?: Record + parsedParams?: Record } function getNodeMatch( @@ -847,7 +971,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 !== '/' @@ -880,8 +1007,16 @@ function getNodeMatch( while (stack.length) { const frame = stack.pop()! - // eslint-disable-next-line prefer-const - let { node, index, skipped, depth, statics, dynamics, optionals } = frame + const { node, index, skipped, depth, statics, dynamics, optionals } = frame + let { extract, rawParams, parsedParams } = frame + + if (node.skipOnParamError) { + const result = validateMatchParams(path, parts, frame) + if (!result) continue + rawParams = frame.rawParams + extract = frame.extract + parsedParams = frame.parsedParams + } // In fuzzy mode, track the best partial match we've found so far if ( @@ -898,8 +1033,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]! @@ -915,6 +1051,13 @@ function getNodeMatch( statics, dynamics, optionals, + extract, + rawParams, + parsedParams, + } + if (node.index.skipOnParamError) { + const result = validateMatchParams(path, parts, indexFrame) + if (!result) continue } // perfect match, no need to continue // this is an optimization, algorithm should work correctly without this block @@ -946,7 +1089,7 @@ function getNodeMatch( } // the first wildcard match is the highest priority one // wildcard matches skip the stack because they cannot have children - wildcardMatch = { + const frame = { node: segment, index: partsLength, skipped, @@ -954,7 +1097,15 @@ function getNodeMatch( statics, dynamics, optionals, + extract, + rawParams, + parsedParams, } + if (segment.skipOnParamError) { + const result = validateMatchParams(path, parts, frame) + if (!result) continue + } + wildcardMatch = frame break } } @@ -974,6 +1125,9 @@ function getNodeMatch( statics, dynamics, optionals, + extract, + rawParams, + parsedParams, }) // enqueue skipping the optional } if (!isBeyondPath) { @@ -995,6 +1149,9 @@ function getNodeMatch( statics, dynamics, optionals: optionals + 1, + extract, + rawParams, + parsedParams, }) } } @@ -1020,6 +1177,9 @@ function getNodeMatch( statics, dynamics: dynamics + 1, optionals, + extract, + rawParams, + parsedParams, }) } } @@ -1038,6 +1198,9 @@ function getNodeMatch( statics: statics + 1, dynamics, optionals, + extract, + rawParams, + parsedParams, }) } } @@ -1054,6 +1217,29 @@ function getNodeMatch( statics: statics + 1, dynamics, optionals, + extract, + rawParams, + parsedParams, + }) + } + } + + // 0. Try pathless match + if (node.pathless) { + const nextDepth = depth + 1 + for (let i = node.pathless.length - 1; i >= 0; i--) { + const segment = node.pathless[i]! + stack.push({ + node: segment, + index, + skipped, + depth: nextDepth, + statics, + dynamics, + optionals, + extract, + rawParams, + parsedParams, }) } } @@ -1075,16 +1261,31 @@ function getNodeMatch( sliceIndex += parts[i]!.length } const splat = sliceIndex === path.length ? '/' : path.slice(sliceIndex) - return { - node: bestFuzzy.node, - skipped: bestFuzzy.skipped, - '**': decodeURIComponent(splat), - } + bestFuzzy.rawParams ??= {} + bestFuzzy.rawParams['**'] = decodeURIComponent(splat) + return bestFuzzy } return null } +function validateMatchParams( + path: string, + parts: Array, + frame: MatchStackFrame, +) { + try { + 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 { + return null + } +} + function isFrameMoreSpecific( // the stack frame previously saved as "best match" prev: MatchStackFrame | null, diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts index 53d726ef05a..10ba75d682e 100644 --- a/packages/router-core/src/route.ts +++ b/packages/router-core/src/route.ts @@ -1188,9 +1188,46 @@ export interface UpdatableRouteOptions< in out TBeforeLoadFn, > extends UpdatableStaticRouteOption, UpdatableRouteOptionsExtensions { - // If true, this route will be matched as case-sensitive + /** + * Options to control route matching behavior with runtime code. + * + * @experimental 🚧 this feature is subject to change + * + * @link https://tanstack.com/router/latest/docs/framework/react/api/router/RouteOptionsType + */ + skipRouteOnParseError?: { + /** + * 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`. + * + * @default false + */ + params?: boolean + /** + * 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 + } + /** + * 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/src/router.ts b/packages/router-core/src/router.ts index d9d808a3185..1e837bd4261 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -698,8 +698,12 @@ 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 } export type EmitFn = (routerEvent: RouterEvent) => void @@ -1260,7 +1264,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 @@ -1401,26 +1405,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 + } } } } @@ -1802,7 +1814,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, @@ -2601,18 +2613,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?: { @@ -2719,15 +2731,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/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 47083567e21..f3c1ac82fc5 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,10 +862,232 @@ 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' }) }) }) }) + 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: { + params: 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, + "parsingPriority": 0, + "pathless": [ + { + "depth": 2, + "dynamic": null, + "fullPath": "/$foo", + "index": { + "depth": 3, + "dynamic": null, + "fullPath": "/$foo/", + "index": null, + "kind": 4, + "optional": null, + "parent": [Circular], + "parse": null, + "parsingPriority": 0, + "pathless": null, + "route": { + "fullPath": "/$foo/", + "id": "/$foo/_layout/", + "options": {}, + "path": "/", + }, + "skipOnParamError": false, + "static": null, + "staticInsensitive": null, + "wildcard": null, + }, + "kind": 5, + "optional": null, + "parent": [Circular], + "parse": [Function], + "parsingPriority": 0, + "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": { + "params": true, + }, + }, + "path": "$foo", + }, + "skipOnParamError": true, + "static": null, + "staticInsensitive": Map { + "bar" => { + "depth": 3, + "dynamic": null, + "fullPath": "/$foo/bar", + "index": null, + "kind": 0, + "optional": null, + "parent": [Circular], + "parse": null, + "parsingPriority": 0, + "pathless": null, + "route": { + "fullPath": "/$foo/bar", + "id": "/$foo/_layout/bar", + "options": {}, + "path": "bar", + }, + "skipOnParamError": false, + "static": null, + "staticInsensitive": null, + "wildcard": null, + }, + }, + "wildcard": null, + }, + ], + "prefix": undefined, + "route": null, + "skipOnParamError": false, + "static": null, + "staticInsensitive": Map { + "hello" => { + "depth": 2, + "dynamic": null, + "fullPath": "/$foo/hello", + "index": null, + "kind": 0, + "optional": null, + "parent": [Circular], + "parse": null, + "parsingPriority": 0, + "pathless": null, + "route": { + "fullPath": "/$foo/hello", + "id": "/$foo/hello", + "options": {}, + "path": "$foo/hello", + }, + "skipOnParamError": false, + "static": null, + "staticInsensitive": null, + "wildcard": null, + }, + }, + "suffix": undefined, + "wildcard": null, + }, + ], + "fullPath": "/", + "index": { + "depth": 1, + "dynamic": null, + "fullPath": "/", + "index": null, + "kind": 4, + "optional": null, + "parent": [Circular], + "parse": null, + "parsingPriority": 0, + "pathless": null, + "route": { + "fullPath": "/", + "id": "/", + "options": {}, + "path": "/", + }, + "skipOnParamError": false, + "static": null, + "staticInsensitive": null, + "wildcard": null, + }, + "kind": 0, + "optional": null, + "parent": null, + "parse": null, + "parsingPriority": 0, + "pathless": null, + "route": null, + "skipOnParamError": false, + "static": null, + "staticInsensitive": null, + "wildcard": null, + } + `) + }) + }) }) describe('processRouteMasks', { sequential: true }, () => { @@ -894,16 +1119,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 ?? {} } 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..9c40af7c996 --- /dev/null +++ b/packages/router-core/tests/skip-route-on-parse-error.test.ts @@ -0,0 +1,857 @@ +import { describe, expect, it, vi } 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') + }) + + 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', () => { + 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' }) + + // non-numeric foo should NOT match the children of the validated layout + const barResult = findRouteMatch('/abc/bar', processedTree) + expect(barResult).toBeNull() + }) + }) + + 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('/it/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') + }) + it('priority option can be used to influence order', () => { + const alphabetical = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/$a', + fullPath: '/$a', + path: '$a', + options: { + params: { + 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 + }, + }, + }, + ], + } + { + 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: '/$a', + fullPath: '/$a', + path: '$a', + options: { + params: { + parse: (params: Record) => params, + }, + skipRouteOnParseError: { + params: true, + priority: -1, // lower priority than /$z + }, + }, + }, + { + id: '/$z', + fullPath: '/$z', + path: '$z', + options: { + params: { + parse: (params: Record) => params, + }, + skipRouteOnParseError: { + params: true, + priority: 1, // higher priority than /$a + }, + }, + }, + ], + } + { + const { processedTree } = processRouteTree(reverse) + const result = findRouteMatch('/123', processedTree) + expect(result?.route.id).toBe('/$z') + } + }) + }) + + 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__', + 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') + }) + }) +})