@tanstack/react-router 1.45.2 → 1.45.3

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 (50) hide show
  1. package/dist/cjs/CatchBoundary.cjs +1 -1
  2. package/dist/cjs/CatchBoundary.cjs.map +1 -1
  3. package/dist/cjs/CatchBoundary.d.cts +1 -1
  4. package/dist/cjs/Match.cjs +24 -34
  5. package/dist/cjs/Match.cjs.map +1 -1
  6. package/dist/cjs/Matches.cjs +1 -1
  7. package/dist/cjs/Matches.cjs.map +1 -1
  8. package/dist/cjs/Matches.d.cts +4 -2
  9. package/dist/cjs/RouterProvider.cjs +0 -8
  10. package/dist/cjs/RouterProvider.cjs.map +1 -1
  11. package/dist/cjs/RouterProvider.d.cts +1 -4
  12. package/dist/cjs/Transitioner.cjs +5 -1
  13. package/dist/cjs/Transitioner.cjs.map +1 -1
  14. package/dist/cjs/index.cjs +0 -1
  15. package/dist/cjs/index.cjs.map +1 -1
  16. package/dist/cjs/index.d.cts +1 -1
  17. package/dist/cjs/route.cjs.map +1 -1
  18. package/dist/cjs/route.d.cts +1 -0
  19. package/dist/cjs/router.cjs +459 -406
  20. package/dist/cjs/router.cjs.map +1 -1
  21. package/dist/cjs/router.d.cts +22 -10
  22. package/dist/esm/CatchBoundary.d.ts +1 -1
  23. package/dist/esm/CatchBoundary.js +1 -1
  24. package/dist/esm/CatchBoundary.js.map +1 -1
  25. package/dist/esm/Match.js +24 -34
  26. package/dist/esm/Match.js.map +1 -1
  27. package/dist/esm/Matches.d.ts +4 -2
  28. package/dist/esm/Matches.js +1 -1
  29. package/dist/esm/Matches.js.map +1 -1
  30. package/dist/esm/RouterProvider.d.ts +1 -4
  31. package/dist/esm/RouterProvider.js +1 -9
  32. package/dist/esm/RouterProvider.js.map +1 -1
  33. package/dist/esm/Transitioner.js +5 -1
  34. package/dist/esm/Transitioner.js.map +1 -1
  35. package/dist/esm/index.d.ts +1 -1
  36. package/dist/esm/index.js +1 -2
  37. package/dist/esm/route.d.ts +1 -0
  38. package/dist/esm/route.js.map +1 -1
  39. package/dist/esm/router.d.ts +22 -10
  40. package/dist/esm/router.js +461 -408
  41. package/dist/esm/router.js.map +1 -1
  42. package/package.json +2 -2
  43. package/src/CatchBoundary.tsx +7 -3
  44. package/src/Match.tsx +45 -36
  45. package/src/Matches.tsx +5 -3
  46. package/src/RouterProvider.tsx +0 -11
  47. package/src/Transitioner.tsx +5 -1
  48. package/src/index.tsx +0 -1
  49. package/src/route.ts +1 -0
  50. package/src/router.ts +639 -565
package/src/router.ts CHANGED
@@ -12,7 +12,6 @@ import {
12
12
  pick,
13
13
  replaceEqualDeep,
14
14
  } from './utils'
15
- import { getRouteMatch } from './RouterProvider'
16
15
  import {
17
16
  cleanPath,
18
17
  interpolatePath,
@@ -365,6 +364,12 @@ export interface RouterOptions<
365
364
  * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#trailingslash-property)
366
365
  */
367
366
  trailingSlash?: TTrailingSlashOption
367
+ /**
368
+ * Defaults to `typeof document !== 'undefined'`
369
+ * While usually automatic, sometimes it can be useful to force the router into a server-side state, e.g. when using the router in a non-browser environment that has access to a global.document object.
370
+ * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#isserver property)
371
+ */
372
+ isServer?: boolean
368
373
  }
369
374
 
