@tanstack/router-core 1.171.15 → 1.171.16-pre.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.
Files changed (134) hide show
  1. package/dist/cjs/Matches.cjs.map +1 -1
  2. package/dist/cjs/Matches.d.cts +3 -12
  3. package/dist/cjs/await-signal.cjs +21 -0
  4. package/dist/cjs/await-signal.cjs.map +1 -0
  5. package/dist/cjs/await-signal.d.cts +1 -0
  6. package/dist/cjs/index.cjs +3 -1
  7. package/dist/cjs/index.d.cts +3 -2
  8. package/dist/cjs/isServer/client.cjs +2 -0
  9. package/dist/cjs/isServer/client.cjs.map +1 -1
  10. package/dist/cjs/isServer/client.d.cts +1 -0
  11. package/dist/cjs/isServer/development.cjs +2 -0
  12. package/dist/cjs/isServer/development.cjs.map +1 -1
  13. package/dist/cjs/isServer/development.d.cts +1 -0
  14. package/dist/cjs/isServer/server.cjs +2 -0
  15. package/dist/cjs/isServer/server.cjs.map +1 -1
  16. package/dist/cjs/isServer/server.d.cts +1 -0
  17. package/dist/cjs/load-client.cjs +1470 -0
  18. package/dist/cjs/load-client.cjs.map +1 -0
  19. package/dist/cjs/load-client.d.cts +147 -0
  20. package/dist/cjs/load-server.cjs +565 -0
  21. package/dist/cjs/load-server.cjs.map +1 -0
  22. package/dist/cjs/load-server.d.cts +16 -0
  23. package/dist/cjs/redirect.cjs.map +1 -1
  24. package/dist/cjs/redirect.d.cts +0 -1
  25. package/dist/cjs/route.cjs.map +1 -1
  26. package/dist/cjs/router.cjs +187 -442
  27. package/dist/cjs/router.cjs.map +1 -1
  28. package/dist/cjs/router.d.cts +61 -51
  29. package/dist/cjs/ssr/client.cjs +2 -2
  30. package/dist/cjs/ssr/createRequestHandler.cjs +60 -9
  31. package/dist/cjs/ssr/createRequestHandler.cjs.map +1 -1
  32. package/dist/cjs/ssr/createRequestHandler.d.cts +1 -0
  33. package/dist/cjs/ssr/handlerCallback.cjs +49 -4
  34. package/dist/cjs/ssr/handlerCallback.cjs.map +1 -1
  35. package/dist/cjs/ssr/handlerCallback.d.cts +3 -0
  36. package/dist/cjs/ssr/server.cjs +4 -0
  37. package/dist/cjs/ssr/server.d.cts +2 -2
  38. package/dist/cjs/ssr/ssr-client.d.cts +1 -10
  39. package/dist/cjs/ssr/ssr-match-id.cjs +2 -2
  40. package/dist/cjs/ssr/ssr-match-id.cjs.map +1 -1
  41. package/dist/cjs/ssr/ssr-server.cjs +6 -6
  42. package/dist/cjs/ssr/ssr-server.cjs.map +1 -1
  43. package/dist/cjs/ssr/transformStreamWithRouter.cjs +27 -1
  44. package/dist/cjs/ssr/transformStreamWithRouter.cjs.map +1 -1
  45. package/dist/cjs/ssr/transformStreamWithRouter.d.cts +2 -0
  46. package/dist/cjs/ssr/types.d.cts +0 -1
  47. package/dist/cjs/stores.cjs +30 -104
  48. package/dist/cjs/stores.cjs.map +1 -1
  49. package/dist/cjs/stores.d.cts +7 -32
  50. package/dist/cjs/utils.cjs +0 -4
  51. package/dist/cjs/utils.cjs.map +1 -1
  52. package/dist/esm/Matches.d.ts +3 -12
  53. package/dist/esm/Matches.js.map +1 -1
  54. package/dist/esm/await-signal.d.ts +1 -0
  55. package/dist/esm/await-signal.js +21 -0
  56. package/dist/esm/await-signal.js.map +1 -0
  57. package/dist/esm/index.d.ts +3 -2
  58. package/dist/esm/index.js +3 -2
  59. package/dist/esm/isServer/client.d.ts +1 -0
  60. package/dist/esm/isServer/client.js +2 -1
  61. package/dist/esm/isServer/client.js.map +1 -1
  62. package/dist/esm/isServer/development.d.ts +1 -0
  63. package/dist/esm/isServer/development.js +2 -1
  64. package/dist/esm/isServer/development.js.map +1 -1
  65. package/dist/esm/isServer/server.d.ts +1 -0
  66. package/dist/esm/isServer/server.js +2 -1
  67. package/dist/esm/isServer/server.js.map +1 -1
  68. package/dist/esm/load-client.d.ts +147 -0
  69. package/dist/esm/load-client.js +1462 -0
  70. package/dist/esm/load-client.js.map +1 -0
  71. package/dist/esm/load-server.d.ts +16 -0
  72. package/dist/esm/load-server.js +565 -0
  73. package/dist/esm/load-server.js.map +1 -0
  74. package/dist/esm/redirect.d.ts +0 -1
  75. package/dist/esm/redirect.js.map +1 -1
  76. package/dist/esm/route.js.map +1 -1
  77. package/dist/esm/router.d.ts +61 -51
  78. package/dist/esm/router.js +188 -444
  79. package/dist/esm/router.js.map +1 -1
  80. package/dist/esm/ssr/client.js +1 -1
  81. package/dist/esm/ssr/createRequestHandler.d.ts +1 -0
  82. package/dist/esm/ssr/createRequestHandler.js +61 -11
  83. package/dist/esm/ssr/createRequestHandler.js.map +1 -1
  84. package/dist/esm/ssr/handlerCallback.d.ts +3 -0
  85. package/dist/esm/ssr/handlerCallback.js +47 -5
  86. package/dist/esm/ssr/handlerCallback.js.map +1 -1
  87. package/dist/esm/ssr/server.d.ts +2 -2
  88. package/dist/esm/ssr/server.js +3 -3
  89. package/dist/esm/ssr/ssr-client.d.ts +1 -10
  90. package/dist/esm/ssr/ssr-match-id.js +2 -2
  91. package/dist/esm/ssr/ssr-match-id.js.map +1 -1
  92. package/dist/esm/ssr/ssr-server.js +6 -6
  93. package/dist/esm/ssr/ssr-server.js.map +1 -1
  94. package/dist/esm/ssr/transformStreamWithRouter.d.ts +2 -0
  95. package/dist/esm/ssr/transformStreamWithRouter.js +27 -1
  96. package/dist/esm/ssr/transformStreamWithRouter.js.map +1 -1
  97. package/dist/esm/ssr/types.d.ts +0 -1
  98. package/dist/esm/stores.d.ts +7 -32
  99. package/dist/esm/stores.js +30 -104
  100. package/dist/esm/stores.js.map +1 -1
  101. package/dist/esm/utils.js +1 -4
  102. package/dist/esm/utils.js.map +1 -1
  103. package/package.json +3 -3
  104. package/src/Matches.ts +6 -21
  105. package/src/await-signal.ts +27 -0
  106. package/src/index.ts +1 -3
  107. package/src/isServer/client.ts +1 -0
  108. package/src/isServer/development.ts +1 -0
  109. package/src/isServer/server.ts +1 -0
  110. package/src/load-client.ts +2701 -0
  111. package/src/load-server.ts +849 -0
  112. package/src/redirect.ts +0 -1
  113. package/src/route.ts +2 -11
  114. package/src/router.ts +379 -769
  115. package/src/ssr/createRequestHandler.ts +109 -16
  116. package/src/ssr/handlerCallback.ts +76 -10
  117. package/src/ssr/server.ts +4 -1
  118. package/src/ssr/ssr-client.ts +1 -310
  119. package/src/ssr/ssr-match-id.ts +11 -2
  120. package/src/ssr/ssr-server.ts +9 -8
  121. package/src/ssr/transformStreamWithRouter.ts +37 -1
  122. package/src/ssr/types.ts +0 -1
  123. package/src/stores.ts +46 -208
  124. package/dist/cjs/load-matches.cjs +0 -658
  125. package/dist/cjs/load-matches.cjs.map +0 -1
  126. package/dist/cjs/load-matches.d.cts +0 -18
  127. package/dist/cjs/ssr/ssr-client.cjs +0 -183
  128. package/dist/cjs/ssr/ssr-client.cjs.map +0 -1
  129. package/dist/esm/load-matches.d.ts +0 -18
  130. package/dist/esm/load-matches.js +0 -656
  131. package/dist/esm/load-matches.js.map +0 -1
  132. package/dist/esm/ssr/ssr-client.js +0 -183
  133. package/dist/esm/ssr/ssr-client.js.map +0 -1
  134. package/src/load-matches.ts +0 -1278
package/src/router.ts CHANGED
@@ -1,8 +1,7 @@
1
1
  import { createBrowserHistory, parseHref } from '@tanstack/history'
