@tanstack/router-core 1.154.14 → 1.156.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.156.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)
@@ -758,6 +778,17 @@ export function trimPathRight(path: string) {
758
778
  return path === '/' ? path : path.replace(/\/{1,}$/, '')
759
779
  }
760
780
 
781
+ export interface ProcessRouteTreeResult<
782
+ TRouteLike extends Extract<RouteLike, { fullPath: string }> & { id: string },
783
+ > {
784
+ /** Should be considered a black box, needs to be provided to all matching functions in this module. */
785
+ processedTree: ProcessedTree<TRouteLike, any, any>
786
+ /** A lookup map of routes by their unique IDs. */
787
+ routesById: Record<string, TRouteLike>
788
+ /** A lookup map of routes by their trimmed full paths. */
789
+ routesByPath: Record<string, TRouteLike>
790
+ }
791
+
761
792
  /**
762
793
  * Processes a route tree into a segment trie for efficient path matching.
763
794
  * Also builds lookup maps for routes by ID and by trimmed full path.
@@ -771,14 +802,7 @@ export function processRouteTree<
771
802
  caseSensitive: boolean = false,
772
803
  /** Optional callback invoked for each route during processing. */
773
804
  initRoute?: (route: TRouteLike, index: number) => void,
774
- ): {
775
- /** Should be considered a black box, needs to be provided to all matching functions in this module. */
776
- processedTree: ProcessedTree<TRouteLike, any, any>
777
- /** A lookup map of routes by their unique IDs. */
778
- routesById: Record<string, TRouteLike>
779
- /** A lookup map of routes by their trimmed full paths. */
780
- routesByPath: Record<string, TRouteLike>
781
- } {
805
+ ): ProcessRouteTreeResult<TRouteLike> {
782
806
  const segmentTree = createStaticNode<TRouteLike>(routeTree.fullPath)
783
807
  const data = new Uint16Array(6)
784
808
  const routesById = {} as Record<string, TRouteLike>
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,
@@ -37,7 +38,11 @@ import {
37
38
  executeRewriteOutput,
38
39
  rewriteBasepath,
39
40
  } from './rewrite'
40
- import type { ProcessedTree } from './new-process-route-tree'
41
+ import type { LRUCache } from './lru-cache'
42
+ import type {
43
+ ProcessRouteTreeResult,
44
+ ProcessedTree,
45
+ } from './new-process-route-tree'
41
46
  import type { SearchParser, SearchSerializer } from './searchParams'
42
47
  import type { AnyRedirect, ResolvedRedirect } from './redirect'
43
48
  import type {
@@ -588,7 +593,6 @@ export type SubscribeFn = <TType extends keyof RouterEvents>(
588
593
  export interface MatchRoutesOpts {
589
594
  preload?: boolean
590
595
  throwOnError?: boolean
591
- _buildLocation?: boolean
592
596
  dest?: BuildNextOptions
593
597
  }
594
598
 
@@ -872,6 +876,17 @@ export type CreateRouterFn = <
872
876
  TDehydrated
873
877
  >
874
878
 
879
+ declare global {
880
+ // eslint-disable-next-line no-var
881
+ var __TSR_CACHE__:
882
+ | {
883
+ routeTree: AnyRoute
884
+ processRouteTreeResult: ProcessRouteTreeResult<AnyRoute>
885
+ resolvePathCache: LRUCache<string, string>
886
+ }
887
+ | undefined
888
+ }
889
+
875
890
  /**
876
891
  * Core, framework-agnostic router engine that powers TanStack Router.
877
892
  *
@@ -922,8 +937,9 @@ export class RouterCore<
922
937
  routesById!: RoutesById<TRouteTree>
923
938
  routesByPath!: RoutesByPath<TRouteTree>
924
939
  processedTree!: ProcessedTree<TRouteTree, any, any>
940
+ resolvePathCache!: LRUCache<string, string>
925
941
  isServer!: boolean
926
- pathParamsDecodeCharMap?: Map<string, string>
942
+ pathParamsDecoder?: (encoded: string) => string
927
943
 
928
944
  /**
929
945
  * @deprecated Use the `createRouter` function instead
@@ -992,14 +1008,10 @@ export class RouterCore<
992
1008
 
993
1009
  this.isServer = this.options.isServer ?? typeof document === 'undefined'
994
1010
 
995
- this.pathParamsDecodeCharMap = this.options.pathParamsAllowedCharacters
996
- ? new Map(
997
- this.options.pathParamsAllowedCharacters.map((char) => [
998
- encodeURIComponent(char),
999
- char,
1000
- ]),
1001
- )
1002
- : undefined
1011
+ if (this.options.pathParamsAllowedCharacters)
1012
+ this.pathParamsDecoder = compileDecodeCharMap(
1013
+ this.options.pathParamsAllowedCharacters,
1014
+ )
1003
1015
 
1004
1016
  if (
1005
1017
  !this.history ||
@@ -1030,7 +1042,28 @@ export class RouterCore<
1030
1042
 
1031
1043
  if (this.options.routeTree !== this.routeTree) {
1032
1044
  this.routeTree = this.options.routeTree as TRouteTree
1033
- this.buildRouteTree()
1045
+ let processRouteTreeResult: ProcessRouteTreeResult<TRouteTree>
1046
+ if (
1047
+ this.isServer &&
1048
+ globalThis.__TSR_CACHE__ &&
1049
+ globalThis.__TSR_CACHE__.routeTree === this.routeTree
1050
+ ) {
1051
+ const cached = globalThis.__TSR_CACHE__
1052
+ this.resolvePathCache = cached.resolvePathCache
1053
+ processRouteTreeResult = cached.processRouteTreeResult as any
1054
+ } else {
1055
+ this.resolvePathCache = createLRUCache(1000)
1056
+ processRouteTreeResult = this.buildRouteTree()
1057
+ // only cache if nothing else is cached yet
1058
+ if (this.isServer && globalThis.__TSR_CACHE__ === undefined) {
1059
+ globalThis.__TSR_CACHE__ = {
1060
+ routeTree: this.routeTree,
1061
+ processRouteTreeResult: processRouteTreeResult as any,
1062
+ resolvePathCache: this.resolvePathCache,
1063
+ }
1064
+ }
1065
+ }
1066
+ this.setRoutes(processRouteTreeResult)
1034
1067
  }
1035
1068
 
1036
1069
  if (!this.__store && this.latestLocation) {
@@ -1113,7 +1146,7 @@ export class RouterCore<
1113
1146
  }
1114
1147
 
1115
1148
  buildRouteTree = () => {
1116
- const { routesById, routesByPath, processedTree } = processRouteTree(
1149
+ const result = processRouteTree(
1117
1150
  this.routeTree,
1118
1151
  this.options.caseSensitive,
1119
1152
  (route, i) => {
@@ -1123,9 +1156,17 @@ export class RouterCore<
1123
1156
  },
1124
1157
  )
1125
1158
  if (this.options.routeMasks) {
1126
- processRouteMasks(this.options.routeMasks, processedTree)
1159
+ processRouteMasks(this.options.routeMasks, result.processedTree)
1127
1160
  }
1128
1161
 
1162
+ return result
1163
+ }
1164
+
1165
+ setRoutes({
1166
+ routesById,
1167
+ routesByPath,
1168
+ processedTree,
1169
+ }: ProcessRouteTreeResult<TRouteTree>) {
1129
1170
  this.routesById = routesById as RoutesById<TRouteTree>
1130
1171
  this.routesByPath = routesByPath as RoutesByPath<TRouteTree>
1131
1172
  this.processedTree = processedTree
@@ -1225,8 +1266,6 @@ export class RouterCore<
1225
1266
  return location
1226
1267
  }
1227
1268
 
1228
- resolvePathCache = createLRUCache<string, string>(1000)
1229
-
1230
1269
  /** Resolve a path against the router basepath and trailing-slash policy. */
1231
1270
  resolvePathWithBase = (from: string, path: string) => {
1232
1271
  const resolvedPath = resolvePath({
@@ -1365,7 +1404,7 @@ export class RouterCore<
1365
1404
  const { interpolatedPath, usedParams } = interpolatePath({
1366
1405
  path: route.fullPath,
1367
1406
  params: routeParams,
1368
- decodeCharMap: this.pathParamsDecodeCharMap,
1407
+ decoder: this.pathParamsDecoder,
1369
1408
  })
1370
1409
 
1371
1410
  // Waste not, want not. If we already have a match for this route,
@@ -1393,35 +1432,19 @@ export class RouterCore<
1393
1432
  let paramsError: unknown = undefined
1394
1433
 
1395
1434
  if (!existingMatch) {
1396
- if (route.options.skipRouteOnParseError) {
1397
- for (const key in usedParams) {
1398
- if (key in parsedParams!) {
1399
- strictParams[key] = parsedParams![key]
1400
- }
1435
+ try {
1436
+ extractStrictParams(route, usedParams, parsedParams!, strictParams)
1437
+ } catch (err: any) {
1438
+ if (isNotFound(err) || isRedirect(err)) {
1439
+ paramsError = err
1440
+ } else {
1441
+ paramsError = new PathParamError(err.message, {
1442
+ cause: err,
1443
+ })
1401
1444
  }
1402
- } else {
1403
- const strictParseParams =
1404
- route.options.params?.parse ?? route.options.parseParams
1405
1445
 
1406
- if (strictParseParams) {
1407
- try {
1408
- Object.assign(
1409
- strictParams,
1410
- strictParseParams(strictParams as Record<string, string>),
1411
- )
1412
- } catch (err: any) {
1413
- if (isNotFound(err) || isRedirect(err)) {
1414
- paramsError = err
1415
- } else {
1416
- paramsError = new PathParamError(err.message, {
1417
- cause: err,
1418
- })
1419
- }
1420
-
1421
- if (opts?.throwOnError) {
1422
- throw paramsError
1423
- }
1424
- }
1446
+ if (opts?.throwOnError) {
1447
+ throw paramsError
1425
1448
  }
1426
1449
  }
1427
1450
  }
@@ -1522,7 +1545,7 @@ export class RouterCore<
1522
1545
 
1523
1546
  // only execute `context` if we are not calling from router.buildLocation
1524
1547
 
1525
- if (!existingMatch && opts?._buildLocation !== true) {
1548
+ if (!existingMatch) {
1526
1549
  const parentMatch = matches[index - 1]
1527
1550
  const parentContext = getParentContext(parentMatch)
1528
1551
 
@@ -1566,6 +1589,80 @@ export class RouterCore<
1566
1589
  })
1567
1590
  }
1568
1591
 
1592
+ /**
1593
+ * Lightweight route matching for buildLocation.
1594
+ * Only computes fullPath, accumulated search, and params - skipping expensive
1595
+ * operations like AbortController, ControlledPromise, loaderDeps, and full match objects.
1596
+ */
1597
+ private matchRoutesLightweight(location: ParsedLocation): {
1598
+ matchedRoutes: ReadonlyArray<AnyRoute>
1599
+ fullPath: string
1600
+ search: Record<string, unknown>
1601
+ params: Record<string, unknown>
1602
+ } {
1603
+ const { matchedRoutes, routeParams, parsedParams } = this.getMatchedRoutes(
1604
+ location.pathname,
1605
+ )
1606
+ const lastRoute = last(matchedRoutes)!
1607
+
1608
+ // I don't know if we should run the full search middleware chain, or just validateSearch
1609
+ // // Accumulate search validation through the route chain
1610
+ // const accumulatedSearch: Record<string, unknown> = applySearchMiddleware({
1611
+ // search: { ...location.search },
1612
+ // dest: location,
1613
+ // destRoutes: matchedRoutes,
1614
+ // _includeValidateSearch: true,
1615
+ // })
1616
+
1617
+ // Accumulate search validation through route chain
1618
+ const accumulatedSearch = { ...location.search }
1619
+ for (const route of matchedRoutes) {
1620
+ try {
1621
+ Object.assign(
1622
+ accumulatedSearch,
1623
+ validateSearch(route.options.validateSearch, accumulatedSearch),
1624
+ )
1625
+ } catch {
1626
+ // Ignore errors, we're not actually routing
1627
+ }
1628
+ }
1629
+
1630
+ // Determine params: reuse from state if possible, otherwise parse
1631
+ const lastStateMatch = last(this.state.matches)
1632
+ const canReuseParams =
1633
+ lastStateMatch &&
1634
+ lastStateMatch.routeId === lastRoute.id &&
1635
+ location.pathname === this.state.location.pathname
1636
+
1637
+ let params: Record<string, unknown>
1638
+ if (canReuseParams) {
1639
+ params = lastStateMatch.params
1640
+ } else {
1641
+ // Parse params through the route chain
1642
+ const strictParams: Record<string, unknown> = { ...routeParams }
1643
+ for (const route of matchedRoutes) {
1644
+ try {
1645
+ extractStrictParams(
1646
+ route,
1647
+ routeParams,
1648
+ parsedParams ?? {},
1649
+ strictParams,
1650
+ )
1651
+ } catch {
1652
+ // Ignore errors, we're not actually routing
1653
+ }
1654
+ }
1655
+ params = strictParams
1656
+ }
1657
+
1658
+ return {
1659
+ matchedRoutes,
1660
+ fullPath: lastRoute.fullPath,
1661
+ search: accumulatedSearch,
1662
+ params,
1663
+ }
1664
+ }
1665
+
1569
1666
  cancelMatch = (id: string) => {
1570
1667
  const match = this.getMatch(id)
1571
1668
 
@@ -1610,13 +1707,9 @@ export class RouterCore<
1610
1707
  const currentLocation =
1611
1708
  dest._fromLocation || this.pendingBuiltLocation || this.latestLocation
1612
1709
 
1613
- const allCurrentLocationMatches = this.matchRoutes(currentLocation, {
1614
- _buildLocation: true,
1615
- })
1616
-
1617
- // Now let's find the starting pathname
1618
- // This should default to the current location if no from is provided
1619
- const lastMatch = last(allCurrentLocationMatches)!
1710
+ // Use lightweight matching - only computes what buildLocation needs
1711
+ // (fullPath, search, params) without creating full match objects
1712
+ const lightweightResult = this.matchRoutesLightweight(currentLocation)
1620
1713
 
1621
1714
  // check that from path exists in the current route tree
1622
1715
  // do this check only on navigations during test or development
@@ -1627,12 +1720,12 @@ export class RouterCore<
1627
1720
  ) {
1628
1721
  const allFromMatches = this.getMatchedRoutes(dest.from).matchedRoutes
1629
1722
 
1630
- const matchedFrom = findLast(allCurrentLocationMatches, (d) => {
1723
+ const matchedFrom = findLast(lightweightResult.matchedRoutes, (d) => {
1631
1724
  return comparePaths(d.fullPath, dest.from!)
1632
1725
  })
1633
1726
 
1634
1727
  const matchedCurrent = findLast(allFromMatches, (d) => {
1635
- return comparePaths(d.fullPath, lastMatch.fullPath)
1728
+ return comparePaths(d.fullPath, lightweightResult.fullPath)
1636
1729
  })
1637
1730
 
1638
1731
  // for from to be invalid it shouldn't just be unmatched to currentLocation
@@ -1645,15 +1738,15 @@ export class RouterCore<
1645
1738
  const defaultedFromPath =
1646
1739
  dest.unsafeRelative === 'path'
1647
1740
  ? currentLocation.pathname
1648
- : (dest.from ?? lastMatch.fullPath)
1741
+ : (dest.from ?? lightweightResult.fullPath)
1649
1742
 
1650
1743
  // ensure this includes the basePath if set
1651
1744
  const fromPath = this.resolvePathWithBase(defaultedFromPath, '.')
1652
1745
 
1653
1746
  // From search should always use the current location
1654
- const fromSearch = lastMatch.search
1747
+ const fromSearch = lightweightResult.search
1655
1748
  // Same with params. It can't hurt to provide as many as possible
1656
- const fromParams = { ...lastMatch.params }
1749
+ const fromParams = { ...lightweightResult.params }
1657
1750
 
1658
1751
  // Resolve the next to
1659
1752
  // ensure this includes the basePath if set
@@ -1721,7 +1814,7 @@ export class RouterCore<
1721
1814
  interpolatePath({
1722
1815
  path: nextTo,
1723
1816
  params: nextParams,
1724
- decodeCharMap: this.pathParamsDecodeCharMap,
1817
+ decoder: this.pathParamsDecoder,
1725
1818
  }).interpolatedPath,
1726
1819
  )
1727
1820
 
@@ -2802,7 +2895,7 @@ function applySearchMiddleware({
2802
2895
  _includeValidateSearch,
2803
2896
  }: {
2804
2897
  search: any
2805
- dest: BuildNextOptions
2898
+ dest: { search?: unknown }
2806
2899
  destRoutes: ReadonlyArray<AnyRoute>
2807
2900
  _includeValidateSearch: boolean | undefined
2808
2901
  }) {
@@ -2937,3 +3030,25 @@ function findGlobalNotFoundRouteId(
2937
3030
  }
2938
3031
  return rootRouteId
2939
3032
  }
3033
+
3034
+ function extractStrictParams(
3035
+ route: AnyRoute,
3036
+ referenceParams: Record<string, unknown>,
3037
+ parsedParams: Record<string, unknown>,
3038
+ accumulatedParams: Record<string, unknown>,
3039
+ ) {
3040
+ const parseParams = route.options.params?.parse ?? route.options.parseParams
3041
+ if (parseParams) {
3042
+ if (route.options.skipRouteOnParseError) {
3043
+ // Use pre-parsed params from route matching for skipRouteOnParseError routes
3044
+ for (const key in referenceParams) {
3045
+ if (key in parsedParams) {
3046
+ accumulatedParams[key] = parsedParams[key]
3047
+ }
3048
+ }
3049
+ } else {
3050
+ const result = parseParams(accumulatedParams as Record<string, string>)
3051
+ Object.assign(accumulatedParams, result)
3052
+ }
3053
+ }
3054
+ }