Repository: remix-run/remix
Pull request #4385 (2022-10-18 22:25:09)

feat(remix-server-runtime): RRR 1.3 / 1.4 - handleDocumentRequest
[decision doc](https://github-md.com/remix-run/remix/blob/dev/decisions/0007-remix-on-react-router-6-4-0.md#decision)

Part 3 and 4 of step 1 in layering Remix back on top of React Router now that we have released `@remix-run/router`.

Changes:

- Implement handleDocumentRequest on top of RR alongside existing implementation
- Disambiguate existing internal errors surfacing to CatchBoundary

Closes: #

- [ ] Docs
- [x] Tests

Testing Strategy:

Assert that existing tests pass and manually check new code is not in non-experimental production builds.


changeset-bot[bot]: ###  ⚠️  No Changeset found

Latest commit: 9d0d6e37a4f4add7efc4f66ccc6ca2fdb8c413b7

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/master/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/remix-run/remix/new/rrr-document-requests-1?filename=.changeset/soft-camels-notice.md&value=---%0A%22remix%22%3A%20patch%0A%22%40remix-run%2Fserve%22%3A%20patch%0A%22%40remix-run%2Fserver-runtime%22%3A%20patch%0A---%0A%0Ainitial%20work%20for%20document%20requests%0A)


jacob-ebey: > Just curious, what's this going to do? Remix already serves document requests for SSR so I suppose is another thing

All of the data loading / error handling logic is no longer going to be owned in the server-runtime, but rather by the router package. Essentially this now calls `router.query(request)` and that's it™️
machour: @brophdawg11 is there a decision doc for this work? Would help me follow along
brophdawg11: @machour Yep!  https://github.com/remix-run/remix/blob/main/decisions/0007-remix-on-react-router-6-4-0.md - this PR is steps `1.iii`/`1.iv` under `Decision` - steps `1.i`/`1.ii` were already merged in #4245 and #4359 (behind the compile-time flag)
brophdawg11: Need to add a changeset to describe the changes in error/catch boundary behaviors in Remix.  Merging this PR will enable those even without the flag enabled.
brophdawg11: I think this is good to go now?  Let's touch base with @chaance and make sure that we're good to merge this (since he already cut the `1.7.6` release branch).  This code in dev, without the feature flag enabled, would just result in the minor fixes to catch/error boundary handling and could be part of a `1.7.7` if needed.  But the plan is to enable the feature flag for a `1.8.0-pre.0` later this week or early next week.

