@tanstack/router-core 1.151.6 → 1.153.2

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/src/router.ts CHANGED
@@ -46,6 +46,7 @@ import type {
46
46
  ParsedHistoryState,
47
47
  RouterHistory,
48
48
  } from '@tanstack/history'
49
+
49
50
  import type {
50
51
  Awaitable,
51
52
  Constrain,
@@ -55,7 +56,7 @@ import type {
55
56
  PickAsRequired,
56
57
  Updater,
57
58
  } from './utils'
58
- import type { ParsedLocation } from './location'
59
+ import type { MatchSnapshot, ParsedLocation } from './location'
59
60
  import type {
60
61
  AnyContext,
61
62
  AnyRoute,
@@ -589,6 +590,8 @@ export interface MatchRoutesOpts {
589
590
  throwOnError?: boolean
590
591
  _buildLocation?: boolean
591
592
  dest?: BuildNextOptions
593
+ /** Optional match snapshot hint for fast-path (skips path matching) */
594
+ snapshot?: MatchSnapshot
592
595
  }
593
596
 
594
597
  export type InferRouterContext<TRouteTree extends AnyRoute> =
@@ -701,14 +704,17 @@ export type GetMatchRoutesFn = (pathname: string) => {
701
704
  /** exhaustive params, still in their string form */
702
705
  routeParams: Record<string, string>
703
706
  /** partial params, parsed from routeParams during matching */
704
- parsedParams: Record<string, unknown> | undefined
707
+ parsedParams: Record<string, unknown>
705
708
  foundRoute: AnyRoute | undefined
706
709
  parseError?: unknown
707
710
  }
708
711
 
709
712
  export type EmitFn = (routerEvent: RouterEvent) => void
710
713
 
711
- export type LoadFn = (opts?: { sync?: boolean }) => Promise<void>
714
+ export type LoadFn = (opts?: {
715
+ sync?: boolean
716
+ _skipUpdateLatestLocation?: boolean
717
+ }) => Promise<void>
712
718
 
713
719
  export type CommitLocationFn = ({
714
720
  viewTransition,
@@ -891,7 +897,6 @@ export class RouterCore<
891
897
  tempLocationKey: string | undefined = `${Math.round(
892
898
  Math.random() * 10000000,
893
899
  )}`
894
- resetNextScroll = true
895
900
  shouldViewTransition?: boolean | ViewTransitionOptions = undefined
896
901
  isViewTransitionTypesSupported?: boolean = undefined
897
902
  subscribers = new Set<RouterListener<RouterEvent>>()
@@ -916,6 +921,8 @@ export class RouterCore<
916
921
  origin?: string
917
922
  latestLocation!: ParsedLocation<FullSearchSchema<TRouteTree>>
918
923
  pendingBuiltLocation?: ParsedLocation<FullSearchSchema<TRouteTree>>
924
+ /** Session id for cached history snapshots */
925
+ private sessionId!: string
919
926
  basepath!: string
920
927
  routeTree!: TRouteTree
921
928
  routesById!: RoutesById<TRouteTree>
@@ -936,6 +943,11 @@ export class RouterCore<
936
943
  TDehydrated
937
944
  >,
938
945
  ) {
946
+ this.sessionId =
947
+ typeof crypto !== 'undefined' && 'randomUUID' in crypto
948
+ ? crypto.randomUUID()
949
+ : `${Date.now()}-${Math.random().toString(36).slice(2)}`
950
+
939
951
  this.update({
940
952
  defaultPreloadDelay: 50,
941
953
  defaultPendingMs: 1000,
@@ -1199,7 +1211,7 @@ export class RouterCore<
1199
1211
  pathname: decodePath(url.pathname),
1200
1212
  searchStr,
1201
1213
  search: replaceEqualDeep(previousLocation?.search, parsedSearch) as any,
1202
- hash: url.hash.split('#').reverse()[0] ?? '',
1214
+ hash: decodePath(url.hash.split('#').reverse()[0] ?? ''),
1203
1215
  state: replaceEqualDeep(previousLocation?.state, state),
1204
1216
  }
1205
1217
  }
@@ -1263,44 +1275,68 @@ export class RouterCore<
1263
1275
  next: ParsedLocation,
1264
1276
  opts?: MatchRoutesOpts,
1265
1277
  ): Array<AnyRouteMatch> {
1266
- const matchedRoutesResult = this.getMatchedRoutes(next.pathname)
1267
- const { foundRoute, routeParams, parsedParams } = matchedRoutesResult
1268
- let { matchedRoutes } = matchedRoutesResult
1269
- let isGlobalNotFound = false
1278
+ // Fast-path: use snapshot hint if valid
1279
+ const snapshot = opts?.snapshot
1280
+ const snapshotValid =
1281
+ snapshot &&
1282
+ snapshot.routeIds.length > 0 &&
1283
+ snapshot.routeIds.every((id) => this.routesById[id])
1284
+
1285
+ let matchedRoutes: ReadonlyArray<AnyRoute>
1286
+ let routeParams: Record<string, string>
1287
+ let globalNotFoundRouteId: string | undefined
1288
+ let parsedParams: Record<string, unknown>
1289
+
1290
+ if (snapshotValid) {
1291
+ // Rebuild matched routes from snapshot
1292
+ matchedRoutes = snapshot.routeIds.map((id) => this.routesById[id]!)
1293
+ routeParams = { ...snapshot.params }
1294
+ globalNotFoundRouteId = snapshot.globalNotFoundRouteId
1295
+ parsedParams = snapshot.parsedParams
1296
+ } else {
1297
+ // Normal path matching
1298
+ const matchedRoutesResult = this.getMatchedRoutes(next.pathname)
1299
+ const { foundRoute, routeParams: rp } = matchedRoutesResult
1300
+ routeParams = rp
1301
+ matchedRoutes = matchedRoutesResult.matchedRoutes
1302
+ parsedParams = matchedRoutesResult.parsedParams
1270
1303
 
1271
- // Check to see if the route needs a 404 entry
1272
- if (
1273
- // If we found a route, and it's not an index route and we have left over path
1274
- foundRoute
1275
- ? foundRoute.path !== '/' && routeParams['**']
1276
- : // Or if we didn't find a route and we have left over path
1277
- trimPathRight(next.pathname)
1278
- ) {
1279
- // If the user has defined an (old) 404 route, use it
1280
- if (this.options.notFoundRoute) {
1281
- matchedRoutes = [...matchedRoutes, this.options.notFoundRoute]
1282
- } else {
1283
- // If there is no routes found during path matching
1284
- isGlobalNotFound = true
1285
- }
1286
- }
1304
+ let isGlobalNotFound = false
1287
1305
 
1288
- const globalNotFoundRouteId = (() => {
1289
- if (!isGlobalNotFound) {
1290
- return undefined
1306
+ // Check to see if the route needs a 404 entry
1307
+ if (
1308
+ // If we found a route, and it's not an index route and we have left over path
1309
+ foundRoute
1310
+ ? foundRoute.path !== '/' && routeParams['**']
1311
+ : // Or if we didn't find a route and we have left over path
1312
+ trimPathRight(next.pathname)
1313
+ ) {
1314
+ // If the user has defined an (old) 404 route, use it
1315
+ if (this.options.notFoundRoute) {
1316
+ matchedRoutes = [...matchedRoutes, this.options.notFoundRoute]
1317
+ } else {
1318
+ // If there is no routes found during path matching
1319
+ isGlobalNotFound = true
1320
+ }
1291
1321
  }
1292
1322
 
1293
- if (this.options.notFoundMode !== 'root') {
1294
- for (let i = matchedRoutes.length - 1; i >= 0; i--) {
1295
- const route = matchedRoutes[i]!
1296
- if (route.children) {
1297
- return route.id
1323
+ globalNotFoundRouteId = (() => {
1324
+ if (!isGlobalNotFound) {
1325
+ return undefined
1326
+ }
1327
+
1328
+ if (this.options.notFoundMode !== 'root') {
1329
+ for (let i = matchedRoutes.length - 1; i >= 0; i--) {
1330
+ const route = matchedRoutes[i]!
1331
+ if (route.children) {
1332
+ return route.id
1333
+ }
1298
1334
  }
1299
1335
  }
1300
- }
1301
1336
 
1302
- return rootRouteId
1303
- })()
1337
+ return rootRouteId
1338
+ })()
1339
+ }
1304
1340
 
1305
1341
  const matches: Array<AnyRouteMatch> = []
1306
1342
 
@@ -1314,6 +1350,19 @@ export class RouterCore<
1314
1350
  return parentContext
1315
1351
  }
1316
1352
 
1353
+ // Check if we can use cached validated searches from snapshot
1354
+ // Valid if: snapshot exists, searchStr matches, and validatedSearches has correct length
1355
+ const canUseCachedSearch =
1356
+ snapshotValid &&
1357
+ snapshot.searchStr === next.searchStr &&
1358
+ snapshot.validatedSearches?.length === matchedRoutes.length
1359
+
1360
+ // Collect validated searches to cache in snapshot (only when not using cache)
1361
+ const validatedSearchesToCache: Array<{
1362
+ search: Record<string, unknown>
1363
+ strictSearch: Record<string, unknown>
1364
+ }> = []
1365
+
1317
1366
  matchedRoutes.forEach((route, index) => {
1318
1367
  // Take each matched route and resolve + validate its search params
1319
1368
  // This has to happen serially because each route's search params
@@ -1329,6 +1378,12 @@ export class RouterCore<
1329
1378
  Record<string, any>,
1330
1379
  any,
1331
1380
  ] = (() => {
1381
+ // Fast-path: use cached validated search from snapshot
1382
+ if (canUseCachedSearch) {
1383
+ const cached = snapshot.validatedSearches![index]!
1384
+ return [cached.search, cached.strictSearch, undefined]
1385
+ }
1386
+
1332
1387
  // Validate the search params and stabilize them
1333
1388
  const parentSearch = parentMatch?.search ?? next.search
1334
1389
  const parentStrictSearch = parentMatch?._strictSearch ?? undefined
@@ -1362,6 +1417,14 @@ export class RouterCore<
1362
1417
  }
1363
1418
  })()
1364
1419
 
1420
+ // Cache the validated search for future pop navigations
1421
+ if (!canUseCachedSearch) {
1422
+ validatedSearchesToCache.push({
1423
+ search: preMatchSearch,
1424
+ strictSearch: strictMatchSearch,
1425
+ })
1426
+ }
1427
+
1365
1428
  // This is where we need to call route.options.loaderDeps() to get any additional
1366
1429
  // deps that the route's loader function might need to run. We need to do this
1367
1430
  // before we create the match so that we can pass the deps to the route's
@@ -1528,6 +1591,18 @@ export class RouterCore<
1528
1591
  matches.push(match)
1529
1592
  })
1530
1593
 
1594
+ // Cache validated searches in snapshot for future pop navigations
1595
+ // Only update if we computed fresh values (not using cached)
1596
+ if (!canUseCachedSearch && validatedSearchesToCache.length > 0) {
1597
+ const existingSnapshot = next.state?.__TSR_matches as
1598
+ | MatchSnapshot
1599
+ | undefined
1600
+ if (existingSnapshot) {
1601
+ existingSnapshot.searchStr = next.searchStr
1602
+ existingSnapshot.validatedSearches = validatedSearchesToCache
1603
+ }
1604
+ }
1605
+
1531
1606
  matches.forEach((match, index) => {
1532
1607
  const route = this.looseRoutesById[match.routeId]!
1533
1608
  const existingMatch = this.getMatch(match.id)
@@ -1690,9 +1765,15 @@ export class RouterCore<
1690
1765
  params: nextParams,
1691
1766
  }).interpolatedPath
1692
1767
 
1693
- const destRoutes = this.matchRoutes(interpolatedNextTo, undefined, {
1768
+ const destMatches = this.matchRoutes(interpolatedNextTo, undefined, {
1694
1769
  _buildLocation: true,
1695
- }).map((d) => this.looseRoutesById[d.routeId]!)
1770
+ })
1771
+ const destRoutes = destMatches.map(
1772
+ (d) => this.looseRoutesById[d.routeId]!,
1773
+ )
1774
+
1775
+ // Check if any match indicates global not found
1776
+ const globalNotFoundMatch = destMatches.find((m) => m.globalNotFound)
1696
1777
 
1697
1778
  // If there are any params, we need to stringify them
1698
1779
  if (Object.keys(nextParams).length > 0) {
@@ -1774,6 +1855,15 @@ export class RouterCore<
1774
1855
  // Replace the equal deep
1775
1856
  nextState = replaceEqualDeep(currentLocation.state, nextState)
1776
1857
 
1858
+ // Build match snapshot for fast-path on back/forward navigation
1859
+ // Use destRoutes and nextParams directly (after stringify)
1860
+ const matchSnapshot = buildMatchSnapshotFromRoutes({
1861
+ routes: destRoutes,
1862
+ params: nextParams,
1863
+ searchStr,
1864
+ globalNotFoundRouteId: globalNotFoundMatch?.routeId,
1865
+ })
1866
+
1777
1867
  // Create the full path of the location
1778
1868
  const fullPath = `${nextPathname}${searchStr}${hashStr}`
1779
1869
 
@@ -1783,10 +1873,13 @@ export class RouterCore<
1783
1873
  // If a rewrite function is provided, use it to rewrite the URL
1784
1874
  const rewrittenUrl = executeRewriteOutput(this.rewrite, url)
1785
1875
 
1876
+ // Use encoded URL path for href (consistent with parseLocation)
1877
+ const encodedHref = url.href.replace(url.origin, '')
1878
+
1786
1879
  return {
1787
1880
  publicHref:
1788
1881
  rewrittenUrl.pathname + rewrittenUrl.search + rewrittenUrl.hash,
1789
- href: fullPath,
1882
+ href: encodedHref,
1790
1883
  url: rewrittenUrl,
1791
1884
  pathname: nextPathname,
1792
1885
  search: nextSearch,
@@ -1794,6 +1887,7 @@ export class RouterCore<
1794
1887
  state: nextState as any,
1795
1888
  hash: hash ?? '',
1796
1889
  unmaskOnReload: dest.unmaskOnReload,
1890
+ _matchSnapshot: matchSnapshot,
1797
1891
  }
1798
1892
  }
1799
1893
 
@@ -1863,7 +1957,7 @@ export class RouterCore<
1863
1957
  * Commit a previously built location to history (push/replace), optionally
1864
1958
  * using view transitions and scroll restoration options.
1865
1959
  */
1866
- commitLocation: CommitLocationFn = ({
1960
+ commitLocation: CommitLocationFn = async ({
1867
1961
  viewTransition,
1868
1962
  ignoreBlocker,
1869
1963
  ...next
@@ -1899,65 +1993,105 @@ export class RouterCore<
1899
1993
  // Don't commit to history if nothing changed
1900
1994
  if (isSameUrl && isSameState()) {
1901
1995
  this.load()
1902
- } else {
1903
- let {
1904
- // eslint-disable-next-line prefer-const
1905
- maskedLocation,
1906
- // eslint-disable-next-line prefer-const
1907
- hashScrollIntoView,
1908
- // don't pass url into history since it is a URL instance that cannot be serialized
1909
- // eslint-disable-next-line prefer-const
1910
- url: _url,
1911
- ...nextHistory
1912
- } = next
1913
-
1914
- if (maskedLocation) {
1915
- nextHistory = {
1916
- ...maskedLocation,
1917
- state: {
1918
- ...maskedLocation.state,
1919
- __tempKey: undefined,
1920
- __tempLocation: {
1921
- ...nextHistory,
1922
- search: nextHistory.searchStr,
1923
- state: {
1924
- ...nextHistory.state,
1925
- __tempKey: undefined!,
1926
- __tempLocation: undefined!,
1927
- __TSR_key: undefined!,
1928
- key: undefined!, // TODO: Remove in v2 - use __TSR_key instead
1929
- },
1996
+ return this.commitLocationPromise
1997
+ }
1998
+
1999
+ let {
2000
+ // eslint-disable-next-line prefer-const
2001
+ maskedLocation,
2002
+ // eslint-disable-next-line prefer-const
2003
+ hashScrollIntoView,
2004
+ // don't pass url into history since it is a URL instance that cannot be serialized
2005
+ // eslint-disable-next-line prefer-const
2006
+ url: _url,
2007
+ ...nextHistory
2008
+ } = next
2009
+
2010
+ if (maskedLocation) {
2011
+ nextHistory = {
2012
+ ...maskedLocation,
2013
+ state: {
2014
+ ...maskedLocation.state,
2015
+ __tempKey: undefined,
2016
+ __tempLocation: {
2017
+ ...nextHistory,
2018
+ search: nextHistory.searchStr,
2019
+ state: {
2020
+ ...nextHistory.state,
2021
+ __tempKey: undefined!,
2022
+ __tempLocation: undefined!,
2023
+ __TSR_key: undefined!,
2024
+ key: undefined!, // TODO: Remove in v2 - use __TSR_key instead
1930
2025
  },
1931
2026
  },
1932
- }
2027
+ },
2028
+ }
1933
2029
 
1934
- if (
1935
- nextHistory.unmaskOnReload ??
1936
- this.options.unmaskOnReload ??
1937
- false
1938
- ) {
1939
- nextHistory.state.__tempKey = this.tempLocationKey
1940
- }
2030
+ if (nextHistory.unmaskOnReload ?? this.options.unmaskOnReload ?? false) {
2031
+ nextHistory.state.__tempKey = this.tempLocationKey
1941
2032
  }
2033
+ }
1942
2034
 
1943
- nextHistory.state.__hashScrollIntoViewOptions =
1944
- hashScrollIntoView ?? this.options.defaultHashScrollIntoView ?? true
2035
+ nextHistory.state.__hashScrollIntoViewOptions =
2036
+ hashScrollIntoView ?? this.options.defaultHashScrollIntoView ?? true
1945
2037
 
1946
- this.shouldViewTransition = viewTransition
2038
+ // Store resetScroll in history state so it survives back/forward navigation
2039
+ nextHistory.state.__TSR_resetScroll = next.resetScroll ?? true
1947
2040
 
1948
- this.history[next.replace ? 'replace' : 'push'](
1949
- nextHistory.publicHref,
1950
- nextHistory.state,
1951
- { ignoreBlocker },
1952
- )
2041
+ this.shouldViewTransition = viewTransition
2042
+
2043
+ // Store session id for this router lifetime
2044
+ nextHistory.state.__TSR_sessionId = this.sessionId
2045
+
2046
+ // Use match snapshot from buildLocation if available, otherwise compute it.
2047
+ // Stored in history state for pop/back/forward fast-path.
2048
+ nextHistory.state.__TSR_matches =
2049
+ next._matchSnapshot ??
2050
+ buildMatchSnapshot({
2051
+ matchResult: this.getMatchedRoutes(next.pathname),
2052
+ pathname: next.pathname,
2053
+ searchStr: next.searchStr,
2054
+ notFoundRoute: this.options.notFoundRoute,
2055
+ notFoundMode: this.options.notFoundMode,
2056
+ })
2057
+
2058
+ // Build the pre-computed ParsedLocation to avoid re-parsing after push
2059
+ // Spread next (which has href, pathname, search, etc.) and override with final state
2060
+ const precomputedLocation: ParsedLocation = {
2061
+ ...next,
2062
+ publicHref: nextHistory.publicHref,
2063
+ state: nextHistory.state,
2064
+ maskedLocation,
1953
2065
  }
1954
2066
 
1955
- this.resetNextScroll = next.resetScroll ?? true
2067
+ // Await push/replace to handle blockers before proceeding
2068
+ // Pass skipTransitionerLoad so Transitioner doesn't call load() - we handle it below
2069
+ const result = await this.history[next.replace ? 'replace' : 'push'](
2070
+ nextHistory.publicHref,
2071
+ nextHistory.state,
2072
+ { ignoreBlocker, skipTransitionerLoad: true },
2073
+ )
1956
2074
 
1957
- if (!this.history.subscribers.size) {
1958
- this.load()
2075
+ // If blocked, resolve promise and return
2076
+ if (result.type === 'BLOCKED') {
2077
+ this.commitLocationPromise?.resolve()
2078
+ return this.commitLocationPromise
1959
2079
  }
1960
2080
 
2081
+ // Check if another navigation has superseded this one while we awaited
2082
+ // If so, let the newer navigation handle things - don't overwrite latestLocation
2083
+ if (this.history.location.href !== nextHistory.publicHref) {
2084
+ return this.commitLocationPromise
2085
+ }
2086
+
2087
+ // Success: set latestLocation directly (we skip updateLatestLocation in load)
2088
+ this.latestLocation = precomputedLocation as unknown as ParsedLocation<
2089
+ FullSearchSchema<TRouteTree>
2090
+ >
2091
+
2092
+ // Call load() with _skipUpdateLatestLocation since we already set latestLocation
2093
+ this.load({ _skipUpdateLatestLocation: true })
2094
+
1961
2095
  return this.commitLocationPromise
1962
2096
  }
1963
2097
 
@@ -2109,10 +2243,14 @@ export class RouterCore<
2109
2243
 
2110
2244
  latestLoadPromise: undefined | Promise<void>
2111
2245
 
2112
- beforeLoad = () => {
2246
+ beforeLoad = (opts?: { _skipUpdateLatestLocation?: boolean }) => {
2113
2247
  // Cancel any pending matches
2114
2248
  this.cancelMatches()
2115
- this.updateLatestLocation()
2249
+ if (!opts?._skipUpdateLatestLocation) {
2250
+ this.updateLatestLocation()
2251
+ } else {
2252
+ // Already have latestLocation from commitLocation, skip parsing
2253
+ }
2116
2254
 
2117
2255
  if (this.isServer) {
2118
2256
  // for SPAs on the initial load, this is handled by the Transitioner
@@ -2136,7 +2274,12 @@ export class RouterCore<
2136
2274
  }
2137
2275
 
2138
2276
  // Match the routes
2139
- const pendingMatches = this.matchRoutes(this.latestLocation)
2277
+ // Use snapshot from history state for fast-path only within same router lifetime
2278
+ const snapshot =
2279
+ this.latestLocation.state.__TSR_sessionId === this.sessionId
2280
+ ? this.latestLocation.state.__TSR_matches
2281
+ : undefined
2282
+ const pendingMatches = this.matchRoutes(this.latestLocation, { snapshot })
2140
2283
 
2141
2284
  // Ingest the new matches
2142
2285
  this.__store.setState((s) => ({
@@ -2153,7 +2296,10 @@ export class RouterCore<
2153
2296
  }))
2154
2297
  }
2155
2298
 
2156
- load: LoadFn = async (opts?: { sync?: boolean }): Promise<void> => {
2299
+ load: LoadFn = async (opts?: {
2300
+ sync?: boolean
2301
+ _skipUpdateLatestLocation?: boolean
2302
+ }): Promise<void> => {
2157
2303
  let redirect: AnyRedirect | undefined
2158
2304
  let notFound: NotFoundError | undefined
2159
2305
  let loadPromise: Promise<void>
@@ -2162,7 +2308,9 @@ export class RouterCore<
2162
2308
  loadPromise = new Promise<void>((resolve) => {
2163
2309
  this.startTransition(async () => {
2164
2310
  try {
2165
- this.beforeLoad()
2311
+ this.beforeLoad({
2312
+ _skipUpdateLatestLocation: opts?._skipUpdateLatestLocation,
2313
+ })
2166
2314
  const next = this.latestLocation
2167
2315
  const prevLocation = this.state.resolvedLocation
2168
2316
 
@@ -2770,7 +2918,7 @@ export function getMatchedRoutes<TRouteLike extends RouteLike>({
2770
2918
  const trimmedPath = trimPathRight(pathname)
2771
2919
 
2772
2920
  let foundRoute: TRouteLike | undefined = undefined
2773
- let parsedParams: Record<string, unknown> | undefined = undefined
2921
+ let parsedParams: Record<string, unknown> = {}
2774
2922
  const match = findRouteMatch<TRouteLike>(trimmedPath, processedTree, true)
2775
2923
  if (match) {
2776
2924
  foundRoute = match.route
@@ -2783,6 +2931,96 @@ export function getMatchedRoutes<TRouteLike extends RouteLike>({
2783
2931
  return { matchedRoutes, routeParams, foundRoute, parsedParams }
2784
2932
  }
2785
2933
 
2934
+ /**
2935
+ * Build a MatchSnapshot from a getMatchedRoutes result.
2936
+ * Determines globalNotFoundRouteId using the same logic as matchRoutesInternal.
2937
+ */
2938
+ export function buildMatchSnapshot({
2939
+ matchResult,
2940
+ pathname,
2941
+ searchStr,
2942
+ notFoundRoute,
2943
+ notFoundMode,
2944
+ }: {
2945
+ matchResult: ReturnType<typeof getMatchedRoutes>
2946
+ pathname: string
2947
+ searchStr?: string
2948
+ notFoundRoute?: AnyRoute
2949
+ notFoundMode?: 'root' | 'fuzzy'
2950
+ }): MatchSnapshot {
2951
+ const snapshot: MatchSnapshot = {
2952
+ routeIds: matchResult.matchedRoutes.map((r) => r.id),
2953
+ params: matchResult.routeParams,
2954
+ parsedParams: matchResult.parsedParams,
2955
+ searchStr,
2956
+ }
2957
+
2958
+ const isGlobalNotFound = matchResult.foundRoute
2959
+ ? matchResult.foundRoute.path !== '/' && matchResult.routeParams['**']
2960
+ : trimPathRight(pathname)
2961
+
2962
+ if (isGlobalNotFound) {
2963
+ if (notFoundRoute) {
2964
+ // Custom notFoundRoute provided - use its id
2965
+ snapshot.globalNotFoundRouteId = notFoundRoute.id
2966
+ } else {
2967
+ if (notFoundMode !== 'root') {
2968
+ for (let i = matchResult.matchedRoutes.length - 1; i >= 0; i--) {
2969
+ const route = matchResult.matchedRoutes[i]!
2970
+ if (route.children) {
2971
+ snapshot.globalNotFoundRouteId = route.id
2972
+ break
2973
+ }
2974
+ }
2975
+ }
2976
+ if (!snapshot.globalNotFoundRouteId) {
2977
+ snapshot.globalNotFoundRouteId = rootRouteId
2978
+ }
2979
+ }
2980
+ }
2981
+
2982
+ return snapshot
2983
+ }
2984
+
2985
+ /**
2986
+ * Build a MatchSnapshot from routes and params directly.
2987
+ * Used by buildLocation to avoid duplicate getMatchedRoutes call.
2988
+ */
2989
+ export function buildMatchSnapshotFromRoutes({
2990
+ routes,
2991
+ params,
2992
+ searchStr,
2993
+ globalNotFoundRouteId,
2994
+ }: {
2995
+ routes: ReadonlyArray<AnyRoute>
2996
+ params: Record<string, unknown>
2997
+ searchStr?: string
2998
+ globalNotFoundRouteId?: string
2999
+ }): MatchSnapshot {
3000
+ // Convert all params to strings for snapshot storage
3001
+ // (params from path matching are always strings)
3002
+ const stringParams: Record<string, string> = {}
3003
+ for (const key in params) {
3004
+ const value = params[key]
3005
+ if (value != null) {
3006
+ stringParams[key] = String(value)
3007
+ }
3008
+ }
3009
+
3010
+ const snapshot: MatchSnapshot = {
3011
+ routeIds: routes.map((r) => r.id),
3012
+ params: stringParams,
3013
+ parsedParams: params,
3014
+ searchStr,
3015
+ }
3016
+
3017
+ if (globalNotFoundRouteId) {
3018
+ snapshot.globalNotFoundRouteId = globalNotFoundRouteId
3019
+ }
3020
+
3021
+ return snapshot
3022
+ }
3023
+
2786
3024
  function applySearchMiddleware({
2787
3025
  search,
2788
3026
  dest,
@@ -340,8 +340,8 @@ export function setupScrollRestoration(router: AnyRouter, force?: boolean) {
340
340
 
341
341
  // If the user doesn't want to restore the scroll position,
342
342
  // we don't need to do anything.
343
- if (!router.resetNextScroll) {
344
- router.resetNextScroll = true
343
+ const resetScroll = event.toLocation.state.__TSR_resetScroll ?? true
344
+ if (!resetScroll) {
345
345
  return
346
346
  }
347
347
  if (typeof router.options.scrollRestoration === 'function') {