2
- import { isServer } from '@tanstack/router-core/isServer'
2
+ import { isServer, loadServerRoute } from '@tanstack/router-core/isServer'
3
3
  import {
4
4
  DEFAULT_PROTOCOL_ALLOWLIST,
5
- createControlledPromise,
6
5
  decodePath,
7
6
  deepEqual,
8
7
  encodePathLikeUrl,
@@ -35,8 +34,15 @@ import { isNotFound } from './not-found'
35
34
  import { setupScrollRestoration } from './scroll-restoration'
36
35
  import { defaultParseSearch, defaultStringifySearch } from './searchParams'
37
36
  import { rootRouteId } from './root'
38
- import { isRedirect, redirect } from './redirect'
39
- import { loadMatches, loadRouteChunk, routeNeedsPreload } from './load-matches'
37
+ import { isRedirect } from './redirect'
38
+ import {
39
+ loadClientRoute,
40
+ loadRouteChunk,
41
+ preloadClientRoute,
42
+ refreshClientRoute,
43
+ replaceRouteChunk,
44
+ transferMatchResources,
45
+ } from './load-client'
40
46
  import {
41
47
  composeRewrites,
42
48
  executeRewriteInput,
@@ -51,6 +57,13 @@ import type {
51
57
  } from './new-process-route-tree'
52
58
  import type { SearchParser, SearchSerializer } from './searchParams'
53
59
  import type { AnyRedirect, ResolvedRedirect } from './redirect'
60
+ import type {
61
+ ActivePreload,
62
+ LoadTransaction,
63
+ LoaderFlight,
64
+ PendingSession,
65
+ } from './load-client'
66
+ import type { ServerLoadResult } from './load-server'
54
67
  import type {
55
68
  HistoryAction,
56
69
  HistoryLocation,
@@ -62,7 +75,6 @@ import type {
62
75
  import type {
63
76
  Awaitable,
64
77
  Constrain,
65
- ControlledPromise,
66
78
  NoInfer,
67
79
  NonNullableUpdater,
68
80
  PickAsRequired,
@@ -75,8 +87,6 @@ import type {
75
87
  AnyRouteWithContext,
76
88
  LoaderStaleReloadMode,
77
89
  MakeRemountDepsOptionsUnion,
78
- RouteContextOptions,
79
- RouteLike,
80
90
  RouteMask,
81
91
  SearchMiddleware,
82
92
  SearchMiddlewareMeta,
@@ -106,7 +116,6 @@ import type {
106
116
  } from './manifest'
107
117
  import type { AnySchema, AnyValidator } from './validators'
108
118
  import type { NavigateOptions, ResolveRelativePath, ToOptions } from './link'
109
- import type { NotFoundError } from './not-found'
110
119
  import type {
111
120
  AnySerializationAdapter,
112
121
  ValidateSerializableInput,
@@ -263,9 +272,9 @@ export interface RouterOptions<
263
272
  */
264
273
  defaultPreloadStaleTime?: number
265
274
  /**
266
- * The default `defaultPreloadGcTime` a route should use if no preloadGcTime is provided.
275
+ * The default `preloadGcTime` a route should use if none is provided.
267
276
  *
268
- * @default 1_800_000 `(30 minutes)`
277
+ * @default 300_000 `(5 minutes)`
269
278
  * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#defaultpreloadgctime-property)
270
279
  * @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/preloading)
271
280
  */
@@ -297,7 +306,7 @@ export interface RouterOptions<
297
306
  /**
298
307
  * The default `gcTime` a route should use if no gcTime is provided.
299
308
  *
300
- * @default 1_800_000 `(30 minutes)`
309
+ * @default 300_000 `(5 minutes)`
301
310
  * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#defaultgctime-property)
302
311
  * @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#key-options)
303
312
  */
@@ -544,14 +553,10 @@ export interface RouterState<
544
553
  in out TRouteMatch = MakeRouteMatchUnion,
545
554
  > {
546
555
  status: 'pending' | 'idle'
547
- loadedAt: number
548
556
  isLoading: boolean
549
- isTransitioning: boolean
550
557
  matches: Array<TRouteMatch>
551
558
  location: ParsedLocation<FullSearchSchema<TRouteTree>>
552
559
  resolvedLocation?: ParsedLocation<FullSearchSchema<TRouteTree>>
553
- statusCode: number
554
- redirect?: AnyRedirect
555
560
  }
556
561
 
557
562
  export interface BuildNextOptions {
@@ -619,9 +624,21 @@ export type SubscribeFn = <TType extends keyof RouterEvents>(
619
624
  ) => () => void
620
625
 
621
626
  export interface MatchRoutesOpts {
622
- preload?: boolean
623
627
  throwOnError?: boolean
624
- dest?: BuildNextOptions
628
+ /** @internal */
629
+ _controller?: AbortController
630
+ /** @internal */
631
+ _rematerialize?: boolean
632
+ }
633
+
634
+ function routeNeedsLoad(route: AnyRoute): unknown {
635
+ return (
636
+ route.options.loader ||
637
+ route.options.beforeLoad ||
638
+ route.lazyFn ||
639
+ (route.options.component as any)?.preload ||
640
+ (route.options.pendingComponent as any)?.preload
641
+ )
625
642
  }
626
643
 
627
644
  export type InferRouterContext<TRouteTree extends AnyRoute> =
@@ -748,6 +765,8 @@ export type EmitFn = (routerEvent: RouterEvent) => void
748
765
  export type LoadFn = (opts?: {
749
766
  sync?: boolean
750
767
  action?: { type: HistoryAction }
768
+ _signal?: AbortSignal
769
+ _dedupe?: boolean
751
770
  }) => Promise<void>
752
771
 
753
772
  export type CommitLocationFn = ({
@@ -756,7 +775,11 @@ export type CommitLocationFn = ({
756
775
  ...next
757
776
  }: ParsedLocation & CommitLocationOptions) => Promise<void>
758
777
 
759
- export type StartTransitionFn = (fn: () => void) => void
778
+ export type StartTransitionFn = (
779
+ fn: () => void,
780
+ expected: Array<AnyRouteMatch>,
781
+ urgent?: boolean,
782
+ ) => Promise<boolean>
760
783
 
761
784
  export interface MatchRoutesFn {
762
785
  (
@@ -775,13 +798,6 @@ export interface MatchRoutesFn {
775
798
  ): Array<AnyRouteMatch>
776
799
  }
777
800
 
778
- export type GetMatchFn = (matchId: string) => AnyRouteMatch | undefined
779
-
780
- export type UpdateMatchFn = (
781
- id: string,
782
- updater: (match: AnyRouteMatch) => AnyRouteMatch,
783
- ) => void
784
-
785
801
  export type LoadRouteChunkFn = (route: AnyRoute) => Promise<Array<void>>
786
802
 
787
803
  export type ResolveRedirect = (err: AnyRedirect) => ResolvedRedirect
@@ -896,12 +912,60 @@ export function getLocationChangeInfo(
896
912
  location: ParsedLocation,
897
913
  resolvedLocation?: ParsedLocation,
898
914
  ) {
899
- const fromLocation = resolvedLocation
900
- const toLocation = location
901
- const pathChanged = fromLocation?.pathname !== toLocation.pathname
902
- const hrefChanged = fromLocation?.href !== toLocation.href
903
- const hashChanged = fromLocation?.hash !== toLocation.hash
904
- return { fromLocation, toLocation, pathChanged, hrefChanged, hashChanged }
915
+ return {
916
+ fromLocation: resolvedLocation,
917
+ toLocation: location,
918
+ pathChanged: resolvedLocation?.pathname !== location.pathname,
919
+ hrefChanged: resolvedLocation?.href !== location.href,
920
+ hashChanged: resolvedLocation?.hash !== location.hash,
921
+ }
922
+ }
923
+
924
+ /**
925
+ * Return only state owned by the application, excluding volatile history
926
+ * bookkeeping. Mask payloads (`__tempLocation`/`__tempKey`) are kept: they
927
+ * distinguish otherwise-identical locations.
928
+ */
929
+ export function _getUserHistoryState({
930
+ key: _key,
931
+ __TSR_key: _tsrKey,
932
+ __TSR_index: _tsrIndex,
933
+ __hashScrollIntoViewOptions: _hashScroll,
934
+ ...state
935
+ }: ParsedHistoryState): HistoryState {
936
+ return state
937
+ }
938
+
939
+ /** Run route lifecycle callbacks in leave/enter/stay phases. */
940
+ export function runRouteLifecycle(
941
+ router: AnyRouter,
942
+ previous: Array<AnyRouteMatch>,
943
+ matches: Array<AnyRouteMatch>,
944
+ isCurrent?: () => boolean,
945
+ ): void {
946
+ for (const match of previous) {
947
+ if (isCurrent?.() === false) {
948
+ return
949
+ }
950
+ if (!matches.some((candidate) => candidate.routeId === match.routeId)) {
951
+ ;(router.routesById as Record<string, AnyRoute>)[
952
+ match.routeId
953
+ ]!.options.onLeave?.(match)
954
+ }
955
+ }
956
+ for (const match of matches) {
957
+ if (isCurrent?.() === false) {
958
+ return
959
+ }
960
+ const route = (router.routesById as Record<string, AnyRoute>)[
961
+ match.routeId
962
+ ]!
963
+ route.options[
964
+ previous.some((candidate) => candidate.routeId === match.routeId)
965
+ ? 'onStay'
966
+ : 'onEnter'
967
+ ]?.(match)
968
+ }
905
969
  }
906
970
 
907
971
  type LightweightRouteMatchResult = {
@@ -951,6 +1015,43 @@ declare global {
951
1015
  | undefined
952
1016
  }
953
1017
 
1018
+ export interface RouterCore<
1019
+ in out TRouteTree extends AnyRoute,
1020
+ in out TTrailingSlashOption extends TrailingSlashOption,
1021
+ in out TDefaultStructuralSharingOption extends boolean,
1022
+ in out TRouterHistory extends RouterHistory = RouterHistory,
1023
+ in out TDehydrated extends Record<string, any> = Record<string, any>,
1024
+ > {
1025
+ shouldViewTransition?: boolean | ViewTransitionOptions
1026
+ /** Current client load transaction and owner of navigation writes. */
1027
+ _tx?: LoadTransaction
1028
+ /** Joinable in-flight loader generations keyed by match ID. */
1029
+ _flights?: Map<string, LoaderFlight>
1030
+ /** Whole speculative lanes that an identical navigation may adopt. */
1031
+ _preloads?: Map<string, ActivePreload>
1032
+ /** Owns cancellable work before a client transaction publishes. */
1033
+ _preflight?: AbortController
1034
+ /** Transfers one reconstructed SSR prefix into its initial client load. */
1035
+ _handoff?: HydrationHandoff
1036
+ /** Pending-boundary reveal and minimum-visible timing state. */
1037
+ _pending?: PendingSession
1038
+ /** Result of the latest server load, used to render or redirect. */
1039
+ _serverResult?: ServerLoadResult
1040
+ /** Framework callback that acknowledges an exact matches publication. */
1041
+ _rendered?: (matches: Array<AnyRouteMatch>) => void
1042
+ /** Development-only HMR reload for a route and its descendants. */
1043
+ _refreshRoute: (() => Promise<void>) | undefined
1044
+ /** Development-only replacement for a route's lazy chunk owner. */
1045
+ _replaceRouteChunk:
1046
+ | ((route: AnyRoute, lazyFn: AnyRoute['lazyFn']) => void)
1047
+ | undefined
1048
+ }
1049
+
1050
+ export type HydrationHandoff = [
1051
+ claim: () => AbortController | undefined,
1052
+ finish: (matches?: Array<AnyRouteMatch>) => number | undefined,
1053
+ ]
1054
+
954
1055
  /**
955
1056
  * Core, framework-agnostic router engine that powers TanStack Router.
956
1057
  *
@@ -979,10 +1080,11 @@ export class RouterCore<
979
1080
  restoration?: boolean
980
1081
  reset?: boolean
981
1082
  } = { next: true }
982
- shouldViewTransition?: boolean | ViewTransitionOptions = undefined
983
- isViewTransitionTypesSupported?: boolean = undefined
984
1083
  subscribers = new Set<RouterListener<RouterEvent>>()
985
- viewTransitionPromise?: ControlledPromise<true>
1084
+ /** Accepted off-screen loader generations keyed by match ID. */
1085
+ _cache = new Map<string, AnyRouteMatch>()
1086
+ /** Accepted semantic lane, excluding temporary pending presentation. */
1087
+ _committed: Array<AnyRouteMatch> = []
986
1088
 
987
1089
  // Must build in constructor
988
1090
  stores!: RouterStores<TRouteTree>
@@ -1003,7 +1105,7 @@ export class RouterCore<
1003
1105
  rewrite?: LocationRewrite
1004
1106
  origin?: string
1005
1107
  latestLocation!: ParsedLocation<FullSearchSchema<TRouteTree>>
1006
- pendingBuiltLocation?: ParsedLocation<FullSearchSchema<TRouteTree>>
1108
+ _pendingLocation?: ParsedLocation<FullSearchSchema<TRouteTree>>
1007
1109
  basepath!: string
1008
1110
  routeTree!: TRouteTree
1009
1111
  routesById!: RoutesById<TRouteTree>
@@ -1048,24 +1150,19 @@ export class RouterCore<
1048
1150
  options.protocolAllowlist ?? DEFAULT_PROTOCOL_ALLOWLIST,
1049
1151
  })
1050
1152
 
1051
- if (typeof document !== 'undefined') {
1153
+ if (!(isServer ?? typeof document === 'undefined')) {
1052
1154
  self.__TSR_ROUTER__ = this
1053
1155
  }
1054
1156
  }
1055
1157
 
1056
- // This is a default implementation that can optionally be overridden
1057
- // by the router provider once rendered. We provide this so that the
1058
- // router can be used in a non-react environment if necessary
1059
- startTransition: StartTransitionFn = (fn) => fn()
1060
-
1158
+ startTransition: StartTransitionFn = async (fn) => {
1159
+ fn()
1160
+ return false
1161
+ }
1061
1162
  isShell() {
1062
1163
  return !!this.options.isShell
1063
1164
  }
1064
1165
 
1065
- isPrerendering() {
1066
- return !!this.options.isPrerendering
1067
- }
1068
-
1069
1166
  update: UpdateFn<
1070
1167
  TRouteTree,
1071
1168
  TTrailingSlashOption,
@@ -1091,7 +1188,8 @@ export class RouterCore<
1091
1188
  ...newOptions,
1092
1189
  }
1093
1190
 
1094
- this.isServer = this.options.isServer ?? typeof document === 'undefined'
1191
+ this.isServer =
1192
+ this.options.isServer ?? isServer ?? typeof document === 'undefined'
1095
1193
 
1096
1194
  this.protocolAllowlist = new Set(this.options.protocolAllowlist)
1097
1195
 
@@ -1165,17 +1263,13 @@ export class RouterCore<
1165
1263
  if (!this.stores && this.latestLocation) {
1166
1264
  const config = this.getStoreConfig(this)
1167
1265
  this.batch = config.batch
1168
- this.stores = createRouterStores(
1169
- getInitialRouterState(this.latestLocation),
1170
- config,
1171
- )
1266
+ this.stores = createRouterStores(this.latestLocation, config)
1172
1267
 
1173
1268
  if (!(isServer ?? this.isServer)) {
1174
1269
  setupScrollRestoration(this)
1175
1270
  }
1176
1271
  }
1177
1272
 
1178
- let needsLocationUpdate = false
1179
1273
  const nextBasepath = this.options.basepath ?? '/'
1180
1274
  const nextRewriteOption = this.options.rewrite
1181
1275
  const basepathChanged = basepathWasUnset || prevBasepath !== nextBasepath
@@ -1208,21 +1302,9 @@ export class RouterCore<
1208
1302
  this.updateLatestLocation()
1209
1303
  }
1210
1304
 
1211
- needsLocationUpdate = true
1212
- }
1213
-
1214
- if (needsLocationUpdate && this.stores) {
1215
- this.stores.location.set(this.latestLocation)
1216
- }
1217
-
1218
- if (
1219
- typeof window !== 'undefined' &&
1220
- 'CSS' in window &&
1221
- typeof window.CSS?.supports === 'function'
1222
- ) {
1223
- this.isViewTransitionTypesSupported = window.CSS.supports(
1224
- 'selector(:active-view-transition-type(a))',
1225
- )
1305
+ if (this.stores) {
1306
+ this.stores.location.set(this.latestLocation)
1307
+ }
1226
1308
  }
1227
1309
  }
1228
1310
 
@@ -1293,11 +1375,15 @@ export class RouterCore<
1293
1375
  }
1294
1376
 
1295
1377
  emit: EmitFn = (routerEvent) => {
1296
- this.subscribers.forEach((listener) => {
1378
+ for (const listener of this.subscribers) {
1297
1379
  if (listener.eventType === routerEvent.type) {
1298
- listener.fn(routerEvent)
1380
+ try {
1381
+ listener.fn(routerEvent)
1382
+ } catch (e) {
1383
+ console.error(e)
1384
+ }
1299
1385
  }
1300
- })
1386
+ }
1301
1387
  }
1302
1388
 
1303
1389
  /**
@@ -1405,10 +1491,6 @@ export class RouterCore<
1405
1491
  return branch
1406
1492
  }
1407
1493
 
1408
- get looseRoutesById() {
1409
- return this.routesById as Record<string, AnyRoute>
1410
- }
1411
-
1412
1494
  matchRoutes: MatchRoutesFn = (
1413
1495
  pathnameOrNext: string | ParsedLocation,
1414
1496
  locationSearchOrOpts?: AnySchema | MatchRoutesOpts,
@@ -1427,16 +1509,6 @@ export class RouterCore<
1427
1509
  return this.matchRoutesInternal(pathnameOrNext, locationSearchOrOpts)
1428
1510
  }
1429
1511
 
1430
- private getParentContext(parentMatch?: AnyRouteMatch) {
1431
- const parentMatchId = parentMatch?.id
1432
-
1433
- const parentContext = !parentMatchId
1434
- ? ((this.options.context as any) ?? undefined)
1435
- : (parentMatch.context ?? this.options.context ?? undefined)
1436
-
1437
- return parentContext
1438
- }
1439
-
1440
1512
  private matchRoutesInternal(
1441
1513
  next: ParsedLocation,
1442
1514
  opts?: MatchRoutesOpts,
@@ -1463,18 +1535,19 @@ export class RouterCore<
1463
1535
  }
1464
1536
  }
1465
1537
 
1466
- const globalNotFoundRouteId = isGlobalNotFound
1538
+ const _notFoundRouteId = isGlobalNotFound
1467
1539
  ? findGlobalNotFoundRouteId(this.options.notFoundMode, matchedRoutes)
1468
1540
  : undefined
1469
1541
 
1470
1542
  const matches = new Array<AnyRouteMatch>(matchedRoutes.length)
1471
- // Snapshot of active match state keyed by routeId, used to stabilise
1472
- // params/search across navigations.
1473
- const previousActiveMatchesByRouteId = new Map<string, AnyRouteMatch>()
1474
- for (const store of this.stores.matchStores.values()) {
1475
- if (store.routeId) {
1476
- previousActiveMatchesByRouteId.set(store.routeId, store.get())
1477
- }
1543
+ const committed = this._committed
1544
+ const previousAt = (route: AnyRoute, index: number) => {
1545
+ const match = committed[index]
1546
+ return match?.routeId === route.id
1547
+ ? match
1548
+ : route === this.options.notFoundRoute
1549
+ ? committed.find((candidate) => candidate.routeId === route.id)
1550
+ : undefined
1478
1551
  }
1479
1552
 
1480
1553
  for (let index = 0; index < matchedRoutes.length; index++) {
@@ -1506,7 +1579,6 @@ export class RouterCore<
1506
1579
  ...strictSearch,
1507
1580
  }
1508
1581
  strictMatchSearch = { ...parentStrictSearch, ...strictSearch }
1509
- searchError = undefined
1510
1582
  } catch (err: any) {
1511
1583
  let searchParamError = err
1512
1584
  if (!(err instanceof SearchParamError)) {
@@ -1524,19 +1596,25 @@ export class RouterCore<
1524
1596
  searchError = searchParamError
1525
1597
  }
1526
1598
  }
1527
-
1528
1599
  // This is where we need to call route.options.loaderDeps() to get any additional
1529
1600
  // deps that the route's loader function might need to run. We need to do this
1530
1601
  // before we create the match so that we can pass the deps to the route's
1531
1602
  // potential key function which is used to uniquely identify the route match in state
1532
1603
 
1533
- const loaderDeps =
1534
- route.options.loaderDeps?.({
1535
- search: preMatchSearch,
1536
- }) ?? ''
1537
-
1538
- const loaderDepsHash = loaderDeps ? JSON.stringify(loaderDeps) : ''
1539
-
1604
+ let loaderDeps: any = ''
1605
+ let loaderDepsHash = ''
1606
+ try {
1607
+ loaderDeps =
1608
+ route.options.loaderDeps?.({
1609
+ search: preMatchSearch,
1610
+ }) ?? ''
1611
+ loaderDepsHash = loaderDeps ? JSON.stringify(loaderDeps) || '' : ''
1612
+ } catch (cause) {
1613
+ if (opts?.throwOnError) {
1614
+ throw cause
1615
+ }
1616
+ searchError ??= cause
1617
+ }
1540
1618
  const { interpolatedPath, usedParams } = interpolatePath({
1541
1619
  path: route.fullPath,
1542
1620
  params: routeParams,
@@ -1544,12 +1622,9 @@ export class RouterCore<
1544
1622
  server: this.isServer,
1545
1623
  })
1546
1624
 
1547
- // Waste not, want not. If we already have a match for this route,
1548
- // reuse it. This is important for layout routes, which might stick
1549
- // around between navigation actions that only change leaf routes.
1550
-
1551
- // Existing matches are matches that are already loaded along with
1552
- // pending matches that are still loading
1625
+ // Seed planning from the accepted same-ID cache generation first, then
1626
+ // from the committed generation for this route. Presentation stores are
1627
+ // deliberately not a semantic reuse authority.
1553
1628
  const matchId =
1554
1629
  // route.id for disambiguation
1555
1630
  route.id +
@@ -1558,13 +1633,16 @@ export class RouterCore<
1558
1633
  // explicit deps
1559
1634
  loaderDepsHash
1560
1635
 
1561
- const existingMatch = this.getMatch(matchId)
1562
-
1563
- const previousMatch = previousActiveMatchesByRouteId.get(route.id)
1636
+ const previousMatch = previousAt(route, index)
1637
+ const existingMatch =
1638
+ process.env.NODE_ENV !== 'production' && opts?._rematerialize
1639
+ ? undefined
1640
+ : (this._cache.get(matchId) ??
1641
+ (previousMatch?.id === matchId ? previousMatch : undefined))
1564
1642
 
1565
1643
  const strictParams = existingMatch?._strictParams ?? usedParams
1566
1644
 
1567
- let paramsError: unknown = undefined
1645
+ let paramsError: unknown
1568
1646
 
1569
1647
  if (!existingMatch) {
1570
1648
  try {
@@ -1600,15 +1678,10 @@ export class RouterCore<
1600
1678
  ? nullReplaceEqualDeep(previousMatch.search, preMatchSearch)
1601
1679
  : nullReplaceEqualDeep(existingMatch.search, preMatchSearch),
1602
1680
  _strictSearch: strictMatchSearch,
1681
+ searchError,
1603
1682
  }
1604
1683
  } else {
1605
- const status =
1606
- route.options.loader ||
1607
- route.options.beforeLoad ||
1608
- route.lazyFn ||
1609
- routeNeedsPreload(route)
1610
- ? 'pending'
1611
- : 'success'
1684
+ const status = routeNeedsLoad(route) ? 'pending' : 'success'
1612
1685
 
1613
1686
  match = {
1614
1687
  id: matchId,
@@ -1623,96 +1696,42 @@ export class RouterCore<
1623
1696
  ? nullReplaceEqualDeep(previousMatch.search, preMatchSearch)
1624
1697
  : preMatchSearch,
1625
1698
  _strictSearch: strictMatchSearch,
1626
- searchError: undefined,
1699
+ searchError,
1627
1700
  status,
1628
1701
  isFetching: false,
1629
1702
  error: undefined,
1630
1703
  paramsError,
1631
- __routeContext: undefined,
1632
- _nonReactive: {
1633
- loadPromise: createControlledPromise(),
1634
- },
1635
- __beforeLoadContext: undefined,
1636
1704
  context: {},
1637
- abortController: new AbortController(),
1638
- fetchCount: 0,
1705
+ abortController: opts?._controller ?? new AbortController(),
1639
1706
  cause,
1640
1707
  loaderDeps: previousMatch
1641
1708
  ? replaceEqualDeep(previousMatch.loaderDeps, loaderDeps)
1642
1709
  : loaderDeps,
1643
1710
  invalid: false,
1644
1711
  preload: false,
1645
- links: undefined,
1646
- scripts: undefined,
1647
- headScripts: undefined,
1648
- meta: undefined,
1649
1712
  staticData: route.options.staticData || {},
1650
1713
  fullPath: route.fullPath,
1651
1714
  }
1652
1715
  }
1653
1716
 
1654
- if (!opts?.preload) {
1655
- // If we have a global not found, mark the right match as global not found
1656
- match.globalNotFound = globalNotFoundRouteId === route.id
1657
- }
1658
-
1659
- // update the searchError if there is one
1660
- match.searchError = searchError
1661
-
1662
- const parentContext = this.getParentContext(parentMatch)
1663
-
1664
- match.context = {
1665
- ...parentContext,
1666
- ...match.__routeContext,
1667
- ...match.__beforeLoadContext,
1717
+ // If we have a global not found, mark the right match as global not found
1718
+ const _notFound = _notFoundRouteId === route.id
1719
+ if (match._notFound && !_notFound) {
1720
+ match.error = undefined
1668
1721
  }
1722
+ match._notFound = _notFound
1669
1723
 
1670
1724
  matches[index] = match
1671
1725
  }
1672
1726
 
1673
1727
  for (let index = 0; index < matches.length; index++) {
1674
1728
  const match = matches[index]!
1675
- const route = this.looseRoutesById[match.routeId]!
1676
- const existingMatch = this.getMatch(match.id)
1677
-
1678
- // Update the match's params
1679
- const previousMatch = previousActiveMatchesByRouteId.get(match.routeId)
1680
- match.params = previousMatch
1681
- ? nullReplaceEqualDeep(previousMatch.params, routeParams)
1682
- : routeParams
1683
-
1684
- if (!existingMatch) {
1685
- const parentMatch = matches[index - 1]
1686
- const parentContext = this.getParentContext(parentMatch)
1687
-
1688
- // Update the match's context
1689
-
1690
- if (route.options.context) {
1691
- const contextFnContext: RouteContextOptions<any, any, any, any, any> =
1692
- {
1693
- deps: match.loaderDeps,
1694
- params: match.params,
1695
- context: parentContext ?? {},
1696
- location: next,
1697
- navigate: (opts: any) =>
1698
- this.navigate({ ...opts, _fromLocation: next }),
1699
- buildLocation: this.buildLocation,
1700
- cause: match.cause,
1701
- abortController: match.abortController,
1702
- preload: !!match.preload,
1703
- matches,
1704
- routeId: route.id,
1705
- }
1706
- // Get the route context
1707
- match.__routeContext =
1708
- route.options.context(contextFnContext) ?? undefined
1709
- }
1710
-
1711
- match.context = {
1712
- ...parentContext,
1713
- ...match.__routeContext,
1714
- ...match.__beforeLoadContext,
1715
- }
1729
+ match.params =
1730
+ match.cause === 'stay'
1731
+ ? nullReplaceEqualDeep(match.params, routeParams)
1732
+ : routeParams
1733
+ if (opts?._controller) {
1734
+ match.context = {}
1716
1735
  }
1717
1736
  }
1718
1737
 
@@ -1720,22 +1739,35 @@ export class RouterCore<
1720
1739
  }
1721
1740
 
1722
1741
  getMatchedRoutes: GetMatchRoutesFn = (pathname) => {
1723
- return getMatchedRoutes({
1724
- pathname,
1725
- routesById: this.routesById,
1726
- processedTree: this.processedTree,
1727
- })
1742
+ const routeParams: Record<string, string> = Object.create(null)
1743
+ const match = findRouteMatch(
1744
+ trimPathRight(pathname),
1745
+ this.processedTree,
1746
+ true,
1747
+ )
1748
+ if (match) {
1749
+ Object.assign(routeParams, match.rawParams)
1750
+ }
1751
+ return {
1752
+ matchedRoutes: match?.branch || [this.routesById[rootRouteId]!],
1753
+ routeParams,
1754
+ foundRoute: match?.route,
1755
+ }
1728
1756
  }
1729
1757
 
1730
1758
  /**
1731
1759
  * Lightweight route matching for buildLocation.
1732
1760
  * Only computes fullPath, accumulated search, and params - skipping expensive
1733
- * operations like AbortController, ControlledPromise, loaderDeps, and full match objects.
1761
+ * operations like AbortController, loaderDeps, and full match objects.
1734
1762
  */
1735
1763
  private matchRoutesLightweight(
1736
1764
  location: ParsedLocation,
1737
1765
  ): LightweightRouteMatchResult {
1738
- const lastStateMatchId = last(this.stores.matchesId.get())
1766
+ const lastRouteId = last(this.stores.ids.get())
1767
+ const lastStateMatch = lastRouteId
1768
+ ? this.stores.byRoute.get(lastRouteId)!.get()
1769
+ : undefined
1770
+ const lastStateMatchId = lastStateMatch?.id
1739
1771
  const cached = this.lightweightCache.get(location)
1740
1772
  if (cached && cached[0] === lastStateMatchId) {
1741
1773
  return cached[1]
@@ -1769,8 +1801,6 @@ export class RouterCore<
1769
1801
  }
1770
1802
 
1771
1803
  // Determine params: reuse from state if possible, otherwise parse
1772
- const lastStateMatch =
1773
- lastStateMatchId && this.stores.matchStores.get(lastStateMatchId)?.get()
1774
1804
  const canReuseParams =
1775
1805
  lastStateMatch &&
1776
1806
  lastStateMatch.routeId === lastRoute.id &&
@@ -1805,37 +1835,6 @@ export class RouterCore<
1805
1835
  return result
1806
1836
  }
1807
1837
 
1808
- cancelMatch = (id: string) => {
1809
- const match = this.getMatch(id)
1810
-
1811
- if (!match) return
1812
-
1813
- match.abortController.abort()
1814
- clearTimeout(match._nonReactive.pendingTimeout)
1815
- match._nonReactive.pendingTimeout = undefined
1816
- }
1817
-
1818
- cancelMatches = () => {
1819
- this.stores.pendingIds.get().forEach((matchId) => {
1820
- this.cancelMatch(matchId)
1821
- })
1822
-
1823
- this.stores.matchesId.get().forEach((matchId) => {
1824
- if (this.stores.pendingMatchStores.has(matchId)) {
1825
- return
1826
- }
1827
-
1828
- const match = this.stores.matchStores.get(matchId)?.get()
1829
- if (!match) {
1830
- return
1831
- }
1832
-
1833
- if (match.status === 'pending' || match.isFetching === 'loader') {
1834
- this.cancelMatch(matchId)
1835
- }
1836
- })
1837
- }
1838
-
1839
1838
  /**
1840
1839
  * Build the next ParsedLocation from navigation options without committing.
1841
1840
  * Resolves `to`/`from`, params/search/hash/state, applies search validation
@@ -1851,7 +1850,7 @@ export class RouterCore<
1851
1850
  ): ParsedLocation => {
1852
1851
  // We allow the caller to override the current location
1853
1852
  const currentLocation =
1854
- dest._fromLocation || this.pendingBuiltLocation || this.latestLocation
1853
+ dest._fromLocation || this._pendingLocation || this.latestLocation
1855
1854
 
1856
1855
  // Use lightweight matching - only computes what buildLocation needs
1857
1856
  // (fullPath, search, params) without creating full match objects
@@ -1906,15 +1905,7 @@ export class RouterCore<
1906
1905
  : sourcePath
1907
1906
 
1908
1907
  // Resolve the next params
1909
- const nextParams =
1910
- dest.params === false || dest.params === null
1911
- ? Object.create(null)
1912
- : (dest.params ?? true) === true
1913
- ? fromParams
1914
- : Object.assign(
1915
- fromParams,
1916
- functionalUpdate(dest.params as any, fromParams),
1917
- )
1908
+ const nextParams = resolveNextParams(dest.params, fromParams)
1918
1909
 
1919
1910
  const destRoute = this.routesByPath[
1920
1911
  trimPathRight(nextTo) as keyof typeof this.routesByPath
@@ -2012,12 +2003,12 @@ export class RouterCore<
2012
2003
  nextSearch = validatedSearch
2013
2004
  }
2014
2005
 
2015
- nextSearch = applySearchMiddleware({
2016
- search: nextSearch,
2006
+ nextSearch = applySearchMiddleware(
2007
+ nextSearch,
2017
2008
  dest,
2018
2009
  destRoutes,
2019
- _includeValidateSearch: opts._includeValidateSearch,
2020
- })
2010
+ opts._includeValidateSearch,
2011
+ )
2021
2012
 
2022
2013
  // Replace the equal deep
2023
2014
  nextSearch = nullReplaceEqualDeep(fromSearch, nextSearch)
@@ -2117,12 +2108,7 @@ export class RouterCore<
2117
2108
 
2118
2109
  // If mask has a params function, call it with the matched params as context
2119
2110
  // Otherwise, use the matched params or the provided params value
2120
- const nextParams =
2121
- maskParams === false || maskParams === null
2122
- ? Object.create(null)
2123
- : (maskParams ?? true) === true
2124
- ? params
2125
- : Object.assign(params, functionalUpdate(maskParams, params))
2111
+ const nextParams = resolveNextParams(maskParams, params)
2126
2112
 
2127
2113
  maskedDest = {
2128
2114
  from: opts.from,
@@ -2151,7 +2137,7 @@ export class RouterCore<
2151
2137
  return buildWithMatches(opts)
2152
2138
  }
2153
2139
 
2154
- commitLocationPromise: undefined | ControlledPromise<void>
2140
+ _commitPromise: (Promise<void> & { resolve: () => void }) | undefined
2155
2141
 
2156
2142
  /**
2157
2143
  * Commit a previously built location to history (push/replace), optionally
@@ -2163,38 +2149,27 @@ export class RouterCore<
2163
2149
  ...next
2164
2150
  }) => {
2165
2151
  let historyAction: HistoryAction | undefined
2166
- const isSameState = () => {
2167
- // the following props are ignored but may still be provided when navigating,
2168
- // temporarily add the previous values to the next state so they don't affect
2169
- // the comparison
2170
- const ignoredProps = [
2171
- 'key', // TODO: Remove in v2 - use __TSR_key instead
2172
- '__TSR_key',
2173
- '__TSR_index',
2174
- '__hashScrollIntoViewOptions',
2175
- ] as const
2176
- ignoredProps.forEach((prop) => {
2177
- ;(next.state as any)[prop] = this.latestLocation.state[prop]
2178
- })
2179
- const isEqual = deepEqual(next.state, this.latestLocation.state)
2180
- ignoredProps.forEach((prop) => {
2181
- delete next.state[prop]
2182
- })
2183
- return isEqual
2184
- }
2185
-
2186
- const isSameUrl =
2187
- trimPathRight(this.latestLocation.href) === trimPathRight(next.href)
2152
+ const isSameLocation =
2153
+ trimPathRight(this.latestLocation.href) === trimPathRight(next.href) &&
2154
+ deepEqual(
2155
+ _getUserHistoryState(next.state),
2156
+ _getUserHistoryState(this.latestLocation.state),
2157
+ )
2188
2158
 
2189
- let previousCommitPromise = this.commitLocationPromise
2190
- this.commitLocationPromise = createControlledPromise<void>(() => {
2159
+ const previousCommitPromise = this._commitPromise
2160
+ let resolve!: () => void
2161
+ const commitPromise = new Promise<void>((done) => {
2162
+ resolve = done
2163
+ }) as Promise<void> & { resolve: () => void }
2164
+ commitPromise.resolve = () => {
2165
+ resolve()
2191
2166
  previousCommitPromise?.resolve()
2192
- previousCommitPromise = undefined
2193
- })
2167
+ }
2168
+ this._commitPromise = commitPromise
2194
2169
 
2195
2170
  // Don't commit to history if nothing changed
2196
- if (isSameUrl && isSameState()) {
2197
- this.load()
2171
+ if (isSameLocation) {
2172
+ this.load({ _dedupe: true })
2198
2173
  } else {
2199
2174
  let {
2200
2175
  // eslint-disable-next-line prefer-const
@@ -2245,21 +2220,17 @@ export class RouterCore<
2245
2220
  nextHistory.state,
2246
2221
  { ignoreBlocker },
2247
2222
  )
2223
+
2224
+ // Without a subscribed adapter the history commit cannot trigger the
2225
+ // load itself. The same-location branch already loads directly.
2226
+ if (!this.history.subscribers.size) {
2227
+ this.load({ action: { type: historyAction } })
2228
+ }
2248
2229
  }
2249
2230
 
2250
2231
  this._scroll.next = next.resetScroll ?? true
2251
2232
 
2252
- if (!this.history.subscribers.size) {
2253
- this.load(
2254
- historyAction
2255
- ? {
2256
- action: { type: historyAction },
2257
- }
2258
- : undefined,
2259
- )
2260
- }
2261
-
2262
- return this.commitLocationPromise
2233
+ return this._commitPromise
2263
2234
  }
2264
2235
 
2265
2236
  /** Convenience helper: build a location from options, then commit it. */
@@ -2269,9 +2240,11 @@ export class RouterCore<
2269
2240
  hashScrollIntoView,
2270
2241
  viewTransition,
2271
2242
  ignoreBlocker,
2243
+ _redirects,
2272
2244
  href,
2273
2245
  ...rest
2274
- }: BuildNextOptions & CommitLocationOptions = {}) => {
2246
+ }: BuildNextOptions &
2247
+ CommitLocationOptions & { _redirects?: number } = {}) => {
2275
2248
  if (href) {
2276
2249
  const currentIndex = this.history.location.state.__TSR_index
2277
2250
 
@@ -2295,8 +2268,12 @@ export class RouterCore<
2295
2268
  ...(rest as any),
2296
2269
  _includeValidateSearch: true,
2297
2270
  })
2271
+ if (_redirects) {
2272
+ ;(location as typeof location & { _redirects?: number })._redirects =
2273
+ _redirects
2274
+ }
2298
2275
 
2299
- this.pendingBuiltLocation = location as ParsedLocation<
2276
+ this._pendingLocation = location as ParsedLocation<
2300
2277
  FullSearchSchema<TRouteTree>
2301
2278
  >
2302
2279
 
@@ -2312,8 +2289,8 @@ export class RouterCore<
2312
2289
  // Clear pending location after commit starts
2313
2290
  // We do this on next microtask to allow synchronous navigate calls to chain
2314
2291
  queueMicrotask(() => {
2315
- if (this.pendingBuiltLocation === location) {
2316
- this.pendingBuiltLocation = undefined
2292
+ if (this._pendingLocation === location) {
2293
+ this._pendingLocation = undefined
2317
2294
  }
2318
2295
  })
2319
2296
 
@@ -2409,253 +2386,17 @@ export class RouterCore<
2409
2386
  })
2410
2387
  }
