@tanstack/router-core 1.171.23 → 1.171.25

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.
@@ -163,18 +163,30 @@ const NOT_FOUND = 2
163
163
  // Control outcomes stay contiguous so the hot path can test them together.
164
164
  const REDIRECTED = 3
165
165
  const CANCELED = 4
166
+ const CANCELED_OUTCOME: [kind: typeof CANCELED] = [CANCELED]
166
167
 
167
- type LoaderOutcome =
168
+ type RedirectOutcome = [
169
+ kind: typeof REDIRECTED,
170
+ redirect: AnyRedirect,
171
+ location?: ParsedLocation,
172
+ ]
173
+
174
+ type NonRedirectOutcome =
168
175
  | [kind: typeof SUCCESS, data: unknown]
169
176
  | [kind: typeof ERROR, error: unknown]
170
177
  | [kind: typeof NOT_FOUND, error: NotFoundError]
171
- | [kind: typeof REDIRECTED, redirect: AnyRedirect]
172
178
  | [kind: typeof CANCELED]
173
179
 
180
+ type RawLoaderOutcome =
181
+ | NonRedirectOutcome
182
+ | [kind: typeof REDIRECTED, redirect: AnyRedirect]
183
+
184
+ type LoaderOutcome = NonRedirectOutcome | RedirectOutcome
185
+
174
186
  type IndexedOutcome = [index: number, outcome: LoaderOutcome, boundary?: number]
175
187
 
176
188
  export type LoaderFlight = [
177
- outcome: Promise<LoaderOutcome>,
189
+ outcome: Promise<RawLoaderOutcome>,
178
190
  controller: AbortController,
179
191
  leases: number,
180
192
  ]
@@ -202,25 +214,20 @@ export type LoadTransaction = [
202
214
  startedAt: number,
203
215
  done: Promise<void>,
204
216
  /**
205
- * Dev-only HMR refresh mode. Presence is the mode flag; a refresh always
206
- * carries the presentation it started from and its optional hydration
207
- * handoff. While a publication awaits acknowledgement, its rollback lives
208
- * with the transaction that owns the publication.
217
+ * Dev-only HMR refresh mode. Presence forces successor rematerialization
218
+ * until this publication is acknowledged. The optional hydration handoff is
219
+ * retired when the refresh publishes.
209
220
  */
210
- refresh?: [
211
- presentation: Array<AnyRouteMatch>,
212
- handoff: NonNullable<AnyRouter['_handoff']> | undefined,
213
- rollback?: () => boolean,
214
- ],
221
+ refresh?: [handoff: NonNullable<AnyRouter['_handoff']> | undefined],
215
222
  ]
216
223
 
217
224
  export type PendingSession = [
218
- owner: LoadTransaction,
219
- boundary: number,
225
+ generation: LoadTransaction,
226
+ boundaryId: string,
220
227
  /** Pending reveal time until acknowledged, then minimum-visible-until time. */
221
228
  deadline: number,
222
- timer?: ReturnType<typeof setTimeout>,
223
- ack?: Promise<boolean>,
229
+ revealTimer?: ReturnType<typeof setTimeout>,
230
+ ack?: Promise<boolean> | true,
224
231
  component?: unknown,
225
232
  ]
226
233
 
@@ -228,15 +235,6 @@ type CoordinatorRouter = AnyRouter & {
228
235
  /** Active speculative lanes retained for cancellation, invalidation, and cache clearing. */
229
236
  _preloads?: Map<AbortController, Array<AnyRouteMatch>>
230
237
  _refreshNextLoad?: boolean
231
- _cancelTransition?: () => void
232
- }
233
-
234
- type PublicationCheckpoint = {
235
- previousMatches: Array<AnyRouteMatch>
236
- previousPresentation: Array<AnyRouteMatch>
237
- previousCache: Map<string, AnyRouteMatch>
238
- commitPromise: CoordinatorRouter['_commitPromise']
239
- published: boolean
240
238
  }
241
239
 
