Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
d9e403b
feat(router-core): validate params while matching
Sheraff Nov 21, 2025
77aade6
Merge branch 'main' into feat-router-core-validate-params-while-matching
Sheraff Nov 26, 2025
2fd3009
more wip
Sheraff Nov 30, 2025
102ef3c
Merge branch 'main' into feat-router-core-validate-params-while-matching
Sheraff Nov 30, 2025
c9f0f62
Merge branch 'main' into feat-router-core-validate-params-while-matching
Sheraff Dec 20, 2025
b9c416a
introduce index node and pathless node
Sheraff Dec 21, 2025
9b08317
Merge branch 'main' into feat-router-core-validate-params-while-matching
Sheraff Dec 21, 2025
5e0ea27
merge typo
Sheraff Dec 21, 2025
fca20ce
more post-merge fixes
Sheraff Dec 21, 2025
c577ce9
don't handle regular parsing, only skip parsing
Sheraff Dec 21, 2025
0353059
fix sorting
Sheraff Dec 21, 2025
c67704e
format
Sheraff Dec 21, 2025
c221f3e
remove error from types, its currently unused
Sheraff Dec 22, 2025
1a3df20
format
Sheraff Dec 22, 2025
152a6bf
collect rawParams and parsedParams instead of just 'params'
Sheraff Dec 22, 2025
725b764
accumulating parsed params shouldn't mutate the branch, shallow copy …
Sheraff Dec 22, 2025
7f821bf
fix renaming
Sheraff Dec 22, 2025
c96fdc7
new skip API options
Sheraff Dec 23, 2025
8b78730
update snapshot
Sheraff Dec 23, 2025
6b9d31b
pathless nodes can match beyond path length
Sheraff Dec 23, 2025
c35ac00
ai generated tests, seem good, but should review more deeply
Sheraff Dec 23, 2025
cac7a3b
improve tests
Sheraff Dec 23, 2025
eed4b0b
docs
Sheraff Dec 24, 2025
40b9428
fix jsdoc
Sheraff Dec 24, 2025
086f196
Merge branch 'main' into feat-router-core-validate-params-while-matching
Sheraff Jan 7, 2026
50227f6
no docs for now, will do in a separate PR
Sheraff Jan 7, 2026
1a51a43
fix parsing priority sorting
Sheraff Jan 8, 2026
d826289
remove working tests
Sheraff Jan 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
299 changes: 250 additions & 49 deletions packages/router-core/src/new-process-route-tree.ts

Large diffs are not rendered by default.

