@remix-run/router 1.0.3 → 1.0.4-pre.1

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,33 @@ 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
+ if (replace) {
1608
+ window.location.replace(redirect.location);
1609
+ } else {
1610
+ window.location.assign(redirect.location);
1611
+ }
1612
+ return;
1613
+ }
1614
+
1569
1615
  // There's no need to abort on redirects, since we don't detect the
1570
1616
  // redirect until the action/loaders have settled
1571
1617
  pendingNavigationController = null;
@@ -1573,9 +1619,40 @@ export function createRouter(init: RouterInit): Router {
1573
1619
  let redirectHistoryAction =
1574
1620
  replace === true ? HistoryAction.Replace : HistoryAction.Push;
1575
1621
 
1576
- await startNavigation(redirectHistoryAction, navigation.location, {
1577
- overrideNavigation: navigation,
1578
- });
1622
+ let { formMethod, formAction, formEncType, formData } = state.navigation;
1623
+
1624
+ // If this was a 307/308 submission we want to preserve the HTTP method and
1625
+ // re-submit the POST/PUT/PATCH/DELETE as a submission navigation to the
1626
+ // redirected location
1627
+ if (
1628
+ redirectPreserveMethodStatusCodes.has(redirect.status) &&
1629
+ formMethod &&
1630
+ isSubmissionMethod(formMethod) &&
1631
+ formEncType &&
1632
+ formData
1633
+ ) {
1634
+ await startNavigation(redirectHistoryAction, redirectLocation, {
1635
+ submission: {
1636
+ formMethod,
1637
+ formAction: redirect.location,
1638
+ formEncType,
1639
+ formData,
1640
+ },
1641
+ });
1642
+ } else {
1643
+ // Otherwise, we kick off a new loading navigation, preserving the
1644
+ // submission info for the duration of this navigation
1645
+ await startNavigation(redirectHistoryAction, redirectLocation, {
1646
+ overrideNavigation: {
1647
+ state: "loading",
1648
+ location: redirectLocation,
1649
+ formMethod: formMethod || undefined,
1650
+ formAction: formAction || undefined,
1651
+ formEncType: formEncType || undefined,
1652
+ formData: formData || undefined,
1653
+ },
1654
+ });
1655
+ }
1579
1656
  }
1580
1657
 
