From c33a3a2c22c7ed49d53a9e08a79bfba874a8ee21 Mon Sep 17 00:00:00 2001
From: Matt Brophy
Date: Fri, 14 Jun 2024 10:47:06 -0400
Subject: [PATCH] Avoid hydration loops when layout error renders also throw
(#9566)
---
.changeset/mighty-experts-agree.md | 5 +
integration/root-route-test.ts | 251 +++++++++++++++++++++++
packages/remix-react/errorBoundaries.tsx | 41 +++-
3 files changed, 293 insertions(+), 4 deletions(-)
create mode 100644 .changeset/mighty-experts-agree.md
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 (
+
+
+ Layout Title
+
+
+ {data.this.should.throw}
+ {children}
+
+
+
+ );
+ }
+ export function loader() {
+ return { ok: true };
+ }
+ export default function Root() {
+ return success
;
+ }
+ export function ErrorBoundary() {
+ return error
;
+ }
+ `,
+ },
+ },
+ 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 (
+
+
+ Layout Title
+
+
+ Loading...
}>
+
+ {(v) => {v.this.should.throw}
}
+
+
+ {children}
+
+
+
+ );
+ }
+ export function loader() {
+ return defer({
+ // this lets the app hydrate properly, then reject the deferred promise,
+ // which should throw on the initial render _and_ the error render,
+ // resulting in us bubbling to the default error boundary and skipping
+ // our Layout component entirely to avoid a loop
+ lazy: new Promise((r) => setTimeout(() => r(null), 100)),
+ });
+ }
+ export default function Root() {
+ return success
;
+ }
+ export function ErrorBoundary() {
+ return error
;
+ }
+ `,
+ },
+ },
+ 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 (
+
+
+ Layout Title
+
+
+ {data.this.should.throw}
+ {children}
+
+
+
+ );
+ }
+ export function loader() {
+ return { ok: true };
+ }
+ export default function Root() {
+ return 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 (
+
+
+ Layout Title
+
+
+ Loading...}>
+
+ {(v) => {v.this.should.throw}
}
+
+
+ {children}
+
+
+
+ );
+ }
+ export function loader() {
+ return defer({
+ // this lets the app hydrate properly, then reject the deferred promise,
+ // which should throw on the initial render _and_ the error render,
+ // resulting in us bubbling to the default error boundary and skipping
+ // our Layout component entirely to avoid a loop
+ lazy: new Promise((r) => setTimeout(() => r(null), 100)),
+ });
+ }
+ export default function Root() {
+ return 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 ;
+ return (
+
+ );
} else {
return this.props.children;
}
@@ -63,7 +69,13 @@ 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: unknown }) {
+export function RemixRootDefaultErrorBoundary({
+ error,
+ isOutsideRemixApp,
+}: {
+ error: unknown;
+ isOutsideRemixApp?: boolean;
+}) {
console.error(error);
let heyDeveloper = (
@@ -103,7 +115,10 @@ export function RemixRootDefaultErrorBoundary({ error }: { error: unknown }) {
}
return (
-
+
Application Error
` 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;
}