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 = [
{