@tanstack/react-router 1.45.2 → 1.45.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +4 -1
  7. package/dist/cjs/Matches.cjs.map +1 -1
  8. package/dist/cjs/Matches.d.cts +5 -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 +464 -407
  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 +5 -2
  28. package/dist/esm/Matches.js +4 -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 +466 -409
  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 +10 -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 +647 -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,12 @@ export class Router<
1134
1126
  }
1135
1127
 
1136
1128
  cancelMatch = (id: string) => {
1137
- getRouteMatch(this.state, id)?.abortController.abort()
1129
+ const match = this.getMatch(id)
1130
+
1131
+ if (!match) return
1132
+
1133
+ match.abortController.abort()
1134
+ clearTimeout(match.pendingTimeout)
1138
1135
  }
1139
1136
 
1140
1137
  cancelMatches = () => {
@@ -1367,12 +1364,13 @@ export class Router<
1367
1364
  return buildWithMatches(opts)
1368
1365
  }
1369
1366
 
1370
- commitLocation = async ({
1371
- startTransition,
1367
+ commitLocationPromise: undefined | ControlledPromise<void>
1368
+
1369
+ commitLocation = ({
1372
1370
  viewTransition,
1373
1371
  ignoreBlocker,
1374
1372
  ...next
1375
- }: ParsedLocation & CommitLocationOptions) => {
1373
+ }: ParsedLocation & CommitLocationOptions): Promise<void> => {
1376
1374
  const isSameState = () => {
1377
1375
  // `state.key` is ignored but may still be provided when navigating,
1378
1376
  // temporarily add the previous key to the next state so it doesn't affect
@@ -1386,6 +1384,11 @@ export class Router<
1386
1384
 
1387
1385
  const isSameUrl = this.latestLocation.href === next.href
1388
1386
 
1387
+ const previousCommitPromise = this.commitLocationPromise
1388
+ this.commitLocationPromise = createControlledPromise<void>(() => {
1389
+ previousCommitPromise?.resolve()
1390
+ })
1391
+
1389
1392
  // Don't commit to history if nothing changed
1390
1393
  if (isSameUrl && isSameState()) {
1391
1394
  this.load()
@@ -1432,13 +1435,16 @@ export class Router<
1432
1435
 
1433
1436
  this.resetNextScroll = next.resetScroll ?? true
1434
1437
 
1435
- return this.latestLoadPromise
1438
+ if (!this.history.subscribers.size) {
1439
+ this.load()
1440
+ }
1441
+
1442
+ return this.commitLocationPromise
1436
1443
  }
1437
1444
 
1438
1445
  buildAndCommitLocation = ({
1439
1446
  replace,
1440
1447
  resetScroll,
1441
- startTransition,
1442
1448
  viewTransition,
1443
1449
  ignoreBlocker,
1444
1450
  ...rest
@@ -1446,7 +1452,6 @@ export class Router<
1446
1452
  const location = this.buildLocation(rest as any)
1447
1453
  return this.commitLocation({
1448
1454
  ...location,
1449
- startTransition,
1450
1455
  viewTransition,
1451
1456
  replace,
1452
1457
  resetScroll,
@@ -1471,7 +1476,7 @@ export class Router<
1471
1476
 
1472
1477
  invariant(
1473
1478
  !isExternal,
1474
- 'Attempting to navigate to external url with this.navigate!',
1479
+ 'Attempting to navigate to external url with router.navigate!',
1475
1480
  )
1476
1481
 
1477
1482
  return this.buildAndCommitLocation({
@@ -1482,156 +1487,174 @@ export class Router<
1482
1487
  })
1483
1488
  }
1484
1489
 
1490
+ latestLoadPromise: undefined | Promise<void>
1491
+
1485
1492
  load = async (): Promise<void> => {
1486
1493
  this.latestLocation = this.parseLocation(this.latestLocation)
1487
1494
 
1488
- if (this.state.location === this.latestLocation) {
1489
- return
1490
- }
1495
+ this.__store.setState((s) => ({
1496
+ ...s,
1497
+ loadedAt: Date.now(),
1498
+ }))
1491
1499
 
1492
- const promise = createControlledPromise<void>()
1493
- this.latestLoadPromise = promise
1494
1500
  let redirect: ResolvedRedirect | undefined
1495
1501
  let notFound: NotFoundError | undefined
1496
1502
 
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)
1503
+ const loadPromise = new Promise<void>((resolve) => {
1504
+ this.startReactTransition(async () => {
1505
+ try {
1506
+ const next = this.latestLocation
1507
+ const prevLocation = this.state.resolvedLocation
1508
+ const pathDidChange = prevLocation.href !== next.href
1509
+
1510
+ // Cancel any pending matches
1511
+ this.cancelMatches()
1512
+
1513
+ let pendingMatches!: Array<AnyRouteMatch>
1514
+
1515
+ this.__store.batch(() => {
1516
+ // this call breaks a route context of destination route after a redirect
1517
+ // we should be fine not eagerly calling this since we call it later
1518
+ // this.cleanCache()
1519
+
1520
+ // Match the routes
1521
+ pendingMatches = this.matchRoutes(next.pathname, next.search)
1522
+
1523
+ // Ingest the new matches
1524
+ this.__store.setState((s) => ({
1525
+ ...s,
1526
+ status: 'pending',
1527
+ isLoading: true,
1528
+ location: next,
1529
+ pendingMatches,
1530
+ // If a cached moved to pendingMatches, remove it from cachedMatches
1531
+ cachedMatches: s.cachedMatches.filter((d) => {
1532
+ return !pendingMatches.find((e) => e.id === d.id)
1533
+ }),
1534
+ }))
1535
+ })
1515
1536
 
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
- })
1537
+ if (!this.state.redirect) {
1538
+ this.emit({
1539
+ type: 'onBeforeNavigate',
1540
+ fromLocation: prevLocation,
1541
+ toLocation: next,
1542
+ pathChanged: pathDidChange,
1543
+ })
1544
+ }
1529
1545
 
1530
- if (!this.state.redirect) {
1531
1546
  this.emit({
1532
- type: 'onBeforeNavigate',
1547
+ type: 'onBeforeLoad',
1533
1548
  fromLocation: prevLocation,
1534
1549
  toLocation: next,
1535
1550
  pathChanged: pathDidChange,
1536
1551
  })
1537
- }
1538
1552
 
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
-
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
- }
1553
+ await this.loadMatches({
1554
+ matches: pendingMatches,
1555
+ location: next,
1556
+ // eslint-disable-next-line ts/require-await
1557
+ onReady: async () => {
1558
+ // eslint-disable-next-line ts/require-await
1559
+ this.startViewTransition(async () => {
1560
+ // this.viewTransitionPromise = createControlledPromise<true>()
1561
+
1562
+ // Commit the pending matches. If a previous match was
1563
+ // removed, place it in the cachedMatches
1564
+ let exitingMatches!: Array<AnyRouteMatch>
1565
+ let enteringMatches!: Array<AnyRouteMatch>
1566
+ let stayingMatches!: Array<AnyRouteMatch>
1567
+
1568
+ this.__store.batch(() => {
1569
+ this.__store.setState((s) => {
1570
+ const previousMatches = s.matches
1571
+ const newMatches = s.pendingMatches || s.matches
1572
+
1573
+ exitingMatches = previousMatches.filter(
1574
+ (match) => !newMatches.find((d) => d.id === match.id),
1575
+ )
1576
+ enteringMatches = newMatches.filter(
1577
+ (match) =>
1578
+ !previousMatches.find((d) => d.id === match.id),
1579
+ )
1580
+ stayingMatches = previousMatches.filter((match) =>
1581
+ newMatches.find((d) => d.id === match.id),
1582
+ )
1583
+
1584
+ return {
1585
+ ...s,
1586
+ isLoading: false,
1587
+ matches: newMatches,
1588
+ pendingMatches: undefined,
1589
+ cachedMatches: [
1590
+ ...s.cachedMatches,
1591
+ ...exitingMatches.filter((d) => d.status !== 'error'),
1592
+ ],
1593
+ }
1594
+ })
1595
+ this.cleanCache()
1585
1596
  })
1586
- this.cleanCache()
1587
- })
1588
1597
 
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)
1598
+ //
1599
+ ;(
1600
+ [
1601
+ [exitingMatches, 'onLeave'],
1602
+ [enteringMatches, 'onEnter'],
1603
+ [stayingMatches, 'onStay'],
1604
+ ] as const
1605
+ ).forEach(([matches, hook]) => {
1606
+ matches.forEach((match) => {
1607
+ this.looseRoutesById[match.routeId]!.options[hook]?.(match)
1608
+ })
1599
1609
  })
1600
1610
  })
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()
1611
+ },
1612
+ })
1613
+ } catch (err) {
1614
+ if (isResolvedRedirect(err)) {
1615
+ redirect = err
1616
+ if (!this.isServer) {
1617
+ this.navigate({ ...err, replace: true, __isRedirect: true })
1618
+ }
1619
+ } else if (isNotFound(err)) {
1620
+ notFound = err
1610
1621
  }
1611
- } else if (isNotFound(err)) {
1612
- notFound = err
1613
- }
1614
1622
 
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
- }
1623
+ this.__store.setState((s) => ({
1624
+ ...s,
1625
+ statusCode: redirect
1626
+ ? redirect.statusCode
1627
+ : notFound
1628
+ ? 404
1629
+ : s.matches.some((d) => d.status === 'error')
1630
+ ? 500
1631
+ : 200,
1632
+ redirect,
1633
+ }))
1634
+ }
1627
1635
 
1628
- promise.resolve()
1636
+ if (this.latestLoadPromise === loadPromise) {
1637
+ this.commitLocationPromise?.resolve()
1638
+ this.latestLoadPromise = undefined
1639
+ this.commitLocationPromise = undefined
1640
+ }
1641
+ resolve()
1642
+ })
1629
1643
  })
1630
1644
 
1631
- return this.latestLoadPromise
1645
+ this.latestLoadPromise = loadPromise
1646
+
1647
+ await loadPromise
1648
+
1649
+ while (
1650
+ (this.latestLoadPromise as any) &&
1651
+ loadPromise !== this.latestLoadPromise
1652
+ ) {
1653
+ await this.latestLoadPromise
1654
+ }
1632
1655
  }
1633
1656
 
1634
- startViewTransition = async (fn: () => Promise<void>) => {
1657
+ startViewTransition = (fn: () => Promise<void>) => {
1635
1658
  // Determine if we should start a view transition from the navigation
1636
1659
  // or from the router default
1637
1660
  const shouldViewTransition =
@@ -1648,18 +1671,54 @@ export class Router<
1648
1671
  ?.startViewTransition?.(fn) || fn()
1649
1672
  }
1650
1673
 
1674
+ updateMatch = (
1675
+ id: string,
1676
+ updater: (match: AnyRouteMatch) => AnyRouteMatch,
1677
+ ) => {
1678
+ let updated!: AnyRouteMatch
1679
+ const isPending = this.state.pendingMatches?.find((d) => d.id === id)
1680
+ const isMatched = this.state.matches.find((d) => d.id === id)
1681
+
1682
+ const matchesKey = isPending
1683
+ ? 'pendingMatches'
1684
+ : isMatched
1685
+ ? 'matches'
1686
+ : 'cachedMatches'
1687
+
1688
+ this.__store.setState((s) => ({
1689
+ ...s,
1690
+ [matchesKey]: s[matchesKey]?.map((d) =>
1691
+ d.id === id ? (updated = updater(d)) : d,
1692
+ ),
1693
+ }))
1694
+
1695
+ return updated
1696
+ }
1697
+
1698
+ getMatch = (matchId: string) => {
1699
+ return [
1700
+ ...this.state.cachedMatches,
1701
+ ...(this.state.pendingMatches ?? []),
1702
+ ...this.state.matches,
1703
+ ].find((d) => d.id === matchId)
1704
+ }
1705
+
1651
1706
  loadMatches = async ({
1652
- checkLatest,
1653
1707
  location,
1654
1708
  matches,
1655
1709
  preload,
1656
1710
  onReady,
1711
+ updateMatch = this.updateMatch,
1657
1712
  }: {
1658
- checkLatest: () => void
1659
1713
  location: ParsedLocation
1660
1714
  matches: Array<AnyRouteMatch>
1661
1715
  preload?: boolean
1662
1716
  onReady?: () => Promise<void>
1717
+ updateMatch?: (
1718
+ id: string,
1719
+ updater: (match: AnyRouteMatch) => AnyRouteMatch,
1720
+ ) => void
1721
+ getMatch?: (matchId: string) => AnyRouteMatch | undefined
1663
1722
  }): Promise<Array<MakeRouteMatch>> => {
1664
1723
  let firstBadMatchIndex: number | undefined
1665
1724
  let rendered = false
@@ -1675,38 +1734,10 @@ export class Router<
1675
1734
  triggerOnReady()
1676
1735
  }
1677
1736
 
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
1737
  const handleRedirectAndNotFound = (match: AnyRouteMatch, err: any) => {
1706
1738
  if (isResolvedRedirect(err)) throw err
1707
1739
 
1708
1740
  if (isRedirect(err) || isNotFound(err)) {
1709
- // if (!rendered) {
1710
1741
  updateMatch(match.id, (prev) => ({
1711
1742
  ...prev,
1712
1743
  status: isRedirect(err)
@@ -1716,19 +1747,26 @@ export class Router<
1716
1747
  : 'error',
1717
1748
  isFetching: false,
1718
1749
  error: err,
1750
+ beforeLoadPromise: undefined,
1751
+ loaderPromise: undefined,
1719
1752
  }))
1720
- // }
1721
1753
 
1722
1754
  if (!(err as any).routeId) {
1723
1755
  ;(err as any).routeId = match.routeId
1724
1756
  }
1725
1757
 
1758
+ match.beforeLoadPromise?.resolve()
1759
+ match.loaderPromise?.resolve()
1760
+ match.loadPromise?.resolve()
1761
+
1726
1762
  if (isRedirect(err)) {
1727
1763
  rendered = true
1728
1764
  err = this.resolveRedirect({ ...err, _fromLocation: location })
1729
1765
  throw err
1730
1766
  } else if (isNotFound(err)) {
1731
- this.handleNotFound(matches, err)
1767
+ this._handleNotFound(matches, err, {
1768
+ updateMatch,
1769
+ })
1732
1770
  throw err
1733
1771
  }
1734
1772
  }
@@ -1738,397 +1776,429 @@ export class Router<
1738
1776
  await new Promise<void>((resolveAll, rejectAll) => {
1739
1777
  ;(async () => {
1740
1778
  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)
1779
+ const handleSerialError = (
1780
+ index: number,
1781
+ err: any,
1782
+ routerCode: string,
1783
+ ) => {
1784
+ const { id: matchId, routeId } = matches[index]!
1785
+ const route = this.looseRoutesById[routeId]!
1786
+
1787
+ // Much like suspense, we use a promise here to know if
1788
+ // we've been outdated by a new loadMatches call and
1789
+ // should abort the current async operation
1790
+ if (err instanceof Promise) {
1791
+ throw err
1774
1792
  }
1775
1793
 
1776
- if (match.isFetching) {
1777
- continue
1778
- }
1794
+ err.routerCode = routerCode
1795
+ firstBadMatchIndex = firstBadMatchIndex ?? index
1796
+ handleRedirectAndNotFound(this.getMatch(matchId)!, err)
1779
1797
 
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
- }
1798
+ try {
1799
+ route.options.onError?.(err)
1800
+ } catch (errorHandlerErr) {
1801
+ err = errorHandlerErr
1802
+ handleRedirectAndNotFound(this.getMatch(matchId)!, err)
1803
+ }
1800
1804
 
1801
- err.routerCode = routerCode
1802
- firstBadMatchIndex = firstBadMatchIndex ?? index
1803
- handleRedirectAndNotFound(match, err)
1805
+ updateMatch(matchId, (prev) => {
1806
+ prev.beforeLoadPromise?.resolve()
1804
1807
 
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,
1808
+ return {
1809
+ ...prev,
1814
1810
  error: err,
1815
1811
  status: 'error',
1812
+ isFetching: false,
1816
1813
  updatedAt: Date.now(),
1817
1814
  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,
1815
+ beforeLoadPromise: undefined,
1842
1816
  }
1817
+ })
1818
+ }
1843
1819
 
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
- }
1820
+ for (const [index, { id: matchId, routeId }] of matches.entries()) {
1821
+ const existingMatch = this.getMatch(matchId)!
1822
+
1823
+ if (
1824
+ // If we are in the middle of a load, either of these will be present
1825
+ // (not to be confused with `loadPromise`, which is always defined)
1826
+ existingMatch.beforeLoadPromise ||
1827
+ existingMatch.loaderPromise
1828
+ ) {
1829
+ // Wait for the beforeLoad to resolve before we continue
1830
+ await existingMatch.beforeLoadPromise
1831
+ } else {
1832
+ // If we are not in the middle of a load, start it
1833
+ try {
1834
+ updateMatch(matchId, (prev) => ({
1835
+ ...prev,
1836
+ loadPromise: createControlledPromise<void>(() => {
1837
+ prev.loadPromise?.resolve()
1838
+ }),
1839
+ beforeLoadPromise: createControlledPromise<void>(),
1840
+ }))
1841
+
1842
+ const route = this.looseRoutesById[routeId]!
1843
+ const abortController = new AbortController()
1844
+
1845
+ const parentMatchId = matches[index - 1]?.id
1846
+
1847
+ const getParentContext = () => {
1848
+ if (!parentMatchId) {
1849
+ return (this.options.context as any) ?? {}
1850
+ }
1856
1851
 
1857
- const beforeLoadContext = route.options.beforeLoad
1858
- ? ((await route.options.beforeLoad(beforeLoadFnContext)) ??
1859
- {})
1860
- : {}
1852
+ return (
1853
+ this.getMatch(parentMatchId)!.context ??
1854
+ this.options.context ??
1855
+ {}
1856
+ )
1857
+ }
1861
1858
 
1862
- checkLatest()
1859
+ const pendingMs =
1860
+ route.options.pendingMs ?? this.options.defaultPendingMs
1861
+
1862
+ const shouldPending = !!(
1863
+ onReady &&
1864
+ !this.isServer &&
1865
+ !preload &&
1866
+ (route.options.loader || route.options.beforeLoad) &&
1867
+ typeof pendingMs === 'number' &&
1868
+ pendingMs !== Infinity &&
1869
+ (route.options.pendingComponent ??
1870
+ this.options.defaultPendingComponent)
1871
+ )
1863
1872
 
1864
- if (
1865
- isRedirect(beforeLoadContext) ||
1866
- isNotFound(beforeLoadContext)
1867
- ) {
1868
- handleSerialError(beforeLoadContext, 'BEFORE_LOAD')
1869
- }
1873
+ let pendingTimeout: ReturnType<typeof setTimeout>
1874
+
1875
+ if (shouldPending) {
1876
+ // If we might show a pending component, we need to wait for the
1877
+ // pending promise to resolve before we start showing that state
1878
+ pendingTimeout = setTimeout(() => {
1879
+ try {
1880
+ // Update the match and prematurely resolve the loadMatches promise so that
1881
+ // the pending component can start rendering
1882
+ triggerOnReady()
1883
+ } catch {}
1884
+ }, pendingMs)
1885
+ }
1870
1886
 
1871
- const context = {
1872
- ...parentContext,
1873
- ...beforeLoadContext,
1874
- }
1887
+ const { paramsError, searchError } = this.getMatch(matchId)!
1875
1888
 
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
- }
1889
+ if (paramsError) {
1890
+ handleSerialError(index, paramsError, 'PARSE_PARAMS')
1891
+ }
1891
1892
 
1892
- checkLatest()
1893
+ if (searchError) {
1894
+ handleSerialError(index, searchError, 'VALIDATE_SEARCH')
1895
+ }
1893
1896
 
1894
- const validResolvedMatches = matches.slice(0, firstBadMatchIndex)
1895
- const matchPromises: Array<Promise<any>> = []
1897
+ const parentContext = getParentContext()
1898
+
1899
+ updateMatch(matchId, (prev) => ({
1900
+ ...prev,
1901
+ isFetching: 'beforeLoad',
1902
+ fetchCount: prev.fetchCount + 1,
1903
+ routeContext: replaceEqualDeep(
1904
+ prev.routeContext,
1905
+ parentContext,
1906
+ ),
1907
+ context: replaceEqualDeep(prev.context, parentContext),
1908
+ abortController,
1909
+ pendingTimeout,
1910
+ }))
1911
+
1912
+ const { search, params, routeContext, cause } =
1913
+ this.getMatch(matchId)!
1914
+
1915
+ const beforeLoadFnContext = {
1916
+ search,
1917
+ abortController,
1918
+ params,
1919
+ preload: !!preload,
1920
+ context: routeContext,
1921
+ location,
1922
+ navigate: (opts: any) =>
1923
+ this.navigate({ ...opts, _fromLocation: location }),
1924
+ buildLocation: this.buildLocation,
1925
+ cause: preload ? 'preload' : cause,
1926
+ }
1896
1927
 
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
- }
1928
+ const beforeLoadContext =
1929
+ (await route.options.beforeLoad?.(beforeLoadFnContext)) ??
1930
+ {}
1915
1931
 
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
- }
1932
+ if (
1933
+ isRedirect(beforeLoadContext) ||
1934
+ isNotFound(beforeLoadContext)
1935
+ ) {
1936
+ handleSerialError(index, beforeLoadContext, 'BEFORE_LOAD')
1940
1937
  }
1941
1938
 
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
- )
1939
+ updateMatch(matchId, (prev) => {
1940
+ const routeContext = {
1941
+ ...prev.routeContext,
1942
+ ...beforeLoadContext,
1998
1943
  }
1999
1944
 
2000
- let loaderData = await loaderPromise
2001
- if (this.serializeLoaderData) {
2002
- loaderData = this.serializeLoaderData(loaderData, {
2003
- router: this,
2004
- match,
2005
- })
1945
+ return {
1946
+ ...prev,
1947
+ routeContext: replaceEqualDeep(
1948
+ prev.routeContext,
1949
+ routeContext,
1950
+ ),
1951
+ context: replaceEqualDeep(prev.context, routeContext),
1952
+ abortController,
2006
1953
  }
2007
- checkLatest()
1954
+ })
1955
+ } catch (err) {
1956
+ handleSerialError(index, err, 'BEFORE_LOAD')
1957
+ }
2008
1958
 
2009
- handleRedirectAndNotFound(match, loaderData)
1959
+ updateMatch(matchId, (prev) => {
1960
+ prev.beforeLoadPromise?.resolve()
2010
1961
 
2011
- await potentialPendingMinPromise()
2012
- checkLatest()
1962
+ return {
1963
+ ...prev,
1964
+ beforeLoadPromise: undefined,
1965
+ isFetching: false,
1966
+ }
1967
+ })
1968
+ }
1969
+ }
2013
1970
 
2014
- const meta = route.options.meta?.({
2015
- matches,
2016
- match,
2017
- params: match.params,
2018
- loaderData,
2019
- })
1971
+ const validResolvedMatches = matches.slice(0, firstBadMatchIndex)
1972
+ const matchPromises: Array<Promise<any>> = []
2020
1973
 
2021
- const headers = route.options.headers?.({
2022
- loaderData,
2023
- })
1974
+ validResolvedMatches.forEach(({ id: matchId, routeId }, index) => {
1975
+ matchPromises.push(
1976
+ (async () => {
1977
+ const { loaderPromise: prevLoaderPromise } =
1978
+ this.getMatch(matchId)!
1979
+
1980
+ if (prevLoaderPromise) {
1981
+ await prevLoaderPromise
1982
+ } else {
1983
+ const parentMatchPromise = matchPromises[index - 1]
1984
+ const route = this.looseRoutesById[routeId]!
1985
+
1986
+ const getLoaderContext = (): LoaderFnContext => {
1987
+ const {
1988
+ params,
1989
+ loaderDeps,
1990
+ abortController,
1991
+ context,
1992
+ cause,
1993
+ } = this.getMatch(matchId)!
1994
+
1995
+ return {
1996
+ params,
1997
+ deps: loaderDeps,
1998
+ preload: !!preload,
1999
+ parentMatchPromise,
2000
+ abortController: abortController,
2001
+ context,
2002
+ location,
2003
+ navigate: (opts) =>
2004
+ this.navigate({ ...opts, _fromLocation: location }),
2005
+ cause: preload ? 'preload' : cause,
2006
+ route,
2007
+ }
2008
+ }
2024
2009
 
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
2010
+ // This is where all of the stale-while-revalidate magic happens
2011
+ const age = Date.now() - this.getMatch(matchId)!.updatedAt
2038
2012
 
2039
- await potentialPendingMinPromise()
2040
- checkLatest()
2013
+ const staleAge = preload
2014
+ ? (route.options.preloadStaleTime ??
2015
+ this.options.defaultPreloadStaleTime ??
2016
+ 30_000) // 30 seconds for preloads by default
2017
+ : (route.options.staleTime ??
2018
+ this.options.defaultStaleTime ??
2019
+ 0)
2041
2020
 
2042
- handleRedirectAndNotFound(match, e)
2021
+ const shouldReloadOption = route.options.shouldReload
2043
2022
 
2044
- try {
2045
- route.options.onError?.(e)
2046
- } catch (onErrorError) {
2047
- error = onErrorError
2048
- handleRedirectAndNotFound(match, onErrorError)
2049
- }
2023
+ // Default to reloading the route all the time
2024
+ // Allow shouldReload to get the last say,
2025
+ // if provided.
2026
+ const shouldReload =
2027
+ typeof shouldReloadOption === 'function'
2028
+ ? shouldReloadOption(getLoaderContext())
2029
+ : shouldReloadOption
2050
2030
 
2051
- matches[index] = match = updateMatch(match.id, (prev) => ({
2031
+ updateMatch(matchId, (prev) => ({
2052
2032
  ...prev,
2053
- error,
2054
- status: 'error',
2055
- isFetching: false,
2033
+ loaderPromise: createControlledPromise<void>(),
2034
+ preload:
2035
+ !!preload &&
2036
+ !this.state.matches.find((d) => d.id === matchId),
2056
2037
  }))
2057
- }
2058
2038
 
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()
2039
+ const runLoader = async () => {
2040
+ try {
2041
+ // If the Matches component rendered
2042
+ // the pending component and needs to show it for
2043
+ // a minimum duration, we''ll wait for it to resolve
2044
+ // before committing to the match and resolving
2045
+ // the loadPromise
2046
+ const potentialPendingMinPromise = async () => {
2047
+ const latestMatch = this.getMatch(matchId)!
2048
+
2049
+ if (latestMatch.minPendingPromise) {
2050
+ await latestMatch.minPendingPromise
2051
+ }
2052
+ }
2053
+
2054
+ // Actually run the loader and handle the result
2055
+ try {
2056
+ route._lazyPromise =
2057
+ route._lazyPromise ||
2058
+ (route.lazyFn
2059
+ ? route.lazyFn().then((lazyRoute) => {
2060
+ Object.assign(
2061
+ route.options,
2062
+ lazyRoute.options,
2063
+ )
2064
+ })
2065
+ : Promise.resolve())
2066
+
2067
+ // If for some reason lazy resolves more lazy components...
2068
+ // We'll wait for that before pre attempt to preload any
2069
+ // components themselves.
2070
+ const componentsPromise =
2071
+ this.getMatch(matchId)!.componentsPromise ||
2072
+ route._lazyPromise.then(() =>
2073
+ Promise.all(
2074
+ componentTypes.map(async (type) => {
2075
+ const component = route.options[type]
2076
+
2077
+ if ((component as any)?.preload) {
2078
+ await (component as any).preload()
2079
+ }
2080
+ }),
2081
+ ),
2082
+ )
2083
+
2084
+ // Otherwise, load the route
2085
+ updateMatch(matchId, (prev) => ({
2086
+ ...prev,
2087
+ isFetching: 'loader',
2088
+ componentsPromise,
2089
+ }))
2090
+
2091
+ // Lazy option can modify the route options,
2092
+ // so we need to wait for it to resolve before
2093
+ // we can use the options
2094
+ await route._lazyPromise
2095
+
2096
+ // Kick off the loader!
2097
+ let loaderData =
2098
+ await route.options.loader?.(getLoaderContext())
2099
+
2100
+ if (this.serializeLoaderData) {
2101
+ loaderData = this.serializeLoaderData(loaderData, {
2102
+ router: this,
2103
+ match: this.getMatch(matchId)!,
2104
+ })
2105
+ }
2106
+
2107
+ handleRedirectAndNotFound(
2108
+ this.getMatch(matchId)!,
2109
+ loaderData,
2110
+ )
2111
+
2112
+ await potentialPendingMinPromise()
2113
+
2114
+ const meta = route.options.meta?.({
2115
+ matches,
2116
+ match: this.getMatch(matchId)!,
2117
+ params: this.getMatch(matchId)!.params,
2118
+ loaderData,
2119
+ })
2120
+
2121
+ const headers = route.options.headers?.({
2122
+ loaderData,
2123
+ })
2124
+
2125
+ updateMatch(matchId, (prev) => ({
2126
+ ...prev,
2127
+ error: undefined,
2128
+ status: 'success',
2129
+ isFetching: false,
2130
+ updatedAt: Date.now(),
2131
+ loaderData,
2132
+ meta,
2133
+ headers,
2134
+ }))
2135
+ } catch (e) {
2136
+ let error = e
2137
+
2138
+ await potentialPendingMinPromise()
2139
+
2140
+ handleRedirectAndNotFound(this.getMatch(matchId)!, e)
2141
+
2142
+ try {
2143
+ route.options.onError?.(e)
2144
+ } catch (onErrorError) {
2145
+ error = onErrorError
2146
+ handleRedirectAndNotFound(
2147
+ this.getMatch(matchId)!,
2148
+ onErrorError,
2149
+ )
2150
+ }
2151
+
2152
+ updateMatch(matchId, (prev) => ({
2153
+ ...prev,
2154
+ error,
2155
+ status: 'error',
2156
+ isFetching: false,
2157
+ }))
2158
+ }
2159
+
2160
+ // Last but not least, wait for the the component
2161
+ // to be preloaded before we resolve the match
2162
+ await this.getMatch(matchId)!.componentsPromise
2163
+ } catch (err) {
2164
+ handleRedirectAndNotFound(this.getMatch(matchId)!, err)
2165
+ }
2166
+ }
2064
2167
 
2065
- match.loadPromise.resolve()
2066
- }
2168
+ // If the route is successful and still fresh, just resolve
2169
+ const { status, invalid } = this.getMatch(matchId)!
2170
+
2171
+ if (
2172
+ status === 'success' &&
2173
+ (invalid || (shouldReload ?? age > staleAge))
2174
+ ) {
2175
+ ;(async () => {
2176
+ try {
2177
+ await runLoader()
2178
+ } catch (err) {}
2179
+ })()
2180
+ } else if (status !== 'success') {
2181
+ await runLoader()
2182
+ }
2067
2183
 
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
- }
2184
+ const { loaderPromise, loadPromise } =
2185
+ this.getMatch(matchId)!
2095
2186
 
2096
- const fetchWithRedirectAndNotFound = async () => {
2097
- try {
2098
- await fetchAndResolveInLoaderLifetime()
2099
- } catch (err) {
2100
- checkLatest()
2101
- handleRedirectAndNotFound(match, err)
2187
+ loaderPromise?.resolve()
2188
+ loadPromise?.resolve()
2102
2189
  }
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
2190
 
2118
- if (match.status !== 'success') {
2119
- await fetchWithRedirectAndNotFound()
2120
- }
2121
-
2122
- return
2123
- }
2124
-
2125
- matchPromises.push(createValidateResolvedMatchPromise())
2191
+ updateMatch(matchId, (prev) => ({
2192
+ ...prev,
2193
+ isFetching: false,
2194
+ loaderPromise: undefined,
2195
+ }))
2196
+ })(),
2197
+ )
2126
2198
  })
2127
2199
 
2128
2200
  await Promise.all(matchPromises)
2129
2201
 
2130
- checkLatest()
2131
-
2132
2202
  resolveAll()
2133
2203
  } catch (err) {
2134
2204
  rejectAll(err)
@@ -2152,7 +2222,9 @@ export class Router<
2152
2222
  const invalidate = (d: MakeRouteMatch<TRouteTree>) => ({
2153
2223
  ...d,
2154
2224
  invalid: true,
2155
- ...(d.status === 'error' ? ({ status: 'pending' } as const) : {}),
2225
+ ...(d.status === 'error'
2226
+ ? ({ status: 'pending', error: undefined } as const)
2227
+ : {}),
2156
2228
  })
2157
2229
 
2158
2230
  this.__store.setState((s) => ({
@@ -2242,29 +2314,24 @@ export class Router<
2242
2314
  })
2243
2315
  })
2244
2316
 
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
- }
2317
+ const activeMatchIds = new Set(
2318
+ [...this.state.matches, ...(this.state.pendingMatches ?? [])].map(
2319
+ (d) => d.id,
2320
+ ),
2321
+ )
2261
2322
 
2262
2323
  try {
2263
2324
  matches = await this.loadMatches({
2264
2325
  matches,
2265
2326
  location: next,
2266
2327
  preload: true,
2267
- checkLatest: () => undefined,
2328
+ updateMatch: (id, updater) => {
2329
+ if (activeMatchIds.has(id)) {
2330
+ matches = matches.map((d) => (d.id === id ? updater(d) : d))
2331
+ } else {
2332
+ this.updateMatch(id, updater)
2333
+ }
2334
+ },
2268
2335
  })
2269
2336
 
2270
2337
  return matches
@@ -2363,7 +2430,7 @@ export class Router<
2363
2430
  }
2364
2431
  }
2365
2432
 
2366
- hydrate = async () => {
2433
+ hydrate = () => {
2367
2434
  // Client hydrates from window
2368
2435
  let ctx: HydrationCtx | undefined
2369
2436
 
@@ -2457,7 +2524,18 @@ export class Router<
2457
2524
  )
2458
2525
  }
2459
2526
 
2460
- handleNotFound = (matches: Array<AnyRouteMatch>, err: NotFoundError) => {
2527
+ _handleNotFound = (
2528
+ matches: Array<AnyRouteMatch>,
2529
+ err: NotFoundError,
2530
+ {
2531
+ updateMatch = this.updateMatch,
2532
+ }: {
2533
+ updateMatch?: (
2534
+ id: string,
2535
+ updater: (match: AnyRouteMatch) => AnyRouteMatch,
2536
+ ) => void
2537
+ } = {},
2538
+ ) => {
2461
2539
  const matchesByRouteId = Object.fromEntries(
2462
2540
  matches.map((match) => [match.routeId, match]),
2463
2541
  ) as Record<string, AnyRouteMatch>
@@ -2488,11 +2566,20 @@ export class Router<
2488
2566
  invariant(match, 'Could not find match for route: ' + routeCursor.id)
2489
2567
 
2490
2568
  // Assign the error to the match
2491
- Object.assign(match, {
2569
+
2570
+ updateMatch(match.id, (prev) => ({
2571
+ ...prev,
2492
2572
  status: 'notFound',
2493
2573
  error: err,
2494
2574
  isFetching: false,
2495
- } as AnyRouteMatch)
2575
+ }))
2576
+
2577
+ if ((err as any).routerCode === 'BEFORE_LOAD' && routeCursor.parentRoute) {
2578
+ err.routeId = routeCursor.parentRoute.id
2579
+ this._handleNotFound(matches, err, {
2580
+ updateMatch,
2581
+ })
2582
+ }
2496
2583
  }
2497
2584
 
2498
2585
  hasNotFoundMatch = () => {
@@ -2500,12 +2587,6 @@ export class Router<
2500
2587
  (d) => d.status === 'notFound' || d.globalNotFound,
2501
2588
  )
2502
2589
  }
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
2590
  }
2510
2591
 
2511
2592
  // A function that takes an import() argument which is a function and returns a new function that will
@@ -2531,6 +2612,7 @@ export function getInitialRouterState(
2531
2612
  location: ParsedLocation,
2532
2613
  ): RouterState<any> {
2533
2614
  return {
2615
+ loadedAt: 0,
2534
2616
  isLoading: false,
2535
2617
  isTransitioning: false,
2536
2618
  status: 'idle',