@tanstack/router-core 1.154.14 → 1.155.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/router-core",
3
- "version": "1.154.14",
3
+ "version": "1.155.0",
4
4
  "description": "Modern and scalable routing for React applications",
5
5
  "author": "Tanner Linsley",
6
6
  "license": "MIT",
@@ -27,11 +27,17 @@ type ExtendedSegmentKind =
27
27
  | typeof SEGMENT_TYPE_INDEX
28
28
  | typeof SEGMENT_TYPE_PATHLESS
29
29
 
30
- const PARAM_W_CURLY_BRACES_RE =
31
- /^([^{]*)\{\$([a-zA-Z_$][a-zA-Z0-9_$]*)\}([^}]*)$/ // prefix{$paramName}suffix
32
- const OPTIONAL_PARAM_W_CURLY_BRACES_RE =
33
- /^([^{]*)\{-\$([a-zA-Z_$][a-zA-Z0-9_$]*)\}([^}]*)$/ // prefix{-$paramName}suffix
34
- const WILDCARD_W_CURLY_BRACES_RE = /^([^{]*)\{\$\}([^}]*)$/ // prefix{$}suffix
30
+ function getOpenAndCloseBraces(
31
+ part: string,
32
+ ): [openBrace: number, closeBrace: number] | null {
33
+ const openBrace = part.indexOf('{')
34
+ if (openBrace === -1) return null
35
+ const closeBrace = part.indexOf('}', openBrace)
36
+ if (closeBrace === -1) return null
37
+ const afterOpen = openBrace + 1
38
+ if (afterOpen >= part.length) return null
39
+ return [openBrace, closeBrace]
40
+ }
35
41
 
