Repository: remix-run/remix
Pull request #6495 (2023-05-26 19:43:04)

Fix server error logging and add onUnhandledError support
This PR does 2 things (broken up by commit for easier reviewing):

* Ensure we're logging unsanitized errors to the server console in produciton - eventhough we only send the sanitized errors to the browser
  * This fixes a regression from the last set of fixes to ensure all code paths would sanitize before hydrating
* While in this code, I also added support for a `handleError` export from `entry.server` (see https://github.com/remix-run/remix/discussions/2190).  
  * The default implementation of `handleError` if not provided is our previous `logServerErrorIfNotAborted` which will log in non-test environments and only if the request wasn't aborted
  * This is the _only_ logging we do from the server, so if you provide your own `handleError` then Remix will do _no_ logging (except from the express middleware built into `remix-serve`) and it's entirely up to you to log from your `handleError` method.


**TODO:**
* [x] Add Docs

changeset-bot[bot]: ###  🦋  Changeset detected

Latest commit: 671ffcf83babed4e745b2de29d0fa85719c102b9

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 18 packages</summary>

  | Name                          | Type  |
| ----------------------------- | ----- |
| @remix-run/server-runtime     | Minor |
| @remix-run/cloudflare         | Minor |
| @remix-run/deno               | Minor |
| @remix-run/dev                | Minor |
| @remix-run/node               | Minor |
| @remix-run/react              | Minor |
| @remix-run/cloudflare-pages   | Minor |
| @remix-run/cloudflare-workers | Minor |
| create-remix                  | Minor |
| @remix-run/css-bundle         | Minor |
| @remix-run/architect          | Minor |
| @remix-run/express            | Minor |
| @remix-run/netlify            | Minor |
| @remix-run/serve              | Minor |
| @remix-run/testing            | Minor |
| @remix-run/vercel             | Minor |
| remix                         | Minor |
| @remix-run/eslint-config      | Minor |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/remix-run/remix/new/brophdawg11/server-errors?filename=.changeset/blue-snails-complain.md&value=---%0A%22remix%22%3A%20patch%0A%22%40remix-run%2Fdev%22%3A%20patch%0A%22%40remix-run%2Freact%22%3A%20patch%0A%22%40remix-run%2Fserve%22%3A%20patch%0A%22%40remix-run%2Fserver-runtime%22%3A%20patch%0A---%0A%0AFix%20server%20error%20logging%20and%20add%20handleError%20support%0A)



Patch:
diff --git a/.changeset/hdr-revalidation-retry.md b/.changeset/hdr-revalidation-retry.md
new file mode 100644
index 000000000..949fb91bc
--- /dev/null
+++ b/.changeset/hdr-revalidation-retry.md
@@ -0,0 +1,5 @@
+---
+"@remix-run/react": patch
+---
+
+retry HDR revalidations in development mode to aid in 3rd party server race conditions
diff --git a/.changeset/log-server-errors.md b/.changeset/log-server-errors.md
new file mode 100644
index 000000000..5d646cf5f
--- /dev/null
+++ b/.changeset/log-server-errors.md
@@ -0,0 +1,5 @@
+---
+"@remix-run/server-runtime": patch
+---
+
+Ensure un-sanitized server errors are logged on the server during document requests
diff --git a/.changeset/on-unhandled-error.md b/.changeset/on-unhandled-error.md
new file mode 100644
index 000000000..ebd7f7401
--- /dev/null
+++ b/.changeset/on-unhandled-error.md
@@ -0,0 +1,22 @@
+---
+"@remix-run/server-runtime": minor
+---
+
+Add optional `onUnhandledError` export for custom server-side error processing.  This is a new optional export from your `entry.server.tsx` that will be called with any encountered error on the Remix server (loader, action, or render error):
+
+```ts
+// entry.server.tsx
+export function onUnhandledError(
+  error: unknown,
+  { request, params, context }: DataFunctionArgs
+): void {
+  if (error instanceof Error) {
+    sendErrorToBugReportingService(error);
+    console.error(formatError(error));
+  } else {
+    let unknownError = new Error("Unknown Server Error");
+    sendErrorToBugReportingService(unknownError);
+    console.error(unknownError);
+  }
+}
+```
diff --git a/.changeset/req-clone-instanceof.md b/.changeset/req-clone-instanceof.md
new file mode 100644
index 000000000..a83a3f1e2
--- /dev/null
+++ b/.changeset/req-clone-instanceof.md
@@ -0,0 +1,6 @@
+---
+"remix": patch
+"@remix-run/node": patch
+---
+
+Fix `request.clone() instanceof Request` returning false.
diff --git a/contributors.yml b/contributors.yml
index efe470f9d..dcac6473e 100644
--- a/contributors.yml
+++ b/contributors.yml
@@ -491,6 +491,7 @@
 - tombiju
 - tombyrer
 - TomerAberbach
+- tomer-yechiel
 - toyozaki
 - turkerdev
 - tvanantwerp
diff --git a/docs/file-conventions/entry.server.md b/docs/file-conventions/entry.server.md
index cc25b6729..401909555 100644
--- a/docs/file-conventions/entry.server.md
+++ b/docs/file-conventions/entry.server.md
@@ -5,10 +5,53 @@ toc: false
 
 # entry.server
 
-By default, Remix will handle generating the HTTP Response for you. If you want to customize this behavior, you can run `npx remix reveal` to generate a `app/entry.server.tsx` (or `.jsx`) that will take precedence. The `default` export of this module is a function that lets you create the response, including HTTP status, headers, and HTML, giving you full control over the way the markup is generated and sent to the client.
+By default, Remix will handle generating the HTTP Response for you. If you want to customize this behavior, you can run `npx remix reveal` to generate an `app/entry.server.tsx` (or `.jsx`) that will take precedence. The `default` export of this module is a function that lets you create the response, including HTTP status, headers, and HTML, giving you full control over the way the markup is generated and sent to the client.
 
 This module should render the markup for the current page using a `<RemixServer>` element with the `context` and `url` for the current request. This markup will (optionally) be re-hydrated once JavaScript loads in the browser using the [browser entry module][browser-entry-module].
 
-You can also export an optional `handleDataRequest` function that will allow you to modify the response of a data request. These are the requests that do not render HTML, but rather return the loader and action data to the browser once client-side hydration has occurred.
+## `handleDataRequest`
+
+You can export an optional `handleDataRequest` function that will allow you to modify the response of a data request. These are the requests that do not render HTML, but rather return the loader and action data to the browser once client-side hydration has occurred.
+
+```tsx
+export function handleDataRequest(
+  response: Response,
+  { request, params, context }: DataFunctionArgs
+) {
+  response.headers.set("X-Custom-Header", "value");
+  return response;
+}
+```
+
+## `onUnhandledError`
+
+By default, Remix will log encountered server-side errors to the console. If you'd like more control over the logging, or would like to also report these errors to an external service, then you can export an optional `onUnhandledError` function which will give you control (and will disable the built-in error logging).
+
+```tsx
+export function onUnhandledError(
+  error: unknown,
+  { request, params, context }: DataFunctionArgs
+) {
+  sendErrorToErrorReportingService(error);
+  console.error(formatErrorForJsonLogging(error));
+}
+```
+
+### Streaming Rendering Errors
+
+When you are streaming your HTML responses via [`renderToPipeableStream`][rendertopipeablestream] or [`renderToReadableStream`][rendertoreadablestream], your own `onUnhandledError` implementation will only handle errors encountered uring the initial shell render. If you encounter a rendering error during subsequent streamed rendering you will need handle these errors manually since the Remix server has already sent the Response by that point.
+
+- For `renderToPipeableStream`, you can handle these errors in the `onError` callback function. You will need to toggle a boolean when the in `onShellReady` so you know if the error was a shell rendering error (and can be ignored) or an async rendering error (and must be handled).
+  - For an exmaple, please see the default [`entry.server.tsx`][node-streaming-entry-server] for Node.
+- For `renderToReadableStream`, you can handle these errors in the `onError` callback function
+  - For an example, please see the default [`entry.server.tsx`][cloudflare-streaming-entry-server] for Cloudflare
+
+### Thrown Responses
+
+Note that this does not handle thrown `Response` instances from your `loader`/`action` functions. The intention of this handler is to find bugs in your code which result in unexpected thrown errors. If you are detecting a scenario and throwing a 401/404/etc. `Response` in your `loader`/`action` then it's an expected flow that is handled by your code. If you also wish to log, or send those to an external service, that should be done at the time you throw the response.
 
 [browser-entry-module]: ./entry.client