370
375
  export interface RouterTransformer {
@@ -381,6 +386,7 @@ export interface RouterState<
381
386
  TRouteMatch = MakeRouteMatch<TRouteTree>,
382
387
  > {
383
388
  status: 'pending' | 'idle'
389
+ loadedAt: number
384
390
  isLoading: boolean
385
391
  isTransitioning: boolean
386
392
  matches: Array<TRouteMatch>
@@ -517,7 +523,6 @@ export class Router<
517
523
  )}`
518
524
  resetNextScroll = true
519
525
  shouldViewTransition?: boolean = undefined
520
- latestLoadPromise: Promise<void> = Promise.resolve()
521
526
  subscribers = new Set<RouterListener<RouterEvent>>()
522
527
  dehydratedData?: TDehydrated
523
528
  viewTransitionPromise?: ControlledPromise<true>
@@ -561,6 +566,7 @@ export class Router<
561
566
  routesById!: RoutesById<TRouteTree>
562
567
  routesByPath!: RoutesByPath<TRouteTree>
563
568
  flatRoutes!: Array<AnyRoute>
569
+ isServer!: boolean
564
570
 
565
571
  /**
566
572
  * @deprecated Use the `createRouter` function instead
@@ -588,8 +594,6 @@ export class Router<
588
594
  }
589
595
  }
590
596
 
591
- isServer = typeof document === 'undefined'
592
-
593
597
  // These are default implementations that can optionally be overridden
594
598
  // by the router provider once rendered. We provide these so that the
595
599
  // router can be used in a non-react environment if necessary
@@ -615,6 +619,8 @@ export class Router<
615
619
  ...newOptions,
616
620
  }
617
621
 
622
+ this.isServer = this.options.isServer ?? typeof document === 'undefined'
623
+
618
624
  if (
619
625
  !this.basepath ||
620
626
  (newOptions.basepath && newOptions.basepath !== previousOptions.basepath)
@@ -637,11 +643,11 @@ export class Router<
637
643
  ) {
638
644
  this.history =
639
645
  this.options.history ??
640
- (typeof document !== 'undefined'
641
- ? createBrowserHistory()
642
- : createMemoryHistory({
643
- initialEntries: [this.options.basepath || '/'],
644
- }))
646
+ (this.isServer
647
+ ? createMemoryHistory({
648
+ initialEntries: [this.basepath || '/'],
649
+ })
650
+ : createBrowserHistory())
645
651
  this.latestLocation = this.parseLocation()
646
652
  }
647
653
 
@@ -808,12 +814,6 @@ export class Router<
808
814
  })
809
815
  }
810
816
 
811
- checkLatest = (promise: Promise<void>): void => {
812
- if (this.latestLoadPromise !== promise) {
813
- throw this.latestLoadPromise
814
- }
815
- }
816
-
817
817
  parseLocation = (
818
818
  previousLocation?: ParsedLocation<FullSearchSchema<TRouteTree>>,
819
819
  ): ParsedLocation<FullSearchSchema<TRouteTree>> => {
@@ -827,7 +827,7 @@ export class Router<
827
827
  const searchStr = this.options.stringifySearch(parsedSearch)
828
828
 
829
829
  return {
830
- pathname: pathname,
830
+ pathname,
831
831
  searchStr,
832
832
  search: replaceEqualDeep(previousLocation?.search, parsedSearch) as any,
833
833
  hash: hash.split('#').reverse()[0] ?? '',
@@ -1044,7 +1044,7 @@ export class Router<
1044
1044
  // Waste not, want not. If we already have a match for this route,
1045
1045
  // reuse it. This is important for layout routes, which might stick
1046
1046
  // around between navigation actions that only change leaf routes.
1047
- const existingMatch = getRouteMatch(this.state, matchId)
1047
+ const existingMatch = this.getMatch(matchId)
1048
1048
 
1049
1049
  const cause = this.state.matches.find((d) => d.id === matchId)
1050
1050
  ? 'stay'
@@ -1060,17 +1060,10 @@ export class Router<
1060
1060
  }
1061
1061
  } else {
1062
1062
  const status =
1063
- route.options.loader || route.options.beforeLoad
1063
+ route.options.loader || route.options.beforeLoad || route.lazyFn
1064
1064
  ? 'pending'
1065
1065
  : 'success'
1066
1066
 
1067
- const loadPromise = createControlledPromise<void>()
1068
-
1069
- // If it's already a success, resolve the load promise
1070
- if (status === 'success') {
1071
- loadPromise.resolve()
1072
- }
1073
-
1074
1067
  match = {
1075
1068
  id: matchId,
1076
1069
  index,
@@ -1080,12 +1073,10 @@ export class Router<
1080
1073
  updatedAt: Date.now(),
1081
1074
  search: {} as any,
1082
1075
  searchError: undefined,
1083
- status: 'pending',
1076
+ status,
1084
1077
  isFetching: false,
1085
1078
  error: undefined,
1086
1079
  paramsError: parseErrors[index],
1087
- loaderPromise: Promise.resolve(),
1088
- loadPromise,
1089
1080
  routeContext: undefined!,
1090
1081
  context: undefined!,
1091
1082
  abortController: new AbortController(),
@@ -1097,6 +1088,7 @@ export class Router<
1097
1088
  links: route.options.links?.(),
1098
1089
  scripts: route.options.scripts?.(),
1099
1090
  staticData: route.options.staticData || {},
1091
+ loadPromise: createControlledPromise(),
1100
1092
  }
1101
1093
  }
1102
1094
 
@@ -1134,7 +1126,7 @@ export class Router<
1134
1126
  }
1135
1127
 
1136
1128
  cancelMatch = (id: string) => {
1137
- getRouteMatch(this.state, id)?.abortController.abort()
1129
+ this.getMatch(id)?.abortController.abort()
1138
1130
  }
1139
1131
 
1140
1132
  cancelMatches = () => {
@@ -1367,12 +1359,13 @@ export class Router<
1367
1359
  return buildWithMatches(opts)
1368
1360
  }
1369
1361
 
1370
- commitLocation = async ({
1371
- startTransition,
1362
+ commitLocationPromise: undefined | ControlledPromise<void>
1363
+
1364
+ commitLocation = ({
1372
1365
  viewTransition,
1373
1366
  ignoreBlocker,
1374
1367
  ...next
1375
- }: ParsedLocation & CommitLocationOptions) => {
1368
+ }: ParsedLocation & CommitLocationOptions): Promise<void> => {
1376
1369
  const isSameState = () => {
1377
1370
  // `state.key` is ignored but may still be provided when navigating,
1378
1371
  // temporarily add the previous key to the next state so it doesn't affect
@@ -1386,6 +1379,11 @@ export class Router<
1386
1379
 
1387
1380
  const isSameUrl = this.latestLocation.href === next.href
1388
1381
 
1382
+ const previousCommitPromise = this.commitLocationPromise
1383
+ this.commitLocationPromise = createControlledPromise<void>(() => {
1384
+ previousCommitPromise?.resolve()
1385
+ })
1386
+
1389
1387
  // Don't commit to history if nothing changed
1390
1388
  if (isSameUrl && isSameState()) {
1391
1389
  this.load()
@@ -1432,13 +1430,16 @@ export class Router<
1432
1430
 
1433
1431
  this.resetNextScroll = next.resetScroll ?? true
1434
1432
 
1435
- return this.latestLoadPromise
1433
+ if (!this.history.subscribers.size) {
1434
+ this.load()
1435
+ }
1436
+
1437
+ return this.commitLocationPromise
1436
1438
  }
1437
1439
 
1438
1440
  buildAndCommitLocation = ({
1439
1441
  replace,
1440
1442
  resetScroll,
1441
- startTransition,
1442
1443
  viewTransition,
1443
1444
  ignoreBlocker,
1444
1445
  ...rest
@@ -1446,7 +1447,6 @@ export class Router<
1446
1447
  const location = this.buildLocation(rest as any)
1447
1448
  return this.commitLocation({
1448
1449
  ...location,
1449
- startTransition,
1450
1450
  viewTransition,
1451
1451
  replace,
1452
1452
  resetScroll,
@@ -1471,7 +1471,7 @@ export class Router<
1471
1471
 
1472
1472
  invariant(
1473
1473
  !isExternal,
1474
- 'Attempting to navigate to external url with this.navigate!',
1474
+ 'Attempting to navigate to external url with router.navigate!',
1475
1475
  )
1476
1476
 
1477
1477
  return this.buildAndCommitLocation({
@@ -1482,156 +1482,174 @@ export class Router<
1482
1482
  })
1483
1483
  }
1484
1484
 
1485
+ latestLoadPromise: undefined | Promise<void>
1486
+
1485
1487
  load = async (): Promise<void> => {
1486
1488
  this.latestLocation = this.parseLocation(this.latestLocation)
1487
1489
 
1488
- if (this.state.location === this.latestLocation) {
1489
- return
1490
- }
1490
+ this.__store.setState((s) => ({
1491
+ ...s,
1492
+ loadedAt: Date.now(),
1493
+ }))
1491
1494
 
1492
- const promise = createControlledPromise<void>()
1493
- this.latestLoadPromise = promise
1494
1495
  let redirect: ResolvedRedirect | undefined
1495
1496
  let notFound: NotFoundError | undefined
1496
1497
 
1497
- this.startReactTransition(async () => {
1498
- try {
1499
- const next = this.latestLocation
1500
- const prevLocation = this.state.resolvedLocation
1501
- const pathDidChange = prevLocation.href !== next.href
1502
-
1503
- // Cancel any pending matches
1504
- this.cancelMatches()
1505
-
1506
- let pendingMatches!: Array<AnyRouteMatch>
1507
-
1508
- this.__store.batch(() => {
1509
- // this call breaks a route context of destination route after a redirect
1510
- // we should be fine not eagerly calling this since we call it later
1511
- // this.cleanCache()
1512
-
1513
- // Match the routes
1514
- pendingMatches = this.matchRoutes(next.pathname, next.search)
1498
+ const loadPromise = new Promise<void>((resolve) => {
1499
+ this.startReactTransition(async () => {
1500
+ try {
1501
+ const next = this.latestLocation
1502
+ const prevLocation = this.state.resolvedLocation
1503
+ const pathDidChange = prevLocation.href !== next.href
1504
+
1505
+ // Cancel any pending matches
1506
+ this.cancelMatches()
1507
+
1508
+ let pendingMatches!: Array<AnyRouteMatch>
1509
+
1510
+ this.__store.batch(() => {
1511
+ // this call breaks a route context of destination route after a redirect
1512
+ // we should be fine not eagerly calling this since we call it later
1513
+ // this.cleanCache()
1514
+
1515
+ // Match the routes
1516
+ pendingMatches = this.matchRoutes(next.pathname, next.search)
1517
+
1518
+ // Ingest the new matches
1519
+ this.__store.setState((s) => ({
1520
+ ...s,
1521
+ status: 'pending',
1522
+ isLoading: true,
1523
+ location: next,
1524
+ pendingMatches,
1525
+ // If a cached moved to pendingMatches, remove it from cachedMatches
1526
+ cachedMatches: s.cachedMatches.filter((d) => {
1527
+ return !pendingMatches.find((e) => e.id === d.id)
1528
+ }),
1529
+ }))
1530
+ })
1515
1531
 
1516
- // Ingest the new matches
1517
- this.__store.setState((s) => ({
1518
- ...s,
1519
- status: 'pending',
1520
- isLoading: true,
1521
- location: next,
1522
- pendingMatches,
1523
- // If a cached moved to pendingMatches, remove it from cachedMatches
1524
- cachedMatches: s.cachedMatches.filter((d) => {
1525
- return !pendingMatches.find((e) => e.id === d.id)
1526
- }),
1527
- }))
1528
- })
1532
+ if (!this.state.redirect) {
1533
+ this.emit({
1534
+ type: 'onBeforeNavigate',
1535
+ fromLocation: prevLocation,
1536
+ toLocation: next,
1537
+ pathChanged: pathDidChange,
1538
+ })
1539
+ }
1529
1540
 
1530
- if (!this.state.redirect) {
1531
1541
  this.emit({
1532
- type: 'onBeforeNavigate',
1542
+ type: 'onBeforeLoad',
1533
1543
  fromLocation: prevLocation,
1534
1544
  toLocation: next,
1535
1545
  pathChanged: pathDidChange,
1536
1546
  })
1537
- }
1538
-
1539
- this.emit({
1540
- type: 'onBeforeLoad',
1541
- fromLocation: prevLocation,
1542
- toLocation: next,
1543
- pathChanged: pathDidChange,
1544
- })
1545
-
1546
- await this.loadMatches({
1547
- matches: pendingMatches,
1548
- location: next,
1549
- checkLatest: () => this.checkLatest(promise),
1550
- onReady: async () => {
1551
- await this.startViewTransition(async () => {
1552
- // this.viewTransitionPromise = createControlledPromise<true>()
1553
-
1554
- // Commit the pending matches. If a previous match was
1555
- // removed, place it in the cachedMatches
1556
- let exitingMatches!: Array<AnyRouteMatch>
1557
- let enteringMatches!: Array<AnyRouteMatch>
1558
- let stayingMatches!: Array<AnyRouteMatch>
1559
-
1560
- this.__store.batch(() => {
1561
- this.__store.setState((s) => {
1562
- const previousMatches = s.matches
1563
- const newMatches = s.pendingMatches || s.matches
1564
-
1565
- exitingMatches = previousMatches.filter(
1566
- (match) => !newMatches.find((d) => d.id === match.id),
1567
- )
1568
- enteringMatches = newMatches.filter(
1569
- (match) => !previousMatches.find((d) => d.id === match.id),
1570
- )
1571
- stayingMatches = previousMatches.filter((match) =>
1572
- newMatches.find((d) => d.id === match.id),
1573
- )
1574
1547
 
1575
- return {
1576
- ...s,
1577
- isLoading: false,
1578
- matches: newMatches,
1579
- pendingMatches: undefined,
1580
- cachedMatches: [
1581
- ...s.cachedMatches,
1582
- ...exitingMatches.filter((d) => d.status !== 'error'),
1583
- ],
1584
- }
1548
+ await this.loadMatches({
1549
+ matches: pendingMatches,
1550
+ location: next,
1551
+ // eslint-disable-next-line ts/require-await
1552
+ onReady: async () => {
1553
+ // eslint-disable-next-line ts/require-await
1554
+ this.startViewTransition(async () => {
1555
+ // this.viewTransitionPromise = createControlledPromise<true>()
1556
+
1557
+ // Commit the pending matches. If a previous match was
1558
+ // removed, place it in the cachedMatches
1559
+ let exitingMatches!: Array<AnyRouteMatch>
1560
+ let enteringMatches!: Array<AnyRouteMatch>
1561
+ let stayingMatches!: Array<AnyRouteMatch>
1562
+
1563
+ this.__store.batch(() => {
1564
+ this.__store.setState((s) => {
1565
+ const previousMatches = s.matches
1566
+ const newMatches = s.pendingMatches || s.matches
1567
+
1568
+ exitingMatches = previousMatches.filter(
1569
+ (match) => !newMatches.find((d) => d.id === match.id),
1570
+ )
1571
+ enteringMatches = newMatches.filter(
1572
+ (match) =>
1573
+ !previousMatches.find((d) => d.id === match.id),
1574
+ )
1575
+ stayingMatches = previousMatches.filter((match) =>
1576
+ newMatches.find((d) => d.id === match.id),
1577
+ )
1578
+
1579
+ return {
1580
+ ...s,
1581
+ isLoading: false,
1582
+ matches: newMatches,
1583
+ pendingMatches: undefined,
1584
+ cachedMatches: [
1585
+ ...s.cachedMatches,
1586
+ ...exitingMatches.filter((d) => d.status !== 'error'),
1587
+ ],
1588
+ }
1589
+ })
1590
+ this.cleanCache()
1585
1591
  })
1586
- this.cleanCache()
1587
- })
1588
1592
 
1589
- //
1590
- ;(
1591
- [
1592
- [exitingMatches, 'onLeave'],
1593
- [enteringMatches, 'onEnter'],
1594
- [stayingMatches, 'onStay'],
1595
- ] as const
1596
- ).forEach(([matches, hook]) => {
1597
- matches.forEach((match) => {
1598
- this.looseRoutesById[match.routeId]!.options[hook]?.(match)
1593
+ //
1594
+ ;(
1595
+ [
1596
+ [exitingMatches, 'onLeave'],
1597
+ [enteringMatches, 'onEnter'],
1598
+ [stayingMatches, 'onStay'],
1599
+ ] as const
1600
+ ).forEach(([matches, hook]) => {
1601
+ matches.forEach((match) => {
1602
+ this.looseRoutesById[match.routeId]!.options[hook]?.(match)
1603
+ })
1599
1604
  })
1600
1605
  })
1601
- })
1602
- },
1603
- })
1604
- } catch (err) {
1605
- if (isResolvedRedirect(err)) {
1606
- redirect = err
1607
- if (!this.isServer) {
1608
- this.navigate({ ...err, replace: true, __isRedirect: true })
1609
- // this.load()
1606
+ },
1607
+ })
1608
+ } catch (err) {
1609
+ if (isResolvedRedirect(err)) {
1610
+ redirect = err
1611
+ if (!this.isServer) {
1612
+ this.navigate({ ...err, replace: true, __isRedirect: true })
1613
+ }
1614
+ } else if (isNotFound(err)) {
1615
+ notFound = err
1610
1616
  }
1611
- } else if (isNotFound(err)) {
1612
- notFound = err
1613
- }
1614
1617
 
1615
- this.__store.setState((s) => ({
1616
- ...s,
1617
- statusCode: redirect
1618
- ? redirect.statusCode
1619
- : notFound
1620
- ? 404
1621
- : s.matches.some((d) => d.status === 'error')
1622
- ? 500
1623
- : 200,
1624
- redirect,
1625
- }))
1626
- }
1618
+ this.__store.setState((s) => ({
1619
+ ...s,
1620
+ statusCode: redirect
1621
+ ? redirect.statusCode
1622
+ : notFound
1623
+ ? 404
1624
+ : s.matches.some((d) => d.status === 'error')
1625
+ ? 500
1626
+ : 200,
1627
+ redirect,
1628
+ }))
1629
+ }
1627
1630
 
1628
- promise.resolve()
1631
+ if (this.latestLoadPromise === loadPromise) {
1632
+ this.commitLocationPromise?.resolve()
1633
+ this.latestLoadPromise = undefined
1634
+ this.commitLocationPromise = undefined
1635
+ }
1636
+ resolve()
1637
+ })
1629
1638
  })
1630
1639
 
1631
- return this.latestLoadPromise
1640
+ this.latestLoadPromise = loadPromise
1641
+
1642
+ await loadPromise
1643
+
1644
+ while (
1645
+ (this.latestLoadPromise as any) &&
1646
+ loadPromise !== this.latestLoadPromise
1647
+ ) {
1648
+ await this.latestLoadPromise
1649
+ }
1632
1650
  }
1633
1651
 
1634
- startViewTransition = async (fn: () => Promise<void>) => {
1652
+ startViewTransition = (fn: () => Promise<void>) => {
1635
1653
  // Determine if we should start a view transition from the navigation
1636
1654
  // or from the router default
1637
1655
  const shouldViewTransition =
@@ -1648,18 +1666,54 @@ export class Router<
1648
1666
  ?.startViewTransition?.(fn) || fn()
1649
1667
  }
1650
1668
 
1669
+ updateMatch = (
1670
+ id: string,
1671
+ updater: (match: AnyRouteMatch) => AnyRouteMatch,
1672
+ ) => {
1673
+ let updated!: AnyRouteMatch
1674
+ const isPending = this.state.pendingMatches?.find((d) => d.id === id)
1675
+ const isMatched = this.state.matches.find((d) => d.id === id)
1676
+
1677
+ const matchesKey = isPending
1678
+ ? 'pendingMatches'
1679
+ : isMatched
1680
+ ? 'matches'
1681
+ : 'cachedMatches'
1682
+
1683
+ this.__store.setState((s) => ({
1684
+ ...s,
1685
+ [matchesKey]: s[matchesKey]?.map((d) =>
1686
+ d.id === id ? (updated = updater(d)) : d,
1687
+ ),
1688
+ }))
1689
+
1690
+ return updated
1691
+ }
1692
+
1693
+ getMatch = (matchId: string) => {
1694
+ return [
1695
+ ...this.state.cachedMatches,
1696
+ ...(this.state.pendingMatches ?? []),
1697
+ ...this.state.matches,
1698
+ ].find((d) => d.id === matchId)
1699
+ }
1700
+
1651
1701
  loadMatches = async ({
1652
- checkLatest,
1653
1702
  location,
1654
1703
  matches,
1655
1704
  preload,
1656
1705
  onReady,
1706
+ updateMatch = this.updateMatch,
1657
1707
  }: {
1658
- checkLatest: () => void
1659
1708
  location: ParsedLocation
1660
1709
  matches: Array<AnyRouteMatch>
1661
1710
  preload?: boolean
1662
1711
  onReady?: () => Promise<void>
1712
+ updateMatch?: (
1713
+ id: string,
1714
+ updater: (match: AnyRouteMatch) => AnyRouteMatch,
1715
+ ) => void
1716
+ getMatch?: (matchId: string) => AnyRouteMatch | undefined
1663
1717
  }): Promise<Array<MakeRouteMatch>> => {
1664
1718
  let firstBadMatchIndex: number | undefined
1665
1719
  let rendered = false
@@ -1675,38 +1729,10 @@ export class Router<
1675
1729
  triggerOnReady()
1676
1730
  }
1677
1731
 
1678
- const updateMatch = (
1679
- id: string,
1680
- updater: (match: AnyRouteMatch) => AnyRouteMatch,
1681
- opts?: { remove?: boolean },
1682
- ) => {
1683
- let updated!: AnyRouteMatch
1684
- const isPending = this.state.pendingMatches?.find((d) => d.id === id)
1685
- const isMatched = this.state.matches.find((d) => d.id === id)
1686
-
1687
- const matchesKey = isPending
1688
- ? 'pendingMatches'
1689
- : isMatched
1690
- ? 'matches'
1691
- : 'cachedMatches'
1692
-
1693
- this.__store.setState((s) => ({
1694
- ...s,
1695
- [matchesKey]: opts?.remove
1696
- ? s[matchesKey]?.filter((d) => d.id !== id)
1697
- : s[matchesKey]?.map((d) =>
1698
- d.id === id ? (updated = updater(d)) : d,
1699
- ),
1700
- }))
1701
-
1702
- return updated
1703
- }
1704
-
1705
1732
  const handleRedirectAndNotFound = (match: AnyRouteMatch, err: any) => {
1706
1733
  if (isResolvedRedirect(err)) throw err
1707
1734
 
1708
1735
  if (isRedirect(err) || isNotFound(err)) {
1709
- // if (!rendered) {
1710
1736
  updateMatch(match.id, (prev) => ({
1711
1737
  ...prev,
1712
1738
  status: isRedirect(err)
@@ -1716,19 +1742,26 @@ export class Router<
1716
1742
  : 'error',
1717
1743
  isFetching: false,
1718
1744
  error: err,
1745
+ beforeLoadPromise: undefined,
1746
+ loaderPromise: undefined,
1719
1747
  }))
1720
- // }
1721
1748
 
1722
1749
  if (!(err as any).routeId) {
1723
1750
  ;(err as any).routeId = match.routeId
1724
1751
  }
1725
1752
 
1753
+ match.beforeLoadPromise?.resolve()
1754
+ match.loaderPromise?.resolve()
1755
+ match.loadPromise?.resolve()
1756
+
1726
1757
  if (isRedirect(err)) {
1727
1758
  rendered = true
1728
1759
  err = this.resolveRedirect({ ...err, _fromLocation: location })
1729
1760
  throw err
1730
1761
  } else if (isNotFound(err)) {
1731
- this.handleNotFound(matches, err)
1762
+ this._handleNotFound(matches, err, {
1763
+ updateMatch,
1764
+ })
1732
1765
  throw err
1733
1766
  }
1734
1767
  }
@@ -1738,397 +1771,426 @@ export class Router<
1738
1771
  await new Promise<void>((resolveAll, rejectAll) => {
1739
1772
  ;(async () => {
1740
1773
  try {
1741
- // Check each match middleware to see if the route can be accessed
1742
- // eslint-disable-next-line prefer-const
1743
- for (let [index, match] of matches.entries()) {
1744
- const parentMatch = matches[index - 1]
1745
- const route = this.looseRoutesById[match.routeId]!
1746
- const abortController = new AbortController()
1747
- let loadPromise = match.loadPromise
1748
-
1749
- const pendingMs =
1750
- route.options.pendingMs ?? this.options.defaultPendingMs
1751
-
1752
- const shouldPending = !!(
1753
- onReady &&
1754
- !this.isServer &&
1755
- !preload &&
1756
- (route.options.loader || route.options.beforeLoad) &&
1757
- typeof pendingMs === 'number' &&
1758
- pendingMs !== Infinity &&
1759
- (route.options.pendingComponent ??
1760
- this.options.defaultPendingComponent)
1761
- )
1762
-
1763
- if (shouldPending) {
1764
- // If we might show a pending component, we need to wait for the
1765
- // pending promise to resolve before we start showing that state
1766
- setTimeout(() => {
1767
- try {
1768
- checkLatest()
1769
- // Update the match and prematurely resolve the loadMatches promise so that
1770
- // the pending component can start rendering
1771
- triggerOnReady()
1772
- } catch {}
1773
- }, pendingMs)
1774
+ const handleSerialError = (
1775
+ index: number,
1776
+ err: any,
1777
+ routerCode: string,
1778
+ ) => {
1779
+ const { id: matchId, routeId } = matches[index]!
1780
+ const route = this.looseRoutesById[routeId]!
1781
+
1782
+ // Much like suspense, we use a promise here to know if
1783
+ // we've been outdated by a new loadMatches call and
1784
+ // should abort the current async operation
1785
+ if (err instanceof Promise) {
1786
+ throw err
1774
1787
  }
1775
1788
 
1776
- if (match.isFetching) {
1777
- continue
1778
- }
1789
+ err.routerCode = routerCode
1790
+ firstBadMatchIndex = firstBadMatchIndex ?? index
1791
+ handleRedirectAndNotFound(this.getMatch(matchId)!, err)
1779
1792
 
1780
- const previousResolve = loadPromise.resolve
1781
- // Create a new one
1782
- loadPromise = createControlledPromise<void>(
1783
- // Resolve the old when we we resolve the new one
1784
- previousResolve,
1785
- )
1786
-
1787
- // Otherwise, load the route
1788
- matches[index] = match = updateMatch(match.id, (prev) => ({
1789
- ...prev,
1790
- isFetching: 'beforeLoad',
1791
- loadPromise,
1792
- }))
1793
-
1794
- const handleSerialError = (err: any, routerCode: string) => {
1795
- // If the error is a promise, it means we're outdated and
1796
- // should abort the current async operation
1797
- if (err instanceof Promise) {
1798
- throw err
1799
- }
1793
+ try {
1794
+ route.options.onError?.(err)
1795
+ } catch (errorHandlerErr) {
1796
+ err = errorHandlerErr
1797
+ handleRedirectAndNotFound(this.getMatch(matchId)!, err)
1798
+ }
1800
1799
 
1801
- err.routerCode = routerCode
1802
- firstBadMatchIndex = firstBadMatchIndex ?? index
1803
- handleRedirectAndNotFound(match, err)
1800
+ updateMatch(matchId, (prev) => {
1801
+ prev.beforeLoadPromise?.resolve()
1804
1802
 
1805
- try {
1806
- route.options.onError?.(err)
1807
- } catch (errorHandlerErr) {
1808
- err = errorHandlerErr
1809
- handleRedirectAndNotFound(match, err)
1810
- }
1811
-
1812
- matches[index] = match = updateMatch(match.id, () => ({
1813
- ...match,
1803
+ return {
1804
+ ...prev,
1814
1805
  error: err,
1815
1806
  status: 'error',
1807
+ isFetching: false,
1816
1808
  updatedAt: Date.now(),
1817
1809
  abortController: new AbortController(),
1818
- }))
1819
- }
1820
-
1821
- if (match.paramsError) {
1822
- handleSerialError(match.paramsError, 'PARSE_PARAMS')
1823
- }
1824
-
1825
- if (match.searchError) {
1826
- handleSerialError(match.searchError, 'VALIDATE_SEARCH')
1827
- }
1828
-
1829
- try {
1830
- const parentContext =
1831
- parentMatch?.context ?? this.options.context ?? {}
1832
-
1833
- // Make sure the match has parent context set before going further
1834
- matches[index] = match = {
1835
- ...match,
1836
- routeContext: replaceEqualDeep(
1837
- match.routeContext,
1838
- parentContext,
1839
- ),
1840
- context: replaceEqualDeep(match.context, parentContext),
1841
- abortController,
1810
+ beforeLoadPromise: undefined,
1842
1811
  }
1812
+ })
1813
+ }
1843
1814
 
1844
- const beforeLoadFnContext = {
1845
- search: match.search,
1846
- abortController,
1847
- params: match.params,
1848
- preload: !!preload,
1849
- context: match.routeContext,
1850
- location,
1851
- navigate: (opts: any) =>
1852
- this.navigate({ ...opts, _fromLocation: location }),
1853
- buildLocation: this.buildLocation,
1854
- cause: preload ? 'preload' : match.cause,
1855
- }
1815
+ for (const [index, { id: matchId, routeId }] of matches.entries()) {
1816
+ const existingMatch = this.getMatch(matchId)!
1817
+
1818
+ if (
1819
+ // If we are in the middle of a load, either of these will be present
1820
+ // (not to be confused with `loadPromise`, which is always defined)
1821
+ existingMatch.beforeLoadPromise ||
1822
+ existingMatch.loaderPromise
1823
+ ) {
1824
+ // Wait for the beforeLoad to resolve before we continue
1825
+ await existingMatch.beforeLoadPromise
1826
+ } else {
1827
+ // If we are not in the middle of a load, start it
1828
+ try {
1829
+ updateMatch(matchId, (prev) => ({
1830
+ ...prev,
1831
+ loadPromise: createControlledPromise<void>(() => {
1832
+ prev.loadPromise?.resolve()
1833
+ }),
1834
+ beforeLoadPromise: createControlledPromise<void>(),
1835
+ }))
1836
+
1837
+ const route = this.looseRoutesById[routeId]!
1838
+ const abortController = new AbortController()
1839
+
1840
+ const parentMatchId = matches[index - 1]?.id
1841
+
1842
+ const getParentContext = () => {
1843
+ if (!parentMatchId) {
1844
+ return (this.options.context as any) ?? {}
1845
+ }
1856
1846
 
1857
- const beforeLoadContext = route.options.beforeLoad
1858
- ? ((await route.options.beforeLoad(beforeLoadFnContext)) ??
1859
- {})
1860
- : {}
1847
+ return (
1848
+ this.getMatch(parentMatchId)!.context ??
1849
+ this.options.context ??
1850
+ {}
1851
+ )
1852
+ }
1861
1853
 
1862
- checkLatest()
1854
+ const pendingMs =
1855
+ route.options.pendingMs ?? this.options.defaultPendingMs
1856
+
1857
+ const shouldPending = !!(
1858
+ onReady &&
1859
+ !this.isServer &&
1860
+ !preload &&
1861
+ (route.options.loader || route.options.beforeLoad) &&
1862
+ typeof pendingMs === 'number' &&
1863
+ pendingMs !== Infinity &&
1864
+ (route.options.pendingComponent ??
1865
+ this.options.defaultPendingComponent)
1866
+ )
1863
1867
 
1864
- if (
1865
- isRedirect(beforeLoadContext) ||
1866
- isNotFound(beforeLoadContext)
1867
- ) {
1868
- handleSerialError(beforeLoadContext, 'BEFORE_LOAD')
1869
- }
1868
+ if (shouldPending) {
1869
+ // If we might show a pending component, we need to wait for the
1870
+ // pending promise to resolve before we start showing that state
1871
+ setTimeout(() => {
1872
+ try {
1873
+ // Update the match and prematurely resolve the loadMatches promise so that
1874
+ // the pending component can start rendering
1875
+ triggerOnReady()
1876
+ } catch {}
1877
+ }, pendingMs)
1878
+ }
1870
1879
 
1871
- const context = {
1872
- ...parentContext,
1873
- ...beforeLoadContext,
1874
- }
1880
+ const { paramsError, searchError } = this.getMatch(matchId)!
1875
1881
 
1876
- matches[index] = match = {
1877
- ...match,
1878
- routeContext: replaceEqualDeep(
1879
- match.routeContext,
1880
- beforeLoadContext,
1881
- ),
1882
- context: replaceEqualDeep(match.context, context),
1883
- abortController,
1884
- }
1885
- updateMatch(match.id, () => match)
1886
- } catch (err) {
1887
- handleSerialError(err, 'BEFORE_LOAD')
1888
- break
1889
- }
1890
- }
1882
+ if (paramsError) {
1883
+ handleSerialError(index, paramsError, 'PARSE_PARAMS')
1884
+ }
1891
1885
 
1892
- checkLatest()
1886
+ if (searchError) {
1887
+ handleSerialError(index, searchError, 'VALIDATE_SEARCH')
1888
+ }
1893
1889
 
1894
- const validResolvedMatches = matches.slice(0, firstBadMatchIndex)
1895
- const matchPromises: Array<Promise<any>> = []
1890
+ const parentContext = getParentContext()
1891
+
1892
+ updateMatch(matchId, (prev) => ({
1893
+ ...prev,
1894
+ isFetching: 'beforeLoad',
1895
+ fetchCount: prev.fetchCount + 1,
1896
+ routeContext: replaceEqualDeep(
1897
+ prev.routeContext,
1898
+ parentContext,
1899
+ ),
1900
+ context: replaceEqualDeep(prev.context, parentContext),
1901
+ abortController,
1902
+ }))
1903
+
1904
+ const { search, params, routeContext, cause } =
1905
+ this.getMatch(matchId)!
1906
+
1907
+ const beforeLoadFnContext = {
1908
+ search,
1909
+ abortController,
1910
+ params,
1911
+ preload: !!preload,
1912
+ context: routeContext,
1913
+ location,
1914
+ navigate: (opts: any) =>
1915
+ this.navigate({ ...opts, _fromLocation: location }),
1916
+ buildLocation: this.buildLocation,
1917
+ cause: preload ? 'preload' : cause,
1918
+ }
1896
1919
 
1897
- validResolvedMatches.forEach((match, index) => {
1898
- const createValidateResolvedMatchPromise = async () => {
1899
- const parentMatchPromise = matchPromises[index - 1]
1900
- const route = this.looseRoutesById[match.routeId]!
1901
-
1902
- const loaderContext: LoaderFnContext = {
1903
- params: match.params,
1904
- deps: match.loaderDeps,
1905
- preload: !!preload,
1906
- parentMatchPromise,
1907
- abortController: match.abortController,
1908
- context: match.context,
1909
- location,
1910
- navigate: (opts) =>
1911
- this.navigate({ ...opts, _fromLocation: location }),
1912
- cause: preload ? 'preload' : match.cause,
1913
- route,
1914
- }
1920
+ const beforeLoadContext =
1921
+ (await route.options.beforeLoad?.(beforeLoadFnContext)) ??
1922
+ {}
1915
1923
 
1916
- const fetchAndResolveInLoaderLifetime = async () => {
1917
- const existing = getRouteMatch(this.state, match.id)!
1918
- let lazyPromise = Promise.resolve()
1919
- let componentsPromise = Promise.resolve() as Promise<any>
1920
- let loaderPromise = existing.loaderPromise
1921
-
1922
- // If the Matches component rendered
1923
- // the pending component and needs to show it for
1924
- // a minimum duration, we''ll wait for it to resolve
1925
- // before committing to the match and resolving
1926
- // the loadPromise
1927
- const potentialPendingMinPromise = async () => {
1928
- const latestMatch = getRouteMatch(this.state, match.id)
1929
-
1930
- if (latestMatch?.minPendingPromise) {
1931
- await latestMatch.minPendingPromise
1932
-
1933
- checkLatest()
1934
-
1935
- updateMatch(latestMatch.id, (prev) => ({
1936
- ...prev,
1937
- minPendingPromise: undefined,
1938
- }))
1939
- }
1924
+ if (
1925
+ isRedirect(beforeLoadContext) ||
1926
+ isNotFound(beforeLoadContext)
1927
+ ) {
1928
+ handleSerialError(index, beforeLoadContext, 'BEFORE_LOAD')
1940
1929
  }
1941
1930
 
1942
- try {
1943
- if (match.isFetching === 'beforeLoad') {
1944
- // If the user doesn't want the route to reload, just
1945
- // resolve with the existing loader data
1946
-
1947
- // if (match.fetchCount && match.status === 'success') {
1948
- // resolve()
1949
- // }
1950
-
1951
- // Otherwise, load the route
1952
- matches[index] = match = updateMatch(
1953
- match.id,
1954
- (prev) => ({
1955
- ...prev,
1956
- isFetching: 'loader',
1957
- fetchCount: match.fetchCount + 1,
1958
- }),
1959
- )
1960
-
1961
- lazyPromise =
1962
- route.lazyFn?.().then((lazyRoute) => {
1963
- Object.assign(route.options, lazyRoute.options)
1964
- }) || Promise.resolve()
1965
-
1966
- // If for some reason lazy resolves more lazy components...
1967
- // We'll wait for that before pre attempt to preload any
1968
- // components themselves.
1969
- componentsPromise = lazyPromise.then(() =>
1970
- Promise.all(
1971
- componentTypes.map(async (type) => {
1972
- const component = route.options[type]
1973
-
1974
- if ((component as any)?.preload) {
1975
- await (component as any).preload()
1976
- }
1977
- }),
1978
- ),
1979
- )
1980
-
1981
- // Lazy option can modify the route options,
1982
- // so we need to wait for it to resolve before
1983
- // we can use the options
1984
- await lazyPromise
1985
-
1986
- checkLatest()
1987
-
1988
- // Kick off the loader!
1989
- loaderPromise = route.options.loader?.(loaderContext)
1990
-
1991
- matches[index] = match = updateMatch(
1992
- match.id,
1993
- (prev) => ({
1994
- ...prev,
1995
- loaderPromise,
1996
- }),
1997
- )
1931
+ updateMatch(matchId, (prev) => {
1932
+ const routeContext = {
1933
+ ...prev.routeContext,
1934
+ ...beforeLoadContext,
1998
1935
  }
1999
1936
 
2000
- let loaderData = await loaderPromise
2001
- if (this.serializeLoaderData) {
2002
- loaderData = this.serializeLoaderData(loaderData, {
2003
- router: this,
2004
- match,
2005
- })
1937
+ return {
1938
+ ...prev,
1939
+ routeContext: replaceEqualDeep(
1940
+ prev.routeContext,
1941
+ routeContext,
1942
+ ),
1943
+ context: replaceEqualDeep(prev.context, routeContext),
1944
+ abortController,
2006
1945
  }
2007
- checkLatest()
1946
+ })
1947
+ } catch (err) {
1948
+ handleSerialError(index, err, 'BEFORE_LOAD')
1949
+ }
2008
1950
 
2009
- handleRedirectAndNotFound(match, loaderData)
1951
+ updateMatch(matchId, (prev) => {
1952
+ prev.beforeLoadPromise?.resolve()
2010
1953
 
2011
- await potentialPendingMinPromise()
2012
- checkLatest()
1954
+ return {
1955
+ ...prev,
1956
+ beforeLoadPromise: undefined,
1957
+ isFetching: false,
1958
+ }
1959
+ })
1960
+ }
1961
+ }
2013
1962
 
2014
- const meta = route.options.meta?.({
2015
- matches,
2016
- match,
2017
- params: match.params,
2018
- loaderData,
2019
- })
1963
+ const validResolvedMatches = matches.slice(0, firstBadMatchIndex)
1964
+ const matchPromises: Array<Promise<any>> = []
2020
1965
 
2021
- const headers = route.options.headers?.({
2022
- loaderData,
2023
- })
1966
+ validResolvedMatches.forEach(({ id: matchId, routeId }, index) => {
1967
+ matchPromises.push(
1968
+ (async () => {
1969
+ const { loaderPromise: prevLoaderPromise } =
1970
+ this.getMatch(matchId)!
1971
+
1972
+ if (prevLoaderPromise) {
1973
+ await prevLoaderPromise
1974
+ } else {
1975
+ const parentMatchPromise = matchPromises[index - 1]
1976
+ const route = this.looseRoutesById[routeId]!
1977
+
1978
+ const getLoaderContext = (): LoaderFnContext => {
1979
+ const {
1980
+ params,
1981
+ loaderDeps,
1982
+ abortController,
1983
+ context,
1984
+ cause,
1985
+ } = this.getMatch(matchId)!
1986
+
1987
+ return {
1988
+ params,
1989
+ deps: loaderDeps,
1990
+ preload: !!preload,
1991
+ parentMatchPromise,
1992
+ abortController: abortController,
1993
+ context,
1994
+ location,
1995
+ navigate: (opts) =>
1996
+ this.navigate({ ...opts, _fromLocation: location }),
1997
+ cause: preload ? 'preload' : cause,
1998
+ route,
1999
+ }
2000
+ }
2024
2001
 
2025
- matches[index] = match = updateMatch(match.id, (prev) => ({
2026
- ...prev,
2027
- error: undefined,
2028
- status: 'success',
2029
- isFetching: false,
2030
- updatedAt: Date.now(),
2031
- loaderData,
2032
- meta,
2033
- headers,
2034
- }))
2035
- } catch (e) {
2036
- checkLatest()
2037
- let error = e
2002
+ // This is where all of the stale-while-revalidate magic happens
2003
+ const age = Date.now() - this.getMatch(matchId)!.updatedAt
2038
2004
 
2039
- await potentialPendingMinPromise()
2040
- checkLatest()
2005
+ const staleAge = preload
2006
+ ? (route.options.preloadStaleTime ??
2007
+ this.options.defaultPreloadStaleTime ??
2008
+ 30_000) // 30 seconds for preloads by default
2009
+ : (route.options.staleTime ??
2010
+ this.options.defaultStaleTime ??
2011
+ 0)
2041
2012
 
2042
- handleRedirectAndNotFound(match, e)
2013
+ const shouldReloadOption = route.options.shouldReload
2043
2014
 
2044
- try {
2045
- route.options.onError?.(e)
2046
- } catch (onErrorError) {
2047
- error = onErrorError
2048
- handleRedirectAndNotFound(match, onErrorError)
2049
- }
2015
+ // Default to reloading the route all the time
2016
+ // Allow shouldReload to get the last say,
2017
+ // if provided.
2018
+ const shouldReload =
2019
+ typeof shouldReloadOption === 'function'
2020
+ ? shouldReloadOption(getLoaderContext())
2021
+ : shouldReloadOption
2050
2022
 
2051
- matches[index] = match = updateMatch(match.id, (prev) => ({
2023
+ updateMatch(matchId, (prev) => ({
2052
2024
  ...prev,
2053
- error,
2054
- status: 'error',
2055
- isFetching: false,
2025
+ loaderPromise: createControlledPromise<void>(),
2026
+ preload:
2027
+ !!preload &&
2028
+ !this.state.matches.find((d) => d.id === matchId),
2056
2029
  }))
2057
- }
2058
2030
 
2059
- // Last but not least, wait for the the component
2060
- // to be preloaded before we resolve the match
2061
- await componentsPromise
2062
-
2063
- checkLatest()
2031
+ const runLoader = async () => {
2032
+ try {
2033
+ // If the Matches component rendered
2034
+ // the pending component and needs to show it for
2035
+ // a minimum duration, we''ll wait for it to resolve
2036
+ // before committing to the match and resolving
2037
+ // the loadPromise
2038
+ const potentialPendingMinPromise = async () => {
2039
+ const latestMatch = this.getMatch(matchId)!
2040
+
2041
+ if (latestMatch.minPendingPromise) {
2042
+ await latestMatch.minPendingPromise
2043
+ }
2044
+ }
2045
+
2046
+ // Actually run the loader and handle the result
2047
+ try {
2048
+ route._lazyPromise =
2049
+ route._lazyPromise ||
2050
+ (route.lazyFn
2051
+ ? route.lazyFn().then((lazyRoute) => {
2052
+ Object.assign(
2053
+ route.options,
2054
+ lazyRoute.options,
2055
+ )
2056
+ })
2057
+ : Promise.resolve())
2058
+
2059
+ // If for some reason lazy resolves more lazy components...
2060
+ // We'll wait for that before pre attempt to preload any
2061
+ // components themselves.
2062
+ const componentsPromise =
2063
+ this.getMatch(matchId)!.componentsPromise ||
2064
+ route._lazyPromise.then(() =>
2065
+ Promise.all(
2066
+ componentTypes.map(async (type) => {
2067
+ const component = route.options[type]
2068
+
2069
+ if ((component as any)?.preload) {
2070
+ await (component as any).preload()
2071
+ }
2072
+ }),
2073
+ ),
2074
+ )
2075
+
2076
+ // Otherwise, load the route
2077
+ updateMatch(matchId, (prev) => ({
2078
+ ...prev,
2079
+ isFetching: 'loader',
2080
+ componentsPromise,
2081
+ }))
2082
+
2083
+ // Lazy option can modify the route options,
2084
+ // so we need to wait for it to resolve before
2085
+ // we can use the options
2086
+ await route._lazyPromise
2087
+
2088
+ // Kick off the loader!
2089
+ let loaderData =
2090
+ await route.options.loader?.(getLoaderContext())
2091
+
2092
+ if (this.serializeLoaderData) {
2093
+ loaderData = this.serializeLoaderData(loaderData, {
2094
+ router: this,
2095
+ match: this.getMatch(matchId)!,
2096
+ })
2097
+ }
2098
+
2099
+ handleRedirectAndNotFound(
2100
+ this.getMatch(matchId)!,
2101
+ loaderData,
2102
+ )
2103
+
2104
+ await potentialPendingMinPromise()
2105
+
2106
+ const meta = route.options.meta?.({
2107
+ matches,
2108
+ match: this.getMatch(matchId)!,
2109
+ params: this.getMatch(matchId)!.params,
2110
+ loaderData,
2111
+ })
2112
+
2113
+ const headers = route.options.headers?.({
2114
+ loaderData,
2115
+ })
2116
+
2117
+ updateMatch(matchId, (prev) => ({
2118
+ ...prev,
2119
+ error: undefined,
2120
+ status: 'success',
2121
+ isFetching: false,
2122
+ updatedAt: Date.now(),
2123
+ loaderData,
2124
+ meta,
2125
+ headers,
2126
+ }))
2127
+ } catch (e) {
2128
+ let error = e
2129
+
2130
+ await potentialPendingMinPromise()
2131
+
2132
+ handleRedirectAndNotFound(this.getMatch(matchId)!, e)
2133
+
2134
+ try {
2135
+ route.options.onError?.(e)
2136
+ } catch (onErrorError) {
2137
+ error = onErrorError
2138
+ handleRedirectAndNotFound(
2139
+ this.getMatch(matchId)!,
2140
+ onErrorError,
2141
+ )
2142
+ }
2143
+
2144
+ updateMatch(matchId, (prev) => ({
2145
+ ...prev,
2146
+ error,
2147
+ status: 'error',
2148
+ isFetching: false,
2149
+ }))
2150
+ }
2151
+
2152
+ // Last but not least, wait for the the component
2153
+ // to be preloaded before we resolve the match
2154
+ await this.getMatch(matchId)!.componentsPromise
2155
+ } catch (err) {
2156
+ handleRedirectAndNotFound(this.getMatch(matchId)!, err)
2157
+ }
2158
+ }
2064
2159
 
2065
- match.loadPromise.resolve()
2066
- }
2160
+ // If the route is successful and still fresh, just resolve
2161
+ const { status, invalid } = this.getMatch(matchId)!
2162
+
2163
+ if (
2164
+ status === 'success' &&
2165
+ (invalid || (shouldReload ?? age > staleAge))
2166
+ ) {
2167
+ ;(async () => {
2168
+ try {
2169
+ await runLoader()
2170
+ } catch (err) {}
2171
+ })()
2172
+ } else if (status !== 'success') {
2173
+ await runLoader()
2174
+ }
2067
2175
 
2068
- // This is where all of the stale-while-revalidate magic happens
2069
- const age = Date.now() - match.updatedAt
2070
-
2071
- const staleAge = preload
2072
- ? (route.options.preloadStaleTime ??
2073
- this.options.defaultPreloadStaleTime ??
2074
- 30_000) // 30 seconds for preloads by default
2075
- : (route.options.staleTime ??
2076
- this.options.defaultStaleTime ??
2077
- 0)
2078
-
2079
- const shouldReloadOption = route.options.shouldReload
2080
-
2081
- // Default to reloading the route all the time
2082
- // Allow shouldReload to get the last say,
2083
- // if provided.
2084
- const shouldReload =
2085
- typeof shouldReloadOption === 'function'
2086
- ? shouldReloadOption(loaderContext)
2087
- : shouldReloadOption
2088
-
2089
- matches[index] = match = {
2090
- ...match,
2091
- preload:
2092
- !!preload &&
2093
- !this.state.matches.find((d) => d.id === match.id),
2094
- }
2176
+ const { loaderPromise, loadPromise } =
2177
+ this.getMatch(matchId)!
2095
2178
 
2096
- const fetchWithRedirectAndNotFound = async () => {
2097
- try {
2098
- await fetchAndResolveInLoaderLifetime()
2099
- } catch (err) {
2100
- checkLatest()
2101
- handleRedirectAndNotFound(match, err)
2179
+ loaderPromise?.resolve()
2180
+ loadPromise?.resolve()
2102
2181
  }
2103
- }
2104
-
2105
- // If the route is successful and still fresh, just resolve
2106
- if (
2107
- match.status === 'success' &&
2108
- (match.invalid || (shouldReload ?? age > staleAge))
2109
- ) {
2110
- ;(async () => {
2111
- try {
2112
- await fetchWithRedirectAndNotFound()
2113
- } catch (err) {}
2114
- })()
2115
- return
2116
- }
2117
2182
 
2118
- if (match.status !== 'success') {
2119
- await fetchWithRedirectAndNotFound()
2120
- }
2121
-
2122
- return
2123
- }
2124
-
2125
- matchPromises.push(createValidateResolvedMatchPromise())
2183
+ updateMatch(matchId, (prev) => ({
2184
+ ...prev,
2185
+ isFetching: false,
2186
+ loaderPromise: undefined,
2187
+ }))
2188
+ })(),
2189
+ )
2126
2190
  })
2127
2191
 
2128
2192
  await Promise.all(matchPromises)
2129
2193
 
2130
- checkLatest()
2131
-
2132
2194
  resolveAll()
2133
2195
  } catch (err) {
2134
2196
  rejectAll(err)
@@ -2152,7 +2214,9 @@ export class Router<
2152
2214
  const invalidate = (d: MakeRouteMatch<TRouteTree>) => ({
2153
2215
  ...d,
2154
2216
  invalid: true,
2155
- ...(d.status === 'error' ? ({ status: 'pending' } as const) : {}),
2217
+ ...(d.status === 'error'
2218
+ ? ({ status: 'pending', error: undefined } as const)
2219
+ : {}),
2156
2220
  })
2157
2221
 
2158
2222
  this.__store.setState((s) => ({
@@ -2242,29 +2306,24 @@ export class Router<
2242
2306
  })
2243
2307
  })
2244
2308
 
2245
- // If the preload leaf match is the same as the current or pending leaf match,
2246
- // do not preload as it could cause a mutation of the current route.
2247
- // The user should specify proper loaderDeps (which are used to uniquely identify a route)
2248
- // to trigger preloads for routes with the same pathname, but different deps
2249
-
2250
- const leafMatch = last(matches)
2251
- const currentLeafMatch = last(this.state.matches)
2252
- const pendingLeafMatch = last(this.state.pendingMatches ?? [])
2253
-
2254
- if (
2255
- leafMatch &&
2256
- (currentLeafMatch?.id === leafMatch.id ||
2257
- pendingLeafMatch?.id === leafMatch.id)
2258
- ) {
2259
- return undefined
2260
- }
2309
+ const activeMatchIds = new Set(
2310
+ [...this.state.matches, ...(this.state.pendingMatches ?? [])].map(
2311
+ (d) => d.id,
2312
+ ),
2313
+ )
2261
2314
 
2262
2315
  try {
2263
2316
  matches = await this.loadMatches({
2264
2317
  matches,
2265
2318
  location: next,
2266
2319
  preload: true,
2267
- checkLatest: () => undefined,
2320
+ updateMatch: (id, updater) => {
2321
+ if (activeMatchIds.has(id)) {
2322
+ matches = matches.map((d) => (d.id === id ? updater(d) : d))
2323
+ } else {
2324
+ this.updateMatch(id, updater)
2325
+ }
2326
+ },
2268
2327
  })
2269
2328
 
2270
2329
  return matches
@@ -2363,7 +2422,7 @@ export class Router<
2363
2422
  }
2364
2423
  }
2365
2424
 
2366
- hydrate = async () => {
2425
+ hydrate = () => {
2367
2426
  // Client hydrates from window
2368
2427
  let ctx: HydrationCtx | undefined
2369
2428
 
@@ -2457,7 +2516,18 @@ export class Router<
2457
2516
  )
2458
2517
  }
2459
2518
 
2460
- handleNotFound = (matches: Array<AnyRouteMatch>, err: NotFoundError) => {
2519
+ _handleNotFound = (
2520
+ matches: Array<AnyRouteMatch>,
2521
+ err: NotFoundError,
2522
+ {
2523
+ updateMatch = this.updateMatch,
2524
+ }: {
2525
+ updateMatch?: (
2526
+ id: string,
2527
+ updater: (match: AnyRouteMatch) => AnyRouteMatch,
2528
+ ) => void
2529
+ } = {},
2530
+ ) => {
2461
2531
  const matchesByRouteId = Object.fromEntries(
2462
2532
  matches.map((match) => [match.routeId, match]),
2463
2533
  ) as Record<string, AnyRouteMatch>
@@ -2488,11 +2558,20 @@ export class Router<
2488
2558
  invariant(match, 'Could not find match for route: ' + routeCursor.id)
2489
2559
 
2490
2560
  // Assign the error to the match
2491
- Object.assign(match, {
2561
+
2562
+ updateMatch(match.id, (prev) => ({
2563
+ ...prev,
2492
2564
  status: 'notFound',
2493
2565
  error: err,
2494
2566
  isFetching: false,
2495
- } as AnyRouteMatch)
2567
+ }))
2568
+
2569
+ if ((err as any).routerCode === 'BEFORE_LOAD' && routeCursor.parentRoute) {
2570
+ err.routeId = routeCursor.parentRoute.id
2571
+ this._handleNotFound(matches, err, {
2572
+ updateMatch,
2573
+ })
2574
+ }
2496
2575
  }
2497
2576
 
2498
2577
  hasNotFoundMatch = () => {
@@ -2500,12 +2579,6 @@ export class Router<
2500
2579
  (d) => d.status === 'notFound' || d.globalNotFound,
2501
2580
  )
2502
2581
  }
2503
-
2504
- // resolveMatchPromise = (matchId: string, key: string, value: any) => {
2505
- // state.matches
2506
- // .find((d) => d.id === matchId)
2507
- // ?.__promisesByKey[key]?.resolve(value)
2508
- // }
2509
2582
  }
2510
2583
 
2511
2584
  // A function that takes an import() argument which is a function and returns a new function that will
@@ -2531,6 +2604,7 @@ export function getInitialRouterState(
2531
2604
  location: ParsedLocation,
2532
2605
  ): RouterState<any> {
2533
2606
  return {
2607
+ loadedAt: 0,
2534
2608
  isLoading: false,
2535
2609
  isTransitioning: false,
2536
2610
  status: 'idle',