@tanstack/router-core 1.163.3 → 1.166.4

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.
@@ -29,13 +29,13 @@ type InnerLoadContext = {
29
29
  firstBadMatchIndex?: number
30
30
  /** mutable state, scoped to a `loadMatches` call */
31
31
  rendered?: boolean
32
+ serialError?: unknown
32
33
  updateMatch: UpdateMatchFn
33
34
  matches: Array<AnyRouteMatch>
34
35
  preload?: boolean
36
+ forceStaleReload?: boolean
35
37
  onReady?: () => Promise<void>
36
38
  sync?: boolean
37
- /** mutable state, scoped to a `loadMatches` call */
38
- matchPromises: Array<Promise<AnyRouteMatch>>
39
39
  }
40
40
 
41
41
  const triggerOnReady = (inner: InnerLoadContext): void | Promise<void> => {
@@ -74,64 +74,45 @@ const buildMatchContext = (
74
74
  return context
75
75
  }
76
76
 
77
- const _handleNotFound = (
77
+ const getNotFoundBoundaryIndex = (
78
78
  inner: InnerLoadContext,
79
79
  err: NotFoundError,
80
- routerCode?: string,
81
- ) => {
82
- // Find the route that should handle the not found error
83
- // First check if a specific route is requested to show the error
84
- const routeCursor =
85
- inner.router.routesById[err.routeId ?? ''] ?? inner.router.routeTree
86
-
87
- // Ensure a NotFoundComponent exists on the route
88
- if (
89
- !routeCursor.options.notFoundComponent &&
90
- (inner.router.options as any)?.defaultNotFoundComponent
91
- ) {
92
- routeCursor.options.notFoundComponent = (
93
- inner.router.options as any
94
- ).defaultNotFoundComponent
95
- }
96
-
97
- // For BEFORE_LOAD errors that will walk up to a parent route,
98
- // don't require notFoundComponent on the current (child) route —
99
- // an ancestor will handle it. Only enforce the invariant when
100
- // we've reached a route that won't walk up further.
101
- const willWalkUp = routerCode === 'BEFORE_LOAD' && routeCursor.parentRoute
102
-
103
- if (!willWalkUp) {
104
- // Ensure we have a notFoundComponent
105
- invariant(
106
- routeCursor.options.notFoundComponent,
107
- 'No notFoundComponent found. Please set a notFoundComponent on your route or provide a defaultNotFoundComponent to the router.',
108
- )
80
+ ): number | undefined => {
81
+ if (!inner.matches.length) {
82
+ return undefined
109
83
  }
110
84
 
111
- // Find the match for this route
112
- const matchForRoute = inner.matches.find((m) => m.routeId === routeCursor.id)
85
+ const requestedRouteId = err.routeId
86
+ const matchedRootIndex = inner.matches.findIndex(
87
+ (m) => m.routeId === inner.router.routeTree.id,
88
+ )
89
+ const rootIndex = matchedRootIndex >= 0 ? matchedRootIndex : 0
113
90
 
114
- invariant(matchForRoute, 'Could not find match for route: ' + routeCursor.id)
91
+ let startIndex = requestedRouteId
92
+ ? inner.matches.findIndex((match) => match.routeId === requestedRouteId)
93
+ : (inner.firstBadMatchIndex ?? inner.matches.length - 1)
115
94
 
116
- // Assign the error to the match - using non-null assertion since we've checked with invariant
117
- inner.updateMatch(matchForRoute.id, (prev) => ({
118
- ...prev,
119
- status: 'notFound',
120
- error: err,
121
- isFetching: false,
122
- }))
95
+ if (startIndex < 0) {
96
+ startIndex = rootIndex
97
+ }
123
98
 
124
- if (willWalkUp) {
125
- err.routeId = routeCursor.parentRoute!.id
126
- _handleNotFound(inner, err, routerCode)
99
+ for (let i = startIndex; i >= 0; i--) {
100
+ const match = inner.matches[i]!
101
+ const route = inner.router.looseRoutesById[match.routeId]!
102
+ if (route.options.notFoundComponent) {
103
+ return i
104
+ }
127
105
  }
106
+
107
+ // If no boundary component is found, preserve explicit routeId targeting behavior,
108
+ // otherwise default to root for untargeted notFounds.
109
+ return requestedRouteId ? startIndex : rootIndex
128
110
  }
129
111
 
130
112
  const handleRedirectAndNotFound = (
131
113
  inner: InnerLoadContext,
132
114
  match: AnyRouteMatch | undefined,
133
115
  err: unknown,
134
- routerCode?: string,
135
116
  ): void => {
136
117
  if (!isRedirect(err) && !isNotFound(err)) return
137
118
 
@@ -146,19 +127,26 @@ const handleRedirectAndNotFound = (
146
127
  match._nonReactive.beforeLoadPromise = undefined
147
128
  match._nonReactive.loaderPromise = undefined
148
129
 
149
- const status = isRedirect(err) ? 'redirected' : 'notFound'
150
-
151
130
  match._nonReactive.error = err
152
131
 
153
132
  inner.updateMatch(match.id, (prev) => ({
154
133
  ...prev,
155
- status,
134
+ status: isRedirect(err)
135
+ ? 'redirected'
136
+ : prev.status === 'pending'
137
+ ? 'success'
138
+ : prev.status,
156
139
  context: buildMatchContext(inner, match.index),
157
140
  isFetching: false,
158
141
  error: err,
159
142
  }))
160
143
 
161
144
  if (isNotFound(err) && !err.routeId) {
145
+ // Stamp the throwing match's routeId so that the finalization step in
146
+ // loadMatches knows where the notFound originated. The actual boundary
147
+ // resolution (walking up to the nearest notFoundComponent) is deferred to
148
+ // the finalization step, where firstBadMatchIndex is stable and
149
+ // headMaxIndex can be capped correctly.
162
150
  err.routeId = match.routeId
163
151
  }
164
152
 
@@ -170,18 +158,19 @@ const handleRedirectAndNotFound = (
170
158
  err.options._fromLocation = inner.location
171
159
  err.redirectHandled = true
172
160
  err = inner.router.resolveRedirect(err)
173
- throw err
174
- } else {
175
- _handleNotFound(inner, err, routerCode)
176
- throw err
177
161
  }
162
+
163
+ throw err
178
164
  }
179
165
 
180
166
  const shouldSkipLoader = (
181
167
  inner: InnerLoadContext,
182
168
  matchId: string,
183
169
  ): boolean => {
184
- const match = inner.router.getMatch(matchId)!
170
+ const match = inner.router.getMatch(matchId)
171
+ if (!match) {
172
+ return true
173
+ }
185
174
  // upon hydration, we skip the loader if the match has been dehydrated on the server
186
175
  if (!(isServer ?? inner.router.isServer) && match._nonReactive.dehydrated) {
187
176
  return true
@@ -194,6 +183,21 @@ const shouldSkipLoader = (
194
183
  return false
195
184
  }
196
185
 
186
+ const syncMatchContext = (
187
+ inner: InnerLoadContext,
188
+ matchId: string,
189
+ index: number,
190
+ ): void => {
191
+ const nextContext = buildMatchContext(inner, index)
192
+
193
+ inner.updateMatch(matchId, (prev) => {
194
+ return {
195
+ ...prev,
196
+ context: nextContext,
197
+ }
198
+ })
199
+ }
200
+
197
201
  const handleSerialError = (
198
202
  inner: InnerLoadContext,
199
203
  index: number,
@@ -212,23 +216,13 @@ const handleSerialError = (
212
216
 
213
217
  err.routerCode = routerCode
214
218
  inner.firstBadMatchIndex ??= index
215
- handleRedirectAndNotFound(
216
- inner,
217
- inner.router.getMatch(matchId),
218
- err,
219
- routerCode,
220
- )
219
+ handleRedirectAndNotFound(inner, inner.router.getMatch(matchId), err)
221
220
 
222
221
  try {
223
222
  route.options.onError?.(err)
224
223
  } catch (errorHandlerErr) {
225
224
  err = errorHandlerErr
226
- handleRedirectAndNotFound(
227
- inner,
228
- inner.router.getMatch(matchId),
229
- err,
230
- routerCode,
231
- )
225
+ handleRedirectAndNotFound(inner, inner.router.getMatch(matchId), err)
232
226
  }
233
227
 
234
228
  inner.updateMatch(matchId, (prev) => {
@@ -245,6 +239,10 @@ const handleSerialError = (
245
239
  abortController: new AbortController(),
246
240
  }
247
241
  })
242
+
243
+ if (!inner.preload && !isRedirect(err) && !isNotFound(err)) {
244
+ inner.serialError ??= err
245
+ }
248
246
  }
249
247
 
250
248
  const isBeforeLoadSsr = (
@@ -500,8 +498,6 @@ const executeBeforeLoad = (
500
498
 
501
499
  batch(() => {
502
500
  pending()
503
- // Only store __beforeLoadContext here, don't update context yet
504
- // Context will be updated in loadRouteMatch after loader completes
505
501
  inner.updateMatch(matchId, (prev) => ({
506
502
  ...prev,
507
503
  __beforeLoadContext: beforeLoadContext,
@@ -606,11 +602,12 @@ const executeHead = (
606
602
 
607
603
  const getLoaderContext = (
608
604
  inner: InnerLoadContext,
605
+ matchPromises: Array<Promise<AnyRouteMatch>>,
609
606
  matchId: string,
610
607
  index: number,
611
608
  route: AnyRoute,
612
609
  ): LoaderFnContext => {
613
- const parentMatchPromise = inner.matchPromises[index - 1] as any
610
+ const parentMatchPromise = matchPromises[index - 1] as any
614
611
  const { params, loaderDeps, abortController, cause } =
615
612
  inner.router.getMatch(matchId)!
616
613
 
@@ -639,6 +636,7 @@ const getLoaderContext = (
639
636
 
640
637
  const runLoader = async (
641
638
  inner: InnerLoadContext,
639
+ matchPromises: Array<Promise<AnyRouteMatch>>,
642
640
  matchId: string,
643
641
  index: number,
644
642
  route: AnyRoute,
@@ -660,7 +658,7 @@ const runLoader = async (
660
658
 
661
659
  // Kick off the loader!
662
660
  const loaderResult = route.options.loader?.(
663
- getLoaderContext(inner, matchId, index, route),
661
+ getLoaderContext(inner, matchPromises, matchId, index, route),
664
662
  )
665
663
  const loaderResultIsPromise =
666
664
  route.options.loader && isPromise(loaderResult)
@@ -775,11 +773,13 @@ const runLoader = async (
775
773
 
776
774
  const loadRouteMatch = async (
777
775
  inner: InnerLoadContext,
776
+ matchPromises: Array<Promise<AnyRouteMatch>>,
778
777
  index: number,
779
778
  ): Promise<AnyRouteMatch> => {
780
779
  async function handleLoader(
781
780
  preload: boolean,
782
781
  prevMatch: AnyRouteMatch,
782
+ previousRouteMatchId: string | undefined,
783
783
  match: AnyRouteMatch,
784
784
  route: AnyRoute,
785
785
  ) {
@@ -798,20 +798,29 @@ const loadRouteMatch = async (
798
798
  // if provided.
799
799
  const shouldReload =
800
800
  typeof shouldReloadOption === 'function'
801
- ? shouldReloadOption(getLoaderContext(inner, matchId, index, route))
801
+ ? shouldReloadOption(
802
+ getLoaderContext(inner, matchPromises, matchId, index, route),
803
+ )
802
804
  : shouldReloadOption
803
805
 
804
806
  // If the route is successful and still fresh, just resolve
805
807
  const { status, invalid } = match
808
+ const staleMatchShouldReload =
809
+ age > staleAge &&
810
+ (!!inner.forceStaleReload ||
811
+ match.cause === 'enter' ||
812
+ (previousRouteMatchId !== undefined &&
813
+ previousRouteMatchId !== match.id))
806
814
  loaderShouldRunAsync =
807
- status === 'success' && (invalid || (shouldReload ?? age > staleAge))
815
+ status === 'success' &&
816
+ (invalid || (shouldReload ?? staleMatchShouldReload))
808
817
  if (preload && route.options.preload === false) {
809
818
  // Do nothing
810
819
  } else if (loaderShouldRunAsync && !inner.sync) {
811
820
  loaderIsRunningAsync = true
812
821
  ;(async () => {
813
822
  try {
814
- await runLoader(inner, matchId, index, route)
823
+ await runLoader(inner, matchPromises, matchId, index, route)
815
824
  const match = inner.router.getMatch(matchId)!
816
825
  match._nonReactive.loaderPromise?.resolve()
817
826
  match._nonReactive.loadPromise?.resolve()
@@ -823,7 +832,9 @@ const loadRouteMatch = async (
823
832
  }
824
833
  })()
825
834
  } else if (status !== 'success' || (loaderShouldRunAsync && inner.sync)) {
826
- await runLoader(inner, matchId, index, route)
835
+ await runLoader(inner, matchPromises, matchId, index, route)
836
+ } else {
837
+ syncMatchContext(inner, matchId, index)
827
838
  }
828
839
  }
829
840
 
@@ -833,11 +844,22 @@ const loadRouteMatch = async (
833
844
  const route = inner.router.looseRoutesById[routeId]!
834
845
 
835
846
  if (shouldSkipLoader(inner, matchId)) {
847
+ const match = inner.router.getMatch(matchId)
848
+ if (!match) {
849
+ return inner.matches[index]!
850
+ }
851
+
852
+ syncMatchContext(inner, matchId, index)
853
+
836
854
  if (isServer ?? inner.router.isServer) {
837
855
  return inner.router.getMatch(matchId)!
838
856
  }
839
857
  } else {
840
858
  const prevMatch = inner.router.getMatch(matchId)! // This is where all of the stale-while-revalidate magic happens
859
+ const previousRouteMatchId =
860
+ inner.router.state.matches[index]?.routeId === routeId
861
+ ? inner.router.state.matches[index]!.id
862
+ : inner.router.state.matches.find((d) => d.routeId === routeId)?.id
841
863
  const preload = resolvePreload(inner, matchId)
842
864
 
843
865
  // there is a loaderPromise, so we are in the middle of a load
@@ -856,7 +878,13 @@ const loadRouteMatch = async (
856
878
  }
857
879
 
858
880
  if (match.status === 'pending') {
859
- await handleLoader(preload, prevMatch, match, route)
881
+ await handleLoader(
882
+ preload,
883
+ prevMatch,
884
+ previousRouteMatchId,
885
+ match,
886
+ route,
887
+ )
860
888
  }
861
889
  } else {
862
890
  const nextPreload =
@@ -870,7 +898,7 @@ const loadRouteMatch = async (
870
898
  }))
871
899
  }
872
900
 
873
- await handleLoader(preload, prevMatch, match, route)
901
+ await handleLoader(preload, prevMatch, previousRouteMatchId, match, route)
874
902
  }
875
903
  }
876
904
  const match = inner.router.getMatch(matchId)!
@@ -902,13 +930,13 @@ export async function loadMatches(arg: {
902
930
  location: ParsedLocation
903
931
  matches: Array<AnyRouteMatch>
904
932
  preload?: boolean
933
+ forceStaleReload?: boolean
905
934
  onReady?: () => Promise<void>
906
935
  updateMatch: UpdateMatchFn
907
936
  sync?: boolean
908
937
  }): Promise<Array<MakeRouteMatch>> {
909
- const inner: InnerLoadContext = Object.assign(arg, {
910
- matchPromises: [],
911
- })
938
+ const inner: InnerLoadContext = arg
939
+ const matchPromises: Array<Promise<AnyRouteMatch>> = []
912
940
 
913
941
  // make sure the pending component is immediately rendered when hydrating a match that is not SSRed
914
942
  // the pending component was already rendered on the server and we want to keep it shown on the client until minPendingMs is reached
@@ -919,77 +947,201 @@ export async function loadMatches(arg: {
919
947
  triggerOnReady(inner)
920
948
  }
921
949
 
922
- try {
923
- // Execute all beforeLoads one by one
924
- for (let i = 0; i < inner.matches.length; i++) {
950
+ let beforeLoadNotFound: NotFoundError | undefined
951
+
952
+ // Execute all beforeLoads one by one
953
+ for (let i = 0; i < inner.matches.length; i++) {
954
+ try {
925
955
  const beforeLoad = handleBeforeLoad(inner, i)
926
956
  if (isPromise(beforeLoad)) await beforeLoad
957
+ } catch (err) {
958
+ if (isRedirect(err)) {
959
+ throw err
960
+ }
961
+ if (isNotFound(err)) {
962
+ beforeLoadNotFound = err
963
+ } else {
964
+ if (!inner.preload) throw err
965
+ }
966
+ break
927
967
  }
928
968
 
929
- // Execute all loaders in parallel
930
- const max = inner.firstBadMatchIndex ?? inner.matches.length
931
- for (let i = 0; i < max; i++) {
932
- inner.matchPromises.push(loadRouteMatch(inner, i))
969
+ if (inner.serialError) {
970
+ break
933
971
  }
934
- // Use allSettled to ensure all loaders complete regardless of success/failure
935
- const results = await Promise.allSettled(inner.matchPromises)
936
-
937
- const failures = results
938
- // TODO when we drop support for TS 5.4, we can use the built-in type guard for PromiseRejectedResult
939
- .filter(
940
- (result): result is PromiseRejectedResult =>
941
- result.status === 'rejected',
942
- )
943
- .map((result) => result.reason)
972
+ }
944
973
 
945
- // Find first redirect (throw immediately) or notFound (throw after head execution)
946
- let firstNotFound: unknown
947
- for (const err of failures) {
948
- if (isRedirect(err)) {
949
- throw err
974
+ // Execute loaders once, with max index adapted for beforeLoad notFound handling.
975
+ const baseMaxIndexExclusive = inner.firstBadMatchIndex ?? inner.matches.length
976
+
977
+ const boundaryIndex =
978
+ beforeLoadNotFound && !inner.preload
979
+ ? getNotFoundBoundaryIndex(inner, beforeLoadNotFound)
980
+ : undefined
981
+
982
+ const maxIndexExclusive =
983
+ beforeLoadNotFound && inner.preload
984
+ ? 0
985
+ : boundaryIndex !== undefined
986
+ ? Math.min(boundaryIndex + 1, baseMaxIndexExclusive)
987
+ : baseMaxIndexExclusive
988
+
989
+ let firstNotFound: NotFoundError | undefined
990
+ let firstUnhandledRejection: unknown
991
+
992
+ for (let i = 0; i < maxIndexExclusive; i++) {
993
+ matchPromises.push(loadRouteMatch(inner, matchPromises, i))
994
+ }
995
+
996
+ try {
997
+ await Promise.all(matchPromises)
998
+ } catch {
999
+ const settled = await Promise.allSettled(matchPromises)
1000
+
1001
+ for (const result of settled) {
1002
+ if (result.status !== 'rejected') continue
1003
+
1004
+ const reason = result.reason
1005
+ if (isRedirect(reason)) {
1006
+ throw reason
950
1007
  }
951
- if (!firstNotFound && isNotFound(err)) {
952
- firstNotFound = err
1008
+ if (isNotFound(reason)) {
1009
+ firstNotFound ??= reason
1010
+ } else {
1011
+ firstUnhandledRejection ??= reason
953
1012
  }
954
1013
  }
955
1014
 
956
- // serially execute head functions after all loaders have completed (successfully or not)
957
- // Each head execution is wrapped in try-catch to ensure all heads run even if one fails
958
- for (const match of inner.matches) {
959
- const { id: matchId, routeId } = match
960
- const route = inner.router.looseRoutesById[routeId]!
961
- try {
962
- const headResult = executeHead(inner, matchId, route)
963
- if (headResult) {
964
- const head = await headResult
965
- inner.updateMatch(matchId, (prev) => ({
966
- ...prev,
967
- ...head,
968
- }))
969
- }
970
- } catch (err) {
971
- // Log error but continue executing other head functions
972
- console.error(`Error executing head for route ${routeId}:`, err)
973
- }
1015
+ if (firstUnhandledRejection !== undefined) {
1016
+ throw firstUnhandledRejection
974
1017
  }
1018
+ }
1019
+
1020
+ const notFoundToThrow =
1021
+ firstNotFound ??
1022
+ (beforeLoadNotFound && !inner.preload ? beforeLoadNotFound : undefined)
1023
+
1024
+ let headMaxIndex = inner.serialError
1025
+ ? (inner.firstBadMatchIndex ?? 0)
1026
+ : inner.matches.length - 1
1027
+
1028
+ if (!notFoundToThrow && beforeLoadNotFound && inner.preload) {
1029
+ return inner.matches
1030
+ }
1031
+
1032
+ if (notFoundToThrow) {
1033
+ // Determine once which matched route will actually render the
1034
+ // notFoundComponent, then pass this precomputed index through the remaining
1035
+ // finalization steps.
1036
+ // This can differ from the throwing route when routeId targets an ancestor
1037
+ // boundary (or when bubbling resolves to a parent/root boundary).
1038
+ const renderedBoundaryIndex = getNotFoundBoundaryIndex(
1039
+ inner,
1040
+ notFoundToThrow,
1041
+ )
1042
+
1043
+ invariant(
1044
+ renderedBoundaryIndex !== undefined,
1045
+ 'Could not find match for notFound boundary',
1046
+ )
1047
+ const boundaryMatch = inner.matches[renderedBoundaryIndex]!
975
1048
 
976
- // Throw notFound after head execution
977
- if (firstNotFound) {
978
- throw firstNotFound
1049
+ const boundaryRoute = inner.router.looseRoutesById[boundaryMatch.routeId]!
1050
+ const defaultNotFoundComponent = (inner.router.options as any)
1051
+ ?.defaultNotFoundComponent
1052
+
1053
+ // Ensure a notFoundComponent exists on the boundary route
1054
+ if (!boundaryRoute.options.notFoundComponent && defaultNotFoundComponent) {
1055
+ boundaryRoute.options.notFoundComponent = defaultNotFoundComponent
979
1056
  }
980
1057
 
981
- const readyPromise = triggerOnReady(inner)
982
- if (isPromise(readyPromise)) await readyPromise
983
- } catch (err) {
984
- if (isNotFound(err) && !inner.preload) {
985
- const readyPromise = triggerOnReady(inner)
986
- if (isPromise(readyPromise)) await readyPromise
987
- throw err
1058
+ notFoundToThrow.routeId = boundaryMatch.routeId
1059
+
1060
+ const boundaryIsRoot = boundaryMatch.routeId === inner.router.routeTree.id
1061
+
1062
+ inner.updateMatch(boundaryMatch.id, (prev) => ({
1063
+ ...prev,
1064
+ ...(boundaryIsRoot
1065
+ ? // For root boundary, use globalNotFound so the root component's
1066
+ // shell still renders and <Outlet> handles the not-found display,
1067
+ // instead of replacing the entire root shell via status='notFound'.
1068
+ { status: 'success' as const, globalNotFound: true, error: undefined }
1069
+ : // For non-root boundaries, set status:'notFound' so MatchInner
1070
+ // renders the notFoundComponent directly.
1071
+ { status: 'notFound' as const, error: notFoundToThrow }),
1072
+ isFetching: false,
1073
+ }))
1074
+
1075
+ headMaxIndex = renderedBoundaryIndex
1076
+
1077
+ // Ensure the rendering boundary route chunk (and its lazy components, including
1078
+ // lazy notFoundComponent) is loaded before we continue to head execution/render.
1079
+ await loadRouteChunk(boundaryRoute)
1080
+ } else if (!inner.preload) {
1081
+ // Clear stale root global-not-found state on normal navigations that do not
1082
+ // throw notFound. This must live here (instead of only in runLoader success)
1083
+ // because the root loader may be skipped when data is still fresh.
1084
+ const rootMatch = inner.matches[0]!
1085
+ // `rootMatch` is the next match for this navigation. If it is not global
1086
+ // not-found, then any currently stored root global-not-found is stale.
1087
+ if (!rootMatch.globalNotFound) {
1088
+ // `currentRootMatch` is the current store state (from the previous
1089
+ // navigation/load). Update only when a stale flag is actually present.
1090
+ const currentRootMatch = inner.router.getMatch(rootMatch.id)
1091
+ if (currentRootMatch?.globalNotFound) {
1092
+ inner.updateMatch(rootMatch.id, (prev) => ({
1093
+ ...prev,
1094
+ globalNotFound: false,
1095
+ error: undefined,
1096
+ }))
1097
+ }
988
1098
  }
989
- if (isRedirect(err)) {
990
- throw err
1099
+ }
1100
+
1101
+ // When a serial error occurred (e.g. beforeLoad threw a regular Error),
1102
+ // the erroring route's lazy chunk wasn't loaded because loaders were skipped.
1103
+ // We need to load it so the code-split errorComponent is available for rendering.
1104
+ if (inner.serialError && inner.firstBadMatchIndex !== undefined) {
1105
+ const errorRoute =
1106
+ inner.router.looseRoutesById[
1107
+ inner.matches[inner.firstBadMatchIndex]!.routeId
1108
+ ]!
1109
+ await loadRouteChunk(errorRoute)
1110
+ }
1111
+
1112
+ // serially execute heads once after loaders/notFound handling, ensuring
1113
+ // all head functions get a chance even if one throws.
1114
+ for (let i = 0; i <= headMaxIndex; i++) {
1115
+ const match = inner.matches[i]!
1116
+ const { id: matchId, routeId } = match
1117
+ const route = inner.router.looseRoutesById[routeId]!
1118
+ try {
1119
+ const headResult = executeHead(inner, matchId, route)
1120
+ if (headResult) {
1121
+ const head = await headResult
1122
+ inner.updateMatch(matchId, (prev) => ({
1123
+ ...prev,
1124
+ ...head,
1125
+ }))
1126
+ }
1127
+ } catch (err) {
1128
+ console.error(`Error executing head for route ${routeId}:`, err)
991
1129
  }
992
1130
  }
1131
+
1132
+ const readyPromise = triggerOnReady(inner)
1133
+ if (isPromise(readyPromise)) {
1134
+ await readyPromise
1135
+ }
1136
+
1137
+ if (notFoundToThrow) {
1138
+ throw notFoundToThrow
1139
+ }
1140
+
1141
+ if (inner.serialError && !inner.preload && !inner.onReady) {
1142
+ throw inner.serialError
1143
+ }
1144
+
993
1145
  return inner.matches
994
1146
  }
995
1147
 
package/src/router.ts CHANGED
@@ -2364,6 +2364,7 @@ export class RouterCore<
2364
2364
  let redirect: AnyRedirect | undefined
2365
2365
  let notFound: NotFoundError | undefined
2366
2366
  let loadPromise: Promise<void>
2367
+ const previousLocation = this.state.resolvedLocation ?? this.state.location
2367
2368
 
2368
2369
  // eslint-disable-next-line prefer-const
2369
2370
  loadPromise = new Promise<void>((resolve) => {
@@ -2394,6 +2395,7 @@ export class RouterCore<
2394
2395
  await loadMatches({
2395
2396
  router: this,
2396
2397
  sync: opts?.sync,
2398
+ forceStaleReload: previousLocation.href === next.href,
2397
2399
  matches: this.state.pendingMatches as Array<AnyRouteMatch>,
2398
2400
  location: next,
2399
2401
  updateMatch: this.updateMatch,