@remix-run/router 1.16.1 → 1.17.0

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/router.ts CHANGED
@@ -35,6 +35,7 @@ import type {
35
35
  UIMatch,
36
36
  V7_FormMethod,
37
37
  V7_MutationFormMethod,
38
+ AgnosticPatchRoutesOnMissFunction,
38
39
  } from "./utils";
39
40
  import {
40
41
  ErrorResponseImpl,
@@ -47,6 +48,7 @@ import {
47
48
  isRouteErrorResponse,
48
49
  joinPaths,
49
50
  matchRoutes,
51
+ matchRoutesImpl,
50
52
  resolveTo,
51
53
  stripBasename,
52
54
  } from "./utils";
@@ -242,6 +244,16 @@ export interface Router {
242
244
  */
243
245
  deleteBlocker(key: string): void;
244
246
 
247
+ /**
248
+ * @internal
249
+ * PRIVATE DO NOT USE
250
+ *
251
+ * Patch additional children routes into an existing parent route
252
+ * @param routeId The parent route id
253
+ * @param children The additional children routes
254
+ */
255
+ patchRoutes(routeId: string | null, children: AgnosticRouteObject[]): void;
256
+
245
257
  /**
246
258
  * @internal
247
259
  * PRIVATE - DO NOT USE
@@ -377,6 +389,7 @@ export interface RouterInit {
377
389
  future?: Partial<FutureConfig>;
378
390
  hydrationData?: HydrationState;
379
391
  window?: Window;
392
+ unstable_patchRoutesOnMiss?: AgnosticPatchRoutesOnMissFunction;
380
393
  unstable_dataStrategy?: DataStrategyFunction;
381
394
  }
382
395
 
@@ -631,6 +644,10 @@ interface ShortCircuitable {
631
644
  type PendingActionResult = [string, SuccessResult | ErrorResult];
632
645
 
633
646
  interface HandleActionResult extends ShortCircuitable {
647
+ /**
648
+ * Route matches which may have been updated from fog of war discovery
649
+ */
650
+ matches?: RouterState["matches"];
634
651
  /**
635
652
  * Tuple for the returned or thrown value from the current action. The routeId
636
653
  * is the action route for success and the bubbled boundary route for errors.
@@ -639,6 +656,10 @@ interface HandleActionResult extends ShortCircuitable {
639
656
  }
640
657
 
641
658
  interface HandleLoadersResult extends ShortCircuitable {
659
+ /**
660
+ * Route matches which may have been updated from fog of war discovery
661
+ */
662
+ matches?: RouterState["matches"];
642
663
  /**
643
664
  * loaderData returned from the current set of loaders
644
665
  */
@@ -775,6 +796,8 @@ export function createRouter(init: RouterInit): Router {
775
796
  let inFlightDataRoutes: AgnosticDataRouteObject[] | undefined;
776
797
  let basename = init.basename || "/";
777
798
  let dataStrategyImpl = init.unstable_dataStrategy || defaultDataStrategy;
799
+ let patchRoutesOnMissImpl = init.unstable_patchRoutesOnMiss;
800
+
778
801
  // Config driven behavior flags
779
802
  let future: FutureConfig = {
780
803
  v7_fetcherPersist: false,
@@ -806,7 +829,7 @@ export function createRouter(init: RouterInit): Router {
806
829
  let initialMatches = matchRoutes(dataRoutes, init.history.location, basename);
807
830
  let initialErrors: RouteData | null = null;
808
831
 
809
- if (initialMatches == null) {
832
+ if (initialMatches == null && !patchRoutesOnMissImpl) {
810
833
  // If we do not match a user-provided-route, fall back to the root
811
834
  // to allow the error boundary to take over
812
835
  let error = getInternalRouterError(404, {
@@ -818,13 +841,15 @@ export function createRouter(init: RouterInit): Router {
818
841
  }
819
842
 
820
843
  let initialized: boolean;
821
- let hasLazyRoutes = initialMatches.some((m) => m.route.lazy);
822
- let hasLoaders = initialMatches.some((m) => m.route.loader);
823
- if (hasLazyRoutes) {
844
+ if (!initialMatches) {
845
+ // We need to run patchRoutesOnMiss in initialize()
846
+ initialized = false;
847
+ initialMatches = [];
848
+ } else if (initialMatches.some((m) => m.route.lazy)) {
824
849
  // All initialMatches need to be loaded before we're ready. If we have lazy
825
850
  // functions around still then we'll need to run them in initialize()
826
851
  initialized = false;
827
- } else if (!hasLoaders) {
852
+ } else if (!initialMatches.some((m) => m.route.loader)) {
828
853
  // If we've got no loaders to run, then we're good to go
829
854
  initialized = true;
830
855
  } else if (future.v7_partialHydration) {
@@ -963,6 +988,13 @@ export function createRouter(init: RouterInit): Router {
963
988
  // we don't need to update UI state if they change
964
989
  let blockerFunctions = new Map<string, BlockerFunction>();
965
990
 
991
+ // Map of pending patchRoutesOnMiss() promises (keyed by path/matches) so
992
+ // that we only kick them off once for a given combo
993
+ let pendingPatchRoutes = new Map<
994
+ string,
995
+ ReturnType<AgnosticPatchRoutesOnMissFunction>
996
+ >();
997
+
966
998
  // Flag to ignore the next history update, so we can revert the URL change on
967
999
  // a POP navigation that was blocked by the user without touching router state
968
1000
  let ignoreNextHistoryUpdate = false;
@@ -1455,13 +1487,16 @@ export function createRouter(init: RouterInit): Router {
1455
1487
  let matches = matchRoutes(routesToUse, location, basename);
1456
1488
  let flushSync = (opts && opts.flushSync) === true;
1457
1489
 
1490
+ let fogOfWar = checkFogOfWar(matches, routesToUse, location.pathname);
1491
+ if (fogOfWar.active && fogOfWar.matches) {
1492
+ matches = fogOfWar.matches;
1493
+ }
1494
+
1458
1495
  // Short circuit with a 404 on the root error boundary if we match nothing
1459
1496
  if (!matches) {
1460
- let error = getInternalRouterError(404, { pathname: location.pathname });
1461
- let { matches: notFoundMatches, route } =
1462
- getShortCircuitMatches(routesToUse);
1463
- // Cancel all pending deferred on 404s since we don't keep any routes
1464
- cancelActiveDeferreds();
1497
+ let { error, notFoundMatches, route } = handleNavigational404(
1498
+ location.pathname
1499
+ );
1465
1500
  completeNavigation(
1466
1501
  location,
1467
1502
  {
@@ -1522,6 +1557,7 @@ export function createRouter(init: RouterInit): Router {
1522
1557
  location,
1523
1558
  opts.submission,
1524
1559
  matches,
1560
+ fogOfWar.active,
1525
1561
  { replace: opts.replace, flushSync }
1526
1562
  );
1527
1563
 
@@ -1529,9 +1565,34 @@ export function createRouter(init: RouterInit): Router {
1529
1565
  return;
1530
1566
  }
1531
1567
 
1568
+ // If we received a 404 from handleAction, it's because we couldn't lazily
1569
+ // discover the destination route so we don't want to call loaders
1570
+ if (actionResult.pendingActionResult) {
1571
+ let [routeId, result] = actionResult.pendingActionResult;
1572
+ if (
1573
+ isErrorResult(result) &&
1574
+ isRouteErrorResponse(result.error) &&
1575
+ result.error.status === 404
1576
+ ) {
1577
+ pendingNavigationController = null;
1578
+
1579
+ completeNavigation(location, {
1580
+ matches: actionResult.matches,
1581
+ loaderData: {},
1582
+ errors: {
1583
+ [routeId]: result.error,
1584
+ },
1585
+ });
1586
+ return;
1587
+ }
1588
+ }
1589
+
1590
+ matches = actionResult.matches || matches;
1532
1591
  pendingActionResult = actionResult.pendingActionResult;
1533
1592
  loadingNavigation = getLoadingNavigation(location, opts.submission);
1534
1593
  flushSync = false;
1594
+ // No need to do fog of war matching again on loader execution
1595
+ fogOfWar.active = false;
1535
1596
 
1536
1597
  // Create a GET request for the loaders
1537
1598
  request = createClientSideRequest(
@@ -1542,10 +1603,16 @@ export function createRouter(init: RouterInit): Router {
1542
1603
  }
1543
1604
 
1544
1605
  // Call loaders
1545
- let { shortCircuited, loaderData, errors } = await handleLoaders(
1606
+ let {
1607
+ shortCircuited,
1608
+ matches: updatedMatches,
1609
+ loaderData,
1610
+ errors,
1611
+ } = await handleLoaders(
1546
1612
  request,
1547
1613
  location,
1548
1614
  matches,
1615
+ fogOfWar.active,
1549
1616
  loadingNavigation,
1550
1617
  opts && opts.submission,
1551
1618
  opts && opts.fetcherSubmission,
@@ -1565,7 +1632,7 @@ export function createRouter(init: RouterInit): Router {
1565
1632
  pendingNavigationController = null;
1566
1633
 
1567
1634
  completeNavigation(location, {
1568
- matches,
1635
+ matches: updatedMatches || matches,
1569
1636
  ...getActionDataForCommit(pendingActionResult),
1570
1637
  loaderData,
1571
1638
  errors,
@@ -1579,6 +1646,7 @@ export function createRouter(init: RouterInit): Router {
1579
1646
  location: Location,
1580
1647
  submission: Submission,
1581
1648
  matches: AgnosticDataRouteMatch[],
1649
+ isFogOfWar: boolean,
1582
1650
  opts: { replace?: boolean; flushSync?: boolean } = {}
1583
1651
  ): Promise<HandleActionResult> {
1584
1652
  interruptActiveLoads();
@@ -1587,6 +1655,48 @@ export function createRouter(init: RouterInit): Router {
1587
1655
  let navigation = getSubmittingNavigation(location, submission);
1588
1656
  updateState({ navigation }, { flushSync: opts.flushSync === true });
1589
1657
 
1658
+ if (isFogOfWar) {
1659
+ let discoverResult = await discoverRoutes(
1660
+ matches,
1661
+ location.pathname,
1662
+ request.signal
1663
+ );
1664
+ if (discoverResult.type === "aborted") {
1665
+ return { shortCircuited: true };
1666
+ } else if (discoverResult.type === "error") {
1667
+ let { error, notFoundMatches, route } = handleDiscoverRouteError(
1668
+ location.pathname,
1669
+ discoverResult
1670
+ );
1671
+ return {
1672
+ matches: notFoundMatches,
1673
+ pendingActionResult: [
1674
+ route.id,
1675
+ {
1676
+ type: ResultType.error,
1677
+ error,
1678
+ },
1679
+ ],
1680
+ };
1681
+ } else if (!discoverResult.matches) {
1682
+ let { notFoundMatches, error, route } = handleNavigational404(
1683
+ location.pathname
1684
+ );
1685
+ return {
1686
+ matches: notFoundMatches,
1687
+ pendingActionResult: [
1688
+ route.id,
1689
+ {
1690
+ type: ResultType.error,
1691
+ error,
1692
+ },
1693
+ ],
1694
+ };
1695
+ } else {
1696
+ matches = discoverResult.matches;
1697
+ }
1698
+ }
1699
+
1590
1700
  // Call our action and get the result
1591
1701
  let result: DataResult;
1592
1702
  let actionMatch = getTargetMatch(matches, location);
@@ -1645,20 +1755,23 @@ export function createRouter(init: RouterInit): Router {
1645
1755
  // to call and will commit it when we complete the navigation
1646
1756
  let boundaryMatch = findNearestBoundary(matches, actionMatch.route.id);
1647
1757
 
1648
- // By default, all submissions are REPLACE navigations, but if the
1649
- // action threw an error that'll be rendered in an errorElement, we fall
1650
- // back to PUSH so that the user can use the back button to get back to
1651
- // the pre-submission form location to try again
1758
+ // By default, all submissions to the current location are REPLACE
1759
+ // navigations, but if the action threw an error that'll be rendered in
1760
+ // an errorElement, we fall back to PUSH so that the user can use the
1761
+ // back button to get back to the pre-submission form location to try
1762
+ // again
1652
1763
  if ((opts && opts.replace) !== true) {
1653
1764
  pendingAction = HistoryAction.Push;
1654
1765
  }
1655
1766
 
1656
1767
  return {
1768
+ matches,
1657
1769
  pendingActionResult: [boundaryMatch.route.id, result],
1658
1770
  };
1659
1771
  }
1660
1772
 
1661
1773
  return {
1774
+ matches,
1662
1775
  pendingActionResult: [actionMatch.route.id, result],
1663
1776
  };
1664
1777
  }
@@ -1669,6 +1782,7 @@ export function createRouter(init: RouterInit): Router {
1669
1782
  request: Request,
1670
1783
  location: Location,
1671
1784
  matches: AgnosticDataRouteMatch[],
1785
+ isFogOfWar: boolean,
1672
1786
  overrideNavigation?: Navigation,
1673
1787
  submission?: Submission,
1674
1788
  fetcherSubmission?: Submission,
@@ -1688,6 +1802,71 @@ export function createRouter(init: RouterInit): Router {
1688
1802
  fetcherSubmission ||
1689
1803
  getSubmissionFromNavigation(loadingNavigation);
1690
1804
 
1805
+ // If this is an uninterrupted revalidation, we remain in our current idle
1806
+ // state. If not, we need to switch to our loading state and load data,
1807
+ // preserving any new action data or existing action data (in the case of
1808
+ // a revalidation interrupting an actionReload)
1809
+ // If we have partialHydration enabled, then don't update the state for the
1810
+ // initial data load since it's not a "navigation"
1811
+ let shouldUpdateNavigationState =
1812
+ !isUninterruptedRevalidation &&
1813
+ (!future.v7_partialHydration || !initialHydration);
1814
+
1815
+ // When fog of war is enabled, we enter our `loading` state earlier so we
1816
+ // can discover new routes during the `loading` state. We skip this if
1817
+ // we've already run actions since we would have done our matching already.
1818
+ // If the children() function threw then, we want to proceed with the
1819
+ // partial matches it discovered.
1820
+ if (isFogOfWar) {
1821
+ if (shouldUpdateNavigationState) {
1822
+ let actionData = getUpdatedActionData(pendingActionResult);
1823
+ updateState(
1824
+ {
1825
+ navigation: loadingNavigation,
1826
+ ...(actionData !== undefined ? { actionData } : {}),
1827
+ },
1828
+ {
1829
+ flushSync,
1830
+ }
1831
+ );
1832
+ }
1833
+
1834
+ let discoverResult = await discoverRoutes(
1835
+ matches,
1836
+ location.pathname,
1837
+ request.signal
1838
+ );
1839
+
1840
+ if (discoverResult.type === "aborted") {
1841
+ return { shortCircuited: true };
1842
+ } else if (discoverResult.type === "error") {
1843
+ let { error, notFoundMatches, route } = handleDiscoverRouteError(
1844
+ location.pathname,
1845
+ discoverResult
1846
+ );
1847
+ return {
1848
+ matches: notFoundMatches,
1849
+ loaderData: {},
1850
+ errors: {
1851
+ [route.id]: error,
1852
+ },
1853
+ };
1854
+ } else if (!discoverResult.matches) {
1855
+ let { error, notFoundMatches, route } = handleNavigational404(
1856
+ location.pathname
1857
+ );
1858
+ return {
1859
+ matches: notFoundMatches,
1860
+ loaderData: {},
1861
+ errors: {
1862
+ [route.id]: error,
1863
+ },
1864
+ };
1865
+ } else {
1866
+ matches = discoverResult.matches;
1867
+ }
1868
+ }
1869
+
1691
1870
  let routesToUse = inFlightDataRoutes || dataRoutes;
1692
1871
  let [matchesToLoad, revalidatingFetchers] = getMatchesToLoad(
1693
1872
  init.history,
@@ -1740,53 +1919,20 @@ export function createRouter(init: RouterInit): Router {
1740
1919
  return { shortCircuited: true };
1741
1920
  }
1742
1921
 
1743
- // If this is an uninterrupted revalidation, we remain in our current idle
1744
- // state. If not, we need to switch to our loading state and load data,
1745
- // preserving any new action data or existing action data (in the case of
1746
- // a revalidation interrupting an actionReload)
1747
- // If we have partialHydration enabled, then don't update the state for the
1748
- // initial data load since it's not a "navigation"
1749
- if (
1750
- !isUninterruptedRevalidation &&
1751
- (!future.v7_partialHydration || !initialHydration)
1752
- ) {
1753
- revalidatingFetchers.forEach((rf) => {
1754
- let fetcher = state.fetchers.get(rf.key);
1755
- let revalidatingFetcher = getLoadingFetcher(
1756
- undefined,
1757
- fetcher ? fetcher.data : undefined
1758
- );
1759
- state.fetchers.set(rf.key, revalidatingFetcher);
1760
- });
1761
-
1762
- let actionData: Record<string, RouteData> | null | undefined;
1763
- if (pendingActionResult && !isErrorResult(pendingActionResult[1])) {
1764
- // This is cast to `any` currently because `RouteData`uses any and it
1765
- // would be a breaking change to use any.
1766
- // TODO: v7 - change `RouteData` to use `unknown` instead of `any`
1767
- actionData = {
1768
- [pendingActionResult[0]]: pendingActionResult[1].data as any,
1769
- };
1770
- } else if (state.actionData) {
1771
- if (Object.keys(state.actionData).length === 0) {
1772
- actionData = null;
1773
- } else {
1774
- actionData = state.actionData;
1922
+ if (shouldUpdateNavigationState) {
1923
+ let updates: Partial<RouterState> = {};
1924
+ if (!isFogOfWar) {
1925
+ // Only update navigation/actionNData if we didn't already do it above
1926
+ updates.navigation = loadingNavigation;
1927
+ let actionData = getUpdatedActionData(pendingActionResult);
1928
+ if (actionData !== undefined) {
1929
+ updates.actionData = actionData;
1775
1930
  }
1776
1931
  }
1777
-
1778
- updateState(
1779
- {
1780
- navigation: loadingNavigation,
1781
- ...(actionData !== undefined ? { actionData } : {}),
1782
- ...(revalidatingFetchers.length > 0
1783
- ? { fetchers: new Map(state.fetchers) }
1784
- : {}),
1785
- },
1786
- {
1787
- flushSync,
1788
- }
1789
- );
1932
+ if (revalidatingFetchers.length > 0) {
1933
+ updates.fetchers = getUpdatedRevalidatingFetchers(revalidatingFetchers);
1934
+ }
1935
+ updateState(updates, { flushSync });
1790
1936
  }
1791
1937
 
1792
1938
  revalidatingFetchers.forEach((rf) => {
@@ -1891,12 +2037,46 @@ export function createRouter(init: RouterInit): Router {
1891
2037
  updatedFetchers || didAbortFetchLoads || revalidatingFetchers.length > 0;
1892
2038
 
1893
2039
  return {
2040
+ matches,
1894
2041
  loaderData,
1895
2042
  errors,
1896
2043
  ...(shouldUpdateFetchers ? { fetchers: new Map(state.fetchers) } : {}),
1897
2044
  };
1898
2045
  }
1899
2046
 
2047
+ function getUpdatedActionData(
2048
+ pendingActionResult: PendingActionResult | undefined
2049
+ ): Record<string, RouteData> | null | undefined {
2050
+ if (pendingActionResult && !isErrorResult(pendingActionResult[1])) {
2051
+ // This is cast to `any` currently because `RouteData`uses any and it
2052
+ // would be a breaking change to use any.
2053
+ // TODO: v7 - change `RouteData` to use `unknown` instead of `any`
2054
+ return {
2055
+ [pendingActionResult[0]]: pendingActionResult[1].data as any,
2056
+ };
2057
+ } else if (state.actionData) {
2058
+ if (Object.keys(state.actionData).length === 0) {
2059
+ return null;
2060
+ } else {
2061
+ return state.actionData;
2062
+ }
2063
+ }
2064
+ }
2065
+
2066
+ function getUpdatedRevalidatingFetchers(
2067
+ revalidatingFetchers: RevalidatingFetcher[]
2068
+ ) {
2069
+ revalidatingFetchers.forEach((rf) => {
2070
+ let fetcher = state.fetchers.get(rf.key);
2071
+ let revalidatingFetcher = getLoadingFetcher(
2072
+ undefined,
2073
+ fetcher ? fetcher.data : undefined
2074
+ );
2075
+ state.fetchers.set(rf.key, revalidatingFetcher);
2076
+ });
2077
+ return new Map(state.fetchers);
2078
+ }
2079
+
1900
2080
  // Trigger a fetcher load/submit for the given fetcher key
1901
2081
  function fetch(
1902
2082
  key: string,
@@ -1928,6 +2108,11 @@ export function createRouter(init: RouterInit): Router {
1928
2108
  );
1929
2109
  let matches = matchRoutes(routesToUse, normalizedPath, basename);
1930
2110
 
2111
+ let fogOfWar = checkFogOfWar(matches, routesToUse, normalizedPath);
2112
+ if (fogOfWar.active && fogOfWar.matches) {
2113
+ matches = fogOfWar.matches;
2114
+ }
2115
+
1931
2116
  if (!matches) {
1932
2117
  setFetcherError(
1933
2118
  key,
@@ -1961,6 +2146,7 @@ export function createRouter(init: RouterInit): Router {
1961
2146
  path,
1962
2147
  match,
1963
2148
  matches,
2149
+ fogOfWar.active,
1964
2150
  flushSync,
1965
2151
  submission
1966
2152
  );
@@ -1976,6 +2162,7 @@ export function createRouter(init: RouterInit): Router {
1976
2162
  path,
1977
2163
  match,
1978
2164
  matches,
2165
+ fogOfWar.active,
1979
2166
  flushSync,
1980
2167
  submission
1981
2168
  );
@@ -1989,19 +2176,27 @@ export function createRouter(init: RouterInit): Router {
1989
2176
  path: string,
1990
2177
  match: AgnosticDataRouteMatch,
1991
2178
  requestMatches: AgnosticDataRouteMatch[],
2179
+ isFogOfWar: boolean,
1992
2180
  flushSync: boolean,
1993
2181
  submission: Submission
1994
2182
  ) {
1995
2183
  interruptActiveLoads();
1996
2184
  fetchLoadMatches.delete(key);
1997
2185
 
1998
- if (!match.route.action && !match.route.lazy) {
1999
- let error = getInternalRouterError(405, {
2000
- method: submission.formMethod,
2001
- pathname: path,
2002
- routeId: routeId,
2003
- });
2004
- setFetcherError(key, routeId, error, { flushSync });
2186
+ function detectAndHandle405Error(m: AgnosticDataRouteMatch) {
2187
+ if (!m.route.action && !m.route.lazy) {
2188
+ let error = getInternalRouterError(405, {
2189
+ method: submission.formMethod,
2190
+ pathname: path,
2191
+ routeId: routeId,
2192
+ });
2193
+ setFetcherError(key, routeId, error, { flushSync });
2194
+ return true;
2195
+ }
2196
+ return false;
2197
+ }
2198
+
2199
+ if (!isFogOfWar && detectAndHandle405Error(match)) {
2005
2200
  return;
2006
2201
  }
2007
2202
 
@@ -2011,7 +2206,6 @@ export function createRouter(init: RouterInit): Router {
2011
2206
  flushSync,
2012
2207
  });
2013
2208
 
2014
- // Call the action for the fetcher
2015
2209
  let abortController = new AbortController();
2016
2210
  let fetchRequest = createClientSideRequest(
2017
2211
  init.history,
@@ -2019,6 +2213,39 @@ export function createRouter(init: RouterInit): Router {
2019
2213
  abortController.signal,
2020
2214
  submission
2021
2215
  );
2216
+
2217
+ if (isFogOfWar) {
2218
+ let discoverResult = await discoverRoutes(
2219
+ requestMatches,
2220
+ path,
2221
+ fetchRequest.signal
2222
+ );
2223
+
2224
+ if (discoverResult.type === "aborted") {
2225
+ return;
2226
+ } else if (discoverResult.type === "error") {
2227
+ let { error } = handleDiscoverRouteError(path, discoverResult);
2228
+ setFetcherError(key, routeId, error, { flushSync });
2229
+ return;
2230
+ } else if (!discoverResult.matches) {
2231
+ setFetcherError(
2232
+ key,
2233
+ routeId,
2234
+ getInternalRouterError(404, { pathname: path }),
2235
+ { flushSync }
2236
+ );
2237
+ return;
2238
+ } else {
2239
+ requestMatches = discoverResult.matches;
2240
+ match = getTargetMatch(requestMatches, path);
2241
+
2242
+ if (detectAndHandle405Error(match)) {
2243
+ return;
2244
+ }
2245
+ }
2246
+ }
2247
+
2248
+ // Call the action for the fetcher
2022
2249
  fetchControllers.set(key, abortController);
2023
2250
 
2024
2251
  let originatingLoadId = incrementingLoadId;
@@ -2247,6 +2474,7 @@ export function createRouter(init: RouterInit): Router {
2247
2474
  path: string,
2248
2475
  match: AgnosticDataRouteMatch,
2249
2476
  matches: AgnosticDataRouteMatch[],
2477
+ isFogOfWar: boolean,
2250
2478
  flushSync: boolean,
2251
2479
  submission?: Submission
2252
2480
  ) {
@@ -2260,13 +2488,41 @@ export function createRouter(init: RouterInit): Router {
2260
2488
  { flushSync }
2261
2489
  );
2262
2490
 
2263
- // Call the loader for this fetcher route match
2264
2491
  let abortController = new AbortController();
2265
2492
  let fetchRequest = createClientSideRequest(
2266
2493
  init.history,
2267
2494
  path,
2268
2495
  abortController.signal
2269
2496
  );
2497
+
2498
+ if (isFogOfWar) {
2499
+ let discoverResult = await discoverRoutes(
2500
+ matches,
2501
+ path,
2502
+ fetchRequest.signal
2503
+ );
2504
+
2505
+ if (discoverResult.type === "aborted") {
2506
+ return;
2507
+ } else if (discoverResult.type === "error") {
2508
+ let { error } = handleDiscoverRouteError(path, discoverResult);
2509
+ setFetcherError(key, routeId, error, { flushSync });
2510
+ return;
2511
+ } else if (!discoverResult.matches) {
2512
+ setFetcherError(
2513
+ key,
2514
+ routeId,
2515
+ getInternalRouterError(404, { pathname: path }),
2516
+ { flushSync }
2517
+ );
2518
+ return;
2519
+ } else {
2520
+ matches = discoverResult.matches;
2521
+ match = getTargetMatch(matches, path);
2522
+ }
2523
+ }
2524
+
2525
+ // Call the loader for this fetcher route match
2270
2526
  fetchControllers.set(key, abortController);
2271
2527
 
2272
2528
  let originatingLoadId = incrementingLoadId;
@@ -2777,6 +3033,35 @@ export function createRouter(init: RouterInit): Router {
2777
3033
  }
2778
3034
  }
2779
3035
 
3036
+ function handleNavigational404(pathname: string) {
3037
+ let error = getInternalRouterError(404, { pathname });
3038
+ let routesToUse = inFlightDataRoutes || dataRoutes;
3039
+ let { matches, route } = getShortCircuitMatches(routesToUse);
3040
+
3041
+ // Cancel all pending deferred on 404s since we don't keep any routes
3042
+ cancelActiveDeferreds();
3043
+
3044
+ return { notFoundMatches: matches, route, error };
3045
+ }
3046
+
3047
+ function handleDiscoverRouteError(
3048
+ pathname: string,
3049
+ discoverResult: DiscoverRoutesErrorResult
3050
+ ) {
3051
+ let matches = discoverResult.partialMatches;
3052
+ let route = matches[matches.length - 1].route;
3053
+ let error = getInternalRouterError(400, {
3054
+ type: "route-discovery",
3055
+ routeId: route.id,
3056
+ pathname,
3057
+ message:
3058
+ discoverResult.error != null && "message" in discoverResult.error
3059
+ ? discoverResult.error
3060
+ : String(discoverResult.error),
3061
+ });
3062
+ return { notFoundMatches: matches, route, error };
3063
+ }
3064
+
2780
3065
  function cancelActiveDeferreds(
2781
3066
  predicate?: (routeId: string) => boolean
2782
3067
  ): string[] {
@@ -2858,6 +3143,137 @@ export function createRouter(init: RouterInit): Router {
2858
3143
  return null;
2859
3144
  }
2860
3145
 
3146
+ function checkFogOfWar(
3147
+ matches: AgnosticDataRouteMatch[] | null,
3148
+ routesToUse: AgnosticDataRouteObject[],
3149
+ pathname: string
3150
+ ): { active: boolean; matches: AgnosticDataRouteMatch[] | null } {
3151
+ if (patchRoutesOnMissImpl) {
3152
+ if (!matches) {
3153
+ let fogMatches = matchRoutesImpl<AgnosticDataRouteObject>(
3154
+ routesToUse,
3155
+ pathname,
3156
+ basename,
3157
+ true
3158
+ );
3159
+
3160
+ return { active: true, matches: fogMatches || [] };
3161
+ } else {
3162
+ let leafRoute = matches[matches.length - 1].route;
3163
+ if (leafRoute.path === "*") {
3164
+ // If we matched a splat, it might only be because we haven't yet fetched
3165
+ // the children that would match with a higher score, so let's fetch
3166
+ // around and find out
3167
+ let partialMatches = matchRoutesImpl<AgnosticDataRouteObject>(
3168
+ routesToUse,
3169
+ pathname,
3170
+ basename,
3171
+ true
3172
+ );
3173
+ return { active: true, matches: partialMatches };
3174
+ }
3175
+ }
3176
+ }
3177
+
3178
+ return { active: false, matches: null };
3179
+ }
3180
+
3181
+ type DiscoverRoutesSuccessResult = {
3182
+ type: "success";
3183
+ matches: AgnosticDataRouteMatch[] | null;
3184
+ };
3185
+ type DiscoverRoutesErrorResult = {
3186
+ type: "error";
3187
+ error: any;
3188
+ partialMatches: AgnosticDataRouteMatch[];
3189
+ };
3190
+ type DiscoverRoutesAbortedResult = { type: "aborted" };
3191
+ type DiscoverRoutesResult =
3192
+ | DiscoverRoutesSuccessResult
3193
+ | DiscoverRoutesErrorResult
3194
+ | DiscoverRoutesAbortedResult;
3195
+
3196
+ async function discoverRoutes(
3197
+ matches: AgnosticDataRouteMatch[],
3198
+ pathname: string,
3199
+ signal: AbortSignal
3200
+ ): Promise<DiscoverRoutesResult> {
3201
+ let partialMatches: AgnosticDataRouteMatch[] | null = matches;
3202
+ let route =
3203
+ partialMatches.length > 0
3204
+ ? partialMatches[partialMatches.length - 1].route
3205
+ : null;
3206
+ while (true) {
3207
+ try {
3208
+ await loadLazyRouteChildren(
3209
+ patchRoutesOnMissImpl!,
3210
+ pathname,
3211
+ partialMatches,
3212
+ dataRoutes || inFlightDataRoutes,
3213
+ manifest,
3214
+ mapRouteProperties,
3215
+ pendingPatchRoutes,
3216
+ signal
3217
+ );
3218
+ } catch (e) {
3219
+ return { type: "error", error: e, partialMatches };
3220
+ }
3221
+
3222
+ if (signal.aborted) {
3223
+ return { type: "aborted" };
3224
+ }
3225
+
3226
+ let routesToUse = inFlightDataRoutes || dataRoutes;
3227
+ let newMatches = matchRoutes(routesToUse, pathname, basename);
3228
+ let matchedSplat = false;
3229
+ if (newMatches) {
3230
+ let leafRoute = newMatches[newMatches.length - 1].route;
3231
+
3232
+ if (leafRoute.index) {
3233
+ // If we found an index route, we can stop
3234
+ return { type: "success", matches: newMatches };
3235
+ }
3236
+
3237
+ if (leafRoute.path && leafRoute.path.length > 0) {
3238
+ if (leafRoute.path === "*") {
3239
+ // If we found a splat route, we can't be sure there's not a
3240
+ // higher-scoring route down some partial matches trail so we need
3241
+ // to check that out
3242
+ matchedSplat = true;
3243
+ } else {
3244
+ // If we found a non-splat route, we can stop
3245
+ return { type: "success", matches: newMatches };
3246
+ }
3247
+ }
3248
+ }
3249
+
3250
+ let newPartialMatches = matchRoutesImpl<AgnosticDataRouteObject>(
3251
+ routesToUse,
3252
+ pathname,
3253
+ basename,
3254
+ true
3255
+ );
3256
+
3257
+ // If we are no longer partially matching anything, this was either a
3258
+ // legit splat match above, or it's a 404. Also avoid loops if the
3259
+ // second pass results in the same partial matches
3260
+ if (
3261
+ !newPartialMatches ||
3262
+ partialMatches.map((m) => m.route.id).join("-") ===
3263
+ newPartialMatches.map((m) => m.route.id).join("-")
3264
+ ) {
3265
+ return { type: "success", matches: matchedSplat ? newMatches : null };
3266
+ }
3267
+
3268
+ partialMatches = newPartialMatches;
3269
+ route = partialMatches[partialMatches.length - 1].route;
3270
+ if (route.path === "*") {
3271
+ // The splat is still our most accurate partial, so run with it
3272
+ return { type: "success", matches: partialMatches };
3273
+ }
3274
+ }
3275
+ }
3276
+
2861
3277
  function _internalSetRoutes(newRoutes: AgnosticDataRouteObject[]) {
2862
3278
  manifest = {};
2863
3279
  inFlightDataRoutes = convertRoutesToDataRoutes(
@@ -2899,6 +3315,15 @@ export function createRouter(init: RouterInit): Router {
2899
3315
  dispose,
2900
3316
  getBlocker,
2901
3317
  deleteBlocker,
3318
+ patchRoutes(routeId, children) {
3319
+ return patchRoutes(
3320
+ routeId,
3321
+ children,
3322
+ dataRoutes || inFlightDataRoutes,
3323
+ manifest,
3324
+ mapRouteProperties
3325
+ );
3326
+ },
2902
3327
  _internalFetchControllers: fetchControllers,
2903
3328
  _internalActiveDeferreds: activeDeferreds,
2904
3329
  // TODO: Remove setRoutes, it's temporary to avoid dealing with
@@ -4062,6 +4487,85 @@ function shouldRevalidateLoader(
4062
4487
  return arg.defaultShouldRevalidate;
4063
4488
  }
4064
4489
 
4490
+ /**
4491
+ * Idempotent utility to execute route.children() method to lazily load route
4492
+ * definitions and update the routes/routeManifest
4493
+ */
4494
+ async function loadLazyRouteChildren(
4495
+ patchRoutesOnMissImpl: AgnosticPatchRoutesOnMissFunction,
4496
+ path: string,
4497
+ matches: AgnosticDataRouteMatch[],
4498
+ routes: AgnosticDataRouteObject[],
4499
+ manifest: RouteManifest,
4500
+ mapRouteProperties: MapRoutePropertiesFunction,
4501
+ pendingRouteChildren: Map<string, ReturnType<typeof patchRoutesOnMissImpl>>,
4502
+ signal: AbortSignal
4503
+ ) {
4504
+ let key = [path, ...matches.map((m) => m.route.id)].join("-");
4505
+ try {
4506
+ let pending = pendingRouteChildren.get(key);
4507
+ if (!pending) {
4508
+ pending = patchRoutesOnMissImpl({
4509
+ path,
4510
+ matches,
4511
+ patch: (routeId, children) => {
4512
+ if (!signal.aborted) {
4513
+ patchRoutes(
4514
+ routeId,
4515
+ children,
4516
+ routes,
4517
+ manifest,
4518
+ mapRouteProperties
4519
+ );
4520
+ }
4521
+ },
4522
+ });
4523
+ pendingRouteChildren.set(key, pending);
4524
+ }
4525
+
4526
+ if (pending && isPromise<AgnosticRouteObject[]>(pending)) {
4527
+ await pending;
4528
+ }
4529
+ } finally {
4530
+ pendingRouteChildren.delete(key);
4531
+ }
4532
+ }
4533
+
4534
+ function patchRoutes(
4535
+ routeId: string | null,
4536
+ children: AgnosticRouteObject[],
4537
+ routes: AgnosticDataRouteObject[],
4538
+ manifest: RouteManifest,
4539
+ mapRouteProperties: MapRoutePropertiesFunction
4540
+ ) {
4541
+ if (routeId) {
4542
+ let route = manifest[routeId];
4543
+ invariant(
4544
+ route,
4545
+ `No route found to patch children into: routeId = ${routeId}`
4546
+ );
4547
+ let dataChildren = convertRoutesToDataRoutes(
4548
+ children,
4549
+ mapRouteProperties,
4550
+ [routeId, "patch", String(route.children?.length || "0")],
4551
+ manifest
4552
+ );
4553
+ if (route.children) {
4554
+ route.children.push(...dataChildren);
4555
+ } else {
4556
+ route.children = dataChildren;
4557
+ }
4558
+ } else {
4559
+ let dataChildren = convertRoutesToDataRoutes(
4560
+ children,
4561
+ mapRouteProperties,
4562
+ ["patch", String(routes.length || "0")],
4563
+ manifest
4564
+ );
4565
+ routes.push(...dataChildren);
4566
+ }
4567
+ }
4568
+
4065
4569
  /**
4066
4570
  * Execute route.lazy() methods to lazily load route modules (loader, action,
4067
4571
  * shouldRevalidate) and update the routeManifest in place which shares objects
@@ -4797,11 +5301,13 @@ function getInternalRouterError(
4797
5301
  routeId,
4798
5302
  method,
4799
5303
  type,
5304
+ message,
4800
5305
  }: {
4801
5306
  pathname?: string;
4802
5307
  routeId?: string;
4803
5308
  method?: string;
4804
- type?: "defer-action" | "invalid-body";
5309
+ type?: "defer-action" | "invalid-body" | "route-discovery";
5310
+ message?: string;
4805
5311
  } = {}
4806
5312
  ) {
4807
5313
  let statusText = "Unknown Server Error";
@@ -4809,7 +5315,11 @@ function getInternalRouterError(
4809
5315
 
4810
5316
  if (status === 400) {
4811
5317
  statusText = "Bad Request";
4812
- if (method && pathname && routeId) {
5318
+ if (type === "route-discovery") {
5319
+ errorMessage =
5320
+ `Unable to match URL "${pathname}" - the \`children()\` function for ` +
5321
+ `route \`${routeId}\` threw the following error:\n${message}`;
5322
+ } else if (method && pathname && routeId) {
4813
5323
  errorMessage =
4814
5324
  `You made a ${method} request to "${pathname}" but ` +
4815
5325
  `did not provide a \`loader\` for route "${routeId}", ` +
@@ -4883,6 +5393,10 @@ function isHashChangeOnly(a: Location, b: Location): boolean {
4883
5393
  return false;
4884
5394
  }
4885
5395
 
5396
+ function isPromise<T = unknown>(val: unknown): val is Promise<T> {
5397
+ return typeof val === "object" && val != null && "then" in val;
5398
+ }
5399
+
4886
5400
  function isHandlerResult(result: unknown): result is HandlerResult {
4887
5401
  return (
4888
5402
  result != null &&
@@ -5243,5 +5757,4 @@ function persistAppliedTransitions(
5243
5757
  }
5244
5758
  }
5245
5759
  }
5246
-
5247
5760
  //#endregion