diff --git a/.changeset/hydrate-error-type.md b/.changeset/hydrate-error-type.md new file mode 100644 index 00000000000..2dd2052a75c --- /dev/null +++ b/.changeset/hydrate-error-type.md @@ -0,0 +1,6 @@ +--- +"@remix-run/react": patch +"@remix-run/server-runtime": patch +--- + +Support proper hydration of `Error` subclasses such as `ReferenceError`/`TypeError` in development mode diff --git a/integration/error-sanitization-test.ts b/integration/error-sanitization-test.ts index 34afa0f8a45..a575557469b 100644 --- a/integration/error-sanitization-test.ts +++ b/integration/error-sanitization-test.ts @@ -1,8 +1,9 @@ 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"; +import { createAppFixture, createFixture, js } from "./helpers/create-fixture"; +import { PlaywrightFixture } from "./helpers/playwright-fixture"; const routeFiles = { "app/root.jsx": js` @@ -33,6 +34,10 @@ const routeFiles = { if (new URL(request.url).searchParams.has('loader')) { throw new Error("Loader Error"); } + if (new URL(request.url).searchParams.has('subclass')) { + // This will throw a ReferenceError + console.log(thisisnotathing); + } return "LOADER" } @@ -58,6 +63,7 @@ const routeFiles = { <>

Index Error

{"MESSAGE:" + error.message}

+

{"NAME:" + error.name}

{error.stack ?

{"STACK:" + error.stack}

: null} ); @@ -279,6 +285,21 @@ test.describe("Error Sanitization", () => { ); expect(errorLogs[0][0].stack).toMatch(" at "); }); + + test("does not support hydration of Error subclasses", async ({ page }) => { + let response = await fixture.requestDocument("/?subclass"); + let html = await response.text(); + expect(html).toMatch("

MESSAGE:Unexpected Server Error"); + expect(html).toMatch("

NAME:Error"); + + // Hydration + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/?subclass", true); + html = await app.getHtml(); + expect(html).toMatch("

MESSAGE:Unexpected Server Error"); + expect(html).toMatch("

NAME:Error"); + }); }); test.describe("serverMode=development", () => { @@ -428,6 +449,27 @@ test.describe("Error Sanitization", () => { ); expect(errorLogs[0][0].stack).toMatch(" at "); }); + + test("supports hydration of Error subclasses", async ({ page }) => { + let response = await fixture.requestDocument("/?subclass"); + let html = await response.text(); + expect(html).toMatch("

MESSAGE:thisisnotathing is not defined"); + expect(html).toMatch("

NAME:ReferenceError"); + expect(html).toMatch( + "

STACK:ReferenceError: thisisnotathing is not defined" + ); + + // Hydration + let appFixture = await createAppFixture(fixture, ServerMode.Development); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/?subclass", true); + html = await app.getHtml(); + expect(html).toMatch("

MESSAGE:thisisnotathing is not defined"); + expect(html).toMatch("

NAME:ReferenceError"); + expect(html).toMatch( + "STACK:ReferenceError: thisisnotathing is not defined" + ); + }); }); test.describe("serverMode=production (user-provided handleError)", () => { diff --git a/packages/remix-react/errors.ts b/packages/remix-react/errors.ts index d8605ccbd4a..80bee97af66 100644 --- a/packages/remix-react/errors.ts +++ b/packages/remix-react/errors.ts @@ -33,9 +33,26 @@ export function deserializeErrors( val.internal === true ); } else if (val && val.__type === "Error") { - let error = new Error(val.message); - error.stack = val.stack; - serialized[key] = error; + // Attempt to reconstruct the right type of Error (i.e., ReferenceError) + if (val.__subType) { + let ErrorConstructor = window[val.__subType]; + if (typeof ErrorConstructor === "function") { + try { + // @ts-expect-error + let error = new ErrorConstructor(val.message); + error.stack = val.stack; + serialized[key] = error; + } catch (e) { + // no-op - fall through and create a normal Error + } + } + } + + if (serialized[key] == null) { + let error = new Error(val.message); + error.stack = val.stack; + serialized[key] = error; + } } else { serialized[key] = val; } diff --git a/packages/remix-server-runtime/errors.ts b/packages/remix-server-runtime/errors.ts index 1422f8c5e74..95fb143c499 100644 --- a/packages/remix-server-runtime/errors.ts +++ b/packages/remix-server-runtime/errors.ts @@ -109,6 +109,15 @@ export function serializeErrors( message: sanitized.message, stack: sanitized.stack, __type: "Error", + // If this is a subclass (i.e., ReferenceError), send up the type so we + // can re-create the same type during hydration. This will only apply + // in dev mode since all production errors are sanitized to normal + // Error instances + ...(sanitized.name !== "Error" + ? { + __subType: sanitized.name, + } + : {}), }; } else { serialized[key] = val;