Skip to content

Commit d0fd0d7

Browse files
authored
feat: added error boundary (#1602)
* added error boundary and error ui components * add body txt and standardize btn size * added story * feat: added error boundary closes #1013 * committing lockfile * added email body to help link
1 parent 52230fa commit d0fd0d7

File tree

13 files changed

+454
-22
lines changed

13 files changed

+454
-22
lines changed

.vscode/settings.json

+1
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
"rpty",
5353
"sdkproto",
5454
"Signup",
55+
"sourcemapped",
5556
"stretchr",
5657
"TCGETS",
5758
"tcpip",

site/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"react": "17.0.2",
4343
"react-dom": "17.0.2",
4444
"react-router-dom": "6.3.0",
45+
"sourcemapped-stacktrace": "1.1.11",
4546
"swr": "1.2.2",
4647
"uuid": "8.3.2",
4748
"xstate": "4.32.1",

site/src/app.tsx

+10-7
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import React from "react"
44
import { BrowserRouter as Router } from "react-router-dom"
55
import { SWRConfig } from "swr"
66
import { AppRouter } from "./AppRouter"
7+
import { ErrorBoundary } from "./components/ErrorBoundary/ErrorBoundary"
78
import { GlobalSnackbar } from "./components/GlobalSnackbar/GlobalSnackbar"
89
import { dark } from "./theme"
910
import "./theme/globalFonts"
@@ -30,13 +31,15 @@ export const App: React.FC = () => {
3031
},
3132
}}
3233
>
33-
<XServiceProvider>
34-
<ThemeProvider theme={dark}>
35-
<CssBaseline />
36-
<AppRouter />
37-
<GlobalSnackbar />
38-
</ThemeProvider>
39-
</XServiceProvider>
34+
<ThemeProvider theme={dark}>
35+
<CssBaseline />
36+
<ErrorBoundary>
37+
<XServiceProvider>
38+
<AppRouter />
39+
<GlobalSnackbar />
40+
</XServiceProvider>
41+
</ErrorBoundary>
42+
</ThemeProvider>
4043
</SWRConfig>
4144
</Router>
4245
)

site/src/components/CodeBlock/CodeBlock.tsx

+23-7
Original file line numberDiff line numberDiff line change
@@ -5,26 +5,38 @@ import { combineClasses } from "../../util/combineClasses"
55

66
export interface CodeBlockProps {
77
lines: string[]
8+
ctas?: React.ReactElement[]
89
className?: string
910
}
1011

