[improvement] Adds error boundary (#690)

* Add error boundary

* Update useStyle.tsx

* Update ErrorFallback.tsx
This commit is contained in:
Steve Ruiz 2022-05-18 12:45:04 +01:00 committed by GitHub
parent e33edb9cab
commit e2a6badaef
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 280 additions and 124 deletions

View file

@ -48,11 +48,11 @@
"react-dom": "^16.8 || ^17.0" "react-dom": "^16.8 || ^17.0"
}, },
"devDependencies": { "devDependencies": {
"@tldraw/intersect": "*",
"@tldraw/vec": "*",
"@swc-node/jest": "^1.4.3", "@swc-node/jest": "^1.4.3",
"@testing-library/jest-dom": "^5.16.2", "@testing-library/jest-dom": "^5.16.2",
"@testing-library/react": "^12.1.2", "@testing-library/react": "^12.1.2",
"@tldraw/intersect": "*",
"@tldraw/vec": "*",
"@types/node": "^17.0.14", "@types/node": "^17.0.14",
"@types/react": "^17.0.38", "@types/react": "^17.0.38",
"@typescript-eslint/eslint-plugin": "^5.10.2", "@typescript-eslint/eslint-plugin": "^5.10.2",

View file

@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { observer } from 'mobx-react-lite'
import * as React from 'react' import * as React from 'react'
import { observer } from 'mobx-react-lite'
import { import {
usePreventNavigationCss, usePreventNavigationCss,
useZoomEvents, useZoomEvents,
@ -136,5 +136,3 @@ export const Canvas = observer(function _Canvas<
</div> </div>
) )
}) })
const stopPropagation: React.ClipboardEventHandler<HTMLDivElement> = (e) => e.stopPropagation()

View file

