Repository: remix-run/remix (ts)

Issue:
Unexpected HTTP errors on _data calls cause blank routes without any caught errors
### What version of Remix are you using?

1.12.0

### Are all your remix dependencies & dev-dependencies using the same version?

- [X] Yes

### Steps to Reproduce

Force a route's _data request to fail with an HTTP error and a response that remix doesn't expect (e.g. some string). This could happen when there is something wrong with e.g. CDN, proxy or load balancer configuration, an error that is generated outside or remix's control. Then make a client side navigation to that route. 
Here's a diff from base create-remix app with express server where I respond with an error from express handler: https://github.com/celebro/remix-http-error-example/commit/3e644df76e4e673a86cbdaecae4e138386ccb682

### Expected Behavior

Remix should trigger ErrorBoundary with some generic error.

### Actual Behavior

Remix renders an empty route, without any indication of an error.

Pull request description:
Properly handle in-flight network errors on _data requests
If we make a `?_data` `fetch` and it never actually reaches the Remix server, and instead we get a 4xx/5xx response back from a CDN/Proxy/Load Balancer, etc., then we currently incorrectly think it's a returned response from a `loader`/`action` since it's perfectly valid to:

```js
export function loader() {
  return json({ isTeapot: true }, { status: 418 });
}
```

This PR adds an `X-Remix-Response: yes` header to successful responses for `?_data` requests so we can differentiate responses that reached the Remix server from those that did not - and we can treat CDN/Proxy errors as real errors and surface them to the `ErrorBoundary`.

Closes #5418 

Patch:
diff --git a/.changeset/chatty-pears-float.md b/.changeset/chatty-pears-float.md
new file mode 100644
index 000000000..a51f222e4
--- /dev/null
+++ b/.changeset/chatty-pears-float.md
@@ -0,0 +1,5 @@
+---
+"@remix-run/react": patch
+---
+
+Narrowed the type of `fetcher.formEncType` to use `FormEncType` from `react-router-dom` instead of `string`
diff --git a/.changeset/handle-network-errors.md b/.changeset/handle-network-errors.md
new file mode 100644
index 000000000..ded0ba07a
--- /dev/null
+++ b/.changeset/handle-network-errors.md
@@ -0,0 +1,6 @@
+---
+"@remix-run/react": patch
+"@remix-run/server-runtime": patch
+---
+
+Properly handle `?_data` HTTP/Network errors that don't reach the Remix server and ensure they bubble to the `ErrorBoundary`
diff --git a/.changeset/mean-months-lick.md b/.changeset/mean-months-lick.md
new file mode 100644
index 000000000..8d3feedeb
--- /dev/null
+++ b/.changeset/mean-months-lick.md
@@ -0,0 +1,5 @@
+---
+"@remix-run/react": patch
+---
+
+fix router race condition for hmr
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
index 3f5bcef3c..013e074ea 100644
--- a/DEVELOPMENT.md
+++ b/DEVELOPMENT.md
@@ -107,7 +107,7 @@ By default, the Remix `rollup` build will strip any `console.debug` calls to avo
 REMIX_DEBUG=true yarn watch
 ```
 
-**`REMIX_LOCAL_BUILD_DIRECTORY`**
+**`LOCAL_BUILD_DIRECTORY`**
 
 When developing Remix locally, you often need to go beyond unit/integration tests and test your changes in a local Remix application. The easiest way to do this is to run your local Remix build and use this environment variable to direct `rollup` to write the output files directly into the local Remix application's `node_modules` folder. Then you just need to restart your local Remix application server to pick up the changes.
 
@@ -118,7 +118,7 @@ cd my-remix-app
 npm run dev
 
 # Tab 2 - remix repository
-REMIX_LOCAL_BUILD_DIRECTORY=../my-remix-app yarn watch
+LOCAL_BUILD_DIRECTORY=../my-remix-app yarn watch
 ```
 
 Now - any time you make changes in the Remix repository, they will be written out to the appropriate locations in `../my-remix-app/node_modules` and you can restart the `npm run dev` command to pick them up 🎉.
diff --git a/contributors.yml b/contributors.yml
index ecb1d1a4c..0af5e3d95 100644
--- a/contributors.yml
+++ b/contributors.yml
@@ -554,4 +554,7 @@
 - amir-ziaei
 - mrkhosravian
 - tanerijun
+- toufiqnuur
 - ally1002
