Skip to content

Commit 5de0f91

Browse files
authored
refactor(router-core): improve interpolatePath performance by pre-compiling a single regex decoder (#6471)
1 parent 12a7288 commit 5de0f91

File tree

4 files changed

+49
-33
lines changed

4 files changed

+49
-33
lines changed

packages/router-core/src/path.ts

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -197,11 +197,33 @@ export function resolvePath({
197197
return result
198198
}
199199

200+
/**
201+
* Create a pre-compiled decode config from allowed characters.
202+
* This should be called once at router initialization.
203+
*/
204+
export function compileDecodeCharMap(
205+
pathParamsAllowedCharacters: ReadonlyArray<string>,
206+
) {
207+
const charMap = new Map(
208+
pathParamsAllowedCharacters.map((char) => [encodeURIComponent(char), char]),
209+
)
210+
// Escape special regex characters and join with |
211+
const pattern = Array.from(charMap.keys())
212+
.map((key) => key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
213+
.join('|')
214+
const regex = new RegExp(pattern, 'g')
215+
return (encoded: string) =>
216+
encoded.replace(regex, (match) => charMap.get(match) ?? match)
217+
}
218+
200219
interface InterpolatePathOptions {
201220
path?: string
202221
params: Record<string, unknown>
203-
// Map of encoded chars to decoded chars (e.g. '%40' -> '@') that should remain decoded in path params
204-
decodeCharMap?: Map<string, string>
222+
/**
223+
* A function that decodes a path parameter value.
224+
* Obtained from `compileDecodeCharMap(pathParamsAllowedCharacters)`.
225+
*/
226+
decoder?: (encoded: string) => string
205227
}
206228

207229
type InterPolatePathResult = {
@@ -213,7 +235,7 @@ type InterPolatePathResult = {
213235
function encodeParam(
214236
key: string,
215237
params: InterpolatePathOptions['params'],
216-
decodeCharMap: InterpolatePathOptions['decodeCharMap'],
238+
decoder: InterpolatePathOptions['decoder'],
217239
): any {
218240
const value = params[key]
219241
if (typeof value !== 'string') return value
@@ -222,7 +244,7 @@ function encodeParam(
222244
// the splat/catch-all routes shouldn't have the '/' encoded out
223245
return encodeURI(value)
224246
} else {
225-
return encodePathParam(value, decodeCharMap)
247+
return encodePathParam(value, decoder)
226248
}
227249
}
228250

@@ -235,7 +257,7 @@ function encodeParam(
235257
export function interpolatePath({
236258
path,
237259
params,
238-
decodeCharMap,
260+
decoder,
239261
}: InterpolatePathOptions): InterPolatePathResult {
240262
// Tracking if any params are missing in the `params` object
241263
// when interpolating the path
@@ -286,7 +308,7 @@ export function interpolatePath({
286308
continue
287309
}
288310

289-
const value = encodeParam('_splat', params, decodeCharMap)
311+
const value = encodeParam('_splat', params, decoder)
290312
joined += '/' + prefix + value + suffix
291313
continue
292314
}
@@ -300,7 +322,7 @@ export function interpolatePath({
300322

301323
const prefix = path.substring(start, segment[1])
302324
const suffix = path.substring(segment[4], end)
303-
const value = encodeParam(key, params, decodeCharMap) ?? 'undefined'
325+
const value = encodeParam(key, params, decoder) ?? 'undefined'
304326
joined += '/' + prefix + value + suffix
305327
continue
306328
}
@@ -316,7 +338,7 @@ export function interpolatePath({
316338

317339
const prefix = path.substring(start, segment[1])
318340
const suffix = path.substring(segment[4], end)
319-
const value = encodeParam(key, params, decodeCharMap) ?? ''
341+
const value = encodeParam(key, params, decoder) ?? ''
320342
joined += '/' + prefix + value + suffix
321343
continue
322344
}
@@ -329,12 +351,10 @@ export function interpolatePath({
329351
return { usedParams, interpolatedPath, isMissingParams }
330352
}
331353

332-
function encodePathParam(value: string, decodeCharMap?: Map<string, string>) {
333-
let encoded = encodeURIComponent(value)
334-
if (decodeCharMap) {
335-
for (const [encodedChar, char] of decodeCharMap) {
336-
encoded = encoded.replaceAll(encodedChar, char)
337-
}
338-
}
339-
return encoded
354+
function encodePathParam(
355+
value: string,
356+
decoder?: InterpolatePathOptions['decoder'],
357+
) {
358+
const encoded = encodeURIComponent(value)
359+
return decoder?.(encoded) ?? encoded
340360
}

packages/router-core/src/router.ts

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
} from './new-process-route-tree'
2020
import {
2121
cleanPath,
22+
compileDecodeCharMap,
2223
interpolatePath,
2324
resolvePath,
2425
trimPath,
@@ -923,7 +924,7 @@ export class RouterCore<
923924
routesByPath!: RoutesByPath<TRouteTree>
924925
processedTree!: ProcessedTree<TRouteTree, any, any>
925926
isServer!: boolean
926-
pathParamsDecodeCharMap?: Map<string, string>
927+
pathParamsDecoder?: (encoded: string) => string
927928

928929
/**
929930
* @deprecated Use the `createRouter` function instead
@@ -992,14 +993,10 @@ export class RouterCore<
992993

993994
this.isServer = this.options.isServer ?? typeof document === 'undefined'
994995

995-
this.pathParamsDecodeCharMap = this.options.pathParamsAllowedCharacters
996-
? new Map(
997-
this.options.pathParamsAllowedCharacters.map((char) => [
998-
encodeURIComponent(char),
999-
char,
1000-
]),
1001-
)
1002-
: undefined
996+
if (this.options.pathParamsAllowedCharacters)
997+
this.pathParamsDecoder = compileDecodeCharMap(
998+
this.options.pathParamsAllowedCharacters,
999+
)
10031000

10041001
if (
10051002
!this.history ||
@@ -1365,7 +1362,7 @@ export class RouterCore<
13651362
const { interpolatedPath, usedParams } = interpolatePath({
13661363
path: route.fullPath,
13671364
params: routeParams,
1368-
decodeCharMap: this.pathParamsDecodeCharMap,
1365+
decoder: this.pathParamsDecoder,
13691366
})
13701367

13711368
// Waste not, want not. If we already have a match for this route,
@@ -1721,7 +1718,7 @@ export class RouterCore<
17211718
interpolatePath({
17221719
path: nextTo,
17231720
params: nextParams,
1724-
decodeCharMap: this.pathParamsDecodeCharMap,
1721+
decoder: this.pathParamsDecoder,
17251722
}).interpolatedPath,
17261723
)
17271724

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

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, it } from 'vitest'
22
import {
3+
compileDecodeCharMap,
34
exactPathTest,
45
interpolatePath,
56
removeTrailingSlash,
@@ -309,9 +310,7 @@ describe('interpolatePath', () => {
309310
path: '/users/$id',
310311
params: { id: '?#@john+smith' },
311312
result: '/users/%3F%23@john+smith',
312-
decodeCharMap: new Map(
313-
['@', '+'].map((char) => [encodeURIComponent(char), char]),
314-
),
313+
decoder: compileDecodeCharMap(['@', '+']),
315314
},
316315
{
317316
name: 'should interpolate the path with the splat param at the end',
@@ -348,12 +347,12 @@ describe('interpolatePath', () => {
348347
params: { _splat: 'sean/cassiere' },
349348
result: '/users/sean/cassiere',
350349
},
351-
])('$name', ({ path, params, decodeCharMap, result }) => {
350+
])('$name', ({ path, params, decoder, result }) => {
352351
expect(
353352
interpolatePath({
354353
path,
355354
params,
356-
decodeCharMap,
355+
decoder,
357356
}).interpolatedPath,
358357
).toBe(result)
359358
})

packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ function RouteComp({
178178
const interpolated = interpolatePath({
179179
path: route.fullPath,
180180
params: allParams,
181-
decodeCharMap: router().pathParamsDecodeCharMap,
181+
decoder: router().pathParamsDecoder,
182182
})
183183

184184
// only if `interpolated` is not missing params, return the path since this

0 commit comments

Comments
 (0)