diff --git a/.changeset/mighty-experts-agree.md b/.changeset/mighty-experts-agree.md new file mode 100644 index 00000000000..707af186750 --- /dev/null +++ b/.changeset/mighty-experts-agree.md @@ -0,0 +1,5 @@ +--- +"@remix-run/react": patch +--- + +Avoid hydration loops when `Layout` `ErrorBoundary` renders also throw diff --git a/integration/root-route-test.ts b/integration/root-route-test.ts index b72ee98b1e6..903cdedf6a7 100644 --- a/integration/root-route-test.ts +++ b/integration/root-route-test.ts @@ -153,4 +153,255 @@ test.describe("root route", () => { console.error = oldConsoleError; }); + + test("Skip the Layout on subsequent server renders if Layout/ErrorBoundary throws (sync)", async ({ + page, + }) => { + let oldConsoleError; + oldConsoleError = console.error; + console.error = () => {}; + + fixture = await createFixture( + { + files: { + "app/root.tsx": js` + import * as React from "react"; + import { defer } from "@remix-run/node"; + import { Await, Scripts, useRouteError, useRouteLoaderData } from "@remix-run/react"; + + export function Layout({ children }) { + let data = useRouteLoaderData("root"); + return ( + +
+{data.this.should.throw}
+ {children} +success
; + } + export function ErrorBoundary() { + returnerror
; + } + `, + }, + }, + ServerMode.Development + ); + appFixture = await createAppFixture(fixture, ServerMode.Development); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + // The server should send back the fallback 500 HTML since it was unable + // to render the Layout/ErrorBoundary combo + expect(await app.page.$("#layout")).toBeNull(); + expect(await app.getHtml("pre")).toMatch("Unexpected Server Error"); + expect(await app.getHtml("pre")).toMatch( + "Cannot read properties of undefined" + ); + + console.error = oldConsoleError; + }); + + test("Skip the Layout on subsequent client renders if Layout/ErrorBoundary throws (async)", async ({ + page, + }) => { + let oldConsoleError; + oldConsoleError = console.error; + console.error = () => {}; + + fixture = await createFixture( + { + files: { + "app/root.tsx": js` + import * as React from "react"; + import { defer } from "@remix-run/node"; + import { Await, Scripts, useRouteError, useRouteLoaderData } from "@remix-run/react"; + + export function Layout({ children }) { + let data = useRouteLoaderData("root"); + return ( + + +{v.this.should.throw}
} +success
; + } + export function ErrorBoundary() { + returnerror
; + } + `, + }, + }, + ServerMode.Development + ); + appFixture = await createAppFixture(fixture, ServerMode.Development); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", false); + expect(await app.page.$("#layout")).toBeDefined(); + expect(await app.getHtml("#loading")).toMatch("Loading..."); + await page.waitForSelector("h1"); + expect(await app.page.$("#layout")).toBeNull(); + expect(await app.getHtml("title")).toMatch("Application Error"); + expect(await app.getHtml("h1")).toMatch("Application Error"); + expect(await app.getHtml("pre")).toMatch( + "TypeError: Cannot read properties of null" + ); + + console.error = oldConsoleError; + }); + + test("Skip the Layout on subsequent server renders if the Layout/DefaultErrorBoundary throws (sync)", async ({ + page, + }) => { + let oldConsoleError; + oldConsoleError = console.error; + console.error = () => {}; + + fixture = await createFixture( + { + files: { + "app/root.tsx": js` + import * as React from "react"; + import { defer } from "@remix-run/node"; + import { Await, Scripts, useRouteError, useRouteLoaderData } from "@remix-run/react"; + + export function Layout({ children }) { + let data = useRouteLoaderData("root"); + return ( + + +{data.this.should.throw}
+ {children} +success
; + } + `, + }, + }, + ServerMode.Development + ); + appFixture = await createAppFixture(fixture, ServerMode.Development); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/"); + // The server should send back the fallback 500 HTML since it was unable + // to render the Layout/ErrorBoundary combo + expect(await app.page.$("#layout")).toBeNull(); + expect(await app.getHtml("pre")).toMatch("Unexpected Server Error"); + expect(await app.getHtml("pre")).toMatch( + "Cannot read properties of undefined" + ); + + console.error = oldConsoleError; + }); + + test("Skip the Layout on subsequent client renders if the Layout/DefaultErrorBoundary throws (async)", async ({ + page, + }) => { + let oldConsoleError; + oldConsoleError = console.error; + console.error = () => {}; + + fixture = await createFixture( + { + files: { + "app/root.tsx": js` + import * as React from "react"; + import { defer } from "@remix-run/node"; + import { Await, Scripts, useRouteError, useRouteLoaderData } from "@remix-run/react"; + + export function Layout({ children }) { + let data = useRouteLoaderData("root"); + return ( + + +{v.this.should.throw}
} +success
; + } + `, + }, + }, + ServerMode.Development + ); + appFixture = await createAppFixture(fixture, ServerMode.Development); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", false); + expect(await app.page.$("#layout")).toBeDefined(); + expect(await app.getHtml("#loading")).toMatch("Loading..."); + await page.waitForSelector("h1"); + expect(await app.page.$("#layout")).toBeNull(); + expect(await app.getHtml("title")).toMatch("Application Error"); + expect(await app.getHtml("h1")).toMatch("Application Error"); + expect(await app.getHtml("pre")).toMatch( + "TypeError: Cannot read properties of null" + ); + + console.error = oldConsoleError; + }); }); diff --git a/packages/remix-react/errorBoundaries.tsx b/packages/remix-react/errorBoundaries.tsx index 5f9caf4d723..2ab1d46ba8b 100644 --- a/packages/remix-react/errorBoundaries.tsx +++ b/packages/remix-react/errorBoundaries.tsx @@ -6,6 +6,7 @@ import { Scripts, useRemixContext } from "./components"; type RemixErrorBoundaryProps = React.PropsWithChildren<{ location: Location; + isOutsideRemixApp?: boolean; error?: Error; }>; @@ -53,7 +54,12 @@ export class RemixErrorBoundary extends React.Component< render() { if (this.state.error) { - return` to + // the `document` and the DOM will throw, putting React into an error/hydration + // loop. + + // Instead, if we're ever rendering from the outermost `RemixErrorBoundary` + // during hydration that wraps `RouterProvider`, then we can't trust the + // `Layout` and should fallback to the default app shell so we're always + // returning an `` document. + if (routeModules.root?.Layout && !isOutsideRemixApp) { return children; }