1581
1658
  async function callLoadersAndMaybeResolveData(
@@ -1809,6 +1886,7 @@ export function createRouter(init: RouterInit): Router {
1809
1886
  // Passthrough to history-aware createHref used by useHref so we get proper
1810
1887
  // hash-aware URLs in DOM paths
1811
1888
  createHref: (to: To) => init.history.createHref(to),
1889
+ encodeLocation: (to: To) => init.history.encodeLocation(to),
1812
1890
  getFetcher,
1813
1891
  deleteFetcher,
1814
1892
  dispose,
@@ -1824,11 +1902,11 @@ export function createRouter(init: RouterInit): Router {
1824
1902
  //#region createStaticHandler
1825
1903
  ////////////////////////////////////////////////////////////////////////////////
1826
1904
 
1827
- const validActionMethods = new Set(["POST", "PUT", "PATCH", "DELETE"]);
1828
- const validRequestMethods = new Set(["GET", "HEAD", ...validActionMethods]);
1829
-
1830
1905
  export function unstable_createStaticHandler(
1831
- routes: AgnosticRouteObject[]
1906
+ routes: AgnosticRouteObject[],
1907
+ opts?: {
1908
+ basename?: string;
1909
+ }
1832
1910
  ): StaticHandler {
1833
1911
  invariant(
1834
1912
  routes.length > 0,
@@ -1836,6 +1914,7 @@ export function unstable_createStaticHandler(
1836
1914
  );
1837
1915
 
1838
1916
  let dataRoutes = convertRoutesToDataRoutes(routes);
1917
+ let basename = (opts ? opts.basename : null) || "/";
1839
1918
 
1840
1919
  /**
1841
1920
  * The query() method is intended for document requests, in which we want to
@@ -1860,16 +1939,17 @@ export function unstable_createStaticHandler(
1860
1939
  request: Request
1861
1940
  ): Promise<StaticHandlerContext | Response> {
1862
1941
  let url = new URL(request.url);
1942
+ let method = request.method.toLowerCase();
1863
1943
  let location = createLocation("", createPath(url), null, "default");
1864
- let matches = matchRoutes(dataRoutes, location);
1944
+ let matches = matchRoutes(dataRoutes, location, basename);
1865
1945
 
1866
- if (!validRequestMethods.has(request.method)) {
1867
- let {
1868
- matches: methodNotAllowedMatches,
1869
- route,
1870
- error,
1871
- } = getMethodNotAllowedMatches(dataRoutes);
1946
+ // SSR supports HEAD requests while SPA doesn't
1947
+ if (!isValidMethod(method) && method !== "head") {
1948
+ let error = getInternalRouterError(405, { method });
1949
+ let { matches: methodNotAllowedMatches, route } =
1950
+ getShortCircuitMatches(dataRoutes);
1872
1951
  return {
1952
+ basename,
1873
1953
  location,
1874
1954
  matches: methodNotAllowedMatches,
1875
1955
  loaderData: {},
@@ -1882,12 +1962,11 @@ export function unstable_createStaticHandler(
1882
1962
  actionHeaders: {},
1883
1963
  };
1884
1964
  } else if (!matches) {
1885
- let {
1886
- matches: notFoundMatches,
1887
- route,
1888
- error,
1889
- } = getNotFoundMatches(dataRoutes);
1965
+ let error = getInternalRouterError(404, { pathname: location.pathname });
1966
+ let { matches: notFoundMatches, route } =
1967
+ getShortCircuitMatches(dataRoutes);
1890
1968
  return {
1969
+ basename,
1891
1970
  location,
1892
1971
  matches: notFoundMatches,
1893
1972
  loaderData: {},
@@ -1909,7 +1988,7 @@ export function unstable_createStaticHandler(
1909
1988
  // When returning StaticHandlerContext, we patch back in the location here
1910
1989
  // since we need it for React Context. But this helps keep our submit and
1911
1990
  // loadRouteData operating on a Request instead of a Location
1912
- return { location, ...result };
1991
+ return { location, basename, ...result };
1913
1992
  }
1914
1993
 
1915
1994
  /**
@@ -1925,36 +2004,38 @@ export function unstable_createStaticHandler(
1925
2004
  * can do proper boundary identification in Remix where a thrown Response
1926
2005
  * must go to the Catch Boundary but a returned Response is happy-path.
1927
2006
  *
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.
2007
+ * One thing to note is that any Router-initiated Errors that make sense
2008
+ * to associate with a status code will be thrown as an ErrorResponse
2009
+ * instance which include the raw Error, such that the calling context can
2010
+ * serialize the error as they see fit while including the proper response
2011
+ * code. Examples here are 404 and 405 errors that occur prior to reaching
2012
+ * any user-defined loaders.
1931
2013
  */
1932
2014
  async function queryRoute(request: Request, routeId?: string): Promise<any> {
1933
2015
  let url = new URL(request.url);
2016
+ let method = request.method.toLowerCase();
1934
2017
  let location = createLocation("", createPath(url), null, "default");
1935
- let matches = matchRoutes(dataRoutes, location);
2018
+ let matches = matchRoutes(dataRoutes, location, basename);
1936
2019
 
1937
- if (!validRequestMethods.has(request.method)) {
1938
- throw createRouterErrorResponse(null, {
1939
- status: 405,
1940
- statusText: "Method Not Allowed",
1941
- });
2020
+ // SSR supports HEAD requests while SPA doesn't
2021
+ if (!isValidMethod(method) && method !== "head") {
2022
+ throw getInternalRouterError(405, { method });
1942
2023
  } else if (!matches) {
1943
- throw createRouterErrorResponse(null, {
1944
- status: 404,
1945
- statusText: "Not Found",
1946
- });
2024
+ throw getInternalRouterError(404, { pathname: location.pathname });
1947
2025
  }
1948
2026
 
1949
2027
  let match = routeId
1950
2028
  ? matches.find((m) => m.route.id === routeId)
1951
2029
  : getTargetMatch(matches, location);
1952
2030
 
1953
- if (!match) {
1954
- throw createRouterErrorResponse(null, {
1955
- status: 404,
1956
- statusText: "Not Found",
2031
+ if (routeId && !match) {
2032
+ throw getInternalRouterError(403, {
2033
+ pathname: location.pathname,
2034
+ routeId,
1957
2035
  });
2036
+ } else if (!match) {
2037
+ // This should never hit I don't think?
2038
+ throw getInternalRouterError(404, { pathname: location.pathname });
1958
2039
  }
1959
2040
 
1960
2041
  let result = await queryImpl(request, location, matches, match);
@@ -1981,14 +2062,14 @@ export function unstable_createStaticHandler(
1981
2062
  location: Location,
1982
2063
  matches: AgnosticDataRouteMatch[],
1983
2064
  routeMatch?: AgnosticDataRouteMatch
1984
- ): Promise<Omit<StaticHandlerContext, "location"> | Response> {
2065
+ ): Promise<Omit<StaticHandlerContext, "location" | "basename"> | Response> {
1985
2066
  invariant(
1986
2067
  request.signal,
1987
2068
  "query()/queryRoute() requests must contain an AbortController signal"
1988
2069
  );
1989
2070
 
1990
2071
  try {
1991
- if (validActionMethods.has(request.method)) {
2072
+ if (isSubmissionMethod(request.method.toLowerCase())) {
1992
2073
  let result = await submit(
1993
2074
  request,
1994
2075
  matches,
@@ -2030,23 +2111,29 @@ export function unstable_createStaticHandler(
2030
2111
  matches: AgnosticDataRouteMatch[],
2031
2112
  actionMatch: AgnosticDataRouteMatch,
2032
2113
  isRouteRequest: boolean
2033
- ): Promise<Omit<StaticHandlerContext, "location"> | Response> {
2114
+ ): Promise<Omit<StaticHandlerContext, "location" | "basename"> | Response> {
2034
2115
  let result: DataResult;
2116
+
2035
2117
  if (!actionMatch.route.action) {
2118
+ let error = getInternalRouterError(405, {
2119
+ method: request.method,
2120
+ pathname: createURL(request.url).pathname,
2121
+ routeId: actionMatch.route.id,
2122
+ });
2036
2123
  if (isRouteRequest) {
2037
- throw createRouterErrorResponse(null, {
2038
- status: 405,
2039
- statusText: "Method Not Allowed",
2040
- });
2124
+ throw error;
2041
2125
  }
2042
- result = getMethodNotAllowedResult(request.url);
2126
+ result = {
2127
+ type: ResultType.error,
2128
+ error,
2129
+ };
2043
2130
  } else {
2044
2131
  result = await callLoaderOrAction(
2045
2132
  "action",
2046
2133
  request,
2047
2134
  actionMatch,
2048
2135
  matches,
2049
- undefined, // Basename not currently supported in static handlers
2136
+ basename,
2050
2137
  true,
2051
2138
  isRouteRequest
2052
2139
  );
@@ -2078,20 +2165,7 @@ export function unstable_createStaticHandler(
2078
2165
  // Note: This should only be non-Response values if we get here, since
2079
2166
  // isRouteRequest should throw any Response received in callLoaderOrAction
2080
2167
  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
- };
2168
+ throw result.error;
2095
2169
  }
2096
2170
 
2097
2171
  return {
@@ -2149,10 +2223,23 @@ export function unstable_createStaticHandler(
2149
2223
  routeMatch?: AgnosticDataRouteMatch,
2150
2224
  pendingActionError?: RouteData
2151
2225
  ): Promise<
2152
- | Omit<StaticHandlerContext, "location" | "actionData" | "actionHeaders">
2226
+ | Omit<
2227
+ StaticHandlerContext,
2228
+ "location" | "basename" | "actionData" | "actionHeaders"
2229
+ >
2153
2230
  | Response
2154
2231
  > {
2155
2232
  let isRouteRequest = routeMatch != null;
2233
+
2234
+ // Short circuit if we have no loaders to run (queryRoute())
2235
+ if (isRouteRequest && !routeMatch?.route.loader) {
2236
+ throw getInternalRouterError(400, {
2237
+ method: request.method,
2238
+ pathname: createURL(request.url).pathname,
2239
+ routeId: routeMatch?.route.id,
2240
+ });
2241
+ }
2242
+
2156
2243
  let requestMatches = routeMatch
2157
2244
  ? [routeMatch]
2158
2245
  : getLoaderMatchesUntilBoundary(
@@ -2161,7 +2248,7 @@ export function unstable_createStaticHandler(
2161
2248
  );
2162
2249
  let matchesToLoad = requestMatches.filter((m) => m.route.loader);
2163
2250
 
2164
- // Short circuit if we have no loaders to run
2251
+ // Short circuit if we have no loaders to run (query())
2165
2252
  if (matchesToLoad.length === 0) {
2166
2253
  return {
2167
2254
  matches,
@@ -2179,7 +2266,7 @@ export function unstable_createStaticHandler(
2179
2266
  request,
2180
2267
  match,
2181
2268
  matches,
2182
- undefined, // Basename not currently supported in static handlers
2269
+ basename,
2183
2270
  true,
2184
2271
  isRouteRequest
2185
2272
  )
@@ -2213,19 +2300,6 @@ export function unstable_createStaticHandler(
2213
2300
  };
2214
2301
  }
2215
2302
 
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
2303
  return {
2230
2304
  dataRoutes,
2231
2305
  query,
@@ -2258,6 +2332,12 @@ export function getStaticContextFromError(
2258
2332
  return newContext;
2259
2333
  }
2260
2334
 
2335
+ function isSubmissionNavigation(
2336
+ opts: RouterNavigateOptions
2337
+ ): opts is SubmissionNavigateOptions {
2338
+ return opts != null && "formData" in opts;
2339
+ }
2340
+
2261
2341
  // Normalize navigation options by converting formMethod=GET formData objects to
2262
2342
  // URLSearchParams so they behave identically to links with query params
2263
2343
  function normalizeNavigateOptions(
@@ -2272,12 +2352,19 @@ function normalizeNavigateOptions(
2272
2352
  let path = typeof to === "string" ? to : createPath(to);
2273
2353
 
2274
2354
  // Return location verbatim on non-submission navigations
2275
- if (!opts || (!("formMethod" in opts) && !("formData" in opts))) {
2355
+ if (!opts || !isSubmissionNavigation(opts)) {
2276
2356
  return { path };
2277
2357
  }
2278
2358
 
2359
+ if (opts.formMethod && !isValidMethod(opts.formMethod)) {
2360
+ return {
2361
+ path,
2362
+ error: getInternalRouterError(405, { method: opts.formMethod }),
2363
+ };
2364
+ }
2365
+
2279
2366
  // Create a Submission on non-GET navigations
2280
- if (opts.formMethod != null && opts.formMethod !== "get") {
2367
+ if (opts.formMethod && isSubmissionMethod(opts.formMethod)) {
2281
2368
  return {
2282
2369
  path,
2283
2370
  submission: {
@@ -2290,11 +2377,6 @@ function normalizeNavigateOptions(
2290
2377
  };
2291
2378
  }
2292
2379
 
2293
- // No formData to flatten for GET submission
2294
- if (!opts.formData) {
2295
- return { path };
2296
- }
2297
-
2298
2380
  // Flatten submission onto URLSearchParams for GET submissions
2299
2381
  let parsedPath = parsePath(path);
2300
2382
  try {
@@ -2313,33 +2395,13 @@ function normalizeNavigateOptions(
2313
2395
  } catch (e) {
2314
2396
  return {
2315
2397
  path,
2316
- error: new ErrorResponse(
2317
- 400,
2318
- "Bad Request",
2319
- "Cannot submit binary form data using GET"
2320
- ),
2398
+ error: getInternalRouterError(400),
2321
2399
  };
2322
2400
  }
2323
2401
 
2324
2402
  return { path: createPath(parsedPath) };
2325
2403
  }
2326
2404
 
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
2405
  // Filter out all routes below any caught error as they aren't going to
2344
2406
  // render so we don't need to load them
2345
2407
  function getLoaderMatchesUntilBoundary(
@@ -2507,7 +2569,7 @@ async function callLoaderOrAction(
2507
2569
  request: Request,
2508
2570
  match: AgnosticDataRouteMatch,
2509
2571
  matches: AgnosticDataRouteMatch[],
2510
- basename: string | undefined,
2572
+ basename = "/",
2511
2573
  isStaticRequest: boolean = false,
2512
2574
  isRouteRequest: boolean = false
2513
2575
  ): Promise<DataResult> {
@@ -2531,6 +2593,13 @@ async function callLoaderOrAction(
2531
2593
  handler({ request, params: match.params }),
2532
2594
  abortPromise,
2533
2595
  ]);
2596
+
2597
+ invariant(
2598
+ result !== undefined,
2599
+ `You defined ${type === "action" ? "an action" : "a loader"} for route ` +
2600
+ `"${match.route.id}" but didn't return anything from your \`${type}\` ` +
2601
+ `function. Please return a value or \`null\`.`
2602
+ );
2534
2603
  } catch (e) {
2535
2604
  resultType = ResultType.error;
2536
2605
  result = e;
@@ -2542,33 +2611,38 @@ async function callLoaderOrAction(
2542
2611
  let status = result.status;
2543
2612
 
2544
2613
  // Process redirects
2545
- if (status >= 300 && status <= 399) {
2614
+ if (redirectStatusCodes.has(status)) {
2546
2615
  let location = result.headers.get("Location");
2547
2616
  invariant(
2548
2617
  location,
2549
2618
  "Redirects returned/thrown from loaders/actions must have a Location header"
2550
2619
  );
2551
2620
 
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
- );
2621
+ // Check if this an external redirect that goes to a new origin
2622
+ let external = createURL(location).origin !== createURL("/").origin;
2563
2623
 
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
- }
2624
+ // Support relative routing in internal redirects
2625
+ if (!external) {
2626
+ let activeMatches = matches.slice(0, matches.indexOf(match) + 1);
2627
+ let routePathnames = getPathContributingMatches(activeMatches).map(
2628
+ (match) => match.pathnameBase
2629
+ );
2630
+ let requestPath = createURL(request.url).pathname;
2631
+ let resolvedLocation = resolveTo(location, routePathnames, requestPath);
2632
+ invariant(
2633
+ createPath(resolvedLocation),
2634
+ `Unable to resolve redirect location: ${location}`
2635
+ );
2570
2636
 
2571
- location = createPath(resolvedLocation);
2637
+ // Prepend the basename to the redirect location if we have one
2638
+ if (basename) {
2639
+ let path = resolvedLocation.pathname;
2640
+ resolvedLocation.pathname =
2641
+ path === "/" ? basename : joinPaths([basename, path]);
2642
+ }
2643
+
2644
+ location = createPath(resolvedLocation);
2645
+ }
2572
2646
 
2573
2647
  // Don't process redirects in the router during static requests requests.
2574
2648
  // Instead, throw the Response and let the server handle it with an HTTP
@@ -2584,6 +2658,7 @@ async function callLoaderOrAction(
2584
2658
  status,
2585
2659
  location,
2586
2660
  revalidate: result.headers.get("X-Remix-Revalidate") !== null,
2661
+ external,
2587
2662
  };
2588
2663
  }
2589
2664
 
@@ -2851,18 +2926,13 @@ function findNearestBoundary(
2851
2926
  );
2852
2927
  }
2853
2928
 
2854
- function getShortCircuitMatches(
2855
- routes: AgnosticDataRouteObject[],
2856
- status: number,
2857
- statusText: string
2858
- ): {
2929
+ function getShortCircuitMatches(routes: AgnosticDataRouteObject[]): {
2859
2930
  matches: AgnosticDataRouteMatch[];
2860
2931
  route: AgnosticDataRouteObject;
2861
- error: ErrorResponse;
2862
2932
  } {
2863
2933
  // Prefer a root layout route if present, otherwise shim in a route object
2864
2934
  let route = routes.find((r) => r.index || !r.path || r.path === "/") || {
2865
- id: `__shim-${status}-route__`,
2935
+ id: `__shim-error-route__`,
2866
2936
  };
2867
2937
 
2868
2938
  return {
@@ -2875,29 +2945,60 @@ function getShortCircuitMatches(
2875
2945
  },
2876
2946
  ],
2877
2947
  route,
2878
- error: new ErrorResponse(status, statusText, null),
2879
2948
  };
2880
2949
  }
2881
2950
 
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
- }
2951
+ function getInternalRouterError(
2952
+ status: number,
2953
+ {
2954
+ pathname,
2955
+ routeId,
2956
+ method,
2957
+ message,
2958
+ }: {
2959
+ pathname?: string;
2960
+ routeId?: string;
2961
+ method?: string;
2962
+ message?: string;
2963
+ } = {}
2964
+ ) {
2965
+ let statusText = "Unknown Server Error";
2966
+ let errorMessage = "Unknown @remix-run/router error";
2967
+
2968
+ if (status === 400) {
2969
+ statusText = "Bad Request";
2970
+ if (method && pathname && routeId) {
2971
+ errorMessage =
2972
+ `You made a ${method} request to "${pathname}" but ` +
2973
+ `did not provide a \`loader\` for route "${routeId}", ` +
2974
+ `so there is no way to handle the request.`;
2975
+ } else {
2976
+ errorMessage = "Cannot submit binary form data using GET";
2977
+ }
2978
+ } else if (status === 403) {
2979
+ statusText = "Forbidden";
2980
+ errorMessage = `Route "${routeId}" does not match URL "${pathname}"`;
2981
+ } else if (status === 404) {
2982
+ statusText = "Not Found";
2983
+ errorMessage = `No route matches URL "${pathname}"`;
2984
+ } else if (status === 405) {
2985
+ statusText = "Method Not Allowed";
2986
+ if (method && pathname && routeId) {
2987
+ errorMessage =
2988
+ `You made a ${method.toUpperCase()} request to "${pathname}" but ` +
2989
+ `did not provide an \`action\` for route "${routeId}", ` +
2990
+ `so there is no way to handle the request.`;
2991
+ } else if (method) {
2992
+ errorMessage = `Invalid request method "${method.toUpperCase()}"`;
2993
+ }
2994
+ }
2889
2995
 
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}]`
2996
+ return new ErrorResponse(
2997
+ status || 500,
2998
+ statusText,
2999
+ new Error(errorMessage),
3000
+ true
2896
3001
  );
2897
- return {
2898
- type: ResultType.error,
2899
- error: new ErrorResponse(405, "Method Not Allowed", ""),
2900
- };
2901
3002
  }
2902
3003
 
2903
3004
  // Find any returned redirect errors, starting from the lowest match
@@ -2951,6 +3052,14 @@ function isQueryRouteResponse(obj: any): obj is QueryRouteResponse {
2951
3052
  );
2952
3053
  }
2953
3054
 
3055
+ function isValidMethod(method: string): method is FormMethod {
3056
+ return validRequestMethods.has(method as FormMethod);
3057
+ }
3058
+
3059
+ function isSubmissionMethod(method: string): method is SubmissionFormMethod {
3060
+ return validActionMethods.has(method as SubmissionFormMethod);
3061
+ }
3062
+
2954
3063
  async function resolveDeferredResults(
2955
3064
  currentMatches: AgnosticDataRouteMatch[],
2956
3065
  matchesToLoad: AgnosticDataRouteMatch[],