Repository: remix-run/remix
Pull request #5116 (2023-01-17 11:23:29)

chore(bug-report): Failing test for issue Redirect with absolute path results in 404s
This is a failing test case for the issue Redirect with absolute path results in 404s. 

Link to bug report issue: https://github.com/remix-run/remix/issues/5117

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

Latest commit: 55a782c50aa5ad5e7edd7919672d5c9ab16e2a93

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

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

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

</details>

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

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/emilbryggare/remix/new/redirect-absolute-urls?filename=.changeset/wet-cars-ring.md&value=---%0A%0A---%0A%0AFailing%20test%20for%20issue%20Redirect%20with%20absolute%20path%20results%20in%20404s%0A)


remix-cla-bot[bot]: Hi @emilbryggare,

Welcome, and thank you for contributing to Remix!

Before we consider your pull request, we ask that you sign our **Contributor License Agreement** (CLA). We require this only once.

You may [review the CLA](https://github.com/remix-run/remix/blob/main/CLA.md) and sign it by [adding your name to contributors.yml](https://github.com/emilbryggare/remix/blob/redirect-absolute-urls/contributors.yml).

Once the CLA is signed, the `CLA Signed` label will be added to the pull request.

If you have already signed the CLA and received this response in error, or if you have any questions, please contact us at [hello@remix.run](mailto:hello@remix.run?subject=CLA%20for%20open%20source%20contributions).

Thanks!

\- The Remix team
remix-cla-bot[bot]: Thank you for signing the Contributor License Agreement. Let's get this merged! 🥳
github-actions[bot]: This PR has been automatically marked stale because we haven't received a response from the original author in a while 🙈. This automation helps keep the issue tracker clean from issues that are not actionable. Please reach out if you have more information for us or you think this issue shouldn't be closed! 🙂 If you don't do so within 7 days, this PR will be automatically closed.
github-actions[bot]: This PR has been automatically closed because we didn't hear anything from the original author after the previous notice.
brophdawg11: This was resolved in 1.11.0 - moved this test into redirects-test for future regression purposes
brophdawg11: Unsure why CI isn't running - going to close and reopen and see if that jump starts it

Patch:
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/contributors.yml b/contributors.yml
index 0b76eb9c3..753feecdc 100644
--- a/contributors.yml
+++ b/contributors.yml
@@ -142,6 +142,7 @@
 - efkann
 - eldarshamukhamedov
 - eltociear
+- emilbryggare
 - emzoumpo
 - eps1lon
 - esamattis
@@ -556,4 +557,5 @@
 - tanerijun
 - toufiqnuur
 - ally1002
+- defjosiah
 - AlemTuzlak
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-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)) {


Test patch:
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/redirects-test.ts b/integration/redirects-test.ts
index afdb7eddb..661f118f9 100644
--- a/integration/redirects-test.ts
+++ b/integration/redirects-test.ts
@@ -42,7 +42,7 @@ test.describe("redirects", () => {
           }
         `,
 
-        [`app/routes/action.form.jsx`]: js`
+        "app/routes/action.form.jsx": js`
           import { redirect } from "@remix-run/node";
           import { Form } from "@remix-run/react";
 
@@ -59,7 +59,7 @@ test.describe("redirects", () => {
           }
         `,
 
-        [`app/routes/action.1.jsx`]: js`
+        "app/routes/action.1.jsx": js`
           import { redirect } from "@remix-run/node";
 
           export async function loader({ request }) {
@@ -67,12 +67,28 @@ test.describe("redirects", () => {
           };
         `,
 
-        [`app/routes/action.2.jsx`]: js`
+        "app/routes/action.2.jsx": js`
           export default function () {
             return <h1>Page 2</h1>
           }
         `,
 
+        "app/routes/action.absolute.jsx": js`
+          import { redirect } from "@remix-run/node";
+          import { Form } from "@remix-run/react";
+
+          export async function action({ request }) {
+            return redirect(new URL(request.url).origin + "/action/1");
+          };
+
+          export default function Login() {
+            return (
+              <Form method="post">
+                <button type="submit">Submit</button>
+              </Form>
+            );
+          }
+        `,
         "app/session.server.js": js`
           import { createCookie } from "@remix-run/node";
           export const session = createCookie("session");
@@ -117,7 +133,7 @@ test.describe("redirects", () => {
           }
         `,
 
-        [`app/routes/loader.redirect.jsx`]: js`
+        "app/routes/loader.redirect.jsx": js`
             import { redirect } from "@remix-run/node";
             import { Form } from "@remix-run/react";
             import { session } from "~/session.server";
@@ -134,7 +150,7 @@ test.describe("redirects", () => {
             };
         `,
 
-        [`app/routes/loader.1.jsx`]: js`
+        "app/routes/loader.1.jsx": js`
           import { redirect } from "@remix-run/node";
 
           export async function loader({ request }) {
@@ -142,12 +158,12 @@ test.describe("redirects", () => {
           };
         `,
 
-        [`app/routes/loader.2.jsx`]: js`
+        "app/routes/loader.2.jsx": js`
           export default function () {
             return <h1>Page 2</h1>
           }
         `,
-        [`app/routes/loader.external.js`]: js`
+        "app/routes/loader.external.js": js`
           import { redirect } from "@remix-run/node";
           export const loader = () => {
             return redirect("https://remix.run/");
@@ -202,4 +218,15 @@ test.describe("redirects", () => {
     await app.waitForNetworkAfter(() => app.goto("/loader/external"));
     expect(app.page.url()).toBe("https://remix.run/");
   });
+
+  test("redirects to absolute URLs in the app", async ({ page }) => {
+    let app = new PlaywrightFixture(appFixture, page);
+    await app.goto(`/action/absolute`);
+    expect(await app.getHtml("#count")).toMatch("1");
+    await app.waitForNetworkAfter(() =>
+      app.clickSubmitButton("/action/absolute")
+    );
+    await page.waitForSelector(`#app:has-text("Page 2")`);
+    await page.waitForSelector(`#count:has-text("3")`);
+  });
 });
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 () => {