Skip to content

Commit a0e4212

Browse files
Kira-Pilotkylecarbs
authored andcommitted
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 ef1b6be commit a0e4212

File tree

13 files changed

+454
-22
lines changed

13 files changed

+454
-22
lines changed

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
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

Lines changed: 1 addition & 0 deletions
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

Lines changed: 10 additions & 7 deletions
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

Lines changed: 23 additions & 7 deletions
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

Lines changed: 22 additions & 6 deletions
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
}))
Lines changed: 31 additions & 0 deletions
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+
}
Lines changed: 83 additions & 0 deletions
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+
}))
Lines changed: 30 additions & 0 deletions
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+
}
Lines changed: 43 additions & 0 deletions
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)
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