@ -29,14 +29,24 @@ export function useSelection<T extends TLShape>(
if (selectedIds.length === 1) { if (selectedIds.length === 1) {
const id = selectedIds[0] const id = selectedIds[0]
const shape = page.shapes[id] const shape = page.shapes[id]
if (!shape) {
throw Error(`selectedIds is set to the id of a shape that doesn't exist: ${id}`)
}
rotation = shape.rotation || 0 rotation = shape.rotation || 0
isLocked = shape.isLocked || false isLocked = shape.isLocked || false
const utils = getShapeUtils(shapeUtils, shape) const utils = getShapeUtils(shapeUtils, shape)
bounds = utils.hideBounds ? undefined : utils.getBounds(shape) bounds = utils.hideBounds ? undefined : utils.getBounds(shape)
} else if (selectedIds.length > 1) { } else if (selectedIds.length > 1) {
const selectedShapes = selectedIds.map((id) => page.shapes[id]) const selectedShapes = selectedIds.map((id) => page.shapes[id])
rotation = 0 rotation = 0
isLocked = selectedShapes.every((shape) => shape.isLocked) isLocked = selectedShapes.every((shape) => shape.isLocked)
bounds = selectedShapes.reduce((acc, shape, i) => { bounds = selectedShapes.reduce((acc, shape, i) => {
if (i === 0) { if (i === 0) {
return getShapeUtils(shapeUtils, shape).getRotatedBounds(shape) return getShapeUtils(shapeUtils, shape).getRotatedBounds(shape)
@ -48,9 +58,11 @@ export function useSelection<T extends TLShape>(
if (bounds) { if (bounds) {
const [minX, minY] = canvasToScreen([bounds.minX, bounds.minY], pageState.camera) const [minX, minY] = canvasToScreen([bounds.minX, bounds.minY], pageState.camera)
const [maxX, maxY] = canvasToScreen([bounds.maxX, bounds.maxY], pageState.camera) const [maxX, maxY] = canvasToScreen([bounds.maxX, bounds.maxY], pageState.camera)
isLinked = !!Object.values(page.bindings).find( isLinked = !!Object.values(page.bindings).find(
(binding) => selectedIds.includes(binding.toId) || selectedIds.includes(binding.fromId) (binding) => selectedIds.includes(binding.toId) || selectedIds.includes(binding.fromId)
) )
rSelectionBounds.current = { rSelectionBounds.current = {
minX, minX,
minY, minY,

View file

@ -138,8 +138,14 @@ export function useShapeTree<T extends TLShape, M extends Record<string, unknown
// If the shape's parent is a different shape (e.g. a group), // If the shape's parent is a different shape (e.g. a group),
// add the parent to the sets of shapes to render. The parent's // add the parent to the sets of shapes to render. The parent's
// children will all be rendered. // children will all be rendered.
shapesIdsToRender.add(shape.parentId) const parent = page.shapes[shape.parentId]
shapesToRender.add(page.shapes[shape.parentId])
if (parent === undefined) {
throw Error(`A shape (${shape.id}) has a parent (${shape.parentId}) that does not exist!`)
} else {
shapesIdsToRender.add(parent.id)
shapesToRender.add(parent)
}
}) })
// Call onRenderCountChange callback when number of rendering shapes changes // Call onRenderCountChange callback when number of rendering shapes changes
@ -163,7 +169,11 @@ export function useShapeTree<T extends TLShape, M extends Record<string, unknown
const tree: IShapeTreeNode<T, M>[] = [] const tree: IShapeTreeNode<T, M>[] = []
shapesToRender.forEach((shape) => shapesToRender.forEach((shape) => {
if (shape === undefined) {
throw Error('Rendered shapes included a missing shape')
}
addToShapeTree( addToShapeTree(
shape, shape,
tree, tree,
@ -174,7 +184,7 @@ export function useShapeTree<T extends TLShape, M extends Record<string, unknown
false, false,
meta meta
) )
) })
tree.sort((a, b) => a.shape.childIndex - b.shape.childIndex) tree.sort((a, b) => a.shape.childIndex - b.shape.childIndex)

View file

@ -79,36 +79,6 @@ const defaultTheme: TLTheme = {
} }
export const TLCSS = css` export const TLCSS = css`
@font-face {
font-family: 'Recursive';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(https://fonts.gstatic.com/s/recursive/v23/8vI-7wMr0mhh-RQChyHEH06TlXhq_gukbYrFMk1QuAIcyEwG_X-dpEfaE5YaERmK-CImKsvxvU-MXGX2fSqasNfUlTGZnI14ZeY.woff2)
format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Recursive';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(https://fonts.gstatic.com/s/recursive/v23/8vI-7wMr0mhh-RQChyHEH06TlXhq_gukbYrFMk1QuAIcyEwG_X-dpEfaE5YaERmK-CImKsvxvU-MXGX2fSqasNfUlTGZnI14ZeY.woff2)
format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Recursive Mono';
font-style: normal;
font-weight: 420;
font-display: swap;
src: url(https://fonts.gstatic.com/s/recursive/v23/8vI-7wMr0mhh-RQChyHEH06TlXhq_gukbYrFMk1QuAIcyEwG_X-dpEfaE5YaERmK-CImqvTxvU-MXGX2fSqasNfUlTGZnI14ZeY.woff2)
format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
.tl-container { .tl-container {
--tl-zoom: 1; --tl-zoom: 1;
--tl-scale: calc(1 / var(--tl-zoom)); --tl-scale: calc(1 / var(--tl-zoom));
@ -351,11 +321,6 @@ export const TLCSS = css`
.tl-brush.dashed { .tl-brush.dashed {
stroke: none; stroke: none;
} }
.tl-dot {
fill: var(--tl-background);
stroke: var(--tl-foreground);
stroke-width: 2px;
}
.tl-handle { .tl-handle {
pointer-events: all; pointer-events: all;
cursor: grab; cursor: grab;

View file

@ -56,6 +56,7 @@
"idb-keyval": "^6.1.0", "idb-keyval": "^6.1.0",
"lz-string": "^1.4.4", "lz-string": "^1.4.4",
"perfect-freehand": "^1.0.16", "perfect-freehand": "^1.0.16",
"react-error-boundary": "^3.1.4",
"react-hotkey-hook": "^1.0.2", "react-hotkey-hook": "^1.0.2",
"react-hotkeys-hook": "^3.4.4", "react-hotkeys-hook": "^3.4.4",
"tslib": "^2.3.1", "tslib": "^2.3.1",

View file

@ -12,6 +12,8 @@ import { FocusButton } from '~components/FocusButton'
import { TLDR } from '~state/TLDR' import { TLDR } from '~state/TLDR'
import { GRID_SIZE } from '~constants' import { GRID_SIZE } from '~constants'
import { Loading } from '~components/Loading' import { Loading } from '~components/Loading'
import { ErrorBoundary } from 'react-error-boundary'
import { ErrorFallback } from '~components/ErrorFallback'
export interface TldrawProps extends TDCallbacks { export interface TldrawProps extends TDCallbacks {
/** /**
@ -415,6 +417,7 @@ const InnerTldraw = React.memo(function InnerTldraw({
<Loading /> <Loading />
<OneOff focusableRef={rWrapper} autofocus={autofocus} /> <OneOff focusableRef={rWrapper} autofocus={autofocus} />
<ContextMenu> <ContextMenu>
<ErrorBoundary FallbackComponent={ErrorFallback}>
<Renderer <Renderer
id={id} id={id}
containerRef={rWrapper} containerRef={rWrapper}
@ -490,6 +493,7 @@ const InnerTldraw = React.memo(function InnerTldraw({
onDragOver={app.onDragOver} onDragOver={app.onDragOver}
onDrop={app.onDrop} onDrop={app.onDrop}
/> />
</ErrorBoundary>
</ContextMenu> </ContextMenu>
{showUI && ( {showUI && (
<StyledUI> <StyledUI>

View file

@ -0,0 +1,122 @@
import * as React from 'react'
import { FallbackProps } from 'react-error-boundary'
import { Divider } from '~components/Primitives/Divider'
import { RowButton } from '~components/Primitives/RowButton'
import { useTldrawApp } from '~hooks'
import { styled } from '~styles'
export function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
const app = useTldrawApp()
const refreshPage = () => {
window.location.reload()
resetErrorBoundary()
}
const copyError = () => {
const textarea = document.createElement('textarea')
textarea.value = error.message
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
textarea.remove()
}
const downloadBackup = () => {
app.saveProjectAs()
}
const resetDocument = () => {
app.resetDocument()
resetErrorBoundary()
}
return (
<Container>
<InnerContainer>
<div>We've encountered an error!</div>
<pre>
<code>{error.message}</code>
</pre>
<Buttons>
<RowButton onClick={copyError}>Copy Error</RowButton>
<RowButton onClick={refreshPage}>Refresh Page</RowButton>
</Buttons>
<Divider />
<p>
Keep getting this error?{' '}
<a onClick={downloadBackup} title="Download your project">
Download your project
</a>{' '}
as a backup and then{' '}
<a onClick={resetDocument} title="Reset the document">
reset the document
</a>
.
</p>
</InnerContainer>
</Container>
)
}
const Container = styled('div', {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '$canvas',
})
const InnerContainer = styled('div', {
backgroundColor: '$panel',
border: '1px solid $panelContrast',
padding: '$5',
borderRadius: 8,
boxShadow: '$panel',
maxWidth: 320,
color: '$text',
fontFamily: '$ui',
fontSize: '$2',
textAlign: 'center',
display: 'flex',
flexDirection: 'column',
gap: '$3',
'& > pre': {
marginTop: '$3',
marginBottom: '$3',
textAlign: 'left',
whiteSpace: 'pre-wrap',
backgroundColor: '$hover',
padding: '$4',
borderRadius: '$2',
fontFamily: '"Menlo", "Monaco", monospace',
fontWeight: 500,
},
'& p': {
fontFamily: '$body',
lineHeight: 1.7,
padding: '$5',
margin: 0,
},
'& a': {
color: '$text',
cursor: 'pointer',
textDecoration: 'underline',
},
'& hr': {
marginLeft: '-$5',
marginRight: '-$5',
},
})
const Buttons = styled('div', {
display: 'flex',
'& > button > div': {
justifyContent: 'center',
textAlign: 'center',
},
})

View file

@ -0,0 +1 @@
export * from './ErrorFallback'

View file

@ -3,9 +3,43 @@ import * as React from 'react'
const styles = new Map<string, HTMLStyleElement>() const styles = new Map<string, HTMLStyleElement>()
const UID = `tldraw-fonts` const UID = `tldraw-fonts`
const WEBFONT_URL =
'https://fonts.googleapis.com/css2?family=Caveat+Brush&family=Source+Code+Pro&family=Source+Sans+Pro&family=Crimson+Pro&display=block' const CSS = `
const CSS = `@import url('${WEBFONT_URL}');` @import url('https://fonts.googleapis.com/css2?family=Caveat+Brush&family=Source+Code+Pro&family=Source+Sans+Pro&family=Crimson+Pro&display=block');
@font-face {
font-family: 'Recursive';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(https://fonts.gstatic.com/s/recursive/v23/8vI-7wMr0mhh-RQChyHEH06TlXhq_gukbYrFMk1QuAIcyEwG_X-dpEfaE5YaERmK-CImKsvxvU-MXGX2fSqasNfUlTGZnI14ZeY.woff2)
format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Recursive';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(https://fonts.gstatic.com/s/recursive/v23/8vI-7wMr0mhh-RQChyHEH06TlXhq_gukbYrFMk1QuAIcyEwG_X-dpEfaE5YaERmK-CImKsvxvU-MXGX2fSqasNfUlTGZnI14ZeY.woff2)
format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Recursive Mono';
font-style: normal;
font-weight: 420;
font-display: swap;
src: url(https://fonts.gstatic.com/s/recursive/v23/8vI-7wMr0mhh-RQChyHEH06TlXhq_gukbYrFMk1QuAIcyEwG_X-dpEfaE5YaERmK-CImqvTxvU-MXGX2fSqasNfUlTGZnI14ZeY.woff2)
format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
`
export function useStylesheet() { export function useStylesheet() {
React.useLayoutEffect(() => { React.useLayoutEffect(() => {

View file

@ -21,6 +21,7 @@ const { styled, createTheme } = createStitches({
tooltip: '#1d1d1d', tooltip: '#1d1d1d',
tooltipContrast: '#ffffff', tooltipContrast: '#ffffff',
warn: 'rgba(255, 100, 100, 1)', warn: 'rgba(255, 100, 100, 1)',
canvas: 'rgb(248, 249, 250)',
}, },
shadows: { shadows: {
2: '0px 1px 1px rgba(0, 0, 0, 0.14)', 2: '0px 1px 1px rgba(0, 0, 0, 0.14)',
@ -110,6 +111,7 @@ export const dark = createTheme({
text: '#f8f9fa', text: '#f8f9fa',
tooltip: '#1d1d1d', tooltip: '#1d1d1d',
tooltipContrast: '#ffffff', tooltipContrast: '#ffffff',
canvas: '#212529',
}, },
shadows: { shadows: {
2: '0px 1px 1px rgba(0, 0, 0, 0.24)', 2: '0px 1px 1px rgba(0, 0, 0, 0.24)',

View file

@ -9385,6 +9385,13 @@ rc@^1.2.7, rc@^1.2.8:
object-assign "^4.1.1" object-assign "^4.1.1"
scheduler "^0.20.2" scheduler "^0.20.2"
react-error-boundary@^3.1.4:
version "3.1.4"
resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.4.tgz#255db92b23197108757a888b01e5b729919abde0"
integrity sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==
dependencies:
"@babel/runtime" "^7.12.5"
react-feather@^2.0.9: react-feather@^2.0.9:
version "2.0.9" version "2.0.9"
resolved "https://registry.yarnpkg.com/react-feather/-/react-feather-2.0.9.tgz#6e42072130d2fa9a09d4476b0e61b0ed17814480" resolved "https://registry.yarnpkg.com/react-feather/-/react-feather-2.0.9.tgz#6e42072130d2fa9a09d4476b0e61b0ed17814480"