+- defjosiah
+- AlemTuzlak
diff --git a/docs/guides/envvars.md b/docs/guides/envvars.md
index 52bd7344a..0e80c5982 100644
--- a/docs/guides/envvars.md
+++ b/docs/guides/envvars.md
@@ -48,11 +48,24 @@ export async function loader() {
 
 If you're using the `@remix-run/cloudflare-pages` adapter, env variables work a little differently. Since Cloudflare Pages are powered by Functions, you'll need to define your local environment variables in the [`.dev.vars`][dev-vars] file. It has the same syntax as `.env` example file mentioned above.
 
-Then, in your `loader` functions, you can access environment variables directly on `context`:
+Then, you can pass those through via `getLoadContext` in your server file:
+
+```ts
+export const onRequest = createPagesFunctionHandler({
+  build,
+  getLoadContext(context) {
+    // Hand-off Cloudflare ENV vars to the Remix `context` object
+    return { env: context.env };
+  },
+  mode: process.env.NODE_ENV,
+});
+```
+
+And they'll be available via Remix's `context` in your `loader`/`action` functions:
 
 ```tsx
 export const loader = async ({ context }: LoaderArgs) => {
-  console.log(context.SOME_SECRET);
+  console.log(context.env.SOME_SECRET);
 };
 ```
 
diff --git a/integration/helpers/playwright-fixture.ts b/integration/helpers/playwright-fixture.ts
index c5adb1511..9cf6a58b6 100644
--- a/integration/helpers/playwright-fixture.ts
+++ b/integration/helpers/playwright-fixture.ts
@@ -139,6 +139,17 @@ export class PlaywrightFixture {
     }
   }
 
