diff --git a/.changeset/plenty-jokes-listen.md b/.changeset/plenty-jokes-listen.md new file mode 100644 index 0000000000..e70b44fe94 --- /dev/null +++ b/.changeset/plenty-jokes-listen.md @@ -0,0 +1,5 @@ +--- +'@tanstack/router-core': patch +--- + +Fix `matchRoute` to respect parsed path parameters, route precedence, and exact trailing-slash matching. diff --git a/packages/react-router/tests/Matches.test.tsx b/packages/react-router/tests/Matches.test.tsx index dd3ca3dfa6..c668ab46a3 100644 --- a/packages/react-router/tests/Matches.test.tsx +++ b/packages/react-router/tests/Matches.test.tsx @@ -145,6 +145,44 @@ test('should show pendingComponent of root route', async () => { expect(await rendered.findByTestId('root-content')).toBeInTheDocument() }) +test('useMatchRoute matches typed params from routes with custom parse and stringify functions', async () => { + const rootRoute = createRootRoute() + + function InvoiceComponent() { + const matchRoute = useMatchRoute() + const match = matchRoute({ + to: '/invoices/$invoiceId', + params: { invoiceId: 123 }, + }) + + return
{JSON.stringify(match)}
+ } + + const invoiceRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/invoices/$invoiceId', + params: { + parse: ({ invoiceId }: { invoiceId: string }) => ({ + invoiceId: Number(invoiceId), + }), + stringify: ({ invoiceId }: { invoiceId: number }) => ({ + invoiceId: String(invoiceId), + }), + }, + component: InvoiceComponent, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([invoiceRoute]), + history: createMemoryHistory({ initialEntries: ['/invoices/123'] }), + }) + + render() + + expect(await screen.findByTestId('match')).toHaveTextContent( + '{"invoiceId":123}', + ) +}) + describe('matching on different param types', () => { const testCases = [ { diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 6978b071ce..1b2db28ffb 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -641,14 +641,11 @@ type RouteLike = { export type ProcessedTree< TTree extends Extract, TFlat extends Extract, - TSingle extends Extract, > = { /** a representation of the `routeTree` as a segment tree */ segmentTree: AnySegmentNode /** a mini route tree generated from the flat `routeMasks` list */ masksTree: AnySegmentNode | null - /** @deprecated keep until v2 so that `router.matchRoute` can keep not caring about the actual route tree */ - singleCache: LRUCache> /** a cache of route matches from the `segmentTree` */ matchCache: LRUCache | null> /** a cache of route matches from the `masksTree` */ @@ -657,10 +654,7 @@ export type ProcessedTree< export function processRouteMasks< TRouteLike extends Extract, ->( - routeList: Array, - processedTree: ProcessedTree, -) { +>(routeList: Array, processedTree: ProcessedTree) { const segmentTree = createStaticNode('/') const data = new Uint16Array(6) for (const route of routeList) { @@ -681,7 +675,7 @@ export function findFlatMatch>( /** The path to match. */ path: string, /** The `processedTree` returned by the initial `processRouteTree` call. */ - processedTree: ProcessedTree, + processedTree: ProcessedTree, ) { path ||= '/' const cached = processedTree.flatCache!.get(path) @@ -691,35 +685,11 @@ export function findFlatMatch>( return result } -/** - * @deprecated keep until v2 so that `router.matchRoute` can keep not caring about the actual route tree - */ -export function findSingleMatch( - from: string, - caseSensitive: boolean, - fuzzy: boolean, - path: string, - processedTree: ProcessedTree, -) { - from ||= '/' - path ||= '/' - const key = caseSensitive ? `case\0${from}` : from - let tree = processedTree.singleCache.get(key) - if (!tree) { - // single flat routes (router.matchRoute) are not eagerly processed, - // if we haven't seen this route before, process it now - tree = createStaticNode<{ from: string }>('/') - const data = new Uint16Array(6) - parseSegments(caseSensitive, data, { from }, 1, tree, 0) - processedTree.singleCache.set(key, tree) - } - return findMatch(path, tree, fuzzy) -} - type RouteMatch> = { route: T rawParams: Record branch: ReadonlyArray + routeParams: ReadonlyArray | undefined> } export function findRouteMatch< @@ -728,7 +698,7 @@ export function findRouteMatch< /** The path to match against the route tree. */ path: string, /** The `processedTree` returned by the initial `processRouteTree` call. */ - processedTree: ProcessedTree, + processedTree: ProcessedTree, /** If `true`, allows fuzzy matching (partial matches), i.e. which node in the tree would have been an exact match if the `path` had been shorter? */ fuzzy = false, ): RouteMatch | null { @@ -743,6 +713,7 @@ export function findRouteMatch< path, processedTree.segmentTree, fuzzy, + true, ) as RouteMatch | null } catch (err) { if (err instanceof URIError) { @@ -752,7 +723,6 @@ export function findRouteMatch< } } - if (result) result.branch = buildRouteBranch(result.route) processedTree.matchCache.set(key, result) return result } @@ -766,7 +736,7 @@ export interface ProcessRouteTreeResult< TRouteLike extends Extract & { id: string }, > { /** Should be considered a black box, needs to be provided to all matching functions in this module. */ - processedTree: ProcessedTree + processedTree: ProcessedTree /** A lookup map of routes by their unique IDs. */ routesById: Record /** A lookup map of routes by their trimmed full paths. */ @@ -817,9 +787,8 @@ export function processRouteTree< index++ }) sortTreeNodes(segmentTree) - const processedTree: ProcessedTree = { + const processedTree: ProcessedTree = { segmentTree, - singleCache: createLRUCache>(1000), matchCache: createLRUCache | null>(1000), flatCache: null, masksTree: null, @@ -835,6 +804,7 @@ function findMatch( path: string, segmentTree: AnySegmentNode, fuzzy = false, + includeRouteParams = false, ): { route: T /** @@ -842,14 +812,31 @@ function findMatch( * This will be the exhaustive list of all params defined in the route's path. */ rawParams: Record + branch: ReadonlyArray + routeParams?: ReadonlyArray | undefined> } | null { const parts = path.split('/') const leaf = getNodeMatch(path, parts, segmentTree, fuzzy) if (!leaf) return null - const [rawParams] = extractParams(path, parts, leaf) + const routeBranch = includeRouteParams + ? buildRouteBranch(leaf.node.route!) + : undefined + const routeParams: Array | undefined> = [] + const rawParams = extractParams( + path, + parts, + { node: leaf.node, skipped: leaf.skipped }, + routeBranch, + routeParams, + ) + if (leaf.fuzzyRemainder !== undefined) { + rawParams['**'] = leaf.fuzzyRemainder + } return { route: leaf.node.route!, - rawParams, + rawParams: rawParams as Record, + branch: routeBranch ?? buildRouteBranch(leaf.node.route!), + routeParams, } } @@ -862,10 +849,8 @@ type ParamExtractionState = { /** * 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. + * The optional `state` resumes extraction from where the previous call stopped + * and is updated with the new position. */ function extractParams( path: string, @@ -874,12 +859,19 @@ function extractParams( node: AnySegmentNode skipped: number extract?: ParamExtractionState - rawParams?: Record + params?: Record }, -): [rawParams: Record, state: ParamExtractionState] { + routeBranch?: ReadonlyArray, + routeParams?: Array | undefined>, +): Record { const list = buildBranch(leaf.node) let nodeParts: Array | null = null - const rawParams: Record = Object.create(null) + const rawParams: Record = Object.assign( + Object.create(null), + leaf.params, + ) + let routeIndex = 0 + let routeRawParams: Record | undefined /** which segment of the path we're currently processing */ let partIndex = leaf.extract?.part ?? 0 /** which node of the route tree branch we're currently processing */ @@ -894,79 +886,103 @@ function extractParams( partIndex++, nodeIndex++, pathIndex++, segmentCount++ ) { const node = list[nodeIndex]! - // index nodes are terminating nodes, nothing to extract, just leave - if (node.kind === SEGMENT_TYPE_INDEX) break - // pathless nodes do not consume a path segment - if (node.kind === SEGMENT_TYPE_PATHLESS) { + const done = node.kind === SEGMENT_TYPE_INDEX + if (done) { + // index nodes are terminating nodes and have nothing to extract + } else if (node.kind === SEGMENT_TYPE_PATHLESS) { + // pathless nodes do not consume a path segment segmentCount-- partIndex-- pathIndex-- - continue - } - const part = parts[partIndex] - const currentPathIndex = pathIndex - if (part) pathIndex += part.length - if (node.kind === SEGMENT_TYPE_PARAM) { + } else { + const part = parts[partIndex] + const currentPathIndex = pathIndex + if (part) pathIndex += part.length nodeParts ??= leaf.node.fullPath.split('/') const nodePart = nodeParts[segmentCount]! - const preLength = node.prefix?.length ?? 0 - // we can't rely on the presence of prefix/suffix to know whether it's curly-braced or not, because `/{$param}/` is valid, but has no prefix/suffix - const isCurlyBraced = nodePart.charCodeAt(preLength) === 123 // '{' - // param name is extracted at match-time so that tree nodes that are identical except for param name can share the same node - if (isCurlyBraced) { + if (node.kind === SEGMENT_TYPE_PARAM) { + const preLength = node.prefix?.length ?? 0 + // we can't rely on the presence of prefix/suffix to know whether it's curly-braced or not, because `/{$param}/` is valid, but has no prefix/suffix + const isCurlyBraced = nodePart.charCodeAt(preLength) === 123 // '{' + // param name is extracted at match-time so that tree nodes that are identical except for param name can share the same node + if (isCurlyBraced) { + const sufLength = node.suffix?.length ?? 0 + const name = nodePart.substring( + preLength + 2, + nodePart.length - sufLength - 1, + ) + const value = part!.substring(preLength, part!.length - sufLength) + const decodedValue = decodeURIComponent(value) + rawParams[name] = decodedValue + ;(routeRawParams ??= Object.create(null))[name] = decodedValue + } else { + const name = nodePart.substring(1) + const decodedValue = decodeURIComponent(part!) + rawParams[name] = decodedValue + ;(routeRawParams ??= Object.create(null))[name] = decodedValue + } + } else if ( + node.kind === SEGMENT_TYPE_OPTIONAL_PARAM && + leaf.skipped & (1 << nodeIndex) + ) { + partIndex-- // stay on the same part + pathIndex = currentPathIndex - 1 // undo pathIndex advancement; -1 to account for loop increment + } else if (node.kind === SEGMENT_TYPE_OPTIONAL_PARAM) { + const preLength = node.prefix?.length ?? 0 const sufLength = node.suffix?.length ?? 0 const name = nodePart.substring( - preLength + 2, + preLength + 3, nodePart.length - sufLength - 1, ) - const value = part!.substring(preLength, part!.length - sufLength) - rawParams[name] = decodeURIComponent(value) - } else { - const name = nodePart.substring(1) - rawParams[name] = decodeURIComponent(part!) + const value = + node.suffix || node.prefix + ? part!.substring(preLength, part!.length - sufLength) + : part + if (value) { + const decodedValue = decodeURIComponent(value) + rawParams[name] = decodedValue + ;(routeRawParams ??= Object.create(null))[name] = decodedValue + } + } else if (node.kind === SEGMENT_TYPE_WILDCARD) { + const preLength = node.prefix?.length ?? 0 + const sufLength = node.suffix?.length ?? 0 + const value = path.substring( + currentPathIndex + preLength, + path.length - sufLength, + ) + const splat = decodeURIComponent(value) + // TODO: Deprecate * + rawParams['*'] = splat + rawParams._splat = splat + const wildcardParams = (routeRawParams ??= Object.create(null)) + wildcardParams['*'] = splat + wildcardParams._splat = splat } - } else if (node.kind === SEGMENT_TYPE_OPTIONAL_PARAM) { - if (leaf.skipped & (1 << nodeIndex)) { - partIndex-- // stay on the same part - pathIndex = currentPathIndex - 1 // undo pathIndex advancement; -1 to account for loop increment - continue + } + + while (routeBranch && routeIndex < routeBranch.length) { + const route = routeBranch[routeIndex]! + if ( + route.fullPath !== '/' && + route.fullPath!.split('/').length - 1 > segmentCount + ) { + break } - nodeParts ??= leaf.node.fullPath.split('/') - const nodePart = nodeParts[segmentCount]! - const preLength = node.prefix?.length ?? 0 - const sufLength = node.suffix?.length ?? 0 - const name = nodePart.substring( - preLength + 3, - nodePart.length - sufLength - 1, - ) - const value = - node.suffix || node.prefix - ? part!.substring(preLength, part!.length - sufLength) - : part - if (value) rawParams[name] = decodeURIComponent(value) - } else if (node.kind === SEGMENT_TYPE_WILDCARD) { - const n = node - const value = path.substring( - currentPathIndex + (n.prefix?.length ?? 0), - path.length - (n.suffix?.length ?? 0), - ) - const splat = decodeURIComponent(value) - // TODO: Deprecate * - rawParams['*'] = splat - rawParams._splat = splat + routeParams!.push(routeRawParams) + routeRawParams = undefined + routeIndex++ + } + if (done || node.kind === SEGMENT_TYPE_WILDCARD) { break } } - if (leaf.rawParams) Object.assign(rawParams, leaf.rawParams) - return [ - rawParams, - { - part: partIndex, - node: nodeIndex, - path: pathIndex, - segment: segmentCount, - }, - ] + leaf.extract = { + part: partIndex, + node: nodeIndex, + path: pathIndex, + segment: segmentCount, + } + return rawParams } export function buildRouteBranch(route: T) { @@ -990,25 +1006,20 @@ function buildBranch(node: AnySegmentNode) { type MatchStackFrame = { node: AnySegmentNode - /** index of the segment of path */ + /** index of the path segment */ index: number - /** how many nodes between `node` and the root of the segment tree */ - depth: number - /** - * Bitmask of skipped optional segments. - * - * This is a very performant way of storing an "array of booleans", but it means beyond 32 segments we can't track skipped optionals. - * If we really really need to support more than 32 segments we can switch to using a `BigInt` here. It's about 2x slower in worst case scenarios. - */ + /** bitmask of skipped optional segments */ skipped: number - /** Positional bitmasks tracking which consumed URL segments matched each segment kind. */ + /** how many nodes are between `node` and the root */ + depth: number + /** positional specificity bitmasks */ statics: number dynamics: number optionals: number - /** intermediary state for param extraction */ + /** intermediary parameter extraction state */ extract?: ParamExtractionState - /** intermediary params from param extraction */ - rawParams?: Record + params?: Record + fuzzyRemainder?: string } function getNodeMatch( @@ -1016,14 +1027,12 @@ function getNodeMatch( parts: Array, segmentTree: AnySegmentNode, fuzzy: boolean, -) { +): MatchStackFrame | null { // 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 } as Pick< - Frame, - 'node' | 'skipped' - > + if (path === '/' && segmentTree.index) { + return { node: segmentTree.index, skipped: 0 } as MatchStackFrame + } const trailingSlash = !last(parts) const pathIsIndex = trailingSlash && path !== '/' @@ -1056,7 +1065,7 @@ function getNodeMatch( while (stack.length) { const frame = stack.pop()! const { node, index, skipped, depth, statics, dynamics, optionals } = frame - let { extract, rawParams } = frame + let { extract, params } = frame // Wildcard candidates are pushed speculatively as fallbacks in case a // higher-priority wildcard later fails params.parse. If a better wildcard @@ -1073,7 +1082,7 @@ function getNodeMatch( if (node.parse) { const result = validateParseParams(path, parts, frame) if (!result) continue - rawParams = frame.rawParams + params = frame.params extract = frame.extract } @@ -1108,7 +1117,7 @@ function getNodeMatch( // 0. Try index match if (isBeyondPath && node.index) { - const indexFrame = { + const indexFrame: Frame = { node: node.index, index, skipped, @@ -1117,14 +1126,9 @@ function getNodeMatch( dynamics, optionals, extract, - rawParams, - } - let indexValid = true - if (node.index.parse) { - const result = validateParseParams(path, parts, indexFrame) - if (!result) indexValid = false + params, } - if (indexValid) { + if (!node.index.parse || validateParseParams(path, parts, indexFrame)) { // perfect match, no need to continue // this is an optimization, algorithm should work correctly without this block if ( @@ -1170,7 +1174,7 @@ function getNodeMatch( dynamics, optionals, extract, - rawParams, + params, }) } } @@ -1191,7 +1195,7 @@ function getNodeMatch( dynamics, optionals, extract, - rawParams, + params, }) // enqueue skipping the optional } if (!isBeyondPath) { @@ -1214,7 +1218,7 @@ function getNodeMatch( dynamics, optionals: optionals + segmentScore(partsLength, index), extract, - rawParams, + params, }) } } @@ -1241,7 +1245,7 @@ function getNodeMatch( dynamics: dynamics + segmentScore(partsLength, index), optionals, extract, - rawParams, + params, }) } } @@ -1261,7 +1265,7 @@ function getNodeMatch( dynamics, optionals, extract, - rawParams, + params, }) } } @@ -1279,7 +1283,7 @@ function getNodeMatch( dynamics, optionals, extract, - rawParams, + params, }) } } @@ -1298,7 +1302,7 @@ function getNodeMatch( dynamics, optionals, extract, - rawParams, + params, }) } } @@ -1312,8 +1316,7 @@ function getNodeMatch( sliceIndex += parts[i]!.length } const splat = sliceIndex === path.length ? '/' : path.slice(sliceIndex) - bestFuzzy.rawParams ??= Object.create(null) - bestFuzzy.rawParams!['**'] = decodeURIComponent(splat) + bestFuzzy.fuzzyRemainder = decodeURIComponent(splat) return bestFuzzy } @@ -1338,22 +1341,20 @@ function validateParseParams( parts: Array, frame: MatchStackFrame, ) { - let rawParams: Record - let state: ParamExtractionState + let params: Record try { - ;[rawParams, state] = extractParams(path, parts, frame) + params = extractParams(path, parts, frame) } catch { return null } - frame.rawParams = rawParams - frame.extract = state - - if (!frame.node.parse) return true + frame.params = params try { - if (frame.node.parse(rawParams) === false) return null + const result = frame.node.parse!(params as Record) + if (result === false) return null + Object.assign(params, result) } catch { // Thrown parse errors should be surfaced on the selected match by // extractStrictParams, not used as fallback route selection. diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 2197dab737..af6d42ff91 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -18,7 +18,6 @@ import { buildRouteBranch, findFlatMatch, findRouteMatch, - findSingleMatch, processRouteMasks, processRouteTree, } from './new-process-route-tree' @@ -26,6 +25,7 @@ import { cleanPath, compileDecodeCharMap, interpolatePath, + removeTrailingSlash, resolvePath, trimPath, trimPathRight, @@ -739,6 +739,7 @@ export type GetMatchRoutesFn = (pathname: string) => { matchedRoutes: ReadonlyArray /** exhaustive params, still in their string form */ routeParams: Record + routeMatchData?: ReadonlyArray | undefined> foundRoute: AnyRoute | undefined parseError?: unknown } @@ -1011,7 +1012,7 @@ export class RouterCore< routeTree!: TRouteTree routesById!: RoutesById routesByPath!: RoutesByPath - processedTree!: ProcessedTree + processedTree!: ProcessedTree resolvePathCache!: LRUCache private routeBranchCache = new WeakMap>() private lightweightCache = new WeakMap< @@ -1445,7 +1446,13 @@ export class RouterCore< opts?: MatchRoutesOpts, ): Array { const matchedRoutesResult = this.getMatchedRoutes(next.pathname) - const { foundRoute, routeParams } = matchedRoutesResult + const { + foundRoute, + routeMatchData, + routeParams: rawRouteParams, + } = matchedRoutesResult + const routeParams: Record = Object.create(null) + const rawParams: Record = Object.create(null) let { matchedRoutes } = matchedRoutesResult let isGlobalNotFound = false @@ -1453,7 +1460,7 @@ export class RouterCore< if ( // If we found a route, and it's not an index route and we have left over path foundRoute - ? foundRoute.path !== '/' && routeParams['**'] + ? foundRoute.path !== '/' && rawRouteParams['**'] : // Or if we didn't find a route and we have left over path trimPathRight(next.pathname) ) { @@ -1540,13 +1547,14 @@ export class RouterCore< const loaderDepsHash = loaderDeps ? JSON.stringify(loaderDeps) : '' - const { interpolatedPath, usedParams } = interpolatePath({ + Object.assign(rawParams, routeMatchData?.[index]) + + const { interpolatedPath } = interpolatePath({ path: route.fullPath, - params: routeParams, + params: rawParams, decoder: this.pathParamsDecoder, server: this.isServer, }) - // Waste not, want not. If we already have a match for this route, // reuse it. This is important for layout routes, which might stick // around between navigation actions that only change leaf routes. @@ -1565,7 +1573,9 @@ export class RouterCore< const previousMatch = previousActiveMatchesByRouteId.get(route.id) - const strictParams = existingMatch?._strictParams ?? usedParams + const strictParams = + existingMatch?._strictParams ?? + Object.assign(Object.create(null), routeParams, routeMatchData?.[index]) let paramsError: unknown = undefined @@ -2982,31 +2992,61 @@ export class RouterCore< ? this.latestLocation : this.stores.resolvedLocation.get() || this.stores.location.get() - const match = findSingleMatch( - next.pathname, - opts?.caseSensitive ?? false, - opts?.fuzzy ?? false, - baseLocation.pathname, - this.processedTree, - ) - - if (!match) { + const destinationPath = trimPathRight(next.pathname) + const fuzzy = opts?.fuzzy + let routeMatches = pending + ? this.stores.pendingMatches.get() + : this.stores.matches.get() + if (!routeMatches.length && !opts?.pending) { + routeMatches = this.stores.matches.get() + } + if (!routeMatches.length) { + routeMatches = this.matchRoutes(baseLocation) + } + const destinationMatch = fuzzy + ? routeMatches.find( + (match) => trimPathRight(match.fullPath) === destinationPath, + ) + : last(routeMatches) + if ( + !destinationMatch || + (!fuzzy && + trimPathRight(destinationMatch.fullPath) !== destinationPath) || + destinationMatch.paramsError + ) { return false } - if (location.params) { - if (!deepEqual(match.rawParams, location.params, { partial: true })) { + const params = Object.assign( + Object.create(null), + destinationMatch._strictParams, + ) + + try { + const currentPathname = trimPathRight(baseLocation.pathname) + const matchedPathname = trimPathRight( + decodeURI(destinationMatch.pathname), + ) + if (opts?.caseSensitive && !currentPathname.startsWith(matchedPathname)) { return false } - } - if (opts?.includeSearch ?? true) { - return deepEqual(baseLocation.search, next.search, { partial: true }) - ? match.rawParams - : false + if (fuzzy) { + const remainder = currentPathname.slice(matchedPathname.length) + if (remainder) { + params['**'] = decodeURIComponent(remainder.replace(/^\/+/, '')) + } + } + } catch { + return false } - return match.rawParams + return (!location.params || + deepEqual(params, location.params, { partial: true })) && + (!(opts?.includeSearch ?? true) || + deepEqual(baseLocation.search, next.search, { partial: true })) + ? params + : false } ssr?: { @@ -3107,21 +3147,25 @@ export function getMatchedRoutes({ }: { pathname: string routesById: Record - processedTree: ProcessedTree + processedTree: ProcessedTree }) { const routeParams: Record = Object.create(null) const trimmedPath = trimPathRight(pathname) let foundRoute: TRouteLike | undefined = undefined + let routeMatchData: + | ReadonlyArray | undefined> + | undefined const match = findRouteMatch(trimmedPath, processedTree, true) if (match) { foundRoute = match.route Object.assign(routeParams, match.rawParams) // Copy params, because they're cached + routeMatchData = match.routeParams } const matchedRoutes = match?.branch || [routesById[rootRouteId]!] - return { matchedRoutes, routeParams, foundRoute } + return { matchedRoutes, routeMatchData, routeParams, foundRoute } } /** diff --git a/packages/router-core/tests/match-by-path.test.ts b/packages/router-core/tests/match-by-path.test.ts deleted file mode 100644 index f742c22df9..0000000000 --- a/packages/router-core/tests/match-by-path.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - findSingleMatch, - processRouteTree, -} from '../src/new-process-route-tree' - -const { processedTree } = processRouteTree({ - id: '__root__', - isRoot: true, - fullPath: '/', - path: '/', -}) - -describe('default path matching', () => { - it.each([ - ['', '', {}], - ['/', '', {}], - ['', '/', {}], - ['/', '/', {}], - ['/', '/', {}], - ['/a', '/a', {}], - ['/a/b', '/a/b', {}], - ['/a', '/a/', {}], - ['/a/', '/a/', {}], - ['/a/', '/a', undefined], - ['/b', '/a', undefined], - ])('static %s %s => %s', (path, pattern, result) => { - const res = findSingleMatch(pattern, true, false, path, processedTree) - expect(res?.rawParams).toEqual(result) - }) - - it.each([ - ['/a/1', '/a/$id', { id: '1' }], - ['/a/1/b', '/a/$id/b', { id: '1' }], - ['/a/1/b/2', '/a/$id/b/$other', { id: '1', other: '2' }], - ['/a/1_/b/2', '/a/$id/b/$other', { id: '1_', other: '2' }], - ['/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?.rawParams).toEqual(result) - }) - - it('params support more than alphanumeric characters', () => { - // in the value: basically everything except / and % - const anyValueResult = findSingleMatch( - '/a/$id', - false, - false, - '/a/@&é"\'(§è!çà)-_°^¨$*€£`ù=+:;.,?~<>|î©#0123456789\\😀}{', - processedTree, - ) - expect(anyValueResult?.rawParams).toEqual({ - id: '@&é"\'(§è!çà)-_°^¨$*€£`ù=+:;.,?~<>|î©#0123456789\\😀}{', - }) - // in the key: basically everything except / and % and $ - const anyKeyResult = findSingleMatch( - '/a/$@&é"\'(§è!çà)-_°^¨*€£`ù=+:;.,?~<>|î©#0123456789\\😀}{', - false, - false, - '/a/1', - processedTree, - ) - expect(anyKeyResult?.rawParams).toEqual({ - '@&é"\'(§è!çà)-_°^¨*€£`ù=+:;.,?~<>|î©#0123456789\\😀}{': '1', - }) - }) - - it.each([ - ['/a/1', '/a/{-$id}', { id: '1' }], - ['/a', '/a/{-$id}', {}], - ['/a/1/b', '/a/{-$id}/b', { id: '1' }], - ['/a/b', '/a/{-$id}/b', {}], - ['/a/1/b/2', '/a/{-$id}/b/{-$other}', { id: '1', other: '2' }], - ['/a/b/2', '/a/{-$id}/b/{-$other}', { other: '2' }], - ['/a/1/b', '/a/{-$id}/b/{-$other}', { id: '1' }], - ['/a/b', '/a/{-$id}/b/{-$other}', {}], - ['/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?.rawParams).toEqual(result) - }) - - it.each([ - ['/a/b/c', '/a/$', { _splat: 'b/c', '*': 'b/c' }], - ['/a/', '/a/$', { _splat: '', '*': '' }], - ['/a', '/a/$', { _splat: '', '*': '' }], - ['/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?.rawParams).toEqual(result) - }) -}) - -describe('case insensitive path matching', () => { - it.each([ - ['', '', '', {}], - ['', '/', '', {}], - ['', '', '/', {}], - ['', '/', '/', {}], - ['/', '/', '/', {}], - ['/', '/a', '/A', {}], - ['/', '/a/b', '/A/B', {}], - ['/', '/a', '/A/', {}], - ['/', '/a/', '/A/', {}], - ['/', '/a/', '/A', undefined], - ['/', '/b', '/A', undefined], - ])('static %s %s => %s', (base, path, pattern, result) => { - const res = findSingleMatch(pattern, false, false, path, processedTree) - expect(res?.rawParams).toEqual(result) - }) - - it.each([ - ['/a/1', '/A/$id', { id: '1' }], - ['/a/1/b', '/A/$id/B', { id: '1' }], - ['/a/1/b/2', '/A/$id/B/$other', { id: '1', other: '2' }], - ['/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?.rawParams).toEqual(result) - }) - - it.each([ - ['/a/1', '/A/{-$id}', { id: '1' }], - ['/a', '/A/{-$id}', {}], - ['/a/1/b', '/A/{-$id}/B', { id: '1' }], - ['/a/1_/b', '/A/{-$id}/B', { id: '1_' }], - // ['/a/b', '/A/{-$id}/B', {}], - ['/a/1/b/2', '/A/{-$id}/B/{-$other}', { id: '1', other: '2' }], - // ['/a/b/2', '/A/{-$id}/B/{-$other}', { other: '2' }], - ['/a/1/b', '/A/{-$id}/B/{-$other}', { id: '1' }], - // ['/a/b', '/A/{-$id}/B/{-$other}', {}], - ['/a/1/b/2', '/A/{-$id}/B/{-$id}', { id: '2' }], - ['/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?.rawParams).toEqual(result) - }) - - it.each([ - ['/a/b/c', '/A/$', { _splat: 'b/c', '*': 'b/c' }], - ['/a/', '/A/$', { _splat: '', '*': '' }], - ['/a', '/A/$', { _splat: '', '*': '' }], - ['/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?.rawParams).toEqual(result) - }) -}) - -describe('fuzzy path matching', () => { - it.each([ - ['', '', '', {}], - ['', '/', '', {}], - ['', '', '/', {}], - ['', '/', '/', {}], - ['/', '/', '/', {}], - ['/', '/a', '/a', {}], - ['/', '/a', '/a/', {}], - ['/', '/a/', '/a/', {}], - ['/', '/a/', '/a', { '**': '/' }], - ['/', '/a/b', '/a/b', {}], - ['/', '/a/b', '/a', { '**': 'b' }], - ['/', '/a/b/', '/a', { '**': 'b/' }], - ['/', '/a/b/c', '/a', { '**': 'b/c' }], - ['/', '/a', '/a/b', undefined], - ['/', '/b', '/a', undefined], - ['/', '/a', '/b', undefined], - ])('static %s %s => %s', (base, path, pattern, result) => { - const res = findSingleMatch(pattern, true, true, path, processedTree) - expect(res?.rawParams).toEqual(result) - }) - - it.each([ - ['/a/1', '/a/$id', { id: '1' }], - ['/a/1/b', '/a/$id', { id: '1', '**': 'b' }], - ['/a/1/', '/a/$id/', { id: '1' }], - ['/a/1/b/2', '/a/$id/b/$other', { id: '1', other: '2' }], - ['/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?.rawParams).toEqual(result) - }) - - it.each([ - ['/a/1', '/a/{-$id}', { id: '1' }], - ['/a', '/a/{-$id}', {}], - ['/a/1/b', '/a/{-$id}', { '**': 'b', id: '1' }], - ['/a/1/b', '/a/{-$id}/b', { id: '1' }], - ['/a/b', '/a/{-$id}/b', {}], - ['/a/b/c', '/a/{-$id}/b', { '**': 'c' }], - ['/a/b', '/a/{-$id}/b/{-$other}', {}], - ['/a/b/2/d', '/a/{-$id}/b/{-$other}', { other: '2', '**': 'd' }], - ['/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?.rawParams).toEqual(result) - }) - - it.each([ - ['/a/b/c', '/a/$', { _splat: 'b/c', '*': 'b/c' }], - ['/a/', '/a/$', { _splat: '', '*': '' }], - ['/a', '/a/$', { _splat: '', '*': '' }], - ['/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?.rawParams).toEqual(result) - }) -}) diff --git a/packages/router-core/tests/match-route.test.ts b/packages/router-core/tests/match-route.test.ts new file mode 100644 index 0000000000..bf7e78678d --- /dev/null +++ b/packages/router-core/tests/match-route.test.ts @@ -0,0 +1,648 @@ +import { describe, expect, it, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +describe('matchRoute', () => { + function createInvoiceRouter(initialEntry = '/invoices/123') { + const rootRoute = new BaseRootRoute({}) + const invoiceRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/invoices/$invoiceId', + params: { + parse: ({ invoiceId }: { invoiceId: string }) => { + const parsed = Number(invoiceId) + return Number.isInteger(parsed) ? { invoiceId: parsed } : false + }, + stringify: ({ invoiceId }: { invoiceId: number }) => ({ + invoiceId: String(invoiceId), + }), + }, + }) + + return createTestRouter({ + routeTree: rootRoute.addChildren([invoiceRoute]), + history: createMemoryHistory({ initialEntries: [initialEntry] }), + }) + } + + it('matches typed params from routes with custom parse and stringify functions', async () => { + const router = createInvoiceRouter() + + await router.load() + + expect( + router.matchRoute({ + to: '/invoices/$invoiceId', + params: { invoiceId: 123 }, + }), + ).toEqual({ invoiceId: 123 }) + }) + + it('does not match a different typed param', async () => { + const router = createInvoiceRouter() + + await router.load() + + expect( + router.matchRoute({ + to: '/invoices/$invoiceId', + params: { invoiceId: 456 }, + }), + ).toBe(false) + }) + + it('does not match a lower-priority route', async () => { + const rootRoute = new BaseRootRoute({}) + const postRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/posts/$postId', + params: { + parse: ({ postId }: { postId: string }) => ({ postId }), + }, + }) + const editRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/posts/edit', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([postRoute, editRoute]), + history: createMemoryHistory({ initialEntries: ['/posts/edit'] }), + }) + + await router.load() + + expect( + router.matchRoute({ + to: '/posts/$postId', + params: { postId: 'edit' }, + }), + ).toBe(false) + }) + + it('does not match params rejected by the route parser', async () => { + const router = createInvoiceRouter('/invoices/not-a-number') + + await router.load() + + expect( + router.matchRoute({ + to: '/invoices/$invoiceId', + params: { invoiceId: 123 }, + }), + ).toBe(false) + }) + + it('returns cached parsed params without re-running the parser', async () => { + const parse = vi.fn(({ invoiceId }: { invoiceId: string }) => ({ + invoiceId: Number(invoiceId), + })) + const rootRoute = new BaseRootRoute({}) + const invoiceRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/invoices/$invoiceId', + params: { parse }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([invoiceRoute]), + history: createMemoryHistory({ initialEntries: ['/invoices/123'] }), + }) + + await router.load() + parse.mockClear() + + expect( + router.matchRoute({ + to: '/invoices/$invoiceId', + params: { invoiceId: 123 }, + }), + ).toEqual({ invoiceId: 123 }) + expect( + router.matchRoute({ + to: '/invoices/$invoiceId', + params: { invoiceId: 123 }, + }), + ).toEqual({ invoiceId: 123 }) + expect(parse).not.toHaveBeenCalled() + }) + + it('reuses active matches instead of rebuilding them while idle', async () => { + const loaderDeps = vi.fn(() => ({})) + const rootRoute = new BaseRootRoute({}) + const postsRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + loaderDeps, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([postsRoute]), + history: createMemoryHistory({ initialEntries: ['/posts'] }), + }) + + await router.load() + loaderDeps.mockClear() + + expect(router.matchRoute({ to: '/posts' })).toEqual({}) + expect(loaderDeps).not.toHaveBeenCalled() + }) + + it('does not throw parser errors while checking a match', async () => { + const rootRoute = new BaseRootRoute({}) + const invoiceRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/invoices/$invoiceId', + params: { + parse: () => { + throw new Error('invalid invoice') + }, + stringify: ({ invoiceId }: { invoiceId: number }) => ({ + invoiceId: String(invoiceId), + }), + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([invoiceRoute]), + history: createMemoryHistory({ initialEntries: ['/invoices/123'] }), + }) + + await router.load() + + expect( + router.matchRoute({ + to: '/invoices/$invoiceId', + params: { invoiceId: 123 }, + }), + ).toBe(false) + }) + + it('matches an internal ID parsed from a non-canonical public slug', async () => { + const rootRoute = new BaseRootRoute({}) + const thingRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/things/$thingId', + params: { + parse: ({ thingId }: { thingId: string }) => ({ + thingId: thingId.slice(thingId.lastIndexOf('-') + 1), + }), + stringify: ({ thingId }: { thingId: string }) => ({ + thingId, + }), + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([thingRoute]), + history: createMemoryHistory({ + initialEntries: ['/things/Title-of-the-thing-abc123'], + }), + }) + + await router.load() + + expect( + router.matchRoute({ + to: '/things/$thingId', + params: { thingId: 'abc123' }, + }), + ).toEqual({ thingId: 'abc123' }) + }) + + it('passes parsed parent params to child route parsers', async () => { + const rootRoute = new BaseRootRoute({}) + const orgRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/orgs/$orgId', + params: { + parse: ({ orgId }: { orgId: string }) => ({ + orgId: Number(orgId), + }), + }, + }) + const projectRoute = new BaseRoute({ + getParentRoute: () => orgRoute, + path: '/projects/$projectId', + params: { + parse: (params: { projectId: string }) => { + if (typeof (params as Record).orgId !== 'number') { + return false + } + return { projectId: Number(params.projectId) } + }, + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([orgRoute.addChildren([projectRoute])]), + history: createMemoryHistory({ + initialEntries: ['/orgs/42/projects/7'], + }), + }) + + await router.load() + + expect( + router.matchRoute({ + to: '/orgs/$orgId/projects/$projectId', + params: { orgId: 42, projectId: 7 }, + }), + ).toEqual({ orgId: 42, projectId: 7 }) + }) + + it('does not match a template absent from the route tree', async () => { + const router = createInvoiceRouter() + + await router.load() + + expect( + router.matchRoute({ + to: '/invoices/$id', + params: { id: '123' }, + } as any), + ).toBe(false) + }) + + it('does not match an absent static destination', async () => { + const router = createInvoiceRouter() + + await router.load() + + expect(router.matchRoute({ to: '/missing' } as any)).toBe(false) + }) + + it('only matches an active ancestor when fuzzy matching', async () => { + const rootRoute = new BaseRootRoute({}) + const postsRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + }) + const postRoute = new BaseRoute({ + getParentRoute: () => postsRoute, + path: '/$postId', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([postsRoute.addChildren([postRoute])]), + history: createMemoryHistory({ initialEntries: ['/posts/123'] }), + }) + + await router.load() + + expect(router.matchRoute({ to: '/posts' })).toBe(false) + expect(router.matchRoute({ to: '/posts' }, { fuzzy: true })).toEqual({ + '**': '123', + }) + }) + + it('matches the root route without an index child', async () => { + const rootRoute = new BaseRootRoute({}) + const router = createTestRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + + expect(router.matchRoute({ to: '/' })).toEqual({}) + }) + + it('matches encoded params without corrupting fuzzy remainders', async () => { + const rootRoute = new BaseRootRoute({}) + const itemRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/items/$itemId', + }) + const exactRouter = createTestRouter({ + routeTree: rootRoute.addChildren([itemRoute]), + history: createMemoryHistory({ + initialEntries: ['/items/hello%20world'], + }), + }) + + await exactRouter.load() + + expect( + exactRouter.matchRoute( + { + to: '/items/$itemId', + params: { itemId: 'hello world' }, + }, + { caseSensitive: true }, + ), + ).toEqual({ itemId: 'hello world' }) + + const fuzzyRouter = createTestRouter({ + routeTree: rootRoute.addChildren([itemRoute]), + history: createMemoryHistory({ + initialEntries: ['/items/hello%20world/details'], + }), + }) + + await fuzzyRouter.load() + + expect( + fuzzyRouter.matchRoute( + { + to: '/items/$itemId', + params: { itemId: 'hello world' }, + }, + { fuzzy: true }, + ), + ).toEqual({ itemId: 'hello world', '**': 'details' }) + }) + + it.each([ + ['%25', '%'], + ['%2520', '%20'], + ])( + 'decodes the fuzzy remainder %s exactly once', + async (encoded, decoded) => { + const rootRoute = new BaseRootRoute({}) + const postsRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + }) + const childRoute = new BaseRoute({ + getParentRoute: () => postsRoute, + path: '/$value', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + postsRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ + initialEntries: [`/posts/${encoded}`], + }), + }) + + await router.load() + + expect(router.matchRoute({ to: '/posts' }, { fuzzy: true })).toEqual({ + '**': decoded, + }) + }, + ) + + it('keeps ancestor params when a descendant reuses the param name', async () => { + const rootRoute = new BaseRootRoute({}) + const orgRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/orgs/$id', + }) + const projectRoute = new BaseRoute({ + getParentRoute: () => orgRoute, + path: '/projects/$id', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([orgRoute.addChildren([projectRoute])]), + history: createMemoryHistory({ + initialEntries: ['/orgs/one/projects/two'], + }), + }) + + await router.load() + + expect( + router.matchRoute( + { to: '/orgs/$id', params: { id: 'one' } }, + { fuzzy: true }, + ), + ).toEqual({ id: 'one', '**': 'projects/two' }) + }) + + it('passes a reused child param name to the child parser', async () => { + const childParserParams: Array<{ id: string }> = [] + const childParse = vi.fn(({ id }: { id: string }) => { + childParserParams.push({ id }) + return { id: `child:${id}` } + }) + const rootRoute = new BaseRootRoute({}) + const orgRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/orgs/$id', + params: { + parse: ({ id }: { id: string }) => ({ id: `parent:${id}` }), + }, + }) + const projectRoute = new BaseRoute({ + getParentRoute: () => orgRoute, + path: '/projects/$id', + params: { parse: childParse }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([orgRoute.addChildren([projectRoute])]), + history: createMemoryHistory({ + initialEntries: ['/orgs/one/projects/two'], + }), + }) + + await router.load() + + expect( + router.matchRoute({ + to: '/orgs/$id/projects/$id', + params: { id: 'child:two' }, + }), + ).toEqual({ id: 'child:two' }) + expect(childParserParams).toContainEqual({ id: 'two' }) + }) + + it('returns false for a malformed fuzzy remainder', async () => { + const rootRoute = new BaseRootRoute({}) + const postsRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + }) + const malformedRoute = new BaseRoute({ + getParentRoute: () => postsRoute, + path: '/%ZZ', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + postsRoute.addChildren([malformedRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/posts/%ZZ'] }), + }) + + await router.load() + + expect(router.matchRoute({ to: '/posts' }, { fuzzy: true })).toBe(false) + }) + + it('fuzzy matches a layout route that has an index child', async () => { + const rootRoute = new BaseRootRoute({}) + const dashboardRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/dashboard', + }) + const dashboardIndexRoute = new BaseRoute({ + getParentRoute: () => dashboardRoute, + path: '/', + }) + const detailsRoute = new BaseRoute({ + getParentRoute: () => dashboardRoute, + path: '/details', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + dashboardRoute.addChildren([dashboardIndexRoute, detailsRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/dashboard/details'] }), + }) + + await router.load() + + expect(router.matchRoute({ to: '/dashboard' }, { fuzzy: true })).toEqual({ + '**': 'details', + }) + }) + + it('exactly matches an index route that shares its layout path', async () => { + const rootRoute = new BaseRootRoute({}) + const dashboardRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/dashboard', + }) + const dashboardIndexRoute = new BaseRoute({ + getParentRoute: () => dashboardRoute, + path: '/', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + dashboardRoute.addChildren([dashboardIndexRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/dashboard'] }), + }) + + await router.load() + + expect(router.matchRoute({ to: '/dashboard' })).toEqual({}) + }) + + it('respects the caseSensitive matching option', async () => { + const rootRoute = new BaseRootRoute({}) + const postsRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/Posts', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([postsRoute]), + history: createMemoryHistory({ initialEntries: ['/posts'] }), + }) + + await router.load() + + expect(router.matchRoute({ to: '/Posts' })).toEqual({}) + expect(router.matchRoute({ to: '/Posts' }, { caseSensitive: true })).toBe( + false, + ) + }) + + it('checks case sensitivity against the selected sibling route', async () => { + const rootRoute = new BaseRootRoute({}) + const upperRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/Foo/a', + }) + const lowerRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo/b', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([upperRoute, lowerRoute]), + history: createMemoryHistory({ initialEntries: ['/FOO/b'] }), + }) + + await router.load() + + expect(router.matchRoute({ to: '/foo/b' })).toEqual({}) + expect(router.matchRoute({ to: '/foo/b' }, { caseSensitive: true })).toBe( + false, + ) + }) + + it('keeps fuzzy matching behavior with string params', async () => { + const router = createInvoiceRouter('/invoices/123/details') + + await router.load() + + expect( + router.matchRoute( + { + to: '/invoices/$invoiceId', + params: { invoiceId: 123 }, + }, + { fuzzy: true }, + ), + ).toEqual({ invoiceId: 123, '**': 'details' }) + }) + + it('keeps search matching behavior', async () => { + const router = createInvoiceRouter('/invoices/123?tab=details') + + await router.load() + + expect( + router.matchRoute({ + to: '/invoices/$invoiceId', + params: { invoiceId: 123 }, + search: { tab: 'details' }, + }), + ).toEqual({ invoiceId: 123 }) + expect( + router.matchRoute({ + to: '/invoices/$invoiceId', + params: { invoiceId: 123 }, + search: { tab: 'other' }, + }), + ).toBe(false) + }) + + it('matches typed params with a router basepath', async () => { + const rootRoute = new BaseRootRoute({}) + const invoiceRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/invoices/$invoiceId', + params: { + parse: ({ invoiceId }: { invoiceId: string }) => ({ + invoiceId: Number(invoiceId), + }), + stringify: ({ invoiceId }: { invoiceId: number }) => ({ + invoiceId: String(invoiceId), + }), + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([invoiceRoute]), + basepath: '/app', + history: createMemoryHistory({ + initialEntries: ['/app/invoices/123'], + }), + }) + + await router.load() + + expect( + router.matchRoute({ + to: '/invoices/$invoiceId', + params: { invoiceId: 123 }, + }), + ).toEqual({ invoiceId: 123 }) + }) + + it.each(['never', 'always', 'preserve'] as const)( + 'matches a trailing slash with the %s policy', + async (trailingSlash) => { + const rootRoute = new BaseRootRoute({}) + const postsRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([postsRoute]), + trailingSlash, + history: createMemoryHistory({ initialEntries: ['/posts/'] }), + }) + + await router.load() + + expect(router.matchRoute({ to: '/posts' })).toEqual({}) + }, + ) +}) 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 d93581355e..ebb733263e 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -1299,7 +1299,7 @@ describe('findRouteMatch', () => { options: { params: { parse: (params: Record) => { - parsedParams = params + parsedParams = { ...params } return { length: params._splat!.length } }, }, 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 e3a09c2cf2..dcc203c697 100644 --- a/packages/router-core/tests/optional-path-params-clean.test.ts +++ b/packages/router-core/tests/optional-path-params-clean.test.ts @@ -3,7 +3,7 @@ import { interpolatePath } from '../src/path' import { SEGMENT_TYPE_OPTIONAL_PARAM, SEGMENT_TYPE_PATHNAME, - findSingleMatch, + findRouteMatch, parseSegment, processRouteTree, } from '../src/new-process-route-tree' @@ -138,22 +138,30 @@ describe('Optional Path Parameters - Clean Comprehensive Tests', () => { }) describe('matchPathname', () => { - const { processedTree } = processRouteTree({ - id: '__root__', - isRoot: true, - fullPath: '/', - path: '/', - }) const matchPathname = ( from: string, options: { to: string; caseSensitive?: boolean; fuzzy?: boolean }, ) => { - const match = findSingleMatch( - options.to, + const { processedTree } = processRouteTree( + { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: options.to, + fullPath: options.to, + path: options.to.slice(1), + }, + ], + }, options.caseSensitive ?? false, - options.fuzzy ?? false, + ) + const match = findRouteMatch( from, processedTree, + options.fuzzy ?? false, ) const result = match ? match.rawParams : undefined if (options.to && !result) return diff --git a/packages/router-core/tests/optional-path-params.test.ts b/packages/router-core/tests/optional-path-params.test.ts index b58c0b14af..7f610fdb6b 100644 --- a/packages/router-core/tests/optional-path-params.test.ts +++ b/packages/router-core/tests/optional-path-params.test.ts @@ -5,7 +5,7 @@ import { SEGMENT_TYPE_PARAM, SEGMENT_TYPE_PATHNAME, SEGMENT_TYPE_WILDCARD, - findSingleMatch, + findRouteMatch, parseSegment, processRouteTree, } from '../src/new-process-route-tree' @@ -349,23 +349,27 @@ describe('Optional Path Parameters', () => { }) }) - const { processedTree } = processRouteTree({ - id: '__root__', - isRoot: true, - fullPath: '/', - path: '/', - }) const matchPathname = ( from: string, options: { to: string; caseSensitive?: boolean; fuzzy?: boolean }, ) => { - const match = findSingleMatch( - options.to, + const { processedTree } = processRouteTree( + { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: options.to, + fullPath: options.to, + path: options.to.slice(1), + }, + ], + }, options.caseSensitive ?? false, - options.fuzzy ?? false, - from, - processedTree, ) + const match = findRouteMatch(from, processedTree, options.fuzzy ?? false) 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 4e49412209..54c80fcf38 100644 --- a/packages/router-core/tests/path.test.ts +++ b/packages/router-core/tests/path.test.ts @@ -11,7 +11,7 @@ import { SEGMENT_TYPE_PARAM, SEGMENT_TYPE_PATHNAME, SEGMENT_TYPE_WILDCARD, - findSingleMatch, + findRouteMatch, parseSegment, processRouteTree, } from '../src/new-process-route-tree' @@ -663,23 +663,27 @@ describe.each([{ server: true }, { server: false }])( ) describe('matchPathname', () => { - const { processedTree } = processRouteTree({ - id: '__root__', - isRoot: true, - fullPath: '/', - path: '/', - }) const matchPathname = ( from: string, options: { to: string; caseSensitive?: boolean; fuzzy?: boolean }, ) => { - const match = findSingleMatch( - options.to, + const { processedTree } = processRouteTree( + { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: options.to, + fullPath: options.to, + path: options.to.slice(1), + }, + ], + }, options.caseSensitive ?? false, - options.fuzzy ?? false, - from, - processedTree, ) + const match = findRouteMatch(from, processedTree, options.fuzzy ?? false) const result = match ? match.rawParams : undefined if (options.to && !result) return return result ?? {} diff --git a/packages/solid-router/tests/Matches.test.tsx b/packages/solid-router/tests/Matches.test.tsx index 2685500fd0..ff251fc418 100644 --- a/packages/solid-router/tests/Matches.test.tsx +++ b/packages/solid-router/tests/Matches.test.tsx @@ -154,6 +154,41 @@ test('should show pendingComponent of root route', async () => { expect(await rendered.findByTestId('root-content')).toBeInTheDocument() }) +test('useMatchRoute matches typed params from routes with custom parse and stringify functions', async () => { + const rootRoute = createRootRoute() + const invoiceRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/invoices/$invoiceId', + params: { + parse: ({ invoiceId }: { invoiceId: string }) => ({ + invoiceId: Number(invoiceId), + }), + stringify: ({ invoiceId }: { invoiceId: number }) => ({ + invoiceId: String(invoiceId), + }), + }, + component: () => { + const matchRoute = useMatchRoute() + const match = matchRoute({ + to: '/invoices/$invoiceId', + params: { invoiceId: 123 }, + }) + + return
{JSON.stringify(match())}
+ }, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([invoiceRoute]), + history: createMemoryHistory({ initialEntries: ['/invoices/123'] }), + }) + + render(() => ) + + expect(await screen.findByTestId('match')).toHaveTextContent( + '{"invoiceId":123}', + ) +}) + test('MatchRoute updates for navigation and reactive params changes', async () => { function Layout() { const [postId, setPostId] = createSignal('123') diff --git a/packages/vue-router/tests/Matches.test.tsx b/packages/vue-router/tests/Matches.test.tsx index 4e9e9afa51..a45a4f9146 100644 --- a/packages/vue-router/tests/Matches.test.tsx +++ b/packages/vue-router/tests/Matches.test.tsx @@ -158,6 +158,41 @@ test('should show pendingComponent of root route', async () => { expect(await rendered.findByTestId('root-content')).toBeInTheDocument() }) +test('useMatchRoute matches typed params from routes with custom parse and stringify functions', async () => { + const rootRoute = createRootRoute() + const invoiceRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/invoices/$invoiceId', + params: { + parse: ({ invoiceId }: { invoiceId: string }) => ({ + invoiceId: Number(invoiceId), + }), + stringify: ({ invoiceId }: { invoiceId: number }) => ({ + invoiceId: String(invoiceId), + }), + }, + component: () => { + const matchRoute = useMatchRoute() + const match = matchRoute({ + to: '/invoices/$invoiceId', + params: { invoiceId: 123 }, + }) + + return
{JSON.stringify(match.value)}
+ }, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([invoiceRoute]), + history: createMemoryHistory({ initialEntries: ['/invoices/123'] }), + }) + + render() + + expect(await screen.findByTestId('match')).toHaveTextContent( + '{"invoiceId":123}', + ) +}) + describe('matching on different param types', () => { const testCases = [ {