+[rendertopipeablestream]: https://react.dev/reference/react-dom/server/renderToPipeableStream
+[rendertoreadablestream]: https://react.dev/reference/react-dom/server/renderToReadableStream
+[node-streaming-entry-server]: https://github.com/remix-run/remix/blob/main/packages/remix-dev/config/defaults/node/entry.server.react-stream.tsx
+[cloudflare-streaming-entry-server]: https://github.com/remix-run/remix/blob/main/packages/remix-dev/config/defaults/cloudflare/entry.server.react-stream.tsx
diff --git a/packages/remix-cloudflare/index.ts b/packages/remix-cloudflare/index.ts
index e5850c1ac..9d27898e2 100644
--- a/packages/remix-cloudflare/index.ts
+++ b/packages/remix-cloudflare/index.ts
@@ -68,6 +68,7 @@ export type {
   MemoryUploadHandlerOptions,
   MetaDescriptor,
   MetaFunction,
+  OnUnhandledErrorFunction,
   PageLinkDescriptor,
   RequestHandler,
   RouteComponent,
diff --git a/packages/remix-deno/index.ts b/packages/remix-deno/index.ts
index 2eee795df..a4ec61f2d 100644
--- a/packages/remix-deno/index.ts
+++ b/packages/remix-deno/index.ts
@@ -56,6 +56,7 @@ export type {
   MemoryUploadHandlerOptions,
   MetaDescriptor,
   MetaFunction,
+  OnUnhandledErrorFunction,
   PageLinkDescriptor,
   RequestHandler,
   RouteComponent,
diff --git a/packages/remix-dev/codemod/replace-remix-magic-imports/utils/export.ts b/packages/remix-dev/codemod/replace-remix-magic-imports/utils/export.ts
index 5be1b002c..26b0e3153 100644
--- a/packages/remix-dev/codemod/replace-remix-magic-imports/utils/export.ts
+++ b/packages/remix-dev/codemod/replace-remix-magic-imports/utils/export.ts
@@ -68,6 +68,7 @@ const defaultRuntimeExports: ExportNames = {
     "MemoryUploadHandlerOptions",
     "MetaDescriptor",
     "MetaFunction",
+    "OnUnhandledErrorFunction",
     "PageLinkDescriptor",
     "RequestHandler",
     "RouteComponent",
diff --git a/packages/remix-dev/config/defaults/cloudflare/entry.server.react-stream.tsx b/packages/remix-dev/config/defaults/cloudflare/entry.server.react-stream.tsx
index 3e08f46fe..a393a5228 100644
--- a/packages/remix-dev/config/defaults/cloudflare/entry.server.react-stream.tsx
+++ b/packages/remix-dev/config/defaults/cloudflare/entry.server.react-stream.tsx
@@ -14,6 +14,7 @@ export default async function handleRequest(
     {
       signal: request.signal,
       onError(error: unknown) {
+        // Log streaming rendering errors from inside the shell
         console.error(error);
         responseStatusCode = 500;
       },
diff --git a/packages/remix-dev/config/defaults/deno/entry.server.react-stream.tsx b/packages/remix-dev/config/defaults/deno/entry.server.react-stream.tsx
index 7be6c7480..91a1d11e5 100644
--- a/packages/remix-dev/config/defaults/deno/entry.server.react-stream.tsx
+++ b/packages/remix-dev/config/defaults/deno/entry.server.react-stream.tsx
@@ -14,6 +14,7 @@ export default async function handleRequest(
     {
       signal: request.signal,
       onError(error: unknown) {
+        // Log streaming rendering errors from inside the shell
         console.error(error);
         responseStatusCode = 500;
       },
diff --git a/packages/remix-dev/config/defaults/node/entry.server.react-stream.tsx b/packages/remix-dev/config/defaults/node/entry.server.react-stream.tsx
index e9f6ac984..efe28dbcf 100644
--- a/packages/remix-dev/config/defaults/node/entry.server.react-stream.tsx
+++ b/packages/remix-dev/config/defaults/node/entry.server.react-stream.tsx
@@ -36,6 +36,7 @@ function handleBotRequest(
   remixContext: EntryContext
 ) {
   return new Promise((resolve, reject) => {
+    let shellRendered = false;
     const { pipe, abort } = renderToPipeableStream(
       <RemixServer
         context={remixContext}
@@ -44,6 +45,7 @@ function handleBotRequest(
       />,
       {
         onAllReady() {
+          shellRendered = true;
           const body = new PassThrough();
 
           responseHeaders.set("Content-Type", "text/html");
@@ -62,7 +64,12 @@ function handleBotRequest(
         },
         onError(error: unknown) {
           responseStatusCode = 500;
-          console.error(error);
+          // Log streaming rendering errors from inside the shell.  Don't log
+          // errors encountered during initial shell rendering since they'll
+          // reject and get logged in handleDocumentRequest.
+          if (shellRendered) {
+            console.error(error);
+          }
         },
       }
     );
@@ -78,6 +85,7 @@ function handleBrowserRequest(
   remixContext: EntryContext
 ) {
   return new Promise((resolve, reject) => {
+    let shellRendered = false;
     const { pipe, abort } = renderToPipeableStream(
       <RemixServer
         context={remixContext}
@@ -86,6 +94,7 @@ function handleBrowserRequest(
       />,
       {
         onShellReady() {
+          shellRendered = true;
           const body = new PassThrough();
 
           responseHeaders.set("Content-Type", "text/html");
@@ -103,8 +112,13 @@ function handleBrowserRequest(
           reject(error);
         },
         onError(error: unknown) {
-          console.error(error);
           responseStatusCode = 500;
+          // Log streaming rendering errors from inside the shell.  Don't log
+          // errors encountered during initial shell rendering since they'll
+          // reject and get logged in handleDocumentRequest.
+          if (shellRendered) {
+            console.error(error);
+          }
         },
       }
     );
diff --git a/packages/remix-eslint-config/rules/packageExports.js b/packages/remix-eslint-config/rules/packageExports.js
index b3bfa1a3f..49892da0e 100644
--- a/packages/remix-eslint-config/rules/packageExports.js
+++ b/packages/remix-eslint-config/rules/packageExports.js
@@ -46,6 +46,7 @@ const defaultRuntimeExports = {
     "MemoryUploadHandlerOptions",
     "MetaDescriptor",
     "MetaFunction",
+    "OnUnhandledErrorFunction",
     "PageLinkDescriptor",
     "RequestHandler",
     "RouteComponent",
diff --git a/packages/remix-node/fetch.ts b/packages/remix-node/fetch.ts
index ce42c4ed8..f586469dd 100644
--- a/packages/remix-node/fetch.ts
+++ b/packages/remix-node/fetch.ts
@@ -41,7 +41,7 @@ class NodeRequest extends WebRequest {
   }
 
   public clone(): NodeRequest {
-    return super.clone() as NodeRequest;
+    return new NodeRequest(this);
   }
 }
 
diff --git a/packages/remix-node/index.ts b/packages/remix-node/index.ts
index 8e0d62dbe..20fa129bb 100644
--- a/packages/remix-node/index.ts
+++ b/packages/remix-node/index.ts
@@ -79,6 +79,7 @@ export type {
   MemoryUploadHandlerOptions,
   MetaDescriptor,
   MetaFunction,
+  OnUnhandledErrorFunction,
   PageLinkDescriptor,
   RequestHandler,
   RouteComponent,
diff --git a/packages/remix-react/browser.tsx b/packages/remix-react/browser.tsx
index 821731a3d..f56510d6e 100644
--- a/packages/remix-react/browser.tsx
+++ b/packages/remix-react/browser.tsx
@@ -30,6 +30,7 @@ declare global {
   };
   var __remixRouteModules: RouteModules;
   var __remixManifest: EntryContext["manifest"];
+  var __remixRevalidation: number | undefined;
   var $RefreshRuntime$: {
     performReactRefresh: () => void;
   };
@@ -139,6 +140,7 @@ if (import.meta && import.meta.hot) {
           }, 1);
         }
       });
+      window.__remixRevalidation = (window.__remixRevalidation || 0) + 1;
       router.revalidate();
     }
   );
diff --git a/packages/remix-react/data.ts b/packages/remix-react/data.ts
index a065b65c4..675b84137 100644
--- a/packages/remix-react/data.ts
+++ b/packages/remix-react/data.ts
@@ -14,7 +14,7 @@ export function isCatchResponse(response: Response): boolean {
   return response.headers.get("X-Remix-Catch") != null;
 }
 
-export function isErrorResponse(response: Response): boolean {
+export function isErrorResponse(response: any): response is Response {
   return response.headers.get("X-Remix-Error") != null;
 }
 
@@ -28,7 +28,8 @@ export function isDeferredResponse(response: Response): boolean {
 
 export async function fetchData(
   request: Request,
-  routeId: string
+  routeId: string,
+  retry = 0
 ): Promise<Response | Error> {
   let url = new URL(request.url);
   url.searchParams.set("_data", routeId);
@@ -47,7 +48,24 @@ export async function fetchData(
         : await request.formData();
   }
 
-  let response = await fetch(url.href, init);
+  if (retry > 0) {
+    // Retry up to 3 times waiting 50, 250, 1250 ms
+    // between retries for a total of 1550 ms before giving up.
+    await new Promise((resolve) => setTimeout(resolve, 5 ** retry * 10));
+  }
+
+  let revalidation = window.__remixRevalidation;
+  let response = await fetch(url.href, init).catch((error) => {
+    if (
+      typeof revalidation === "number" &&
+      revalidation === window.__remixRevalidation &&
+      error?.name === "TypeError" &&
+      retry < 3
+    ) {
+      return fetchData(request, routeId, retry + 1);
+    }
+    throw error;
+  });
 
   if (isErrorResponse(response)) {
     let data = await response.json();
diff --git a/packages/remix-react/errorBoundaries.tsx b/packages/remix-react/errorBoundaries.tsx
index 32eb75b4e..24d63344a 100644
--- a/packages/remix-react/errorBoundaries.tsx
+++ b/packages/remix-react/errorBoundaries.tsx
@@ -70,7 +70,10 @@ export class RemixErrorBoundary extends React.Component<
  * When app's don't provide a root level ErrorBoundary, we default to this.
  */
 export function RemixRootDefaultErrorBoundary({ error }: { error: Error }) {
-  console.error(error);
+  // Only log client side to avoid double-logging on the server
+  React.useEffect(() => {
+    console.error(error);
+  }, [error]);
   return (
     <html lang="en">
       <head>
@@ -84,16 +87,18 @@ export function RemixRootDefaultErrorBoundary({ error }: { error: Error }) {
       <body>
         <main style={{ fontFamily: "system-ui, sans-serif", padding: "2rem" }}>
           <h1 style={{ fontSize: "24px" }}>Application Error</h1>
-          <pre
-            style={{
-              padding: "2rem",
-              background: "hsla(10, 50%, 50%, 0.1)",
-              color: "red",
-              overflow: "auto",
-            }}
-          >
-            {error.stack}
-          </pre>
+          {error.stack ? (
+            <pre
+              style={{
+                padding: "2rem",
+                background: "hsla(10, 50%, 50%, 0.1)",
+                color: "red",
+                overflow: "auto",
+              }}
+            >
+              {error.stack}
+            </pre>
+          ) : null}
         </main>
         <script
           dangerouslySetInnerHTML={{
diff --git a/packages/remix-server-runtime/build.ts b/packages/remix-server-runtime/build.ts
index dfa5e2810..55d8deff5 100644
--- a/packages/remix-server-runtime/build.ts
+++ b/packages/remix-server-runtime/build.ts
@@ -32,6 +32,10 @@ export interface HandleDataRequestFunction {
   (response: Response, args: DataFunctionArgs): Promise<Response> | Response;
 }
 
+export interface OnUnhandledErrorFunction {
+  (error: unknown, args: DataFunctionArgs): void;
+}
+
 /**
  * A module that serves as the entry point for a Remix app during server
  * rendering.
@@ -39,4 +43,5 @@ export interface HandleDataRequestFunction {
 export interface ServerEntryModule {
   default: HandleDocumentRequestFunction;
   handleDataRequest?: HandleDataRequestFunction;
+  onUnhandledError?: OnUnhandledErrorFunction;
 }
diff --git a/packages/remix-server-runtime/index.ts b/packages/remix-server-runtime/index.ts
index 36c16ec9b..19329ec53 100644
--- a/packages/remix-server-runtime/index.ts
+++ b/packages/remix-server-runtime/index.ts
@@ -60,6 +60,7 @@ export type {
   MemoryUploadHandlerOptions,
   MetaDescriptor,
   MetaFunction,
+  OnUnhandledErrorFunction,
   PageLinkDescriptor,
   RequestHandler,
   RouteComponent,
diff --git a/packages/remix-server-runtime/reexport.ts b/packages/remix-server-runtime/reexport.ts
index 2068ceaf7..349e0577f 100644
--- a/packages/remix-server-runtime/reexport.ts
+++ b/packages/remix-server-runtime/reexport.ts
@@ -1,6 +1,7 @@
 export type {
   HandleDataRequestFunction,
   HandleDocumentRequestFunction,
+  OnUnhandledErrorFunction,
   ServerBuild,
   ServerEntryModule,
 } from "./build";
diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts
index 7bc58a547..1f600c988 100644
--- a/packages/remix-server-runtime/server.ts
+++ b/packages/remix-server-runtime/server.ts
@@ -49,10 +49,24 @@ export const createRequestHandler: CreateRequestHandlerFunction = (
   let serverMode = isServerMode(mode) ? mode : ServerMode.Production;
   let staticHandler = createStaticHandler(dataRoutes);
 
+  let errorHandler =
+    build.entry.module.onUnhandledError ||
+    ((error, { request }) => {
+      if (serverMode !== ServerMode.Test && !request.signal.aborted) {
+        console.error(error);
+      }
+    });
+
   return async function requestHandler(request, loadContext = {}) {
     let url = new URL(request.url);
 
     let matches = matchServerRoutes(routes, url.pathname);
+    let handleError = (error: unknown) =>
+      errorHandler(error, {
+        context: loadContext,
+        params: matches && matches.length > 0 ? matches[0].params : {},
+        request,
+      });
 
     let response: Response;
     if (url.searchParams.has("_data")) {
@@ -63,7 +77,8 @@ export const createRequestHandler: CreateRequestHandlerFunction = (
         staticHandler,
         routeId,
         request,
-        loadContext
+        loadContext,
+        handleError
       );
 
       if (build.entry.module.handleDataRequest) {
@@ -83,7 +98,8 @@ export const createRequestHandler: CreateRequestHandlerFunction = (
         staticHandler,
         matches.slice(-1)[0].route.id,
         request,
-        loadContext
+        loadContext,
+        handleError
       );
     } else {
       response = await handleDocumentRequestRR(
@@ -91,7 +107,8 @@ export const createRequestHandler: CreateRequestHandlerFunction = (
         build,
         staticHandler,
         request,
-        loadContext
+        loadContext,
+        handleError
       );
     }
 
@@ -112,7 +129,8 @@ async function handleDataRequestRR(
   staticHandler: StaticHandler,
   routeId: string,
   request: Request,
-  loadContext: AppLoadContext
+  loadContext: AppLoadContext,
+  handleError: (err: unknown) => void
 ) {
   try {
     let response = await staticHandler.queryRoute(request, {
@@ -160,13 +178,15 @@ async function handleDataRequestRR(
     }
 
     if (isRouteErrorResponse(error)) {
-      logServerErrorIfNotAborted(error.error || error, request, serverMode);
+      if (error.error) {
+        handleError(error.error);
+      }
       return errorResponseToJson(error, serverMode);
     }
 
     let errorInstance =
       error instanceof Error ? error : new Error("Unexpected Server Error");
-    logServerErrorIfNotAborted(errorInstance, request, serverMode);
+    handleError(errorInstance);
     return routerJson(serializeError(errorInstance, serverMode), {
       status: 500,
       headers: {
@@ -230,7 +250,8 @@ async function handleDocumentRequestRR(
   build: ServerBuild,
   staticHandler: StaticHandler,
   request: Request,
-  loadContext: AppLoadContext
+  loadContext: AppLoadContext,
+  handleError: (err: unknown) => void
 ) {
   let context;
   try {
@@ -238,7 +259,7 @@ async function handleDocumentRequestRR(
       requestContext: loadContext,
     });
   } catch (error: unknown) {
-    logServerErrorIfNotAborted(error, request, serverMode);
+    handleError(error);
     return new Response(null, { status: 500 });
   }
 
@@ -248,6 +269,11 @@ async function handleDocumentRequestRR(
 
   // Sanitize errors outside of development environments
   if (context.errors) {
+    Object.values(context.errors).forEach((err) => {
+      if (!isRouteErrorResponse(err) || err.error) {
+        handleError(err);
+      }
+    });
     context.errors = sanitizeErrors(context.errors, serverMode);
   }
 
@@ -284,6 +310,8 @@ async function handleDocumentRequestRR(
       loadContext
     );
   } catch (error: unknown) {
+    handleError(error);
+
     // Get a new StaticHandlerContext that contains the error at the right boundary
     context = getStaticContextFromError(
       staticHandler.dataRoutes,
@@ -324,7 +352,7 @@ async function handleDocumentRequestRR(
         loadContext
       );
     } catch (error: any) {
-      logServerErrorIfNotAborted(error, request, serverMode);
+      handleError(error);
       return returnLastResortErrorResponse(error, serverMode);
     }
   }
@@ -335,7 +363,8 @@ async function handleResourceRequestRR(
   staticHandler: StaticHandler,
   routeId: string,
   request: Request,
-  loadContext: AppLoadContext
+  loadContext: AppLoadContext,
+  handleError: (err: unknown) => void
 ) {
   try {
     // Note we keep the routeId here to align with the Remix handling of
@@ -360,25 +389,17 @@ async function handleResourceRequestRR(
     }
 
     if (isRouteErrorResponse(error)) {
-      logServerErrorIfNotAborted(error.error || error, request, serverMode);
+      if (error.error) {
+        handleError(error.error);
+      }
       return errorResponseToJson(error, serverMode);
     }
 
-    logServerErrorIfNotAborted(error, request, serverMode);
+    handleError(error);
     return returnLastResortErrorResponse(error, serverMode);
   }
 }
 
-function logServerErrorIfNotAborted(
-  error: unknown,
-  request: Request,
-  serverMode: ServerMode
-) {
-  if (serverMode !== ServerMode.Test && !request.signal.aborted) {
-    console.error(error);
-  }
-}
-
 function errorResponseToJson(
   errorResponse: ErrorResponse,
   serverMode: ServerMode
diff --git a/rollup.utils.js b/rollup.utils.js
index 13c5d893d..92ed91e98 100644
--- a/rollup.utils.js
+++ b/rollup.utils.js
@@ -358,6 +358,7 @@ function getMagicExports(packageName) {
         "LoaderFunction",
         "MetaDescriptor",
         "MetaFunction",
+        "OnUnhandledErrorFunction",
         "PageLinkDescriptor",
         "RequestHandler",
         "RouteComponent",
diff --git a/templates/arc/app/entry.server.tsx b/templates/arc/app/entry.server.tsx
index ef312659b..9a56a3600 100644
--- a/templates/arc/app/entry.server.tsx
+++ b/templates/arc/app/entry.server.tsx
@@ -42,6 +42,7 @@ function handleBotRequest(
   remixContext: EntryContext
 ) {
   return new Promise((resolve, reject) => {
+    let shellRendered = false;
     const { pipe, abort } = renderToPipeableStream(
       <RemixServer
         context={remixContext}
@@ -50,6 +51,7 @@ function handleBotRequest(
       />,
       {
         onAllReady() {
+          shellRendered = true;
           const body = new PassThrough();
 
           responseHeaders.set("Content-Type", "text/html");
@@ -68,7 +70,12 @@ function handleBotRequest(
         },
         onError(error: unknown) {
           responseStatusCode = 500;
-          console.error(error);
+          // Log streaming rendering errors from inside the shell.  Don't log
+          // errors encountered during initial shell rendering since they'll
+          // reject and get logged in handleDocumentRequest.
+          if (shellRendered) {
+            console.error(error);
+          }
         },
       }
     );
@@ -84,6 +91,7 @@ function handleBrowserRequest(
   remixContext: EntryContext
 ) {
   return new Promise((resolve, reject) => {
+    let shellRendered = false;
     const { pipe, abort } = renderToPipeableStream(
       <RemixServer
         context={remixContext}
@@ -92,6 +100,7 @@ function handleBrowserRequest(
       />,
       {
         onShellReady() {
+          shellRendered = true;
           const body = new PassThrough();
 
           responseHeaders.set("Content-Type", "text/html");
@@ -109,8 +118,13 @@ function handleBrowserRequest(
           reject(error);
         },
         onError(error: unknown) {
-          console.error(error);
           responseStatusCode = 500;
+          // Log streaming rendering errors from inside the shell.  Don't log
+          // errors encountered during initial shell rendering since they'll
+          // reject and get logged in handleDocumentRequest.
+          if (shellRendered) {
+            console.error(error);
+          }
         },
       }
     );
diff --git a/templates/cloudflare-pages/app/entry.server.tsx b/templates/cloudflare-pages/app/entry.server.tsx
index 7fd5b88a9..e68f4df69 100644
--- a/templates/cloudflare-pages/app/entry.server.tsx
+++ b/templates/cloudflare-pages/app/entry.server.tsx
@@ -20,6 +20,7 @@ export default async function handleRequest(
     {
       signal: request.signal,
       onError(error: unknown) {
+        // Log streaming rendering errors from inside the shell
         console.error(error);
         responseStatusCode = 500;
       },
diff --git a/templates/cloudflare-workers/app/entry.server.tsx b/templates/cloudflare-workers/app/entry.server.tsx
index 7fd5b88a9..e68f4df69 100644
--- a/templates/cloudflare-workers/app/entry.server.tsx
+++ b/templates/cloudflare-workers/app/entry.server.tsx
@@ -20,6 +20,7 @@ export default async function handleRequest(
     {
       signal: request.signal,
       onError(error: unknown) {
+        // Log streaming rendering errors from inside the shell
         console.error(error);
         responseStatusCode = 500;
       },
diff --git a/templates/deno/app/entry.server.tsx b/templates/deno/app/entry.server.tsx
index 739f7b9d2..570f944a9 100644
--- a/templates/deno/app/entry.server.tsx
+++ b/templates/deno/app/entry.server.tsx
@@ -20,6 +20,7 @@ export default async function handleRequest(
     {
       signal: request.signal,
       onError(error: unknown) {
+        // Log streaming rendering errors from inside the shell
         console.error(error);
         responseStatusCode = 500;
       },
diff --git a/templates/express/app/entry.server.tsx b/templates/express/app/entry.server.tsx
index ef312659b..9a56a3600 100644
--- a/templates/express/app/entry.server.tsx
+++ b/templates/express/app/entry.server.tsx
@@ -42,6 +42,7 @@ function handleBotRequest(
   remixContext: EntryContext
 ) {
   return new Promise((resolve, reject) => {
+    let shellRendered = false;
     const { pipe, abort } = renderToPipeableStream(
       <RemixServer
         context={remixContext}
@@ -50,6 +51,7 @@ function handleBotRequest(
       />,
       {
         onAllReady() {
+          shellRendered = true;
           const body = new PassThrough();
 
           responseHeaders.set("Content-Type", "text/html");
@@ -68,7 +70,12 @@ function handleBotRequest(
         },
         onError(error: unknown) {
           responseStatusCode = 500;
-          console.error(error);
+          // Log streaming rendering errors from inside the shell.  Don't log
+          // errors encountered during initial shell rendering since they'll
+          // reject and get logged in handleDocumentRequest.
+          if (shellRendered) {
+            console.error(error);
+          }
         },
       }
     );
@@ -84,6 +91,7 @@ function handleBrowserRequest(
   remixContext: EntryContext
 ) {
   return new Promise((resolve, reject) => {
+    let shellRendered = false;
     const { pipe, abort } = renderToPipeableStream(
       <RemixServer
         context={remixContext}
@@ -92,6 +100,7 @@ function handleBrowserRequest(
       />,
       {
         onShellReady() {
+          shellRendered = true;
           const body = new PassThrough();
 
           responseHeaders.set("Content-Type", "text/html");
@@ -109,8 +118,13 @@ function handleBrowserRequest(
           reject(error);
         },
         onError(error: unknown) {
-          console.error(error);
           responseStatusCode = 500;
+          // Log streaming rendering errors from inside the shell.  Don't log
+          // errors encountered during initial shell rendering since they'll
+          // reject and get logged in handleDocumentRequest.
+          if (shellRendered) {
+            console.error(error);
+          }
         },
       }
     );
diff --git a/templates/fly/app/entry.server.tsx b/templates/fly/app/entry.server.tsx
index ef312659b..9a56a3600 100644
--- a/templates/fly/app/entry.server.tsx
+++ b/templates/fly/app/entry.server.tsx
@@ -42,6 +42,7 @@ function handleBotRequest(
   remixContext: EntryContext
 ) {
   return new Promise((resolve, reject) => {
+    let shellRendered = false;
     const { pipe, abort } = renderToPipeableStream(
       <RemixServer
         context={remixContext}
@@ -50,6 +51,7 @@ function handleBotRequest(
       />,
       {
         onAllReady() {
+          shellRendered = true;
           const body = new PassThrough();
 
           responseHeaders.set("Content-Type", "text/html");
@@ -68,7 +70,12 @@ function handleBotRequest(
         },
         onError(error: unknown) {
           responseStatusCode = 500;
-          console.error(error);
+          // Log streaming rendering errors from inside the shell.  Don't log
+          // errors encountered during initial shell rendering since they'll
+          // reject and get logged in handleDocumentRequest.
+          if (shellRendered) {
+            console.error(error);
+          }
         },
       }
     );
@@ -84,6 +91,7 @@ function handleBrowserRequest(
   remixContext: EntryContext
 ) {
   return new Promise((resolve, reject) => {
+    let shellRendered = false;
     const { pipe, abort } = renderToPipeableStream(
       <RemixServer
         context={remixContext}
@@ -92,6 +100,7 @@ function handleBrowserRequest(
       />,
       {
         onShellReady() {
+          shellRendered = true;
           const body = new PassThrough();
 
           responseHeaders.set("Content-Type", "text/html");
@@ -109,8 +118,13 @@ function handleBrowserRequest(
           reject(error);
         },
         onError(error: unknown) {
-          console.error(error);
           responseStatusCode = 500;
+          // Log streaming rendering errors from inside the shell.  Don't log
+          // errors encountered during initial shell rendering since they'll
+          // reject and get logged in handleDocumentRequest.
+          if (shellRendered) {
+            console.error(error);
+          }
         },
       }
     );
diff --git a/templates/netlify/app/entry.server.tsx b/templates/netlify/app/entry.server.tsx
index ef312659b..9a56a3600 100644
--- a/templates/netlify/app/entry.server.tsx
+++ b/templates/netlify/app/entry.server.tsx
@@ -42,6 +42,7 @@ function handleBotRequest(
   remixContext: EntryContext
 ) {
   return new Promise((resolve, reject) => {
+    let shellRendered = false;
     const { pipe, abort } = renderToPipeableStream(
       <RemixServer
         context={remixContext}
@@ -50,6 +51,7 @@ function handleBotRequest(
       />,
       {
         onAllReady() {
+          shellRendered = true;
           const body = new PassThrough();
 
           responseHeaders.set("Content-Type", "text/html");
@@ -68,7 +70,12 @@ function handleBotRequest(
         },
         onError(error: unknown) {
           responseStatusCode = 500;
-          console.error(error);
+          // Log streaming rendering errors from inside the shell.  Don't log
+          // errors encountered during initial shell rendering since they'll
+          // reject and get logged in handleDocumentRequest.
+          if (shellRendered) {
+            console.error(error);
+          }
         },
       }
     );
@@ -84,6 +91,7 @@ function handleBrowserRequest(
   remixContext: EntryContext
 ) {
   return new Promise((resolve, reject) => {
+    let shellRendered = false;
     const { pipe, abort } = renderToPipeableStream(
       <RemixServer
         context={remixContext}
@@ -92,6 +100,7 @@ function handleBrowserRequest(
       />,
       {
         onShellReady() {
+          shellRendered = true;
           const body = new PassThrough();
 
           responseHeaders.set("Content-Type", "text/html");
@@ -109,8 +118,13 @@ function handleBrowserRequest(
           reject(error);
         },
         onError(error: unknown) {
-          console.error(error);
           responseStatusCode = 500;
+          // Log streaming rendering errors from inside the shell.  Don't log
+          // errors encountered during initial shell rendering since they'll
+          // reject and get logged in handleDocumentRequest.
+          if (shellRendered) {
+            console.error(error);
+          }
         },
       }
     );
diff --git a/templates/remix/app/entry.server.tsx b/templates/remix/app/entry.server.tsx
index ef312659b..9a56a3600 100644
--- a/templates/remix/app/entry.server.tsx
+++ b/templates/remix/app/entry.server.tsx
@@ -42,6 +42,7 @@ function handleBotRequest(
   remixContext: EntryContext
 ) {
   return new Promise((resolve, reject) => {
+    let shellRendered = false;
     const { pipe, abort } = renderToPipeableStream(
       <RemixServer
         context={remixContext}
@@ -50,6 +51,7 @@ function handleBotRequest(
       />,
       {
         onAllReady() {
+          shellRendered = true;
           const body = new PassThrough();
 
           responseHeaders.set("Content-Type", "text/html");
@@ -68,7 +70,12 @@ function handleBotRequest(
         },
         onError(error: unknown) {
           responseStatusCode = 500;
-          console.error(error);
+          // Log streaming rendering errors from inside the shell.  Don't log
+          // errors encountered during initial shell rendering since they'll
+          // reject and get logged in handleDocumentRequest.
+          if (shellRendered) {
+            console.error(error);
+          }
         },
       }
     );
@@ -84,6 +91,7 @@ function handleBrowserRequest(
   remixContext: EntryContext
 ) {
   return new Promise((resolve, reject) => {
+    let shellRendered = false;
     const { pipe, abort } = renderToPipeableStream(
       <RemixServer
         context={remixContext}
@@ -92,6 +100,7 @@ function handleBrowserRequest(
       />,
       {
         onShellReady() {
+          shellRendered = true;
           const body = new PassThrough();
 
           responseHeaders.set("Content-Type", "text/html");
@@ -109,8 +118,13 @@ function handleBrowserRequest(
           reject(error);
         },
         onError(error: unknown) {
-          console.error(error);
           responseStatusCode = 500;
+          // Log streaming rendering errors from inside the shell.  Don't log
+          // errors encountered during initial shell rendering since they'll
+          // reject and get logged in handleDocumentRequest.
+          if (shellRendered) {
+            console.error(error);
+          }
         },
       }
     );
diff --git a/templates/vercel/app/entry.server.tsx b/templates/vercel/app/entry.server.tsx
index ef312659b..9a56a3600 100644
--- a/templates/vercel/app/entry.server.tsx
+++ b/templates/vercel/app/entry.server.tsx
@@ -42,6 +42,7 @@ function handleBotRequest(
   remixContext: EntryContext
 ) {
   return new Promise((resolve, reject) => {
+    let shellRendered = false;
     const { pipe, abort } = renderToPipeableStream(
       <RemixServer
         context={remixContext}
@@ -50,6 +51,7 @@ function handleBotRequest(
       />,
       {
         onAllReady() {
+          shellRendered = true;
           const body = new PassThrough();
 
           responseHeaders.set("Content-Type", "text/html");
@@ -68,7 +70,12 @@ function handleBotRequest(
         },
         onError(error: unknown) {
           responseStatusCode = 500;
-          console.error(error);
+          // Log streaming rendering errors from inside the shell.  Don't log
+          // errors encountered during initial shell rendering since they'll
+          // reject and get logged in handleDocumentRequest.
+          if (shellRendered) {
+            console.error(error);
+          }
         },
       }
     );
@@ -84,6 +91,7 @@ function handleBrowserRequest(
   remixContext: EntryContext
 ) {
   return new Promise((resolve, reject) => {
+    let shellRendered = false;
     const { pipe, abort } = renderToPipeableStream(
       <RemixServer
         context={remixContext}
@@ -92,6 +100,7 @@ function handleBrowserRequest(
       />,
       {
         onShellReady() {
+          shellRendered = true;
           const body = new PassThrough();
 
           responseHeaders.set("Content-Type", "text/html");
@@ -109,8 +118,13 @@ function handleBrowserRequest(
           reject(error);
         },
         onError(error: unknown) {
-          console.error(error);
           responseStatusCode = 500;
+          // Log streaming rendering errors from inside the shell.  Don't log
+          // errors encountered during initial shell rendering since they'll
+          // reject and get logged in handleDocumentRequest.
+          if (shellRendered) {
+            console.error(error);
+          }
         },
       }
     );


Test patch:
diff --git a/integration/error-sanitization-test.ts b/integration/error-sanitization-test.ts
index 4ed62a49f..ad9183014 100644
--- a/integration/error-sanitization-test.ts
+++ b/integration/error-sanitization-test.ts
@@ -27,7 +27,7 @@ const routeFiles = {
   `,
 
   "app/routes/index.jsx": js`
-    import { useLoaderData, useLocation } from "@remix-run/react";
+    import { useLoaderData, useLocation, useRouteError } from "@remix-run/react";
 
     export function loader({ request }) {
       if (new URL(request.url).searchParams.has('loader')) {
@@ -52,7 +52,8 @@ const routeFiles = {
       );
     }
 
-    export function ErrorBoundary({ error }) {
+    export function ErrorBoundary() {
+      let error = useRouteError();
       return (
         <>
           <h1>Index Error</h1>
@@ -66,7 +67,7 @@ const routeFiles = {
   "app/routes/defer.jsx": js`
     import * as React from 'react';
     import { defer } from "@remix-run/server-runtime";
-    import { Await, useLoaderData, useRouteError } from "@remix-run/react";
+    import { Await, useAsyncError, useLoaderData, useRouteError  } from "@remix-run/react";
 
     export function loader({ request }) {
       if (new URL(request.url).searchParams.has('loader')) {
@@ -95,19 +96,20 @@ const routeFiles = {
     }
 
     function AwaitError() {
-      let error = useRouteError();
+      let error = useAsyncError();
       return (
         <>
           <h2>Defer Error</h2>
-          <p>{error}</p>
+          <p>{error.message}</p>
         </>
       );
     }
 
-    export function ErrorBoundary({ error }) {
+    export function ErrorBoundary() {
+      let error = useRouteError();
       return (
         <>
-          <h1>Index Error</h1>
+          <h1>Defer Error</h1>
           <p>{"MESSAGE:" + error.message}</p>
           {error.stack ? <p>{"STACK:" + error.stack}</p> : null}
         </>
@@ -128,10 +130,12 @@ const routeFiles = {
 test.describe("Error Sanitization", () => {
   let fixture: Fixture;
   let oldConsoleError: () => void;
+  let errorLogs: any[] = [];
 
   test.beforeEach(() => {
     oldConsoleError = console.error;
-    console.error = () => {};
+    errorLogs = [];
+    console.error = (...args) => errorLogs.push(args);
   });
 
   test.afterEach(() => {
@@ -142,6 +146,11 @@ test.describe("Error Sanitization", () => {
     test.beforeAll(async () => {
       fixture = await createFixture(
         {
+          config: {
+            future: {
+              v2_errorBoundary: true,
+            },
+          },
           files: routeFiles,
         },
         ServerMode.Production
@@ -167,6 +176,9 @@ test.describe("Error Sanitization", () => {
         '{"routes/index":{"message":"Unexpected Server Error","__type":"Error"}}'
       );
       expect(html).not.toMatch(/stack/i);
+      expect(errorLogs.length).toBe(1);
+      expect(errorLogs[0][0].message).toMatch("Loader Error");
+      expect(errorLogs[0][0].stack).toMatch(" at ");
     });
 
     test("sanitizes render errors in document requests", async () => {
@@ -178,6 +190,9 @@ test.describe("Error Sanitization", () => {
         '{"routes/index":{"message":"Unexpected Server Error","__type":"Error"}}'
       );
       expect(html).not.toMatch(/stack/i);
+      expect(errorLogs.length).toBe(1);
+      expect(errorLogs[0][0].message).toMatch("Render Error");
+      expect(errorLogs[0][0].stack).toMatch(" at ");
     });
 
     test("renders deferred document without errors", async () => {
@@ -200,6 +215,9 @@ test.describe("Error Sanitization", () => {
       // Defer errors are not not part of the JSON blob but rather rejected
       // against a pending promise and therefore are inlined JS.
       expect(html).toMatch("x.stack=undefined;");
+      // defer errors are not logged to the server console since the request
+      // has "succeeded"
+      expect(errorLogs.length).toBe(0);
     });
 
     test("returns data without errors", async () => {
@@ -214,6 +232,9 @@ test.describe("Error Sanitization", () => {
       let response = await fixture.requestData("/?loader", "routes/index");
       let text = await response.text();
       expect(text).toBe('{"message":"Unexpected Server Error"}');
+      expect(errorLogs.length).toBe(1);
+      expect(errorLogs[0][0].message).toMatch("Loader Error");
+      expect(errorLogs[0][0].stack).toMatch(" at ");
     });
 
     test("returns deferred data without errors", async () => {
@@ -231,6 +252,9 @@ test.describe("Error Sanitization", () => {
         '{"lazy":"__deferred_promise:lazy"}\n\n' +
           'error:{"lazy":{"message":"Unexpected Server Error"}}\n\n'
       );
+      // defer errors are not logged to the server console since the request
+      // has "succeeded"
+      expect(errorLogs.length).toBe(0);
     });
 
     test("sanitizes loader errors in resource requests", async () => {
@@ -240,12 +264,20 @@ test.describe("Error Sanitization", () => {
       );
       let text = await response.text();
       expect(text).toBe('{"message":"Unexpected Server Error"}');
+      expect(errorLogs.length).toBe(1);
+      expect(errorLogs[0][0].message).toMatch("Loader Error");
+      expect(errorLogs[0][0].stack).toMatch(" at ");
     });
 
     test("sanitizes mismatched route errors in data requests", async () => {
       let response = await fixture.requestData("/", "not-a-route");
       let text = await response.text();
       expect(text).toBe('{"message":"Unexpected Server Error"}');
+      expect(errorLogs.length).toBe(1);
+      expect(errorLogs[0][0].message).toMatch(
+        'Route "not-a-route" does not match URL "/"'
+      );
+      expect(errorLogs[0][0].stack).toMatch(" at ");
     });
   });
 
@@ -253,6 +285,11 @@ test.describe("Error Sanitization", () => {
     test.beforeAll(async () => {
       fixture = await createFixture(
         {
+          config: {
+            future: {
+              v2_errorBoundary: true,
+            },
+          },
           files: routeFiles,
         },
         ServerMode.Development
@@ -286,6 +323,9 @@ test.describe("Error Sanitization", () => {
       expect(html).toMatch(
         'errors":{"routes/index":{"message":"Loader Error","stack":"Error: Loader Error\\n'
       );
+      expect(errorLogs.length).toBe(1);
+      expect(errorLogs[0][0].message).toMatch("Loader Error");
+      expect(errorLogs[0][0].stack).toMatch(" at ");
     });
 
     test("does not sanitize render errors in document requests", async () => {
@@ -297,6 +337,9 @@ test.describe("Error Sanitization", () => {
       expect(html).toMatch(
         'errors":{"routes/index":{"message":"Render Error","stack":"Error: Render Error\\n'
       );
+      expect(errorLogs.length).toBe(1);
+      expect(errorLogs[0][0].message).toMatch("Render Error");
+      expect(errorLogs[0][0].stack).toMatch(" at ");
     });
 
     test("renders deferred document without errors", async () => {
@@ -316,6 +359,9 @@ test.describe("Error Sanitization", () => {
       // Defer errors are not not part of the JSON blob but rather rejected
       // against a pending promise and therefore are inlined JS.
       expect(html).toMatch("x.stack=e.stack;");
+      // defer errors are not logged to the server console since the request
+      // has "succeeded"
+      expect(errorLogs.length).toBe(0);
     });
 
     test("returns data without errors", async () => {
@@ -332,6 +378,9 @@ test.describe("Error Sanitization", () => {
       expect(text).toMatch(
         '{"message":"Loader Error","stack":"Error: Loader Error'
       );
+      expect(errorLogs.length).toBe(1);
+      expect(errorLogs[0][0].message).toMatch("Loader Error");
+      expect(errorLogs[0][0].stack).toMatch(" at ");
     });
 
     test("returns deferred data without errors", async () => {
@@ -348,6 +397,9 @@ test.describe("Error Sanitization", () => {
       expect(text).toMatch(
         'error:{"lazy":{"message":"REJECTED","stack":"Error: REJECTED'
       );
+      // defer errors are not logged to the server console since the request
+      // has "succeeded"
+      expect(errorLogs.length).toBe(0);
     });
 
     test("does not sanitize loader errors in resource requests", async () => {
@@ -359,6 +411,9 @@ test.describe("Error Sanitization", () => {
       expect(text).toMatch(
         '{"message":"Loader Error","stack":"Error: Loader Error'
       );
+      expect(errorLogs.length).toBe(1);
+      expect(errorLogs[0][0].message).toMatch("Loader Error");
+      expect(errorLogs[0][0].stack).toMatch(" at ");
     });
 
     test("sanitizes mismatched route errors in data requests", async () => {
@@ -367,6 +422,205 @@ test.describe("Error Sanitization", () => {
       expect(text).toMatch(
         '{"message":"Route \\"not-a-route\\" does not match URL \\"/\\"","stack":"Error: Route \\"not-a-route\\" does not match URL \\"/\\"'
       );
+      expect(errorLogs.length).toBe(1);
+      expect(errorLogs[0][0].message).toMatch(
+        'Route "not-a-route" does not match URL "/"'
+      );
+      expect(errorLogs[0][0].stack).toMatch(" at ");
+    });
+  });
+
+  test.describe("serverMode=production (user-provided onUnhandledError)", () => {
+    test.beforeAll(async () => {
+      fixture = await createFixture(
+        {
+          config: {
+            future: {
+              v2_errorBoundary: true,
+            },
+          },
+          files: {
+            "app/entry.server.tsx": js`
+              import type { EntryContext } from "@remix-run/node";
+              import { RemixServer, isRouteErrorResponse } from "@remix-run/react";
+              import { renderToString } from "react-dom/server";
+
+              export default function handleRequest(
+                request: Request,
+                responseStatusCode: number,
+                responseHeaders: Headers,
+                remixContext: EntryContext
+              ) {
+                let markup = renderToString(
+                  <RemixServer context={remixContext} url={request.url} />
+                );
+
+                responseHeaders.set("Content-Type", "text/html");
+
+                return new Response("<!DOCTYPE html>" + markup, {
+                  status: responseStatusCode,
+                  headers: responseHeaders,
+                });
+              }
+
+              export function onUnhandledError(
+                error: unknown,
+                { request }: { request: Request },
+              ) {
+                console.error("App Specific Error Logging:");
+                console.error("  Request: " + request.method + " " + request.url);
+                if (error instanceof Error) {
+                  console.error("  Error: " + error.message);
+                  console.error("  Stack: " + error.stack);
+                } else {
+                  console.error("Dunno what this is");
+                }
+              }
+            `,
+            ...routeFiles,
+          },
+        },
+        ServerMode.Production
+      );
+    });
+
+    test("renders document without errors", async () => {
+      let response = await fixture.requestDocument("/");
+      let html = await response.text();
+      expect(html).toMatch("Index Route");
+      expect(html).toMatch("LOADER");
+      expect(html).not.toMatch("MESSAGE:");
+      expect(html).not.toMatch(/stack/i);
+    });
+
+    test("sanitizes loader errors in document requests", async () => {
+      let response = await fixture.requestDocument("/?loader");
+      let html = await response.text();
+      expect(html).toMatch("Index Error");
+      expect(html).not.toMatch("LOADER");
+      expect(html).toMatch("MESSAGE:Unexpected Server Error");
+      expect(html).toMatch(
+        '{"routes/index":{"message":"Unexpected Server Error","__type":"Error"}}'
+      );
+      expect(html).not.toMatch(/stack/i);
+      expect(errorLogs[0][0]).toEqual("App Specific Error Logging:");
+      expect(errorLogs[1][0]).toEqual("  Request: GET test://test/?loader");
+      expect(errorLogs[2][0]).toEqual("  Error: Loader Error");
+      expect(errorLogs[3][0]).toMatch(" at ");
+      expect(errorLogs.length).toBe(4);
+    });
+
+    test("sanitizes render errors in document requests", async () => {
+      let response = await fixture.requestDocument("/?render");
+      let html = await response.text();
+      expect(html).toMatch("Index Error");
+      expect(html).toMatch("MESSAGE:Unexpected Server Error");
+      expect(html).toMatch(
+        '{"routes/index":{"message":"Unexpected Server Error","__type":"Error"}}'
+      );
+      expect(html).not.toMatch(/stack/i);
+      expect(errorLogs[0][0]).toEqual("App Specific Error Logging:");
+      expect(errorLogs[1][0]).toEqual("  Request: GET test://test/?render");
+      expect(errorLogs[2][0]).toEqual("  Error: Render Error");
+      expect(errorLogs[3][0]).toMatch(" at ");
+      expect(errorLogs.length).toBe(4);
+    });
+
+    test("renders deferred document without errors", async () => {
+      let response = await fixture.requestDocument("/defer");
+      let html = await response.text();
+      expect(html).toMatch("Defer Route");
+      expect(html).toMatch("RESOLVED");
+      expect(html).not.toMatch("MESSAGE:");
+      // Defer errors are not not part of the JSON blob but rather rejected
+      // against a pending promise and therefore are inlined JS.
+      expect(html).not.toMatch("x.stack=e.stack;");
+    });
+
+    test("sanitizes defer errors in document requests", async () => {
+      let response = await fixture.requestDocument("/defer?loader");
+      let html = await response.text();
+      expect(html).toMatch("Defer Error");
+      expect(html).not.toMatch("RESOLVED");
+      expect(html).toMatch('{"message":"Unexpected Server Error"}');
+      // Defer errors are not not part of the JSON blob but rather rejected
+      // against a pending promise and therefore are inlined JS.
+      expect(html).toMatch("x.stack=undefined;");
+      // defer errors are not logged to the server console since the request
+      // has "succeeded"
+      expect(errorLogs.length).toBe(0);
+    });
+
+    test("returns data without errors", async () => {
+      let response = await fixture.requestData("/", "routes/index");
+      let text = await response.text();
+      expect(text).toMatch("LOADER");
+      expect(text).not.toMatch("MESSAGE:");
+      expect(text).not.toMatch(/stack/i);
+    });
+
+    test("sanitizes loader errors in data requests", async () => {
+      let response = await fixture.requestData("/?loader", "routes/index");
+      let text = await response.text();
+      expect(text).toBe('{"message":"Unexpected Server Error"}');
+      expect(errorLogs[0][0]).toEqual("App Specific Error Logging:");
+      expect(errorLogs[1][0]).toEqual(
+        "  Request: GET test://test/?loader=&_data=routes%2Findex"
+      );
+      expect(errorLogs[2][0]).toEqual("  Error: Loader Error");
+      expect(errorLogs[3][0]).toMatch(" at ");
+      expect(errorLogs.length).toBe(4);
+    });
+
+    test("returns deferred data without errors", async () => {
+      let response = await fixture.requestData("/defer", "routes/defer");
+      let text = await response.text();
+      expect(text).toMatch("RESOLVED");
+      expect(text).not.toMatch("REJECTED");
+      expect(text).not.toMatch(/stack/i);
+    });
+
+    test("sanitizes loader errors in deferred data requests", async () => {
+      let response = await fixture.requestData("/defer?loader", "routes/defer");
+      let text = await response.text();
+      expect(text).toBe(
+        '{"lazy":"__deferred_promise:lazy"}\n\n' +
+          'error:{"lazy":{"message":"Unexpected Server Error"}}\n\n'
+      );
+      // defer errors are not logged to the server console since the request
+      // has "succeeded"
+      expect(errorLogs.length).toBe(0);
+    });
+
+    test("sanitizes loader errors in resource requests", async () => {
+      let response = await fixture.requestData(
+        "/resource?loader",
+        "routes/resource"
+      );
+      let text = await response.text();
+      expect(text).toBe('{"message":"Unexpected Server Error"}');
+      expect(errorLogs[0][0]).toEqual("App Specific Error Logging:");
+      expect(errorLogs[1][0]).toEqual(
+        "  Request: GET test://test/resource?loader=&_data=routes%2Fresource"
+      );
+      expect(errorLogs[2][0]).toEqual("  Error: Loader Error");
+      expect(errorLogs[3][0]).toMatch(" at ");
+      expect(errorLogs.length).toBe(4);
+    });
+
+    test("sanitizes mismatched route errors in data requests", async () => {
+      let response = await fixture.requestData("/", "not-a-route");
+      let text = await response.text();
+      expect(text).toBe('{"message":"Unexpected Server Error"}');
+      expect(errorLogs[0][0]).toEqual("App Specific Error Logging:");
+      expect(errorLogs[1][0]).toEqual(
+        "  Request: GET test://test/?_data=not-a-route"
+      );
+      expect(errorLogs[2][0]).toEqual(
+        '  Error: Route "not-a-route" does not match URL "/"'
+      );
+      expect(errorLogs[3][0]).toMatch(" at ");
+      expect(errorLogs.length).toBe(4);
     });
   });
 });
diff --git a/integration/headers-test.ts b/integration/headers-test.ts
index 454c83ac3..a34dfe728 100644
--- a/integration/headers-test.ts
+++ b/integration/headers-test.ts
@@ -1,4 +1,5 @@
 import { test, expect } from "@playwright/test";
+import { ServerMode } from "@remix-run/server-runtime/mode";
 
 import { createFixture, js } from "./helpers/create-fixture";
 import type { Fixture } from "./helpers/create-fixture";
@@ -12,195 +13,198 @@ test.describe("headers export", () => {
   let appFixture: Fixture;
 
   test.beforeAll(async () => {
-    appFixture = await createFixture({
-      config: {
-        future: {
-          v2_routeConvention: true,
-          v2_errorBoundary: true,
-          v2_headers: true,
+    appFixture = await createFixture(
+      {
+        config: {
+          future: {
+            v2_routeConvention: true,
+            v2_errorBoundary: true,
+            v2_headers: true,
+          },
         },
-      },
-      files: {
-        "app/root.jsx": js`
-          import { json } from "@remix-run/node";
-          import { Links, Meta, Outlet, Scripts } from "@remix-run/react";
-
-          export const loader = () => json({});
-
-          export default function Root() {
-            return (
-              <html lang="en">
-                <head>
-                  <Meta />
-                  <Links />
-                </head>
-                <body>
-                  <Outlet />
-                  <Scripts />
-                </body>
-              </html>
-            );
-          }
-        `,
-
-        "app/routes/_index.jsx": js`
-          import { json } from "@remix-run/node";
-
-          export function loader() {
-            return json(null, {
-              headers: {
-                "${ROOT_HEADER_KEY}": "${ROOT_HEADER_VALUE}"
+        files: {
+          "app/root.jsx": js`
+            import { json } from "@remix-run/node";
+            import { Links, Meta, Outlet, Scripts } from "@remix-run/react";
+
+            export const loader = () => json({});
+
+            export default function Root() {
+              return (
+                <html lang="en">
+                  <head>
+                    <Meta />
+                    <Links />
+                  </head>
+                  <body>
+                    <Outlet />
+                    <Scripts />
+                  </body>
+                </html>
+              );
+            }
+          `,
+
+          "app/routes/_index.jsx": js`
+            import { json } from "@remix-run/node";
+
+            export function loader() {
+              return json(null, {
+                headers: {
+                  "${ROOT_HEADER_KEY}": "${ROOT_HEADER_VALUE}"
+                }
+              })
+            }
+
+            export function headers({ loaderHeaders }) {
+              return {
+                "${ROOT_HEADER_KEY}": loaderHeaders.get("${ROOT_HEADER_KEY}")
               }
-            })
-          }
+            }
 
-          export function headers({ loaderHeaders }) {
-            return {
-              "${ROOT_HEADER_KEY}": loaderHeaders.get("${ROOT_HEADER_KEY}")
+            export default function Index() {
+              return <div>Heyo!</div>
             }
-          }
+          `,
 
-          export default function Index() {
-            return <div>Heyo!</div>
-          }
-        `,
+          "app/routes/action.jsx": js`
+            import { json } from "@remix-run/node";
 
-        "app/routes/action.jsx": js`
-          import { json } from "@remix-run/node";
+            export function action() {
+              return json(null, {
+                headers: {
+                  "${ACTION_HKEY}": "${ACTION_HVALUE}"
+                }
+              })
+            }
 
-          export function action() {
-            return json(null, {
-              headers: {
-                "${ACTION_HKEY}": "${ACTION_HVALUE}"
+            export function headers({ actionHeaders }) {
+              return {
+                "${ACTION_HKEY}": actionHeaders.get("${ACTION_HKEY}")
               }
-            })
-          }
+            }
 
-          export function headers({ actionHeaders }) {
-            return {
-              "${ACTION_HKEY}": actionHeaders.get("${ACTION_HKEY}")
+            export default function Action() { return <div/> }
+          `,
+
+          "app/routes/parent.jsx": js`
+            export function headers({ actionHeaders, errorHeaders, loaderHeaders, parentHeaders }) {
+              return new Headers([
+                ...(parentHeaders ? Array.from(parentHeaders.entries()) : []),
+                ...(actionHeaders ? Array.from(actionHeaders.entries()) : []),
+                ...(loaderHeaders ? Array.from(loaderHeaders.entries()) : []),
+                ...(errorHeaders ? Array.from(errorHeaders.entries()) : []),
+              ]);
             }
-          }
-
-          export default function Action() { return <div/> }
-        `,
-
-        "app/routes/parent.jsx": js`
-          export function headers({ actionHeaders, errorHeaders, loaderHeaders, parentHeaders }) {
-            return new Headers([
-              ...(parentHeaders ? Array.from(parentHeaders.entries()) : []),
-              ...(actionHeaders ? Array.from(actionHeaders.entries()) : []),
-              ...(loaderHeaders ? Array.from(loaderHeaders.entries()) : []),
-              ...(errorHeaders ? Array.from(errorHeaders.entries()) : []),
-            ]);
-          }
-
-          export function loader({ request }) {
-            if (new URL(request.url).searchParams.get('throw') === "parent") {
-              throw new Response(null, {
-                status: 400,
-                headers: { 'X-Parent-Loader': 'error' },
+
+            export function loader({ request }) {
+              if (new URL(request.url).searchParams.get('throw') === "parent") {
+                throw new Response(null, {
+                  status: 400,
+                  headers: { 'X-Parent-Loader': 'error' },
+                })
+              }
+              return new Response(null, {
+                headers: { 'X-Parent-Loader': 'success' },
               })
             }
-            return new Response(null, {
-              headers: { 'X-Parent-Loader': 'success' },
-            })
-          }
-
-          export async function action({ request }) {
-            let fd = await request.formData();
-            if (fd.get('throw') === "parent") {
-              throw new Response(null, {
-                status: 400,
-                headers: { 'X-Parent-Action': 'error' },
+
+            export async function action({ request }) {
+              let fd = await request.formData();
+              if (fd.get('throw') === "parent") {
+                throw new Response(null, {
+                  status: 400,
+                  headers: { 'X-Parent-Action': 'error' },
+                })
+              }
+              return new Response(null, {
+                headers: { 'X-Parent-Action': 'success' },
               })
             }
-            return new Response(null, {
-              headers: { 'X-Parent-Action': 'success' },
-            })
-          }
 
-          export default function Component() { return <div/> }
+            export default function Component() { return <div/> }
 
-          export function ErrorBoundary() {
-            return <h1>Error!</h1>
-          }
-        `,
+            export function ErrorBoundary() {
+              return <h1>Error!</h1>
+            }
+          `,
+
+          "app/routes/parent.child.jsx": js`
+            export function loader({ request }) {
+              if (new URL(request.url).searchParams.get('throw') === "child") {
+                throw new Response(null, {
+                  status: 400,
+                  headers: { 'X-Child-Loader': 'error' },
+                })
+              }
+              return null
+            }
 
-        "app/routes/parent.child.jsx": js`
-          export function loader({ request }) {
-            if (new URL(request.url).searchParams.get('throw') === "child") {
-              throw new Response(null, {
-                status: 400,
-                headers: { 'X-Child-Loader': 'error' },
-              })
+            export async function action({ request }) {
+              let fd = await request.formData();
+              if (fd.get('throw') === "child") {
+                throw new Response(null, {
+                  status: 400,
+                  headers: { 'X-Child-Action': 'error' },
+                })
+              }
+              return null
             }
-            return null
-          }
 
-          export async function action({ request }) {
-            let fd = await request.formData();
-            if (fd.get('throw') === "child") {
+            export default function Component() { return <div/> }
+          `,
+
+          "app/routes/parent.child.grandchild.jsx": js`
+            export function loader({ request }) {
               throw new Response(null, {
                 status: 400,
-                headers: { 'X-Child-Action': 'error' },
+                headers: { 'X-Child-Grandchild': 'error' },
               })
             }
-            return null
-          }
-
-          export default function Component() { return <div/> }
-        `,
-
-        "app/routes/parent.child.grandchild.jsx": js`
-          export function loader({ request }) {
-            throw new Response(null, {
-              status: 400,
-              headers: { 'X-Child-Grandchild': 'error' },
-            })
-          }
-
-          export default function Component() { return <div/> }
-        `,
-
-        "app/routes/cookie.jsx": js`
-          import { json } from "@remix-run/server-runtime";
-          import { Outlet } from "@remix-run/react";
-
-          export function loader({ request }) {
-            if (new URL(request.url).searchParams.has("parent-throw")) {
-              throw json(null, { headers: { "Set-Cookie": "parent-thrown-cookie=true" } });
+
+            export default function Component() { return <div/> }
+          `,
+
+          "app/routes/cookie.jsx": js`
+            import { json } from "@remix-run/server-runtime";
+            import { Outlet } from "@remix-run/react";
+
+            export function loader({ request }) {
+              if (new URL(request.url).searchParams.has("parent-throw")) {
+                throw json(null, { headers: { "Set-Cookie": "parent-thrown-cookie=true" } });
+              }
+              return null
+            };
+
+            export default function Parent() {
+              return <Outlet />;
             }
-            return null
-          };
 
-          export default function Parent() {
-            return <Outlet />;
-          }
+            export function ErrorBoundary() {
+              return <h1>Caught!</h1>;
+            }
+          `,
 
-          export function ErrorBoundary() {
-            return <h1>Caught!</h1>;
-          }
-        `,
+          "app/routes/cookie.child.jsx": js`
+            import { json } from "@remix-run/node";
 
-        "app/routes/cookie.child.jsx": js`
-          import { json } from "@remix-run/node";
+            export function loader({ request }) {
+              if (new URL(request.url).searchParams.has("throw")) {
+                throw json(null, { headers: { "Set-Cookie": "thrown-cookie=true" } });
+              }
+              return json(null, {
+                headers: { "Set-Cookie": "normal-cookie=true" },
+              });
+            };
 
-          export function loader({ request }) {
-            if (new URL(request.url).searchParams.has("throw")) {
-              throw json(null, { headers: { "Set-Cookie": "thrown-cookie=true" } });
+            export default function Child() {
+              return <p>Child</p>;
             }
-            return json(null, {
-              headers: { "Set-Cookie": "normal-cookie=true" },
-            });
-          };
-
-          export default function Child() {
-            return <p>Child</p>;
-          }
-        `,
+          `,
+        },
       },
-    });
+      ServerMode.Test
+    );
   });
 
   test("can use `action` headers", async () => {
@@ -220,53 +224,56 @@ test.describe("headers export", () => {
     let HEADER_KEY = "X-Test";
     let HEADER_VALUE = "SUCCESS";
 
-    let fixture = await createFixture({
-      config: {
-        future: { v2_routeConvention: true },
-      },
-      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 { json } from "@remix-run/node";
-
-          export function loader() {
-            return json(null, {
-              headers: {
-                "${HEADER_KEY}": "${HEADER_VALUE}"
-              }
-            })
-          }
+    let fixture = await createFixture(
+      {
+        config: {
+          future: { v2_routeConvention: true },
+        },
+        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>
+              );
+            }
+          `,
 
-          export function headers({ loaderHeaders }) {
-            return {
-              "${HEADER_KEY}": loaderHeaders.get("${HEADER_KEY}")
+          "app/routes/_index.jsx": js`
+            import { json } from "@remix-run/node";
+
+            export function loader() {
+              return json(null, {
+                headers: {
+                  "${HEADER_KEY}": "${HEADER_VALUE}"
+                }
+              })
             }
-          }
 
-          export default function Index() {
-            return <div>Heyo!</div>
-          }
-        `,
+            export function headers({ loaderHeaders }) {
+              return {
+                "${HEADER_KEY}": loaderHeaders.get("${HEADER_KEY}")
+              }
+            }
+
+            export default function Index() {
+              return <div>Heyo!</div>
+            }
+          `,
+        },
       },
-    });
+      ServerMode.Test
+    );
     let response = await fixture.requestDocument("/");
     expect(response.headers.get(HEADER_KEY)).toBe(HEADER_VALUE);
   });
@@ -427,102 +434,105 @@ test.describe("v1 behavior (future.v2_headers=false)", () => {
   let appFixture: Fixture;
 
   test.beforeAll(async () => {
-    appFixture = await createFixture({
-      config: {
-        future: {
-          v2_routeConvention: true,
-          v2_errorBoundary: true,
-          v2_headers: false,
+    appFixture = await createFixture(
+      {
+        config: {
+          future: {
+            v2_routeConvention: true,
+            v2_errorBoundary: true,
+            v2_headers: false,
+          },
         },
-      },
-      files: {
-        "app/root.jsx": js`
-          import { json } from "@remix-run/node";
-          import { Links, Meta, Outlet, Scripts } from "@remix-run/react";
-
-          export const loader = () => json({});
-
-          export default function Root() {
-            return (
-              <html lang="en">
-                <head>
-                  <Meta />
-                  <Links />
-                </head>
-                <body>
-                  <Outlet />
-                  <Scripts />
-                </body>
-              </html>
-            );
-          }
-        `,
-
-        "app/routes/parent.jsx": js`
-          export function headers({ actionHeaders, errorHeaders, loaderHeaders, parentHeaders }) {
-            return new Headers([
-              ...(parentHeaders ? Array.from(parentHeaders.entries()) : []),
-              ...(actionHeaders ? Array.from(actionHeaders.entries()) : []),
-              ...(loaderHeaders ? Array.from(loaderHeaders.entries()) : []),
-              ...(errorHeaders ? Array.from(errorHeaders.entries()) : []),
-            ]);
-          }
-
-          export function loader({ request }) {
-            return new Response(null, {
-              headers: { 'X-Parent-Loader': 'success' },
-            })
-          }
-
-          export default function Component() { return <div/> }
-        `,
-
-        "app/routes/parent.child.jsx": js`
-          export async function action({ request }) {
-            return null;
-          }
-
-          export default function Component() { return <div/> }
-        `,
-
-        "app/routes/cookie.jsx": js`
-          import { json } from "@remix-run/server-runtime";
-          import { Outlet } from "@remix-run/react";
-
-          export function loader({ request }) {
-            if (new URL(request.url).searchParams.has("parent-throw")) {
-              throw json(null, { headers: { "Set-Cookie": "parent-thrown-cookie=true" } });
+        files: {
+          "app/root.jsx": js`
+            import { json } from "@remix-run/node";
+            import { Links, Meta, Outlet, Scripts } from "@remix-run/react";
+
+            export const loader = () => json({});
+
+            export default function Root() {
+              return (
+                <html lang="en">
+                  <head>
+                    <Meta />
+                    <Links />
+                  </head>
+                  <body>
+                    <Outlet />
+                    <Scripts />
+                  </body>
+                </html>
+              );
+            }
+          `,
+
+          "app/routes/parent.jsx": js`
+            export function headers({ actionHeaders, errorHeaders, loaderHeaders, parentHeaders }) {
+              return new Headers([
+                ...(parentHeaders ? Array.from(parentHeaders.entries()) : []),
+                ...(actionHeaders ? Array.from(actionHeaders.entries()) : []),
+                ...(loaderHeaders ? Array.from(loaderHeaders.entries()) : []),
+                ...(errorHeaders ? Array.from(errorHeaders.entries()) : []),
+              ]);
             }
-            return null
-          };
 
-          export default function Parent() {
-            return <Outlet />;
-          }
+            export function loader({ request }) {
+              return new Response(null, {
+                headers: { 'X-Parent-Loader': 'success' },
+              })
+            }
+
+            export default function Component() { return <div/> }
+          `,
+
+          "app/routes/parent.child.jsx": js`
+            export async function action({ request }) {
+              return null;
+            }
+
+            export default function Component() { return <div/> }
+          `,
+
+          "app/routes/cookie.jsx": js`
+            import { json } from "@remix-run/server-runtime";
+            import { Outlet } from "@remix-run/react";
+
+            export function loader({ request }) {
+              if (new URL(request.url).searchParams.has("parent-throw")) {
+                throw json(null, { headers: { "Set-Cookie": "parent-thrown-cookie=true" } });
+              }
+              return null
+            };
+
+            export default function Parent() {
+              return <Outlet />;
+            }
+
+            export function ErrorBoundary() {
+              return <h1>Caught!</h1>;
+            }
+          `,
 
-          export function ErrorBoundary() {
-            return <h1>Caught!</h1>;
-          }
-        `,
+          "app/routes/cookie.child.jsx": js`
+            import { json } from "@remix-run/node";
 
-        "app/routes/cookie.child.jsx": js`
-          import { json } from "@remix-run/node";
+            export function loader({ request }) {
+              if (new URL(request.url).searchParams.has("throw")) {
+                throw json(null, { headers: { "Set-Cookie": "thrown-cookie=true" } });
+              }
+              return json(null, {
+                headers: { "Set-Cookie": "normal-cookie=true" },
+              });
+            };
 
-          export function loader({ request }) {
-            if (new URL(request.url).searchParams.has("throw")) {
-              throw json(null, { headers: { "Set-Cookie": "thrown-cookie=true" } });
+            export default function Child() {
+              return <p>Child</p>;
             }
-            return json(null, {
-              headers: { "Set-Cookie": "normal-cookie=true" },
-            });
-          };
-
-          export default function Child() {
-            return <p>Child</p>;
-          }
-        `,
+          `,
+        },
       },
-    });
+      ServerMode.Test
+    );
   });
 
   test("returns no headers when the leaf route doesn't export a header function (GET)", async () => {
diff --git a/packages/remix-node/__tests__/fetch-test.ts b/packages/remix-node/__tests__/fetch-test.ts
index 9468160d6..4fd79135e 100644
--- a/packages/remix-node/__tests__/fetch-test.ts
+++ b/packages/remix-node/__tests__/fetch-test.ts
@@ -1,4 +1,5 @@
 import { PassThrough } from "stream";
+import { ReadableStream } from "@remix-run/web-stream";
 
 import { Request } from "../fetch";
 
@@ -70,9 +71,15 @@ let test = {
 
 describe("Request", () => {
   it("clones", async () => {
-    let body = new PassThrough();
-    test.source.forEach((chunk) => body.write(chunk));
-    body.end();
+    let encoder = new TextEncoder();
+    let body = new ReadableStream({
+      start(controller) {
+        test.source.forEach((chunk) => {
+          controller.enqueue(encoder.encode(chunk));
+        });
+        controller.close();
+      },
+    });
 
     let req = new Request("http://test.com", {
       method: "post",
@@ -103,6 +110,8 @@ describe("Request", () => {
     file = clonedFormData.get("upload_file_1") as File;
     expect(file.name).toBe("1k_b.dat");
     expect(file.size).toBe(1023);
+
+    expect(cloned instanceof Request).toBeTruthy();
   });
 });
 
diff --git a/packages/remix-server-runtime/__tests__/server-test.ts b/packages/remix-server-runtime/__tests__/server-test.ts
index c0bf55166..4a0d4f91d 100644
--- a/packages/remix-server-runtime/__tests__/server-test.ts
+++ b/packages/remix-server-runtime/__tests__/server-test.ts
@@ -5,8 +5,6 @@ import { ServerMode } from "../mode";
 import type { ServerBuild } from "../build";
 import { mockServerBuild } from "./utils";
 
-const DATA_CALL_MULTIPIER = 1;
-
 function spyConsole() {
   // https://github.com/facebook/react/issues/7047
   let spy: any = {};
@@ -28,7 +26,7 @@ describe("server", () => {
     entry: {
       module: {
         default: async (request) => {
-          return new Response(`${request.method}, ${request.url}`);
+          return new Response(`${request.method}, ${request.url} COMPONENT`);
         },
       },
     },
@@ -37,8 +35,10 @@ describe("server", () => {
         id: routeId,
         path: "",
         module: {
-          action: () => "ACTION",
-          loader: () => "LOADER",
+          action: ({ request }) =>
+            new Response(`${request.method} ${request.url} ACTION`),
+          loader: ({ request }) =>
+            new Response(`${request.method} ${request.url} LOADER`),
           default: () => "COMPONENT",
         },
       },
@@ -59,20 +59,27 @@ describe("server", () => {
   } as unknown as ServerBuild;
 
   describe("createRequestHandler", () => {
+    let spy = spyConsole();
+
+    beforeEach(() => {
+      spy.console.mockClear();
+    });
+
     let allowThrough = [
       ["GET", "/"],
-      ["GET", "/_data=root"],
+      ["GET", "/?_data=root"],
       ["POST", "/"],
-      ["POST", "/_data=root"],
+      ["POST", "/?_data=root"],
       ["PUT", "/"],
-      ["PUT", "/_data=root"],
+      ["PUT", "/?_data=root"],
       ["DELETE", "/"],
-      ["DELETE", "/_data=root"],
+      ["DELETE", "/?_data=root"],
       ["PATCH", "/"],
-      ["PATCH", "/_data=root"],
+      ["PATCH", "/?_data=root"],
     ];
-    for (let [method, to] of allowThrough) {
-      it(`allows through ${method} request to ${to}`, async () => {
+    it.each(allowThrough)(
+      `allows through %s request to %s`,
+      async (method, to) => {
         let handler = createRequestHandler(build);
         let response = await handler(
           new Request(`http://localhost:3000${to}`, {
@@ -80,9 +87,18 @@ describe("server", () => {
           })
         );
 
-        expect(await response.text()).toContain(method);
-      });
-    }
+        expect(response.status).toBe(200);
+        let text = await response.text();
+        expect(text).toContain(method);
+        let expected = !to.includes("?_data=root")
+          ? "COMPONENT"
+          : method === "GET"
+          ? "LOADER"
+          : "ACTION";
+        expect(text).toContain(expected);
+        expect(spy.console).not.toHaveBeenCalled();
+      }
+    );
 
     it("strips body for HEAD requests", async () => {
       let handler = createRequestHandler(build);
@@ -135,7 +151,7 @@ describe("shared server runtime", () => {
       expect(result.status).toBe(200);
       expect(await result.json()).toBe("resource");
       expect(rootLoader.mock.calls.length).toBe(0);
-      expect(resourceLoader.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(resourceLoader.mock.calls.length).toBe(1);
     });
 
     test("calls sub resource route loader", async () => {
@@ -173,7 +189,7 @@ describe("shared server runtime", () => {
       expect(await result.json()).toBe("sub");
       expect(rootLoader.mock.calls.length).toBe(0);
       expect(resourceLoader.mock.calls.length).toBe(0);
-      expect(subResourceLoader.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(subResourceLoader.mock.calls.length).toBe(1);
     });
 
     test("resource route loader allows thrown responses", async () => {
@@ -203,7 +219,7 @@ describe("shared server runtime", () => {
       expect(result.status).toBe(200);
       expect(await result.text()).toBe("resource");
       expect(rootLoader.mock.calls.length).toBe(0);
-      expect(resourceLoader.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(resourceLoader.mock.calls.length).toBe(1);
     });
 
     test("resource route loader responds with generic error when thrown", async () => {
@@ -248,7 +264,7 @@ describe("shared server runtime", () => {
 
       let result = await handler(request);
       expect((await result.text()).includes(error.message)).toBe(true);
-      expect(spy.console.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(spy.console.mock.calls.length).toBe(1);
     });
 
     test("calls resource route action", async () => {
@@ -278,7 +294,7 @@ describe("shared server runtime", () => {
       expect(result.status).toBe(200);
       expect(await result.json()).toBe("resource");
       expect(rootAction.mock.calls.length).toBe(0);
-      expect(resourceAction.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(resourceAction.mock.calls.length).toBe(1);
     });
 
     test("calls sub resource route action", async () => {
@@ -316,7 +332,7 @@ describe("shared server runtime", () => {
       expect(await result.json()).toBe("sub");
       expect(rootAction.mock.calls.length).toBe(0);
       expect(resourceAction.mock.calls.length).toBe(0);
-      expect(subResourceAction.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(subResourceAction.mock.calls.length).toBe(1);
     });
 
     test("resource route action allows thrown responses", async () => {
@@ -346,7 +362,7 @@ describe("shared server runtime", () => {
       expect(result.status).toBe(200);
       expect(await result.text()).toBe("resource");
       expect(rootAction.mock.calls.length).toBe(0);
-      expect(resourceAction.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(resourceAction.mock.calls.length).toBe(1);
     });
 
     test("resource route action responds with generic error when thrown", async () => {
@@ -391,7 +407,7 @@ describe("shared server runtime", () => {
 
       let result = await handler(request);
       expect((await result.text()).includes(message)).toBe(true);
-      expect(spy.console.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(spy.console.mock.calls.length).toBe(1);
     });
   });
 
@@ -446,7 +462,7 @@ describe("shared server runtime", () => {
       expect(result.status).toBe(200);
       expect(await result.json()).toBe("index");
       expect(rootLoader.mock.calls.length).toBe(0);
-      expect(indexLoader.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(indexLoader.mock.calls.length).toBe(1);
     });
 
     test("data request calls loader and responds with generic message and error header", async () => {
@@ -477,7 +493,7 @@ describe("shared server runtime", () => {
       expect(result.status).toBe(500);
       expect((await result.json()).message).toBe("Unexpected Server Error");
       expect(result.headers.get("X-Remix-Error")).toBe("yes");
-      expect(rootLoader.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(rootLoader.mock.calls.length).toBe(1);
       expect(testAction.mock.calls.length).toBe(0);
     });
 
@@ -511,9 +527,9 @@ describe("shared server runtime", () => {
       expect(result.status).toBe(500);
       expect((await result.json()).message).toBe(message);
       expect(result.headers.get("X-Remix-Error")).toBe("yes");
-      expect(rootLoader.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(rootLoader.mock.calls.length).toBe(1);
       expect(testAction.mock.calls.length).toBe(0);
-      expect(spy.console.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(spy.console.mock.calls.length).toBe(1);
     });
 
     test("data request calls loader and responds with catch header", async () => {
@@ -544,7 +560,7 @@ describe("shared server runtime", () => {
       expect(result.status).toBe(400);
       expect(await result.text()).toBe("test");
       expect(result.headers.get("X-Remix-Catch")).toBe("yes");
-      expect(rootLoader.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(rootLoader.mock.calls.length).toBe(1);
       expect(testAction.mock.calls.length).toBe(0);
     });
 
@@ -576,7 +592,7 @@ describe("shared server runtime", () => {
       expect(result.status).toBe(200);
       expect(await result.json()).toBe("test");
       expect(rootLoader.mock.calls.length).toBe(0);
-      expect(testAction.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(testAction.mock.calls.length).toBe(1);
     });
 
     test("data request calls action and responds with generic message and error header", async () => {
@@ -608,7 +624,7 @@ describe("shared server runtime", () => {
       expect((await result.json()).message).toBe("Unexpected Server Error");
       expect(result.headers.get("X-Remix-Error")).toBe("yes");
       expect(rootLoader.mock.calls.length).toBe(0);
-      expect(testAction.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(testAction.mock.calls.length).toBe(1);
     });
 
     test("data request calls action and responds with detailed info and error header in development mode", async () => {
@@ -642,8 +658,8 @@ describe("shared server runtime", () => {
       expect((await result.json()).message).toBe(message);
       expect(result.headers.get("X-Remix-Error")).toBe("yes");
       expect(rootLoader.mock.calls.length).toBe(0);
-      expect(testAction.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
-      expect(spy.console.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(testAction.mock.calls.length).toBe(1);
+      expect(spy.console.mock.calls.length).toBe(1);
     });
 
     test("data request calls action and responds with catch header", async () => {
@@ -675,7 +691,7 @@ describe("shared server runtime", () => {
       expect(await result.text()).toBe("test");
       expect(result.headers.get("X-Remix-Catch")).toBe("yes");
       expect(rootLoader.mock.calls.length).toBe(0);
-      expect(testAction.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(testAction.mock.calls.length).toBe(1);
     });
 
     test("data request calls layout action", async () => {
@@ -706,7 +722,7 @@ describe("shared server runtime", () => {
       expect(result.status).toBe(200);
       expect(await result.json()).toBe("root");
       expect(rootLoader.mock.calls.length).toBe(0);
-      expect(rootAction.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(rootAction.mock.calls.length).toBe(1);
     });
 
     test("data request calls index action", async () => {
@@ -737,7 +753,7 @@ describe("shared server runtime", () => {
       expect(result.status).toBe(200);
       expect(await result.json()).toBe("index");
       expect(rootLoader.mock.calls.length).toBe(0);
-      expect(indexAction.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(indexAction.mock.calls.length).toBe(1);
     });
   });
 
@@ -761,12 +777,10 @@ 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 * DATA_CALL_MULTIPIER
-      );
+      expect(build.entry.module.default.mock.calls.length).toBe(1);
 
       let calls = build.entry.module.default.mock.calls;
-      expect(calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(calls.length).toBe(1);
       let context = calls[0][3].staticHandlerContext as StaticHandlerContext;
       expect(context.errors).toBeTruthy();
       expect(context.errors!.root.status).toBe(404);
@@ -790,12 +804,10 @@ 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 * DATA_CALL_MULTIPIER
-      );
+      expect(build.entry.module.default.mock.calls.length).toBe(1);
 
       let calls = build.entry.module.default.mock.calls;
-      expect(calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(calls.length).toBe(1);
       let context = calls[0][3].staticHandlerContext as StaticHandlerContext;
       expect(context.errors).toBeTruthy();
       expect(context.errors!.root.status).toBe(404);
@@ -828,14 +840,12 @@ describe("shared server runtime", () => {
 
       let result = await handler(request);
       expect(result.status).toBe(400);
-      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
-      );
+      expect(rootLoader.mock.calls.length).toBe(1);
+      expect(indexLoader.mock.calls.length).toBe(1);
+      expect(build.entry.module.default.mock.calls.length).toBe(1);
 
       let calls = build.entry.module.default.mock.calls;
-      expect(calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(calls.length).toBe(1);
       let context = calls[0][3].staticHandlerContext as StaticHandlerContext;
       expect(context.errors).toBeTruthy();
       expect(context.errors!.root.status).toBe(400);
@@ -871,14 +881,12 @@ describe("shared server runtime", () => {
 
       let result = await handler(request);
       expect(result.status).toBe(400);
-      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
-      );
+      expect(rootLoader.mock.calls.length).toBe(1);
+      expect(indexLoader.mock.calls.length).toBe(1);
+      expect(build.entry.module.default.mock.calls.length).toBe(1);
 
       let calls = build.entry.module.default.mock.calls;
-      expect(calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(calls.length).toBe(1);
       let context = calls[0][3].staticHandlerContext as StaticHandlerContext;
       expect(context.errors).toBeTruthy();
       expect(context.errors!["routes/index"].status).toBe(400);
@@ -917,16 +925,14 @@ describe("shared server runtime", () => {
 
       let result = await handler(request);
       expect(result.status).toBe(400);
-      expect(testAction.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(testAction.mock.calls.length).toBe(1);
       // 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 * DATA_CALL_MULTIPIER
-      );
+      expect(build.entry.module.default.mock.calls.length).toBe(1);
 
       let calls = build.entry.module.default.mock.calls;
-      expect(calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(calls.length).toBe(1);
       let context = calls[0][3].staticHandlerContext as StaticHandlerContext;
       expect(context.errors).toBeTruthy();
       expect(context.errors!.root.status).toBe(400);
@@ -966,16 +972,14 @@ describe("shared server runtime", () => {
 
       let result = await handler(request);
       expect(result.status).toBe(400);
-      expect(indexAction.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(indexAction.mock.calls.length).toBe(1);
       // 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 * DATA_CALL_MULTIPIER
-      );
+      expect(build.entry.module.default.mock.calls.length).toBe(1);
 
       let calls = build.entry.module.default.mock.calls;
-      expect(calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(calls.length).toBe(1);
       let context = calls[0][3].staticHandlerContext as StaticHandlerContext;
       expect(context.errors).toBeTruthy();
       expect(context.errors!.root.status).toBe(400);
@@ -1016,15 +1020,13 @@ describe("shared server runtime", () => {
 
       let result = await handler(request);
       expect(result.status).toBe(400);
-      expect(testAction.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
-      expect(rootLoader.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(testAction.mock.calls.length).toBe(1);
+      expect(rootLoader.mock.calls.length).toBe(1);
       expect(testLoader.mock.calls.length).toBe(0);
-      expect(build.entry.module.default.mock.calls.length).toBe(
-        1 * DATA_CALL_MULTIPIER
-      );
+      expect(build.entry.module.default.mock.calls.length).toBe(1);
 
       let calls = build.entry.module.default.mock.calls;
-      expect(calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(calls.length).toBe(1);
       let context = calls[0][3].staticHandlerContext as StaticHandlerContext;
       expect(context.errors).toBeTruthy();
       expect(context.errors!["routes/test"].status).toBe(400);
@@ -1065,15 +1067,13 @@ describe("shared server runtime", () => {
 
       let result = await handler(request);
       expect(result.status).toBe(400);
-      expect(indexAction.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
-      expect(rootLoader.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(indexAction.mock.calls.length).toBe(1);
+      expect(rootLoader.mock.calls.length).toBe(1);
       expect(indexLoader.mock.calls.length).toBe(0);
-      expect(build.entry.module.default.mock.calls.length).toBe(
-        1 * DATA_CALL_MULTIPIER
-      );
+      expect(build.entry.module.default.mock.calls.length).toBe(1);
 
       let calls = build.entry.module.default.mock.calls;
-      expect(calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(calls.length).toBe(1);
       let context = calls[0][3].staticHandlerContext as StaticHandlerContext;
       expect(context.errors).toBeTruthy();
       expect(context.errors!["routes/index"].status).toBe(400);
@@ -1122,15 +1122,13 @@ describe("shared server runtime", () => {
 
       let result = await handler(request);
       expect(result.status).toBe(400);
-      expect(testAction.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
-      expect(rootLoader.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(testAction.mock.calls.length).toBe(1);
+      expect(rootLoader.mock.calls.length).toBe(1);
       expect(testLoader.mock.calls.length).toBe(0);
-      expect(build.entry.module.default.mock.calls.length).toBe(
-        1 * DATA_CALL_MULTIPIER
-      );
+      expect(build.entry.module.default.mock.calls.length).toBe(1);
 
       let calls = build.entry.module.default.mock.calls;
-      expect(calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(calls.length).toBe(1);
       let context = calls[0][3].staticHandlerContext as StaticHandlerContext;
       expect(context.errors).toBeTruthy();
       expect(context.errors!["routes/__layout"].data).toBe("action");
@@ -1180,15 +1178,13 @@ describe("shared server runtime", () => {
 
       let result = await handler(request);
       expect(result.status).toBe(400);
-      expect(indexAction.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
-      expect(rootLoader.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(indexAction.mock.calls.length).toBe(1);
+      expect(rootLoader.mock.calls.length).toBe(1);
       expect(indexLoader.mock.calls.length).toBe(0);
-      expect(build.entry.module.default.mock.calls.length).toBe(
-        1 * DATA_CALL_MULTIPIER
-      );
+      expect(build.entry.module.default.mock.calls.length).toBe(1);
 
       let calls = build.entry.module.default.mock.calls;
-      expect(calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(calls.length).toBe(1);
       let context = calls[0][3].staticHandlerContext as StaticHandlerContext;
       expect(context.errors).toBeTruthy();
       expect(context.errors!["routes/__layout"].data).toBe("action");
@@ -1225,14 +1221,12 @@ describe("shared server runtime", () => {
 
       let result = await handler(request);
       expect(result.status).toBe(500);
-      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
-      );
+      expect(rootLoader.mock.calls.length).toBe(1);
+      expect(indexLoader.mock.calls.length).toBe(1);
+      expect(build.entry.module.default.mock.calls.length).toBe(1);
 
       let calls = build.entry.module.default.mock.calls;
-      expect(calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(calls.length).toBe(1);
       let context = calls[0][3].staticHandlerContext as StaticHandlerContext;
       expect(context.errors).toBeTruthy();
       expect(context.errors!.root).toBeInstanceOf(Error);
@@ -1270,14 +1264,12 @@ describe("shared server runtime", () => {
 
       let result = await handler(request);
       expect(result.status).toBe(500);
-      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
-      );
+      expect(rootLoader.mock.calls.length).toBe(1);
+      expect(indexLoader.mock.calls.length).toBe(1);
+      expect(build.entry.module.default.mock.calls.length).toBe(1);
 
       let calls = build.entry.module.default.mock.calls;
-      expect(calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(calls.length).toBe(1);
       let context = calls[0][3].staticHandlerContext as StaticHandlerContext;
       expect(context.errors).toBeTruthy();
       expect(context.errors!["routes/index"]).toBeInstanceOf(Error);
@@ -1320,16 +1312,14 @@ describe("shared server runtime", () => {
 
       let result = await handler(request);
       expect(result.status).toBe(500);
-      expect(testAction.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(testAction.mock.calls.length).toBe(1);
       // 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 * DATA_CALL_MULTIPIER
-      );
+      expect(build.entry.module.default.mock.calls.length).toBe(1);
 
       let calls = build.entry.module.default.mock.calls;
-      expect(calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(calls.length).toBe(1);
       let context = calls[0][3].staticHandlerContext as StaticHandlerContext;
       expect(context.errors).toBeTruthy();
       expect(context.errors!.root).toBeInstanceOf(Error);
@@ -1371,16 +1361,14 @@ describe("shared server runtime", () => {
 
       let result = await handler(request);
       expect(result.status).toBe(500);
-      expect(indexAction.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(indexAction.mock.calls.length).toBe(1);
       // 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 * DATA_CALL_MULTIPIER
-      );
+      expect(build.entry.module.default.mock.calls.length).toBe(1);
 
       let calls = build.entry.module.default.mock.calls;
-      expect(calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(calls.length).toBe(1);
       let context = calls[0][3].staticHandlerContext as StaticHandlerContext;
       expect(context.errors).toBeTruthy();
       expect(context.errors!.root).toBeInstanceOf(Error);
@@ -1423,15 +1411,13 @@ describe("shared server runtime", () => {
 
       let result = await handler(request);
       expect(result.status).toBe(500);
-      expect(testAction.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
-      expect(rootLoader.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(testAction.mock.calls.length).toBe(1);
+      expect(rootLoader.mock.calls.length).toBe(1);
       expect(testLoader.mock.calls.length).toBe(0);
-      expect(build.entry.module.default.mock.calls.length).toBe(
-        1 * DATA_CALL_MULTIPIER
-      );
+      expect(build.entry.module.default.mock.calls.length).toBe(1);
 
       let calls = build.entry.module.default.mock.calls;
-      expect(calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(calls.length).toBe(1);
       let context = calls[0][3].staticHandlerContext as StaticHandlerContext;
       expect(context.errors).toBeTruthy();
       expect(context.errors!["routes/test"]).toBeInstanceOf(Error);
@@ -1476,15 +1462,13 @@ describe("shared server runtime", () => {
 
       let result = await handler(request);
       expect(result.status).toBe(500);
-      expect(indexAction.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
-      expect(rootLoader.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(indexAction.mock.calls.length).toBe(1);
+      expect(rootLoader.mock.calls.length).toBe(1);
       expect(indexLoader.mock.calls.length).toBe(0);
-      expect(build.entry.module.default.mock.calls.length).toBe(
-        1 * DATA_CALL_MULTIPIER
-      );
+      expect(build.entry.module.default.mock.calls.length).toBe(1);
 
       let calls = build.entry.module.default.mock.calls;
-      expect(calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(calls.length).toBe(1);
       let context = calls[0][3].staticHandlerContext as StaticHandlerContext;
       expect(context.errors).toBeTruthy();
       expect(context.errors!["routes/index"]).toBeInstanceOf(Error);
@@ -1537,15 +1521,13 @@ describe("shared server runtime", () => {
 
       let result = await handler(request);
       expect(result.status).toBe(500);
-      expect(testAction.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
-      expect(rootLoader.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(testAction.mock.calls.length).toBe(1);
+      expect(rootLoader.mock.calls.length).toBe(1);
       expect(testLoader.mock.calls.length).toBe(0);
-      expect(build.entry.module.default.mock.calls.length).toBe(
-        1 * DATA_CALL_MULTIPIER
-      );
+      expect(build.entry.module.default.mock.calls.length).toBe(1);
 
       let calls = build.entry.module.default.mock.calls;
-      expect(calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(calls.length).toBe(1);
       let context = calls[0][3].staticHandlerContext as StaticHandlerContext;
       expect(context.errors).toBeTruthy();
       expect(context.errors!["routes/__layout"]).toBeInstanceOf(Error);
@@ -1599,15 +1581,13 @@ describe("shared server runtime", () => {
 
       let result = await handler(request);
       expect(result.status).toBe(500);
-      expect(indexAction.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
-      expect(rootLoader.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(indexAction.mock.calls.length).toBe(1);
+      expect(rootLoader.mock.calls.length).toBe(1);
       expect(indexLoader.mock.calls.length).toBe(0);
-      expect(build.entry.module.default.mock.calls.length).toBe(
-        1 * DATA_CALL_MULTIPIER
-      );
+      expect(build.entry.module.default.mock.calls.length).toBe(1);
 
       let calls = build.entry.module.default.mock.calls;
-      expect(calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(calls.length).toBe(1);
       let context = calls[0][3].staticHandlerContext as StaticHandlerContext;
       expect(context.errors).toBeTruthy();
       expect(context.errors!["routes/__layout"]).toBeInstanceOf(Error);
@@ -1660,7 +1640,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 * DATA_CALL_MULTIPIER);
+      expect(calls.length).toBe(2);
       let context = calls[1][3].staticHandlerContext;
       expect(context.errors.root).toBeTruthy();
       expect(context.errors!.root.message).toBe("thrown");
@@ -1704,7 +1684,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 * DATA_CALL_MULTIPIER);
+      expect(calls.length).toBe(2);
     });
 
     test("returns more detailed message if handleDocumentRequest throws a second time in development mode", async () => {
@@ -1731,11 +1711,12 @@ describe("shared server runtime", () => {
       let lastThrownError;
       build.entry.module.default = jest.fn(function () {
         lastThrownError = new Error(errorMessage);
+        errorMessage = "second error thrown from handleDocumentRequest";
         throw lastThrownError;
       }) as any;
       let handler = createRequestHandler(build, ServerMode.Development);
 
-      let request = new Request(`${baseUrl}/`, { method: "get" });
+      let request = new Request(`${baseUrl}/`);
 
       let result = await handler(request);
       expect(result.status).toBe(500);
@@ -1744,10 +1725,20 @@ describe("shared server runtime", () => {
       expect(indexLoader.mock.calls.length).toBe(0);
 
       let calls = build.entry.module.default.mock.calls;
-      expect(calls.length).toBe(2 * DATA_CALL_MULTIPIER);
-      expect(spy.console.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER);
+      expect(calls.length).toBe(2);
+      expect(spy.console.mock.calls[0][0].data).toEqual(
+        'Error: No route matches URL "/"'
+      );
+      expect(spy.console.mock.calls[1][0].message).toEqual(
+        "thrown from handleDocumentRequest and expected to be logged in console only once"
+      );
+      expect(spy.console.mock.calls[2][0].message).toEqual(
+        "second error thrown from handleDocumentRequest"
+      );
+      expect(spy.console.mock.calls.length).toBe(3);
     });
   });
+
   test("provides load context to server entrypoint", async () => {
     let rootLoader = jest.fn(() => {
       return "root";