+  /**
+   * "Clicks" the refresh button.
+   */
+  async reload(options: { wait: boolean } = { wait: true }) {
+    if (options.wait) {
+      await doAndWait(this.page, () => this.page.reload());
+    } else {
+      await this.page.reload();
+    }
+  }
+
   /**
    * Collects data responses from the network, usually after a link click or
    * form submission. This is useful for asserting that specific loaders
diff --git a/packages/remix-react/browser.tsx b/packages/remix-react/browser.tsx
index 3bad7c708..a18bb8850 100644
--- a/packages/remix-react/browser.tsx
+++ b/packages/remix-react/browser.tsx
@@ -48,6 +48,19 @@ declare global {
 
 let router: Router;
 let hmrAbortController: AbortController | undefined;
+let hmrRouterReadyResolve: ((router: Router) => void) | undefined;
+// There's a race condition with HMR where the remix:manifest is signaled before
+// the router is assigned in the RemixBrowser component. This promise gates the
+// HMR handler until the router is ready
+let hmrRouterReadyPromise = new Promise<Router>((resolve) => {
+  // body of a promise is executed immediately, so this can be resolved outside
+  // of the promise body
+  hmrRouterReadyResolve = resolve;
+}).catch(() => {
+  // This is a noop catch handler to avoid unhandled promise rejection warnings
+  // in the console. The promise is never rejected.
+  return undefined;
+});
 
 if (import.meta && import.meta.hot) {
   import.meta.hot.accept(
@@ -59,6 +72,15 @@ if (import.meta && import.meta.hot) {
       assetsManifest: EntryContext["manifest"];
       needsRevalidation: Set<string>;
     }) => {
+      let router = await hmrRouterReadyPromise;
+      // This should never happen, but just in case...
+      if (!router) {
+        console.error(
+          "Failed to accept HMR update because the router was not ready."
+        );
+        return;
+      }
+
       let routeIds = [
         ...new Set(
           router.state.matches
@@ -197,6 +219,11 @@ export function RemixBrowser(_props: RemixBrowserProps): ReactElement {
       console.error(errorMsg);
       window.location.reload();
     }
+
+    // Notify that the router is ready for HMR
+    if (hmrRouterReadyResolve) {
+      hmrRouterReadyResolve(router);
+    }
   }
 
   let [location, setLocation] = React.useState(router.state.location);
diff --git a/packages/remix-react/data.ts b/packages/remix-react/data.ts
index c2fde12f6..f8e0d14b5 100644
--- a/packages/remix-react/data.ts
+++ b/packages/remix-react/data.ts
@@ -18,6 +18,25 @@ export function isErrorResponse(response: any): response is Response {
   return response.headers.get("X-Remix-Error") != null;
 }
 
+export function isNetworkErrorResponse(response: any): response is Response {
+  // If we reach the Remix server, we can safely identify response types via the
+  // X-Remix-Error/X-Remix-Catch headers.  However, if we never reach the Remix
+  // server, and instead receive a 4xx/5xx from somewhere in between (like
+  // Cloudflare), then we get a false negative n the isErrorResponse check and
+  // we incorrectly assume that the user returns the 4xx/5xx response and
+  // consider it successful.  To alleviate this, we add X-Remix-Response to any
+  // non-Error/non-Catch responses coming back from the server.  If we don't
+  // see this, we can conclude that a 4xx/5xx response never actually reached
+  // the Remix server and we can bubble it up as an error.
+  return (
+    isResponse(response) &&
+    response.status >= 400 &&
+    response.headers.get("X-Remix-Error") == null &&
+    response.headers.get("X-Remix-Catch") == null &&
+    response.headers.get("X-Remix-Response") == null
+  );
+}
+
 export function isRedirectResponse(response: Response): boolean {
   return response.headers.get("X-Remix-Redirect") != null;
 }
@@ -26,6 +45,16 @@ export function isDeferredResponse(response: Response): boolean {
   return !!response.headers.get("Content-Type")?.match(/text\/remix-deferred/);
 }
 
+function isResponse(value: any): value is Response {
+  return (
+    value != null &&
+    typeof value.status === "number" &&
+    typeof value.statusText === "string" &&
+    typeof value.headers === "object" &&
+    typeof value.body !== "undefined"
+  );
+}
+
 export async function fetchData(
   request: Request,
   routeId: string,
@@ -85,6 +114,13 @@ export async function fetchData(
     return error;
   }
 
+  if (isNetworkErrorResponse(response)) {
+    let text = await response.text();
+    let error = new Error(text);
+    error.stack = undefined;
+    return error;
+  }
+
   return response;
 }
 
diff --git a/packages/remix-react/transition.ts b/packages/remix-react/transition.ts
index ff9372575..ae12b614c 100644
--- a/packages/remix-react/transition.ts
+++ b/packages/remix-react/transition.ts
@@ -1,4 +1,4 @@
-import type { Location } from "react-router-dom";
+import type { Location, FormEncType } from "react-router-dom";
 
 export interface Submission {
   action: string;
@@ -137,7 +137,7 @@ export type FetcherStates<TData = any> = {
     type: "actionSubmission";
     formMethod: FetcherActionSubmission["method"];
     formAction: string;
-    formEncType: string;
+    formEncType: FormEncType;
     submission: FetcherActionSubmission;
     data: TData | undefined;
   } & FetcherSubmissionDataTypes;
@@ -146,7 +146,7 @@ export type FetcherStates<TData = any> = {
     type: "loaderSubmission";
     formMethod: FetcherLoaderSubmission["method"];
     formAction: string;
-    formEncType: string;
+    formEncType: FormEncType;
     submission: FetcherLoaderSubmission;
     data: TData | undefined;
   } & FetcherSubmissionDataTypes;
@@ -155,7 +155,7 @@ export type FetcherStates<TData = any> = {
     type: "actionReload";
     formMethod: FetcherActionSubmission["method"];
     formAction: string;
-    formEncType: string;
+    formEncType: FormEncType;
     submission: FetcherActionSubmission;
     data: TData;
   } & FetcherSubmissionDataTypes;
@@ -164,7 +164,7 @@ export type FetcherStates<TData = any> = {
     type: "actionRedirect";
     formMethod: FetcherActionSubmission["method"];
     formAction: string;
-    formEncType: string;
+    formEncType: FormEncType;
     submission: FetcherActionSubmission;
     data: undefined;
   } & FetcherSubmissionDataTypes;
diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts
index eb30cb36f..81dfd59c0 100644
--- a/packages/remix-server-runtime/server.ts
+++ b/packages/remix-server-runtime/server.ts
@@ -166,10 +166,16 @@ async function handleDataRequestRR(
       let init = deferredData.init || {};
       let headers = new Headers(init.headers);
       headers.set("Content-Type", "text/remix-deferred");
+      // Mark successful responses with a header so we can identify in-flight
+      // network errors that are missing this header
+      headers.set("X-Remix-Response", "yes");
       init.headers = headers;
       return new Response(body, init);
     }
 
+    // Mark all successful responses with a header so we can identify in-flight
+    // network errors that are missing this header
+    response.headers.set("X-Remix-Response", "yes");
     return response;
   } catch (error: unknown) {
     if (isResponse(error)) {
diff --git a/packages/remix/rollup.config.js b/packages/remix/rollup.config.js
index 1948486d0..42c878681 100644
--- a/packages/remix/rollup.config.js
+++ b/packages/remix/rollup.config.js
@@ -13,7 +13,7 @@ let { name: packageName, version } = require("./package.json");
 module.exports = function rollup() {
   // Don't blow away remix magic exports on local builds, since they've
   // already been configured by postinstall
-  if (process.env.REMIX_LOCAL_BUILD_DIRECTORY) {
+  if (process.env.LOCAL_BUILD_DIRECTORY) {
     return [];
   }
 
diff --git a/rollup.utils.js b/rollup.utils.js
index 3da8600e8..6f36b9677 100644
--- a/rollup.utils.js
+++ b/rollup.utils.js
@@ -9,16 +9,13 @@ const path = require("path");
 const REPO_ROOT_DIR = __dirname;
 
 let activeOutputDir = "build";
-if (process.env.REMIX_LOCAL_BUILD_DIRECTORY) {
-  let appDir = path.join(
-    process.cwd(),
-    process.env.REMIX_LOCAL_BUILD_DIRECTORY
-  );
+if (process.env.LOCAL_BUILD_DIRECTORY) {
+  let appDir = path.resolve(process.env.LOCAL_BUILD_DIRECTORY);
   try {
     fse.readdirSync(path.join(appDir, "node_modules"));
   } catch {
     console.error(
-      "Oops! You pointed REMIX_LOCAL_BUILD_DIRECTORY to a directory that " +
+      "Oops! You pointed LOCAL_BUILD_DIRECTORY to a directory that " +
         "does not have a node_modules/ folder. Please `npm install` in that " +
         "directory and try again."
     );
@@ -124,7 +121,7 @@ function copyToPlaygrounds() {
           await triggerLiveReload(playgroundDir);
         }
       } else {
-        // Otherwise, trigger live reload on our REMIX_LOCAL_BUILD_DIRECTORY folder
+        // Otherwise, trigger live reload on our LOCAL_BUILD_DIRECTORY folder
         await triggerLiveReload(activeOutputDir);
       }
     },
diff --git a/scripts/copy-build-to-dist.mjs b/scripts/copy-build-to-dist.mjs
index 4b9e4cb27..c9602b6f3 100644
--- a/scripts/copy-build-to-dist.mjs
+++ b/scripts/copy-build-to-dist.mjs
@@ -7,13 +7,13 @@ const PACKAGES_PATH = path.join(ROOT_DIR, "packages");
 const DEFAULT_BUILD_PATH = path.join(ROOT_DIR, "build");
 
 let activeOutputDir = DEFAULT_BUILD_PATH;
-if (process.env.REMIX_LOCAL_BUILD_DIRECTORY) {
-  let appDir = path.join(ROOT_DIR, process.env.REMIX_LOCAL_BUILD_DIRECTORY);
+if (process.env.LOCAL_BUILD_DIRECTORY) {
+  let appDir = path.resolve(process.env.LOCAL_BUILD_DIRECTORY);
   try {
     fse.readdirSync(path.join(appDir, "node_modules"));
   } catch {
     console.error(
-      "Oops! You pointed `REMIX_LOCAL_BUILD_DIRECTORY` to a directory that " +
+      "Oops! You pointed `LOCAL_BUILD_DIRECTORY` to a directory that " +
         "does not have a `node_modules` folder. Please `npm install` in that " +
         "directory and try again."
     );


Test patch:
diff --git a/integration/abort-signal-test.ts b/integration/abort-signal-test.ts
index db047de7a..df4b5c38b 100644
--- a/integration/abort-signal-test.ts
+++ b/integration/abort-signal-test.ts
@@ -44,7 +44,7 @@ test.beforeAll(async () => {
     },
   });
 
-  // This creates an interactive app using puppeteer.
+  // This creates an interactive app using playwright.
   appFixture = await createAppFixture(fixture);
 });
 
diff --git a/integration/bug-report-test.ts b/integration/bug-report-test.ts
index ec2fa93a3..0d6fe2c89 100644
--- a/integration/bug-report-test.ts
+++ b/integration/bug-report-test.ts
@@ -84,7 +84,7 @@ test.beforeAll(async () => {
     },
   });
 
-  // This creates an interactive app using puppeteer.
+  // This creates an interactive app using playwright.
   appFixture = await createAppFixture(fixture);
 });
 
diff --git a/integration/defer-test.ts b/integration/defer-test.ts
index 267ffba5b..8fa01a49e 100644
--- a/integration/defer-test.ts
+++ b/integration/defer-test.ts
@@ -561,7 +561,7 @@ test.describe("non-aborted", () => {
       },
     });
 
-    // This creates an interactive app using puppeteer.
+    // This creates an interactive app using playwright.
     appFixture = await createAppFixture(fixture);
   });
 
@@ -1244,7 +1244,7 @@ test.describe("aborted", () => {
       },
     });
 
-    // This creates an interactive app using puppeteer.
+    // This creates an interactive app using playwright.
     appFixture = await createAppFixture(fixture);
   });
 
diff --git a/integration/error-boundary-test.ts b/integration/error-boundary-test.ts
index 1f202145e..ae08c21d1 100644
--- a/integration/error-boundary-test.ts
+++ b/integration/error-boundary-test.ts
@@ -1250,6 +1250,118 @@ test.describe("Default ErrorBoundary", () => {
   });
 });
 
+test("Allows back-button out of an error boundary after a hard reload", async ({
+  page,
+  browserName,
+}) => {
+  let _consoleError = console.error;
+  console.error = () => {};
+
+  let fixture = await createFixture({
+    config: {
+      future: {
+        v2_routeConvention: true,
+        v2_errorBoundary: false,
+      },
+    },
+    files: {
+      "app/root.jsx": js`
+        import { Links, Meta, Outlet, Scripts } from "@remix-run/react";
+
+        export default function App() {
+          return (
+            <html lang="en">
+              <head>
+                <Meta />
+                <Links />
+              </head>
+              <body>
+                <Outlet />
+                <Scripts />
+              </body>
+            </html>
+          );
+        }
+
+        export function ErrorBoundary({ error }) {
+          return (
+            <html>
+              <head>
+                <title>Oh no!</title>
+                <Meta />
+                <Links />
+              </head>
+              <body>
+                <h1 id="error">ERROR BOUNDARY</h1>
+                <Scripts />
+              </body>
+            </html>
+          );
+        }
+      `,
+      "app/routes/_index.jsx": js`
+        import { Link } from "@remix-run/react";
+
+        export default function Index() {
+          return (
+            <div>
+              <h1 id="index">INDEX</h1>
+              <Link to="/boom">This will error</Link>
+            </div>
+          );
+        }
+      `,
+
+      "app/routes/boom.jsx": js`
+        import { json } from "@remix-run/node";
+        export function loader() { return boom(); }
+        export default function() { return <b>my page</b>; }
+      `,
+    },
+  });
+
+  let appFixture = await createAppFixture(fixture);
+  let app = new PlaywrightFixture(appFixture, page);
+
+  await app.goto("/");
+  await page.waitForSelector("#index");
+  expect(app.page.url()).not.toMatch("/boom");
+
+  await app.clickLink("/boom");
+  await page.waitForSelector("#error");
+  expect(app.page.url()).toMatch("/boom");
+
+  await app.reload();
+  await page.waitForSelector("#error");
+  expect(app.page.url()).toMatch("boom");
+
+  await app.goBack();
+
+  // Here be dragons
+  // - Playwright sets the Firefox `fission.webContentIsolationStrategy=0` preference
+  //   for reasons having to do with out-of-process iframes:
+  //   https://github.com/microsoft/playwright/issues/22640#issuecomment-1543287282
+  // - That preference exposes a bug in firefox where a hard reload adds to the
+  //   history stack: https://bugzilla.mozilla.org/show_bug.cgi?id=1832341
+  // - Your can disable this preference via the Playwright `firefoxUserPrefs` config,
+  //   but that is broken until 1.34:
+  //   https://github.com/microsoft/playwright/issues/22640#issuecomment-1546230104
+  //   https://github.com/microsoft/playwright/issues/15405
+  // - We can't yet upgrade to 1.34 because it drops support for Node 14:
+  //   https://github.com/microsoft/playwright/releases/tag/v1.34.0
+  //
+  // So for now when in firefox we just navigate back twice to work around the issue
+  if (browserName === "firefox") {
+    await app.goBack();
+  }
+
+  await page.waitForSelector("#index");
+  expect(app.page.url()).not.toContain("boom");
+
+  appFixture.close();
+  console.error = _consoleError;
+});
+
 // Copy/Paste of the above tests altered to use v2_errorBoundary.  In v2 we can:
 // - delete the above tests
 // - remove this describe block
@@ -2518,4 +2630,117 @@ test.describe("v2_errorBoundary", () => {
       });
     });
   });
+
+  test("Allows back-button out of an error boundary after a hard reload", async ({
+    page,
+    browserName,
+  }) => {
+    let _consoleError = console.error;
+    console.error = () => {};
+
+    let fixture = await createFixture({
+      config: {
+        future: {
+          v2_routeConvention: true,
+          v2_errorBoundary: true,
+        },
+      },
+      files: {
+        "app/root.jsx": js`
+          import { Links, Meta, Outlet, Scripts, useRouteError } from "@remix-run/react";
+
+          export default function App() {
+            return (
+              <html lang="en">
+                <head>
+                  <Meta />
+                  <Links />
+                </head>
+                <body>
+                  <Outlet />
+                  <Scripts />
+                </body>
+              </html>
+            );
+          }
+
+          export function ErrorBoundary() {
+            let error = useRouteError();
+            return (
+              <html>
+                <head>
+                  <title>Oh no!</title>
+                  <Meta />
+                  <Links />
+                </head>
+                <body>
+                  <h1 id="error">ERROR BOUNDARY</h1>
+                  <Scripts />
+                </body>
+              </html>
+            );
+          }
+        `,
+        "app/routes/_index.jsx": js`
+          import { Link } from "@remix-run/react";
+
+          export default function Index() {
+            return (
+              <div>
+                <h1 id="index">INDEX</h1>
+                <Link to="/boom">This will error</Link>
+              </div>
+            );
+          }
+        `,
+
+        "app/routes/boom.jsx": js`
+          import { json } from "@remix-run/node";
+          export function loader() { return boom(); }
+          export default function() { return <b>my page</b>; }
+        `,
+      },
+    });
+
+    let appFixture = await createAppFixture(fixture);
+    let app = new PlaywrightFixture(appFixture, page);
+
+    await app.goto("/");
+    await page.waitForSelector("#index");
+    expect(app.page.url()).not.toMatch("/boom");
+
+    await app.clickLink("/boom");
+    await page.waitForSelector("#error");
+    expect(app.page.url()).toMatch("/boom");
+
+    await app.reload();
+    await page.waitForSelector("#error");
+    expect(app.page.url()).toMatch("boom");
+
+    await app.goBack();
+
+    // Here be dragons
+    // - Playwright sets the Firefox `fission.webContentIsolationStrategy=0` preference
+    //   for reasons having to do with out-of-process iframes:
+    //   https://github.com/microsoft/playwright/issues/22640#issuecomment-1543287282
+    // - That preference exposes a bug in firefox where a hard reload adds to the
+    //   history stack: https://bugzilla.mozilla.org/show_bug.cgi?id=1832341
+    // - Your can disable this preference via the Playwright `firefoxUserPrefs` config,
+    //   but that is broken until 1.34:
+    //   https://github.com/microsoft/playwright/issues/22640#issuecomment-1546230104
+    //   https://github.com/microsoft/playwright/issues/15405
+    // - We can't yet upgrade to 1.34 because it drops support for Node 14:
+    //   https://github.com/microsoft/playwright/releases/tag/v1.34.0
+    //
+    // So for now when in firefox we just navigate back twice to work around the issue
+    if (browserName === "firefox") {
+      await app.goBack();
+    }
+
+    await page.waitForSelector("#index");
+    expect(app.page.url()).not.toContain("boom");
+
+    appFixture.close();
+    console.error = _consoleError;
+  });
 });
diff --git a/integration/error-boundary-v2-test.ts b/integration/error-boundary-v2-test.ts
index 9afb95275..9e972418a 100644
--- a/integration/error-boundary-v2-test.ts
+++ b/integration/error-boundary-v2-test.ts
@@ -164,20 +164,24 @@ test.describe("V2 Singular ErrorBoundary (future.v2_errorBoundary)", () => {
   test.describe("with JavaScript", () => {
     test.use({ javaScriptEnabled: true });
     runBoundaryTests();
+
+    test("Network errors that never reach the Remix server", async ({
+      page,
+    }) => {
+      // Cause a ?_data request to trigger an HTTP error that never reaches the
+      // Remix server, and ensure we properly handle it at the ErrorBoundary
+      await page.route(
+        "**/parent/child-with-boundary?_data=routes%2Fparent.child-with-boundary",
+        (route) => route.fulfill({ status: 500, body: "CDN Error!" })
+      );
+      let app = new PlaywrightFixture(appFixture, page);
+      await app.goto("/parent");
+      await app.clickLink("/parent/child-with-boundary");
+      await waitForAndAssert(page, app, "#child-error", "CDN Error!");
+    });
   });
 
   function runBoundaryTests() {
-    // Shorthand util to wait for an element to appear before asserting it
-    async function waitForAndAssert(
-      page: Page,
-      app: PlaywrightFixture,
-      selector: string,
-      match: string
-    ) {
-      await page.waitForSelector(selector);
-      expect(await app.getHtml(selector)).toMatch(match);
-    }
-
     test("No errors", async ({ page }) => {
       let app = new PlaywrightFixture(appFixture, page);
       await app.goto("/parent");
@@ -238,3 +242,14 @@ test.describe("V2 Singular ErrorBoundary (future.v2_errorBoundary)", () => {
     });
   }
 });