36
42
  type ParsedSegment = Uint16Array & {
37
43
  /** segment type (0 = pathname, 1 = param, 2 = wildcard, 3 = optional param) */
@@ -110,47 +116,61 @@ export function parseSegment(
110
116
  return output as ParsedSegment
111
117
  }
112
118
 
113
- const wildcardBracesMatch = part.match(WILDCARD_W_CURLY_BRACES_RE)
114
- if (wildcardBracesMatch) {
115
- const prefix = wildcardBracesMatch[1]!
116
- const pLength = prefix.length
117
- output[0] = SEGMENT_TYPE_WILDCARD
118
- output[1] = start + pLength
119
- output[2] = start + pLength + 1 // skip '{'
120
- output[3] = start + pLength + 2 // '$'
121
- output[4] = start + pLength + 3 // skip '}'
122
- output[5] = path.length
123
- return output as ParsedSegment
124
- }
125
-
126
- const optionalParamBracesMatch = part.match(OPTIONAL_PARAM_W_CURLY_BRACES_RE)
127
- if (optionalParamBracesMatch) {
128
- const prefix = optionalParamBracesMatch[1]!
129
- const paramName = optionalParamBracesMatch[2]!
130
- const suffix = optionalParamBracesMatch[3]!
131
- const pLength = prefix.length
132
- output[0] = SEGMENT_TYPE_OPTIONAL_PARAM
133
- output[1] = start + pLength
134
- output[2] = start + pLength + 3 // skip '{-$'
135
- output[3] = start + pLength + 3 + paramName.length
136
- output[4] = end - suffix.length
137
- output[5] = end
138
- return output as ParsedSegment
139
- }
140
-
141
- const paramBracesMatch = part.match(PARAM_W_CURLY_BRACES_RE)
142
- if (paramBracesMatch) {
143
- const prefix = paramBracesMatch[1]!
144
- const paramName = paramBracesMatch[2]!
145
- const suffix = paramBracesMatch[3]!
146
- const pLength = prefix.length
147
- output[0] = SEGMENT_TYPE_PARAM
148
- output[1] = start + pLength
149
- output[2] = start + pLength + 2 // skip '{$'
150
- output[3] = start + pLength + 2 + paramName.length
151
- output[4] = end - suffix.length
152
- output[5] = end
153
- return output as ParsedSegment
119
+ const braces = getOpenAndCloseBraces(part)
120
+ if (braces) {
121
+ const [openBrace, closeBrace] = braces
122
+ const firstChar = part.charCodeAt(openBrace + 1)
123
+
124
+ // Check for {-$...} (optional param)
125
+ // prefix{-$paramName}suffix
126
+ // /^([^{]*)\{-\$([a-zA-Z_$][a-zA-Z0-9_$]*)\}([^}]*)$/
127
+ if (firstChar === 45) {
128
+ // '-'
129
+ if (
130
+ openBrace + 2 < part.length &&
131
+ part.charCodeAt(openBrace + 2) === 36 // '$'
132
+ ) {
133
+ const paramStart = openBrace + 3
134
+ const paramEnd = closeBrace
135
+ // Validate param name exists
136
+ if (paramStart < paramEnd) {
137
+ output[0] = SEGMENT_TYPE_OPTIONAL_PARAM
138
+ output[1] = start + openBrace
139
+ output[2] = start + paramStart
140
+ output[3] = start + paramEnd
141
+ output[4] = start + closeBrace + 1
142
+ output[5] = end
143
+ return output as ParsedSegment
144
+ }
145
+ }
146
+ } else if (firstChar === 36) {
147
+ // '$'
148
+ const dollarPos = openBrace + 1
149
+ const afterDollar = openBrace + 2
150
+ // Check for {$} (wildcard)
151
+ if (afterDollar === closeBrace) {
152
+ // For wildcard, value should be '$' (from dollarPos to afterDollar)
153
+ // prefix{$}suffix
154
+ // /^([^{]*)\{\$\}([^}]*)$/
155
+ output[0] = SEGMENT_TYPE_WILDCARD
156
+ output[1] = start + openBrace
157
+ output[2] = start + dollarPos
158
+ output[3] = start + afterDollar
159
+ output[4] = start + closeBrace + 1
160
+ output[5] = path.length
161
+ return output as ParsedSegment
162
+ }
163
+ // Regular param {$paramName} - value is the param name (after $)
164
+ // prefix{$paramName}suffix
165
+ // /^([^{]*)\{\$([a-zA-Z_$][a-zA-Z0-9_$]*)\}([^}]*)$/
166
+ output[0] = SEGMENT_TYPE_PARAM
167
+ output[1] = start + openBrace
168
+ output[2] = start + afterDollar
169
+ output[3] = start + closeBrace
170
+ output[4] = start + closeBrace + 1
171
+ output[5] = end
172
+ return output as ParsedSegment
173
+ }
154
174
  }
155
175
 
156
176
  // fallback to static pathname (should never happen)
package/src/path.ts CHANGED
@@ -197,11 +197,33 @@ export function resolvePath({
197
197
  return result
198
198
  }
199
199
 
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
+
200
219
  interface InterpolatePathOptions {
201
220
  path?: string
202
221
  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
205
227
  }
206
228
 
207
229
  type InterPolatePathResult = {
@@ -213,7 +235,7 @@ type InterPolatePathResult = {
213
235
  function encodeParam(
214
236
  key: string,
215
237
  params: InterpolatePathOptions['params'],
216
- decodeCharMap: InterpolatePathOptions['decodeCharMap'],
238
+ decoder: InterpolatePathOptions['decoder'],
217
239
  ): any {
218
240
  const value = params[key]
219
241
  if (typeof value !== 'string') return value
@@ -222,7 +244,7 @@ function encodeParam(
222
244
  // the splat/catch-all routes shouldn't have the '/' encoded out
223
245
  return encodeURI(value)
224
246
  } else {
225
- return encodePathParam(value, decodeCharMap)
247
+ return encodePathParam(value, decoder)
226
248
  }
227
249
  }
228
250
 
@@ -235,7 +257,7 @@ function encodeParam(
235
257
  export function interpolatePath({
236
258
  path,
237
259
  params,
238
- decodeCharMap,
260
+ decoder,
239
261
  }: InterpolatePathOptions): InterPolatePathResult {
240
262
  // Tracking if any params are missing in the `params` object
241
263
  // when interpolating the path
@@ -286,7 +308,7 @@ export function interpolatePath({
286
308
  continue
287
309
  }
288
310
 
289
- const value = encodeParam('_splat', params, decodeCharMap)
311
+ const value = encodeParam('_splat', params, decoder)
290
312
  joined += '/' + prefix + value + suffix
291
313
  continue
292
314
  }
@@ -300,7 +322,7 @@ export function interpolatePath({
300
322
 
301
323
  const prefix = path.substring(start, segment[1])
302
324
  const suffix = path.substring(segment[4], end)
303
- const value = encodeParam(key, params, decodeCharMap) ?? 'undefined'
325
+ const value = encodeParam(key, params, decoder) ?? 'undefined'
304
326
  joined += '/' + prefix + value + suffix
305
327
  continue
306
328
  }
@@ -316,7 +338,7 @@ export function interpolatePath({
316
338
 
317
339
  const prefix = path.substring(start, segment[1])
318
340
  const suffix = path.substring(segment[4], end)
319
- const value = encodeParam(key, params, decodeCharMap) ?? ''
341
+ const value = encodeParam(key, params, decoder) ?? ''
320
342
  joined += '/' + prefix + value + suffix
321
343
  continue
322
344
  }
@@ -329,12 +351,10 @@ export function interpolatePath({
329
351
  return { usedParams, interpolatedPath, isMissingParams }
330
352
  }
331
353
 
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
340
360
  }
package/src/router.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  } from './new-process-route-tree'
20
20
  import {
21
21
  cleanPath,
22
+ compileDecodeCharMap,
22
23
  interpolatePath,
23
24
  resolvePath,
24
25
  trimPath,
@@ -923,7 +924,7 @@ export class RouterCore<
923
924
  routesByPath!: RoutesByPath<TRouteTree>
924
925
  processedTree!: ProcessedTree<TRouteTree, any, any>
925
926
  isServer!: boolean
926
- pathParamsDecodeCharMap?: Map<string, string>
927
+ pathParamsDecoder?: (encoded: string) => string
927
928
 
928
929
  /**
929
930
  * @deprecated Use the `createRouter` function instead
@@ -992,14 +993,10 @@ export class RouterCore<
992
993
 
993
994
  this.isServer = this.options.isServer ?? typeof document === 'undefined'
994
995
 
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
+ )
1003
1000
 
1004
1001
  if (
1005
1002
  !this.history ||
@@ -1365,7 +1362,7 @@ export class RouterCore<
1365
1362
  const { interpolatedPath, usedParams } = interpolatePath({
1366
1363
  path: route.fullPath,
1367
1364
  params: routeParams,
1368
- decodeCharMap: this.pathParamsDecodeCharMap,
1365
+ decoder: this.pathParamsDecoder,
1369
1366
  })
1370
1367
 
1371
1368
  // Waste not, want not. If we already have a match for this route,
@@ -1721,7 +1718,7 @@ export class RouterCore<
1721
1718
  interpolatePath({
1722
1719
  path: nextTo,
1723
1720
  params: nextParams,
1724
- decodeCharMap: this.pathParamsDecodeCharMap,
1721
+ decoder: this.pathParamsDecoder,
1725
1722
  }).interpolatedPath,
1726
1723
  )
1727
1724