Skip to content

Commit 8578a52

Browse files
authored
feat(router-core): validate params while matching (#5936)
1 parent ce88608 commit 8578a52

10 files changed

+1445
-111
lines changed

packages/router-core/src/new-process-route-tree.ts

Lines changed: 250 additions & 49 deletions
Large diffs are not rendered by default.

packages/router-core/src/route.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1188,9 +1188,46 @@ export interface UpdatableRouteOptions<
11881188
in out TBeforeLoadFn,
11891189
> extends UpdatableStaticRouteOption,
11901190
UpdatableRouteOptionsExtensions {
1191-
// If true, this route will be matched as case-sensitive
1191+
/**
1192+
* Options to control route matching behavior with runtime code.
1193+
*
1194+
* @experimental 🚧 this feature is subject to change
1195+
*
1196+
* @link https://tanstack.com/router/latest/docs/framework/react/api/router/RouteOptionsType
1197+
*/
1198+
skipRouteOnParseError?: {
1199+
/**
1200+
* If `true`, skip this route during matching if `params.parse` fails.
1201+
*
1202+
* Without this option, a `/$param` route could match *any* value for `param`,
1203+
* and only later during the route lifecycle would `params.parse` run and potentially
1204+
* show the `errorComponent` if validation failed.
1205+
*
1206+
* With this option enabled, the route will only match if `params.parse` succeeds.
1207+
* If it fails, the router will continue trying to match other routes, potentially
1208+
* finding a different route that works, or ultimately showing the `notFoundComponent`.
1209+
*
1210+
* @default false
1211+
*/
1212+
params?: boolean
1213+
/**
1214+
* In cases where multiple routes would need to run `params.parse` during matching
1215+
* to determine which route to pick, this priority number can be used as a tie-breaker
1216+
* for which route to try first. Higher number = higher priority.
1217+
*
1218+
* @default 0
1219+
*/
1220+
priority?: number
1221+
}
1222+
/**
1223+
* If true, this route will be matched as case-sensitive
1224+
*
1225+
* @default false
1226+
*/
11921227
caseSensitive?: boolean
1193-
// If true, this route will be forcefully wrapped in a suspense boundary
1228+
/**
1229+
* If true, this route will be forcefully wrapped in a suspense boundary
1230+
*/
11941231
wrapInSuspense?: boolean
11951232
// The content to be rendered when the route is matched. If no component is provided, defaults to `<Outlet />`
11961233

packages/router-core/src/router.ts

Lines changed: 39 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -698,8 +698,12 @@ export type ParseLocationFn<TRouteTree extends AnyRoute> = (
698698

699699
export type GetMatchRoutesFn = (pathname: string) => {
700700
matchedRoutes: ReadonlyArray<AnyRoute>
701+
/** exhaustive params, still in their string form */
701702
routeParams: Record<string, string>
703+
/** partial params, parsed from routeParams during matching */
704+
parsedParams: Record<string, unknown> | undefined
702705
foundRoute: AnyRoute | undefined
706+
parseError?: unknown
703707
}
704708

705709
export type EmitFn = (routerEvent: RouterEvent) => void
@@ -1260,7 +1264,7 @@ export class RouterCore<
12601264
opts?: MatchRoutesOpts,
12611265
): Array<AnyRouteMatch> {
12621266
const matchedRoutesResult = this.getMatchedRoutes(next.pathname)
1263-
const { foundRoute, routeParams } = matchedRoutesResult
1267+
const { foundRoute, routeParams, parsedParams } = matchedRoutesResult
12641268
let { matchedRoutes } = matchedRoutesResult
12651269
let isGlobalNotFound = false
12661270

@@ -1401,26 +1405,34 @@ export class RouterCore<
14011405
let paramsError: unknown = undefined
14021406

14031407
if (!existingMatch) {
1404-
const strictParseParams =
1405-
route.options.params?.parse ?? route.options.parseParams
1406-
1407-
if (strictParseParams) {
1408-
try {
1409-
Object.assign(
1410-
strictParams,
1411-
strictParseParams(strictParams as Record<string, string>),
1412-
)
1413-
} catch (err: any) {
1414-
if (isNotFound(err) || isRedirect(err)) {
1415-
paramsError = err
1416-
} else {
1417-
paramsError = new PathParamError(err.message, {
1418-
cause: err,
1419-
})
1408+
if (route.options.skipRouteOnParseError) {
1409+
for (const key in usedParams) {
1410+
if (key in parsedParams!) {
1411+
strictParams[key] = parsedParams![key]
14201412
}
1413+
}
1414+
} else {
1415+
const strictParseParams =
1416+
route.options.params?.parse ?? route.options.parseParams
14211417

1422-
if (opts?.throwOnError) {
1423-
throw paramsError
1418+
if (strictParseParams) {
1419+
try {
1420+
Object.assign(
1421+
strictParams,
1422+
strictParseParams(strictParams as Record<string, string>),
1423+
)
1424+
} catch (err: any) {
1425+
if (isNotFound(err) || isRedirect(err)) {
1426+
paramsError = err
1427+
} else {
1428+
paramsError = new PathParamError(err.message, {
1429+
cause: err,
1430+
})
1431+
}
1432+
1433+
if (opts?.throwOnError) {
1434+
throw paramsError
1435+
}
14241436
}
14251437
}
14261438
}
@@ -1802,7 +1814,7 @@ export class RouterCore<
18021814
this.processedTree,
18031815
)
18041816
if (match) {
1805-
Object.assign(params, match.params) // Copy params, because they're cached
1817+
Object.assign(params, match.rawParams) // Copy params, because they're cached
18061818
const {
18071819
from: _from,
18081820
params: maskParams,
@@ -2601,18 +2613,18 @@ export class RouterCore<
26012613
}
26022614

26032615
if (location.params) {
2604-
if (!deepEqual(match.params, location.params, { partial: true })) {
2616+
if (!deepEqual(match.rawParams, location.params, { partial: true })) {
26052617
return false
26062618
}
26072619
}
26082620

26092621
if (opts?.includeSearch ?? true) {
26102622
return deepEqual(baseLocation.search, next.search, { partial: true })
2611-
? match.params
2623+
? match.rawParams
26122624
: false
26132625
}
26142626

2615-
return match.params
2627+
return match.rawParams
26162628
}
26172629

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

27212733
let foundRoute: TRouteLike | undefined = undefined
2734+
let parsedParams: Record<string, unknown> | undefined = undefined
27222735
const match = findRouteMatch<TRouteLike>(trimmedPath, processedTree, true)
27232736
if (match) {
27242737
foundRoute = match.route
2725-
Object.assign(routeParams, match.params) // Copy params, because they're cached
2738+
Object.assign(routeParams, match.rawParams) // Copy params, because they're cached
2739+
parsedParams = Object.assign({}, match.parsedParams)
27262740
}
27272741

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

2730-
return { matchedRoutes, routeParams, foundRoute }
2744+
return { matchedRoutes, routeParams, foundRoute, parsedParams }
27312745
}
27322746

27332747
function applySearchMiddleware({

packages/router-core/tests/curly-params-smoke.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,6 @@ describe('curly params smoke tests', () => {
136136
}
137137
const processed = processRouteTree(tree)
138138
const res = findRouteMatch(nav, processed.processedTree)
139-
expect(res?.params).toEqual(params)
139+
expect(res?.rawParams).toEqual(params)
140140
})
141141
})

packages/router-core/tests/match-by-path.test.ts

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ describe('default path matching', () => {
2626
['/b', '/a', undefined],
2727
])('static %s %s => %s', (path, pattern, result) => {
2828
const res = findSingleMatch(pattern, true, false, path, processedTree)
29-
expect(res?.params).toEqual(result)
29+
expect(res?.rawParams).toEqual(result)
3030
})
3131

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

4343
it('params support more than alphanumeric characters', () => {
@@ -49,7 +49,7 @@ describe('default path matching', () => {
4949
'/a/@&é"\'(§è!çà)-_°^¨$*€£`ù=+:;.,?~<>|î©#0123456789\\😀}{',
5050
processedTree,
5151
)
52-
expect(anyValueResult?.params).toEqual({
52+
expect(anyValueResult?.rawParams).toEqual({
5353
id: '@&é"\'(§è!çà)-_°^¨$*€£`ù=+:;.,?~<>|î©#0123456789\\😀}{',
5454
})
5555
// in the key: basically everything except / and % and $
@@ -60,7 +60,7 @@ describe('default path matching', () => {
6060
'/a/1',
6161
processedTree,
6262
)
63-
expect(anyKeyResult?.params).toEqual({
63+
expect(anyKeyResult?.rawParams).toEqual({
6464
'@&é"\'(§è!çà)-_°^¨*€£`ù=+:;.,?~<>|î©#0123456789\\😀}{': '1',
6565
})
6666
})
@@ -77,7 +77,7 @@ describe('default path matching', () => {
7777
['/a/1/b/2', '/a/{-$id}/b/{-$id}', { id: '2' }],
7878
])('optional %s => %s', (path, pattern, result) => {
7979
const res = findSingleMatch(pattern, true, false, path, processedTree)
80-
expect(res?.params).toEqual(result)
80+
expect(res?.rawParams).toEqual(result)
8181
})
8282

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

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

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

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

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

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

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

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

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

0 commit comments

Comments
 (0)