[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"
},
"devDependencies": {
"@tldraw/intersect": "*",
"@tldraw/vec": "*",
"@swc-node/jest": "^1.4.3",
"@testing-library/jest-dom": "^5.16.2",
"@testing-library/react": "^12.1.2",
"@tldraw/intersect": "*",
"@tldraw/vec": "*",
"@types/node": "^17.0.14",
"@types/react": "^17.0.38",
"@typescript-eslint/eslint-plugin": "^5.10.2",

View file

@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { observer } from 'mobx-react-lite'
import * as React from 'react'
import { observer } from 'mobx-react-lite'
import {
usePreventNavigationCss,
useZoomEvents,
@ -136,5 +136,3 @@ export const Canvas = observer(function _Canvas<
</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) {
const id = selectedIds[0]
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
isLocked = shape.isLocked || false
const utils = getShapeUtils(shapeUtils, shape)
bounds = utils.hideBounds ? undefined : utils.getBounds(shape)
} else if (selectedIds.length > 1) {
const selectedShapes = selectedIds.map((id) => page.shapes[id])
rotation = 0
isLocked = selectedShapes.every((shape) => shape.isLocked)
bounds = selectedShapes.reduce((acc, shape, i) => {
if (i === 0) {
return getShapeUtils(shapeUtils, shape).getRotatedBounds(shape)
@ -48,9 +58,11 @@ export function useSelection<T extends TLShape>(
if (bounds) {
const [minX, minY] = canvasToScreen([bounds.minX, bounds.minY], pageState.camera)
const [maxX, maxY] = canvasToScreen([bounds.maxX, bounds.maxY], pageState.camera)
isLinked = !!Object.values(page.bindings).find(
(binding) => selectedIds.includes(binding.toId) || selectedIds.includes(binding.fromId)
)
rSelectionBounds.current = {
minX,
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),
// add the parent to the sets of shapes to render. The parent's
// children will all be rendered.
shapesIdsToRender.add(shape.parentId)
shapesToRender.add(page.shapes[shape.parentId])
const parent = 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
@ -163,7 +169,11 @@ export function useShapeTree<T extends TLShape, M extends Record<string, unknown
const tree: IShapeTreeNode<T, M>[] = []
shapesToRender.forEach((shape) =>
shapesToRender.forEach((shape) => {
if (shape === undefined) {
throw Error('Rendered shapes included a missing shape')
}
addToShapeTree(
shape,
tree,
@ -174,7 +184,7 @@ export function useShapeTree<T extends TLShape, M extends Record<string, unknown
false,
meta
)
)
})
tree.sort((a, b) => a.shape.childIndex - b.shape.childIndex)

View file

@ -79,36 +79,6 @@ const defaultTheme: TLTheme = {
}
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-zoom: 1;
--tl-scale: calc(1 / var(--tl-zoom));
@ -351,11 +321,6 @@ export const TLCSS = css`
.tl-brush.dashed {
stroke: none;
}
.tl-dot {
fill: var(--tl-background);
stroke: var(--tl-foreground);
stroke-width: 2px;
}
.tl-handle {
pointer-events: all;
cursor: grab;

View file

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

View file

@ -12,6 +12,8 @@ import { FocusButton } from '~components/FocusButton'
import { TLDR } from '~state/TLDR'
import { GRID_SIZE } from '~constants'
import { Loading } from '~components/Loading'
import { ErrorBoundary } from 'react-error-boundary'
import { ErrorFallback } from '~components/ErrorFallback'
export interface TldrawProps extends TDCallbacks {
/**
@ -415,81 +417,83 @@ const InnerTldraw = React.memo(function InnerTldraw({
<Loading />
<OneOff focusableRef={rWrapper} autofocus={autofocus} />
<ContextMenu>
<Renderer
id={id}
containerRef={rWrapper}
shapeUtils={shapeUtils}
page={page}
pageState={pageState}
assets={assets}
snapLines={appState.snapLines}
grid={GRID_SIZE}
users={room?.users}
userId={room?.userId}
theme={theme}
meta={meta}
hideBounds={hideBounds}
hideHandles={hideHandles}
hideResizeHandles={isHideResizeHandlesShape}
hideIndicators={hideIndicators}
hideBindingHandles={!settings.showBindingHandles}
hideCloneHandles={hideCloneHandles}
hideRotateHandles={!settings.showRotateHandles}
hideGrid={!settings.showGrid}
showDashedBrush={showDashedBrush}
performanceMode={app.session?.performanceMode}
onPinchStart={app.onPinchStart}
onPinchEnd={app.onPinchEnd}
onPinch={app.onPinch}
onPan={app.onPan}
onZoom={app.onZoom}
onPointerDown={app.onPointerDown}
onPointerMove={app.onPointerMove}
onPointerUp={app.onPointerUp}
onPointCanvas={app.onPointCanvas}
onDoubleClickCanvas={app.onDoubleClickCanvas}
onRightPointCanvas={app.onRightPointCanvas}
onDragCanvas={app.onDragCanvas}
onReleaseCanvas={app.onReleaseCanvas}
onPointShape={app.onPointShape}
onDoubleClickShape={app.onDoubleClickShape}
onRightPointShape={app.onRightPointShape}
onDragShape={app.onDragShape}
onHoverShape={app.onHoverShape}
onUnhoverShape={app.onUnhoverShape}
onReleaseShape={app.onReleaseShape}
onPointBounds={app.onPointBounds}
onDoubleClickBounds={app.onDoubleClickBounds}
onRightPointBounds={app.onRightPointBounds}
onDragBounds={app.onDragBounds}
onHoverBounds={app.onHoverBounds}
onUnhoverBounds={app.onUnhoverBounds}
onReleaseBounds={app.onReleaseBounds}
onPointBoundsHandle={app.onPointBoundsHandle}
onDoubleClickBoundsHandle={app.onDoubleClickBoundsHandle}
onRightPointBoundsHandle={app.onRightPointBoundsHandle}
onDragBoundsHandle={app.onDragBoundsHandle}
onHoverBoundsHandle={app.onHoverBoundsHandle}
onUnhoverBoundsHandle={app.onUnhoverBoundsHandle}
onReleaseBoundsHandle={app.onReleaseBoundsHandle}
onPointHandle={app.onPointHandle}
onDoubleClickHandle={app.onDoubleClickHandle}
onRightPointHandle={app.onRightPointHandle}
onDragHandle={app.onDragHandle}
onHoverHandle={app.onHoverHandle}
onUnhoverHandle={app.onUnhoverHandle}
onReleaseHandle={app.onReleaseHandle}
onError={app.onError}
onRenderCountChange={app.onRenderCountChange}
onShapeChange={app.onShapeChange}
onShapeBlur={app.onShapeBlur}
onShapeClone={app.onShapeClone}
onBoundsChange={app.updateBounds}
onKeyDown={app.onKeyDown}
onKeyUp={app.onKeyUp}
onDragOver={app.onDragOver}
onDrop={app.onDrop}
/>
<ErrorBoundary FallbackComponent={ErrorFallback}>
<Renderer
id={id}
containerRef={rWrapper}
shapeUtils={shapeUtils}
page={page}
pageState={pageState}
assets={assets}
snapLines={appState.snapLines}
grid={GRID_SIZE}
users={room?.users}
userId={room?.userId}
theme={theme}
meta={meta}
hideBounds={hideBounds}
hideHandles={hideHandles}
hideResizeHandles={isHideResizeHandlesShape}
hideIndicators={hideIndicators}
hideBindingHandles={!settings.showBindingHandles}
hideCloneHandles={hideCloneHandles}
hideRotateHandles={!settings.showRotateHandles}
hideGrid={!settings.showGrid}
showDashedBrush={showDashedBrush}
performanceMode={app.session?.performanceMode}
onPinchStart={app.onPinchStart}
onPinchEnd={app.onPinchEnd}
onPinch={app.onPinch}
onPan={app.onPan}
onZoom={app.onZoom}
onPointerDown={app.onPointerDown}
onPointerMove={app.onPointerMove}
onPointerUp={app.onPointerUp}
onPointCanvas={app.onPointCanvas}
onDoubleClickCanvas={app.onDoubleClickCanvas}
onRightPointCanvas={app.onRightPointCanvas}
onDragCanvas={app.onDragCanvas}
onReleaseCanvas={app.onReleaseCanvas}
onPointShape={app.onPointShape}
onDoubleClickShape={app.onDoubleClickShape}
onRightPointShape={app.onRightPointShape}
onDragShape={app.onDragShape}
onHoverShape={app.onHoverShape}
onUnhoverShape={app.onUnhoverShape}
onReleaseShape={app.onReleaseShape}
onPointBounds={app.onPointBounds}
onDoubleClickBounds={app.onDoubleClickBounds}
onRightPointBounds={app.onRightPointBounds}
onDragBounds={app.onDragBounds}
onHoverBounds={app.onHoverBounds}
onUnhoverBounds={app.onUnhoverBounds}
onReleaseBounds={app.onReleaseBounds}
onPointBoundsHandle={app.onPointBoundsHandle}
onDoubleClickBoundsHandle={app.onDoubleClickBoundsHandle}
onRightPointBoundsHandle={app.onRightPointBoundsHandle}
onDragBoundsHandle={app.onDragBoundsHandle}
onHoverBoundsHandle={app.onHoverBoundsHandle}
onUnhoverBoundsHandle={app.onUnhoverBoundsHandle}
onReleaseBoundsHandle={app.onReleaseBoundsHandle}
onPointHandle={app.onPointHandle}
onDoubleClickHandle={app.onDoubleClickHandle}
onRightPointHandle={app.onRightPointHandle}
onDragHandle={app.onDragHandle}
onHoverHandle={app.onHoverHandle}
onUnhoverHandle={app.onUnhoverHandle}
onReleaseHandle={app.onReleaseHandle}
onError={app.onError}
onRenderCountChange={app.onRenderCountChange}
onShapeChange={app.onShapeChange}
onShapeBlur={app.onShapeBlur}
onShapeClone={app.onShapeClone}
onBoundsChange={app.updateBounds}
onKeyDown={app.onKeyDown}
onKeyUp={app.onKeyUp}
onDragOver={app.onDragOver}
onDrop={app.onDrop}
/>
</ErrorBoundary>
</ContextMenu>
{showUI && (
<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 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 = `@import url('${WEBFONT_URL}');`
const CSS = `
@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() {
React.useLayoutEffect(() => {

View file

@ -21,6 +21,7 @@ const { styled, createTheme } = createStitches({
tooltip: '#1d1d1d',
tooltipContrast: '#ffffff',
warn: 'rgba(255, 100, 100, 1)',
canvas: 'rgb(248, 249, 250)',
},
shadows: {
2: '0px 1px 1px rgba(0, 0, 0, 0.14)',
@ -110,6 +111,7 @@ export const dark = createTheme({
text: '#f8f9fa',
tooltip: '#1d1d1d',
tooltipContrast: '#ffffff',
canvas: '#212529',
},
shadows: {
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"
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:
version "2.0.9"
resolved "https://registry.yarnpkg.com/react-feather/-/react-feather-2.0.9.tgz#6e42072130d2fa9a09d4476b0e61b0ed17814480"