Skip to content

Commit f6d37f6

Browse files
authored
fix(site): ensure Error Boundary catches render errors correctly (#15963)
## Changes made - Replaced previous `ErrorBoundary` functionality with `GlobalErrorBoundary` component - Wired up `GlobalErrorBoundary` to React Router rather than the top of the app
1 parent d788223 commit f6d37f6

File tree

13 files changed

+334
-318
lines changed

13 files changed

+334
-318
lines changed

site/src/App.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import {
1010
import { HelmetProvider } from "react-helmet-async";
1111
import { QueryClient, QueryClientProvider } from "react-query";
1212
import { RouterProvider } from "react-router-dom";
13-
import { ErrorBoundary } from "./components/ErrorBoundary/ErrorBoundary";
1413
import { GlobalSnackbar } from "./components/GlobalSnackbar/GlobalSnackbar";
1514
import { ThemeProvider } from "./contexts/ThemeProvider";
1615
import { AuthProvider } from "./contexts/auth/AuthProvider";
@@ -81,11 +80,11 @@ export const AppProviders: FC<AppProvidersProps> = ({
8180
export const App: FC = () => {
8281
return (
8382
<StrictMode>
84-
<ErrorBoundary>
85-
<AppProviders>
86-
<RouterProvider router={router} />
87-
</AppProviders>
88-
</ErrorBoundary>
83+
<AppProviders>
84+
{/* If you're wondering where the global error boundary is,
85+
it's connected to the router */}
86+
<RouterProvider router={router} />
87+
</AppProviders>
8988
</StrictMode>
9089
);
9190
};

site/src/components/Button/Button.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,19 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
5555
const Comp = asChild ? Slot : "button";
5656
return (
5757
<Comp
58-
className={cn(buttonVariants({ variant, size, className }))}
59-
ref={ref}
6058
{...props}
59+
ref={ref}
60+
className={cn(buttonVariants({ variant, size }), className)}
61+
// Adding default button type to make sure that buttons don't
62+
// accidentally trigger form actions when clicked. But because
63+
// this Button component is so polymorphic (it's also used to
64+
// make <a> elements look like buttons), we can only safely
65+
// default to adding the prop when we know that we're rendering
66+
// a real HTML button instead of an arbitrary Slot. Adding the
67+
// type attribute to any non-buttons will produce invalid HTML
68+
type={
69+
props.type === undefined && Comp === "button" ? "button" : props.type
70+
}
6171
/>
6272
);
6373
},

site/src/components/CustomLogo/CustomLogo.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,6 @@ export const CustomLogo: FC<{ css?: Interpolation<Theme> }> = (props) => {
2828
className="application-logo"
2929
/>
3030
) : (
31-
<CoderIcon {...props} css={[{ fontSize: 64, fill: "white" }, props.css]} />
31+
<CoderIcon {...props} className="w-12 h-12" />
3232
);
3333
};

site/src/components/ErrorBoundary/ErrorBoundary.tsx

Lines changed: 0 additions & 39 deletions
This file was deleted.
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { expect, userEvent } from "@storybook/test";
3+
import { within } from "@testing-library/react";
4+
import type { ErrorResponse } from "react-router-dom";
5+
import { GlobalErrorBoundaryInner } from "./GlobalErrorBoundary";
6+
7+
/**
8+
* React Router ErrorResponses have a "hidden" internal field that RR uses to
9+
* detect whether something is a loader error. The property doesn't exist in
10+
* the type information, but it does exist at runtime, and we need it to mock
11+
* out the story correctly
12+
*/
13+
type FullErrorResponse = Readonly<
14+
ErrorResponse & {
15+
internal: true;
16+
}
17+
>;
18+
19+
const meta = {
20+
title: "components/GlobalErrorBoundary",
21+
component: GlobalErrorBoundaryInner,
22+
} satisfies Meta<typeof GlobalErrorBoundaryInner>;
23+
24+
export default meta;
25+
type Story = StoryObj<typeof meta>;
26+
27+
export const VanillaJavascriptError: Story = {
28+
args: {
29+
error: new Error("Something blew up :("),
30+
},
31+
play: async ({ canvasElement, args }) => {
32+
const error = args.error as Error;
33+
const canvas = within(canvasElement);
34+
const showErrorButton = canvas.getByRole("button", {
35+
name: /Show error/i,
36+
});
37+
await userEvent.click(showErrorButton);
38+
39+
// Verify that error message content is now on screen; defer to
40+
// accessible name queries as much as possible
41+
canvas.getByRole("heading", { name: /Error/i });
42+
43+
const p = canvas.getByTestId("description");
44+
expect(p).toHaveTextContent(error.message);
45+
46+
const codeBlock = canvas.getByTestId("code");
47+
expect(codeBlock).toHaveTextContent(error.name);
48+
expect(codeBlock).toHaveTextContent(error.message);
49+
},
50+
};
51+
52+
export const ReactRouterErrorResponse: Story = {
53+
args: {
54+
error: {
55+
internal: true,
56+
status: 500,
57+
statusText: "Aww, beans!",
58+
data: { message: "beans" },
59+
} satisfies FullErrorResponse,
60+
},
61+
play: async ({ canvasElement, args }) => {
62+
const error = args.error as FullErrorResponse;
63+
const canvas = within(canvasElement);
64+
const showErrorButton = canvas.getByRole("button", {
65+
name: /Show error/i,
66+
});
67+
await userEvent.click(showErrorButton);
68+
69+
// Verify that error message content is now on screen; defer to
70+
// accessible name queries as much as possible
71+
const header = canvas.getByRole("heading", { name: /Aww, beans!/i });
72+
expect(header).toHaveTextContent(String(error.status));
73+
74+
const codeBlock = canvas.getByTestId("code");
75+
const content = codeBlock.innerText;
76+
const parsed = JSON.parse(content);
77+
expect(parsed).toEqual(error.data);
78+
},
79+
};
80+
81+
export const UnparsableError: Story = {
82+
args: {
83+
error: class WellThisIsDefinitelyWrong {},
84+
},
85+
play: async ({ canvasElement }) => {
86+
const canvas = within(canvasElement);
87+
const showErrorButton = canvas.queryByRole("button", {
88+
name: /Show error/i,
89+
});
90+
expect(showErrorButton).toBe(null);
91+
},
92+
};
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
/**
2+
* @file A global error boundary designed to work with React Router.
3+
*
4+
* This is not documented well, but because of React Router works, it will
5+
* automatically intercept any render errors produced in routes, and will
6+
* "swallow" them, preventing the errors from bubbling up to any error
7+
* boundaries above the router. The global error boundary must be explicitly
8+
* bound to a route to work as expected.
9+
*/
10+
import type { Interpolation } from "@emotion/react";
11+
import Link from "@mui/material/Link";
12+
import { Button } from "components/Button/Button";
13+
import { CoderIcon } from "components/Icons/CoderIcon";
14+
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
15+
import { type FC, useState } from "react";
16+
import { Helmet } from "react-helmet-async";
17+
import {
18+
type ErrorResponse,
19+
isRouteErrorResponse,
20+
useLocation,
21+
useRouteError,
22+
} from "react-router-dom";
23+
24+
const errorPageTitle = "Something went wrong";
25+
26+
// Mocking React Router's error-handling logic is a pain; the next best thing is
27+
// to split it off from the rest of the code, and pass the value via props
28+
export const GlobalErrorBoundary: FC = () => {
29+
const error = useRouteError();
30+
return <GlobalErrorBoundaryInner error={error} />;
31+
};
32+
33+
type GlobalErrorBoundaryInnerProps = Readonly<{ error: unknown }>;
34+
export const GlobalErrorBoundaryInner: FC<GlobalErrorBoundaryInnerProps> = ({
35+
error,
36+
}) => {
37+
const [showErrorMessage, setShowErrorMessage] = useState(false);
38+
const { metadata } = useEmbeddedMetadata();
39+
const location = useLocation();
40+
41+
const coderVersion = metadata["build-info"].value?.version;
42+
const isRenderableError =
43+
error instanceof Error || isRouteErrorResponse(error);
44+
45+
return (
46+
<div className="bg-surface-primary text-center w-full h-full flex justify-center items-center">
47+
<Helmet>
48+
<title>{errorPageTitle}</title>
49+
</Helmet>
50+
51+
<main className="flex gap-6 w-full max-w-prose p-4 flex-col flex-nowrap">
52+
<div className="flex gap-2 flex-col items-center">
53+
<CoderIcon className="w-11 h-11" />
54+
55+
<div className="text-content-primary flex flex-col gap-1">
56+
<h1 className="text-2xl font-normal m-0">{errorPageTitle}</h1>
57+
<p className="leading-6 m-0">
58+
Please try reloading the page. If reloading does not work, you can
59+
ask for help in the{" "}
60+
<Link
61+
href="https://discord.gg/coder"
62+
target="_blank"
63+
rel="noreferer"
64+
>
65+
Coder Discord community
66+
<span className="sr-only"> (link opens in a new tab)</span>
67+
</Link>{" "}
68+
or{" "}
69+
<Link
70+
target="_blank"
71+
rel="noreferer"
72+
href={publicGithubIssueLink(
73+
coderVersion,
74+
location.pathname,
75+
error,
76+
)}
77+
>
78+
open an issue on GitHub
79+
<span className="sr-only"> (link opens in a new tab)</span>
80+
</Link>
81+
.
82+
</p>
83+
</div>
84+
</div>
85+
86+
<div className="flex flex-row flex-nowrap justify-center gap-4">
87+
<Button asChild className="min-w-32 font-medium">
88+
<Link href={location.pathname}>Reload page</Link>
89+
</Button>
90+
91+
{isRenderableError && (
92+
<Button
93+
variant="outline"
94+
className="min-w-32"
95+
onClick={() => setShowErrorMessage(!showErrorMessage)}
96+
>
97+
{showErrorMessage ? "Hide error" : "Show error"}
98+
</Button>
99+
)}
100+
</div>
101+
102+
{isRenderableError && showErrorMessage && <ErrorStack error={error} />}
103+
</main>
104+
</div>
105+
);
106+
};
107+
108+
type ErrorStackProps = Readonly<{ error: Error | ErrorResponse }>;
109+
const ErrorStack: FC<ErrorStackProps> = ({ error }) => {
110+
return (
111+
<aside className="p-4 text-left rounded-md border-[1px] border-content-tertiary border-solid">
112+
{isRouteErrorResponse(error) ? (
113+
<>
114+
<h2 className="text-base font-bold text-content-primary m-0">
115+
HTTP {error.status} - {error.statusText}
116+
</h2>
117+
<pre className="m-0 py-2 px-0 overflow-x-auto text-xs">
118+
<code data-testid="code">{serializeDataAsJson(error.data)}</code>
119+
</pre>
120+
</>
121+
) : (
122+
<>
123+
<h2 className="text-base font-bold text-content-primary m-0">
124+
{error.name}
125+
</h2>
126+
<p data-testid="description" className="pb-4 leading-5 m-0">
127+
{error.message}
128+
</p>
129+
{error.stack && (
130+
<pre className="m-0 py-2 px-0 overflow-x-auto text-xs">
131+
<code data-testid="code">{error.stack}</code>
132+
</pre>
133+
)}
134+
</>
135+
)}
136+
</aside>
137+
);
138+
};
139+
140+
function serializeDataAsJson(data: unknown): string | null {
141+
try {
142+
return JSON.stringify(data, null, 2);
143+
} catch {
144+
return null;
145+
}
146+
}
147+
148+
function publicGithubIssueLink(
149+
coderVersion: string | undefined,
150+
pathName: string,
151+
error: unknown,
152+
): string {
153+
const baseLink = "https://github.com/coder/coder/issues/new";
154+
155+
// Anytime you see \`\`\`txt, that's wrapping the text in a GitHub codeblock
156+
let printableError: string;
157+
if (error instanceof Error) {
158+
printableError = [
159+
`${error.name}: ${error.message}`,
160+
error.stack ? `\`\`\`txt\n${error.stack}\n\`\`\`` : "No stack",
161+
].join("\n");
162+
} else if (isRouteErrorResponse(error)) {
163+
const serialized = serializeDataAsJson(error.data);
164+
printableError = [
165+
`HTTP ${error.status} - ${error.statusText}`,
166+
serialized ? `\`\`\`txt\n${serialized}\n\`\`\`` : "(No data)",
167+
].join("\n");
168+
} else {
169+
printableError = "No error message available";
170+
}
171+
172+
const messageBody = `\
173+
**Version**
174+
${coderVersion ?? "-- Set version --"}
175+
176+
**Path**
177+
\`${pathName}\`
178+
179+
**Error**
180+
${printableError}`;
181+
182+
return `${baseLink}?body=${encodeURIComponent(messageBody)}`;
183+
}

site/src/components/ErrorBoundary/RuntimeErrorState.stories.tsx

Lines changed: 0 additions & 24 deletions
This file was deleted.

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy