@tanstack/react-router 1.31.1 → 1.31.2

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.
package/src/router.ts CHANGED
@@ -25,7 +25,7 @@ import {
25
25
  trimPathLeft,
26
26
  trimPathRight,
27
27
  } from './path'
28
- import { isRedirect } from './redirects'
28
+ import { isRedirect, isResolvedRedirect } from './redirects'
29
29
  import { isNotFound } from './not-found'
30
30
  import type * as React from 'react'
31
31
  import type {
@@ -583,10 +583,10 @@ export class Router<
583
583
  })
584
584
  }
585
585
 
586
- checkLatest = (promise: Promise<void>): undefined | Promise<void> => {
587
- return this.latestLoadPromise !== promise
588
- ? this.latestLoadPromise
589
- : undefined
586
+ checkLatest = (promise: Promise<void>): void => {
587
+ if (this.latestLoadPromise !== promise) {
588
+ throw this.latestLoadPromise
589
+ }
590
590
  }
591
591
 
592
592
  parseLocation = (
@@ -1206,19 +1206,180 @@ export class Router<
1206
1206
  })
1207
1207
  }
1208
1208
 
1209
+ load = async (): Promise<void> => {
1210
+ const promise = createControlledPromise<void>()
1211
+ this.latestLoadPromise = promise
1212
+ let redirect: ResolvedRedirect | undefined
1213
+ let notFound: NotFoundError | undefined
1214
+
1215
+ this.startReactTransition(async () => {
1216
+ try {
1217
+ const next = this.latestLocation
1218
+ const prevLocation = this.state.resolvedLocation
1219
+ const pathDidChange = prevLocation.href !== next.href
1220
+
1221
+ // Cancel any pending matches
1222
+ this.cancelMatches()
1223
+
1224
+ this.emit({
1225
+ type: 'onBeforeLoad',
1226
+ fromLocation: prevLocation,
1227
+ toLocation: next,
1228
+ pathChanged: pathDidChange,
1229
+ })
1230
+
1231
+ let pendingMatches!: Array<AnyRouteMatch>
1232
+
1233
+ this.__store.batch(() => {
1234
+ this.cleanCache()
1235
+
1236
+ // Match the routes
1237
+ pendingMatches = this.matchRoutes(next.pathname, next.search)
1238
+
1239
+ // Ingest the new matches
1240
+ this.__store.setState((s) => ({
1241
+ ...s,
1242
+ status: 'pending',
1243
+ isLoading: true,
1244
+ location: next,
1245
+ pendingMatches,
1246
+ // If a cached moved to pendingMatches, remove it from cachedMatches
1247
+ cachedMatches: s.cachedMatches.filter((d) => {
1248
+ return !pendingMatches.find((e) => e.id === d.id)
1249
+ }),
1250
+ }))
1251
+ })
1252
+
1253
+ await this.loadMatches({
1254
+ matches: pendingMatches,
1255
+ location: next,
1256
+ checkLatest: () => this.checkLatest(promise),
1257
+ onReady: async () => {
1258
+ await this.startViewTransition(async () => {
1259
+ // this.viewTransitionPromise = createControlledPromise<true>()
1260
+
1261
+ // Commit the pending matches. If a previous match was
1262
+ // removed, place it in the cachedMatches
1263
+ let exitingMatches!: Array<AnyRouteMatch>
1264
+ let enteringMatches!: Array<AnyRouteMatch>
1265
+ let stayingMatches!: Array<AnyRouteMatch>
1266
+
1267
+ this.__store.batch(() => {
1268
+ this.__store.setState((s) => {
1269
+ const previousMatches = s.matches
1270
+ const newMatches = s.pendingMatches || s.matches
1271
+
1272
+ exitingMatches = previousMatches.filter(
1273
+ (match) => !newMatches.find((d) => d.id === match.id),
1274
+ )
1275
+ enteringMatches = newMatches.filter(
1276
+ (match) => !previousMatches.find((d) => d.id === match.id),
1277
+ )
1278
+ stayingMatches = previousMatches.filter((match) =>
1279
+ newMatches.find((d) => d.id === match.id),
1280
+ )
1281
+
1282
+ return {
1283
+ ...s,
1284
+ isLoading: false,
1285
+ matches: newMatches,
1286
+ pendingMatches: undefined,
1287
+ cachedMatches: [
1288
+ ...s.cachedMatches,
1289
+ ...exitingMatches.filter((d) => d.status !== 'error'),
1290
+ ],
1291
+ }
1292
+ })
1293
+ this.cleanCache()
1294
+ })
1295
+
1296
+ //
1297
+ ;(
1298
+ [
1299
+ [exitingMatches, 'onLeave'],
1300
+ [enteringMatches, 'onEnter'],
1301
+ [stayingMatches, 'onStay'],
1302
+ ] as const
1303
+ ).forEach(([matches, hook]) => {
1304
+ matches.forEach((match) => {
1305
+ this.looseRoutesById[match.routeId]!.options[hook]?.(match)
1306
+ })
1307
+ })
1308
+ })
1309
+ },
1310
+ })
1311
+ } catch (err) {
1312
+ if (isResolvedRedirect(err)) {
1313
+ redirect = err
1314
+ if (!this.isServer) {
1315
+ this.navigate({ ...err, replace: true })
1316
+ this.load()
1317
+ }
1318
+ } else if (isNotFound(err)) {
1319
+ notFound = err
1320
+ }
1321
+
1322
+ this.__store.setState((s) => ({
1323
+ ...s,
1324
+ statusCode:
1325
+ redirect?.statusCode || notFound
1326
+ ? 404
1327
+ : s.matches.some((d) => d.status === 'error')
1328
+ ? 500
1329
+ : 200,
1330
+ redirect,
1331
+ }))
1332
+ }
1333
+
1334
+ promise.resolve()
1335
+ })
1336
+
1337
+ return this.latestLoadPromise
1338
+ }
1339
+
1340
+ startViewTransition = async (fn: () => Promise<void>) => {
1341
+ // Determine if we should start a view transition from the navigation
1342
+ // or from the router default
1343
+ const shouldViewTransition =
1344
+ this.shouldViewTransition ?? this.options.defaultViewTransition
1345
+
1346
+ // Reset the view transition flag
1347
+ delete this.shouldViewTransition
1348
+ // Attempt to start a view transition (or just apply the changes if we can't)
1349
+ ;(shouldViewTransition && typeof document !== 'undefined'
1350
+ ? document
1351
+ : undefined
1352
+ )
1353
+ // @ts-expect-error
1354
+ ?.startViewTransition?.(fn) || fn()
1355
+ }
1356
+
1209
1357
  loadMatches = async ({
1210
1358
  checkLatest,
1211
1359
  location,
1212
1360
  matches,
1213
1361
  preload,
1362
+ onReady,
1214
1363
  }: {
1215
- checkLatest: () => Promise<void> | undefined
1364
+ checkLatest: () => void
1216
1365
  location: ParsedLocation
1217
1366
  matches: Array<AnyRouteMatch>
1218
1367
  preload?: boolean
1368
+ onReady?: () => Promise<void>
1219
1369
  }): Promise<Array<MakeRouteMatch>> => {
1220
- let latestPromise
1221
1370
  let firstBadMatchIndex: number | undefined
1371
+ let rendered = false
1372
+
1373
+ const triggerOnReady = async () => {
1374
+ if (!rendered) {
1375
+ rendered = true
1376
+ await onReady?.()
1377
+ }
1378
+ }
1379
+
1380
+ if (!this.isServer && !this.state.matches.length) {
1381
+ triggerOnReady()
1382
+ }
1222
1383
 
1223
1384
  const updateMatch = (
1224
1385
  id: string,
@@ -1247,40 +1408,43 @@ export class Router<
1247
1408
  return updated
1248
1409
  }
1249
1410
 
1411
+ const handleRedirectAndNotFound = (match: AnyRouteMatch, err: any) => {
1412
+ if (isResolvedRedirect(err)) throw err
1413
+
1414
+ if (isRedirect(err) || isNotFound(err)) {
1415
+ // if (!rendered) {
1416
+ updateMatch(match.id, (prev) => ({
1417
+ ...prev,
1418
+ status: isRedirect(err)
1419
+ ? 'redirected'
1420
+ : isNotFound(err)
1421
+ ? 'notFound'
1422
+ : 'error',
1423
+ isFetching: false,
1424
+ error: err,
1425
+ }))
1426
+ // }
1427
+
1428
+ rendered = true
1429
+
1430
+ if (!(err as any).routeId) {
1431
+ ;(err as any).routeId = match.routeId
1432
+ }
1433
+
1434
+ if (isRedirect(err)) {
1435
+ err = this.resolveRedirect(err)
1436
+ throw err
1437
+ } else if (isNotFound(err)) {
1438
+ this.handleNotFound(matches, err)
1439
+ throw err
1440
+ }
1441
+ }
1442
+ }
1443
+
1250
1444
  try {
1251
1445
  await new Promise<void>((resolveAll, rejectAll) => {
1252
1446
  ;(async () => {
1253
1447
  try {
1254
- const handleRedirectAndNotFound = (
1255
- match: AnyRouteMatch,
1256
- err: any,
1257
- ) => {
1258
- if (isRedirect(err) || isNotFound(err)) {
1259
- updateMatch(match.id, (prev) => ({
1260
- ...prev,
1261
- status: isRedirect(err)
1262
- ? 'redirected'
1263
- : isNotFound(err)
1264
- ? 'notFound'
1265
- : 'error',
1266
- isFetching: false,
1267
- error: err,
1268
- }))
1269
-
1270
- if (!(err as any).routeId) {
1271
- ;(err as any).routeId = match.routeId
1272
- }
1273
-
1274
- if (isRedirect(err)) {
1275
- err = this.resolveRedirect(err)
1276
- throw err
1277
- } else if (isNotFound(err)) {
1278
- this.handleNotFound(matches, err)
1279
- throw err
1280
- }
1281
- }
1282
- }
1283
-
1284
1448
  // Check each match middleware to see if the route can be accessed
1285
1449
  // eslint-disable-next-line prefer-const
1286
1450
  for (let [index, match] of matches.entries()) {
@@ -1289,6 +1453,33 @@ export class Router<
1289
1453
  const abortController = new AbortController()
1290
1454
  let loadPromise = match.loadPromise
1291
1455
 
1456
+ const pendingMs =
1457
+ route.options.pendingMs ?? this.options.defaultPendingMs
1458
+
1459
+ const shouldPending = !!(
1460
+ onReady &&
1461
+ !this.isServer &&
1462
+ !preload &&
1463
+ (route.options.loader || route.options.beforeLoad) &&
1464
+ typeof pendingMs === 'number' &&
1465
+ pendingMs !== Infinity &&
1466
+ (route.options.pendingComponent ??
1467
+ this.options.defaultPendingComponent)
1468
+ )
1469
+
1470
+ if (shouldPending) {
1471
+ // If we might show a pending component, we need to wait for the
1472
+ // pending promise to resolve before we start showing that state
1473
+ setTimeout(() => {
1474
+ try {
1475
+ checkLatest()
1476
+ // Update the match and prematurely resolve the loadMatches promise so that
1477
+ // the pending component can start rendering
1478
+ triggerOnReady()
1479
+ } catch {}
1480
+ }, pendingMs)
1481
+ }
1482
+
1292
1483
  if (match.isFetching) {
1293
1484
  continue
1294
1485
  }
@@ -1336,42 +1527,10 @@ export class Router<
1336
1527
  handleSerialError(match.searchError, 'VALIDATE_SEARCH')
1337
1528
  }
1338
1529
 
1339
- // if (match.globalNotFound && !preload) {
1340
- // handleSerialError(notFound({ _global: true }), 'NOT_FOUND')
1341
- // }
1342
-
1343
1530
  try {
1344
1531
  const parentContext =
1345
1532
  parentMatch?.context ?? this.options.context ?? {}
1346
1533
 
1347
- const pendingMs =
1348
- route.options.pendingMs ?? this.options.defaultPendingMs
1349
- const pendingPromise =
1350
- typeof pendingMs !== 'number' || pendingMs <= 0
1351
- ? Promise.resolve()
1352
- : new Promise<void>((r) => {
1353
- if (pendingMs !== Infinity) setTimeout(r, pendingMs)
1354
- })
1355
-
1356
- const shouldPending =
1357
- !this.isServer &&
1358
- !preload &&
1359
- (route.options.loader || route.options.beforeLoad) &&
1360
- typeof pendingMs === 'number' &&
1361
- (route.options.pendingComponent ??
1362
- this.options.defaultPendingComponent)
1363
-
1364
- if (shouldPending) {
1365
- // If we might show a pending component, we need to wait for the
1366
- // pending promise to resolve before we start showing that state
1367
- pendingPromise.then(async () => {
1368
- if ((latestPromise = checkLatest())) return latestPromise
1369
- // Update the match and prematurely resolve the loadMatches promise so that
1370
- // the pending component can start rendering
1371
- resolveAll()
1372
- })
1373
- }
1374
-
1375
1534
  const beforeLoadContext =
1376
1535
  (await route.options.beforeLoad?.({
1377
1536
  search: match.search,
@@ -1386,7 +1545,7 @@ export class Router<
1386
1545
  cause: preload ? 'preload' : match.cause,
1387
1546
  })) ?? ({} as any)
1388
1547
 
1389
- if ((latestPromise = checkLatest())) return latestPromise
1548
+ checkLatest()
1390
1549
 
1391
1550
  if (
1392
1551
  isRedirect(beforeLoadContext) ||
@@ -1417,7 +1576,7 @@ export class Router<
1417
1576
  }
1418
1577
  }
1419
1578
 
1420
- if ((latestPromise = checkLatest())) return latestPromise
1579
+ checkLatest()
1421
1580
 
1422
1581
  const validResolvedMatches = matches.slice(0, firstBadMatchIndex)
1423
1582
  const matchPromises: Array<Promise<any>> = []
@@ -1458,8 +1617,7 @@ export class Router<
1458
1617
  if (latestMatch?.minPendingPromise) {
1459
1618
  await latestMatch.minPendingPromise
1460
1619
 
1461
- if ((latestPromise = checkLatest()))
1462
- return await latestPromise
1620
+ checkLatest()
1463
1621
 
1464
1622
  updateMatch(latestMatch.id, (prev) => ({
1465
1623
  ...prev,
@@ -1512,8 +1670,7 @@ export class Router<
1512
1670
  // we can use the options
1513
1671
  await lazyPromise
1514
1672
 
1515
- if ((latestPromise = checkLatest()))
1516
- return await latestPromise
1673
+ checkLatest()
1517
1674
 
1518
1675
  // Kick off the loader!
1519
1676
  loaderPromise = route.options.loader?.(loaderContext)
@@ -1528,17 +1685,12 @@ export class Router<
1528
1685
  }
1529
1686
 
1530
1687
  const loaderData = await loaderPromise
1531
- if ((latestPromise = checkLatest()))
1532
- return await latestPromise
1688
+ checkLatest()
1533
1689
 
1534
1690
  handleRedirectAndNotFound(match, loaderData)
1535
1691
 
1536
- if ((latestPromise = checkLatest()))
1537
- return await latestPromise
1538
-
1539
1692
  await potentialPendingMinPromise()
1540
- if ((latestPromise = checkLatest()))
1541
- return await latestPromise
1693
+ checkLatest()
1542
1694
 
1543
1695
  const meta = route.options.meta?.({
1544
1696
  params: match.params,
@@ -1560,13 +1712,11 @@ export class Router<
1560
1712
  headers,
1561
1713
  }))
1562
1714
  } catch (e) {
1715
+ checkLatest()
1563
1716
  let error = e
1564
- if ((latestPromise = checkLatest()))
1565
- return await latestPromise
1566
1717
 
1567
1718
  await potentialPendingMinPromise()
1568
- if ((latestPromise = checkLatest()))
1569
- return await latestPromise
1719
+ checkLatest()
1570
1720
 
1571
1721
  handleRedirectAndNotFound(match, e)
1572
1722
 
@@ -1589,8 +1739,7 @@ export class Router<
1589
1739
  // to be preloaded before we resolve the match
1590
1740
  await componentsPromise
1591
1741
 
1592
- if ((latestPromise = checkLatest()))
1593
- return await latestPromise
1742
+ checkLatest()
1594
1743
 
1595
1744
  match.loadPromise.resolve()
1596
1745
  }
@@ -1627,8 +1776,7 @@ export class Router<
1627
1776
  try {
1628
1777
  await fetch()
1629
1778
  } catch (err) {
1630
- if ((latestPromise = checkLatest()))
1631
- return await latestPromise
1779
+ checkLatest()
1632
1780
  handleRedirectAndNotFound(match, err)
1633
1781
  }
1634
1782
  }
@@ -1648,7 +1796,7 @@ export class Router<
1648
1796
  }),
1649
1797
  )
1650
1798
 
1651
- if ((latestPromise = checkLatest())) return await latestPromise
1799
+ checkLatest()
1652
1800
 
1653
1801
  resolveAll()
1654
1802
  } catch (err) {
@@ -1656,6 +1804,7 @@ export class Router<
1656
1804
  }
1657
1805
  })()
1658
1806
  })
1807
+ await triggerOnReady()
1659
1808
  } catch (err) {
1660
1809
  if (isRedirect(err) || isNotFound(err)) {
1661
1810
  throw err
@@ -1682,184 +1831,6 @@ export class Router<
1682
1831
  return this.load()
1683
1832
  }
1684
1833
 
1685
- load = async (): Promise<void> => {
1686
- let resolveLoad!: (value: void) => void
1687
- let rejectLoad!: (reason: any) => void
1688
-
1689
- const promise = new Promise<void>((resolve, reject) => {
1690
- resolveLoad = resolve
1691
- rejectLoad = reject
1692
- })
1693
-
1694
- this.latestLoadPromise = promise
1695
-
1696
- let latestPromise: Promise<void> | undefined | null
1697
-
1698
- this.startReactTransition(async () => {
1699
- try {
1700
- const next = this.latestLocation
1701
- const prevLocation = this.state.resolvedLocation
1702
- const pathDidChange = prevLocation.href !== next.href
1703
-
1704
- // Cancel any pending matches
1705
- this.cancelMatches()
1706
-
1707
- this.emit({
1708
- type: 'onBeforeLoad',
1709
- fromLocation: prevLocation,
1710
- toLocation: next,
1711
- pathChanged: pathDidChange,
1712
- })
1713
-
1714
- let pendingMatches!: Array<AnyRouteMatch>
1715
- const previousMatches = this.state.matches
1716
-
1717
- this.__store.batch(() => {
1718
- this.cleanCache()
1719
-
1720
- // Match the routes
1721
- pendingMatches = this.matchRoutes(next.pathname, next.search)
1722
-
1723
- // Ingest the new matches
1724
- // If a cached moved to pendingMatches, remove it from cachedMatches
1725
- this.__store.setState((s) => ({
1726
- ...s,
1727
- status: 'pending',
1728
- isLoading: true,
1729
- location: next,
1730
- pendingMatches,
1731
- cachedMatches: s.cachedMatches.filter((d) => {
1732
- return !pendingMatches.find((e) => e.id === d.id)
1733
- }),
1734
- }))
1735
- })
1736
-
1737
- let redirect: ResolvedRedirect | undefined
1738
- let notFound: NotFoundError | undefined
1739
-
1740
- const loadMatches = () =>
1741
- this.loadMatches({
1742
- matches: pendingMatches,
1743
- location: next,
1744
- checkLatest: () => this.checkLatest(promise),
1745
- })
1746
-
1747
- // If we are on the server or non-first load on the client, await
1748
- // the loadMatches before transitioning
1749
- if (previousMatches.length || this.isServer) {
1750
- try {
1751
- await loadMatches()
1752
- } catch (err) {
1753
- if (isRedirect(err)) {
1754
- redirect = err as ResolvedRedirect
1755
- } else if (isNotFound(err)) {
1756
- notFound = err
1757
- }
1758
- }
1759
- } else {
1760
- // For client-only first loads, we need to start the transition
1761
- // immediately and load the matches in the background
1762
- loadMatches().catch((err) => {
1763
- // This also means that we need to handle any redirects
1764
- // that might happen during the load/transition
1765
- if (isRedirect(err)) {
1766
- this.navigate({ ...err, replace: true })
1767
- }
1768
- // Because our history listener isn't guaranteed to be mounted
1769
- // on the first load, we need to manually call load again
1770
- this.load()
1771
- })
1772
- }
1773
-
1774
- // Only apply the latest transition
1775
- if ((latestPromise = this.checkLatest(promise))) {
1776
- return latestPromise
1777
- }
1778
-
1779
- const exitingMatches = previousMatches.filter(
1780
- (match) => !pendingMatches.find((d) => d.id === match.id),
1781
- )
1782
- const enteringMatches = pendingMatches.filter(
1783
- (match) => !previousMatches.find((d) => d.id === match.id),
1784
- )
1785
- const stayingMatches = previousMatches.filter((match) =>
1786
- pendingMatches.find((d) => d.id === match.id),
1787
- )
1788
-
1789
- // Determine if we should start a view transition from the navigation
1790
- // or from the router default
1791
- const shouldViewTransition =
1792
- this.shouldViewTransition ?? this.options.defaultViewTransition
1793
-
1794
- // Reset the view transition flag
1795
- delete this.shouldViewTransition
1796
-
1797
- const apply = () => {
1798
- // this.viewTransitionPromise = createControlledPromise<true>()
1799
-
1800
- // Commit the pending matches. If a previous match was
1801
- // removed, place it in the cachedMatches
1802
- this.__store.batch(() => {
1803
- this.__store.setState((s) => ({
1804
- ...s,
1805
- isLoading: false,
1806
- matches: s.pendingMatches!,
1807
- pendingMatches: undefined,
1808
- cachedMatches: [
1809
- ...s.cachedMatches,
1810
- ...exitingMatches.filter((d) => d.status !== 'error'),
1811
- ],
1812
- statusCode:
1813
- redirect?.statusCode || notFound
1814
- ? 404
1815
- : s.matches.some((d) => d.status === 'error')
1816
- ? 500
1817
- : 200,
1818
- redirect,
1819
- }))
1820
- this.cleanCache()
1821
- })
1822
-
1823
- //
1824
- ;(
1825
- [
1826
- [exitingMatches, 'onLeave'],
1827
- [enteringMatches, 'onEnter'],
1828
- [stayingMatches, 'onStay'],
1829
- ] as const
1830
- ).forEach(([matches, hook]) => {
1831
- matches.forEach((match) => {
1832
- this.looseRoutesById[match.routeId]!.options[hook]?.(match)
1833
- })
1834
- })
1835
-
1836
- resolveLoad()
1837
-
1838
- // return this.viewTransitionPromise
1839
- }
1840
-
1841
- // Attempt to start a view transition (or just apply the changes if we can't)
1842
- ;(shouldViewTransition && typeof document !== 'undefined'
1843
- ? document
1844
- : undefined
1845
- )
1846
- // @ts-expect-error
1847
- ?.startViewTransition?.(apply) || apply()
1848
- } catch (err) {
1849
- // Only apply the latest transition
1850
- if ((latestPromise = this.checkLatest(promise))) {
1851
- return latestPromise
1852
- }
1853
-
1854
- console.error('Load Error', err)
1855
-
1856
- rejectLoad(err)
1857
- }
1858
- })
1859
-
1860
- return this.latestLoadPromise
1861
- }
1862
-
1863
1834
  resolveRedirect = (err: AnyRedirect): ResolvedRedirect => {
1864
1835
  const redirect = err as ResolvedRedirect
1865
1836