Fix client-side error boundary (#623)

* * add error boundary immediately above page component

* send error logs to server via LoggerContext

* display error stack and component stack in copyable text area

* wrap every context provider in an error boundary

* memoize context values to fix error boundary propagation issue

* Wrap page component in an error boundary so we can use our context utilities
for a better experience, like toast and logger

Still have a top-level error boundary that catches things from the context providers,
just in case.

Don't display the whole error stack, just because it's ugly, but make it copyable
This commit is contained in:
SatsAllDay 2023-11-19 15:24:56 -05:00 committed by GitHub
parent 158baa61e3
commit 64a16373c5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 67 additions and 19 deletions

View File

@ -1,4 +1,4 @@
import { createContext, useContext } from 'react'
import { createContext, useContext, useMemo } from 'react'
import { useQuery } from '@apollo/client'
import { SSR } from '../lib/constants'
import { BLOCK_HEIGHT } from '../fragments/blockHeight'
@ -18,9 +18,9 @@ export const BlockHeightProvider = ({ blockHeight, children }) => {
nextFetchPolicy: 'cache-and-network'
})
})
const value = {
const value = useMemo(() => ({
height: data?.blockHeight ?? blockHeight ?? 0
}
}), [data, blockHeight])
return (
<BlockHeightContext.Provider value={value}>
{children}

View File

@ -2,41 +2,79 @@ import { Component } from 'react'
import { StaticLayout } from './layout'
import styles from '../styles/error.module.css'
import Image from 'react-bootstrap/Image'
import copy from 'clipboard-copy'
import { LoggerContext } from './logger'
import Button from 'react-bootstrap/Button'
import { useToast } from './toast'
class ErrorBoundary extends Component {
constructor (props) {
super(props)
// Define a state variable to track whether is an error or not
this.state = { hasError: false }
this.state = {
hasError: false,
error: undefined,
errorInfo: undefined
}
}
static getDerivedStateFromError () {
static getDerivedStateFromError (error) {
// Update state so the next render will show the fallback UI
return { hasError: true }
return { hasError: true, error }
}
getErrorDetails () {
let details = this.state.error.stack
if (this.state.errorInfo?.componentStack) {
details += `\n\nComponent stack:${this.state.errorInfo.componentStack}`
}
return details
}
componentDidCatch (error, errorInfo) {
// You can use your own error logging service here
console.log({ error, errorInfo })
this.setState({ errorInfo })
const logger = this.context
logger?.error(this.getErrorDetails())
}
render () {
// Check if the error is thrown
if (this.state.hasError) {
// You can render any custom fallback UI
const errorDetails = this.getErrorDetails()
return (
<StaticLayout>
<StaticLayout footer={false}>
<Image width='500' height='375' className='rounded-1 shadow-sm' src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/floating.gif`} fluid />
<h1 className={styles.status} style={{ fontSize: '48px' }}>something went wrong</h1>
{this.state.error && <CopyErrorButton errorDetails={errorDetails} />}
</StaticLayout>
)
}
// Return children components in case of no error
return this.props.children
}
}
ErrorBoundary.contextType = LoggerContext
export default ErrorBoundary
// This button is a functional component so we can use `useToast` hook, which
// can't be easily done in a class component that already consumes a context
const CopyErrorButton = ({ errorDetails }) => {
const toaster = useToast()
const onClick = async () => {
try {
await copy(errorDetails)
toaster?.success?.('copied')
} catch (err) {
console.error(err)
toaster?.danger?.('failed to copy')
}
}
return <Button className='mt-3' onClick={onClick}>copy error information</Button>
}

View File

@ -36,7 +36,7 @@ function detectOS () {
return os
}
const LoggerContext = createContext()
export const LoggerContext = createContext()
export function LoggerProvider ({ children }) {
const me = useMe()

View File

@ -1,4 +1,4 @@
import React, { useContext } from 'react'
import React, { useContext, useMemo } from 'react'
import { useQuery } from '@apollo/client'
import { ME } from '../fragments/users'
import { SSR } from '../lib/constants'
@ -11,11 +11,11 @@ export function MeProvider ({ me, children }) {
const { data } = useQuery(ME, SSR ? {} : { pollInterval: 1000, nextFetchPolicy: 'cache-and-network' })
const futureMe = data?.me || me
const contextValue = {
const contextValue = useMemo(() => ({
me: futureMe
? { ...futureMe, ...futureMe.privates, ...futureMe.optional }
: null
}
}), [me, data])
return (
<MeContext.Provider value={contextValue}>

View File

@ -1,4 +1,4 @@
import React, { useContext, useEffect, useState } from 'react'
import React, { useContext, useEffect, useMemo, useState } from 'react'
import { useQuery } from '@apollo/client'
import { fixedDecimal } from '../lib/format'
import { useMe } from './me'
@ -29,10 +29,10 @@ export function PriceProvider ({ price, children }) {
})
})
const contextValue = {
const contextValue = useMemo(() => ({
price: data?.price || price,
fiatSymbol: CURRENCY_SYMBOLS[fiatCurrency] || '$'
}
}), [data?.price, price, me?.fiatCurrency])
return (
<PriceContext.Provider value={contextValue}>

View File

@ -1,4 +1,4 @@
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
import { createContext, useContext, useEffect, useState, useCallback, useMemo } from 'react'
import { Workbox } from 'workbox-window'
import { gql, useMutation } from '@apollo/client'
import { useLogger } from './logger'
@ -140,8 +140,16 @@ export const ServiceWorkerProvider = ({ children }) => {
logger.info('sent SYNC_SUBSCRIPTION to service worker')
}, [registration])
const contextValue = useMemo(() => ({
registration,
support,
permission,
requestNotificationPermission,
togglePushSubscription
}), [registration, support, permission, requestNotificationPermission, togglePushSubscription])
return (
<ServiceWorkerContext.Provider value={{ registration, support, permission, requestNotificationPermission, togglePushSubscription }}>
<ServiceWorkerContext.Provider value={contextValue}>
{children}
</ServiceWorkerContext.Provider>
)

View File

@ -46,7 +46,7 @@ export const ToastProvider = ({ children }) => {
removeToast: () => removeToast(id)
}
}
}), [dispatchToast])
}), [dispatchToast, removeToast])
// Clear all toasts on page navigation
useEffect(() => {

View File

@ -96,7 +96,9 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
<ToastProvider>
<ShowModalProvider>
<BlockHeightProvider blockHeight={blockHeight}>
<Component ssrData={ssrData} {...otherProps} />
<ErrorBoundary>
<Component ssrData={ssrData} {...otherProps} />
</ErrorBoundary>
</BlockHeightProvider>
</ShowModalProvider>
</ToastProvider>