@remix-run/router 1.0.3 → 1.0.4-pre.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
@@ -1,4 +1,4 @@
1
- import type { History, Location, To } from "./history";
1
+ import type { History, Location, Path, To } from "./history";
2
2
  import {
3
3
  Action as HistoryAction,
4
4
  createLocation,
@@ -20,6 +20,7 @@ import type {
20
20
  Submission,
21
21
  SuccessResult,
22
22
  AgnosticRouteMatch,
23
+ SubmissionFormMethod,
23
24
  } from "./utils";
24
25
  import {
25
26
  DeferredData,
@@ -154,6 +155,16 @@ export interface Router {
154
155
  */
155
156
  createHref(location: Location | URL): string;
156
157
 
158
+ /**
159
+ * @internal
160
+ * PRIVATE - DO NOT USE
161
+ *
162
+ * Utility function to URL encode a destination path according to the internal
163
+ * history implementation
164
+ * @param to
165
+ */
166
+ encodeLocation(to: To): Path;
167
+
157
168
  /**
158
169
  * @internal
159
170
  * PRIVATE - DO NOT USE
@@ -288,6 +299,7 @@ export interface RouterInit {
288
299
  * State returned from a server-side query() call
289
300
  */
290
301
  export interface StaticHandlerContext {
302
+ basename: Router["basename"];
291
303
  location: RouterState["location"];
292
304
  matches: RouterState["matches"];
293
305
  loaderData: RouterState["loaderData"];
@@ -503,6 +515,20 @@ interface QueryRouteResponse {
503
515
  response: Response;
504
516
  }
505
517
 
518
+ const validActionMethodsArr: SubmissionFormMethod[] = [
519
+ "post",
520
+ "put",
521
+ "patch",
522
+ "delete",
523
+ ];
524
+ const validActionMethods = new Set<SubmissionFormMethod>(validActionMethodsArr);
525
+
526
+ const validRequestMethodsArr: FormMethod[] = ["get", ...validActionMethodsArr];
527
+ const validRequestMethods = new Set<FormMethod>(validRequestMethodsArr);
528
+
529
+ const redirectStatusCodes = new Set([301, 302, 303, 307, 308]);
530
+ const redirectPreserveMethodStatusCodes = new Set([307, 308]);
531
+
506
532
  export const IDLE_NAVIGATION: NavigationStates["Idle"] = {
507
533
  state: "idle",
508
534
  location: undefined,
@@ -568,7 +594,10 @@ export function createRouter(init: RouterInit): Router {
568
594
  if (initialMatches == null) {
569
595
  // If we do not match a user-provided-route, fall back to the root
570
596
  // to allow the error boundary to take over
571
- let { matches, route, error } = getNotFoundMatches(dataRoutes);
597
+ let error = getInternalRouterError(404, {
598
+ pathname: init.history.location.pathname,
599
+ });
600
+ let { matches, route } = getShortCircuitMatches(dataRoutes);
572
601
  initialMatches = matches;
573
602
  initialErrors = { [route.id]: error };
574
603
  }
@@ -770,7 +799,10 @@ export function createRouter(init: RouterInit): Router {
770
799
  // remains the same as POP and non-data-router usages. new URL() does all
771
800
  // the same encoding we'd get from a history.pushState/window.location read
772
801
  // without having to touch history
773
- location = init.history.encodeLocation(location);
802
+ location = {
803
+ ...location,
804
+ ...init.history.encodeLocation(location),
805
+ };
774
806
 
775
807
  let historyAction =
776
808
  (opts && opts.replace) === true || submission != null
@@ -858,11 +890,9 @@ export function createRouter(init: RouterInit): Router {
858
890
 
859
891
  // Short circuit with a 404 on the root error boundary if we match nothing
860
892
  if (!matches) {
861
- let {
862
- matches: notFoundMatches,
863
- route,
864
- error,
865
- } = getNotFoundMatches(dataRoutes);
893
+ let error = getInternalRouterError(404, { pathname: location.pathname });
894
+ let { matches: notFoundMatches, route } =
895
+ getShortCircuitMatches(dataRoutes);
866
896
  // Cancel all pending deferred on 404s since we don't keep any routes
867
897
  cancelActiveDeferreds();
868
898
  completeNavigation(location, {
@@ -976,7 +1006,14 @@ export function createRouter(init: RouterInit): Router {
976
1006
  let actionMatch = getTargetMatch(matches, location);
977
1007
 
978
1008
  if (!actionMatch.route.action) {
979
- result = getMethodNotAllowedResult(location);
1009
+ result = {
1010
+ type: ResultType.error,
1011
+ error: getInternalRouterError(405, {
1012
+ method: request.method,
1013
+ pathname: location.pathname,
1014
+ routeId: actionMatch.route.id,
1015
+ }),
1016
+ };
980
1017
  } else {
981
1018
  result = await callLoaderOrAction(
982
1019
  "action",
@@ -992,15 +1029,10 @@ export function createRouter(init: RouterInit): Router {
992
1029
  }
993
1030
 
994
1031
  if (isRedirectResult(result)) {
995
- let redirectNavigation: NavigationStates["Loading"] = {
996
- state: "loading",
997
- location: createLocation(state.location, result.location),
998
- ...submission,
999
- };
1000
1032
  await startRedirectNavigation(
1033
+ state,
1001
1034
  result,
1002
- redirectNavigation,
1003
- opts && opts.replace
1035
+ opts && opts.replace === true
1004
1036
  );
1005
1037
  return { shortCircuited: true };
1006
1038
  }
@@ -1144,8 +1176,7 @@ export function createRouter(init: RouterInit): Router {
1144
1176
  // If any loaders returned a redirect Response, start a new REPLACE navigation
1145
1177
  let redirect = findRedirect(results);
1146
1178
  if (redirect) {
1147
- let redirectNavigation = getLoaderRedirect(state, redirect);
1148
- await startRedirectNavigation(redirect, redirectNavigation, replace);
1179
+ await startRedirectNavigation(state, redirect, replace);
1149
1180
  return { shortCircuited: true };
1150
1181
  }
1151
1182
 
@@ -1208,7 +1239,11 @@ export function createRouter(init: RouterInit): Router {
1208
1239
 
1209
1240
  let matches = matchRoutes(dataRoutes, href, init.basename);
1210
1241
  if (!matches) {
1211
- setFetcherError(key, routeId, new ErrorResponse(404, "Not Found", null));
1242
+ setFetcherError(
1243
+ key,
1244
+ routeId,
1245
+ getInternalRouterError(404, { pathname: href })
1246
+ );
1212
1247
  return;
1213
1248
  }
1214
1249
 
@@ -1240,7 +1275,11 @@ export function createRouter(init: RouterInit): Router {
1240
1275
  fetchLoadMatches.delete(key);
1241
1276
 
1242
1277
  if (!match.route.action) {
1243
- let { error } = getMethodNotAllowedResult(path);
1278
+ let error = getInternalRouterError(405, {
1279
+ method: submission.formMethod,
1280
+ pathname: path,
1281
+ routeId: routeId,
1282
+ });
1244
1283
  setFetcherError(key, routeId, error);
1245
1284
  return;
1246
1285
  }
@@ -1288,13 +1327,7 @@ export function createRouter(init: RouterInit): Router {
1288
1327
  state.fetchers.set(key, loadingFetcher);
1289
1328
  updateState({ fetchers: new Map(state.fetchers) });
1290
1329
 
1291
- let redirectNavigation: NavigationStates["Loading"] = {
1292
- state: "loading",
1293
- location: createLocation(state.location, actionResult.location),
1294
- ...submission,
1295
- };
1296
- await startRedirectNavigation(actionResult, redirectNavigation);
1297
- return;
1330
+ return startRedirectNavigation(state, actionResult);
1298
1331
  }
1299
1332
 
1300
1333
  // Process any non-redirect errors thrown
@@ -1386,9 +1419,7 @@ export function createRouter(init: RouterInit): Router {
1386
1419
 
1387
1420
  let redirect = findRedirect(results);
1388
1421
  if (redirect) {
1389
- let redirectNavigation = getLoaderRedirect(state, redirect);
1390
- await startRedirectNavigation(redirect, redirectNavigation);
1391
- return;
1422
+ return startRedirectNavigation(state, redirect);
1392
1423
  }
1393
1424
 
1394
1425
  // Process and commit output from loaders
@@ -1499,8 +1530,7 @@ export function createRouter(init: RouterInit): Router {
1499
1530
 
1500
1531
  // If the loader threw a redirect Response, start a new REPLACE navigation
1501
1532
  if (isRedirectResult(result)) {
1502
- let redirectNavigation = getLoaderRedirect(state, result);
1503
- await startRedirectNavigation(result, redirectNavigation);
1533
+ await startRedirectNavigation(state, result);
1504
1534
  return;
1505
1535
  }
1506
1536
 
@@ -1555,17 +1585,29 @@ export function createRouter(init: RouterInit): Router {
1555
1585
  * the history action from the original navigation (PUSH or REPLACE).
1556
1586
  */
1557
1587
  async function startRedirectNavigation(
1588
+ state: RouterState,
1558
1589
  redirect: RedirectResult,
1559
- navigation: Navigation,
1560
1590
  replace?: boolean
1561
1591
  ) {
1562
1592
  if (redirect.revalidate) {
1563
1593
  isRevalidationRequired = true;
1564
1594
  }
1595
+
1596
+ let redirectLocation = createLocation(state.location, redirect.location);
1565
1597
  invariant(
1566
- navigation.location,
1598
+ redirectLocation,
1567
1599
  "Expected a location on the redirect navigation"
1568
1600
  );
1601
+
1602
+ if (
1603
+ redirect.external &&
1604
+ typeof window !== "undefined" &&
1605
+ typeof window.location !== "undefined"
1606
+ ) {
1607
+ window.location.replace(redirect.location);
1608
+ return;
1609
+ }
1610
+
1569
1611
  // There's no need to abort on redirects, since we don't detect the
1570
1612
  // redirect until the action/loaders have settled
1571
1613
  pendingNavigationController = null;
@@ -1573,9 +1615,40 @@ export function createRouter(init: RouterInit): Router {
1573
1615
  let redirectHistoryAction =
1574
1616
  replace === true ? HistoryAction.Replace : HistoryAction.Push;
1575
1617
 
1576
- await startNavigation(redirectHistoryAction, navigation.location, {
1577
- overrideNavigation: navigation,
1578
- });
1618
+ let { formMethod, formAction, formEncType, formData } = state.navigation;
1619
+
1620
+ // If this was a 307/308 submission we want to preserve the HTTP method and
1621
+ // re-submit the POST/PUT/PATCH/DELETE as a submission navigation to the
1622
+ // redirected location
1623
+ if (
1624
+ redirectPreserveMethodStatusCodes.has(redirect.status) &&
1625
+ formMethod &&
1626
+ isSubmissionMethod(formMethod) &&
1627
+ formEncType &&
1628
+ formData
1629
+ ) {
1630
+ await startNavigation(redirectHistoryAction, redirectLocation, {
1631
+ submission: {
1632
+ formMethod,
1633
+ formAction: redirect.location,
1634
+ formEncType,
1635
+ formData,
1636
+ },
1637
+ });
1638
+ } else {
1639
+ // Otherwise, we kick off a new loading navigation, preserving the
1640
+ // submission info for the duration of this navigation
1641
+ await startNavigation(redirectHistoryAction, redirectLocation, {
1642
+ overrideNavigation: {
1643
+ state: "loading",
1644
+ location: redirectLocation,
1645
+ formMethod: formMethod || undefined,
1646
+ formAction: formAction || undefined,
1647
+ formEncType: formEncType || undefined,
1648
+ formData: formData || undefined,
1649
+ },
1650
+ });
1651
+ }
1579
1652
  }
1580
1653
 
1581
1654
  async function callLoadersAndMaybeResolveData(
@@ -1809,6 +1882,7 @@ export function createRouter(init: RouterInit): Router {
1809
1882
  // Passthrough to history-aware createHref used by useHref so we get proper
1810
1883
  // hash-aware URLs in DOM paths
1811
1884
  createHref: (to: To) => init.history.createHref(to),
1885
+ encodeLocation: (to: To) => init.history.encodeLocation(to),
1812
1886
  getFetcher,
1813
1887
  deleteFetcher,
1814
1888
  dispose,
@@ -1824,11 +1898,11 @@ export function createRouter(init: RouterInit): Router {
1824
1898
  //#region createStaticHandler
1825
1899
  ////////////////////////////////////////////////////////////////////////////////
1826
1900
 
1827
- const validActionMethods = new Set(["POST", "PUT", "PATCH", "DELETE"]);
1828
- const validRequestMethods = new Set(["GET", "HEAD", ...validActionMethods]);
1829
-
1830
1901
  export function unstable_createStaticHandler(
1831
- routes: AgnosticRouteObject[]
1902
+ routes: AgnosticRouteObject[],
1903
+ opts?: {
1904
+ basename?: string;
1905
+ }
1832
1906
  ): StaticHandler {
1833
1907
  invariant(
1834
1908
  routes.length > 0,
@@ -1836,6 +1910,7 @@ export function unstable_createStaticHandler(
1836
1910
  );
1837
1911
 
1838
1912
  let dataRoutes = convertRoutesToDataRoutes(routes);
1913
+ let basename = (opts ? opts.basename : null) || "/";
1839
1914
 
1840
1915
  /**
1841
1916
  * The query() method is intended for document requests, in which we want to
@@ -1860,16 +1935,17 @@ export function unstable_createStaticHandler(
1860
1935
  request: Request
1861
1936
  ): Promise<StaticHandlerContext | Response> {
1862
1937
  let url = new URL(request.url);
1938
+ let method = request.method.toLowerCase();
1863
1939
  let location = createLocation("", createPath(url), null, "default");
1864
- let matches = matchRoutes(dataRoutes, location);
1940
+ let matches = matchRoutes(dataRoutes, location, basename);
1865
1941
 
1866
- if (!validRequestMethods.has(request.method)) {
1867
- let {
1868
- matches: methodNotAllowedMatches,
1869
- route,
1870
- error,
1871
- } = getMethodNotAllowedMatches(dataRoutes);
1942
+ // SSR supports HEAD requests while SPA doesn't
1943
+ if (!isValidMethod(method) && method !== "head") {
1944
+ let error = getInternalRouterError(405, { method });
1945
+ let { matches: methodNotAllowedMatches, route } =
1946
+ getShortCircuitMatches(dataRoutes);
1872
1947
  return {
1948
+ basename,
1873
1949
  location,
1874
1950
  matches: methodNotAllowedMatches,
1875
1951
  loaderData: {},
@@ -1882,12 +1958,11 @@ export function unstable_createStaticHandler(
1882
1958
  actionHeaders: {},
1883
1959
  };
1884
1960
  } else if (!matches) {
1885
- let {
1886
- matches: notFoundMatches,
1887
- route,
1888
- error,
1889
- } = getNotFoundMatches(dataRoutes);
1961
+ let error = getInternalRouterError(404, { pathname: location.pathname });
1962
+ let { matches: notFoundMatches, route } =
1963
+ getShortCircuitMatches(dataRoutes);
1890
1964
  return {
1965
+ basename,
1891
1966
  location,
1892
1967
  matches: notFoundMatches,
1893
1968
  loaderData: {},
@@ -1909,7 +1984,7 @@ export function unstable_createStaticHandler(
1909
1984
  // When returning StaticHandlerContext, we patch back in the location here
1910
1985
  // since we need it for React Context. But this helps keep our submit and
1911
1986
  // loadRouteData operating on a Request instead of a Location
1912
- return { location, ...result };
1987
+ return { location, basename, ...result };
1913
1988
  }
1914
1989
 
1915
1990
  /**
@@ -1925,36 +2000,38 @@ export function unstable_createStaticHandler(
1925
2000
  * can do proper boundary identification in Remix where a thrown Response
1926
2001
  * must go to the Catch Boundary but a returned Response is happy-path.
1927
2002
  *
1928
- * One thing to note is that any Router-initiated thrown Response (such as a
1929
- * 404 or 405) will have a custom X-Remix-Router-Error: "yes" header on it
1930
- * in order to differentiate from responses thrown from user actions/loaders.
2003
+ * One thing to note is that any Router-initiated Errors that make sense
2004
+ * to associate with a status code will be thrown as an ErrorResponse
2005
+ * instance which include the raw Error, such that the calling context can
2006
+ * serialize the error as they see fit while including the proper response
2007
+ * code. Examples here are 404 and 405 errors that occur prior to reaching
2008
+ * any user-defined loaders.
1931
2009
  */
1932
2010
  async function queryRoute(request: Request, routeId?: string): Promise<any> {
1933
2011
  let url = new URL(request.url);
2012
+ let method = request.method.toLowerCase();
1934
2013
  let location = createLocation("", createPath(url), null, "default");
1935
- let matches = matchRoutes(dataRoutes, location);
2014
+ let matches = matchRoutes(dataRoutes, location, basename);
1936
2015
 
1937
- if (!validRequestMethods.has(request.method)) {
1938
- throw createRouterErrorResponse(null, {
1939
- status: 405,
1940
- statusText: "Method Not Allowed",
1941
- });
2016
+ // SSR supports HEAD requests while SPA doesn't
2017
+ if (!isValidMethod(method) && method !== "head") {
2018
+ throw getInternalRouterError(405, { method });
1942
2019
  } else if (!matches) {
1943
- throw createRouterErrorResponse(null, {
1944
- status: 404,
1945
- statusText: "Not Found",
1946
- });
2020
+ throw getInternalRouterError(404, { pathname: location.pathname });
1947
2021
  }
1948
2022
 
1949
2023
  let match = routeId
1950
2024
  ? matches.find((m) => m.route.id === routeId)
1951
2025
  : getTargetMatch(matches, location);
1952
2026
 
1953
- if (!match) {
1954
- throw createRouterErrorResponse(null, {
1955
- status: 404,
1956
- statusText: "Not Found",
2027
+ if (routeId && !match) {
2028
+ throw getInternalRouterError(403, {
2029
+ pathname: location.pathname,
2030
+ routeId,
1957
2031
  });
2032
+ } else if (!match) {
2033
+ // This should never hit I don't think?
2034
+ throw getInternalRouterError(404, { pathname: location.pathname });
1958
2035
  }
1959
2036
 
1960
2037
  let result = await queryImpl(request, location, matches, match);
@@ -1981,14 +2058,14 @@ export function unstable_createStaticHandler(
1981
2058
  location: Location,
1982
2059
  matches: AgnosticDataRouteMatch[],
1983
2060
  routeMatch?: AgnosticDataRouteMatch
1984
- ): Promise<Omit<StaticHandlerContext, "location"> | Response> {
2061
+ ): Promise<Omit<StaticHandlerContext, "location" | "basename"> | Response> {
1985
2062
  invariant(
1986
2063
  request.signal,
1987
2064
  "query()/queryRoute() requests must contain an AbortController signal"
1988
2065
  );
1989
2066
 
1990
2067
  try {
1991
- if (validActionMethods.has(request.method)) {
2068
+ if (isSubmissionMethod(request.method.toLowerCase())) {
1992
2069
  let result = await submit(
1993
2070
  request,
1994
2071
  matches,
@@ -2030,23 +2107,29 @@ export function unstable_createStaticHandler(
2030
2107
  matches: AgnosticDataRouteMatch[],
2031
2108
  actionMatch: AgnosticDataRouteMatch,
2032
2109
  isRouteRequest: boolean
2033
- ): Promise<Omit<StaticHandlerContext, "location"> | Response> {
2110
+ ): Promise<Omit<StaticHandlerContext, "location" | "basename"> | Response> {
2034
2111
  let result: DataResult;
2112
+
2035
2113
  if (!actionMatch.route.action) {
2114
+ let error = getInternalRouterError(405, {
2115
+ method: request.method,
2116
+ pathname: createURL(request.url).pathname,
2117
+ routeId: actionMatch.route.id,
2118
+ });
2036
2119
  if (isRouteRequest) {
2037
- throw createRouterErrorResponse(null, {
2038
- status: 405,
2039
- statusText: "Method Not Allowed",
2040
- });
2120
+ throw error;
2041
2121
  }
2042
- result = getMethodNotAllowedResult(request.url);
2122
+ result = {
2123
+ type: ResultType.error,
2124
+ error,
2125
+ };
2043
2126
  } else {
2044
2127
  result = await callLoaderOrAction(
2045
2128
  "action",
2046
2129
  request,
2047
2130
  actionMatch,
2048
2131
  matches,
2049
- undefined, // Basename not currently supported in static handlers
2132
+ basename,
2050
2133
  true,
2051
2134
  isRouteRequest
2052
2135
  );
@@ -2078,20 +2161,7 @@ export function unstable_createStaticHandler(
2078
2161
  // Note: This should only be non-Response values if we get here, since
2079
2162
  // isRouteRequest should throw any Response received in callLoaderOrAction
2080
2163
  if (isErrorResult(result)) {
2081
- let boundaryMatch = findNearestBoundary(matches, actionMatch.route.id);
2082
- return {
2083
- matches: [actionMatch],
2084
- loaderData: {},
2085
- actionData: null,
2086
- errors: {
2087
- [boundaryMatch.route.id]: result.error,
2088
- },
2089
- // Note: statusCode + headers are unused here since queryRoute will
2090
- // return the raw Response or value
2091
- statusCode: 500,
2092
- loaderHeaders: {},
2093
- actionHeaders: {},
2094
- };
2164
+ throw result.error;
2095
2165
  }
2096
2166
 
2097
2167
  return {
@@ -2149,10 +2219,23 @@ export function unstable_createStaticHandler(
2149
2219
  routeMatch?: AgnosticDataRouteMatch,
2150
2220
  pendingActionError?: RouteData
2151
2221
  ): Promise<
2152
- | Omit<StaticHandlerContext, "location" | "actionData" | "actionHeaders">
2222
+ | Omit<
2223
+ StaticHandlerContext,
2224
+ "location" | "basename" | "actionData" | "actionHeaders"
2225
+ >
2153
2226
  | Response
2154
2227
  > {
2155
2228
  let isRouteRequest = routeMatch != null;
2229
+
2230
+ // Short circuit if we have no loaders to run (queryRoute())
2231
+ if (isRouteRequest && !routeMatch?.route.loader) {
2232
+ throw getInternalRouterError(400, {
2233
+ method: request.method,
2234
+ pathname: createURL(request.url).pathname,
2235
+ routeId: routeMatch?.route.id,
2236
+ });
2237
+ }
2238
+
2156
2239
  let requestMatches = routeMatch
2157
2240
  ? [routeMatch]
2158
2241
  : getLoaderMatchesUntilBoundary(
@@ -2161,7 +2244,7 @@ export function unstable_createStaticHandler(
2161
2244
  );
2162
2245
  let matchesToLoad = requestMatches.filter((m) => m.route.loader);
2163
2246
 
2164
- // Short circuit if we have no loaders to run
2247
+ // Short circuit if we have no loaders to run (query())
2165
2248
  if (matchesToLoad.length === 0) {
2166
2249
  return {
2167
2250
  matches,
@@ -2179,7 +2262,7 @@ export function unstable_createStaticHandler(
2179
2262
  request,
2180
2263
  match,
2181
2264
  matches,
2182
- undefined, // Basename not currently supported in static handlers
2265
+ basename,
2183
2266
  true,
2184
2267
  isRouteRequest
2185
2268
  )
@@ -2213,19 +2296,6 @@ export function unstable_createStaticHandler(
2213
2296
  };
2214
2297
  }
2215
2298
 
2216
- function createRouterErrorResponse(
2217
- body: BodyInit | null | undefined,
2218
- init: ResponseInit
2219
- ) {
2220
- return new Response(body, {
2221
- ...init,
2222
- headers: {
2223
- ...init.headers,
2224
- "X-Remix-Router-Error": "yes",
2225
- },
2226
- });
2227
- }
2228
-
2229
2299
  return {
2230
2300
  dataRoutes,
2231
2301
  query,
@@ -2258,6 +2328,12 @@ export function getStaticContextFromError(
2258
2328
  return newContext;
2259
2329
  }
2260
2330
 
2331
+ function isSubmissionNavigation(
2332
+ opts: RouterNavigateOptions
2333
+ ): opts is SubmissionNavigateOptions {
2334
+ return opts != null && "formData" in opts;
2335
+ }
2336
+
2261
2337
  // Normalize navigation options by converting formMethod=GET formData objects to
2262
2338
  // URLSearchParams so they behave identically to links with query params
2263
2339
  function normalizeNavigateOptions(
@@ -2272,12 +2348,19 @@ function normalizeNavigateOptions(
2272
2348
  let path = typeof to === "string" ? to : createPath(to);
2273
2349
 
2274
2350
  // Return location verbatim on non-submission navigations
2275
- if (!opts || (!("formMethod" in opts) && !("formData" in opts))) {
2351
+ if (!opts || !isSubmissionNavigation(opts)) {
2276
2352
  return { path };
2277
2353
  }
2278
2354
 
2355
+ if (opts.formMethod && !isValidMethod(opts.formMethod)) {
2356
+ return {
2357
+ path,
2358
+ error: getInternalRouterError(405, { method: opts.formMethod }),
2359
+ };
2360
+ }
2361
+
2279
2362
  // Create a Submission on non-GET navigations
2280
- if (opts.formMethod != null && opts.formMethod !== "get") {
2363
+ if (opts.formMethod && isSubmissionMethod(opts.formMethod)) {
2281
2364
  return {
2282
2365
  path,
2283
2366
  submission: {
@@ -2290,11 +2373,6 @@ function normalizeNavigateOptions(
2290
2373
  };
2291
2374
  }
2292
2375
 
2293
- // No formData to flatten for GET submission
2294
- if (!opts.formData) {
2295
- return { path };
2296
- }
2297
-
2298
2376
  // Flatten submission onto URLSearchParams for GET submissions
2299
2377
  let parsedPath = parsePath(path);
2300
2378
  try {
@@ -2313,33 +2391,13 @@ function normalizeNavigateOptions(
2313
2391
  } catch (e) {
2314
2392
  return {
2315
2393
  path,
2316
- error: new ErrorResponse(
2317
- 400,
2318
- "Bad Request",
2319
- "Cannot submit binary form data using GET"
2320
- ),
2394
+ error: getInternalRouterError(400),
2321
2395
  };
2322
2396
  }
2323
2397
 
2324
2398
  return { path: createPath(parsedPath) };
2325
2399
  }
2326
2400
 
2327
- function getLoaderRedirect(
2328
- state: RouterState,
2329
- redirect: RedirectResult
2330
- ): Navigation {
2331
- let { formMethod, formAction, formEncType, formData } = state.navigation;
2332
- let navigation: NavigationStates["Loading"] = {
2333
- state: "loading",
2334
- location: createLocation(state.location, redirect.location),
2335
- formMethod: formMethod || undefined,
2336
- formAction: formAction || undefined,
2337
- formEncType: formEncType || undefined,
2338
- formData: formData || undefined,
2339
- };
2340
- return navigation;
2341
- }
2342
-
2343
2401
  // Filter out all routes below any caught error as they aren't going to
2344
2402
  // render so we don't need to load them
2345
2403
  function getLoaderMatchesUntilBoundary(
@@ -2507,7 +2565,7 @@ async function callLoaderOrAction(
2507
2565
  request: Request,
2508
2566
  match: AgnosticDataRouteMatch,
2509
2567
  matches: AgnosticDataRouteMatch[],
2510
- basename: string | undefined,
2568
+ basename = "/",
2511
2569
  isStaticRequest: boolean = false,
2512
2570
  isRouteRequest: boolean = false
2513
2571
  ): Promise<DataResult> {
@@ -2531,6 +2589,13 @@ async function callLoaderOrAction(
2531
2589
  handler({ request, params: match.params }),
2532
2590
  abortPromise,
2533
2591
  ]);
2592
+
2593
+ invariant(
2594
+ result !== undefined,
2595
+ `You defined ${type === "action" ? "an action" : "a loader"} for route ` +
2596
+ `"${match.route.id}" but didn't return anything from your \`${type}\` ` +
2597
+ `function. Please return a value or \`null\`.`
2598
+ );
2534
2599
  } catch (e) {
2535
2600
  resultType = ResultType.error;
2536
2601
  result = e;
@@ -2542,33 +2607,38 @@ async function callLoaderOrAction(
2542
2607
  let status = result.status;
2543
2608
 
2544
2609
  // Process redirects
2545
- if (status >= 300 && status <= 399) {
2610
+ if (redirectStatusCodes.has(status)) {
2546
2611
  let location = result.headers.get("Location");
2547
2612
  invariant(
2548
2613
  location,
2549
2614
  "Redirects returned/thrown from loaders/actions must have a Location header"
2550
2615
  );
2551
2616
 
2552
- // Support relative routing in redirects
2553
- let activeMatches = matches.slice(0, matches.indexOf(match) + 1);
2554
- let routePathnames = getPathContributingMatches(activeMatches).map(
2555
- (match) => match.pathnameBase
2556
- );
2557
- let requestPath = createURL(request.url).pathname;
2558
- let resolvedLocation = resolveTo(location, routePathnames, requestPath);
2559
- invariant(
2560
- createPath(resolvedLocation),
2561
- `Unable to resolve redirect location: ${result.headers.get("Location")}`
2562
- );
2617
+ // Check if this an external redirect that goes to a new origin
2618
+ let external = createURL(location).origin !== createURL("/").origin;
2563
2619
 
2564
- // Prepend the basename to the redirect location if we have one
2565
- if (basename) {
2566
- let path = resolvedLocation.pathname;
2567
- resolvedLocation.pathname =
2568
- path === "/" ? basename : joinPaths([basename, path]);
2569
- }
2620
+ // Support relative routing in internal redirects
2621
+ if (!external) {
2622
+ let activeMatches = matches.slice(0, matches.indexOf(match) + 1);
2623
+ let routePathnames = getPathContributingMatches(activeMatches).map(
2624
+ (match) => match.pathnameBase
2625
+ );
2626
+ let requestPath = createURL(request.url).pathname;
2627
+ let resolvedLocation = resolveTo(location, routePathnames, requestPath);
2628
+ invariant(
2629
+ createPath(resolvedLocation),
2630
+ `Unable to resolve redirect location: ${location}`
2631
+ );
2570
2632
 
2571
- location = createPath(resolvedLocation);
2633
+ // Prepend the basename to the redirect location if we have one
2634
+ if (basename) {
2635
+ let path = resolvedLocation.pathname;
2636
+ resolvedLocation.pathname =
2637
+ path === "/" ? basename : joinPaths([basename, path]);
2638
+ }
2639
+
2640
+ location = createPath(resolvedLocation);
2641
+ }
2572
2642
 
2573
2643
  // Don't process redirects in the router during static requests requests.
2574
2644
  // Instead, throw the Response and let the server handle it with an HTTP
@@ -2584,6 +2654,7 @@ async function callLoaderOrAction(
2584
2654
  status,
2585
2655
  location,
2586
2656
  revalidate: result.headers.get("X-Remix-Revalidate") !== null,
2657
+ external,
2587
2658
  };
2588
2659
  }
2589
2660
 
@@ -2851,18 +2922,13 @@ function findNearestBoundary(
2851
2922
  );
2852
2923
  }
2853
2924
 
2854
- function getShortCircuitMatches(
2855
- routes: AgnosticDataRouteObject[],
2856
- status: number,
2857
- statusText: string
2858
- ): {
2925
+ function getShortCircuitMatches(routes: AgnosticDataRouteObject[]): {
2859
2926
  matches: AgnosticDataRouteMatch[];
2860
2927
  route: AgnosticDataRouteObject;
2861
- error: ErrorResponse;
2862
2928
  } {
2863
2929
  // Prefer a root layout route if present, otherwise shim in a route object
2864
2930
  let route = routes.find((r) => r.index || !r.path || r.path === "/") || {
2865
- id: `__shim-${status}-route__`,
2931
+ id: `__shim-error-route__`,
2866
2932
  };
2867
2933
 
2868
2934
  return {
@@ -2875,29 +2941,60 @@ function getShortCircuitMatches(
2875
2941
  },
2876
2942
  ],
2877
2943
  route,
2878
- error: new ErrorResponse(status, statusText, null),
2879
2944
  };
2880
2945
  }
2881
2946
 
2882
- function getNotFoundMatches(routes: AgnosticDataRouteObject[]) {
2883
- return getShortCircuitMatches(routes, 404, "Not Found");
2884
- }
2885
-
2886
- function getMethodNotAllowedMatches(routes: AgnosticDataRouteObject[]) {
2887
- return getShortCircuitMatches(routes, 405, "Method Not Allowed");
2888
- }
2947
+ function getInternalRouterError(
2948
+ status: number,
2949
+ {
2950
+ pathname,
2951
+ routeId,
2952
+ method,
2953
+ message,
2954
+ }: {
2955
+ pathname?: string;
2956
+ routeId?: string;
2957
+ method?: string;
2958
+ message?: string;
2959
+ } = {}
2960
+ ) {
2961
+ let statusText = "Unknown Server Error";
2962
+ let errorMessage = "Unknown @remix-run/router error";
2963
+
2964
+ if (status === 400) {
2965
+ statusText = "Bad Request";
2966
+ if (method && pathname && routeId) {
2967
+ errorMessage =
2968
+ `You made a ${method} request to "${pathname}" but ` +
2969
+ `did not provide a \`loader\` for route "${routeId}", ` +
2970
+ `so there is no way to handle the request.`;
2971
+ } else {
2972
+ errorMessage = "Cannot submit binary form data using GET";
2973
+ }
2974
+ } else if (status === 403) {
2975
+ statusText = "Forbidden";
2976
+ errorMessage = `Route "${routeId}" does not match URL "${pathname}"`;
2977
+ } else if (status === 404) {
2978
+ statusText = "Not Found";
2979
+ errorMessage = `No route matches URL "${pathname}"`;
2980
+ } else if (status === 405) {
2981
+ statusText = "Method Not Allowed";
2982
+ if (method && pathname && routeId) {
2983
+ errorMessage =
2984
+ `You made a ${method.toUpperCase()} request to "${pathname}" but ` +
2985
+ `did not provide an \`action\` for route "${routeId}", ` +
2986
+ `so there is no way to handle the request.`;
2987
+ } else if (method) {
2988
+ errorMessage = `Invalid request method "${method.toUpperCase()}"`;
2989
+ }
2990
+ }
2889
2991
 
2890
- function getMethodNotAllowedResult(path: Location | string): ErrorResult {
2891
- let href = typeof path === "string" ? path : createPath(path);
2892
- console.warn(
2893
- "You're trying to submit to a route that does not have an action. To " +
2894
- "fix this, please add an `action` function to the route for " +
2895
- `[${href}]`
2992
+ return new ErrorResponse(
2993
+ status || 500,
2994
+ statusText,
2995
+ new Error(errorMessage),
2996
+ true
2896
2997
  );
2897
- return {
2898
- type: ResultType.error,
2899
- error: new ErrorResponse(405, "Method Not Allowed", ""),
2900
- };
2901
2998
  }
2902
2999
 
2903
3000
  // Find any returned redirect errors, starting from the lowest match
@@ -2951,6 +3048,14 @@ function isQueryRouteResponse(obj: any): obj is QueryRouteResponse {
2951
3048
  );
2952
3049
  }
2953
3050
 
3051
+ function isValidMethod(method: string): method is FormMethod {
3052
+ return validRequestMethods.has(method as FormMethod);
3053
+ }
3054
+
3055
+ function isSubmissionMethod(method: string): method is SubmissionFormMethod {
3056
+ return validActionMethods.has(method as SubmissionFormMethod);
3057
+ }
3058
+
2954
3059
  async function resolveDeferredResults(
2955
3060
  currentMatches: AgnosticDataRouteMatch[],
2956
3061
  matchesToLoad: AgnosticDataRouteMatch[],