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
This commit is contained in:
Kira Pilot
2022-05-20 10:48:39 -04:00
committed by GitHub
parent 52230fab56
commit d0fd0d7040
13 changed files with 454 additions and 22 deletions
+1
View File
@@ -52,6 +52,7 @@
"rpty",
"sdkproto",
"Signup",
"sourcemapped",
"stretchr",
"TCGETS",
"tcpip",
+1
View File
@@ -42,6 +42,7 @@
"react": "17.0.2",
"react-dom": "17.0.2",
"react-router-dom": "6.3.0",
"sourcemapped-stacktrace": "1.1.11",
"swr": "1.2.2",
"uuid": "8.3.2",
"xstate": "4.32.1",
+10 -7
View File
@@ -4,6 +4,7 @@ import React from "react"
import { BrowserRouter as Router } from "react-router-dom"
import { SWRConfig } from "swr"
import { AppRouter } from "./AppRouter"
import { ErrorBoundary } from "./components/ErrorBoundary/ErrorBoundary"
import { GlobalSnackbar } from "./components/GlobalSnackbar/GlobalSnackbar"
import { dark } from "./theme"
import "./theme/globalFonts"
@@ -30,13 +31,15 @@ export const App: React.FC = () => {
},
}}
>
<XServiceProvider>
<ThemeProvider theme={dark}>
<CssBaseline />
<AppRouter />
<GlobalSnackbar />
</ThemeProvider>
</XServiceProvider>
<ThemeProvider theme={dark}>
<CssBaseline />
<ErrorBoundary>
<XServiceProvider>
<AppRouter />
<GlobalSnackbar />
</XServiceProvider>
</ErrorBoundary>
</ThemeProvider>
</SWRConfig>
</Router>
)
+23 -7
View File
@@ -5,26 +5,38 @@ import { combineClasses } from "../../util/combineClasses"
export interface CodeBlockProps {
lines: string[]
ctas?: React.ReactElement[]
className?: string
}
export const CodeBlock: React.FC<CodeBlockProps> = ({ lines, className = "" }) => {
export const CodeBlock: React.FC<CodeBlockProps> = ({ lines, ctas, className = "" }) => {
const styles = useStyles()
return (
<div className={combineClasses([styles.root, className])}>
{lines.map((line, idx) => (
<div className={styles.line} key={idx}>
{line}
<>
<div className={combineClasses([styles.root, className])}>
{lines.map((line, idx) => (
<div className={styles.line} key={idx}>
{line}
</div>
))}
</div>
{ctas && ctas.length && (
<div className={styles.ctaBar}>
{ctas.map((cta, i) => {
return <React.Fragment key={i}>{cta}</React.Fragment>
})}
</div>
))}
</div>
)}
</>
)
}
const useStyles = makeStyles((theme) => ({
root: {
minHeight: 156,
maxHeight: 240,
overflowY: "scroll",
background: theme.palette.background.default,
color: theme.palette.text.primary,
fontFamily: MONOSPACE_FONT_FAMILY,
@@ -36,4 +48,8 @@ const useStyles = makeStyles((theme) => ({
line: {
whiteSpace: "pre-wrap",
},
ctaBar: {
display: "flex",
justifyContent: "space-between",
},
}))
+22 -6
View File
@@ -1,19 +1,27 @@
import Button from "@material-ui/core/Button"
import IconButton from "@material-ui/core/Button"
import { makeStyles } from "@material-ui/core/styles"
import Tooltip from "@material-ui/core/Tooltip"
import Check from "@material-ui/icons/Check"
import React, { useState } from "react"
import { combineClasses } from "../../util/combineClasses"
import { FileCopyIcon } from "../Icons/FileCopyIcon"
interface CopyButtonProps {
text: string
className?: string
ctaCopy?: string
wrapperClassName?: string
buttonClassName?: string
}
/**
* Copy button used inside the CodeBlock component internally
*/
export const CopyButton: React.FC<CopyButtonProps> = ({ className = "", text }) => {
export const CopyButton: React.FC<CopyButtonProps> = ({
text,
ctaCopy,
wrapperClassName = "",
buttonClassName = "",
}) => {
const styles = useStyles()
const [isCopied, setIsCopied] = useState<boolean>(false)
@@ -36,10 +44,15 @@ export const CopyButton: React.FC<CopyButtonProps> = ({ className = "", text })
return (
<Tooltip title="Copy to Clipboard" placement="top">
<div className={`${styles.copyButtonWrapper} ${className}`}>
<Button className={styles.copyButton} onClick={copyToClipboard} size="small">
<div className={combineClasses([styles.copyButtonWrapper, wrapperClassName])}>
<IconButton
className={combineClasses([styles.copyButton, buttonClassName])}
onClick={copyToClipboard}
size="small"
>
{isCopied ? <Check className={styles.fileCopyIcon} /> : <FileCopyIcon className={styles.fileCopyIcon} />}
</Button>
{ctaCopy && <div className={styles.buttonCopy}>{ctaCopy}</div>}
</IconButton>
</div>
</Tooltip>
)
@@ -65,4 +78,7 @@ const useStyles = makeStyles((theme) => ({
width: 20,
height: 20,
},
buttonCopy: {
marginLeft: theme.spacing(1),
},
}))
@@ -0,0 +1,31 @@
import React from "react"
import { RuntimeErrorState } from "../RuntimeErrorState/RuntimeErrorState"
type ErrorBoundaryProps = Record<string, unknown>
interface ErrorBoundaryState {
error: Error | null
}
/**
* Our app's Error Boundary
* Read more about React Error Boundaries: https://reactjs.org/docs/error-boundaries.html
*/
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props)
this.state = { error: null }
}
static getDerivedStateFromError(error: Error): { error: Error } {
return { error }
}
render(): React.ReactNode {
if (this.state.error) {
return <RuntimeErrorState error={this.state.error} />
}
return this.props.children
}
}
@@ -0,0 +1,83 @@
import { makeStyles } from "@material-ui/core/styles"
import React from "react"
import { CodeBlock } from "../CodeBlock/CodeBlock"
import { createCtas } from "./createCtas"
const Language = {
reportLoading: "Generating crash report...",
}
interface ReportState {
error: Error
mappedStack: string[] | null
}
interface StackTraceAvailableMsg {
type: "stackTraceAvailable"
stackTrace: string[]
}
/**
* stackTraceUnavailable is a Msg describing a stack trace not being available
*/
export const stackTraceUnavailable = {
type: "stackTraceUnavailable",
} as const
type ReportMessage = StackTraceAvailableMsg | typeof stackTraceUnavailable
export const stackTraceAvailable = (stackTrace: string[]): StackTraceAvailableMsg => {
return {
type: "stackTraceAvailable",
stackTrace,
}
}
const setStackTrace = (model: ReportState, mappedStack: string[]): ReportState => {
return {
...model,
mappedStack,
}
}
export const reducer = (model: ReportState, msg: ReportMessage): ReportState => {
switch (msg.type) {
case "stackTraceAvailable":
return setStackTrace(model, msg.stackTrace)
case "stackTraceUnavailable":
return setStackTrace(model, ["Unable to get stack trace"])
}
}
export const createFormattedStackTrace = (error: Error, mappedStack: string[] | null): string[] => {
return [
"======================= STACK TRACE ========================",
"",
error.message,
...(mappedStack ? mappedStack : []),
"",
"============================================================",
]
}
/**
* A code block component that contains the error stack resulting from an error boundary trigger
*/
export const RuntimeErrorReport = ({ error, mappedStack }: ReportState): React.ReactElement => {
const styles = useStyles()
if (!mappedStack) {
return <CodeBlock lines={[Language.reportLoading]} className={styles.codeBlock} />
}
const formattedStackTrace = createFormattedStackTrace(error, mappedStack)
return <CodeBlock lines={formattedStackTrace} className={styles.codeBlock} ctas={createCtas(formattedStackTrace)} />
}
const useStyles = makeStyles(() => ({
codeBlock: {
minHeight: "auto",
userSelect: "all",
width: "100%",
},
}))
@@ -0,0 +1,30 @@
import { ComponentMeta, Story } from "@storybook/react"
import React from "react"
import { RuntimeErrorState, RuntimeErrorStateProps } from "./RuntimeErrorState"
const error = new Error("An error occurred")
export default {
title: "components/RuntimeErrorState",
component: RuntimeErrorState,
argTypes: {
error: {
defaultValue: error,
},
},
} as ComponentMeta<typeof RuntimeErrorState>
const Template: Story<RuntimeErrorStateProps> = (args) => <RuntimeErrorState {...args} />
export const Errored = Template.bind({})
Errored.parameters = {
// The RuntimeErrorState is noisy for chromatic, because it renders an actual error
// along with the stacktrace - and the stacktrace includes the full URL of
// scripts in the stack. This is problematic, because every deployment uses
// a different URL, causing the validation to fail.
chromatic: { disableSnapshot: true },
}
Errored.args = {
error,
}
@@ -0,0 +1,43 @@
import { screen } from "@testing-library/react"
import React from "react"
import { render } from "../../testHelpers/renderHelpers"
import { Language as ButtonLanguage } from "./createCtas"
import { Language as RuntimeErrorStateLanguage, RuntimeErrorState } from "./RuntimeErrorState"
describe("RuntimeErrorState", () => {
beforeEach(() => {
// Given
const errorText = "broken!"
const errorStateProps = {
error: new Error(errorText),
}
// When
render(<RuntimeErrorState {...errorStateProps} />)
})
it("should show stack when encountering runtime error", () => {
// Then
const reportError = screen.getByText("broken!")
expect(reportError).toBeDefined()
// Despite appearances, this is the stack trace
const stackTrace = screen.getByText("Unable to get stack trace")
expect(stackTrace).toBeDefined()
})
it("should have a button bar", () => {
// Then
const copyCta = screen.getByText(ButtonLanguage.copyReport)
expect(copyCta).toBeDefined()
const reloadCta = screen.getByText(ButtonLanguage.reloadApp)
expect(reloadCta).toBeDefined()
})
it("should have an email link", () => {
// Then
const emailLink = screen.getByText(RuntimeErrorStateLanguage.link)
expect(emailLink.closest("a")).toHaveAttribute("href", expect.stringContaining("mailto:support@coder.com"))
})
})
@@ -0,0 +1,114 @@
import Box from "@material-ui/core/Box"
import Link from "@material-ui/core/Link"
import { makeStyles } from "@material-ui/core/styles"
import ErrorOutlineIcon from "@material-ui/icons/ErrorOutline"
import React, { useEffect, useReducer } from "react"
import { mapStackTrace } from "sourcemapped-stacktrace"
import { Margins } from "../Margins/Margins"
import { Section } from "../Section/Section"
import { Typography } from "../Typography/Typography"
import {
createFormattedStackTrace,
reducer,
RuntimeErrorReport,
stackTraceAvailable,
stackTraceUnavailable,
} from "./RuntimeErrorReport"
export const Language = {
title: "Coder encountered an error",
body: "Please copy the crash log using the button below and",
link: "send it to us.",
}
export interface RuntimeErrorStateProps {
error: Error
}
/**
* A title for our error boundary UI
*/
const ErrorStateTitle = () => {
const styles = useStyles()
return (
<Box className={styles.title} display="flex" alignItems="center">
<ErrorOutlineIcon />
<span>{Language.title}</span>
</Box>
)
}
/**
* A description for our error boundary UI
*/
const ErrorStateDescription = ({ emailBody }: { emailBody?: string }) => {
const styles = useStyles()
return (
<Typography variant="body2" color="textSecondary">
{Language.body}&nbsp;
<Link
href={`mailto:support@coder.com?subject=Error Report from Coder&body=${
emailBody && emailBody.replace(/\r\n|\r|\n/g, "%0D%0A") // preserving line breaks
}`}
className={styles.link}
>
{Language.link}
</Link>
</Typography>
)
}
/**
* An error UI that is displayed when our error boundary (ErrorBoundary.tsx) is triggered
*/
export const RuntimeErrorState: React.FC<RuntimeErrorStateProps> = ({ error }) => {
const styles = useStyles()
const [reportState, dispatch] = useReducer(reducer, { error, mappedStack: null })
useEffect(() => {
try {
mapStackTrace(error.stack, (mappedStack) => dispatch(stackTraceAvailable(mappedStack)))
} catch {
dispatch(stackTraceUnavailable)
}
}, [error])
return (
<Box display="flex" flexDirection="column">
<Margins>
<Section
className={styles.reportContainer}
title={<ErrorStateTitle />}
description={
<ErrorStateDescription
emailBody={createFormattedStackTrace(reportState.error, reportState.mappedStack).join("\r\n")}
/>
}
>
<RuntimeErrorReport error={reportState.error} mappedStack={reportState.mappedStack} />
</Section>
</Margins>
</Box>
)
}
const useStyles = makeStyles((theme) => ({
title: {
"& span": {
paddingLeft: theme.spacing(1),
},
"& .MuiSvgIcon-root": {
color: theme.palette.error.main,
},
},
link: {
textDecoration: "none",
color: theme.palette.primary.main,
},
reportContainer: {
display: "flex",
justifyContent: "center",
marginTop: theme.spacing(5),
},
}))
@@ -0,0 +1,72 @@
import Button from "@material-ui/core/Button"
import { makeStyles } from "@material-ui/core/styles"
import RefreshIcon from "@material-ui/icons/Refresh"
import React from "react"
import { CopyButton } from "../CopyButton/CopyButton"
export const Language = {
reloadApp: "Reload Application",
copyReport: "Copy Report",
}
/**
* A wrapper component for a full-width copy button
*/
const CopyStackButton = ({ text }: { text: string }): React.ReactElement => {
const styles = useStyles()
return (
<CopyButton
text={text}
ctaCopy={Language.copyReport}
wrapperClassName={styles.buttonWrapper}
buttonClassName={styles.copyButton}
/>
)
}
/**
* A button that reloads our application
*/
const ReloadAppButton = (): React.ReactElement => {
const styles = useStyles()
return (
<Button
className={styles.buttonWrapper}
variant="outlined"
color="primary"
startIcon={<RefreshIcon />}
onClick={() => location.replace("/")}
>
{Language.reloadApp}
</Button>
)
}
/**
* createCtas generates an array of buttons to be used with our error boundary UI
*/
export const createCtas = (codeBlock: string[]): React.ReactElement[] => {
// REMARK: we don't have to worry about key order changing
// eslint-disable-next-line react/jsx-key
return [<CopyStackButton text={codeBlock.join("\r\n")} />, <ReloadAppButton />]
}
const useStyles = makeStyles((theme) => ({
buttonWrapper: {
marginTop: theme.spacing(1),
marginLeft: 0,
flex: theme.spacing(1),
textTransform: "uppercase",
fontSize: theme.typography.fontSize,
},
copyButton: {
width: "100%",
marginRight: theme.spacing(1),
backgroundColor: theme.palette.primary.main,
textTransform: "uppercase",
fontSize: theme.typography.fontSize,
},
}))
+12 -2
View File
@@ -2,6 +2,7 @@ import { makeStyles } from "@material-ui/core/styles"
import { fade } from "@material-ui/core/styles/colorManipulator"
import Typography from "@material-ui/core/Typography"
import React from "react"
import { combineClasses } from "../../util/combineClasses"
import { SectionAction } from "../SectionAction/SectionAction"
type SectionLayout = "fixed" | "fluid"
@@ -12,15 +13,24 @@ export interface SectionProps {
toolbar?: React.ReactNode
alert?: React.ReactNode
layout?: SectionLayout
className?: string
children?: React.ReactNode
}
type SectionFC = React.FC<SectionProps> & { Action: typeof SectionAction }
export const Section: SectionFC = ({ title, description, toolbar, alert, children, layout = "fixed" }) => {
export const Section: SectionFC = ({
title,
description,
toolbar,
alert,
className = "",
children,
layout = "fixed",
}) => {
const styles = useStyles({ layout })
return (
<div className={styles.root}>
<div className={combineClasses([styles.root, className])}>
<div className={styles.inner}>
{(title || description) && (
<div className={styles.header}>
+12
View File
@@ -11991,6 +11991,11 @@ source-map-url@^0.4.0:
resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.1.tgz#0af66605a745a5a2f91cf1bbf8a7afbc283dec56"
integrity sha512-cPiFOTLUKvJFIg4SKVScy4ilPPW6rFgMgfuZJPNoDuMs3nC1HbMUycBoJw77xFIp6z1UJQJOfx6C9GMH80DiTw==
source-map@0.5.6:
version "0.5.6"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.6.tgz#75ce38f52bf0733c5a7f0c118d81334a2bb5f412"
integrity sha1-dc449SvwczxafwwRjYEzSiu19BI=
source-map@^0.5.0, source-map@^0.5.6, source-map@^0.5.7:
version "0.5.7"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc"
@@ -12006,6 +12011,13 @@ source-map@^0.7.3, source-map@~0.7.2:
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383"
integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==
sourcemapped-stacktrace@1.1.11:
version "1.1.11"
resolved "https://registry.yarnpkg.com/sourcemapped-stacktrace/-/sourcemapped-stacktrace-1.1.11.tgz#e2dede7fc148599c52a4f883276e527f8452657d"
integrity sha512-O0pcWjJqzQFVsisPlPXuNawJHHg9N9UgpJ/aDmvi9+vnS3x1C0NhwkVFzzZ1VN0Xo+bekyweoqYvBw5ZBKiNnQ==
dependencies:
source-map "0.5.6"
space-separated-tokens@^1.0.0:
version "1.1.5"
resolved "https://registry.yarnpkg.com/space-separated-tokens/-/space-separated-tokens-1.1.5.tgz#85f32c3d10d9682007e917414ddc5c26d1aa6899"