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:
parent
158baa61e3
commit
64a16373c5
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
|
|
@ -36,7 +36,7 @@ function detectOS () {
|
|||
return os
|
||||
}
|
||||
|
||||
const LoggerContext = createContext()
|
||||
export const LoggerContext = createContext()
|
||||
|
||||
export function LoggerProvider ({ children }) {
|
||||
const me = useMe()
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -46,7 +46,7 @@ export const ToastProvider = ({ children }) => {
|
|||
removeToast: () => removeToast(id)
|
||||
}
|
||||
}
|
||||
}), [dispatchToast])
|
||||
}), [dispatchToast, removeToast])
|
||||
|
||||
// Clear all toasts on page navigation
|
||||
useEffect(() => {
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in New Issue