2411
2388
 
2412
- latestLoadPromise: undefined | Promise<void>
2413
-
2414
- beforeLoad = () => {
2415
- // Cancel any pending matches
2416
- this.cancelMatches()
2417
- this.updateLatestLocation()
2418
-
2419
- if (isServer ?? this.isServer) {
2420
- // for SPAs on the initial load, this is handled by the Transitioner
2421
- const nextLocation = this.buildLocation({
2422
- to: this.latestLocation.pathname,
2423
- search: true,
2424
- params: true,
2425
- hash: true,
2426
- state: true,
2427
- _includeValidateSearch: true,
2428
- })
2429
-
2430
- // Check if location changed - origin check is unnecessary since buildLocation
2431
- // always uses this.origin when constructing URLs
2432
- if (this.latestLocation.publicHref !== nextLocation.publicHref) {
2433
- const href = this.getParsedLocationHref(nextLocation)
2434
- if (nextLocation.external) {
2435
- throw redirect({ href })
2436
- } else {
2437
- throw redirect({ href, _builtLocation: nextLocation })
2438
- }
2439
- }
2440
- }
2441
-
2442
- // Match the routes
2443
- const pendingMatches = this.matchRoutes(this.latestLocation)
2444
-
2445
- const nextCachedMatches = this.stores.cachedMatches
2446
- .get()
2447
- .filter((d) => !pendingMatches.some((e) => e.id === d.id))
2448
-
2449
- // Ingest the new matches
2450
- this.batch(() => {
2451
- this.stores.status.set('pending')
2452
- this.stores.statusCode.set(200)
2453
- this.stores.isLoading.set(true)
2454
- this.stores.location.set(this.latestLocation)
2455
- this.stores.setPending(pendingMatches)
2456
- // If a cached match moved to pending matches, remove it from cached matches
2457
- this.stores.setCached(nextCachedMatches)
2458
- })
2459
- }
2460
-
2461
2389
  load: LoadFn = async (opts): Promise<void> => {
2462
- const historyAction = opts?.action?.type
2463
- let redirect: AnyRedirect | undefined
2464
- let notFound: NotFoundError | undefined
2465
- let loadPromise: Promise<void>
2466
- const previousLocation =
2467
- this.stores.resolvedLocation.get() ?? this.stores.location.get()
2468
-
2469
- // eslint-disable-next-line prefer-const
2470
- loadPromise = new Promise<void>((resolve) => {
2471
- this.startTransition(async () => {
2472
- try {
2473
- this.beforeLoad()
2474
- if (historyAction) {
2475
- this._scroll.hash =
2476
- historyAction === 'PUSH' || historyAction === 'REPLACE'
2477
- }
2478
- const next = this.latestLocation
2479
- const prevLocation = this.stores.resolvedLocation.get()
2480
- const locationChangeInfo = getLocationChangeInfo(next, prevLocation)
2481
-
2482
- if (!this.stores.redirect.get()) {
2483
- this.emit({
2484
- type: 'onBeforeNavigate',
2485
- ...locationChangeInfo,
2486
- })
2487
- }
2488
-
2489
- this.emit({
2490
- type: 'onBeforeLoad',
2491
- ...locationChangeInfo,
2492
- })
2493
-
2494
- await loadMatches({
2495
- router: this,
2496
- sync: opts?.sync,
2497
- forceStaleReload: previousLocation.href === next.href,
2498
- matches: this.stores.pendingMatches.get(),
2499
- location: next,
2500
- updateMatch: this.updateMatch,
2501
- // eslint-disable-next-line @typescript-eslint/require-await
2502
- onReady: async () => {
2503
- // Wrap batch in framework-specific transition wrapper (e.g., Solid's startTransition)
2504
- this.startTransition(() => {
2505
- this.startViewTransition(async () => {
2506
- // this.viewTransitionPromise = createControlledPromise<true>()
2507
-
2508
- // Commit the pending matches. If a previous match was
2509
- // removed, place it in the cachedMatches
2510
- //
2511
- // exitingMatches uses match.id (routeId + params + loaderDeps) so
2512
- // navigating /foo?page=1 → /foo?page=2 correctly caches the page=1 entry.
2513
- let exitingMatches: Array<AnyRouteMatch> | null = null
2514
-
2515
- // Lifecycle-hook identity uses routeId only so that navigating between
2516
- // different params/deps of the same route fires onStay (not onLeave+onEnter).
2517
- let hookExitingMatches: Array<AnyRouteMatch> | null = null
2518
- let hookEnteringMatches: Array<AnyRouteMatch> | null = null
2519
- let hookStayingMatches: Array<AnyRouteMatch> | null = null
2520
-
2521
- this.batch(() => {
2522
- const pendingMatches = this.stores.pendingMatches.get()
2523
- const mountPending = pendingMatches.length
2524
- const currentMatches = this.stores.matches.get()
2525
-
2526
- exitingMatches = mountPending
2527
- ? currentMatches.filter(
2528
- (match) =>
2529
- !this.stores.pendingMatchStores.has(match.id),
2530
- )
2531
- : null
2532
-
2533
- // Lifecycle-hook identity: routeId only (route presence in tree)
2534
- // Build routeId sets from pools to avoid derived stores.
2535
- const pendingRouteIds = new Set<string>()
2536
- for (const s of this.stores.pendingMatchStores.values()) {
2537
- if (s.routeId) pendingRouteIds.add(s.routeId)
2538
- }
2539
- const activeRouteIds = new Set<string>()
2540
- for (const s of this.stores.matchStores.values()) {
2541
- if (s.routeId) activeRouteIds.add(s.routeId)
2542
- }
2543
-
2544
- hookExitingMatches = mountPending
2545
- ? currentMatches.filter(
2546
- (match) => !pendingRouteIds.has(match.routeId),
2547
- )
2548
- : null
2549
- hookEnteringMatches = mountPending
2550
- ? pendingMatches.filter(
2551
- (match) => !activeRouteIds.has(match.routeId),
2552
- )
2553
- : null
2554
- hookStayingMatches = mountPending
2555
- ? pendingMatches.filter((match) =>
2556
- activeRouteIds.has(match.routeId),
2557
- )
2558
- : currentMatches
2559
-
2560
- this.stores.isLoading.set(false)
2561
- this.stores.loadedAt.set(Date.now())
2562
- /**
2563
- * When committing new matches, cache any exiting matches that are still usable.
2564
- * Routes that resolved with `status: 'error'` or `status: 'notFound'` are
2565
- * deliberately excluded from `cachedMatches` so that subsequent invalidations
2566
- * or reloads re-run their loaders instead of reusing the failed/not-found data.
2567
- */
2568
- if (mountPending) {
2569
- this.stores.setMatches(pendingMatches)
2570
- this.stores.setPending([])
2571
- this.stores.setCached([
2572
- ...this.stores.cachedMatches.get(),
2573
- ...exitingMatches!.filter(
2574
- (d) =>
2575
- d.status !== 'error' &&
2576
- d.status !== 'notFound' &&
2577
- d.status !== 'redirected',
2578
- ),
2579
- ])
2580
- this.clearExpiredCache()
2581
- }
2582
- })
2583
-
2584
- //
2585
- for (const [matches, hook] of [
2586
- [hookExitingMatches, 'onLeave'],
2587
- [hookEnteringMatches, 'onEnter'],
2588
- [hookStayingMatches, 'onStay'],
2589
- ] as const) {
2590
- if (!matches) continue
2591
- for (const match of matches as Array<AnyRouteMatch>) {
2592
- this.looseRoutesById[match.routeId]!.options[hook]?.(
2593
- match,
2594
- )
2595
- }
2596
- }
2597
- })
2598
- })
2599
- },
2600
- })
2601
- } catch (err) {
2602
- if (isRedirect(err)) {
2603
- redirect = err
2604
- if (!(isServer ?? this.isServer)) {
2605
- this.navigate({
2606
- ...redirect.options,
2607
- replace: true,
2608
- ignoreBlocker: true,
2609
- })
2610
- }
2611
- } else if (isNotFound(err)) {
2612
- notFound = err
2613
- }
2614
-
2615
- const nextStatusCode = redirect
2616
- ? redirect.status
2617
- : notFound
2618
- ? 404
2619
- : this.stores.matches.get().some((d) => d.status === 'error')
2620
- ? 500
2621
- : 200
2622
-
2623
- this.batch(() => {
2624
- this.stores.statusCode.set(nextStatusCode)
2625
- this.stores.redirect.set(redirect)
2626
- })
2627
- }
2628
-
2629
- if (this.latestLoadPromise === loadPromise) {
2630
- this.commitLocationPromise?.resolve()
2631
- this.latestLoadPromise = undefined
2632
- this.commitLocationPromise = undefined
2633
- }
2634
-
2635
- resolve()
2636
- })
2637
- })
2638
-
2639
- this.latestLoadPromise = loadPromise
2640
-
2641
- await loadPromise
2642
-
2643
- while (
2644
- (this.latestLoadPromise as any) &&
2645
- loadPromise !== this.latestLoadPromise
2646
- ) {
2647
- await this.latestLoadPromise
2390
+ if (isServer ?? this.isServer) {
2391
+ return loadServerRoute(this, opts)
2648
2392
  }
2649
2393
 
2650
- let newStatusCode: number | undefined = undefined
2651
- if (this.hasNotFoundMatch()) {
2652
- newStatusCode = 404
2653
- } else if (this.stores.matches.get().some((d) => d.status === 'error')) {
2654
- newStatusCode = 500
2655
- }
2656
- if (newStatusCode !== undefined) {
2657
- this.stores.statusCode.set(newStatusCode)
2394
+ this.updateLatestLocation()
2395
+ if (opts?.action) {
2396
+ this._scroll.hash =
2397
+ opts.action.type === 'PUSH' || opts.action.type === 'REPLACE'
2658
2398
  }
2399
+ await loadClientRoute(this, opts)
2659
2400
  }
2660
2401
 
2661
2402
  startViewTransition = (fn: () => Promise<void>) => {
@@ -2670,9 +2411,8 @@ export class RouterCore<
2670
2411
  // Attempt to start a view transition (or just apply the changes if we can't)
2671
2412
  if (
2672
2413
  shouldViewTransition &&
2673
- typeof document !== 'undefined' &&
2674
- 'startViewTransition' in document &&
2675
- typeof document.startViewTransition === 'function'
2414
+ !(isServer ?? typeof document === 'undefined') &&
2415
+ typeof (document as any).startViewTransition === 'function'
2676
2416
  ) {
2677
2417
  // lib.dom.ts doesn't support viewTransition types variant yet.
2678
2418
  // TODO: Fix this when dom types are updated
@@ -2680,7 +2420,7 @@ export class RouterCore<
2680
2420
 
2681
2421
  if (
2682
2422
  typeof shouldViewTransition === 'object' &&
2683
- this.isViewTransitionTypesSupported
2423
+ window.CSS?.supports?.('selector(:active-view-transition-type(a))')
2684
2424
  ) {
2685
2425
  const next = this.latestLocation
2686
2426
  const prevLocation = this.stores.resolvedLocation.get()
@@ -2693,8 +2433,7 @@ export class RouterCore<
2693
2433
  : shouldViewTransition.types
2694
2434
 
2695
2435
  if (resolvedViewTransitionTypes === false) {
2696
- fn()
2697
- return
2436
+ return fn()
2698
2437
  }
2699
2438
 
2700
2439
  startViewTransitionParams = {
@@ -2705,58 +2444,19 @@ export class RouterCore<
2705
2444
  startViewTransitionParams = fn
2706
2445
  }
2707
2446
 
2708
- document.startViewTransition(startViewTransitionParams)
2709
- } else {
2710
- fn()
2447
+ return (document as any).startViewTransition(startViewTransitionParams)
2448
+ .updateCallbackDone
2711
2449
  }
2712
- }
2713
-
2714
- updateMatch: UpdateMatchFn = (id, updater) => {
2715
- this.startTransition(() => {
2716
- const pendingMatch = this.stores.pendingMatchStores.get(id)
2717
- if (pendingMatch) {
2718
- pendingMatch.set(updater)
2719
- return
2720
- }
2721
-
2722
- const activeMatch = this.stores.matchStores.get(id)
2723
- if (activeMatch) {
2724
- activeMatch.set(updater)
2725
- return
2726
- }
2727
-
2728
- const cachedMatch = this.stores.cachedMatchStores.get(id)
2729
- if (cachedMatch) {
2730
- const next = updater(cachedMatch.get())
2731
- if (next.status === 'redirected') {
2732
- const deleted = this.stores.cachedMatchStores.delete(id)
2733
- if (deleted) {
2734
- this.stores.cachedIds.set((prev) =>
2735
- prev.filter((matchId) => matchId !== id),
2736
- )
2737
- }
2738
- } else {
2739
- cachedMatch.set(next)
2740
- }
2741
- }
2742
- })
2743
- }
2744
-
2745
- getMatch: GetMatchFn = (matchId: string): AnyRouteMatch | undefined => {
2746
- return (
2747
- this.stores.cachedMatchStores.get(matchId)?.get() ??
2748
- this.stores.pendingMatchStores.get(matchId)?.get() ??
2749
- this.stores.matchStores.get(matchId)?.get()
2750
- )
2450
+ return fn()
2751
2451
  }
2752
2452
 
2753
2453
  /**
2754
2454
  * Invalidate the current matches and optionally force them back into a pending state.
2755
2455
  *
2756
2456
  * - Marks all matches that pass the optional `filter` as `invalid: true`.
2757
- * - If `forcePending` is true, or a match is currently in `'error'` or `'notFound'` status,
2758
- * its status is reset to `'pending'` and its `error` cleared so that the loader is re-run
2759
- * on the next `load()` call (eg. after HMR or a manual invalidation).
2457
+ *
2458
+ * The next load decides when to publish pending UI, so invalidation does not
2459
+ * mutate the currently rendered status.
2760
2460
  */
2761
2461
  invalidate: InvalidateFn<
2762
2462
  RouterCore<
@@ -2767,44 +2467,54 @@ export class RouterCore<
2767
2467
  TDehydrated
2768
2468
  >
2769
2469
  > = (opts) => {
2470
+ const committedMatches = this._committed
2471
+ const filter = opts?.filter
2472
+ const invalidIds = filter
2473
+ ? new Set(
2474
+ [...committedMatches, ...this._cache.values()]
2475
+ .filter((match) => filter(match as MakeRouteMatchUnion<this>))
2476
+ .map((match) => match.id),
2477
+ )
2478
+ : undefined
2770
2479
  const invalidate = (d: MakeRouteMatch<TRouteTree>) => {
2771
- if (opts?.filter?.(d as MakeRouteMatchUnion<this>) ?? true) {
2772
- return {
2480
+ if (!invalidIds || invalidIds.has(d.id)) {
2481
+ const route = this.routesById[d.routeId] as AnyRoute
2482
+ const next = {
2773
2483
  ...d,
2774
2484
  invalid: true,
2775
- ...(opts?.forcePending ||
2776
- d.status === 'error' ||
2777
- d.status === 'notFound'
2485
+ ...((opts?.forcePending ||
2486
+ d.status === 'error' ||
2487
+ d.status === 'notFound') &&
2488
+ routeNeedsLoad(route)
2778
2489
  ? ({ status: 'pending', error: undefined } as const)
2779
2490
  : undefined),
2780
2491
  }
2492
+ // Invalidation replaces this owner; it does not create another lease.
2493
+ ;(d as AnyRouteMatch & { _flight?: LoaderFlight })._flight = undefined
2494
+ return next
2781
2495
  }
2782
2496
  return d
2783
2497
  }
2784
2498
 
2785
- this.batch(() => {
2786
- this.stores.setMatches(this.stores.matches.get().map(invalidate))
2787
- this.stores.setCached(this.stores.cachedMatches.get().map(invalidate))
2788
- this.stores.setPending(this.stores.pendingMatches.get().map(invalidate))
2789
- })
2499
+ const committed = committedMatches.map(invalidate)
2500
+ this._committed = committed
2501
+ const cache = new Map<string, AnyRouteMatch>()
2502
+ for (const [id, match] of this._cache) {
2503
+ cache.set(id, invalidate(match))
2504
+ }
2505
+ this._cache = cache
2790
2506
 
2791
2507
  this.shouldViewTransition = false
2792
2508
  return this.load({ sync: opts?.sync })
2793
2509
  }
2794
2510
 
2795
- getParsedLocationHref = (location: ParsedLocation) => {
2796
- // For redirects and external use, we need publicHref (with rewrite output applied)
2797
- // href is the internal path after rewrite input, publicHref is user-facing
2798
- return location.publicHref || '/'
2799
- }
2800
-
2801
2511
  resolveRedirect = (redirect: AnyRedirect): AnyRedirect => {
2802
2512
  const locationHeader = redirect.headers.get('Location')
2803
2513
 
2804
2514
  if (!redirect.options.href || redirect.options._builtLocation) {
2805
2515
  const location =
2806
2516
  redirect.options._builtLocation ?? this.buildLocation(redirect.options)
2807
- const href = this.getParsedLocationHref(location)
2517
+ const href = location.publicHref || '/'
2808
2518
  redirect.options.href = href
2809
2519
  redirect.headers.set('Location', href)
2810
2520
  } else if (locationHeader) {
@@ -2841,43 +2551,37 @@ export class RouterCore<
2841
2551
  }
2842
2552
 
2843
2553
  clearCache: ClearCacheFn<this> = (opts) => {
2554
+ const cached = this._cache
2555
+ const preloads = this._preloads
2844
2556
  const filter = opts?.filter
2845
- if (filter !== undefined) {
2846
- this.stores.setCached(
2847
- this.stores.cachedMatches
2848
- .get()
2849
- .filter((m) => !filter(m as MakeRouteMatchUnion<this>)),
2850
- )
2851
- } else {
2852
- this.stores.setCached([])
2557
+ const retained = new Map<string, AnyRouteMatch>()
2558
+ const discarded: Array<AnyRouteMatch> = []
2559
+ for (const [id, match] of cached) {
2560
+ if (filter && !filter(match as MakeRouteMatchUnion<this>)) {
2561
+ retained.set(id, match)
2562
+ } else {
2563
+ discarded.push(match)
2564
+ }
2853
2565
  }
2854
- }
2855
-
2856
- clearExpiredCache = () => {
2857
- const now = Date.now()
2858
- // This is where all of the garbage collection magic happens
2859
- const filter = (d: MakeRouteMatch<TRouteTree>) => {
2860
- const route = this.looseRoutesById[d.routeId]!
2861
-
2862
- if (!route.options.loader) {
2863
- return true
2566
+ const retainedPreloads = new Map<string, ActivePreload>()
2567
+ const discardedPreloads: Array<ActivePreload> = []
2568
+ for (const [href, preload] of preloads ?? []) {
2569
+ if (!filter || preload[0].some(filter as any)) {
2570
+ discardedPreloads.push(preload)
2571
+ discarded.push(...preload[0])
2572
+ } else {
2573
+ retainedPreloads.set(href, preload)
2864
2574
  }
2575
+ }
2865
2576
 
2866
- // If the route was preloaded, use the preloadGcTime
2867
- // otherwise, use the gcTime
2868
- const gcTime =
2869
- (d.preload
2870
- ? (route.options.preloadGcTime ?? this.options.defaultPreloadGcTime)
2871
- : (route.options.gcTime ?? this.options.defaultGcTime)) ??
2872
- 5 * 60 * 1000
2873
-
2874
- const isError = d.status === 'error'
2875
- if (isError) return true
2876
-
2877
- const gcEligible = now - d.updatedAt >= gcTime
2878
- return gcEligible
2577
+ // Install both replacement authorities before releasing a public loader
2578
+ // signal, whose abort listeners can synchronously reenter the router.
2579
+ this._cache = retained
2580
+ this._preloads = retainedPreloads
2581
+ transferMatchResources(this, discarded)
2582
+ for (const preload of discardedPreloads) {
2583
+ preload[1].abort()
2879
2584
  }
2880
- this.clearCache({ filter })
2881
2585
  }
2882
2586
 
2883
2587
  loadRouteChunk = loadRouteChunk
@@ -2887,69 +2591,7 @@ export class RouterCore<
2887
2591
  TTrailingSlashOption,
2888
2592
  TDefaultStructuralSharingOption,
2889
2593
  TRouterHistory
2890
- > = async (opts) => {
2891
- const next = opts._builtLocation ?? this.buildLocation(opts as any)
2892
-
2893
- let matches = this.matchRoutes(next, {
2894
- throwOnError: true,
2895
- preload: true,
2896
- dest: opts,
2897
- })
2898
-
2899
- const activeMatchIds = new Set([
2900
- ...this.stores.matchesId.get(),
2901
- ...this.stores.pendingIds.get(),
2902
- ])
2903
-
2904
- const loadedMatchIds = new Set([
2905
- ...activeMatchIds,
2906
- ...this.stores.cachedIds.get(),
2907
- ])
2908
-
2909
- // If the matches are already loaded, we need to add them to the cached matches.
2910
- const matchesToCache = matches.filter(
2911
- (match) => !loadedMatchIds.has(match.id),
2912
- )
2913
- if (matchesToCache.length) {
2914
- const cachedMatches = this.stores.cachedMatches.get()
2915
- this.stores.setCached([...cachedMatches, ...matchesToCache])
2916
- }
2917
-
2918
- try {
2919
- matches = await loadMatches({
2920
- router: this,
2921
- matches,
2922
- location: next,
2923
- preload: true,
2924
- updateMatch: (id, updater) => {
2925
- // Don't update the match if it's currently loaded
2926
- if (activeMatchIds.has(id)) {
2927
- matches = matches.map((d) => (d.id === id ? updater(d) : d))
2928
- } else {
2929
- this.updateMatch(id, updater)
2930
- }
2931
- },
2932
- })
2933
-
2934
- return matches
2935
- } catch (err) {
2936
- if (isRedirect(err)) {
2937
- if (err.options.reloadDocument) {
2938
- return undefined
2939
- }
2940
-
2941
- return await this.preloadRoute({
2942
- ...err.options,
2943
- _fromLocation: next,
2944
- })
2945
- }
2946
- if (!isNotFound(err)) {
2947
- // Preload errors are not fatal, but we should still log them
2948
- console.error(err)
2949
- }
2950
- return undefined
2951
- }
2952
- }
2594
+ > = (opts) => preloadClientRoute(this, opts)
2953
2595
 
2954
2596
  matchRoute: MatchRouteFn<
2955
2597
  TRouteTree,
@@ -2967,12 +2609,12 @@ export class RouterCore<
2967
2609
  }
2968
2610
  const next = this.buildLocation(matchLocation as any)
2969
2611
 
2970
- if (opts?.pending && this.stores.status.get() !== 'pending') {
2612
+ const isPending = this.stores.status.get() === 'pending'
2613
+ if (opts?.pending && !isPending) {
2971
2614
  return false
2972
2615
  }
2973
2616
 
2974
- const pending =
2975
- opts?.pending === undefined ? !this.stores.isLoading.get() : opts.pending
2617
+ const pending = opts?.pending ?? !isPending
2976
2618
 
2977
2619
  const baseLocation = pending
2978
2620
  ? this.latestLocation
@@ -3012,11 +2654,19 @@ export class RouterCore<
3012
2654
  serverSsr?: ServerSsr
3013
2655
 
3014
2656
  serverSsrLifecycle?: RouterSsrLifecycle
2657
+ }
3015
2658
 
3016
- hasNotFoundMatch = () => {
3017
- return this.stores.matches
3018
- .get()
3019
- .some((d) => d.status === 'notFound' || d.globalNotFound)
2659
+ /**
2660
+ * In non-production environments,
2661
+ * augment the RouterCore class with a `_refreshRoute` method
2662
+ * dedicated to HMR.
2663
+ */
2664
+ if (process.env.NODE_ENV !== 'production') {
2665
+ RouterCore.prototype._replaceRouteChunk = replaceRouteChunk
2666
+ RouterCore.prototype._refreshRoute = async function () {
2667
+ this._serverResult = undefined
2668
+ this.updateLatestLocation()
2669
+ await refreshClientRoute(this)
3020
2670
  }
3021
2671
  }
3022
2672
 
@@ -3053,14 +2703,11 @@ export function getInitialRouterState(
3053
2703
  location: ParsedLocation,
3054
2704
  ): RouterState<any> {
3055
2705
  return {
3056
- loadedAt: 0,
3057
2706
  isLoading: false,
3058
- isTransitioning: false,
3059
2707
  status: 'idle',
3060
2708
  resolvedLocation: undefined,
3061
2709
  location,
3062
2710
  matches: [],
3063
- statusCode: 200,
3064
2711
  }
3065
2712
  }
3066
2713
 
@@ -3092,56 +2739,12 @@ function validateSearch(validateSearch: AnyValidator, input: unknown): unknown {
3092
2739
  return {}
3093
2740
  }
3094
2741
 
3095
- /**
3096
- * Build the matched route chain and extract params for a pathname.
3097
- * Falls back to the root route if no specific route is found.
3098
- */
3099
- export function getMatchedRoutes<TRouteLike extends RouteLike>({
3100
- pathname,
3101
- routesById,
3102
- processedTree,
3103
- }: {
3104
- pathname: string
3105
- routesById: Record<string, TRouteLike>
3106
- processedTree: ProcessedTree<any, any, any>
3107
- }) {
3108
- const routeParams: Record<string, string> = Object.create(null)
3109
- const trimmedPath = trimPathRight(pathname)
3110
-
3111
- let foundRoute: TRouteLike | undefined = undefined
3112
- const match = findRouteMatch<TRouteLike>(trimmedPath, processedTree, true)
3113
- if (match) {
3114
- foundRoute = match.route
3115
- Object.assign(routeParams, match.rawParams) // Copy params, because they're cached
3116
- }
3117
-
3118
- const matchedRoutes = match?.branch || [routesById[rootRouteId]!]
3119
-
3120
- return { matchedRoutes, routeParams, foundRoute }
3121
- }
3122
-
3123
- /**
3124
- * TODO: once caches are persisted across requests on the server,
3125
- * we can cache the built middleware chain using `last(destRoutes)` as the key
3126
- */
3127
- function applySearchMiddleware({
3128
- search,
3129
- dest,
3130
- destRoutes,
3131
- _includeValidateSearch,
3132
- }: {
3133
- search: any
3134
- dest: { search?: unknown }
3135
- destRoutes: ReadonlyArray<AnyRoute>
3136
- _includeValidateSearch: boolean | undefined
3137
- }) {
3138
- const middleware = buildMiddlewareChain(destRoutes)
3139
- return middleware(search, dest, _includeValidateSearch ?? false)
3140
- }
3141
-
3142
- function buildMiddlewareChain(destRoutes: ReadonlyArray<AnyRoute>) {
3143
- let dest: BuildNextOptions
3144
- let includeValidateSearch: boolean | undefined
2742
+ function applySearchMiddleware(
2743
+ search: any,
2744
+ dest: BuildNextOptions,
2745
+ destRoutes: ReadonlyArray<AnyRoute>,
2746
+ includeValidateSearch: boolean | undefined,
2747
+ ) {
3145
2748
  const middlewares = [] as Array<SearchMiddleware<any>>
3146
2749
 
3147
2750
  for (const route of destRoutes) {
@@ -3234,15 +2837,7 @@ function buildMiddlewareChain(destRoutes: ReadonlyArray<AnyRoute>) {
3234
2837
  return (middlewares[index]! as any)({ search: currentSearch, next, meta })
3235
2838
  }
3236
2839
 
3237
- return function middleware(
3238
- search: any,
3239
- nextDest: BuildNextOptions,
3240
- _includeValidateSearch: boolean,
3241
- ) {
3242
- dest = nextDest
3243
- includeValidateSearch = _includeValidateSearch
3244
- return applyNext(0, search)
3245
- }
2840
+ return applyNext(0, search)
3246
2841
  }
3247
2842
 
3248
2843
  function findGlobalNotFoundRouteId(
@@ -3250,26 +2845,41 @@ function findGlobalNotFoundRouteId(
3250
2845
  routes: ReadonlyArray<AnyRoute>,
3251
2846
  ) {
3252
2847
  if (notFoundMode !== 'root') {
2848
+ let fallback
3253
2849
  for (let i = routes.length - 1; i >= 0; i--) {
3254
2850
  const route = routes[i]!
3255
- if (route.children) {
2851
+ if (route.options.notFoundComponent) {
3256
2852
  return route.id
3257
2853
  }
2854
+ fallback ||= route.children && route.id
2855
+ }
2856
+ if (fallback) {
2857
+ return fallback
3258
2858
  }
3259
2859
  }
3260
2860
  return rootRouteId
3261
2861
  }
3262
2862
 
2863
+ function resolveNextParams(
2864
+ spec: unknown,
2865
+ base: Record<string, unknown>,
2866
+ ): Record<string, unknown> {
2867
+ return spec === false || spec === null
2868
+ ? Object.create(null)
2869
+ : (spec ?? true) === true
2870
+ ? base
2871
+ : Object.assign(base, functionalUpdate(spec as any, base))
2872
+ }
2873
+
3263
2874
  function extractStrictParams(
3264
2875
  route: AnyRoute,
3265
2876
  accumulatedParams: Record<string, unknown>,
3266
2877
  ) {
3267
2878
  const parseParams = route.options.params?.parse ?? route.options.parseParams
3268
2879
  if (parseParams) {
3269
- const result = parseParams(accumulatedParams as Record<string, string>)
3270
- if (result === false) {
3271
- throw new Error('Route params.parse returned false for a matched route')
3272
- }
3273
- Object.assign(accumulatedParams, result)
2880
+ Object.assign(
2881
+ accumulatedParams,
2882
+ parseParams(accumulatedParams as Record<string, string>),
2883
+ )
3274
2884
  }
3275
2885
  }