From 64a16373c59fb9c0aa148e48c682b5cc5e441f69 Mon Sep 17 00:00:00 2001 From: SatsAllDay <128755788+SatsAllDay@users.noreply.github.com> Date: Sun, 19 Nov 2023 15:24:56 -0500 Subject: [PATCH] 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 --- components/block-height.js | 6 ++--- components/error-boundary.js | 48 ++++++++++++++++++++++++++++++++---- components/logger.js | 2 +- components/me.js | 6 ++--- components/price.js | 6 ++--- components/serviceworker.js | 12 +++++++-- components/toast.js | 2 +- pages/_app.js | 4 ++- 8 files changed, 67 insertions(+), 19 deletions(-) diff --git a/components/block-height.js b/components/block-height.js index 6007ac98..1b347b2a 100644 --- a/components/block-height.js +++ b/components/block-height.js @@ -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 ( {children} diff --git a/components/error-boundary.js b/components/error-boundary.js index 75e19727..26b8e966 100644 --- a/components/error-boundary.js +++ b/components/error-boundary.js @@ -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 ( - +

something went wrong

+ {this.state.error && }
) } // 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 +} diff --git a/components/logger.js b/components/logger.js index 1c86962a..22a09f69 100644 --- a/components/logger.js +++ b/components/logger.js @@ -36,7 +36,7 @@ function detectOS () { return os } -const LoggerContext = createContext() +export const LoggerContext = createContext() export function LoggerProvider ({ children }) { const me = useMe() diff --git a/components/me.js b/components/me.js index 4d3e3801..0db92c86 100644 --- a/components/me.js +++ b/components/me.js @@ -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 ( diff --git a/components/price.js b/components/price.js index 1d527a82..bf8a3d79 100644 --- a/components/price.js +++ b/components/price.js @@ -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 ( diff --git a/components/serviceworker.js b/components/serviceworker.js index 88c439b0..43815508 100644 --- a/components/serviceworker.js +++ b/components/serviceworker.js @@ -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 ( - + {children} ) diff --git a/components/toast.js b/components/toast.js index 51c4fb58..8a638712 100644 --- a/components/toast.js +++ b/components/toast.js @@ -46,7 +46,7 @@ export const ToastProvider = ({ children }) => { removeToast: () => removeToast(id) } } - }), [dispatchToast]) + }), [dispatchToast, removeToast]) // Clear all toasts on page navigation useEffect(() => { diff --git a/pages/_app.js b/pages/_app.js index e41b9c18..81b61457 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -96,7 +96,9 @@ export default function MyApp ({ Component, pageProps: { ...props } }) { - + + +