Patch:
diff --git a/.changeset/light-avocados-drop.md b/.changeset/light-avocados-drop.md
new file mode 100644
index 000000000..8be5277aa
--- /dev/null
+++ b/.changeset/light-avocados-drop.md
@@ -0,0 +1,26 @@
+---
+"@remix-run/server-runtime": patch
+---
+
+fix: Properly categorize internal framework-thrown error Responses as error boundary errors
+
+Previously there was some ambiguity around _"thrown Responses go to the `CatchBoundary`"_.
+The `CatchBoundary` exists to give the _user_ a place to handle non-happy path code flows
+such that they can throw Response instances from _their own code_ and handle them in a
+`CatchBoundary`.  However, there are a handful of framework-internal errors that make
+sense to have a non-500 status code, and the fact that these were being thrown as Responses
+was causing them to go into the CatchBoundary, even though they were not user-thrown.
+
+With this change, anything thrown by the framework itself (`Error` or `Response`) will
+go to the `ErrorBoundary`, and any user-thrown `Response` instances will go to the
+`CatchBoundary`.  Thereis  one exception to this rule, which is that framework-detected
+404's will continue to go to the `CatchBoundary` since users should have one single
+location to handle 404 displays.
+
+The primary affected use cases are scenarios such as:
+
+* HTTP `OPTIONS` requests (405 Unsupported Method )
+* `GET` requests to routes without loaders (400 Bad Request)
+* `POST` requests to routes without actions (405 Method Not Allowed)
+* Missing route id in `_data` parameters (403 Forbidden)
+* Non-matching route id included in `_data` parameters (403 Forbidden)
diff --git a/packages/remix-react/routes.tsx b/packages/remix-react/routes.tsx
index 5873e13bf..af9361dd9 100644
--- a/packages/remix-react/routes.tsx
+++ b/packages/remix-react/routes.tsx
@@ -148,25 +148,30 @@ async function loadRouteModuleWithBlockingLinks(
 function createLoader(route: EntryRoute, routeModules: RouteModules) {
   let loader: ClientRoute["loader"] = async ({ url, signal, submission }) => {
     if (route.hasLoader) {
-      let [result] = await Promise.all([
-        fetchData(url, route.id, signal, submission),
-        loadRouteModuleWithBlockingLinks(route, routeModules),
-      ]);
+      let routeModulePromise = loadRouteModuleWithBlockingLinks(
+        route,
+        routeModules
+      );
+      try {
+        let result = await fetchData(url, route.id, signal, submission);
 
-      if (result instanceof Error) throw result;
+        if (result instanceof Error) throw result;
 
-      let redirect = await checkRedirect(result);
-      if (redirect) return redirect;
+        let redirect = await checkRedirect(result);
+        if (redirect) return redirect;
 
-      if (isCatchResponse(result)) {
-        throw new CatchValue(
-          result.status,
-          result.statusText,
-          await extractData(result)
-        );
-      }
+        if (isCatchResponse(result)) {
+          throw new CatchValue(
+            result.status,
+            result.statusText,
+            await extractData(result)
+          );
+        }
 
-      return extractData(result);
+        return extractData(result);
+      } finally {
+        await routeModulePromise;
+      }
     } else {
       await loadRouteModuleWithBlockingLinks(route, routeModules);
     }
@@ -177,33 +182,40 @@ function createLoader(route: EntryRoute, routeModules: RouteModules) {
 
 function createAction(route: EntryRoute, routeModules: RouteModules) {
   let action: ClientRoute["action"] = async ({ url, signal, submission }) => {
-    if (!route.hasAction) {
-      console.error(
-        `Route "${route.id}" does not have an action, but you are trying ` +
-          `to submit to it. To fix this, please add an \`action\` function to the route`
-      );
-    }
+    let routeModulePromise = await loadRouteModuleWithBlockingLinks(
+      route,
+      routeModules
+    );
+
+    try {
+      if (!route.hasAction) {
+        console.error(
+          `Route "${route.id}" does not have an action, but you are trying ` +
+            `to submit to it. To fix this, please add an \`action\` function to the route`
+        );
+      }
 
-    let result = await fetchData(url, route.id, signal, submission);
+      let result = await fetchData(url, route.id, signal, submission);
 
-    if (result instanceof Error) {
-      throw result;
-    }
+      if (result instanceof Error) {
+        throw result;
+      }
 
-    let redirect = await checkRedirect(result);
-    if (redirect) return redirect;
+      let redirect = await checkRedirect(result);
+      if (redirect) return redirect;
 
-    await loadRouteModuleWithBlockingLinks(route, routeModules);
+      if (isCatchResponse(result)) {
+        throw new CatchValue(
+          result.status,
+          result.statusText,
+          await extractData(result)
+        );
+      }
 
-    if (isCatchResponse(result)) {
-      throw new CatchValue(
-        result.status,
-        result.statusText,
-        await extractData(result)
-      );
+      return extractData(result);
+    } finally {
+      await routeModulePromise;
     }
-
-    return extractData(result);
   };
 
   return action;
diff --git a/packages/remix-server-runtime/data.ts b/packages/remix-server-runtime/data.ts
index 0a7d1803a..d5daab04a 100644
--- a/packages/remix-server-runtime/data.ts
+++ b/packages/remix-server-runtime/data.ts
@@ -4,6 +4,7 @@ import type {
   DataFunctionArgs,
   LoaderFunction,
 } from "./routeModules";
+import { ErrorResponse } from "./router";
 
 /**
  * An object of unknown type for route loaders and actions provided by the
@@ -32,9 +33,11 @@ export async function callRouteAction({
   request: Request;
 }) {
   if (!action) {
-    let response = new Response(null, { status: 405 });
-    response.headers.set("X-Remix-Catch", "yes");
-    return response;
+    let pathname = new URL(request.url).pathname;
+    let msg =
+      `You made a ${request.method} request to "${pathname}" but did not provide ` +
+      `an \`action\` for route "${routeId}", so there is no way to handle the request.`;
+    throw new ErrorResponse(405, "Method Not Allowed", new Error(msg), true);
   }
 
   let result;
@@ -79,11 +82,11 @@ export async function callRouteLoader({
   loadContext: AppLoadContext;
 }) {
   if (!loader) {
-    throw new Error(
-      `You made a ${request.method} request to ${request.url} but did not provide ` +
-        `a default component or \`loader\` for route "${routeId}", ` +
-        `so there is no way to handle the request.`
-    );
+    let pathname = new URL(request.url).pathname;
+    let msg =
+      `You made a ${request.method} request to "${pathname}" but did not provide ` +
+      `a \`loader\` for route "${routeId}", so there is no way to handle the request.`;
+    throw new ErrorResponse(400, "Bad Request", new Error(msg), true);
   }
 
   let result;
@@ -114,6 +117,64 @@ export async function callRouteLoader({
   return isResponse(result) ? result : json(result);
 }
 
+export async function callRouteActionRR({
+  loadContext,
+  action,
+  params,
+  request,
+  routeId,
+}: {
+  request: Request;
+  action: ActionFunction;
+  params: DataFunctionArgs["params"];
+  loadContext: AppLoadContext;
+  routeId: string;
+}) {
+  let result = await action({
+    request: stripDataParam(stripIndexParam(request)),
+    context: loadContext,
+    params,
+  });
+
+  if (result === undefined) {
+    throw new Error(
+      `You defined an action for route "${routeId}" but didn't return ` +
+        `anything from your \`action\` function. Please return a value or \`null\`.`
+    );
+  }
+
+  return isResponse(result) ? result : json(result);
+}
+
+export async function callRouteLoaderRR({
+  loadContext,
+  loader,
+  params,
+  request,
+  routeId,
+}: {
+  request: Request;
+  loader: LoaderFunction;
+  params: DataFunctionArgs["params"];
+  loadContext: AppLoadContext;
+  routeId: string;
+}) {
+  let result = await loader({
+    request: stripDataParam(stripIndexParam(request)),
+    context: loadContext,
+    params,
+  });
+
+  if (result === undefined) {
+    throw new Error(
+      `You defined a loader for route "${routeId}" but didn't return ` +
+        `anything from your \`loader\` function. Please return a value or \`null\`.`
+    );
+  }
+
+  return isResponse(result) ? result : json(result);
+}
+
 function stripIndexParam(request: Request) {
   let url = new URL(request.url);
   let indexValues = url.searchParams.getAll("index");
diff --git a/packages/remix-server-runtime/headers.ts b/packages/remix-server-runtime/headers.ts
index 19b494266..bf05eea66 100644
--- a/packages/remix-server-runtime/headers.ts
+++ b/packages/remix-server-runtime/headers.ts
@@ -3,6 +3,7 @@ import { splitCookiesString } from "set-cookie-parser";
 import type { ServerBuild } from "./build";
 import type { ServerRoute } from "./routes";
 import type { RouteMatch } from "./routeMatching";
+import type { StaticHandlerContext } from "./router";
 
 export function getDocumentHeaders(
   build: ServerBuild,
@@ -35,6 +36,34 @@ export function getDocumentHeaders(
   }, new Headers());
 }
 
+export function getDocumentHeadersRR(
+  build: ServerBuild,
+  context: StaticHandlerContext,
+  matches: RouteMatch<ServerRoute>[]
+) {
+  return matches.reduce((parentHeaders, match, index) => {
+    let { id } = match.route;
+    let routeModule = build.routes[id].module;
+    let loaderHeaders = context.loaderHeaders?.[id] || new Headers();
+    let actionHeaders = context.actionHeaders?.[id] || new Headers();
+    let headers = new Headers(
+      routeModule.headers
+        ? typeof routeModule.headers === "function"
+          ? routeModule.headers({ loaderHeaders, parentHeaders, actionHeaders })
+          : routeModule.headers
+        : undefined
+    );
+
+    // Automatically preserve Set-Cookie headers that were set either by the
+    // loader or by a parent route.
+    prependCookies(actionHeaders, headers);
+    prependCookies(loaderHeaders, headers);
+    prependCookies(parentHeaders, headers);
+
+    return headers;
+  }, new Headers());
+}
+
 function prependCookies(parentHeaders: Headers, childHeaders: Headers): void {
   let parentSetCookieString = parentHeaders.get("Set-Cookie");
 
diff --git a/packages/remix-server-runtime/router/history.ts b/packages/remix-server-runtime/router/history.ts
index 7cb1dc95d..1113ac6cd 100644
--- a/packages/remix-server-runtime/router/history.ts
+++ b/packages/remix-server-runtime/router/history.ts
@@ -1,4 +1,5 @@
 // @ts-nocheck
+/* eslint-disable */
 
 ////////////////////////////////////////////////////////////////////////////////
 //#region Types and Constants
@@ -127,6 +128,15 @@ export interface History {
    */
   createHref(to: To): string;
 
+  /**
+   * Encode a location the same way window.history would do (no-op for memory
+   * history) so we ensure our PUSH/REPLAC e navigations for data routers
+   * behave the same as POP
+   *
+   * @param location The incoming location from router.navigate()
+   */
+  encodeLocation(location: Location): Location;
+
   /**
    * Pushes a new location onto the history stack, increasing its length by one.
    * If there were any entries in the stack after the current one, they are
@@ -261,6 +271,9 @@ export function createMemoryHistory(
     createHref(to) {
       return typeof to === "string" ? to : createPath(to);
     },
+    encodeLocation(location) {
+      return location;
+    },
     push(to, state) {
       action = Action.Push;
       let nextLocation = createMemoryLocation(to, state);
@@ -529,6 +542,20 @@ export function parsePath(path: string): Partial<Path> {
   return parsedPath;
 }
 
+export function createURL(location: Location | string): URL {
+  // window.location.origin is "null" (the literal string value) in Firefox
+  // under certain conditions, notably when serving from a local HTML file
+  // See https://bugzilla.mozilla.org/show_bug.cgi?id=878297
+  let base =
+    typeof window !== "undefined" &&
+    typeof window.location !== "undefined" &&
+    window.location.origin !== "null"
+      ? window.location.origin
+      : "unknown://unknown";
+  let href = typeof location === "string" ? location : createPath(location);
+  return new URL(href, base);
+}
+
 export interface UrlHistory extends History {}
 
 export type UrlHistoryOptions = {
@@ -572,7 +599,7 @@ function getUrlBasedHistory(
     }
 
     if (v5Compat && listener) {
-      listener({ action, location });
+      listener({ action, location: history.location });
     }
   }
 
@@ -586,7 +613,7 @@ function getUrlBasedHistory(
     globalHistory.replaceState(historyState, "", url);
 
     if (v5Compat && listener) {
-      listener({ action, location: location });
+      listener({ action, location: history.location });
     }
   }
 
@@ -612,6 +639,16 @@ function getUrlBasedHistory(
     createHref(to) {
       return createHref(window, to);
     },
+    encodeLocation(location) {
+      // Encode a Location the same way window.location would
+      let url = createURL(createPath(location));
+      return {
+        ...location,
+        pathname: url.pathname,
+        search: url.search,
+        hash: url.hash,
+      };
+    },
     push,
     replace,
     go(n) {
diff --git a/packages/remix-server-runtime/router/index.ts b/packages/remix-server-runtime/router/index.ts
index cc57bff84..b643dd550 100644
--- a/packages/remix-server-runtime/router/index.ts
+++ b/packages/remix-server-runtime/router/index.ts
@@ -1,12 +1,16 @@
 // @ts-nocheck
 
-import { convertRoutesToDataRoutes } from "./utils";
+import { convertRoutesToDataRoutes, getPathContributingMatches } from "./utils";
 
 export type {
   ActionFunction,
   ActionFunctionArgs,
+  AgnosticDataIndexRouteObject,
+  AgnosticDataNonIndexRouteObject,
   AgnosticDataRouteMatch,
   AgnosticDataRouteObject,
+  AgnosticIndexRouteObject,
+  AgnosticNonIndexRouteObject,
   AgnosticRouteMatch,
   AgnosticRouteObject,
   TrackedPromise,
@@ -77,4 +81,7 @@ export * from "./router";
 ///////////////////////////////////////////////////////////////////////////////
 
 /** @internal */
-export { convertRoutesToDataRoutes as UNSAFE_convertRoutesToDataRoutes };
+export {
+  convertRoutesToDataRoutes as UNSAFE_convertRoutesToDataRoutes,
+  getPathContributingMatches as UNSAFE_getPathContributingMatches,
+};
diff --git a/packages/remix-server-runtime/router/router.ts b/packages/remix-server-runtime/router/router.ts
index 565fc9aaf..b379bf0e0 100644
--- a/packages/remix-server-runtime/router/router.ts
+++ b/packages/remix-server-runtime/router/router.ts
@@ -1,10 +1,11 @@
 // @ts-nocheck
-// eslint-disable
-import type { History, Location, Path, To } from "./history";
+/* eslint-disable */
+import type { History, Location, To } from "./history";
 import {
   Action as HistoryAction,
   createLocation,
   createPath,
+  createURL,
   parsePath,
 } from "./history";
 import type {
@@ -27,9 +28,12 @@ import {
   ErrorResponse,
   ResultType,
   convertRoutesToDataRoutes,
+  getPathContributingMatches,
   invariant,
   isRouteErrorResponse,
+  joinPaths,
   matchRoutes,
+  resolveTo,
 } from "./utils";
 
 ////////////////////////////////////////////////////////////////////////////////
@@ -471,14 +475,25 @@ interface HandleLoadersResult extends ShortCircuitable {
 }
 
 /**
- * Tuple of [key, href, DataRouterMatch] for a revalidating fetcher.load()
+ * Tuple of [key, href, DataRouteMatch, DataRouteMatch[]] for a revalidating
+ * fetcher.load()
  */
-type RevalidatingFetcher = [string, string, AgnosticDataRouteMatch];
+type RevalidatingFetcher = [
+  string,
+  string,
+  AgnosticDataRouteMatch,
+  AgnosticDataRouteMatch[]
+];
 
 /**
- * Tuple of [href, DataRouteMatch] for an active fetcher.load()
+ * Tuple of [href, DataRouteMatch, DataRouteMatch[]] for an active
+ * fetcher.load()
  */
-type FetchLoadMatch = [string, AgnosticDataRouteMatch];
+type FetchLoadMatch = [
+  string,
+  AgnosticDataRouteMatch,
+  AgnosticDataRouteMatch[]
+];
 
 /**
  * Wrapper object to allow us to throw any response out from callLoaderOrAction
@@ -507,6 +522,12 @@ export const IDLE_FETCHER: FetcherStates["Idle"] = {
   formEncType: undefined,
   formData: undefined,
 };
+
+const isBrowser =
+  typeof window !== "undefined" &&
+  typeof window.document !== "undefined" &&
+  typeof window.document.createElement !== "undefined";
+const isServer = !isBrowser;
 //#endregion
 
 ////////////////////////////////////////////////////////////////////////////////
@@ -549,7 +570,10 @@ export function createRouter(init: RouterInit): Router {
   if (initialMatches == null) {
     // If we do not match a user-provided-route, fall back to the root
     // to allow the error boundary to take over
-    let { matches, route, error } = getNotFoundMatches(dataRoutes);
+    let error = getInternalRouterError(404, {
+      pathname: init.history.location.pathname,
+    });
+    let { matches, route } = getShortCircuitMatches(dataRoutes);
     initialMatches = matches;
     initialErrors = { [route.id]: error };
   }
@@ -745,6 +769,14 @@ export function createRouter(init: RouterInit): Router {
     let { path, submission, error } = normalizeNavigateOptions(to, opts);
 
     let location = createLocation(state.location, path, opts && opts.state);
+
+    // When using navigate as a PUSH/REPLACE we aren't reading an already-encoded
+    // URL from window.location, so we need to encode it here so the behavior
+    // remains the same as POP and non-data-router usages.  new URL() does all
+    // the same encoding we'd get from a history.pushState/window.location read
+    // without having to touch history
+    location = init.history.encodeLocation(location);
+
     let historyAction =
       (opts && opts.replace) === true || submission != null
         ? HistoryAction.Replace
@@ -831,11 +863,9 @@ export function createRouter(init: RouterInit): Router {
 
     // Short circuit with a 404 on the root error boundary if we match nothing
     if (!matches) {
-      let {
-        matches: notFoundMatches,
-        route,
-        error,
-      } = getNotFoundMatches(dataRoutes);
+      let error = getInternalRouterError(404, { pathname: location.pathname });
+      let { matches: notFoundMatches, route } =
+        getShortCircuitMatches(dataRoutes);
       // Cancel all pending deferred on 404s since we don't keep any routes
       cancelActiveDeferreds();
       completeNavigation(location, {
@@ -949,9 +979,22 @@ export function createRouter(init: RouterInit): Router {
     let actionMatch = getTargetMatch(matches, location);
 
     if (!actionMatch.route.action) {
-      result = getMethodNotAllowedResult(location);
+      result = {
+        type: ResultType.error,
+        error: getInternalRouterError(405, {
+          method: request.method,
+          pathname: location.pathname,
+          routeId: actionMatch.route.id,
+        }),
+      };
     } else {
-      result = await callLoaderOrAction("action", request, actionMatch);
+      result = await callLoaderOrAction(
+        "action",
+        request,
+        actionMatch,
+        matches,
+        router.basename
+      );
 
       if (request.signal.aborted) {
         return { shortCircuited: true };
@@ -1093,6 +1136,7 @@ export function createRouter(init: RouterInit): Router {
     let { results, loaderResults, fetcherResults } =
       await callLoadersAndMaybeResolveData(
         state.matches,
+        matches,
         matchesToLoad,
         revalidatingFetchers,
         request
@@ -1162,7 +1206,7 @@ export function createRouter(init: RouterInit): Router {
     href: string,
     opts?: RouterFetchOptions
   ) {
-    if (typeof AbortController === "undefined") {
+    if (isServer) {
       throw new Error(
         "router.fetch() was called during the server render, but it shouldn't be. " +
           "You are likely calling a useFetcher() method in the body of your component. " +
@@ -1174,7 +1218,11 @@ export function createRouter(init: RouterInit): Router {
 
     let matches = matchRoutes(dataRoutes, href, init.basename);
     if (!matches) {
-      setFetcherError(key, routeId, new ErrorResponse(404, "Not Found", null));
+      setFetcherError(
+        key,
+        routeId,
+        getInternalRouterError(404, { pathname: href })
+      );
       return;
     }
 
@@ -1182,14 +1230,14 @@ export function createRouter(init: RouterInit): Router {
     let match = getTargetMatch(matches, path);
 
     if (submission) {
-      handleFetcherAction(key, routeId, path, match, submission);
+      handleFetcherAction(key, routeId, path, match, matches, submission);
       return;
     }
 
     // Store off the match so we can call it's shouldRevalidate on subsequent
     // revalidations
-    fetchLoadMatches.set(key, [path, match]);
-    handleFetcherLoader(key, routeId, path, match);
+    fetchLoadMatches.set(key, [path, match, matches]);
+    handleFetcherLoader(key, routeId, path, match, matches);
   }
 
   // Call the action for the matched fetcher.submit(), and then handle redirects,
@@ -1199,13 +1247,18 @@ export function createRouter(init: RouterInit): Router {
     routeId: string,
     path: string,
     match: AgnosticDataRouteMatch,
+    requestMatches: AgnosticDataRouteMatch[],
     submission: Submission
   ) {
     interruptActiveLoads();
     fetchLoadMatches.delete(key);
 
     if (!match.route.action) {
-      let { error } = getMethodNotAllowedResult(path);
+      let error = getInternalRouterError(405, {
+        method: submission.formMethod,
+        pathname: path,
+        routeId: routeId,
+      });
       setFetcherError(key, routeId, error);
       return;
     }
@@ -1225,7 +1278,13 @@ export function createRouter(init: RouterInit): Router {
     let fetchRequest = createRequest(path, abortController.signal, submission);
     fetchControllers.set(key, abortController);
 
-    let actionResult = await callLoaderOrAction("action", fetchRequest, match);
+    let actionResult = await callLoaderOrAction(
+      "action",
+      fetchRequest,
+      match,
+      requestMatches,
+      router.basename
+    );
 
     if (fetchRequest.signal.aborted) {
       // We can delete this so long as we weren't aborted by ou our own fetcher
@@ -1327,6 +1386,7 @@ export function createRouter(init: RouterInit): Router {
     let { results, loaderResults, fetcherResults } =
       await callLoadersAndMaybeResolveData(
         state.matches,
+        matches,
         matchesToLoad,
         revalidatingFetchers,
         revalidationRequest
@@ -1407,7 +1467,8 @@ export function createRouter(init: RouterInit): Router {
     key: string,
     routeId: string,
     path: string,
-    match: AgnosticDataRouteMatch
+    match: AgnosticDataRouteMatch,
+    matches: AgnosticDataRouteMatch[]
   ) {
     let existingFetcher = state.fetchers.get(key);
     // Put this fetcher into it's loading state
@@ -1429,7 +1490,9 @@ export function createRouter(init: RouterInit): Router {
     let result: DataResult = await callLoaderOrAction(
       "loader",
       fetchRequest,
-      match
+      match,
+      matches,
+      router.basename
     );
 
     // Deferred isn't supported or fetcher loads, await everything and treat it
@@ -1521,12 +1584,23 @@ export function createRouter(init: RouterInit): Router {
       navigation.location,
       "Expected a location on the redirect navigation"
     );
+
+    if (
+      redirect.external &&
+      typeof window !== "undefined" &&
+      typeof window.location !== "undefined"
+    ) {
+      window.location.replace(redirect.location);
+      return;
+    }
+
     // There's no need to abort on redirects, since we don't detect the
     // redirect until the action/loaders have settled
     pendingNavigationController = null;
 
     let redirectHistoryAction =
       replace === true ? HistoryAction.Replace : HistoryAction.Push;
+
     await startNavigation(redirectHistoryAction, navigation.location, {
       overrideNavigation: navigation,
     });
@@ -1534,6 +1608,7 @@ export function createRouter(init: RouterInit): Router {
 
   async function callLoadersAndMaybeResolveData(
     currentMatches: AgnosticDataRouteMatch[],
+    matches: AgnosticDataRouteMatch[],
     matchesToLoad: AgnosticDataRouteMatch[],
     fetchersToLoad: RevalidatingFetcher[],
     request: Request
@@ -1542,9 +1617,17 @@ export function createRouter(init: RouterInit): Router {
     // then slice off the results into separate arrays so we can handle them
     // accordingly
     let results = await Promise.all([
-      ...matchesToLoad.map((m) => callLoaderOrAction("loader", request, m)),
-      ...fetchersToLoad.map(([, href, match]) =>
-        callLoaderOrAction("loader", createRequest(href, request.signal), match)
+      ...matchesToLoad.map((match) =>
+        callLoaderOrAction("loader", request, match, matches, router.basename)
+      ),
+      ...fetchersToLoad.map(([, href, match, fetchMatches]) =>
+        callLoaderOrAction(
+          "loader",
+          createRequest(href, request.signal),
+          match,
+          fetchMatches,
+          router.basename
+        )
       ),
     ]);
     let loaderResults = results.slice(0, matchesToLoad.length);
@@ -1751,7 +1834,9 @@ export function createRouter(init: RouterInit): Router {
     navigate,
     fetch,
     revalidate,
-    createHref,
+    // Passthrough to history-aware createHref used by useHref so we get proper
+    // hash-aware URLs in DOM paths
+    createHref: (to: To) => init.history.createHref(to),
     getFetcher,
     deleteFetcher,
     dispose,
@@ -1767,6 +1852,9 @@ export function createRouter(init: RouterInit): Router {
 //#region createStaticHandler
 ////////////////////////////////////////////////////////////////////////////////
 
+const validActionMethods = new Set(["POST", "PUT", "PATCH", "DELETE"]);
+const validRequestMethods = new Set(["GET", "HEAD", ...validActionMethods]);
+
 export function unstable_createStaticHandler(
   routes: AgnosticRouteObject[]
 ): StaticHandler {
@@ -1803,12 +1891,26 @@ export function unstable_createStaticHandler(
     let location = createLocation("", createPath(url), null, "default");
     let matches = matchRoutes(dataRoutes, location);
 
-    if (!matches) {
-      let {
-        matches: notFoundMatches,
-        route,
-        error,
-      } = getNotFoundMatches(dataRoutes);
+    if (!validRequestMethods.has(request.method)) {
+      let error = getInternalRouterError(405, { method: request.method });
+      let { matches: methodNotAllowedMatches, route } =
+        getShortCircuitMatches(dataRoutes);
+      return {
+        location,
+        matches: methodNotAllowedMatches,
+        loaderData: {},
+        actionData: null,
+        errors: {
+          [route.id]: error,
+        },
+        statusCode: error.status,
+        loaderHeaders: {},
+        actionHeaders: {},
+      };
+    } else if (!matches) {
+      let error = getInternalRouterError(404, { pathname: location.pathname });
+      let { matches: notFoundMatches, route } =
+        getShortCircuitMatches(dataRoutes);
       return {
         location,
         matches: notFoundMatches,
@@ -1817,13 +1919,13 @@ export function unstable_createStaticHandler(
         errors: {
           [route.id]: error,
         },
-        statusCode: 404,
+        statusCode: error.status,
         loaderHeaders: {},
         actionHeaders: {},
       };
     }
 
-    let result = await queryImpl(request, location, matches, false);
+    let result = await queryImpl(request, location, matches);
     if (result instanceof Response) {
       return result;
     }
@@ -1847,34 +1949,39 @@ export function unstable_createStaticHandler(
    * can do proper boundary identification in Remix where a thrown Response
    * must go to the Catch Boundary but a returned Response is happy-path.
    *
-   * One thing to note is that any Router-initiated thrown Response (such as a
-   * 404 or 405) will have a custom X-Remix-Router-Error: "yes" header on it
-   * in order to differentiate from responses thrown from user actions/loaders.
+   * One thing to note is that any Router-initiated Errors that make sense
+   * to associate with a status code will be thrown as an ErrorResponse
+   * instance which include the raw Error, such that the calling context can
+   * serialize the error as they see fit while including the proper response
+   * code.  Examples here are 404 and 405 errors that occur prior to reaching
+   * any user-defined loaders.
    */
   async function queryRoute(request: Request, routeId?: string): Promise<any> {
     let url = new URL(request.url);
     let location = createLocation("", createPath(url), null, "default");
     let matches = matchRoutes(dataRoutes, location);
 
-    if (!matches) {
-      throw createRouterErrorResponse(null, {
-        status: 404,
-        statusText: "Not Found",
-      });
+    if (!validRequestMethods.has(request.method)) {
+      throw getInternalRouterError(405, { method: request.method });
+    } else if (!matches) {
+      throw getInternalRouterError(404, { pathname: location.pathname });
     }
 
     let match = routeId
       ? matches.find((m) => m.route.id === routeId)
       : getTargetMatch(matches, location);
 
-    if (!match) {
-      throw createRouterErrorResponse(null, {
-        status: 404,
-        statusText: "Not Found",
+    if (routeId && !match) {
+      throw getInternalRouterError(403, {
+        pathname: location.pathname,
+        routeId,
       });
+    } else if (!match) {
+      // This should never hit I don't think?
+      throw getInternalRouterError(404, { pathname: location.pathname });
     }
 
-    let result = await queryImpl(request, location, [match], true);
+    let result = await queryImpl(request, location, matches, match);
     if (result instanceof Response) {
       return result;
     }
@@ -1897,29 +2004,25 @@ export function unstable_createStaticHandler(
     request: Request,
     location: Location,
     matches: AgnosticDataRouteMatch[],
-    isRouteRequest: boolean
+    routeMatch?: AgnosticDataRouteMatch
   ): Promise<Omit<StaticHandlerContext, "location"> | Response> {
-    invariant(
-      request.method !== "HEAD",
-      "query()/queryRoute() do not support HEAD requests"
-    );
     invariant(
       request.signal,
       "query()/queryRoute() requests must contain an AbortController signal"
     );
 
     try {
-      if (request.method !== "GET") {
+      if (validActionMethods.has(request.method)) {
         let result = await submit(
           request,
           matches,
-          getTargetMatch(matches, location),
-          isRouteRequest
+          routeMatch || getTargetMatch(matches, location),
+          routeMatch != null
         );
         return result;
       }
 
-      let result = await loadRouteData(request, matches, isRouteRequest);
+      let result = await loadRouteData(request, matches, routeMatch);
       return result instanceof Response
         ? result
         : {
@@ -1953,20 +2056,27 @@ export function unstable_createStaticHandler(
     isRouteRequest: boolean
   ): Promise<Omit<StaticHandlerContext, "location"> | Response> {
     let result: DataResult;
+
     if (!actionMatch.route.action) {
-      let href = createHref(new URL(request.url));
+      let error = getInternalRouterError(405, {
+        method: request.method,
+        pathname: createURL(request.url).pathname,
+        routeId: actionMatch.route.id,
+      });
       if (isRouteRequest) {
-        throw createRouterErrorResponse(`No action found for [${href}]`, {
-          status: 405,
-          statusText: "Method Not Allowed",
-        });
+        throw error;
       }
-      result = getMethodNotAllowedResult(href);
+      result = {
+        type: ResultType.error,
+        error,
+      };
     } else {
       result = await callLoaderOrAction(
         "action",
         request,
         actionMatch,
+        matches,
+        undefined, // Basename not currently supported in static handlers
         true,
         isRouteRequest
       );
@@ -1979,7 +2089,7 @@ export function unstable_createStaticHandler(
 
     if (isRedirectResult(result)) {
       // Uhhhh - this should never happen, we should always throw these from
-      // calLoaderOrAction, but the type narrowing here keeps TS happy and we
+      // callLoaderOrAction, but the type narrowing here keeps TS happy and we
       // can get back on the "throw all redirect responses" train here should
       // this ever happen :/
       throw new Response(null, {
@@ -1998,20 +2108,7 @@ export function unstable_createStaticHandler(
       // Note: This should only be non-Response values if we get here, since
       // isRouteRequest should throw any Response received in callLoaderOrAction
       if (isErrorResult(result)) {
-        let boundaryMatch = findNearestBoundary(matches, actionMatch.route.id);
-        return {
-          matches: [actionMatch],
-          loaderData: {},
-          actionData: null,
-          errors: {
-            [boundaryMatch.route.id]: result.error,
-          },
-          // Note: statusCode + headers are unused here since queryRoute will
-          // return the raw Response or value
-          statusCode: 500,
-          loaderHeaders: {},
-          actionHeaders: {},
-        };
+        throw result.error;
       }
 
       return {
@@ -2031,7 +2128,7 @@ export function unstable_createStaticHandler(
       // Store off the pending error - we use it to determine which loaders
       // to call and will commit it when we complete the navigation
       let boundaryMatch = findNearestBoundary(matches, actionMatch.route.id);
-      let context = await loadRouteData(request, matches, isRouteRequest, {
+      let context = await loadRouteData(request, matches, undefined, {
         [boundaryMatch.route.id]: result.error,
       });
 
@@ -2048,7 +2145,7 @@ export function unstable_createStaticHandler(
       };
     }
 
-    let context = await loadRouteData(request, matches, isRouteRequest);
+    let context = await loadRouteData(request, matches);
 
     return {
       ...context,
@@ -2066,18 +2163,32 @@ export function unstable_createStaticHandler(
   async function loadRouteData(
     request: Request,
     matches: AgnosticDataRouteMatch[],
-    isRouteRequest: boolean,
+    routeMatch?: AgnosticDataRouteMatch,
     pendingActionError?: RouteData
   ): Promise<
     | Omit<StaticHandlerContext, "location" | "actionData" | "actionHeaders">
     | Response
   > {
-    let matchesToLoad = getLoaderMatchesUntilBoundary(
-      matches,
-      Object.keys(pendingActionError || {})[0]
-    ).filter((m) => m.route.loader);
+    let isRouteRequest = routeMatch != null;
+
+    // Short circuit if we have no loaders to run (queryRoute())
+    if (isRouteRequest && !routeMatch?.route.loader) {
+      throw getInternalRouterError(400, {
+        method: request.method,
+        pathname: createURL(request.url).pathname,
+        routeId: routeMatch?.route.id,
+      });
+    }
 
-    // Short circuit if we have no loaders to run
+    let requestMatches = routeMatch
+      ? [routeMatch]
+      : getLoaderMatchesUntilBoundary(
+          matches,
+          Object.keys(pendingActionError || {})[0]
+        );
+    let matchesToLoad = requestMatches.filter((m) => m.route.loader);
+
+    // Short circuit if we have no loaders to run (query())
     if (matchesToLoad.length === 0) {
       return {
         matches,
@@ -2089,8 +2200,16 @@ export function unstable_createStaticHandler(
     }
 
     let results = await Promise.all([
-      ...matchesToLoad.map((m) =>
-        callLoaderOrAction("loader", request, m, true, isRouteRequest)
+      ...matchesToLoad.map((match) =>
+        callLoaderOrAction(
+          "loader",
+          request,
+          match,
+          matches,
+          undefined, // Basename not currently supported in static handlers
+          true,
+          isRouteRequest
+        )
       ),
     ]);
 
@@ -2121,19 +2240,6 @@ export function unstable_createStaticHandler(
     };
   }
 
-  function createRouterErrorResponse(
-    body: BodyInit | null | undefined,
-    init: ResponseInit
-  ) {
-    return new Response(body, {
-      ...init,
-      headers: {
-        ...init.headers,
-        "X-Remix-Router-Error": "yes",
-      },
-    });
-  }
-
   return {
     dataRoutes,
     query,
@@ -2190,7 +2296,7 @@ function normalizeNavigateOptions(
       path,
       submission: {
         formMethod: opts.formMethod,
-        formAction: createHref(parsePath(path)),
+        formAction: stripHashFromPath(path),
         formEncType:
           (opts && opts.formEncType) || "application/x-www-form-urlencoded",
         formData: opts.formData,
@@ -2221,11 +2327,7 @@ function normalizeNavigateOptions(
   } catch (e) {
     return {
       path,
-      error: new ErrorResponse(
-        400,
-        "Bad Request",
-        "Cannot submit binary form data using GET"
-      ),
+      error: getInternalRouterError(400),
     };
   }
 
@@ -2305,10 +2407,10 @@ function getMatchesToLoad(
   // Pick fetcher.loads that need to be revalidated
   let revalidatingFetchers: RevalidatingFetcher[] = [];
   fetchLoadMatches &&
-    fetchLoadMatches.forEach(([href, match], key) => {
+    fetchLoadMatches.forEach(([href, match, fetchMatches], key) => {
       // This fetcher was cancelled from a prior action submission - force reload
       if (cancelledFetcherLoads.includes(key)) {
-        revalidatingFetchers.push([key, href, match]);
+        revalidatingFetchers.push([key, href, match, fetchMatches]);
       } else if (isRevalidationRequired) {
         let shouldRevalidate = shouldRevalidateLoader(
           href,
@@ -2320,7 +2422,7 @@ function getMatchesToLoad(
           actionResult
         );
         if (shouldRevalidate) {
-          revalidatingFetchers.push([key, href, match]);
+          revalidatingFetchers.push([key, href, match, fetchMatches]);
         }
       }
     });
@@ -2414,7 +2516,9 @@ async function callLoaderOrAction(
   type: "loader" | "action",
   request: Request,
   match: AgnosticDataRouteMatch,
-  skipRedirects: boolean = false,
+  matches: AgnosticDataRouteMatch[],
+  basename: string | undefined,
+  isStaticRequest: boolean = false,
   isRouteRequest: boolean = false
 ): Promise<DataResult> {
   let resultType;
@@ -2437,6 +2541,13 @@ async function callLoaderOrAction(
       handler({ request, params: match.params }),
       abortPromise,
     ]);
+
+    invariant(
+      result !== undefined,
+      `You defined ${type === "action" ? "an action" : "a loader"} for route ` +
+        `"${match.route.id}" but didn't return anything from your \`${type}\` ` +
+        `function. Please return a value or \`null\`.`
+    );
   } catch (e) {
     resultType = ResultType.error;
     result = e;
@@ -2445,33 +2556,68 @@ async function callLoaderOrAction(
   }
 
   if (result instanceof Response) {
-    // Process redirects
     let status = result.status;
-    let location = result.headers.get("Location");
 
-    // For SSR single-route requests, we want to hand Responses back directly
-    // without unwrapping.  Wer do this with the QueryRouteResponse wrapper
-    // interface so we can know whether it was returned or thrown
-    if (isRouteRequest) {
-      // eslint-disable-next-line no-throw-literal
-      throw {
-        type: resultType || ResultType.data,
-        response: result,
-      };
-    }
+    // Process redirects
+    if (status >= 300 && status <= 399) {
+      let location = result.headers.get("Location");
+      invariant(
+        location,
+        "Redirects returned/thrown from loaders/actions must have a Location header"
+      );
+
+      // Check if this an external redirect that goes to a new origin
+      let external = createURL(location).origin !== createURL("/").origin;
+
+      // Support relative routing in internal redirects
+      if (!external) {
+        let activeMatches = matches.slice(0, matches.indexOf(match) + 1);
+        let routePathnames = getPathContributingMatches(activeMatches).map(
+          (match) => match.pathnameBase
+        );
+        let requestPath = createURL(request.url).pathname;
+        let resolvedLocation = resolveTo(location, routePathnames, requestPath);
+        invariant(
+          createPath(resolvedLocation),
+          `Unable to resolve redirect location: ${location}`
+        );
+
+        // Prepend the basename to the redirect location if we have one
+        if (basename) {
+          let path = resolvedLocation.pathname;
+          resolvedLocation.pathname =
+            path === "/" ? basename : joinPaths([basename, path]);
+        }
 
-    if (status >= 300 && status <= 399 && location != null) {
-      // Don't process redirects in the router during SSR document requests.
+        location = createPath(resolvedLocation);
+      }
+
+      // Don't process redirects in the router during static requests requests.
       // Instead, throw the Response and let the server handle it with an HTTP
-      // redirect
-      if (skipRedirects) {
+      // redirect.  We also update the Location header in place in this flow so
+      // basename and relative routing is taken into account
+      if (isStaticRequest) {
+        result.headers.set("Location", location);
         throw result;
       }
+
       return {
         type: ResultType.redirect,
         status,
         location,
         revalidate: result.headers.get("X-Remix-Revalidate") !== null,
+        external,
+      };
+    }
+
+    // For SSR single-route requests, we want to hand Responses back directly
+    // without unwrapping.  We do this with the QueryRouteResponse wrapper
+    // interface so we can know whether it was returned or thrown
+    if (isRouteRequest) {
+      // eslint-disable-next-line no-throw-literal
+      throw {
+        type: resultType || ResultType.data,
+        response: result,
       };
     }
 
@@ -2515,7 +2661,7 @@ function createRequest(
   signal: AbortSignal,
   submission?: Submission
 ): Request {
-  let url = createURL(location).toString();
+  let url = createURL(stripHashFromPath(location)).toString();
   let init: RequestInit = { signal };
 
   if (submission) {
@@ -2728,16 +2874,13 @@ function findNearestBoundary(
   );
 }
 
-function getNotFoundMatches(routes: AgnosticDataRouteObject[]): {
+function getShortCircuitMatches(routes: AgnosticDataRouteObject[]): {
   matches: AgnosticDataRouteMatch[];
   route: AgnosticDataRouteObject;
-  error: ErrorResponse;
 } {
   // Prefer a root layout route if present, otherwise shim in a route object
-  let route = routes.find(
-    (r) => r.index || r.path === "" || r.path === "/"
-  ) || {
-    id: "__shim-404-route__",
+  let route = routes.find((r) => r.index || !r.path || r.path === "/") || {
+    id: `__shim-error-route__`,
   };
 
   return {
@@ -2750,25 +2893,63 @@ function getNotFoundMatches(routes: AgnosticDataRouteObject[]): {
       },
     ],
     route,
-    error: new ErrorResponse(404, "Not Found", null),
   };
 }
 
-function getMethodNotAllowedResult(path: Location | string): ErrorResult {
-  let href = typeof path === "string" ? path : createHref(path);
-  console.warn(
-    "You're trying to submit to a route that does not have an action.  To " +
-      "fix this, please add an `action` function to the route for " +
-      `[${href}]`
+function getInternalRouterError(
+  status: number,
+  {
+    pathname,
+    routeId,
+    method,
+    message,
+  }: {
+    pathname?: string;
+    routeId?: string;
+    method?: string;
+    message?: string;
+  } = {}
+) {
+  let statusText: string;
+  let errorMessage = message;
+
+  if (status === 400) {
+    statusText = "Bad Request";
+    if (method && pathname && routeId) {
+      errorMessage =
+        `You made a ${method} request to "${pathname}" but ` +
+        `did not provide a \`loader\` for route "${routeId}", ` +
+        `so there is no way to handle the request.`;
+    } else {
+      errorMessage = "Cannot submit binary form data using GET";
+    }
+  } else if (status === 403) {
+    statusText = "Forbidden";
+    errorMessage = `Route "${routeId}" does not match URL "${pathname}"`;
+  } else if (status === 404) {
+    statusText = "Not Found";
+    errorMessage = `No route matches URL "${pathname}"`;
+  } else if (status === 405) {
+    statusText = "Method Not Allowed";
+    if (method && pathname && routeId) {
+      errorMessage =
+        `You made a ${method} request to "${pathname}" but ` +
+        `did not provide an \`action\` for route "${routeId}", ` +
+        `so there is no way to handle the request.`;
+    } else {
+      errorMessage = `Invalid request method "${method}"`;
+    }
+  } else {
+    statusText = "Unknown Server Error";
+    errorMessage = "Unknown @remix-run/router error";
+  }
+
+  return new ErrorResponse(
+    status || 500,
+    statusText,
+    new Error(errorMessage),
+    true
   );
-  return {
-    type: ResultType.error,
-    error: new ErrorResponse(
-      405,
-      "Method Not Allowed",
-      `No action found for [${href}]`
-    ),
-  };
 }
 
 // Find any returned redirect errors, starting from the lowest match
@@ -2781,9 +2962,9 @@ function findRedirect(results: DataResult[]): RedirectResult | undefined {
   }
 }
 
-// Create an href to represent a "server" URL without the hash
-function createHref(location: Partial<Path> | Location | URL) {
-  return (location.pathname || "") + (location.search || "");
+function stripHashFromPath(path: To) {
+  let parsedPath = typeof path === "string" ? parsePath(path) : path;
+  return createPath({ ...parsedPath, hash: "" });
 }
 
 function isHashChangeOnly(a: Location, b: Location): boolean {
@@ -2913,19 +3094,14 @@ function getTargetMatch(
     typeof location === "string" ? parsePath(location).search : location.search;
   if (
     matches[matches.length - 1].route.index &&
-    !hasNakedIndexQuery(search || "")
+    hasNakedIndexQuery(search || "")
   ) {
-    return matches.slice(-2)[0];
+    // Return the leaf index route when index is present
+    return matches[matches.length - 1];
   }
-  return matches.slice(-1)[0];
-}
-
-function createURL(location: Location | string): URL {
-  let base =
-    typeof window !== "undefined" && typeof window.location !== "undefined"
-      ? window.location.origin
-      : "unknown://unknown";
-  let href = typeof location === "string" ? location : createHref(location);
-  return new URL(href, base);
+  // Otherwise grab the deepest "path contributing" match (ignoring index and
+  // pathless layout routes)
+  let pathMatches = getPathContributingMatches(matches);
+  return pathMatches[pathMatches.length - 1];
 }
 //#endregion
diff --git a/packages/remix-server-runtime/router/utils.ts b/packages/remix-server-runtime/router/utils.ts
index cb34165ac..a4f477513 100644
--- a/packages/remix-server-runtime/router/utils.ts
+++ b/packages/remix-server-runtime/router/utils.ts
@@ -1,5 +1,5 @@
 // @ts-nocheck
-// eslint-disable
+/* eslint-disable */
 import type { Location, Path, To } from "./history";
 import { parsePath } from "./history";
 
@@ -43,6 +43,7 @@ export interface RedirectResult {
   status: number;
   location: string;
   revalidate: boolean;
+  external: boolean;
 }
 
 /**
@@ -331,7 +332,16 @@ export function matchRoutes<
 
   let matches = null;
   for (let i = 0; matches == null && i < branches.length; ++i) {
-    matches = matchRouteBranch<string, RouteObjectType>(branches[i], pathname);
+    matches = matchRouteBranch<string, RouteObjectType>(
+      branches[i],
+      // Incoming pathnames are generally encoded from either window.location
+      // or from router.navigate, but we want to match against the unencoded
+      // paths in the route definitions.  Memory router locations won't be
+      // encoded here but there also shouldn't be anything to decode so this
+      // should be a safe operation.  This avoids needing matchRoutes to be
+      // history-aware.
+      safelyDecodeURI(pathname)
+    );
   }
 
   return matches;
@@ -535,7 +545,7 @@ export function generatePath<Path extends string>(
       return params[key]!;
     })
     .replace(/(\/?)\*/, (_, prefix, __, str) => {
-      let star = "*" as PathParam<Path>;
+      const star = "*" as PathParam<Path>;
 
       if (params[star] == null) {
         // If no splat was provided, trim the trailing slash _unless_ it's
@@ -704,6 +714,21 @@ function compilePath(
   return [matcher, paramNames];
 }
 
+function safelyDecodeURI(value: string) {
+  try {
+    return decodeURI(value);
+  } catch (error) {
+    warning(
+      false,
+      `The URL path "${value}" could not be decoded because it is is a ` +
+        `malformed URL segment. This is probably due to a bad percent ` +
+        `encoding (${error}).`
+    );
+
+    return value;
+  }
+}
+
 function safelyDecodeURIComponent(value: string, paramName: string) {
   try {
     return decodeURIComponent(value);
@@ -837,6 +862,38 @@ function getInvalidPathError(
   );
 }
 
+/**
+ * @private
+ *
+ * When processing relative navigation we want to ignore ancestor routes that
+ * do not contribute to the path, such that index/pathless layout routes don't
+ * interfere.
+ *
+ * For example, when moving a route element into an index route and/or a
+ * pathless layout route, relative link behavior contained within should stay
+ * the same.  Both of the following examples should link back to the root:
+ *
+ *   <Route path="/">
+ *     <Route path="accounts" element={<Link to=".."}>
+ *   </Route>
+ *
+ *   <Route path="/">
+ *     <Route path="accounts">
+ *       <Route element={<AccountsLayout />}>       // <-- Does not contribute
+ *         <Route index element={<Link to=".."} />  // <-- Does not contribute
+ *       </Route
+ *     </Route>
+ *   </Route>
+ */
+export function getPathContributingMatches<
+  T extends AgnosticRouteMatch = AgnosticRouteMatch
+>(matches: T[]) {
+  return matches.filter(
+    (match, index) =>
+      index === 0 || (match.route.path && match.route.path.length > 0)
+  );
+}
+
 /**
  * @private
  */
@@ -1074,7 +1131,7 @@ export class DeferredData {
       this.unlistenAbortSignal();
     }
 
-    let subscriber = this.subscriber;
+    const subscriber = this.subscriber;
     if (error) {
       Object.defineProperty(promise, "_error", { get: () => error });
       subscriber && subscriber(false);
@@ -1189,11 +1246,24 @@ export class ErrorResponse {
   status: number;
   statusText: string;
   data: any;
-
-  constructor(status: number, statusText: string | undefined, data: any) {
+  error?: Error;
+  internal: boolean;
+
+  constructor(
+    status: number,
+    statusText: string | undefined,
+    data: any,
+    internal = false
+  ) {
     this.status = status;
     this.statusText = statusText || "";
-    this.data = data;
+    this.internal = internal;
+    if (data instanceof Error) {
+      this.data = data.toString();
+      this.error = data;
+    } else {
+      this.data = data;
+    }
   }
 }
 
diff --git a/packages/remix-server-runtime/routes.ts b/packages/remix-server-runtime/routes.ts
index 7a8b11625..e1bb2daf0 100644
--- a/packages/remix-server-runtime/routes.ts
+++ b/packages/remix-server-runtime/routes.ts
@@ -4,8 +4,8 @@ import type {
   ActionFunctionArgs,
   LoaderFunctionArgs,
 } from "./router";
-import type { AppLoadContext } from "./data";
-import { callRouteAction, callRouteLoader } from "./data";
+import { type AppLoadContext } from "./data";
+import { callRouteActionRR, callRouteLoaderRR } from "./data";
 import type { ServerRouteModule } from "./routeModules";
 
 export interface RouteManifest<Route> {
@@ -70,20 +70,20 @@ export function createStaticHandlerDataRoutes(
         path: route.path,
         loader: route.module.loader
           ? (args: LoaderFunctionArgs) =>
-              callRouteLoader({
+              callRouteLoaderRR({
                 ...args,
-                routeId: route.id,
-                loader: route.module.loader,
                 loadContext,
+                loader: route.module.loader!,
+                routeId: route.id,
               })
           : undefined,
         action: route.module.action
           ? (args: ActionFunctionArgs) =>
-              callRouteAction({
+              callRouteActionRR({
                 ...args,
-                routeId: route.id,
-                action: route.module.action,
                 loadContext,
+                action: route.module.action!,
+                routeId: route.id,
               })
           : undefined,
         handle: route.module.handle,
diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts
index 21eca52ad..609631b64 100644
--- a/packages/remix-server-runtime/server.ts
+++ b/packages/remix-server-runtime/server.ts
@@ -1,19 +1,20 @@
 // TODO: RRR - Change import to @remix-run/router
-import type { StaticHandler } from "./router";
+import type { StaticHandler, StaticHandlerContext } from "./router";
+import { ErrorResponse, isRouteErrorResponse } from "./router";
 import { unstable_createStaticHandler } from "./router";
 import type { AppLoadContext } from "./data";
 import { callRouteAction, callRouteLoader, extractData } from "./data";
 import type { AppState } from "./errors";
-import type { ServerBuild } from "./build";
+import type { ServerBuild, HandleDocumentRequestFunction } from "./build";
 import type { EntryContext } from "./entry";
 import { createEntryMatches, createEntryRouteModules } from "./entry";
 import { serializeError } from "./errors";
-import { getDocumentHeaders } from "./headers";
+import { getDocumentHeaders, getDocumentHeadersRR } from "./headers";
 import invariant from "./invariant";
 import { ServerMode, isServerMode } from "./mode";
 import type { RouteMatch } from "./routeMatching";
 import { matchServerRoutes } from "./routeMatching";
-import type { ServerRoute } from "./routes";
+import type { ServerRoute, ServerRouteManifest } from "./routes";
 import { createStaticHandlerDataRoutes, createRoutes } from "./routes";
 import { json, isRedirectResponse, isCatchResponse } from "./responses";
 import { createServerHandoffString } from "./serverHandoff";
@@ -51,33 +52,24 @@ export const createRequestHandler: CreateRequestHandlerFunction = (
 
     let response: Response;
     if (url.searchParams.has("_data")) {
-      let responsePromise = handleDataRequest({
-        request:
-          // We need to clone the request here instead of the call to the new
-          // handler otherwise the first handler will lock the body for the other.
-          // Cloning here allows the new handler to be the stream reader and delegate
-          // chunks back to this cloned request.
-          ENABLE_REMIX_ROUTER ? request.clone() : request,
-        loadContext,
-        matches: matches!,
-        serverMode,
-      });
-
       let routeId = url.searchParams.get("_data")!;
-      if (ENABLE_REMIX_ROUTER) {
-        let [response, remixRouterResponse] = await Promise.all([
-          responsePromise,
-          handleDataRequestRR(serverMode, staticHandler!, routeId, request),
-        ]);
 
-        assertResponsesMatch(response, remixRouterResponse);
-
-        console.log("Returning Remix Router Data Request Response");
-        responsePromise = Promise.resolve(remixRouterResponse);
+      if (ENABLE_REMIX_ROUTER) {
+        response = await handleDataRequestRR(
+          serverMode,
+          staticHandler!,
+          routeId,
+          request
+        );
+      } else {
+        response = await handleDataRequest({
+          request,
+          loadContext,
+          matches: matches!,
+          serverMode,
+        });
       }
 
-      response = await responsePromise;
-
       if (build.entry.module.handleDataRequest) {
         let match = matches!.find((match) => match.route.id == routeId)!;
         response = await build.entry.module.handleDataRequest(response, {
@@ -90,45 +82,39 @@ export const createRequestHandler: CreateRequestHandlerFunction = (
       matches &&
       matches[matches.length - 1].route.module.default == null
     ) {
-      let responsePromise = handleResourceRequest({
-        request:
-          // We need to clone the request here instead of the call to the new
-          // handler otherwise the first handler will lock the body for the other.
-          // Cloning here allows the new handler to be the stream reader and delegate
-          // chunks back to this cloned request.
-          ENABLE_REMIX_ROUTER ? request.clone() : request,
-        loadContext,
-        matches,
-        serverMode,
-      });
-
       if (ENABLE_REMIX_ROUTER) {
-        let [response, remixRouterResponse] = await Promise.all([
-          responsePromise,
-          handleResourceRequestRR(
-            serverMode,
-            staticHandler!,
-            matches.slice(-1)[0].route.id,
-            request
-          ),
-        ]);
-
-        assertResponsesMatch(response, remixRouterResponse);
-
-        console.log("Returning Remix Router Resource Request Response");
-        responsePromise = Promise.resolve(remixRouterResponse);
+        response = await handleResourceRequestRR(
+          serverMode,
+          staticHandler!,
+          matches.slice(-1)[0].route.id,
+          request
+        );
+      } else {
+        response = await handleResourceRequest({
+          request,
+          loadContext,
+          matches,
+          serverMode,
+        });
       }
-
-      response = await responsePromise;
     } else {
-      response = await handleDocumentRequest({
-        build,
-        loadContext,
-        matches,
-        request,
-        routes,
-        serverMode,
-      });
+      if (ENABLE_REMIX_ROUTER) {
+        response = await handleDocumentRequestRR(
+          serverMode,
+          build,
+          staticHandler!,
+          request
+        );
+      } else {
+        response = await handleDocumentRequest({
+          build,
+          loadContext,
+          matches,
+          request,
+          routes,
+          serverMode,
+        });
+      }
     }
 
     if (request.method === "HEAD") {
@@ -154,25 +140,51 @@ async function handleDataRequest({
   request: Request;
   serverMode: ServerMode;
 }): Promise<Response> {
-  if (!isValidRequestMethod(request)) {
-    return errorBoundaryError(
-      new Error(`Invalid request method "${request.method}"`),
-      405
-    );
-  }
-
-  let url = new URL(request.url);
-
-  if (!matches) {
-    return errorBoundaryError(
-      new Error(`No route matches URL "${url.pathname}"`),
-      404
-    );
-  }
-
   let response: Response;
   let match: RouteMatch<ServerRoute>;
+
   try {
+    if (!isValidRequestMethod(request)) {
+      throw new ErrorResponse(
+        405,
+        "Method Not Allowed",
+        new Error(`Invalid request method "${request.method}"`),
+        true
+      );
+    }
+
+    let url = new URL(request.url);
+
+    if (!matches) {
+      throw new ErrorResponse(
+        404,
+        "Not Found",
+        new Error(`No route matches URL "${url.pathname}"`),
+        true
+      );
+    }
+
+    let routeId = url.searchParams.get("_data");
+    if (!routeId) {
+      throw new ErrorResponse(
+        403,
+        "Forbidden",
+        new Error(`Missing route id in ?_data`),
+        true
+      );
+    }
+
+    let tempMatch = matches.find((match) => match.route.id === routeId);
+    if (!tempMatch) {
+      throw new ErrorResponse(
+        403,
+        "Forbidden",
+        new Error(`Route "${routeId}" does not match URL "${url.pathname}"`),
+        true
+      );
+    }
+    match = tempMatch;
+
     if (isActionRequest(request)) {
       match = getRequestMatch(url, matches);
 
@@ -184,20 +196,6 @@ async function handleDataRequest({
         request: request,
       });
     } else {
-      let routeId = url.searchParams.get("_data");
-      if (!routeId) {
-        return errorBoundaryError(new Error(`Missing route id in ?_data`), 403);
-      }
-
-      let tempMatch = matches.find((match) => match.route.id === routeId);
-      if (!tempMatch) {
-        return errorBoundaryError(
-          new Error(`Route "${routeId}" does not match URL "${url.pathname}"`),
-          403
-        );
-      }
-      match = tempMatch;
-
       response = await callRouteLoader({
         loadContext,
         loader: match.route.module.loader,
@@ -226,15 +224,26 @@ async function handleDataRequest({
 
     return response;
   } catch (error: unknown) {
+    let status = 500;
+    let errorInstance = error;
+
+    if (isRouteErrorResponse(error)) {
+      status = error.status;
+      errorInstance = error.error || errorInstance;
+    }
+
     if (serverMode !== ServerMode.Test) {
-      console.error(error);
+      console.error(errorInstance);
     }
 
-    if (serverMode === ServerMode.Development && error instanceof Error) {
-      return errorBoundaryError(error, 500);
+    if (
+      serverMode === ServerMode.Development &&
+      errorInstance instanceof Error
+    ) {
+      return errorBoundaryError(errorInstance, status);
     }
 
-    return errorBoundaryError(new Error("Unexpected Server Error"), 500);
+    return errorBoundaryError(new Error("Unexpected Server Error"), status);
   }
 }
 
@@ -267,30 +276,239 @@ async function handleDataRequestRR(
     return response;
   } catch (error) {
     if (error instanceof Response) {
-      // To match existing behavior of remix-thrown responses,
-      // we construct a new one here with a null body and just
-      // the required headers. No remix-throw that surface to
-      // this point will ever have a body. This contrasts with
-      // the new router implementation that has a body under
-      // some conditions.
-      return new Response(null, {
-        status: error.status,
-        statusText: error.statusText,
-        headers: {
-          "X-Remix-Catch": "yes",
-        },
-      });
+      error.headers.set("X-Remix-Catch", "yes");
+      return error;
     }
 
-    if (serverMode !== ServerMode.Test) {
+    let status = 500;
+    let errorInstance = error;
+
+    if (isRouteErrorResponse(error)) {
+      status = error.status;
+      errorInstance = error.error || errorInstance;
+    }
+
+    if (serverMode !== ServerMode.Test && !request.signal.aborted) {
+      console.error(errorInstance);
+    }
+
+    if (
+      serverMode === ServerMode.Development &&
+      errorInstance instanceof Error
+    ) {
+      return errorBoundaryError(errorInstance, status);
+    }
+
+    return errorBoundaryError(new Error("Unexpected Server Error"), status);
+  }
+}
+
+function findParentBoundary(
+  routes: ServerRouteManifest,
+  routeId: string,
+  error: any
+): string {
+  // Fall back to the root route if we don't match any routes, since Remix
+  // has default error/catch boundary handling.  This handles the case where
+  // react-router doesn't have a matching "root" route to assign the error to
+  // so it returns context.errors = { __shim-error-route__: ErrorResponse }
+  let route = routes[routeId] || routes["root"];
+  // Router-thrown ErrorResponses will have the error instance.  User-thrown
+  // Responses will not have an error. The one exception here is internal 404s
+  // which we handle the same as user-thrown 404s
+  let isCatch =
+    isRouteErrorResponse(error) && (!error.error || error.status === 404);
+  if (
+    (isCatch && route.module.CatchBoundary) ||
+    (!isCatch && route.module.ErrorBoundary) ||
+    !route.parentId
+  ) {
+    return route.id;
+  }
+
+  return findParentBoundary(routes, route.parentId, error);
+}
+
+// Re-generate a remix-friendly context.errors structure.  The Router only
+// handles generic errors and does not distinguish error versus catch.  We
+// may have a thrown response tagged to a route that only exports an
+// ErrorBoundary or vice versa.  So we adjust here and ensure that
+// data-loading errors are properly associated with routes that have the right
+// type of boundaries.
+export function differentiateCatchVersusErrorBoundaries(
+  build: ServerBuild,
+  context: StaticHandlerContext
+) {
+  if (!context.errors) {
+    return;
+  }
+
+  let errors: Record<string, any> = {};
+  for (let routeId of Object.keys(context.errors)) {
+    let error = context.errors[routeId];
+    let handlingRouteId = findParentBoundary(build.routes, routeId, error);
+    errors[handlingRouteId] = error;
+  }
+  context.errors = errors;
+}
+
+async function handleDocumentRequestRR(
+  serverMode: ServerMode,
+  build: ServerBuild,
+  staticHandler: StaticHandler,
+  request: Request
+) {
+  let context;
+  try {
+    context = await staticHandler.query(request);
+  } catch (error) {
+    if (!request.signal.aborted && serverMode !== ServerMode.Test) {
       console.error(error);
     }
 
-    if (serverMode === ServerMode.Development && error instanceof Error) {
-      return errorBoundaryError(error, 500);
+    return new Response(null, { status: 500 });
+  }
+
+  if (context instanceof Response) {
+    return context;
+  }
+
+  // Restructure context.errors to the right Catch/Error Boundary
+  differentiateCatchVersusErrorBoundaries(build, context);
+
+  let appState: AppState = {
+    trackBoundaries: true,
+    trackCatchBoundaries: true,
+    catchBoundaryRouteId: null,
+    renderBoundaryRouteId: null,
+    loaderBoundaryRouteId: null,
+  };
+
+  // Copy staticContext.errors to catch/error boundaries in appState
+  // Note: Only assign the boundary id if this module has a boundary.  This
+  // should be true in almost all cases, except for cases in which no
+  // boundaries exist and the router "assigns" the catch/error to the root
+  // route for lack of a better place to put it.  If the root doesn't have a
+  // catch/error boundary, then we leave the boundaryId null to bubble to the
+  // default boundary at render time
+  for (let match of context.matches) {
+    let route = match.route as ServerRoute;
+    let id = route.id;
+    let error = context.errors?.[id];
+    let hasCatchBoundary = build.routes[id]?.module.CatchBoundary != null;
+    let hasErrorBoundary = build.routes[id]?.module.ErrorBoundary != null;
+
+    if (!error) {
+      continue;
+    } else if (isRouteErrorResponse(error)) {
+      // Internal Router errors will throw an ErrorResponse with the actual
+      // error instance, while user-thrown ErrorResponses will not have the
+      // instance.  We also exclude 404s so we can handle them as CatchBoundary
+      // errors so the user has a singular location for 404 UI
+      if (error.internal && error.error && error.status !== 404) {
+        if (hasErrorBoundary) {
+          appState.loaderBoundaryRouteId = id;
+        }
+        appState.trackBoundaries = false;
+        appState.error = await serializeError(error.error);
+
+        if (
+          error.status === 405 &&
+          error.error.message.includes("Invalid request method")
+        ) {
+          // Note: Emptying this out ensures our response matches the Remix-flow
+          // exactly, but functionally both end up using the root error boundary
+          // if it exists.  Might be able to clean this up eventually.
+          context.matches = [];
+        }
+        break;
+      }
+
+      // ErrorResponse's without an error were thrown by the user action/loader
+      if (hasCatchBoundary) {
+        appState.catchBoundaryRouteId = id;
+      }
+      appState.trackCatchBoundaries = false;
+      appState.catch = {
+        // Order is important for response matching assertions!
+        data:
+          error.error && error.status === 404
+            ? error.error.message
+            : error.data,
+        status: error.status,
+        statusText: error.statusText,
+      };
+      break;
+    } else {
+      if (hasErrorBoundary) {
+        appState.loaderBoundaryRouteId = id;
+      }
+      appState.trackBoundaries = false;
+      appState.error = await serializeError(error);
+      break;
+    }
+  }
+
+  let renderableMatches = getRenderableMatches(
+    context.matches as unknown as RouteMatch<ServerRoute>[],
+    appState
+  );
+
+  if (!renderableMatches) {
+    renderableMatches = [];
+
+    let root = staticHandler.dataRoutes[0] as ServerRoute;
+    if (root?.module?.CatchBoundary) {
+      appState.catchBoundaryRouteId = "root";
+      renderableMatches.push({
+        params: {},
+        pathname: "",
+        route: staticHandler.dataRoutes[0] as ServerRoute,
+      });
     }
+  }
+
+  let headers = getDocumentHeadersRR(build, context, renderableMatches);
+
+  let serverHandoff: Pick<
+    EntryContext,
+    "actionData" | "appState" | "matches" | "routeData" | "future"
+  > = {
+    actionData: context.actionData || undefined,
+    appState,
+    matches: createEntryMatches(renderableMatches, build.assets.routes),
+    routeData: context.loaderData || {},
+    future: build.future,
+  };
+
+  let entryContext: EntryContext = {
+    ...serverHandoff,
+    manifest: build.assets,
+    routeModules: createEntryRouteModules(build.routes),
+    serverHandoffString: createServerHandoffString(serverHandoff),
+  };
 
-    return errorBoundaryError(new Error("Unexpected Server Error"), 500);
+  let handleDocumentRequestParameters: Parameters<HandleDocumentRequestFunction> =
+    [request, context.statusCode, headers, entryContext];
+
+  let handleDocumentRequestFunction = build.entry.module.default;
+  try {
+    return await handleDocumentRequestFunction(
+      ...handleDocumentRequestParameters
+    );
+  } catch (error) {
+    handleDocumentRequestParameters[1] = 500;
+    appState.trackBoundaries = false;
+    appState.error = await serializeError(error as Error);
+    entryContext.serverHandoffString = createServerHandoffString(serverHandoff);
+
+    try {
+      return await handleDocumentRequestFunction(
+        ...handleDocumentRequestParameters
+      );
+    } catch (error: any) {
+      return returnLastResortErrorResponse(error, serverMode);
+    }
   }
 }
 
@@ -321,18 +539,22 @@ async function handleDocumentRequest({
     catch: undefined,
   };
 
+  let errorStatus = 500;
+
   if (!isValidRequestMethod(request)) {
     matches = null;
-    appState.trackCatchBoundaries = false;
-    appState.catch = {
-      data: null,
-      status: 405,
-      statusText: "Method Not Allowed",
-    };
+    appState.loaderBoundaryRouteId = routes[0].module.ErrorBoundary
+      ? routes[0].id
+      : null;
+    appState.trackBoundaries = false;
+    errorStatus = 405;
+    appState.error = await serializeError(
+      new Error(`Invalid request method "${request.method}"`)
+    );
   } else if (!matches) {
     appState.trackCatchBoundaries = false;
     appState.catch = {
-      data: null,
+      data: `No route matches URL "${new URL(request.url).pathname}"`,
       status: 404,
       statusText: "Not Found",
     };
@@ -371,8 +593,9 @@ async function handleDocumentRequest({
         );
         appState.trackCatchBoundaries = false;
         appState.catch = {
-          ...actionStatus,
           data: await extractData(actionResponse),
+          status: actionStatus.status,
+          statusText: actionStatus.statusText,
         };
       } else {
         actionData = {
@@ -380,12 +603,18 @@ async function handleDocumentRequest({
         };
       }
     } catch (error: any) {
+      let errorInstance = error;
+      if (isRouteErrorResponse(error)) {
+        errorStatus = error.status;
+        errorInstance = error.error || errorInstance;
+      }
+
       appState.loaderBoundaryRouteId = getDeepestRouteIdWithBoundary(
         matches,
         "ErrorBoundary"
       );
       appState.trackBoundaries = false;
-      appState.error = await serializeError(error);
+      appState.error = await serializeError(errorInstance);
 
       if (serverMode !== ServerMode.Test) {
         console.error(
@@ -487,9 +716,15 @@ async function handleDocumentRequest({
     }
 
     if (error) {
-      loaderStatusCodes.push(500);
       appState.trackBoundaries = false;
-      appState.error = await serializeError(error);
+
+      if (isRouteErrorResponse(error) && error.error) {
+        loaderStatusCodes.push(error.status);
+        appState.error = await serializeError(error.error);
+      } else {
+        loaderStatusCodes.push(500);
+        appState.error = await serializeError(error);
+      }
 
       if (serverMode !== ServerMode.Test) {
         console.error(
@@ -556,7 +791,7 @@ async function handleDocumentRequest({
       : loaderStatusCodes.find((status) => status !== 200);
 
   let responseStatusCode = appState.error
-    ? 500
+    ? errorStatus
     : typeof notOkResponse === "number"
     ? notOkResponse
     : appState.catch
@@ -628,14 +863,23 @@ async function handleResourceRequestRR(
   request: Request
 ) {
   try {
+    // Note we keep the routeId here to align with the Remix handling of
+    // resource routes which doesn't take ?index into account and just takes
+    // the leaf match
     let response = await staticHandler.queryRoute(request, routeId);
-    // Remix should always be returning responses from loaders and actions
+    // callRouteLoader/callRouteAction always return responses
     invariant(
       response instanceof Response,
       "Expected a Response to be returned from queryRoute"
     );
     return response;
   } catch (error) {
+    if (error instanceof Response) {
+      // Note: Not functionally required but ensures that our response headers
+      // match identically to what Remix returns
+      error.headers.set("X-Remix-Catch", "yes");
+      return error;
+    }
     return returnLastResortErrorResponse(error, serverMode);
   }
 }
@@ -783,72 +1027,6 @@ function getRenderableMatches(
   return matches.slice(0, lastRenderableIndex + 1);
 }
 
-async function assert(
-  a: Response,
-  b: Response,
-  accessor: (r: Response) => object | Promise<object>,
-  message: string
-) {
-  let aStr = JSON.stringify(await accessor(a));
-  let bStr = JSON.stringify(await accessor(b));
-  if (aStr !== bStr) {
-    console.error(message);
-    message += "\nResponse 1:\n" + aStr + "\nResponse 2:\n" + bStr;
-    throw new Error(message);
-  }
-}
-
-async function assertResponsesMatch(_a: Response, _b: Response) {
-  let a = _a.clone();
-  let b = _b.clone();
-  assert(
-    a,
-    b,
-    (r) => Object.fromEntries(r.headers.entries()),
-    "Headers did not match!"
-  );
-
-  if (a.headers.get("Content-Type")?.startsWith("application/json")) {
-    if (a.headers.get("X-Remix-Error")) {
-      assert(
-        a,
-        b,
-        async (r) => {
-          let { stack, ...json } = await r.json();
-          return {
-            ...json,
-            stack: stack ? "yes" : "no",
-          };
-        },
-        "JSON error response body did not match!\n Response 1:\n" +
-          (await a.clone().text()) +
-          "\nResponse 2:\n" +
-          (await b.clone().text())
-      );
-    } else {
-      assert(
-        a,
-        b,
-        (r) => r.json(),
-        "JSON response body did not match!\nResponse 1:\n" +
-          (await a.clone().text()) +
-          "\nResponse 2:\n" +
-          (await b.clone().text())
-      );
-    }
-  } else {
-    assert(
-      a,
-      b,
-      (r) => r.text(),
-      "Non-JSON response body did not match!\nResponse 1:\n" +
-        (await a.clone().text()) +
-        "\nResponse 2:\n" +
-        (await b.clone().text())
-    );
-  }
-}
-
 function returnLastResortErrorResponse(error: any, serverMode?: ServerMode) {
   if (serverMode !== ServerMode.Test) {
     console.error(error);
@@ -868,3 +1046,120 @@ function returnLastResortErrorResponse(error: any, serverMode?: ServerMode) {
     },
   });
 }
+
+// TODO: Remove before we "finalize" router migration
+// async function assert(
+//   a: Response,
+//   b: Response,
+//   accessor: (r: Response) => object | Promise<object>,
+//   message: string
+// ) {
+//   let aStr = JSON.stringify(await accessor(a));
+//   let bStr = JSON.stringify(await accessor(b));
+
+//   if (aStr !== bStr) {
+//     console.error(message);
+//     message += "\nResponse 1:\n" + aStr + "\nResponse 2:\n" + bStr;
+//     throw new Error(message);
+//   }
+// }
+
+// async function assertResponsesMatch(_a: Response, _b: Response) {
+//   let a = _a.clone();
+//   let b = _b.clone();
+//   assert(
+//     a,
+//     b,
+//     (r) => Object.fromEntries(r.headers.entries()),
+//     "Headers did not match!"
+//   );
+
+//   if (a.headers.get("Content-Type")?.startsWith("application/json")) {
+//     if (a.headers.get("X-Remix-Error")) {
+//       assert(
+//         a,
+//         b,
+//         async (r) => {
+//           let { stack, ...json } = await r.json();
+//           return {
+//             ...json,
+//             stack: stack ? "yes" : "no",
+//           };
+//         },
+//         "JSON error response body did not match!\n Response 1:\n" +
+//           (await a.clone().text()) +
+//           "\nResponse 2:\n" +
+//           (await b.clone().text())
+//       );
+//     } else {
+//       assert(
+//         a,
+//         b,
+//         (r) => r.json(),
+//         "JSON response body did not match!\nResponse 1:\n" +
+//           (await a.clone().text()) +
+//           "\nResponse 2:\n" +
+//           (await b.clone().text())
+//       );
+//     }
+//   } else {
+//     assert(
+//       a,
+//       b,
+//       async (r) => {
+//         let text = await r.text();
+
+//         // Strip stack trace from default error boundary HTML
+//         if (
+//           text.includes(">Application Error</h1>") &&
+//           text.includes("💿 Hey developer👋")
+//         ) {
+//           return stripStackTraceFromDefaultErrorBoundary(text);
+//         }
+
+//         if (text.includes("<script>window.__remixContext")) {
+//           return stripStackTraceFromRemixContext(text);
+//         }
+
+//         return text;
+//       },
+//       "Non-JSON response body did not match!\nResponse 1:\n" +
+//         (await a.clone().text()) +
+//         "\nResponse 2:\n" +
+//         (await b.clone().text())
+//     );
+//   }
+// }
+
+// function stripStackTraceFromDefaultErrorBoundary(text: string) {
+//   let openPreStart = text.indexOf("<pre ");
+//   let openPreEnd = text.indexOf('">', openPreStart + 1);
+//   let closePre = text.indexOf("</pre>", openPreEnd + 1);
+//   let error = text.substring(openPreEnd + 2, closePre);
+//   let stackStart = error.indexOf("\n");
+//   let errorWithoutStack = error.substring(0, stackStart);
+//   return (
+//     text.replace(error, "ERROR REMOVED") + "\n\nError:\n" + errorWithoutStack
+//   );
+// }
+
+// function stripStackTraceFromRemixContext(text: string) {
+//   let scriptStart = text.indexOf("<script>window.__remixContext");
+//   let scriptEnd = text.indexOf("</script>", scriptStart + 1);
+//   let scriptContents = text.substring(scriptStart, scriptEnd + 9);
+//   let remixContext = JSON.parse(
+//     scriptContents
+//       .replace("<script>window.__remixContext = ", "")
+//       .replace(";</script>", "")
+//   );
+//   // If we didn't have an error - assert the full document
+//   if (!remixContext?.appState?.error?.stack) {
+//     return text;
+//   }
+
+//   // Otherwise remove the stack trace and assert the text/JSON as a
+//   // concatenated string
+//   remixContext.appState.error.stack = "yes";
+//   text = text.replace(scriptContents, "CONTEXT REMOVED");
+//   return text + "\n\nContext:\n" + JSON.stringify(remixContext);
+// }


Test patch:
diff --git a/integration/catch-boundary-test.ts b/integration/catch-boundary-test.ts
index 46da4bea1..82932111b 100644
--- a/integration/catch-boundary-test.ts
+++ b/integration/catch-boundary-test.ts
@@ -13,10 +13,8 @@ test.describe("CatchBoundary", () => {
 
   let HAS_BOUNDARY_LOADER = "/yes/loader";
   let HAS_BOUNDARY_ACTION = "/yes/action";
-  let HAS_BOUNDARY_NO_LOADER_OR_ACTION = "/yes/no-loader-or-action";
   let NO_BOUNDARY_ACTION = "/no/action";
   let NO_BOUNDARY_LOADER = "/no/loader";
-  let NO_BOUNDARY_NO_LOADER_OR_ACTION = "/no/no-loader-or-action";
 
   let NOT_FOUND_HREF = "/not/found";
 
@@ -64,8 +62,6 @@ test.describe("CatchBoundary", () => {
                 <Form method="post">
                   <button formAction="${HAS_BOUNDARY_ACTION}" type="submit" />
                   <button formAction="${NO_BOUNDARY_ACTION}" type="submit" />
-                  <button formAction="${HAS_BOUNDARY_NO_LOADER_OR_ACTION}" type="submit" />
-                  <button formAction="${NO_BOUNDARY_NO_LOADER_OR_ACTION}" type="submit" />
                 </Form>
 
                 <Link to="${HAS_BOUNDARY_LOADER}">
@@ -79,39 +75,6 @@ test.describe("CatchBoundary", () => {
           }
         `,
 
-        "app/routes/fetcher-boundary.jsx": js`
-          import { useFetcher } from "@remix-run/react";
-          export function CatchBoundary() {
-            return <p id="fetcher-boundary">${OWN_BOUNDARY_TEXT}</p>
-          }
-          export default function() {
-            let fetcher = useFetcher();
-
-            return (
-              <div>
-                <fetcher.Form method="post">
-                  <button formAction="${NO_BOUNDARY_NO_LOADER_OR_ACTION}" type="submit" />
-                </fetcher.Form>
-              </div>
-            )
-          }
-        `,
-
-        "app/routes/fetcher-no-boundary.jsx": js`
-          import { useFetcher } from "@remix-run/react";
-          export default function() {
-            let fetcher = useFetcher();
-
-            return (
-              <div>
-                <fetcher.Form method="post">
-                  <button formAction="${NO_BOUNDARY_NO_LOADER_OR_ACTION}" type="submit" />
-                </fetcher.Form>
-              </div>
-            )
-          }
-        `,
-
         [`app/routes${HAS_BOUNDARY_ACTION}.jsx`]: js`
           import { Form } from "@remix-run/react";
           export async function action() {
@@ -147,21 +110,6 @@ test.describe("CatchBoundary", () => {
           }
         `,
 
-        [`app/routes${HAS_BOUNDARY_NO_LOADER_OR_ACTION}.jsx`]: js`
-          export function CatchBoundary() {
-            return <div id="boundary-no-loader-or-action">${OWN_BOUNDARY_TEXT}</div>
-          }
-          export default function Index() {
-            return <div/>
-          }
-        `,
-
-        [`app/routes${NO_BOUNDARY_NO_LOADER_OR_ACTION}.jsx`]: js`
-          export default function Index() {
-            return <div/>
-          }
-        `,
-
         [`app/routes${HAS_BOUNDARY_LOADER}.jsx`]: js`
           export function loader() {
             throw new Response("", { status: 401 })
@@ -250,12 +198,6 @@ test.describe("CatchBoundary", () => {
     await page.waitForSelector("#root-boundary");
   });
 
-  test("invalid request methods", async () => {
-    let res = await fixture.requestDocument("/", { method: "OPTIONS" });
-    expect(res.status).toBe(405);
-    expect(await res.text()).toMatch(ROOT_BOUNDARY_TEXT);
-  });
-
   test("own boundary, action, document request", async () => {
     let params = new URLSearchParams();
     let res = await fixture.postDocument(HAS_BOUNDARY_ACTION, params);
@@ -334,58 +276,6 @@ test.describe("CatchBoundary", () => {
     await page.waitForSelector("#root-boundary");
   });
 
-  test("renders root boundary in document POST without action requests", async () => {
-    let res = await fixture.requestDocument(NO_BOUNDARY_NO_LOADER_OR_ACTION, {
-      method: "post",
-    });
-    expect(res.status).toBe(405);
-    expect(await res.text()).toMatch(ROOT_BOUNDARY_TEXT);
-  });
-
-  test("renders root boundary in action script transitions without action from other routes", async ({
-    page,
-  }) => {
-    let app = new PlaywrightFixture(appFixture, page);
-    await app.goto("/");
-    await app.clickSubmitButton(NO_BOUNDARY_NO_LOADER_OR_ACTION);
-    await page.waitForSelector("#root-boundary");
-  });
-
-  test("renders own boundary in document POST without action requests", async () => {
-    let res = await fixture.requestDocument(HAS_BOUNDARY_NO_LOADER_OR_ACTION, {
-      method: "post",
-    });
-    expect(res.status).toBe(405);
-    expect(await res.text()).toMatch(OWN_BOUNDARY_TEXT);
-  });
-
-  test("renders own boundary in action script transitions without action from other routes", async ({
-    page,
-  }) => {
-    let app = new PlaywrightFixture(appFixture, page);
-    await app.goto("/");
-    await app.clickSubmitButton(HAS_BOUNDARY_NO_LOADER_OR_ACTION);
-    await page.waitForSelector("#boundary-no-loader-or-action");
-  });
-
-  test("renders own boundary in fetcher action submission without action from other routes", async ({
-    page,
-  }) => {
-    let app = new PlaywrightFixture(appFixture, page);
-    await app.goto("/fetcher-boundary");
-    await app.clickSubmitButton(NO_BOUNDARY_NO_LOADER_OR_ACTION);
-    await page.waitForSelector("#fetcher-boundary");
-  });
-
-  test("renders root boundary in fetcher action submission without action from other routes", async ({
-    page,
-  }) => {
-    let app = new PlaywrightFixture(appFixture, page);
-    await app.goto("/fetcher-no-boundary");
-    await app.clickSubmitButton(NO_BOUNDARY_NO_LOADER_OR_ACTION);
-    await page.waitForSelector("#root-boundary");
-  });
-
   test("uses correct catch boundary on server action errors", async ({
     page,
   }) => {
diff --git a/integration/compiler-test.ts b/integration/compiler-test.ts
index 4e4cdb571..3c7515f69 100644
--- a/integration/compiler-test.ts
+++ b/integration/compiler-test.ts
@@ -364,6 +364,19 @@ test.describe("compiler", () => {
   });
 
   test.describe("serverBareModulesPlugin", () => {
+    let ogConsole: typeof global.console;
+    test.beforeEach(() => {
+      ogConsole = global.console;
+      // @ts-ignore
+      global.console = {
+        log() {},
+        warn() {},
+        error() {},
+      };
+    });
+    test.afterEach(() => {
+      global.console = ogConsole;
+    });
     test("warns when a module isn't installed", async () => {
       let buildOutput: string;
       let buildStdio = new PassThrough();
diff --git a/integration/error-boundary-test.ts b/integration/error-boundary-test.ts
index 92c94327e..d90ec5882 100644
--- a/integration/error-boundary-test.ts
+++ b/integration/error-boundary-test.ts
@@ -15,10 +15,12 @@ test.describe("ErrorBoundary", () => {
   let HAS_BOUNDARY_LOADER = "/yes/loader";
   let HAS_BOUNDARY_ACTION = "/yes/action";
   let HAS_BOUNDARY_RENDER = "/yes/render";
+  let HAS_BOUNDARY_NO_LOADER_OR_ACTION = "/yes/no-loader-or-action";
 
   let NO_BOUNDARY_ACTION = "/no/action";
   let NO_BOUNDARY_LOADER = "/no/loader";
   let NO_BOUNDARY_RENDER = "/no/render";
+  let NO_BOUNDARY_NO_LOADER_OR_ACTION = "/no/no-loader-or-action";
 
   let NOT_FOUND_HREF = "/not/found";
 
@@ -56,7 +58,7 @@ test.describe("ErrorBoundary", () => {
                 <head />
                 <body>
                   <main>
-                    <div>${ROOT_BOUNDARY_TEXT}</div>
+                    <div id="root-boundary">${ROOT_BOUNDARY_TEXT}</div>
                   </main>
                   <Scripts />
                 </body>
@@ -79,6 +81,12 @@ test.describe("ErrorBoundary", () => {
                   <button formAction="${NO_BOUNDARY_ACTION}" type="submit">
                     No Boundary
                   </button>
+                  <button formAction="${HAS_BOUNDARY_NO_LOADER_OR_ACTION}" type="submit">
+                    Has Boundary No Loader or Action
+                  </button>
+                  <button formAction="${NO_BOUNDARY_NO_LOADER_OR_ACTION}" type="submit">
+                    No Boundary No Loader or Action
+                  </button>
                 </Form>
 
                 <Link to="${HAS_BOUNDARY_LOADER}">
@@ -104,7 +112,7 @@ test.describe("ErrorBoundary", () => {
             throw new Error("Kaboom!")
           }
           export function ErrorBoundary() {
-            return <p>${OWN_BOUNDARY_TEXT}</p>
+            return <p id="own-boundary">${OWN_BOUNDARY_TEXT}</p>
           }
           export default function () {
             return (
@@ -138,7 +146,7 @@ test.describe("ErrorBoundary", () => {
             throw new Error("Kaboom!")
           }
           export function ErrorBoundary() {
-            return <div>${OWN_BOUNDARY_TEXT}</div>
+            return <div id="own-boundary">${OWN_BOUNDARY_TEXT}</div>
           }
           export default function () {
             return <div/>
@@ -168,7 +176,57 @@ test.describe("ErrorBoundary", () => {
           }
 
           export function ErrorBoundary() {
-            return <div>${OWN_BOUNDARY_TEXT}</div>
+            return <div id="own-boundary">${OWN_BOUNDARY_TEXT}</div>
+          }
+        `,
+
+        [`app/routes${HAS_BOUNDARY_NO_LOADER_OR_ACTION}.jsx`]: js`
+          export function ErrorBoundary() {
+            return <div id="boundary-no-loader-or-action">${OWN_BOUNDARY_TEXT}</div>
+          }
+          export default function Index() {
+            return <div/>
+          }
+        `,
+
+        [`app/routes${NO_BOUNDARY_NO_LOADER_OR_ACTION}.jsx`]: js`
+          export default function Index() {
+            return <div/>
+          }
+        `,
+
+        "app/routes/fetcher-boundary.jsx": js`
+          import { useFetcher } from "@remix-run/react";
+          export function ErrorBoundary() {
+            return <p id="fetcher-boundary">${OWN_BOUNDARY_TEXT}</p>
+          }
+          export default function() {
+            let fetcher = useFetcher();
+
+            return (
+              <div>
+                <fetcher.Form method="post">
+                  <button formAction="${NO_BOUNDARY_NO_LOADER_OR_ACTION}" type="submit" />
+                </fetcher.Form>
+              </div>
+            )
+          }
+        `,
+
+        "app/routes/fetcher-no-boundary.jsx": js`
+          import { useFetcher } from "@remix-run/react";
+          export default function() {
+            let fetcher = useFetcher();
+
+            return (
+              <div>
+                <fetcher.Form method="post">
+                  <button formAction="${NO_BOUNDARY_NO_LOADER_OR_ACTION}" type="submit">
+                    No Loader or Action
+                  </button>
+                </fetcher.Form>
+              </div>
+            )
           }
         `,
 
@@ -228,6 +286,12 @@ test.describe("ErrorBoundary", () => {
     await appFixture.close();
   });
 
+  test("invalid request methods", async () => {
+    let res = await fixture.requestDocument("/", { method: "OPTIONS" });
+    expect(res.status).toBe(405);
+    expect(await res.text()).toMatch(ROOT_BOUNDARY_TEXT);
+  });
+
   test("own boundary, action, document request", async () => {
     let params = new URLSearchParams();
     let res = await fixture.postDocument(HAS_BOUNDARY_ACTION, params);
@@ -235,17 +299,13 @@ test.describe("ErrorBoundary", () => {
     expect(await res.text()).toMatch(OWN_BOUNDARY_TEXT);
   });
 
-  // FIXME: this is broken, test renders the root boundary logging in `RemixRoute`
-  // test's because the route module hasn't been loaded, my gut tells me that we
-  // didn't load the route module but tried to render test's boundary, we need the
-  // module for that!  this will probably fix the twin test over in
-  // catch-boundary-test
-  test.skip("own boundary, action, client transition from other route", async ({
+  test("own boundary, action, client transition from other route", async ({
     page,
   }) => {
     let app = new PlaywrightFixture(appFixture, page);
     await app.goto("/");
     await app.clickSubmitButton(HAS_BOUNDARY_ACTION);
+    await page.waitForSelector("#own-boundary");
     expect(await app.getHtml("main")).toMatch(OWN_BOUNDARY_TEXT);
   });
 
@@ -255,6 +315,7 @@ test.describe("ErrorBoundary", () => {
     let app = new PlaywrightFixture(appFixture, page);
     await app.goto(HAS_BOUNDARY_ACTION);
     await app.clickSubmitButton(HAS_BOUNDARY_ACTION);
+    await page.waitForSelector("#own-boundary");
     expect(await app.getHtml("main")).toMatch(OWN_BOUNDARY_TEXT);
   });
 
@@ -353,9 +414,62 @@ test.describe("ErrorBoundary", () => {
     expect(await app.getHtml("#child-error")).toMatch("Broken!");
   });
 
+  test("renders own boundary in fetcher action submission without action from other routes", async ({
+    page,
+  }) => {
+    let app = new PlaywrightFixture(appFixture, page);
+    await app.goto("/fetcher-boundary");
+    await app.clickSubmitButton(NO_BOUNDARY_NO_LOADER_OR_ACTION);
+    await page.waitForSelector("#fetcher-boundary");
+  });
+
+  test("renders root boundary in fetcher action submission without action from other routes", async ({
+    page,
+  }) => {
+    let app = new PlaywrightFixture(appFixture, page);
+    await app.goto("/fetcher-no-boundary");
+    await app.clickSubmitButton(NO_BOUNDARY_NO_LOADER_OR_ACTION);
+    await page.waitForSelector("#root-boundary");
+  });
+
+  test("renders root boundary in document POST without action requests", async () => {
+    let res = await fixture.requestDocument(NO_BOUNDARY_NO_LOADER_OR_ACTION, {
+      method: "post",
+    });
+    expect(res.status).toBe(405);
+    expect(await res.text()).toMatch(ROOT_BOUNDARY_TEXT);
+  });
+
+  test("renders root boundary in action script transitions without action from other routes", async ({
+    page,
+  }) => {
+    let app = new PlaywrightFixture(appFixture, page);
+    await app.goto("/");
+    await app.clickSubmitButton(NO_BOUNDARY_NO_LOADER_OR_ACTION);
+    await page.waitForSelector("#root-boundary");
+  });
+
+  test("renders own boundary in document POST without action requests", async () => {
+    let res = await fixture.requestDocument(HAS_BOUNDARY_NO_LOADER_OR_ACTION, {
+      method: "post",
+    });
+    expect(res.status).toBe(405);
+    expect(await res.text()).toMatch(OWN_BOUNDARY_TEXT);
+  });
+
+  test("renders own boundary in action script transitions without action from other routes", async ({
+    page,
+  }) => {
+    let app = new PlaywrightFixture(appFixture, page);
+    await app.goto("/");
+    await app.clickSubmitButton(HAS_BOUNDARY_NO_LOADER_OR_ACTION);
+    await page.waitForSelector("#boundary-no-loader-or-action");
+  });
+
   test.describe("if no error boundary exists in the app", () => {
     let NO_ROOT_BOUNDARY_LOADER = "/loader-bad";
     let NO_ROOT_BOUNDARY_ACTION = "/action-bad";
+    let NO_ROOT_BOUNDARY_LOADER_RETURN = "/loader-no-return";
     let NO_ROOT_BOUNDARY_ACTION_RETURN = "/action-no-return";
 
     test.beforeAll(async () => {
@@ -364,20 +478,20 @@ test.describe("ErrorBoundary", () => {
           "app/root.jsx": js`
             import { Links, Meta, Outlet, Scripts } from "@remix-run/react";
 
-          export default function Root() {
-            return (
-              <html lang="en">
-                <head>
-                  <Meta />
-                  <Links />
-                </head>
-                <body>
-                  <Outlet />
-                  <Scripts />
-                </body>
-              </html>
-            );
-          }
+            export default function Root() {
+              return (
+                <html lang="en">
+                  <head>
+                    <Meta />
+                    <Links />
+                  </head>
+                  <body>
+                    <Outlet />
+                    <Scripts />
+                  </body>
+                </html>
+              );
+            }
           `,
 
           "app/routes/index.jsx": js`
@@ -387,6 +501,7 @@ test.describe("ErrorBoundary", () => {
               return (
                 <div>
                   <h1>Home</h1>
+                  <Link to="${NO_ROOT_BOUNDARY_LOADER_RETURN}">Loader no return</Link>
                   <Form method="post">
                     <button formAction="${NO_ROOT_BOUNDARY_ACTION}" type="submit">
                       Action go boom
@@ -401,8 +516,6 @@ test.describe("ErrorBoundary", () => {
           `,
 
           [`app/routes${NO_ROOT_BOUNDARY_LOADER}.jsx`]: js`
-            import { Link, Form } from "@remix-run/react";
-
             export async function loader() {
               throw Error("BLARGH");
             }
@@ -417,8 +530,6 @@ test.describe("ErrorBoundary", () => {
           `,
 
           [`app/routes${NO_ROOT_BOUNDARY_ACTION}.jsx`]: js`
-            import { Link, Form } from "@remix-run/react";
-
             export async function action() {
               throw Error("YOOOOOOOO WHAT ARE YOU DOING");
             }
@@ -432,8 +543,23 @@ test.describe("ErrorBoundary", () => {
             }
           `,
 
+          [`app/routes${NO_ROOT_BOUNDARY_LOADER_RETURN}.jsx`]: js`
+            import { useLoaderData } from "@remix-run/react";
+
+            export async function loader() {}
+
+            export default function () {
+              let data = useLoaderData();
+              return (
+                <div>
+                  <h1>{data}</h1>
+                </div>
+              )
+            }
+          `,
+
           [`app/routes${NO_ROOT_BOUNDARY_ACTION_RETURN}.jsx`]: js`
-            import { Link, Form, useActionData } from "@remix-run/react";
+            import { useActionData } from "@remix-run/react";
 
             export async function action() {}
 
@@ -465,15 +591,41 @@ test.describe("ErrorBoundary", () => {
       let app = new PlaywrightFixture(appFixture, page);
       await app.goto("/");
       await app.clickSubmitButton(NO_ROOT_BOUNDARY_ACTION);
+      await page.waitForSelector(`text=${INTERNAL_ERROR_BOUNDARY_HEADING}`)
       expect(await app.getHtml("h1")).toMatch(INTERNAL_ERROR_BOUNDARY_HEADING);
     });
 
+    test("bubbles to internal boundary if loader doesn't return (document requests)", async () => {
+      let res = await fixture.requestDocument(NO_ROOT_BOUNDARY_LOADER_RETURN);
+      expect(res.status).toBe(500);
+      expect(await res.text()).toMatch(INTERNAL_ERROR_BOUNDARY_HEADING);
+    });
+
+    test("bubbles to internal boundary if loader doesn't return", async ({
+      page,
+    }) => {
+      let app = new PlaywrightFixture(appFixture, page);
+      await app.goto("/");
+      await app.clickLink(NO_ROOT_BOUNDARY_LOADER_RETURN);
+      await page.waitForSelector(`text=${INTERNAL_ERROR_BOUNDARY_HEADING}`)
+      expect(await app.getHtml("h1")).toMatch(INTERNAL_ERROR_BOUNDARY_HEADING);
+    });
+
+    test("bubbles to internal boundary if action doesn't return (document requests)", async () => {
+      let res = await fixture.requestDocument(NO_ROOT_BOUNDARY_ACTION_RETURN, {
+        method: "post",
+      });
+      expect(res.status).toBe(500);
+      expect(await res.text()).toMatch(INTERNAL_ERROR_BOUNDARY_HEADING);
+    });
+
     test("bubbles to internal boundary if action doesn't return", async ({
       page,
     }) => {
       let app = new PlaywrightFixture(appFixture, page);
       await app.goto("/");
       await app.clickSubmitButton(NO_ROOT_BOUNDARY_ACTION_RETURN);
+      await page.waitForSelector(`text=${INTERNAL_ERROR_BOUNDARY_HEADING}`)
       expect(await app.getHtml("h1")).toMatch(INTERNAL_ERROR_BOUNDARY_HEADING);
     });
   });
diff --git a/integration/error-data-request-test.ts b/integration/error-data-request-test.ts
new file mode 100644
index 000000000..537e282f6
--- /dev/null
+++ b/integration/error-data-request-test.ts
@@ -0,0 +1,174 @@
+import { test, expect } from "@playwright/test";
+
+import { createAppFixture, createFixture, js } from "./helpers/create-fixture";
+import type { Fixture, AppFixture } from "./helpers/create-fixture";
+
+test.describe("ErrorBoundary", () => {
+  let fixture: Fixture;
+  let appFixture: AppFixture;
+  let _consoleError: any;
+  let errorLogs: string[];
+
+  test.beforeAll(async () => {
+    _consoleError = console.error;
+    console.error = (whatever) => {
+      errorLogs.push(String(whatever));
+    };
+
+    fixture = await createFixture({
+      files: {
+        "app/root.jsx": js`
+          import { Links, Meta, Outlet, Scripts } from "@remix-run/react";
+
+        export default function Root() {
+          return (
+            <html lang="en">
+              <head>
+                <Meta />
+                <Links />
+              </head>
+              <body>
+                <Outlet />
+                <Scripts />
+              </body>
+            </html>
+          );
+        }
+        `,
+
+        "app/routes/index.jsx": js`
+          import { Link, Form } from "@remix-run/react";
+
+          export default function () {
+            return <h1>Index</h1>
+          }
+        `,
+
+        [`app/routes/loader-throw-error.jsx`]: js`
+          export async function loader() {
+            throw Error("BLARGH");
+          }
+
+          export default function () {
+              return <h1>Hello</h1>
+          }
+        `,
+
+        [`app/routes/loader-return-json.jsx`]: js`
+          import { json } from "@remix-run/server-runtime";
+
+          export async function loader() {
+            return json({ ok: true });
+          }
+
+          export default function () {
+              return <h1>Hello</h1>
+          }
+        `,
+
+        [`app/routes/action-throw-error.jsx`]: js`
+          export async function action() {
+            throw Error("YOOOOOOOO WHAT ARE YOU DOING");
+          }
+
+          export default function () {
+            return <h1>Goodbye</h1>;
+          }
+        `,
+
+        [`app/routes/action-return-json.jsx`]: js`
+          import { json } from "@remix-run/server-runtime";
+
+          export async function action() {
+            return json({ ok: true });
+          }
+
+          export default function () {
+            return <h1>Hi!</h1>
+          }
+        `,
+      },
+    });
+    appFixture = await createAppFixture(fixture);
+  });
+
+  test.beforeEach(async () => {
+    errorLogs = [];
+  });
+
+  test.afterAll(async () => {
+    console.error = _consoleError;
+    await appFixture.close();
+  });
+
+  function assertConsoleError(str: string) {
+    expect(errorLogs[0]).toEqual(str);
+  }
+
+  test("returns a 400 x-remix-error on a data fetch to a path with no loader", async () => {
+    let response = await fixture.requestData("/", "routes/index");
+    expect(response.status).toBe(400);
+    expect(response.headers.get("X-Remix-Error")).toBe("yes");
+    expect(await response.text()).toMatch("Unexpected Server Error");
+    assertConsoleError(
+      'Error: You made a GET request to "/" but did not provide a `loader` for route "routes/index", so there is no way to handle the request.'
+    );
+  });
+
+  test("returns a 405 x-remix-error on a data fetch POST to a path with no action", async () => {
+    let response = await fixture.requestData("/?index", "routes/index", {
+      method: "POST",
+    });
+    expect(response.status).toBe(405);
+    expect(response.headers.get("X-Remix-Error")).toBe("yes");
+    expect(await response.text()).toMatch("Unexpected Server Error");
+    assertConsoleError(
+      'Error: You made a POST request to "/" but did not provide an `action` for route "routes/index", so there is no way to handle the request.'
+    );
+  });
+
+  test("returns a 405 x-remix-error on a data fetch with a bad method", async () => {
+    let response = await fixture.requestData(
+      `/loader-return-json`,
+      "routes/loader-return-json",
+      {
+        method: "OPTIONS",
+      }
+    );
+    expect(response.status).toBe(405);
+    expect(response.headers.get("X-Remix-Error")).toBe("yes");
+    expect(await response.text()).toMatch("Unexpected Server Error");
+    assertConsoleError('Error: Invalid request method "OPTIONS"');
+  });
+
+  test("returns a 403 x-remix-error on a data fetch GET to a bad path", async () => {
+    // just headers content-type mismatch but differs from POST below
+    let response = await fixture.requestData("/", "routes/loader-return-json");
+    expect(response.status).toBe(403);
+    expect(response.headers.get("X-Remix-Error")).toBe("yes");
+    expect(await response.text()).toMatch("Unexpected Server Error");
+    assertConsoleError(
+      'Error: Route "routes/loader-return-json" does not match URL "/"'
+    );
+  });
+
+  test("returns a 403 x-remix-error on a data fetch POST to a bad path", async () => {
+    let response = await fixture.requestData("/", "routes/loader-return-json", {
+      method: "POST",
+    });
+    expect(response.status).toBe(403);
+    expect(response.headers.get("X-Remix-Error")).toBe("yes");
+    expect(await response.text()).toMatch("Unexpected Server Error");
+    assertConsoleError(
+      'Error: Route "routes/loader-return-json" does not match URL "/"'
+    );
+  });
+
+  test("returns a 404 x-remix-error on a data fetch to a path with no matches", async () => {
+    let response = await fixture.requestData("/i/match/nothing", "routes/junk");
+    expect(response.status).toBe(404);
+    expect(response.headers.get("X-Remix-Error")).toBe("yes");
+    expect(await response.text()).toMatch("Unexpected Server Error");
+    assertConsoleError('Error: No route matches URL "/i/match/nothing"');
+  });
+});
diff --git a/integration/fetcher-test.ts b/integration/fetcher-test.ts
index 39e6534a0..b7916692d 100644
--- a/integration/fetcher-test.ts
+++ b/integration/fetcher-test.ts
@@ -168,7 +168,7 @@ test.describe("useFetcher", () => {
         }),
       ]);
 
-      expect(await app.getHtml("pre")).toMatch(LUNCH);
+      await page.waitForSelector(`pre:has-text("${LUNCH}")`);
     });
 
     test("Form can hit an action", async ({ page }) => {
@@ -181,7 +181,7 @@ test.describe("useFetcher", () => {
           method: "post",
         }),
       ]);
-      expect(await app.getHtml("pre")).toMatch(CHEESESTEAK);
+      await page.waitForSelector(`pre:has-text("${CHEESESTEAK}")`);
     });
   });
 
@@ -189,21 +189,21 @@ test.describe("useFetcher", () => {
     let app = new PlaywrightFixture(appFixture, page);
     await app.goto("/");
     await app.clickElement("#fetcher-load");
-    expect(await app.getHtml("pre")).toMatch(LUNCH);
+    await page.waitForSelector(`pre:has-text("${LUNCH}")`);
   });
 
   test("submit can hit an action", async ({ page }) => {
     let app = new PlaywrightFixture(appFixture, page);
     await app.goto("/");
     await app.clickElement("#fetcher-submit");
-    expect(await app.getHtml("pre")).toMatch(CHEESESTEAK);
+    await page.waitForSelector(`pre:has-text("${CHEESESTEAK}")`);
   });
 
   test("submit can hit an action only route", async ({ page }) => {
     let app = new PlaywrightFixture(appFixture, page);
     await app.goto("/fetcher-action-only-call");
     await app.clickElement("#fetcher-submit");
-    expect(await app.getHtml("pre")).toMatch(CHEESESTEAK);
+    await page.waitForSelector(`pre:has-text("${CHEESESTEAK}")`);
   });
 
   test("fetchers handle ?index param correctly", async ({ page }) => {
@@ -211,25 +211,25 @@ test.describe("useFetcher", () => {
     await app.goto("/parent");
 
     await app.clickElement("#load-parent");
-    expect(await app.getHtml("pre")).toMatch(PARENT_LAYOUT_LOADER);
+    await page.waitForSelector(`pre:has-text("${PARENT_LAYOUT_LOADER}")`);
 
     await app.clickElement("#load-index");
-    expect(await app.getHtml("pre")).toMatch(PARENT_INDEX_LOADER);
+    await page.waitForSelector(`pre:has-text("${PARENT_INDEX_LOADER}")`);
 
     // fetcher.submit({}) defaults to GET for the current Route
     await app.clickElement("#submit-empty");
-    expect(await app.getHtml("pre")).toMatch(PARENT_INDEX_LOADER);
+    await page.waitForSelector(`pre:has-text("${PARENT_INDEX_LOADER}")`);
 
     await app.clickElement("#submit-parent-get");
-    expect(await app.getHtml("pre")).toMatch(PARENT_LAYOUT_LOADER);
+    await page.waitForSelector(`pre:has-text("${PARENT_LAYOUT_LOADER}")`);
 
     await app.clickElement("#submit-index-get");
-    expect(await app.getHtml("pre")).toMatch(PARENT_INDEX_LOADER);
+    await page.waitForSelector(`pre:has-text("${PARENT_INDEX_LOADER}")`);
 
     await app.clickElement("#submit-parent-post");
-    expect(await app.getHtml("pre")).toMatch(PARENT_LAYOUT_ACTION);
+    await page.waitForSelector(`pre:has-text("${PARENT_LAYOUT_ACTION}")`);
 
     await app.clickElement("#submit-index-post");
-    expect(await app.getHtml("pre")).toMatch(PARENT_INDEX_ACTION);
+    await page.waitForSelector(`pre:has-text("${PARENT_INDEX_ACTION}")`);
   });
 });
diff --git a/integration/mdx-test.ts b/integration/mdx-test.ts
index c93b9bb71..8c0a6d635 100644
--- a/integration/mdx-test.ts
+++ b/integration/mdx-test.ts
@@ -17,7 +17,6 @@ test.describe("mdx", () => {
     fixture = await createFixture({
       files: {
         "app/root.jsx": js`
-        import { json } from "@remix-run/node";
         import { Links, Meta, Outlet, Scripts } from "@remix-run/react";
 
           export default function Root() {
@@ -37,10 +36,9 @@ test.describe("mdx", () => {
         `,
 
         "app/routes/blog.jsx": js`
-          import { json } from "@remix-run/node";
           import { useMatches, Outlet } from "@remix-run/react";
 
-          export default function Index() {
+          export default function Blog() {
             const matches = useMatches();
             const mdxMatch = matches[matches.length - 1];
             return (
diff --git a/integration/transition-test.ts b/integration/transition-test.ts
index 05d831ecb..8a60e5a88 100644
--- a/integration/transition-test.ts
+++ b/integration/transition-test.ts
@@ -213,10 +213,11 @@ test.describe("rendering", () => {
     await app.goto("/");
     let responses = app.collectDataResponses();
     await app.clickLink(`/${PAGE}`);
+    await page.waitForLoadState("networkidle");
 
     expect(
-      responses.map((res) => new URL(res.url()).searchParams.get("_data"))
-    ).toEqual([`routes/${PAGE}`, `routes/${PAGE}/index`]);
+      responses.map((res) => new URL(res.url()).searchParams.get("_data")).sort()
+    ).toEqual([`routes/${PAGE}`, `routes/${PAGE}/index`].sort());
 
     await page.waitForSelector(`h2:has-text("${PAGE_TEXT}")`);
     await page.waitForSelector(`h3:has-text("${PAGE_INDEX_TEXT}")`);
@@ -227,6 +228,7 @@ test.describe("rendering", () => {
     await app.goto(`/${PAGE}`);
     let responses = app.collectDataResponses();
     await app.clickLink(`/${PAGE}/${CHILD}`);
+    await page.waitForLoadState("networkidle");
 
     expect(
       responses.map((res) => new URL(res.url()).searchParams.get("_data"))
@@ -244,6 +246,7 @@ test.describe("rendering", () => {
 
     await app.clickLink(`/${REDIRECT}`);
     await page.waitForURL(/\/page/);
+    await page.waitForLoadState("networkidle");
 
     expect(
       responses
diff --git a/packages/remix-server-runtime/__tests__/server-test.ts b/packages/remix-server-runtime/__tests__/server-test.ts
index 391eacb2b..da5d4cc7e 100644
--- a/packages/remix-server-runtime/__tests__/server-test.ts
+++ b/packages/remix-server-runtime/__tests__/server-test.ts
@@ -3,7 +3,7 @@ import { ServerMode } from "../mode";
 import type { ServerBuild } from "../build";
 import { mockServerBuild } from "./utils";
 
-const DATA_CALL_MULTIPIER = process.env.ENABLE_REMIX_ROUTER ? 2 : 1;
+const DATA_CALL_MULTIPIER = 1;
 
 function spyConsole() {
   // https://github.com/facebook/react/issues/7047
@@ -409,7 +409,7 @@ describe("shared server runtime", () => {
       });
 
       let result = await handler(request);
-      expect(result.status).toBe(500);
+      expect(result.status).toBe(400);
       expect(result.headers.get("X-Remix-Error")).toBe("yes");
       expect((await result.json()).message).toBeTruthy();
     });
@@ -757,10 +757,12 @@ describe("shared server runtime", () => {
       let result = await handler(request);
       expect(result.status).toBe(404);
       expect(rootLoader.mock.calls.length).toBe(0);
-      expect(build.entry.module.default.mock.calls.length).toBe(1);
+      expect(build.entry.module.default.mock.calls.length).toBe(
+        1 * DATA_CALL_MULTIPIER
+      );
 
       let calls = build.entry.module.default.mock.calls;
-      expect(calls.length).toBe(1);
+      expect(calls.length).toBe(1 * DATA_CALL_MULTIPIER);
       let entryContext = calls[0][3];
       expect(entryContext.appState.catch).toBeTruthy();
       expect(entryContext.appState.catch!.status).toBe(404);
@@ -785,10 +787,12 @@ describe("shared server runtime", () => {
       let result = await handler(request);
       expect(result.status).toBe(404);
       expect(rootLoader.mock.calls.length).toBe(0);
-      expect(build.entry.module.default.mock.calls.length).toBe(1);
+      expect(build.entry.module.default.mock.calls.length).toBe(
+        1 * DATA_CALL_MULTIPIER
+      );
 
       let calls = build.entry.module.default.mock.calls;
-      expect(calls.length).toBe(1);
+      expect(calls.length).toBe(1 * DATA_CALL_MULTIPIER);
       let entryContext = calls[0][3];
       expect(entryContext.appState.catch).toBeTruthy();
       expect(entryContext.appState.catch!.status).toBe(404);
@@ -822,12 +826,14 @@ describe("shared server runtime", () => {
 
       let result = await handler(request);
       expect(result.status).toBe(400);
-      expect(rootLoader.mock.calls.length).toBe(1);
-      expect(indexLoader.mock.calls.length).toBe(1);
-      expect(build.entry.module.default.mock.calls.length).toBe(1);
+      expect(rootLoader.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(indexLoader.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(build.entry.module.default.mock.calls.length).toBe(
+        1 * DATA_CALL_MULTIPIER
+      );
 
       let calls = build.entry.module.default.mock.calls;
-      expect(calls.length).toBe(1);
+      expect(calls.length).toBe(1 * DATA_CALL_MULTIPIER);
       let entryContext = calls[0][3];
       expect(entryContext.appState.catch).toBeTruthy();
       expect(entryContext.appState.catch!.status).toBe(400);
@@ -864,12 +870,14 @@ describe("shared server runtime", () => {
 
       let result = await handler(request);
       expect(result.status).toBe(400);
-      expect(rootLoader.mock.calls.length).toBe(1);
-      expect(indexLoader.mock.calls.length).toBe(1);
-      expect(build.entry.module.default.mock.calls.length).toBe(1);
+      expect(rootLoader.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(indexLoader.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(build.entry.module.default.mock.calls.length).toBe(
+        1 * DATA_CALL_MULTIPIER
+      );
 
       let calls = build.entry.module.default.mock.calls;
-      expect(calls.length).toBe(1);
+      expect(calls.length).toBe(1 * DATA_CALL_MULTIPIER);
       let entryContext = calls[0][3];
       expect(entryContext.appState.catch).toBeTruthy();
       expect(entryContext.appState.catch!.status).toBe(400);
@@ -909,14 +917,16 @@ describe("shared server runtime", () => {
 
       let result = await handler(request);
       expect(result.status).toBe(400);
-      expect(testAction.mock.calls.length).toBe(1);
+      expect(testAction.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
       // Should not call root loader since it is the boundary route
       expect(rootLoader.mock.calls.length).toBe(0);
       expect(testLoader.mock.calls.length).toBe(0);
-      expect(build.entry.module.default.mock.calls.length).toBe(1);
+      expect(build.entry.module.default.mock.calls.length).toBe(
+        1 * DATA_CALL_MULTIPIER
+      );
 
       let calls = build.entry.module.default.mock.calls;
-      expect(calls.length).toBe(1);
+      expect(calls.length).toBe(1 * DATA_CALL_MULTIPIER);
       let entryContext = calls[0][3];
       expect(entryContext.appState.catch).toBeTruthy();
       expect(entryContext.appState.catch!.status).toBe(400);
@@ -954,14 +964,16 @@ describe("shared server runtime", () => {
 
       let result = await handler(request);
       expect(result.status).toBe(400);
-      expect(indexAction.mock.calls.length).toBe(1);
+      expect(indexAction.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
       // Should not call root loader since it is the boundary route
       expect(rootLoader.mock.calls.length).toBe(0);
       expect(indexLoader.mock.calls.length).toBe(0);
-      expect(build.entry.module.default.mock.calls.length).toBe(1);
+      expect(build.entry.module.default.mock.calls.length).toBe(
+        1 * DATA_CALL_MULTIPIER
+      );
 
       let calls = build.entry.module.default.mock.calls;
-      expect(calls.length).toBe(1);
+      expect(calls.length).toBe(1 * DATA_CALL_MULTIPIER);
       let entryContext = calls[0][3];
       expect(entryContext.appState.catch).toBeTruthy();
       expect(entryContext.appState.catch!.status).toBe(400);
@@ -1000,13 +1012,15 @@ describe("shared server runtime", () => {
 
       let result = await handler(request);
       expect(result.status).toBe(400);
-      expect(testAction.mock.calls.length).toBe(1);
-      expect(rootLoader.mock.calls.length).toBe(1);
+      expect(testAction.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(rootLoader.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
       expect(testLoader.mock.calls.length).toBe(0);
-      expect(build.entry.module.default.mock.calls.length).toBe(1);
+      expect(build.entry.module.default.mock.calls.length).toBe(
+        1 * DATA_CALL_MULTIPIER
+      );
 
       let calls = build.entry.module.default.mock.calls;
-      expect(calls.length).toBe(1);
+      expect(calls.length).toBe(1 * DATA_CALL_MULTIPIER);
       let entryContext = calls[0][3];
       expect(entryContext.appState.catch).toBeTruthy();
       expect(entryContext.appState.catch!.status).toBe(400);
@@ -1047,13 +1061,15 @@ describe("shared server runtime", () => {
 
       let result = await handler(request);
       expect(result.status).toBe(400);
-      expect(indexAction.mock.calls.length).toBe(1);
-      expect(rootLoader.mock.calls.length).toBe(1);
+      expect(indexAction.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(rootLoader.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
       expect(indexLoader.mock.calls.length).toBe(0);
-      expect(build.entry.module.default.mock.calls.length).toBe(1);
+      expect(build.entry.module.default.mock.calls.length).toBe(
+        1 * DATA_CALL_MULTIPIER
+      );
 
       let calls = build.entry.module.default.mock.calls;
-      expect(calls.length).toBe(1);
+      expect(calls.length).toBe(1 * DATA_CALL_MULTIPIER);
       let entryContext = calls[0][3];
       expect(entryContext.appState.catch).toBeTruthy();
       expect(entryContext.appState.catch!.status).toBe(400);
@@ -1102,13 +1118,15 @@ describe("shared server runtime", () => {
 
       let result = await handler(request);
       expect(result.status).toBe(400);
-      expect(testAction.mock.calls.length).toBe(1);
-      expect(rootLoader.mock.calls.length).toBe(1);
+      expect(testAction.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(rootLoader.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
       expect(testLoader.mock.calls.length).toBe(0);
-      expect(build.entry.module.default.mock.calls.length).toBe(1);
+      expect(build.entry.module.default.mock.calls.length).toBe(
+        1 * DATA_CALL_MULTIPIER
+      );
 
       let calls = build.entry.module.default.mock.calls;
-      expect(calls.length).toBe(1);
+      expect(calls.length).toBe(1 * DATA_CALL_MULTIPIER);
       let entryContext = calls[0][3];
       expect(entryContext.appState.catch).toBeTruthy();
       expect(entryContext.appState.catch.data).toBe("action");
@@ -1159,13 +1177,15 @@ describe("shared server runtime", () => {
 
       let result = await handler(request);
       expect(result.status).toBe(400);
-      expect(indexAction.mock.calls.length).toBe(1);
-      expect(rootLoader.mock.calls.length).toBe(1);
+      expect(indexAction.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(rootLoader.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
       expect(indexLoader.mock.calls.length).toBe(0);
-      expect(build.entry.module.default.mock.calls.length).toBe(1);
+      expect(build.entry.module.default.mock.calls.length).toBe(
+        1 * DATA_CALL_MULTIPIER
+      );
 
       let calls = build.entry.module.default.mock.calls;
-      expect(calls.length).toBe(1);
+      expect(calls.length).toBe(1 * DATA_CALL_MULTIPIER);
       let entryContext = calls[0][3];
       expect(entryContext.appState.catch).toBeTruthy();
       expect(entryContext.appState.catch.data).toBe("action");
@@ -1203,12 +1223,14 @@ describe("shared server runtime", () => {
 
       let result = await handler(request);
       expect(result.status).toBe(500);
-      expect(rootLoader.mock.calls.length).toBe(1);
-      expect(indexLoader.mock.calls.length).toBe(1);
-      expect(build.entry.module.default.mock.calls.length).toBe(1);
+      expect(rootLoader.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(indexLoader.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(build.entry.module.default.mock.calls.length).toBe(
+        1 * DATA_CALL_MULTIPIER
+      );
 
       let calls = build.entry.module.default.mock.calls;
-      expect(calls.length).toBe(1);
+      expect(calls.length).toBe(1 * DATA_CALL_MULTIPIER);
       let entryContext = calls[0][3];
       expect(entryContext.appState.error).toBeTruthy();
       expect(entryContext.appState.error.message).toBe("index");
@@ -1245,12 +1267,14 @@ describe("shared server runtime", () => {
 
       let result = await handler(request);
       expect(result.status).toBe(500);
-      expect(rootLoader.mock.calls.length).toBe(1);
-      expect(indexLoader.mock.calls.length).toBe(1);
-      expect(build.entry.module.default.mock.calls.length).toBe(1);
+      expect(rootLoader.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(indexLoader.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(build.entry.module.default.mock.calls.length).toBe(
+        1 * DATA_CALL_MULTIPIER
+      );
 
       let calls = build.entry.module.default.mock.calls;
-      expect(calls.length).toBe(1);
+      expect(calls.length).toBe(1 * DATA_CALL_MULTIPIER);
       let entryContext = calls[0][3];
       expect(entryContext.appState.error).toBeTruthy();
       expect(entryContext.appState.error.message).toBe("index");
@@ -1290,14 +1314,16 @@ describe("shared server runtime", () => {
 
       let result = await handler(request);
       expect(result.status).toBe(500);
-      expect(testAction.mock.calls.length).toBe(1);
+      expect(testAction.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
       // Should not call root loader since it is the boundary route
       expect(rootLoader.mock.calls.length).toBe(0);
       expect(testLoader.mock.calls.length).toBe(0);
-      expect(build.entry.module.default.mock.calls.length).toBe(1);
+      expect(build.entry.module.default.mock.calls.length).toBe(
+        1 * DATA_CALL_MULTIPIER
+      );
 
       let calls = build.entry.module.default.mock.calls;
-      expect(calls.length).toBe(1);
+      expect(calls.length).toBe(1 * DATA_CALL_MULTIPIER);
       let entryContext = calls[0][3];
       expect(entryContext.appState.error).toBeTruthy();
       expect(entryContext.appState.error.message).toBe("test");
@@ -1335,14 +1361,16 @@ describe("shared server runtime", () => {
 
       let result = await handler(request);
       expect(result.status).toBe(500);
-      expect(indexAction.mock.calls.length).toBe(1);
+      expect(indexAction.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
       // Should not call root loader since it is the boundary route
       expect(rootLoader.mock.calls.length).toBe(0);
       expect(indexLoader.mock.calls.length).toBe(0);
-      expect(build.entry.module.default.mock.calls.length).toBe(1);
+      expect(build.entry.module.default.mock.calls.length).toBe(
+        1 * DATA_CALL_MULTIPIER
+      );
 
       let calls = build.entry.module.default.mock.calls;
-      expect(calls.length).toBe(1);
+      expect(calls.length).toBe(1 * DATA_CALL_MULTIPIER);
       let entryContext = calls[0][3];
       expect(entryContext.appState.error).toBeTruthy();
       expect(entryContext.appState.error.message).toBe("index");
@@ -1381,13 +1409,15 @@ describe("shared server runtime", () => {
 
       let result = await handler(request);
       expect(result.status).toBe(500);
-      expect(testAction.mock.calls.length).toBe(1);
-      expect(rootLoader.mock.calls.length).toBe(1);
+      expect(testAction.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(rootLoader.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
       expect(testLoader.mock.calls.length).toBe(0);
-      expect(build.entry.module.default.mock.calls.length).toBe(1);
+      expect(build.entry.module.default.mock.calls.length).toBe(
+        1 * DATA_CALL_MULTIPIER
+      );
 
       let calls = build.entry.module.default.mock.calls;
-      expect(calls.length).toBe(1);
+      expect(calls.length).toBe(1 * DATA_CALL_MULTIPIER);
       let entryContext = calls[0][3];
       expect(entryContext.appState.error).toBeTruthy();
       expect(entryContext.appState.error.message).toBe("test");
@@ -1428,13 +1458,15 @@ describe("shared server runtime", () => {
 
       let result = await handler(request);
       expect(result.status).toBe(500);
-      expect(indexAction.mock.calls.length).toBe(1);
-      expect(rootLoader.mock.calls.length).toBe(1);
+      expect(indexAction.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(rootLoader.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
       expect(indexLoader.mock.calls.length).toBe(0);
-      expect(build.entry.module.default.mock.calls.length).toBe(1);
+      expect(build.entry.module.default.mock.calls.length).toBe(
+        1 * DATA_CALL_MULTIPIER
+      );
 
       let calls = build.entry.module.default.mock.calls;
-      expect(calls.length).toBe(1);
+      expect(calls.length).toBe(1 * DATA_CALL_MULTIPIER);
       let entryContext = calls[0][3];
       expect(entryContext.appState.error).toBeTruthy();
       expect(entryContext.appState.error.message).toBe("index");
@@ -1483,13 +1515,15 @@ describe("shared server runtime", () => {
 
       let result = await handler(request);
       expect(result.status).toBe(500);
-      expect(testAction.mock.calls.length).toBe(1);
-      expect(rootLoader.mock.calls.length).toBe(1);
+      expect(testAction.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(rootLoader.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
       expect(testLoader.mock.calls.length).toBe(0);
-      expect(build.entry.module.default.mock.calls.length).toBe(1);
+      expect(build.entry.module.default.mock.calls.length).toBe(
+        1 * DATA_CALL_MULTIPIER
+      );
 
       let calls = build.entry.module.default.mock.calls;
-      expect(calls.length).toBe(1);
+      expect(calls.length).toBe(1 * DATA_CALL_MULTIPIER);
       let entryContext = calls[0][3];
       expect(entryContext.appState.error).toBeTruthy();
       expect(entryContext.appState.error.message).toBe("action");
@@ -1540,13 +1574,15 @@ describe("shared server runtime", () => {
 
       let result = await handler(request);
       expect(result.status).toBe(500);
-      expect(indexAction.mock.calls.length).toBe(1);
-      expect(rootLoader.mock.calls.length).toBe(1);
+      expect(indexAction.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(rootLoader.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
       expect(indexLoader.mock.calls.length).toBe(0);
-      expect(build.entry.module.default.mock.calls.length).toBe(1);
+      expect(build.entry.module.default.mock.calls.length).toBe(
+        1 * DATA_CALL_MULTIPIER
+      );
 
       let calls = build.entry.module.default.mock.calls;
-      expect(calls.length).toBe(1);
+      expect(calls.length).toBe(1 * DATA_CALL_MULTIPIER);
       let entryContext = calls[0][3];
       expect(entryContext.appState.error).toBeTruthy();
       expect(entryContext.appState.error.message).toBe("action");
@@ -1596,7 +1632,7 @@ describe("shared server runtime", () => {
       expect(indexLoader.mock.calls.length).toBe(0);
 
       let calls = build.entry.module.default.mock.calls;
-      expect(calls.length).toBe(2);
+      expect(calls.length).toBe(2 * DATA_CALL_MULTIPIER);
       let entryContext = calls[1][3];
       expect(entryContext.appState.error).toBeTruthy();
       expect(entryContext.appState.error.message).toBe("thrown");
@@ -1641,7 +1677,7 @@ describe("shared server runtime", () => {
       expect(indexLoader.mock.calls.length).toBe(0);
 
       let calls = build.entry.module.default.mock.calls;
-      expect(calls.length).toBe(2);
+      expect(calls.length).toBe(2 * DATA_CALL_MULTIPIER);
     });
 
     test("returns more detailed message if handleDocumentRequest throws a second time in development mode", async () => {
@@ -1681,8 +1717,8 @@ describe("shared server runtime", () => {
       expect(indexLoader.mock.calls.length).toBe(0);
 
       let calls = build.entry.module.default.mock.calls;
-      expect(calls.length).toBe(2);
-      expect(spy.console.mock.calls.length).toBe(1);
+      expect(calls.length).toBe(2 * DATA_CALL_MULTIPIER);
+      expect(spy.console.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
     });
   });
 });