11-
export const CodeBlock: React.FC<CodeBlockProps> = ({ lines, className = "" }) => {
12+
export const CodeBlock: React.FC<CodeBlockProps> = ({ lines, ctas, className = "" }) => {
1213
const styles = useStyles()
1314

1415
return (
15-
<div className={combineClasses([styles.root, className])}>
16-
{lines.map((line, idx) => (
17-
<div className={styles.line} key={idx}>
18-
{line}
16+
<>
17+
<div className={combineClasses([styles.root, className])}>
18+
{lines.map((line, idx) => (
19+
<div className={styles.line} key={idx}>
20+
{line}
21+
</div>
22+
))}
23+
</div>
24+
{ctas && ctas.length && (
25+
<div className={styles.ctaBar}>
26+
{ctas.map((cta, i) => {
27+
return <React.Fragment key={i}>{cta}</React.Fragment>
28+
})}
1929
</div>
20-
))}
21-
</div>
30+
)}
31+
</>
2232
)
2333
}
2434

2535
const useStyles = makeStyles((theme) => ({
2636
root: {
2737
minHeight: 156,
38+
maxHeight: 240,
39+
overflowY: "scroll",
2840
background: theme.palette.background.default,
2941
color: theme.palette.text.primary,
3042
fontFamily: MONOSPACE_FONT_FAMILY,
@@ -36,4 +48,8 @@ const useStyles = makeStyles((theme) => ({
3648
line: {
3749
whiteSpace: "pre-wrap",
3850
},
51+
ctaBar: {
52+
display: "flex",
53+
justifyContent: "space-between",
54+
},
3955
}))

site/src/components/CopyButton/CopyButton.tsx

+22-6
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,27 @@
1-
import Button from "@material-ui/core/Button"
1+
import IconButton from "@material-ui/core/Button"
22
import { makeStyles } from "@material-ui/core/styles"
33
import Tooltip from "@material-ui/core/Tooltip"
44
import Check from "@material-ui/icons/Check"
55
import React, { useState } from "react"
6+
import { combineClasses } from "../../util/combineClasses"
67
import { FileCopyIcon } from "../Icons/FileCopyIcon"
78

89
interface CopyButtonProps {
910
text: string
10-
className?: string
11+
ctaCopy?: string
12+
wrapperClassName?: string
13+
buttonClassName?: string
1114
}
1215

1316
/**
1417
* Copy button used inside the CodeBlock component internally
1518
*/
16-
export const CopyButton: React.FC<CopyButtonProps> = ({ className = "", text }) => {
19+
export const CopyButton: React.FC<CopyButtonProps> = ({
20+
text,
21+
ctaCopy,
22+
wrapperClassName = "",
23+
buttonClassName = "",
24+
}) => {
1725
const styles = useStyles()
1826
const [isCopied, setIsCopied] = useState<boolean>(false)
1927

@@ -36,10 +44,15 @@ export const CopyButton: React.FC<CopyButtonProps> = ({ className = "", text })
3644

3745
return (
3846
<Tooltip title="Copy to Clipboard" placement="top">
39-
<div className={`${styles.copyButtonWrapper} ${className}`}>
40-
<Button className={styles.copyButton} onClick={copyToClipboard} size="small">
47+
<div className={combineClasses([styles.copyButtonWrapper, wrapperClassName])}>
48+
<IconButton
49+
className={combineClasses([styles.copyButton, buttonClassName])}
50+
onClick={copyToClipboard}
51+
size="small"
52+
>
4153
{isCopied ? <Check className={styles.fileCopyIcon} /> : <FileCopyIcon className={styles.fileCopyIcon} />}
42-
</Button>
54+
{ctaCopy && <div className={styles.buttonCopy}>{ctaCopy}</div>}
55+
</IconButton>
4356
</div>
4457
</Tooltip>
4558
)
@@ -65,4 +78,7 @@ const useStyles = makeStyles((theme) => ({
6578
width: 20,
6679
height: 20,
6780
},
81+
buttonCopy: {
82+
marginLeft: theme.spacing(1),
83+
},
6884
}))
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import React from "react"
2+
import { RuntimeErrorState } from "../RuntimeErrorState/RuntimeErrorState"
3+
4+
type ErrorBoundaryProps = Record<string, unknown>
5+
6+
interface ErrorBoundaryState {
7+
error: Error | null
8+
}
9+
10+
/**
11+
* Our app's Error Boundary
12+
* Read more about React Error Boundaries: https://reactjs.org/docs/error-boundaries.html
13+
*/
14+
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
15+
constructor(props: ErrorBoundaryProps) {
16+
super(props)
17+
this.state = { error: null }
18+
}
19+
20+
static getDerivedStateFromError(error: Error): { error: Error } {
21+
return { error }
22+
}
23+
24+
render(): React.ReactNode {
25+
if (this.state.error) {
26+
return <RuntimeErrorState error={this.state.error} />
27+
}
28+
29+
return this.props.children
30+
}
31+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { makeStyles } from "@material-ui/core/styles"
2+
import React from "react"
3+
import { CodeBlock } from "../CodeBlock/CodeBlock"
4+
import { createCtas } from "./createCtas"
5+
6+
const Language = {
7+
reportLoading: "Generating crash report...",
8+
}
9+
10+
interface ReportState {
11+
error: Error
12+
mappedStack: string[] | null
13+
}
14+
15+
interface StackTraceAvailableMsg {
16+
type: "stackTraceAvailable"
17+
stackTrace: string[]
18+
}
19+
20+
/**
21+
* stackTraceUnavailable is a Msg describing a stack trace not being available
22+
*/
23+
export const stackTraceUnavailable = {
24+
type: "stackTraceUnavailable",
25+
} as const
26+
27+
type ReportMessage = StackTraceAvailableMsg | typeof stackTraceUnavailable
28+
29+
export const stackTraceAvailable = (stackTrace: string[]): StackTraceAvailableMsg => {
30+
return {
31+
type: "stackTraceAvailable",
32+
stackTrace,
33+
}
34+
}
35+
36+
const setStackTrace = (model: ReportState, mappedStack: string[]): ReportState => {
37+
return {
38+
...model,
39+
mappedStack,
40+
}
41+
}
42+
43+
export const reducer = (model: ReportState, msg: ReportMessage): ReportState => {
44+
switch (msg.type) {
45+
case "stackTraceAvailable":
46+
return setStackTrace(model, msg.stackTrace)
47+
case "stackTraceUnavailable":
48+
return setStackTrace(model, ["Unable to get stack trace"])
49+
}
50+
}
51+
52+
export const createFormattedStackTrace = (error: Error, mappedStack: string[] | null): string[] => {
53+
return [
54+
"======================= STACK TRACE ========================",
55+
"",
56+
error.message,
57+
...(mappedStack ? mappedStack : []),
58+
"",
59+
"============================================================",
60+
]
61+
}
62+
63+
/**
64+
* A code block component that contains the error stack resulting from an error boundary trigger
65+
*/
66+
export const RuntimeErrorReport = ({ error, mappedStack }: ReportState): React.ReactElement => {
67+
const styles = useStyles()
68+
69+
if (!mappedStack) {
70+
return <CodeBlock lines={[Language.reportLoading]} className={styles.codeBlock} />
71+
}
72+
73+
const formattedStackTrace = createFormattedStackTrace(error, mappedStack)
74+
return <CodeBlock lines={formattedStackTrace} className={styles.codeBlock} ctas={createCtas(formattedStackTrace)} />
75+
}
76+
77+
const useStyles = makeStyles(() => ({
78+
codeBlock: {
79+
minHeight: "auto",
80+
userSelect: "all",
81+
width: "100%",
82+
},
83+
}))
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { ComponentMeta, Story } from "@storybook/react"
2+
import React from "react"
3+
import { RuntimeErrorState, RuntimeErrorStateProps } from "./RuntimeErrorState"
4+
5+
const error = new Error("An error occurred")
6+
7+
export default {
8+
title: "components/RuntimeErrorState",
9+
component: RuntimeErrorState,
10+
argTypes: {
11+
error: {
12+
defaultValue: error,
13+
},
14+
},
15+
} as ComponentMeta<typeof RuntimeErrorState>
16+
17+
const Template: Story<RuntimeErrorStateProps> = (args) => <RuntimeErrorState {...args} />
18+
19+
export const Errored = Template.bind({})
20+
Errored.parameters = {
21+
// The RuntimeErrorState is noisy for chromatic, because it renders an actual error
22+
// along with the stacktrace - and the stacktrace includes the full URL of
23+
// scripts in the stack. This is problematic, because every deployment uses
24+
// a different URL, causing the validation to fail.
25+
chromatic: { disableSnapshot: true },
26+
}
27+
28+
Errored.args = {
29+
error,
30+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { screen } from "@testing-library/react"
2+
import React from "react"
3+
import { render } from "../../testHelpers/renderHelpers"
4+
import { Language as ButtonLanguage } from "./createCtas"
5+
import { Language as RuntimeErrorStateLanguage, RuntimeErrorState } from "./RuntimeErrorState"
6+
7+
describe("RuntimeErrorState", () => {
8+
beforeEach(() => {
9+
// Given
10+
const errorText = "broken!"
11+
const errorStateProps = {
12+
error: new Error(errorText),
13+
}
14+
15+
// When
16+
render(<RuntimeErrorState {...errorStateProps} />)
17+
})
18+
19+
it("should show stack when encountering runtime error", () => {
20+
// Then
21+
const reportError = screen.getByText("broken!")
22+
expect(reportError).toBeDefined()
23+
24+
// Despite appearances, this is the stack trace
25+
const stackTrace = screen.getByText("Unable to get stack trace")
26+
expect(stackTrace).toBeDefined()
27+
})
28+
29+
it("should have a button bar", () => {
30+
// Then
31+
const copyCta = screen.getByText(ButtonLanguage.copyReport)
32+
expect(copyCta).toBeDefined()
33+
34+
const reloadCta = screen.getByText(ButtonLanguage.reloadApp)
35+
expect(reloadCta).toBeDefined()
36+
})
37+
38+
it("should have an email link", () => {
39+
// Then
40+
const emailLink = screen.getByText(RuntimeErrorStateLanguage.link)
41+
expect(emailLink.closest("a")).toHaveAttribute("href", expect.stringContaining("mailto:support@coder.com"))
42+
})
43+
})

0 commit comments

Comments
 (0)