+
+// Shorthand util to wait for an element to appear before asserting it
+async function waitForAndAssert(
+  page: Page,
+  app: PlaywrightFixture,
+  selector: string,
+  match: string
+) {
+  await page.waitForSelector(selector);
+  expect(await app.getHtml(selector)).toMatch(match);
+}
diff --git a/integration/scroll-test.ts b/integration/scroll-test.ts
index 70d1a4eec..f4c77b25a 100644
--- a/integration/scroll-test.ts
+++ b/integration/scroll-test.ts
@@ -46,7 +46,7 @@ test.beforeAll(async () => {
     },
   });
 
-  // This creates an interactive app using puppeteer.
+  // This creates an interactive app using playwright.
   appFixture = await createAppFixture(fixture);
 });
 
diff --git a/integration/set-cookie-revalidation-test.ts b/integration/set-cookie-revalidation-test.ts
index 017bcb6a3..8a87d0ede 100644
--- a/integration/set-cookie-revalidation-test.ts
+++ b/integration/set-cookie-revalidation-test.ts
@@ -114,7 +114,7 @@ test.beforeAll(async () => {
     },
   });
 
-  // This creates an interactive app using puppeteer.
+  // This creates an interactive app using playwright.
   appFixture = await createAppFixture(fixture);
 });
 
diff --git a/packages/remix-architect/__tests__/server-test.ts b/packages/remix-architect/__tests__/server-test.ts
index e0e8643b3..38348e559 100644
--- a/packages/remix-architect/__tests__/server-test.ts
+++ b/packages/remix-architect/__tests__/server-test.ts
@@ -15,7 +15,7 @@ import {
 } from "../server";
 
 // We don't want to test that the remix server works here (that's what the
-// puppetteer tests do), we just want to test the architect adapter
+// playwright tests do), we just want to test the architect adapter
 jest.mock("@remix-run/node", () => {
   let original = jest.requireActual("@remix-run/node");
   return {
diff --git a/packages/remix-express/__tests__/server-test.ts b/packages/remix-express/__tests__/server-test.ts
index 964c89a25..2f50cd5e3 100644
--- a/packages/remix-express/__tests__/server-test.ts
+++ b/packages/remix-express/__tests__/server-test.ts
@@ -14,7 +14,7 @@ import {
 } from "../server";
 
 // We don't want to test that the remix server works here (that's what the
-// puppetteer tests do), we just want to test the express adapter
+// playwright tests do), we just want to test the express adapter
 jest.mock("@remix-run/node", () => {
   let original = jest.requireActual("@remix-run/node");
   return {
diff --git a/packages/remix-netlify/__tests__/server-test.ts b/packages/remix-netlify/__tests__/server-test.ts
index 0b643a814..1236e1181 100644
--- a/packages/remix-netlify/__tests__/server-test.ts
+++ b/packages/remix-netlify/__tests__/server-test.ts
@@ -15,7 +15,7 @@ import {
 } from "../server";
 
 // We don't want to test that the remix server works here (that's what the
-// puppetteer tests do), we just want to test the netlify adapter
+// playwright tests do), we just want to test the netlify adapter
 jest.mock("@remix-run/node", () => {
   let original = jest.requireActual("@remix-run/node");
   return {
diff --git a/packages/remix-server-runtime/__tests__/data-test.ts b/packages/remix-server-runtime/__tests__/data-test.ts
index 5b64f3c71..01eabf178 100644
--- a/packages/remix-server-runtime/__tests__/data-test.ts
+++ b/packages/remix-server-runtime/__tests__/data-test.ts
@@ -1,4 +1,5 @@
 import type { ServerBuild } from "../build";
+import { defer } from "../responses";
 import { createRequestHandler } from "../server";
 
 describe("loaders", () => {
@@ -39,7 +40,133 @@ describe("loaders", () => {
     expect(await res.json()).toMatchInlineSnapshot(`"?foo=bar"`);
   });
 
-  it("sets header for throw responses", async () => {
+  it("sets X-Remix-Response header for returned 2xx response", async () => {
+    let routeId = "routes/random";
+    let build = {
+      routes: {
+        [routeId]: {
+          id: routeId,
+          path: "/random",
+          module: {
+            async loader() {
+              return new Response("text", {
+                status: 200,
+                headers: { "Content-Type": "text/plain" },
+              });
+            },
+          },
+        },
+      },
+      entry: {
+        module: {
+          handleError() {},
+        },
+      },
+      future: {},
+    } as unknown as ServerBuild;
+
+    let handler = createRequestHandler(build);
+
+    let request = new Request(
+      "http://example.com/random?_data=routes/random&foo=bar",
+      {
+        headers: {
+          "Content-Type": "application/json",
+        },
+      }
+    );
+
+    let res = await handler(request);
+    expect(res.headers.get("X-Remix-Response")).toBeTruthy();
+    expect(res.headers.get("X-Remix-Error")).toBeNull();
+    expect(res.headers.get("X-Remix-Catch")).toBeNull();
+    expect(res.headers.get("X-Remix-Redirect")).toBeNull();
+  });
+
+  it("sets X-Remix-Response header for returned 2xx defer response", async () => {
+    let routeId = "routes/random";
+    let build = {
+      routes: {
+        [routeId]: {
+          id: routeId,
+          path: "/random",
+          module: {
+            async loader() {
+              return defer({ lazy: Promise.resolve("hey!") });
+            },
+          },
+        },
+      },
+      entry: {
+        module: {
+          handleError() {},
+        },
+      },
+      future: {},
+    } as unknown as ServerBuild;
+
+    let handler = createRequestHandler(build);
+
+    let request = new Request(
+      "http://example.com/random?_data=routes/random&foo=bar",
+      {
+        headers: {
+          "Content-Type": "application/json",
+        },
+      }
+    );
+
+    let res = await handler(request);
+    expect(res.headers.get("X-Remix-Response")).toBeTruthy();
+    expect(res.headers.get("X-Remix-Error")).toBeNull();
+    expect(res.headers.get("X-Remix-Catch")).toBeNull();
+    expect(res.headers.get("X-Remix-Redirect")).toBeNull();
+  });
+
+  it("sets X-Remix-Redirect header for returned 3xx redirect", async () => {
+    let routeId = "routes/random";
+    let build = {
+      routes: {
+        [routeId]: {
+          id: routeId,
+          path: "/random",
+          module: {
+            async loader() {
+              return new Response("text", {
+                status: 302,
+                headers: { Location: "https://remix.run" },
+              });
+            },
+          },
+        },
+      },
+      entry: {
+        module: {
+          handleError() {},
+        },
+      },
+      future: {},
+    } as unknown as ServerBuild;
+
+    let handler = createRequestHandler(build);
+
+    let request = new Request(
+      "http://example.com/random?_data=routes/random&foo=bar",
+      {
+        headers: {
+          "Content-Type": "application/json",
+        },
+      }
+    );
+
+    let res = await handler(request);
+    expect(res.headers.get("X-Remix-Redirect")).toBeTruthy();
+    expect(res.headers.get("X-Remix-Error")).toBeNull();
+    expect(res.headers.get("X-Remix-Catch")).toBeNull();
+    expect(res.headers.get("X-Remix-Response")).toBeNull();
+  });
+
+  it("sets X-Remix-Catch header for throw responses", async () => {
     let loader = async ({ request }) => {
       throw new Response("null", {
         headers: {
@@ -75,7 +202,50 @@ describe("loaders", () => {
     );
 
     let res = await handler(request);
-    expect(await res.headers.get("X-Remix-Catch")).toBeTruthy();
+    expect(res.headers.get("X-Remix-Catch")).toBeTruthy();
+    expect(res.headers.get("X-Remix-Error")).toBeNull();
+    expect(res.headers.get("X-Remix-Redirect")).toBeNull();
+    expect(res.headers.get("X-Remix-Response")).toBeNull();
+  });
+
+  it("sets X-Remix-Error header for throw error", async () => {
+    let routeId = "routes/random";
+    let build = {
+      routes: {
+        [routeId]: {
+          id: routeId,
+          path: "/random",
+          module: {
+            async loader() {
+              throw new Error("broke!");
+            },
+          },
+        },
+      },
+      entry: {
+        module: {
+          handleError() {},
+        },
+      },
+      future: {},
+    } as unknown as ServerBuild;
+
+    let handler = createRequestHandler(build);
+
+    let request = new Request(
+      "http://example.com/random?_data=routes/random&foo=bar",
+      {
+        headers: {
+          "Content-Type": "application/json",
+        },
+      }
+    );
+
+    let res = await handler(request);
+    expect(res.headers.get("X-Remix-Error")).toBeTruthy();
+    expect(res.headers.get("X-Remix-Catch")).toBeNull();
+    expect(res.headers.get("X-Remix-Redirect")).toBeNull();
+    expect(res.headers.get("X-Remix-Response")).toBeNull();
   });
 
   it("removes index from request.url", async () => {
diff --git a/packages/remix-vercel/__tests__/server-test.ts b/packages/remix-vercel/__tests__/server-test.ts
index c9191d268..852428d39 100644
--- a/packages/remix-vercel/__tests__/server-test.ts
+++ b/packages/remix-vercel/__tests__/server-test.ts
@@ -15,7 +15,7 @@ import {
 } from "../server";
 
 // We don't want to test that the remix server works here (that's what the
-// Puppeteer tests do), we just want to test the Vercel adapter
+// playwright tests do), we just want to test the Vercel adapter
 jest.mock("@remix-run/node", () => {
   let original = jest.requireActual("@remix-run/node");
   return {