242
240
  type LoaderTask = [
@@ -256,7 +254,6 @@ type BackgroundLoaderTask = [
256
254
  type ExecuteLaneOptions = [
257
255
  controller: AbortController,
258
256
  redirects: number,
259
- isCurrent: () => boolean,
260
257
  base: Array<AnyRouteMatch>,
261
258
  preload?: boolean,
262
259
  sync?: boolean,
@@ -265,9 +262,7 @@ type ExecuteLaneOptions = [
265
262
  onReady?: () => void,
266
263
  ]
267
264
 
268
- type ControlOutcome =
269
- | [kind: typeof REDIRECTED, redirect: AnyRedirect]
270
- | [kind: typeof CANCELED]
265
+ type ControlOutcome = RedirectOutcome | [kind: typeof CANCELED]
271
266
 
272
267
  type LaneResult = ProjectedLane | ControlOutcome
273
268
 
@@ -301,7 +296,7 @@ function normalize(
301
296
  value: unknown,
302
297
  rejected: boolean,
303
298
  routeId?: string,
304
- ): LoaderOutcome {
299
+ ): RawLoaderOutcome {
305
300
  if (isRedirect(value)) {
306
301
  return [REDIRECTED, value]
307
302
  }
@@ -315,7 +310,7 @@ function normalize(
315
310
  return rejected ? [ERROR, value] : [SUCCESS, value]
316
311
  }
317
312
 
318
- function normalizeError(route: AnyRoute, cause: unknown): LoaderOutcome {
313
+ function normalizeError(route: AnyRoute, cause: unknown): RawLoaderOutcome {
319
314
  let outcome = normalize(cause, true, route.id)
320
315
  if (outcome[0 /* kind */] !== ERROR) {
321
316
  return outcome
@@ -329,26 +324,22 @@ function normalizeError(route: AnyRoute, cause: unknown): LoaderOutcome {
329
324
  }
330
325
 
331
326
  function normalizeLaneError(
327
+ router: AnyRouter,
328
+ lane: Lane<any>,
332
329
  route: AnyRoute,
333
330
  cause: unknown,
334
331
  options: ExecuteLaneOptions,
335
332
  ): LoaderOutcome {
336
- if (
337
- options[0 /* controller */].signal.aborted ||
338
- !options[2 /* isCurrent */]()
339
- ) {
340
- options[0 /* controller */].abort()
341
- return [CANCELED]
333
+ if (options[0 /* controller */].signal.aborted) {
334
+ return CANCELED_OUTCOME
342
335
  }
343
- return normalizeError(route, cause)
344
- }
345
-
346
- export function navigateFrom(router: AnyRouter, location: ParsedLocation) {
347
- return (opts: any) =>
348
- router.navigate({
349
- ...opts,
350
- _fromLocation: location,
351
- })
336
+ return materializeRedirect(
337
+ router,
338
+ lane,
339
+ route,
340
+ normalizeError(route, cause),
341
+ options,
342
+ )
352
343
  }
353
344
 
354
345
  async function contextualize(
@@ -361,8 +352,8 @@ async function contextualize(
361
352
  ): Promise<IndexedOutcome | undefined> {
362
353
  const [location, matches] = lane
363
354
  const signal = options[0 /* controller */].signal
364
- const preload = !!options[4 /* preload */]
365
- for (let index = options[7 /* resolvedPrefix */] ?? 0; index < end; index++) {
355
+ const preload = !!options[3 /* preload */]
356
+ for (let index = options[6 /* resolvedPrefix */] ?? 0; index < end; index++) {
366
357
  const match = matches[index]!
367
358
  const route = getRoute(router, match)
368
359
 
@@ -374,7 +365,11 @@ async function contextualize(
374
365
  const common = {
375
366
  params: match.params,
376
367
  location,
377
- navigate: navigateFrom(router, location),
368
+ navigate: (opts: any) =>
369
+ router.navigate({
370
+ ...opts,
371
+ _fromLocation: location,
372
+ }),
378
373
  buildLocation: router.buildLocation,
379
374
  cause: preload ? ('preload' as const) : match.cause,
380
375
  abortController: options[0 /* controller */],
@@ -400,16 +395,18 @@ async function contextualize(
400
395
  match.context = context
401
396
  } catch (cause) {
402
397
  releaseFlight(router, match)
403
- return [index, normalizeLaneError(route, cause, options)]
398
+ return [index, normalizeLaneError(router, lane, route, cause, options)]
404
399
  }
405
- if (signal.aborted || !options[2 /* isCurrent */]()) {
406
- options[0 /* controller */].abort()
407
- return [index, [CANCELED]]
400
+ if (signal.aborted) {
401
+ return [index, CANCELED_OUTCOME]
408
402
  }
409
403
  const validationError = match.paramsError ?? match.searchError
410
404
  if (validationError !== undefined) {
411
405
  releaseFlight(router, match)
412
- return [index, normalizeLaneError(route, validationError, options)]
406
+ return [
407
+ index,
408
+ normalizeLaneError(router, lane, route, validationError, options),
409
+ ]
413
410
  }
414
411
  const beforeLoad = route.options.beforeLoad
415
412
  if (!beforeLoad) {
@@ -434,19 +431,23 @@ async function contextualize(
434
431
  }
435
432
 
436
433
  const previousStatus = match.status
437
- // Retain only a success that is mounted through the same valid prefix.
438
- if (previousStatus === 'success' && index >= retainedEnd) {
434
+ if (index >= retainedEnd) {
439
435
  match.status = 'pending'
436
+ options[7 /* onReady */]?.()
440
437
  }
441
- options[8 /* onReady */]?.()
442
438
  try {
443
439
  setFetching(router, match, 'beforeLoad', options[0 /* controller */])
444
440
  const result = await waitFor(beforeLoad(beforeLoadContext), signal)
445
- if (!options[2 /* isCurrent */]()) {
446
- options[0 /* controller */].abort()
447
- return [index, [CANCELED]]
441
+ if (signal.aborted) {
442
+ return [index, CANCELED_OUTCOME]
448
443
  }
449
- const outcome = normalize(result, false, route.id)
444
+ const outcome = materializeRedirect(
445
+ router,
446
+ lane,
447
+ route,
448
+ normalize(result, false, route.id),
449
+ options,
450
+ )
450
451
  if (outcome[0 /* kind */] !== SUCCESS) {
451
452
  releaseFlight(router, match)
452
453
  return [index, outcome]
@@ -457,10 +458,10 @@ async function contextualize(
457
458
  }
458
459
  } catch (cause) {
459
460
  releaseFlight(router, match)
460
- return [index, normalizeLaneError(route, cause, options)]
461
+ return [index, normalizeLaneError(router, lane, route, cause, options)]
461
462
  } finally {
462
- if (previousStatus === 'success' && match.status === 'pending') {
463
- match.status = 'success'
463
+ if (match.status === 'pending') {
464
+ match.status = previousStatus
464
465
  }
465
466
  setFetching(router, match, false, options[0 /* controller */])
466
467
  }
@@ -484,7 +485,6 @@ function releaseOwnedFlight(
484
485
  if (
485
486
  current &&
486
487
  !current[0 /* controller */].signal.aborted &&
487
- !(process.env.NODE_ENV !== 'production' && current[6 /* refresh */]) &&
488
488
  !current[3 /* matches */].includes(match) &&
489
489
  current[3 /* matches */].some((candidate) => candidate.id === match.id) &&
490
490
  current[3 /* matches */].some(
@@ -524,9 +524,6 @@ function transferMatchResources(
524
524
  deferSameIdFlight &&
525
525
  flight?.[2 /* leases */] === 1 &&
526
526
  router._flights?.get(match.id) === flight &&
527
- !(
528
- process.env.NODE_ENV !== 'production' && router._tx?.[6 /* refresh */]
529
- ) &&
530
527
  next?.some((candidate) => candidate.id === match.id)
531
528
  ) {
532
529
  // The successor has not made its same-ID reload decision yet.
@@ -583,7 +580,11 @@ function getLoaderContext(
583
580
  return {
584
581
  params: match.params,
585
582
  location,
586
- navigate: navigateFrom(router, location),
583
+ navigate: (opts: any) =>
584
+ router.navigate({
585
+ ...opts,
586
+ _fromLocation: location,
587
+ }),
587
588
  cause: preload ? ('preload' as const) : match.cause,
588
589
  abortController: controller,
589
590
  preload,
@@ -602,12 +603,12 @@ async function loadResource(
602
603
  route: AnyRoute,
603
604
  loader: RouteLoaderFn<any> | undefined,
604
605
  parentMatchPromise: Promise<WorkMatch> | undefined,
605
- preload: boolean,
606
- owner: AbortController,
606
+ options: ExecuteLaneOptions,
607
607
  ): Promise<LoaderOutcome> {
608
+ const owner = options[0 /* controller */]
608
609
  const signal = owner.signal
609
610
  if (signal.aborted) {
610
- return [CANCELED]
611
+ return CANCELED_OUTCOME
611
612
  }
612
613
  if (!loader) {
613
614
  return [SUCCESS, undefined]
@@ -629,7 +630,7 @@ async function loadResource(
629
630
  route,
630
631
  controller,
631
632
  parentMatchPromise,
632
- preload,
633
+ !!options[3 /* preload */],
633
634
  ),
634
635
  ),
635
636
  )
@@ -637,7 +638,7 @@ async function loadResource(
637
638
  (value) => normalize(value, false, route.id),
638
639
  (cause) => normalize(cause, true, route.id),
639
640
  )
640
- .then((result): LoaderOutcome => {
641
+ .then((result): RawLoaderOutcome => {
641
642
  // The registry controls discovery; leases keep current consumers
642
643
  // sharing the same terminal outcome.
643
644
  if (
@@ -660,13 +661,19 @@ async function loadResource(
660
661
  }
661
662
  match._flight = flight
662
663
  match.abortController = flight[1 /* controller */]
663
- return await waitFor(flight[0 /* outcome */], signal)
664
+ return materializeRedirect(
665
+ router,
666
+ lane,
667
+ route,
668
+ await waitFor(flight[0 /* outcome */], signal),
669
+ options,
670
+ )
664
671
  } catch (cause) {
665
- if (cause !== signal) {
672
+ if (cause !== signal || !signal.aborted) {
666
673
  throw cause
667
674
  }
668
675
  releaseFlight(router, match)
669
- return [CANCELED]
676
+ return CANCELED_OUTCOME
670
677
  } finally {
671
678
  setFetching(router, match, false, owner)
672
679
  }
@@ -749,7 +756,7 @@ function createLoaderTask(
749
756
  ): Promise<WorkMatch> {
750
757
  const match = lane[1 /* matches */][index]!
751
758
  const route = getRoute(router, match)
752
- const preload = !!options[4 /* preload */]
759
+ const preload = !!options[3 /* preload */]
753
760
  const plannedCacheMatch = router._cache.get(match.id)
754
761
  let configured
755
762
  let reload = false
@@ -770,9 +777,8 @@ function createLoaderTask(
770
777
  ),
771
778
  )
772
779
  }
773
- if (!options[2 /* isCurrent */]()) {
774
- options[0 /* controller */].abort()
775
- reloadFailure = [CANCELED]
780
+ if (options[0 /* controller */].signal.aborted) {
781
+ reloadFailure = CANCELED_OUTCOME
776
782
  }
777
783
  }
778
784
  if (!reloadFailure) {
@@ -780,7 +786,7 @@ function createLoaderTask(
780
786
  reload = true
781
787
  } else {
782
788
  const staleAge =
783
- options[4 /* preload */] || match.preload
789
+ options[3 /* preload */] || match.preload
784
790
  ? (route.options.preloadStaleTime ??
785
791
  router.options.defaultPreloadStaleTime ??
786
792
  30_000)
@@ -790,9 +796,9 @@ function createLoaderTask(
790
796
  configured ||
791
797
  (configured === undefined &&
792
798
  Date.now() - match.updatedAt >= staleAge &&
793
- (options[6 /* forceStaleReload */] ||
799
+ (options[5 /* forceStaleReload */] ||
794
800
  match.cause === 'enter' ||
795
- options[3 /* base */].some(
801
+ options[2 /* base */].some(
796
802
  (candidate) =>
797
803
  candidate.routeId === match.routeId &&
798
804
  candidate.id !== match.id,
@@ -803,7 +809,7 @@ function createLoaderTask(
803
809
  } catch (cause) {
804
810
  match.invalid = true
805
811
  releaseFlight(router, match)
806
- reloadFailure = normalizeLaneError(route, cause, options)
812
+ reloadFailure = normalizeLaneError(router, lane, route, cause, options)
807
813
  }
808
814
  const routeLoader = route.options.loader
809
815
  const loader =
@@ -828,7 +834,7 @@ function createLoaderTask(
828
834
  reload &&
829
835
  match.status === 'success' &&
830
836
  !preload &&
831
- !options[5 /* sync */] &&
837
+ !options[4 /* sync */] &&
832
838
  ((typeof routeLoader === 'function'
833
839
  ? undefined
834
840
  : routeLoader?.staleReloadMode) ??
@@ -837,8 +843,8 @@ function createLoaderTask(
837
843
  const loaded = reload && (!preload || route.options.preload !== false)
838
844
  const blocking =
839
845
  loaded && !background && (match.status !== 'success' || !!routeLoader)
840
- const onLazyReady =
841
- route.lazyFn && route._lazy !== true ? options[8 /* onReady */] : undefined
846
+ const onReady = index >= retainedEnd ? options[7 /* onReady */] : undefined
847
+ const onLazyReady = route.lazyFn && route._lazy !== true ? onReady : undefined
842
848
  if (loaded && !routeLoader) {
843
849
  match.invalid = false
844
850
  match.updatedAt = Date.now()
@@ -850,17 +856,17 @@ function createLoaderTask(
850
856
  const acceptedFlight = match._flight
851
857
  match._flight = donor
852
858
  releaseOwnedFlight(router, match, acceptedFlight)?.abort()
853
- // A successful route without a loader has no blocking work to present. A
854
- // mounted success likewise remains renderable while its loader revalidates.
855
- if (match.status === 'success' && index >= retainedEnd) {
859
+ // A mounted success remains renderable while its loader revalidates. Every
860
+ // non-retained blocking generation presents pending state.
861
+ if (index >= retainedEnd) {
856
862
  match.status = 'pending'
857
863
  }
858
- options[8 /* onReady */]?.()
864
+ onReady?.()
859
865
  }
860
866
  if (!loaded) {
861
867
  match.isFetching = false
862
868
  }
863
- const rawOutcome = reloadFailure
869
+ const loaderOutcome = reloadFailure
864
870
  ? Promise.resolve(reloadFailure)
865
871
  : !blocking
866
872
  ? Promise.resolve<LoaderOutcome>([SUCCESS, match.loaderData])
@@ -871,55 +877,54 @@ function createLoaderTask(
871
877
  route,
872
878
  loader,
873
879
  semanticParent,
874
- preload,
875
- options[0 /* controller */],
880
+ options,
876
881
  )
877
- const outcome = rawOutcome.then((result) => {
882
+ const outcome = loaderOutcome.then((result) => {
878
883
  if (blocking) {
879
884
  settleInto(match, result, preload)
880
885
  if (result[0 /* kind */] === SUCCESS) {
881
886
  // A settled generation can outlive its lane without keeping unresolved
882
- // navigation work alive. Refresh generations remain private for rollback.
883
- if (
884
- routeLoader &&
885
- !options[0 /* controller */].signal.aborted &&
886
- !(
887
- process.env.NODE_ENV !== 'production' &&
888
- !preload &&
889
- router._tx?.[6 /* refresh */]
890
- )
891
- ) {
887
+ // navigation work alive.
888
+ if (routeLoader && !options[0 /* controller */].signal.aborted) {
892
889
  cacheLoaderMatch(router, match, plannedCacheMatch)
893
890
  }
894
891
  // A route is renderable only after both its data and normal component
895
892
  // chunk are ready. Its loader data is already available to descendants.
896
- match.status = 'pending'
893
+ if (index >= retainedEnd) {
894
+ match.status = 'pending'
895
+ }
897
896
  }
898
897
  }
899
898
  return result
900
899
  })
901
900
 
902
- const rawChunkFailure = waitFor(
901
+ const chunkOutcome = waitFor(
903
902
  Promise.resolve().then(() => loadRouteChunk(route, undefined, onLazyReady)),
904
903
  options[0 /* controller */].signal,
905
904
  ).then(
906
905
  () => undefined,
907
- (cause): IndexedOutcome => [
908
- index,
909
- normalizeLaneError(route, cause, options),
910
- ],
906
+ (cause): IndexedOutcome | undefined =>
907
+ lane[1 /* matches */].some(
908
+ (candidate, candidateIndex) =>
909
+ candidateIndex <= index &&
910
+ (candidate.status === 'error' ||
911
+ candidate.status === 'notFound' ||
912
+ candidate._notFound),
913
+ )
914
+ ? undefined
915
+ : [index, normalizeLaneError(router, lane, route, cause, options)],
911
916
  )
912
- const chunkFailure = rawChunkFailure.then((failure) =>
917
+ const chunkFailure = chunkOutcome.then((failure) =>
913
918
  outcome.then((result) => {
914
919
  if (
915
920
  blocking &&
916
921
  !failure &&
917
922
  result[0 /* kind */] === SUCCESS &&
918
923
  match.status === 'pending' &&
919
- options[2 /* isCurrent */]()
924
+ !options[0 /* controller */].signal.aborted
920
925
  ) {
921
926
  match.status = 'success'
922
- options[8 /* onReady */]?.()
927
+ onReady?.()
923
928
  }
924
929
  return failure
925
930
  }),
@@ -943,8 +948,7 @@ function createLoaderTask(
943
948
  route,
944
949
  loader,
945
950
  semanticParent,
946
- false,
947
- options[0 /* controller */],
951
+ options,
948
952
  ).then((result) => {
949
953
  match.isFetching = false
950
954
  settleInto(candidate, result, false)
@@ -979,14 +983,14 @@ async function getNotFoundBoundary(
979
983
  }
980
984
  for (let i = index; i >= 0; i--) {
981
985
  const route = getRoute(router, matches[i]!)
982
- const loading = loadRouteChunk(route, false)
983
- if (loading) {
984
- try {
986
+ try {
987
+ const loading = loadRouteChunk(route, false)
988
+ if (loading) {
985
989
  await waitFor(loading, signal)
986
- } catch (cause) {
987
- if (cause === signal) {
988
- throw cause
989
- }
990
+ }
991
+ } catch (cause) {
992
+ if (cause === signal && signal.aborted) {
993
+ throw cause
990
994
  }
991
995
  }
992
996
  if (route.options.notFoundComponent) {
@@ -1054,12 +1058,50 @@ async function settleTasks(
1054
1058
  return serialFailure ?? loaderFailure
1055
1059
  }
1056
1060
 
1061
+ function materializeRedirect(
1062
+ router: AnyRouter,
1063
+ lane: Lane<any>,
1064
+ route: AnyRoute,
1065
+ outcome: RawLoaderOutcome,
1066
+ options: ExecuteLaneOptions,
1067
+ failed?: true,
1068
+ ): LoaderOutcome {
1069
+ while (outcome[0 /* kind */] === REDIRECTED) {
1070
+ const redirect = outcome[1 /* redirect */]
1071
+ if (
1072
+ redirect.options.reloadDocument
1073
+ ? options[3 /* preload */]
1074
+ : options[1 /* redirects */] >= 20
1075
+ ) {
1076
+ return outcome
1077
+ }
1078
+ try {
1079
+ if (redirect.options.href && redirect.options.reloadDocument) {
1080
+ router.resolveRedirect(redirect)
1081
+ return outcome
1082
+ }
1083
+ return [
1084
+ REDIRECTED,
1085
+ redirect,
1086
+ router.buildLocation({
1087
+ ...redirect.options,
1088
+ _fromLocation: lane[0 /* location */],
1089
+ _includeValidateSearch: true,
1090
+ }),
1091
+ ]
1092
+ } catch (cause) {
1093
+ outcome = failed ? [ERROR, cause] : normalizeError(route, cause)
1094
+ failed = true
1095
+ }
1096
+ }
1097
+ return outcome
1098
+ }
1099
+
1057
1100
  async function reduceLane(
1058
1101
  router: AnyRouter,
1059
1102
  lane: ContextualizedLane,
1060
1103
  tasks: Array<LoaderTask>,
1061
1104
  controller: AbortController,
1062
- redirects: number,
1063
1105
  settlement: Promise<IndexedOutcome | undefined>,
1064
1106
  onReady?: () => void,
1065
1107
  ): Promise<ReducedLane | ControlOutcome> {
@@ -1112,7 +1154,7 @@ async function reduceLane(
1112
1154
  if (
1113
1155
  outcome[0 /* kind */] !== REDIRECTED ||
1114
1156
  outcome[1 /* redirect */].options.reloadDocument ||
1115
- redirects < 20
1157
+ outcome[2 /* location */]
1116
1158
  ) {
1117
1159
  discardBackground(router, lane)
1118
1160
  return outcome as ControlOutcome
@@ -1148,6 +1190,9 @@ async function reduceLane(
1148
1190
  }
1149
1191
  }
1150
1192
  install()
1193
+ if (!outcome) {
1194
+ onReady?.()
1195
+ }
1151
1196
  const route = getRoute(router, match)
1152
1197
  try {
1153
1198
  await waitFor<unknown>(
@@ -1165,14 +1210,13 @@ async function reduceLane(
1165
1210
  controller.signal,
1166
1211
  )
1167
1212
  } catch (cause) {
1168
- if (cause === controller.signal) {
1213
+ if (cause === controller.signal && controller.signal.aborted) {
1169
1214
  discardBackground(router, lane)
1170
- return [CANCELED]
1215
+ return CANCELED_OUTCOME
1171
1216
  }
1172
1217
  }
1173
1218
  if (!outcome) {
1174
1219
  match.status = 'success'
1175
- onReady?.()
1176
1220
  } else if (redirectLimitExceeded) {
1177
1221
  controller.abort()
1178
1222
  await Promise.all([
@@ -1224,7 +1268,7 @@ export async function projectLane(
1224
1268
  match.styles = head?.styles
1225
1269
  match.scripts = scripts
1226
1270
  } catch (cause) {
1227
- if (cause === signal) {
1271
+ if (cause === signal && signal.aborted) {
1228
1272
  break
1229
1273
  }
1230
1274
  console.error(cause)
@@ -1244,111 +1288,112 @@ async function executeClientLane(
1244
1288
  options: ExecuteLaneOptions,
1245
1289
  ): Promise<LaneResult> {
1246
1290
  const matched = [location, matches as Array<WorkMatch>] as MatchedLane
1247
- const presented = router.stores.matches.get()
1248
- let plannedBoundary = matches.findIndex((match) => match._notFound)
1249
- if (router.options.notFoundMode !== 'root' && plannedBoundary >= 0) {
1250
- const boundary = await getNotFoundBoundary(
1251
- router,
1252
- matched[1 /* matches */],
1253
- undefined,
1254
- options[0 /* controller */].signal,
1255
- plannedBoundary,
1256
- )
1257
- if (boundary !== plannedBoundary) {
1258
- matches[plannedBoundary]!._notFound = undefined
1259
- matches[boundary]!._notFound = true
1260
- }
1261
- plannedBoundary = boundary
1262
- }
1263
- let end = plannedBoundary < 0 ? matches.length : plannedBoundary + 1
1264
- let retainedEnd = 0
1265
- while (retainedEnd < end && retainedEnd !== plannedBoundary) {
1266
- const match = matches[retainedEnd]!
1267
- const committed = options[3 /* base */][retainedEnd]
1268
- const visible = presented[retainedEnd]
1269
- if (
1270
- committed?.id !== match.id ||
1271
- committed.status !== 'success' ||
1272
- committed._notFound ||
1273
- match.preload ||
1274
- visible?.id !== match.id ||
1275
- visible.status !== 'success' ||
1276
- visible._notFound
1277
- ) {
1278
- break
1279
- }
1280
- retainedEnd++
1281
- }
1282
- const tasks: Array<LoaderTask> = []
1283
- const start = options[7 /* resolvedPrefix */] ?? 0
1284
- let semanticParent = start
1285
- ? Promise.resolve(matched[1 /* matches */][start - 1]!)
1286
- : undefined
1287
- const planSuccessfulLane = () => {
1288
- for (let index = start; index < end; index++) {
1289
- if (options[0 /* controller */].signal.aborted) {
1290
- break
1291
- }
1292
- semanticParent = createLoaderTask(
1293
- router,
1294
- matched as ContextualizedLane,
1295
- index,
1296
- tasks,
1297
- semanticParent,
1298
- options,
1299
- retainedEnd,
1300
- )
1301
- }
1302
- }
1303
- // From here on `matched` is contextualized: `contextualize` communicates
1304
- // through mutation plus a failure return, so the phase brand is asserted at
1305
- // the two use sites below rather than granted by a (byte-costing) return.
1306
- const failure = await contextualize(
1307
- router,
1308
- matched,
1309
- options,
1310
- end,
1311
- planSuccessfulLane,
1312
- retainedEnd,
1313
- )
1314
- if (failure) {
1315
- options[5 /* sync */] = true
1316
- end = failure[0 /* index */]
1317
- if (failure[1 /* outcome */][0 /* kind */] === NOT_FOUND) {
1318
- failure[2 /* boundary */] = await getNotFoundBoundary(
1291
+ const signal = options[0 /* controller */].signal
1292
+ let reduced: ReducedLane | ControlOutcome
1293
+ try {
1294
+ const presented = router.stores.matches.get()
1295
+ let plannedBoundary = matches.findIndex((match) => match._notFound)
1296
+ if (router.options.notFoundMode !== 'root' && plannedBoundary >= 0) {
1297
+ const boundary = await getNotFoundBoundary(
1319
1298
  router,
1320
1299
  matched[1 /* matches */],
1321
- failure,
1322
- options[0 /* controller */].signal,
1300
+ undefined,
1301
+ signal,
1302
+ plannedBoundary,
1323
1303
  )
1324
- end = Math.min(end, failure[2 /* boundary */] + 1)
1325
- } else if (failure[1 /* outcome */][0 /* kind */] >= REDIRECTED) {
1326
- end = 0
1327
- }
1328
- planSuccessfulLane()
1329
- }
1330
- if (options[2 /* isCurrent */]() && !options[4 /* preload */]) {
1331
- const abort: Array<AbortController> = []
1332
- for (const [id, flight] of router._flights ?? []) {
1333
- if (!flight[2 /* leases */]) {
1334
- router._flights!.delete(id)
1335
- abort.push(flight[1 /* controller */])
1304
+ if (boundary !== plannedBoundary) {
1305
+ matches[plannedBoundary]!._notFound = undefined
1306
+ matches[boundary]!._notFound = true
1307
+ }
1308
+ plannedBoundary = boundary
1309
+ }
1310
+ let end = plannedBoundary < 0 ? matches.length : plannedBoundary + 1
1311
+ let retainedEnd = 0
1312
+ while (retainedEnd < end && retainedEnd !== plannedBoundary) {
1313
+ const match = matches[retainedEnd]!
1314
+ const committed = options[2 /* base */][retainedEnd]
1315
+ const visible = presented[retainedEnd]
1316
+ if (
1317
+ committed?.id !== match.id ||
1318
+ committed.status !== 'success' ||
1319
+ committed._notFound ||
1320
+ match.preload ||
1321
+ visible?.id !== match.id ||
1322
+ visible.status !== 'success' ||
1323
+ visible._notFound
1324
+ ) {
1325
+ break
1336
1326
  }
1327
+ retainedEnd++
1337
1328
  }
1338
- for (const controller of abort) {
1339
- controller.abort()
1329
+ const tasks: Array<LoaderTask> = []
1330
+ const start = options[6 /* resolvedPrefix */] ?? 0
1331
+ let semanticParent = start
1332
+ ? Promise.resolve(matched[1 /* matches */][start - 1]!)
1333
+ : undefined
1334
+ const planSuccessfulLane = () => {
1335
+ for (let index = start; index < end; index++) {
1336
+ if (signal.aborted) {
1337
+ break
1338
+ }
1339
+ semanticParent = createLoaderTask(
1340
+ router,
1341
+ matched as ContextualizedLane,
1342
+ index,
1343
+ tasks,
1344
+ semanticParent,
1345
+ options,
1346
+ retainedEnd,
1347
+ )
1348
+ }
1349
+ }
1350
+ // From here on `matched` is contextualized: `contextualize` communicates
1351
+ // through mutation plus a failure return, so the phase brand is asserted at
1352
+ // the two use sites below rather than granted by a (byte-costing) return.
1353
+ const failure = await contextualize(
1354
+ router,
1355
+ matched,
1356
+ options,
1357
+ end,
1358
+ planSuccessfulLane,
1359
+ retainedEnd,
1360
+ )
1361
+ if (failure) {
1362
+ options[4 /* sync */] = true
1363
+ end = failure[0 /* index */]
1364
+ if (failure[1 /* outcome */][0 /* kind */] === NOT_FOUND) {
1365
+ const boundary = await getNotFoundBoundary(
1366
+ router,
1367
+ matched[1 /* matches */],
1368
+ failure,
1369
+ signal,
1370
+ )
1371
+ failure[2 /* boundary */] = boundary
1372
+ end = Math.min(end, boundary + 1)
1373
+ } else if (failure[1 /* outcome */][0 /* kind */] >= REDIRECTED) {
1374
+ end = 0
1375
+ }
1376
+ planSuccessfulLane()
1377
+ }
1378
+ if (!signal.aborted && !options[3 /* preload */]) {
1379
+ const abort: Array<AbortController> = []
1380
+ for (const [id, flight] of router._flights ?? []) {
1381
+ if (!flight[2 /* leases */]) {
1382
+ router._flights!.delete(id)
1383
+ abort.push(flight[1 /* controller */])
1384
+ }
1385
+ }
1386
+ for (const controller of abort) {
1387
+ controller.abort()
1388
+ }
1340
1389
  }
1341
- }
1342
- let reduced: ReducedLane | ControlOutcome
1343
- try {
1344
1390
  const reduction = reduceLane(
1345
1391
  router,
1346
1392
  matched as ContextualizedLane,
1347
1393
  tasks,
1348
1394
  options[0 /* controller */],
1349
- options[1 /* redirects */],
1350
1395
  settleTasks(tasks, failure, matched[2 /* background */]),
1351
- options[8 /* onReady */],
1396
+ options[7 /* onReady */],
1352
1397
  )
1353
1398
  if (matched[2 /* background */]?.length) {
1354
1399
  matched[3 /* backgroundSettlement */] = settleTasks(
@@ -1367,6 +1412,9 @@ async function executeClientLane(
1367
1412
  reduced = await reduction
1368
1413
  } catch (cause) {
1369
1414
  discardBackground(router, matched)
1415
+ if (cause === signal && signal.aborted) {
1416
+ return CANCELED_OUTCOME
1417
+ }
1370
1418
  throw cause
1371
1419
  }
1372
1420
  if (isControl(reduced)) {
@@ -1375,9 +1423,9 @@ async function executeClientLane(
1375
1423
  return projectLane(
1376
1424
  router,
1377
1425
  reduced,
1378
- options[0 /* controller */].signal,
1379
- options[7 /* resolvedPrefix */] === reduced[1 /* matches */].length
1380
- ? options[7 /* resolvedPrefix */]
1426
+ signal,
1427
+ options[6 /* resolvedPrefix */] === reduced[1 /* matches */].length
1428
+ ? options[6 /* resolvedPrefix */]
1381
1429
  : 0,
1382
1430
  )
1383
1431
  }
@@ -1392,127 +1440,146 @@ function offerPending(router: CoordinatorRouter, tx: LoadTransaction): void {
1392
1440
  if (router._tx !== tx) {
1393
1441
  return
1394
1442
  }
1395
- let session = router._pending
1396
- let tookOver = false
1397
- const sessionMatchId =
1398
- session?.[0 /* owner */][3 /* matches */][session[1 /* boundary */]]?.id
1399
- if (session?.[0 /* owner */] !== tx) {
1400
- if (
1401
- session &&
1402
- tx[3 /* matches */][session[1 /* boundary */]]?.id === sessionMatchId
1403
- ) {
1404
- session[0 /* owner */] = tx
1405
- tookOver = true
1406
- } else {
1407
- clearTimeout(session?.[3 /* timer */])
1408
- router._pending = session = undefined
1409
- }
1410
- }
1411
1443
  const matches = tx[3 /* matches */]
1412
1444
  const presented = router.stores.matches.get()
1413
- let boundary = -1
1414
- let delay: number | undefined
1415
- let min!: number
1416
- let component: unknown
1417
- let presentedPending = false
1445
+ let session = router._pending
1418
1446
  for (let index = 0; index < matches.length; index++) {
1419
1447
  const match = matches[index]!
1420
- const success = match.status === 'success'
1421
- presentedPending =
1448
+ const success = match.status === 'success' && !match._notFound
1449
+ const presentedPending =
1422
1450
  presented[index]?.id === match.id &&
1423
1451
  presented[index]?.status === 'pending'
1424
1452
  if (success && !presentedPending) {
1425
1453
  continue
1426
1454
  }
1427
1455
  const route = getRoute(router, match as WorkMatch)
1428
- delay =
1456
+ const delay =
1429
1457
  (success && presentedPending) || match.invalid
1430
1458
  ? 0
1431
1459
  : (route.options.pendingMs ?? router.options.defaultPendingMs)
1432
- component =
1460
+ const component =
1433
1461
  route.options.pendingComponent ??
1434
1462
  (router.options as any).defaultPendingComponent
1435
1463
  if (!component || typeof delay !== 'number' || delay === Infinity) {
1464
+ if (session) {
1465
+ session[0 /* generation */] = tx
1466
+ session[2 /* deadline */] = 0
1467
+ session[4 /* ack */] = true
1468
+ }
1436
1469
  return
1437
1470
  }
1438
- boundary = index
1439
- min = route.options.pendingMinMs ?? router.options.defaultPendingMinMs ?? 0
1440
- break
1441
- }
1442
- if (boundary < 0) {
1443
- return
1444
- }
1445
- const matchId = matches[boundary]!.id
1446
- if (
1447
- !session ||
1448
- session[1 /* boundary */] !== boundary ||
1449
- sessionMatchId !== matchId
1450
- ) {
1451
- // Hydration and redirects can preserve pending presentation without a session.
1452
- // Do not delay it again; conservatively start pendingMinMs from now.
1453
- clearTimeout(session?.[3 /* timer */])
1454
- router._pending = session = [
1455
- tx,
1456
- boundary,
1457
- presentedPending ? Date.now() + min : tx[4 /* startedAt */] + delay!,
1458
- undefined,
1459
- presentedPending ? Promise.resolve(true) : undefined,
1460
- component,
1461
- ]
1462
- }
1463
- if (
1464
- session[4 /* ack */] &&
1465
- !tookOver &&
1466
- session[5 /* component */] === component
1467
- ) {
1468
- return
1469
- }
1470
- session[5 /* component */] = component
1471
- if (!session[4 /* ack */]) {
1472
- clearTimeout(session[3 /* timer */])
1473
- const remaining = session[2 /* deadline */] - Date.now()
1474
- if (remaining > 0) {
1475
- session[3 /* timer */] = setTimeout(
1476
- () => offerPending(router, tx),
1477
- remaining,
1478
- )
1471
+ const min =
1472
+ route.options.pendingMinMs ?? router.options.defaultPendingMinMs ?? 0
1473
+ let tookOver = false
1474
+ if (session?.[1 /* boundaryId */] === match.id) {
1475
+ tookOver = session[0 /* generation */] !== tx
1476
+ session[0 /* generation */] = tx
1477
+ } else {
1478
+ clearTimeout(session?.[3 /* revealTimer */])
1479
+ router._pending = session = undefined
1480
+ }
1481
+ if (!session) {
1482
+ // Hydration and redirects can preserve pending presentation without a session.
1483
+ // Do not delay it again; conservatively start pendingMinMs from now.
1484
+ router._pending = session = [
1485
+ tx,
1486
+ match.id,
1487
+ presentedPending ? Date.now() + min : tx[4 /* startedAt */] + delay,
1488
+ undefined,
1489
+ presentedPending || undefined,
1490
+ component,
1491
+ ]
1492
+ }
1493
+ if (
1494
+ session[4 /* ack */] &&
1495
+ !tookOver &&
1496
+ session[5 /* component */] === component
1497
+ ) {
1479
1498
  return
1480
1499
  }
1481
- session[2 /* deadline */] = 0
1482
- }
1483
- const offered = matches.map((match) => ({
1484
- ...match,
1485
- _flight: undefined,
1486
- }))
1487
- offered[boundary]!.status = 'pending'
1488
- const ack = router
1489
- .startTransition(() => router.stores.setMatches(offered), offered)
1490
- .then((rendered) => {
1491
- if (
1492
- rendered &&
1493
- router._pending === session &&
1494
- session[4 /* ack */] === ack &&
1495
- !session[2 /* deadline */]
1496
- ) {
1497
- session[2 /* deadline */] = Date.now() + min
1500
+ session[5 /* component */] = component
1501
+ if (!session[4 /* ack */]) {
1502
+ clearTimeout(session[3 /* revealTimer */])
1503
+ const remaining = session[2 /* deadline */] - Date.now()
1504
+ if (remaining > 0) {
1505
+ session[3 /* revealTimer */] = setTimeout(
1506
+ () => offerPending(router, tx),
1507
+ remaining,
1508
+ )
1509
+ return
1498
1510
  }
1499
- return rendered
1500
- })
1501
- session[4 /* ack */] = ack
1511
+ session[2 /* deadline */] = 0
1512
+ }
1513
+ const offered = matches.map((match) => ({
1514
+ ...match,
1515
+ _flight: undefined,
1516
+ }))
1517
+ offered[index]!.status = 'pending'
1518
+ const ack = (session[4 /* ack */] = router
1519
+ .startTransition(() => router.stores.setMatches(offered), offered)
1520
+ .then((rendered) => {
1521
+ if (
1522
+ rendered &&
1523
+ router._pending === session &&
1524
+ session![4 /* ack */] === ack &&
1525
+ !session![2 /* deadline */]
1526
+ ) {
1527
+ session![2 /* deadline */] = Date.now() + min
1528
+ }
1529
+ return rendered
1530
+ }))
1531
+ return
1532
+ }
1502
1533
  }
1503
1534
 
1504
1535
  /**
1505
- * Cancels pending UI timing when its load ends. The ownership check prevents
1506
- * an older, superseded load from clearing pending UI that a newer load took over.
1536
+ * Cancels pending UI timing unless the current successor can take over the
1537
+ * same boundary that remains painted.
1507
1538
  */
1508
1539
  function finishPending(router: CoordinatorRouter, tx: LoadTransaction): void {
1509
1540
  const session = router._pending
1510
- if (session?.[0 /* owner */] === tx) {
1511
- clearTimeout(session[3 /* timer */])
1541
+ if (
1542
+ router._tx === tx ||
1543
+ !router._tx?.[3 /* matches */].some(
1544
+ (match) => match.id === session?.[1 /* boundaryId */],
1545
+ )
1546
+ ) {
1547
+ clearTimeout(session?.[3 /* revealTimer */])
1512
1548
  router._pending = undefined
1513
1549
  }
1514
1550
  }
1515
1551
 
1552
+ async function awaitPendingMinimum(
1553
+ router: CoordinatorRouter,
1554
+ tx: LoadTransaction,
1555
+ ): Promise<void> {
1556
+ const session = router._pending
1557
+ if (!session) {
1558
+ return
1559
+ }
1560
+ clearTimeout(session[3 /* revealTimer */])
1561
+ const remaining = session[2 /* deadline */] - Date.now()
1562
+ if (
1563
+ !session[4 /* ack */] ||
1564
+ remaining <= 0 ||
1565
+ !_getRenderedMatches(tx[3 /* matches */]).some(
1566
+ (match) => match.id === session[1 /* boundaryId */],
1567
+ )
1568
+ ) {
1569
+ return
1570
+ }
1571
+ let timer: ReturnType<typeof setTimeout> | undefined
1572
+ try {
1573
+ await waitFor(
1574
+ new Promise<void>((resolve) => {
1575
+ timer = setTimeout(resolve, remaining)
1576
+ }),
1577
+ tx[0 /* controller */].signal,
1578
+ )
1579
+ } catch {}
1580
+ clearTimeout(timer)
1581
+ }
1582
+
1516
1583
  function publishMatches(
1517
1584
  router: CoordinatorRouter,
1518
1585
  matches: Array<AnyRouteMatch>,
@@ -1521,11 +1588,6 @@ function publishMatches(
1521
1588
  router.stores.setMatches(matches)
1522
1589
  }
1523
1590
 
1524
- function discardLane(router: AnyRouter, lane: ProjectedLane): void {
1525
- transferMatchResources(router, lane[1 /* matches */])
1526
- discardBackground(router, lane)
1527
- }
1528
-
1529
1591
  function commitMatches(
1530
1592
  router: CoordinatorRouter,
1531
1593
  tx: LoadTransaction,
@@ -1542,46 +1604,48 @@ function commitMatches(
1542
1604
  }
1543
1605
  const cut = _getRenderedMatches(matches).length
1544
1606
  const cached = new Map<string, AnyRouteMatch>()
1545
- const now = Date.now()
1546
- for (const match of [...previous, ...previousCached.values()]) {
1547
- // Rendered-prefix ids and settled successes anywhere in the lane are
1548
- // authoritative: retaining an older same-id generation would shadow them
1549
- // at the next planning pass. Unsettled beyond-boundary matches are not
1550
- // they must not evict a newer same-id preload.
1551
- if (
1552
- match.status !== 'success' ||
1553
- matches.some(
1554
- (candidate, index) =>
1555
- candidate.id === match.id &&
1556
- (index < cut || candidate.status === 'success'),
1607
+ if (process.env.NODE_ENV === 'production' || !tx[6 /* refresh */]) {
1608
+ const now = Date.now()
1609
+ for (const match of [...previous, ...previousCached.values()]) {
1610
+ // Rendered-prefix ids and settled successes anywhere in the lane are
1611
+ // authoritative: retaining an older same-id generation would shadow them
1612
+ // at the next planning pass. Unsettled beyond-boundary matches are not —
1613
+ // they must not evict a newer same-id preload.
1614
+ if (
1615
+ match.status !== 'success' ||
1616
+ matches.some(
1617
+ (candidate, index) =>
1618
+ candidate.id === match.id &&
1619
+ (index < cut || candidate.status === 'success'),
1620
+ )
1621
+ ) {
1622
+ continue
1623
+ }
1624
+ const work = match as WorkMatch
1625
+ const route = getRoute(router, work)
1626
+ if (
1627
+ !route.options.loader ||
1628
+ now - match.updatedAt >=
1629
+ (match.preload
1630
+ ? (route.options.preloadGcTime ??
1631
+ router.options.defaultPreloadGcTime ??
1632
+ 300_000)
1633
+ : (route.options.gcTime ?? router.options.defaultGcTime ?? 300_000))
1634
+ ) {
1635
+ continue
1636
+ }
1637
+ cached.set(
1638
+ match.id,
1639
+ previousCached.get(match.id) === match
1640
+ ? match
1641
+ : ({
1642
+ ...match,
1643
+ _flight: undefined,
1644
+ isFetching: false,
1645
+ context: {},
1646
+ } as WorkMatch),
1557
1647
  )
1558
- ) {
1559
- continue
1560
- }
1561
- const work = match as WorkMatch
1562
- const route = getRoute(router, work)
1563
- if (
1564
- !route.options.loader ||
1565
- now - match.updatedAt >=
1566
- (match.preload
1567
- ? (route.options.preloadGcTime ??
1568
- router.options.defaultPreloadGcTime ??
1569
- 300_000)
1570
- : (route.options.gcTime ?? router.options.defaultGcTime ?? 300_000))
1571
- ) {
1572
- continue
1573
1648
  }
1574
- cached.set(
1575
- match.id,
1576
- previousCached.get(match.id) === match
1577
- ? match
1578
- : ({
1579
- ...match,
1580
- _flight: undefined,
1581
- isFetching: false,
1582
- context: {},
1583
- } as WorkMatch),
1584
- )
1585
1649
  }
1586
1650
  // The lane becomes committed before publication can synchronously reenter.
1587
1651
  tx[3 /* matches */] = []
@@ -1592,155 +1656,13 @@ function commitMatches(
1592
1656
  [...previousCached.values(), ...previous],
1593
1657
  [...matches, ...cached.values()],
1594
1658
  )
1595
- runRouteLifecycle(router, previous, matches, () => router._tx === tx)
1596
- }
1597
-
1598
- function commitRefreshMatches(
1599
- router: CoordinatorRouter,
1600
- tx: LoadTransaction,
1601
- matches: LaneMatches<'projected'>,
1602
- checkpoint: PublicationCheckpoint,
1603
- ): void {
1604
- const previous = router._committed
1605
- const previousCached = router._cache
1606
- for (const match of matches) {
1607
- match.preload = false
1608
- }
1609
- const cached = new Map<string, AnyRouteMatch>()
1610
- // Delay releasing the previous owners until the HMR render is acknowledged.
1611
- // Old generations must not become reusable cache entries after refresh.
1612
- tx[3 /* matches */] = []
1613
- router._cache = cached
1614
- checkpoint.previousMatches = previous
1615
- checkpoint.previousCache = previousCached
1616
- checkpoint.published = true
1617
- publishMatches(router, matches)
1618
- if (!checkpoint.published || router._tx !== tx) {
1619
- return
1620
- }
1621
- runRouteLifecycle(router, previous, matches, () => router._tx === tx)
1622
- }
1623
-
1624
- function settlePublication(
1625
- router: CoordinatorRouter,
1626
- checkpoint: PublicationCheckpoint,
1627
- ): void {
1628
- if (!checkpoint.published) {
1629
- return
1630
- }
1631
- checkpoint.published = false
1632
- transferMatchResources(
1633
- router,
1634
- [...checkpoint.previousCache.values(), ...checkpoint.previousMatches],
1635
- [...router._cache.values(), ...router._committed],
1636
- )
1637
- }
1638
-
1639
- function rollbackPublication(
1640
- router: CoordinatorRouter,
1641
- tx: LoadTransaction,
1642
- lane: ProjectedLane,
1643
- checkpoint: PublicationCheckpoint,
1644
- ): boolean {
1645
- if (
1646
- !checkpoint.published ||
1647
- router._tx !== tx ||
1648
- router._committed !== lane[1 /* matches */]
1649
- ) {
1650
- settlePublication(router, checkpoint)
1651
- return false
1652
- }
1653
-
1654
- const discarded = [...router._cache.values(), ...router._committed]
1655
- const restored = [
1656
- ...checkpoint.previousCache.values(),
1657
- ...checkpoint.previousMatches,
1658
- ]
1659
- router._cache = checkpoint.previousCache
1660
- router._committed = checkpoint.previousMatches
1661
- checkpoint.published = false
1662
-
1663
- for (const match of discarded as Array<WorkMatch>) {
1664
- if (
1665
- !restored.includes(match) &&
1666
- match._flight &&
1667
- router._flights?.get(match.id) === match._flight
1668
- ) {
1669
- router._flights.delete(match.id)
1670
- }
1671
- }
1672
-
1673
- finishPending(router, tx)
1674
- router.batch(() => {
1675
- router.stores.status.set('idle')
1676
- router.stores.setMatches(checkpoint.previousPresentation)
1677
- })
1678
- tx[0 /* controller */].abort()
1679
- transferMatchResources(router, discarded, restored)
1680
- discardBackground(router, lane)
1681
- if (router._tx === tx && router._commitPromise === checkpoint.commitPromise) {
1682
- router._commitPromise?.resolve()
1683
- router._commitPromise = undefined
1684
- }
1685
- return true
1686
- }
1687
-
1688
- async function transitionRefresh(
1689
- router: CoordinatorRouter,
1690
- tx: LoadTransaction,
1691
- lane: ProjectedLane,
1692
- changeInfo: ReturnType<typeof getLocationChangeInfo>,
1693
- ): Promise<boolean | undefined> {
1694
- const refresh = tx[6 /* refresh */]!
1695
- const checkpoint: PublicationCheckpoint = {
1696
- previousMatches: router._committed,
1697
- previousPresentation: refresh[0 /* presentation */],
1698
- previousCache: router._cache,
1699
- commitPromise: router._commitPromise,
1700
- published: false,
1701
- }
1702
- const commit = () => {
1703
- finishPending(router, tx)
1704
- refresh[2 /* rollback */] = rollback
1705
- commitRefreshMatches(router, tx, lane[1 /* matches */], checkpoint)
1706
- if (!checkpoint.published || router._tx !== tx) {
1707
- return
1708
- }
1709
- router.emit({ type: 'onLoad', ...changeInfo })
1710
- if (router._tx === tx) {
1711
- router.emit({ type: 'onBeforeRouteMount', ...changeInfo })
1712
- }
1713
- }
1714
- const rollback = () => {
1715
- if (refresh[2 /* rollback */] === rollback) {
1716
- refresh[2 /* rollback */] = undefined
1717
- }
1718
- const restored = rollbackPublication(router, tx, lane, checkpoint)
1719
- router._cancelTransition?.()
1720
- return restored
1721
- }
1722
- try {
1723
- const rendered = await router.startTransition(commit, lane[1 /* matches */])
1724
- if (refresh[2 /* rollback */] === rollback) {
1725
- refresh[2 /* rollback */] = undefined
1726
- }
1727
- if (checkpoint.published) {
1728
- const handoff = refresh[1 /* handoff */]
1729
- if (handoff && router._handoff === handoff) {
1730
- handoff[1 /* finish */]()
1731
- }
1732
- if (router._tx === tx) {
1733
- tx[6 /* refresh */] = undefined
1734
- }
1735
- }
1736
- settlePublication(router, checkpoint)
1737
- return rendered
1738
- } catch (cause) {
1739
- if (rollback()) {
1740
- return
1659
+ if (process.env.NODE_ENV !== 'production') {
1660
+ const handoff = tx[6 /* refresh */]?.[0 /* handoff */]
1661
+ if (handoff && router._handoff === handoff) {
1662
+ handoff[1 /* finish */]()
1741
1663
  }
1742
- throw cause
1743
1664
  }
1665
+ runRouteLifecycle(router, previous, matches, tx)
1744
1666
  }
1745
1667
 
1746
1668
  async function awaitCurrent(
@@ -1760,35 +1682,44 @@ async function awaitCurrent(
1760
1682
  async function followRedirect(
1761
1683
  router: CoordinatorRouter,
1762
1684
  tx: LoadTransaction,
1763
- redirect: AnyRedirect,
1685
+ outcome: RedirectOutcome,
1764
1686
  ): Promise<void> {
1765
- await router.navigate({
1766
- ...redirect.options,
1767
- replace: true,
1768
- ignoreBlocker: true,
1769
- _redirects: tx[1 /* redirects */] + 1,
1770
- } as any)
1771
- }
1772
-
1773
- function restoreCommitted(
1774
- router: CoordinatorRouter,
1775
- tx: LoadTransaction,
1776
- ): void {
1777
- finishPending(router, tx)
1778
- tx[0 /* controller */].abort()
1779
- transferMatchResources(router, tx[3 /* matches */])
1780
- tx[3 /* matches */] = []
1781
- if (router._tx !== tx) {
1687
+ const redirect = outcome[1 /* redirect */]
1688
+ const location = outcome[2 /* location */]
1689
+ if (!location) {
1690
+ await router.navigate({
1691
+ ...redirect.options,
1692
+ replace: true,
1693
+ ignoreBlocker: true,
1694
+ } as any)
1782
1695
  return
1783
1696
  }
1784
- router.batch(() => {
1785
- router.stores.status.set('idle')
1786
- router.stores.setMatches(router._committed)
1787
- })
1788
- if (router._tx === tx) {
1789
- router._commitPromise?.resolve()
1790
- router._commitPromise = undefined
1697
+ if (redirect.options.reloadDocument) {
1698
+ await router.navigate({
1699
+ href: location.publicHref,
1700
+ reloadDocument: true,
1701
+ replace: true,
1702
+ ignoreBlocker: true,
1703
+ } as any)
1704
+ return
1791
1705
  }
1706
+ ;(location as ParsedLocation & { _redirects?: number })._redirects =
1707
+ tx[1 /* redirects */] + 1
1708
+ router._pendingLocation = location
1709
+ const committed = router.commitLocation({
1710
+ ...location,
1711
+ viewTransition: redirect.options.viewTransition,
1712
+ replace: true,
1713
+ resetScroll: redirect.options.resetScroll,
1714
+ hashScrollIntoView: redirect.options.hashScrollIntoView,
1715
+ ignoreBlocker: true,
1716
+ })
1717
+ queueMicrotask(() => {
1718
+ if (router._pendingLocation === location) {
1719
+ router._pendingLocation = undefined
1720
+ }
1721
+ })
1722
+ await committed
1792
1723
  }
1793
1724
 
1794
1725
  async function runBackground(
@@ -1814,7 +1745,6 @@ async function runBackground(
1814
1745
  lane,
1815
1746
  tasks,
1816
1747
  tx[0 /* controller */],
1817
- tx[1 /* redirects */],
1818
1748
  settlement,
1819
1749
  )
1820
1750
  } catch (cause) {
@@ -1828,7 +1758,7 @@ async function runBackground(
1828
1758
  router._tx === tx &&
1829
1759
  router._committed === base
1830
1760
  ) {
1831
- await followRedirect(router, tx, reduced[1 /* redirect */])
1761
+ await followRedirect(router, tx, reduced)
1832
1762
  }
1833
1763
  return
1834
1764
  }
@@ -1863,7 +1793,6 @@ async function runClientTransaction(
1863
1793
  const options: ExecuteLaneOptions = [
1864
1794
  tx[0 /* controller */],
1865
1795
  tx[1 /* redirects */],
1866
- () => router._tx === tx && !!tx[3 /* matches */].length,
1867
1796
  router._committed,
1868
1797
  undefined,
1869
1798
  sync,
@@ -1879,63 +1808,38 @@ async function runClientTransaction(
1879
1808
  )
1880
1809
 
1881
1810
  if (isControl(result)) {
1882
- if (result[0 /* kind */] === REDIRECTED && router._tx === tx) {
1811
+ const follow = result[0 /* kind */] === REDIRECTED && router._tx === tx
1812
+ if (!follow || result[1 /* redirect */].options.reloadDocument) {
1883
1813
  finishPending(router, tx)
1884
- transferMatchResources(router, tx[3 /* matches */])
1885
- tx[3 /* matches */] = []
1886
- if (router._tx === tx) {
1887
- if (process.env.NODE_ENV !== 'production' && tx[6 /* refresh */]) {
1888
- router._refreshNextLoad = true
1889
- }
1890
- await followRedirect(router, tx, result[1 /* redirect */])
1891
- }
1892
- } else {
1893
- restoreCommitted(router, tx)
1894
1814
  }
1815
+ transferMatchResources(router, tx[3 /* matches */])
1816
+ tx[3 /* matches */] = []
1817
+ if (!follow) {
1818
+ return
1819
+ }
1820
+ if (router._tx !== tx) {
1821
+ finishPending(router, tx)
1822
+ return
1823
+ }
1824
+ if (process.env.NODE_ENV !== 'production' && tx[6 /* refresh */]) {
1825
+ router._refreshNextLoad = true
1826
+ }
1827
+ await followRedirect(router, tx, result)
1895
1828
  return
1896
1829
  }
1897
- const pending = router._pending
1898
- if (pending?.[0 /* owner */] === tx) {
1899
- /**
1900
- * Loading finished, so cancel any pending reveal. If the fallback rendered,
1901
- * wait out the rest of `pendingMinMs` before replacing it. If it never
1902
- * rendered, there is no minimum wait; if another load took it over, that
1903
- * load owns the deadline.
1904
- */
1905
- clearTimeout(pending[3 /* timer */])
1906
- if (pending[4 /* ack */]) {
1907
- const signal = tx[0 /* controller */].signal
1908
- let rendered = false
1909
- try {
1910
- rendered = await waitFor(pending[4 /* ack */], signal)
1911
- } catch (cause) {
1912
- if (cause !== signal) {
1913
- throw cause
1914
- }
1915
- }
1916
- if (
1917
- rendered &&
1918
- router._pending === pending &&
1919
- pending[0 /* owner */] === tx
1920
- ) {
1921
- const remaining = pending[2 /* deadline */] - Date.now()
1922
- if (remaining > 0) {
1923
- try {
1924
- await waitFor(
1925
- new Promise<void>((resolve) => {
1926
- pending[3 /* timer */] = setTimeout(resolve, remaining)
1927
- }),
1928
- signal,
1929
- )
1930
- } catch {}
1931
- clearTimeout(pending[3 /* timer */])
1932
- }
1933
- }
1934
- }
1830
+ if (router._tx !== tx) {
1831
+ finishPending(router, tx)
1832
+ transferMatchResources(router, result[1 /* matches */])
1833
+ discardBackground(router, result)
1834
+ return
1935
1835
  }
1836
+ // Only an acknowledged fallback owns a minimum. Recheck at the commit
1837
+ // boundary because native view transitions can defer their update callback.
1838
+ await awaitPendingMinimum(router, tx)
1936
1839
  if (router._tx !== tx) {
1937
1840
  finishPending(router, tx)
1938
- discardLane(router, result)
1841
+ transferMatchResources(router, result[1 /* matches */])
1842
+ discardBackground(router, result)
1939
1843
  return
1940
1844
  }
1941
1845
  const toLocation = tx[2 /* location */]
@@ -1946,7 +1850,16 @@ async function runClientTransaction(
1946
1850
  const background = result[2 /* background */]
1947
1851
  await router.startViewTransition(async () => {
1948
1852
  if (router._tx !== tx) {
1949
- discardLane(router, result)
1853
+ finishPending(router, tx)
1854
+ transferMatchResources(router, result[1 /* matches */])
1855
+ discardBackground(router, result)
1856
+ return
1857
+ }
1858
+ await awaitPendingMinimum(router, tx)
1859
+ if (router._tx !== tx) {
1860
+ finishPending(router, tx)
1861
+ transferMatchResources(router, result[1 /* matches */])
1862
+ discardBackground(router, result)
1950
1863
  return
1951
1864
  }
1952
1865
  const commit = () => {
@@ -1960,24 +1873,20 @@ async function runClientTransaction(
1960
1873
  router.emit({ type: 'onBeforeRouteMount', ...changeInfo })
1961
1874
  }
1962
1875
  }
1963
- const rendered =
1964
- process.env.NODE_ENV !== 'production' && tx[6 /* refresh */]
1965
- ? await transitionRefresh(router, tx, result, changeInfo)
1966
- : await router.startTransition(commit, result[1 /* matches */])
1967
- if (
1968
- process.env.NODE_ENV !== 'production' &&
1969
- tx[6 /* refresh */] &&
1970
- rendered === undefined
1971
- ) {
1972
- return
1876
+ const rendered = await router.startTransition(
1877
+ commit,
1878
+ result[1 /* matches */],
1879
+ )
1880
+ if (process.env.NODE_ENV !== 'production' && tx[6 /* refresh */]) {
1881
+ tx[6 /* refresh */] = undefined
1973
1882
  }
1974
1883
  if (router._tx !== tx) {
1975
1884
  discardBackground(router, result)
1976
1885
  return
1977
1886
  }
1978
1887
  if (background?.length) {
1979
- // Publish refreshes only after the foreground render acknowledgement.
1980
- // Otherwise a fast refresh can replace the acknowledged generation
1888
+ // Publish background matches only after the foreground acknowledgement.
1889
+ // Otherwise fast work can replace the acknowledged generation
1981
1890
  // before the framework commits it and strand the navigation.
1982
1891
  runBackground(
1983
1892
  router,
@@ -2011,12 +1920,8 @@ export async function loadClientRoute(
2011
1920
  ): Promise<void> {
2012
1921
  let rematerialize = false
2013
1922
  if (process.env.NODE_ENV !== 'production') {
2014
- router._tx?.[6 /* refresh */]?.[2 /* rollback */]?.()
2015
1923
  rematerialize = !!router._refreshNextLoad || !!router._tx?.[6 /* refresh */]
2016
1924
  }
2017
- const refreshPresentation = rematerialize
2018
- ? router.stores.matches.get()
2019
- : undefined
2020
1925
  const previousOwner = router._tx
2021
1926
  const resolvedLocation = router.stores.resolvedLocation.get()
2022
1927
  const previousLocation = resolvedLocation ?? router.stores.location.get()
@@ -2056,36 +1961,15 @@ export async function loadClientRoute(
2056
1961
  return
2057
1962
  }
2058
1963
  const sameHref = previousLocation.href === location.href
2059
- let matches: Array<AnyRouteMatch>
2060
1964
  let controller = preflight
2061
- try {
2062
- matches =
2063
- process.env.NODE_ENV !== 'production' && rematerialize
2064
- ? router.matchRoutes(location, {
2065
- _controller: preflight,
2066
- _rematerialize: true,
2067
- })
2068
- : router.matchRoutes(location, { _controller: preflight })
2069
- acquireMatchResources(matches)
2070
- } catch (cause) {
2071
- preflight.abort()
2072
- if (!isRedirect(cause)) {
2073
- if (process.env.NODE_ENV !== 'production' && rematerialize) {
2074
- router._refreshNextLoad = undefined
2075
- }
2076
- await awaitCurrent(router)
2077
- router._commitPromise?.resolve()
2078
- router._commitPromise = undefined
2079
- return
2080
- }
2081
- await router.navigate({
2082
- ...cause.options,
2083
- replace: true,
2084
- ignoreBlocker: true,
2085
- })
2086
- await awaitCurrent(router, previousOwner)
2087
- return
2088
- }
1965
+ const matches =
1966
+ process.env.NODE_ENV !== 'production' && rematerialize
1967
+ ? router.matchRoutes(location, {
1968
+ _controller: preflight,
1969
+ _rematerialize: true,
1970
+ })
1971
+ : router.matchRoutes(location, { _controller: preflight })
1972
+ acquireMatchResources(matches)
2089
1973
  const resolvedPrefix = hydrationController
2090
1974
  ? handoff![1 /* finish */](matches)
2091
1975
  : undefined
@@ -2118,15 +2002,12 @@ export async function loadClientRoute(
2118
2002
  resolvedPrefix,
2119
2003
  ),
2120
2004
  )
2121
- .catch(() => {
2122
- if (router._tx === tx) {
2123
- restoreCommitted(router, tx)
2124
- }
2125
- }),
2005
+ // Preserve the settlement turn in which immediately completed background
2006
+ // work can publish before callers resume from `load`.
2007
+ .then(),
2126
2008
  ]
2127
2009
  if (process.env.NODE_ENV !== 'production' && rematerialize) {
2128
- // `refreshPresentation` is always captured when `rematerialize` is set.
2129
- tx[6 /* refresh */] = [refreshPresentation!, handoff]
2010
+ tx[6 /* refresh */] = [handoff]
2130
2011
  router._refreshNextLoad = undefined
2131
2012
  }
2132
2013
  router._tx = tx
@@ -2159,20 +2040,19 @@ export async function loadClientRoute(
2159
2040
  })
2160
2041
  // Cold loads have no committed UI to retain, but provisional not-found
2161
2042
  // matches must wait for lazy routes to place the final boundary.
2162
- if (!resolvedLocation && !matches.some((match) => match._notFound)) {
2043
+ if (
2044
+ resolvedPrefix ||
2045
+ (!router._committed.length && !matches.some((match) => match._notFound))
2046
+ ) {
2163
2047
  offerPending(router, tx)
2164
2048
  }
2165
- try {
2166
- await tx[5 /* done */]
2167
- } finally {
2168
- await awaitCurrent(router, tx)
2169
- }
2049
+ await tx[5 /* done */]
2050
+ await awaitCurrent(router, tx)
2170
2051
  }
2171
2052
 
2172
2053
  export async function refreshClientRoute(
2173
2054
  router: CoordinatorRouter,
2174
2055
  ): Promise<void> {
2175
- router._tx?.[6 /* refresh */]?.[2 /* rollback */]?.()
2176
2056
  const pending = router._tx
2177
2057
  if (
2178
2058
  pending &&
@@ -2184,7 +2064,7 @@ export async function refreshClientRoute(
2184
2064
  await awaitCurrent(router, pending)
2185
2065
  }
2186
2066
  }
2187
- // Existing owners remain alive for rollback but cannot donate stale work.
2067
+ // Existing owners remain presented but cannot donate stale work.
2188
2068
  router._flights?.clear()
2189
2069
  router.clearCache()
2190
2070
  router._refreshNextLoad = true
@@ -2195,17 +2075,15 @@ export async function preloadClientRoute(
2195
2075
  router: CoordinatorRouter,
2196
2076
  opts: any,
2197
2077
  redirects = 0,
2078
+ builtLocation?: ParsedLocation,
2198
2079
  ): Promise<Array<AnyRouteMatch> | undefined> {
2199
- if (redirects > 20) {
2200
- return
2201
- }
2202
2080
  if (
2203
2081
  process.env.NODE_ENV !== 'production' &&
2204
2082
  (router._refreshNextLoad || router._tx?.[6 /* refresh */])
2205
2083
  ) {
2206
2084
  return
2207
2085
  }
2208
- const location = opts._builtLocation ?? router.buildLocation(opts)
2086
+ const location = builtLocation ?? router.buildLocation(opts)
2209
2087
  const base = router._committed
2210
2088
  const controller = new AbortController()
2211
2089
  let matches: Array<AnyRouteMatch>
@@ -2229,9 +2107,6 @@ export async function preloadClientRoute(
2229
2107
  result = await executeClientLane(router, location, matches, [
2230
2108
  controller,
2231
2109
  redirects,
2232
- // Preload lanes run to completion even when unrelated navigations commit:
2233
- // finished work seeds the cache.
2234
- () => true,
2235
2110
  base,
2236
2111
  true,
2237
2112
  ])
@@ -2250,11 +2125,9 @@ export async function preloadClientRoute(
2250
2125
  ) {
2251
2126
  return preloadClientRoute(
2252
2127
  router,
2253
- {
2254
- ...result[1 /* redirect */].options,
2255
- _fromLocation: location,
2256
- },
2128
+ result[1 /* redirect */].options,
2257
2129
  redirects + 1,
2130
+ result[2 /* location */],
2258
2131
  )
2259
2132
  }
2260
2133
  } catch (cause) {
@@ -2314,8 +2187,7 @@ export async function hydrate(router: AnyRouter): Promise<void> {
2314
2187
  const previousPreflight = router._preflight
2315
2188
  router._preflight = controller
2316
2189
  previousPreflight?.abort()
2317
- // Route context can abort this controller itself. Only a new slot owner
2318
- // supersedes hydration.
2190
+ // Only a new slot owner supersedes hydration.
2319
2191
  const isCurrent = () => router._preflight === controller
2320
2192
 
2321
2193
  let location!: AnyRouter['latestLocation']
@@ -2515,7 +2387,11 @@ export async function hydrate(router: AnyRouter): Promise<void> {
2515
2387
  params: match.params,
2516
2388
  context: parentContext,
2517
2389
  location,
2518
- navigate: navigateFrom(router, location),
2390
+ navigate: (opts: any) =>
2391
+ router.navigate({
2392
+ ...opts,
2393
+ _fromLocation: location,
2394
+ }),
2519
2395
  buildLocation: router.buildLocation,
2520
2396
  cause: match.cause,
2521
2397
  abortController: controller,