41 changes: 39 additions & 2 deletions packages/router-core/src/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1188,9 +1188,46 @@ export interface UpdatableRouteOptions<
in out TBeforeLoadFn,
> extends UpdatableStaticRouteOption,
UpdatableRouteOptionsExtensions {
// If true, this route will be matched as case-sensitive
/**
* Options to control route matching behavior with runtime code.
*
* @experimental 🚧 this feature is subject to change
*
* @link https://tanstack.com/router/latest/docs/framework/react/api/router/RouteOptionsType
*/
skipRouteOnParseError?: {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a JSDoc annotation for @experimental mentioned this can change. Maybe even a link to the docs RouteOptionsType reference page.

If you search the codebase we should have something similar to copy off of marked with @link.

/**
* If `true`, skip this route during matching if `params.parse` fails.
*
* Without this option, a `/$param` route could match *any* value for `param`,
* and only later during the route lifecycle would `params.parse` run and potentially
* show the `errorComponent` if validation failed.
*
* With this option enabled, the route will only match if `params.parse` succeeds.
* If it fails, the router will continue trying to match other routes, potentially
* finding a different route that works, or ultimately showing the `notFoundComponent`.
*
* @default false
*/
params?: boolean
/**
* In cases where multiple routes would need to run `params.parse` during matching
* to determine which route to pick, this priority number can be used as a tie-breaker
* for which route to try first. Higher number = higher priority.
*
* @default 0
*/
priority?: number
}
/**
* If true, this route will be matched as case-sensitive
*
* @default false
*/
caseSensitive?: boolean
// If true, this route will be forcefully wrapped in a suspense boundary
/**
* If true, this route will be forcefully wrapped in a suspense boundary
*/
wrapInSuspense?: boolean
// The content to be rendered when the route is matched. If no component is provided, defaults to `<Outlet />`

Expand Down
64 changes: 39 additions & 25 deletions packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -698,8 +698,12 @@ export type ParseLocationFn<TRouteTree extends AnyRoute> = (

export type GetMatchRoutesFn = (pathname: string) => {
matchedRoutes: ReadonlyArray<AnyRoute>
/** exhaustive params, still in their string form */
routeParams: Record<string, string>
/** partial params, parsed from routeParams during matching */
parsedParams: Record<string, unknown> | undefined
foundRoute: AnyRoute | undefined
parseError?: unknown
}

export type EmitFn = (routerEvent: RouterEvent) => void
Expand Down Expand Up @@ -1260,7 +1264,7 @@ export class RouterCore<
opts?: MatchRoutesOpts,
): Array<AnyRouteMatch> {
const matchedRoutesResult = this.getMatchedRoutes(next.pathname)
const { foundRoute, routeParams } = matchedRoutesResult
const { foundRoute, routeParams, parsedParams } = matchedRoutesResult
let { matchedRoutes } = matchedRoutesResult
let isGlobalNotFound = false

Expand Down Expand Up @@ -1401,26 +1405,34 @@ export class RouterCore<
let paramsError: unknown = undefined

if (!existingMatch) {
const strictParseParams =
route.options.params?.parse ?? route.options.parseParams

if (strictParseParams) {
try {
Object.assign(
strictParams,
strictParseParams(strictParams as Record<string, string>),
)
} catch (err: any) {
if (isNotFound(err) || isRedirect(err)) {
paramsError = err
} else {
paramsError = new PathParamError(err.message, {
cause: err,
})
if (route.options.skipRouteOnParseError) {
for (const key in usedParams) {
if (key in parsedParams!) {
strictParams[key] = parsedParams![key]
}
}
} else {
const strictParseParams =
route.options.params?.parse ?? route.options.parseParams

if (opts?.throwOnError) {
throw paramsError
if (strictParseParams) {
try {
Object.assign(
strictParams,
strictParseParams(strictParams as Record<string, string>),
)
} catch (err: any) {
if (isNotFound(err) || isRedirect(err)) {
paramsError = err
} else {
paramsError = new PathParamError(err.message, {
cause: err,
})
}

if (opts?.throwOnError) {
throw paramsError
}
}
}
}
Expand Down Expand Up @@ -1802,7 +1814,7 @@ export class RouterCore<
this.processedTree,
)
if (match) {
Object.assign(params, match.params) // Copy params, because they're cached
Object.assign(params, match.rawParams) // Copy params, because they're cached
const {
from: _from,
params: maskParams,
Expand Down Expand Up @@ -2601,18 +2613,18 @@ export class RouterCore<
}

if (location.params) {
if (!deepEqual(match.params, location.params, { partial: true })) {
if (!deepEqual(match.rawParams, location.params, { partial: true })) {
return false
}
}

if (opts?.includeSearch ?? true) {
return deepEqual(baseLocation.search, next.search, { partial: true })
? match.params
? match.rawParams
: false
}

return match.params
return match.rawParams
}

ssr?: {
Expand Down Expand Up @@ -2719,15 +2731,17 @@ export function getMatchedRoutes<TRouteLike extends RouteLike>({
const trimmedPath = trimPathRight(pathname)

let foundRoute: TRouteLike | undefined = undefined
let parsedParams: Record<string, unknown> | undefined = undefined
const match = findRouteMatch<TRouteLike>(trimmedPath, processedTree, true)
if (match) {
foundRoute = match.route
Object.assign(routeParams, match.params) // Copy params, because they're cached
Object.assign(routeParams, match.rawParams) // Copy params, because they're cached
parsedParams = Object.assign({}, match.parsedParams)
}

const matchedRoutes = match?.branch || [routesById[rootRouteId]!]

return { matchedRoutes, routeParams, foundRoute }
return { matchedRoutes, routeParams, foundRoute, parsedParams }
}

function applySearchMiddleware({
Expand Down
2 changes: 1 addition & 1 deletion packages/router-core/tests/curly-params-smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,6 @@ describe('curly params smoke tests', () => {
}
const processed = processRouteTree(tree)
const res = findRouteMatch(nav, processed.processedTree)
expect(res?.params).toEqual(params)
expect(res?.rawParams).toEqual(params)
})
})
28 changes: 14 additions & 14 deletions packages/router-core/tests/match-by-path.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ describe('default path matching', () => {
['/b', '/a', undefined],
])('static %s %s => %s', (path, pattern, result) => {
const res = findSingleMatch(pattern, true, false, path, processedTree)
expect(res?.params).toEqual(result)
expect(res?.rawParams).toEqual(result)
})

it.each([
Expand All @@ -37,7 +37,7 @@ describe('default path matching', () => {
['/a/1/b/2', '/a/$id/b/$id', { id: '2' }],
])('params %s => %s', (path, pattern, result) => {
const res = findSingleMatch(pattern, true, false, path, processedTree)
expect(res?.params).toEqual(result)
expect(res?.rawParams).toEqual(result)
})

it('params support more than alphanumeric characters', () => {
Expand All @@ -49,7 +49,7 @@ describe('default path matching', () => {
'/a/@&é"\'(§è!çà)-_°^¨$*€£`ù=+:;.,?~<>|î©#0123456789\\😀}{',
processedTree,
)
expect(anyValueResult?.params).toEqual({
expect(anyValueResult?.rawParams).toEqual({
id: '@&é"\'(§è!çà)-_°^¨$*€£`ù=+:;.,?~<>|î©#0123456789\\😀}{',
})
// in the key: basically everything except / and % and $
Expand All @@ -60,7 +60,7 @@ describe('default path matching', () => {
'/a/1',
processedTree,
)
expect(anyKeyResult?.params).toEqual({
expect(anyKeyResult?.rawParams).toEqual({
'@&é"\'(§è!çà)-_°^¨*€£`ù=+:;.,?~<>|î©#0123456789\\😀}{': '1',
})
})
Expand All @@ -77,7 +77,7 @@ describe('default path matching', () => {
['/a/1/b/2', '/a/{-$id}/b/{-$id}', { id: '2' }],
])('optional %s => %s', (path, pattern, result) => {
const res = findSingleMatch(pattern, true, false, path, processedTree)
expect(res?.params).toEqual(result)
expect(res?.rawParams).toEqual(result)
})

it.each([
Expand All @@ -87,7 +87,7 @@ describe('default path matching', () => {
['/a/b/c', '/a/$/foo', { _splat: 'b/c', '*': 'b/c' }],
])('wildcard %s => %s', (path, pattern, result) => {
const res = findSingleMatch(pattern, true, false, path, processedTree)
expect(res?.params).toEqual(result)
expect(res?.rawParams).toEqual(result)
})
})

Expand All @@ -106,7 +106,7 @@ describe('case insensitive path matching', () => {
['/', '/b', '/A', undefined],
])('static %s %s => %s', (base, path, pattern, result) => {
const res = findSingleMatch(pattern, false, false, path, processedTree)
expect(res?.params).toEqual(result)
expect(res?.rawParams).toEqual(result)
})

it.each([
Expand All @@ -116,7 +116,7 @@ describe('case insensitive path matching', () => {
['/a/1/b/2', '/A/$id/B/$id', { id: '2' }],
])('params %s => %s', (path, pattern, result) => {
const res = findSingleMatch(pattern, false, false, path, processedTree)
expect(res?.params).toEqual(result)
expect(res?.rawParams).toEqual(result)
})

it.each([
Expand All @@ -133,7 +133,7 @@ describe('case insensitive path matching', () => {
['/a/1/b/2_', '/A/{-$id}/B/{-$id}', { id: '2_' }],
])('optional %s => %s', (path, pattern, result) => {
const res = findSingleMatch(pattern, false, false, path, processedTree)
expect(res?.params).toEqual(result)
expect(res?.rawParams).toEqual(result)
})

it.each([
Expand All @@ -143,7 +143,7 @@ describe('case insensitive path matching', () => {
['/a/b/c', '/A/$/foo', { _splat: 'b/c', '*': 'b/c' }],
])('wildcard %s => %s', (path, pattern, result) => {
const res = findSingleMatch(pattern, false, false, path, processedTree)
expect(res?.params).toEqual(result)
expect(res?.rawParams).toEqual(result)
})
})

Expand All @@ -167,7 +167,7 @@ describe('fuzzy path matching', () => {
['/', '/a', '/b', undefined],
])('static %s %s => %s', (base, path, pattern, result) => {
const res = findSingleMatch(pattern, true, true, path, processedTree)
expect(res?.params).toEqual(result)
expect(res?.rawParams).toEqual(result)
})

it.each([
Expand All @@ -178,7 +178,7 @@ describe('fuzzy path matching', () => {
['/a/1/b/2/c', '/a/$id/b/$other', { id: '1', other: '2', '**': 'c' }],
])('params %s => %s', (path, pattern, result) => {
const res = findSingleMatch(pattern, true, true, path, processedTree)
expect(res?.params).toEqual(result)
expect(res?.rawParams).toEqual(result)
})

it.each([
Expand All @@ -193,7 +193,7 @@ describe('fuzzy path matching', () => {
['/a/1/b/2/c', '/a/{-$id}/b/{-$other}', { id: '1', other: '2', '**': 'c' }],
])('optional %s => %s', (path, pattern, result) => {
const res = findSingleMatch(pattern, true, true, path, processedTree)
expect(res?.params).toEqual(result)
expect(res?.rawParams).toEqual(result)
})

it.each([
Expand All @@ -203,6 +203,6 @@ describe('fuzzy path matching', () => {
['/a/b/c/d', '/a/$/foo', { _splat: 'b/c/d', '*': 'b/c/d' }],
])('wildcard %s => %s', (path, pattern, result) => {
const res = findSingleMatch(pattern, true, true, path, processedTree)
expect(res?.params).toEqual(result)
expect(res?.rawParams).toEqual(result)
})
})
Loading
Loading