Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 6 additions & 7 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/** @type {import('jest').Config} */
// eslint-disable-next-line import/no-anonymous-default-export
export default {
const config = {
extensionsToTreatAsEsm: ['.ts', '.tsx', '.jsx'],
testMatch: [
'**/__tests__/**/*.[jt]s?(x)',
'**/?(*.)+(spec|test).[jt]s?(x)'
Expand Down Expand Up @@ -28,8 +28,7 @@ export default {
'uint8arrays/from-string': '<rootDir>/node_modules/uint8arrays/dist/src/from-string.js',
'uint8arrays/to-string': '<rootDir>/node_modules/uint8arrays/dist/src/to-string.js',
'@chainsafe/is-ip/parse': '<rootDir>/node_modules/@chainsafe/is-ip/lib/parse.js',
// eslint-disable-next-line quote-props
'eventemitter3': '<rootDir>/node_modules/eventemitter3/dist/eventemitter3.esm.js',
eventemitter3: '<rootDir>/node_modules/eventemitter3/dist/eventemitter3.esm.js',
'cheerio/lib/utils': '<rootDir>/node_modules/cheerio/dist/commonjs/utils.js',
'@ipld/dag-pb': '<rootDir>/node_modules/@ipld/dag-pb/src/index.js',
'@multiformats/multiaddr': '<rootDir>/node_modules/@multiformats/multiaddr/dist/src/index.js',
Expand All @@ -40,11 +39,11 @@ export default {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy'
},
transform: {
'^.+\\.[tj]sx?$': 'babel-jest'
'^.+\\.[tj]sx?$': ['babel-jest', { presets: [['@babel/preset-env', { targets: { node: 'current' } }]] }]
},
transformIgnorePatterns: [
'node_module/(?!(eventemitter3)/).+\\.(js|jsx|mjs|cjs|ts|tsx)$',
'^.+\\.module\\.(css|sass|scss)$' // default
]

}

export default config
4 changes: 2 additions & 2 deletions src/components/box/Box.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react'
import ErrorBoundary from '../error/ErrorBoundary.js'
import ErrorBoundary from '../error/error-boundary.js'

export const Box = ({
className = 'pa4',
Expand All @@ -9,7 +9,7 @@ export const Box = ({
}) => {
return (
<section className={className} style={{ background: '#fbfbfb', ...style }}>
<ErrorBoundary>
<ErrorBoundary resetKeys={[global.location.pathname]}>
{children}
</ErrorBoundary>
</section>
Expand Down
2 changes: 1 addition & 1 deletion src/components/cid/Cid.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react'
import { Identicon } from '../identicon/Identicon.js'
import ErrorBoundary from '../error/ErrorBoundary.js'
import ErrorBoundary from '../error/error-boundary.jsx'

export function cidStartAndEnd (value) {
const chars = value.toString().split('')
Expand Down
29 changes: 0 additions & 29 deletions src/components/error/ErrorBoundary.js

This file was deleted.

184 changes: 184 additions & 0 deletions src/components/error/error-boundary.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import ErrorBoundary from './error-boundary'

// Helpers
const Ok: React.FC = () => <div data-testid="ok">ok</div>

const Thrower: React.FC<{ msg?: string }> = ({ msg = 'boom' }) => {
throw new Error(msg)
}

const CustomFallback: React.FC<{ error?: Error; componentStack?: string }> = ({ error, componentStack }) => (
<div role="alert">
<span data-testid="cf-msg">{error?.message}</span>
<span data-testid="cf-stack">{componentStack ?? ''}</span>
</div>
)

describe('ErrorBoundary', () => {
let consoleErrorSpy: jest.SpyInstance

beforeEach(() => {
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
})

afterEach(() => {
consoleErrorSpy.mockRestore()
})

it('renders children when no error occurs', () => {
render(
<ErrorBoundary>
<Ok />
</ErrorBoundary>
)
expect(screen.getByTestId('ok')).toBeInTheDocument()
})

it('renders default fallback UI when a child component throws an error', () => {
render(
<ErrorBoundary>
<Thrower msg="kapow" />
</ErrorBoundary>
)

const alert = screen.getByRole('alert')
expect(alert).toBeInTheDocument()
expect(alert).toHaveTextContent('kapow')
expect(consoleErrorSpy).toHaveBeenCalled()
})

it('calls onError callback with error and component stack info', () => {
const onError = jest.fn()
render(
<ErrorBoundary onError={onError}>
<Thrower />
</ErrorBoundary>
)
expect(onError).toHaveBeenCalledTimes(1)
const [err, info] = onError.mock.calls[0]
expect((err as Error).message).toBe('boom')
expect(info && typeof (info as any).componentStack).toBe('string')
})

it('auto-resets error state when resetKeys prop changes', () => {
const { rerender } = render(
<ErrorBoundary resetKeys={[0]}>
<Thrower />
</ErrorBoundary>
)
expect(screen.getByRole('alert')).toBeInTheDocument()

rerender(
<ErrorBoundary resetKeys={[1]}>
<Ok />
</ErrorBoundary>
)
expect(screen.getByTestId('ok')).toBeInTheDocument()
})

it('does not reset error state when resetKeys prop remains the same', () => {
const { rerender } = render(
<ErrorBoundary resetKeys={[1, 2, 3]}>
<Thrower />
</ErrorBoundary>
)
expect(screen.getByRole('alert')).toBeInTheDocument()

rerender(
<ErrorBoundary resetKeys={[1, 2, 3]}>
<Ok />
</ErrorBoundary>
)
expect(screen.getByRole('alert')).toBeInTheDocument()
})

it('renders custom fallback component with error and component stack', () => {
render(
<ErrorBoundary fallback={CustomFallback}>
<Thrower msg="custom-msg" />
</ErrorBoundary>
)
expect(screen.getByRole('alert')).toBeInTheDocument()
expect(screen.getByTestId('cf-msg')).toHaveTextContent('custom-msg')
expect(screen.getByTestId('cf-stack')).toBeInTheDocument()
})

it('renders default fallback when fallback prop is undefined', () => {
render(
<ErrorBoundary fallback={undefined}>
<Thrower msg="undefined-fallback" />
</ErrorBoundary>
)
const alert = screen.getByRole('alert')
expect(alert).toBeInTheDocument()
expect(alert).toHaveTextContent('undefined-fallback')
})

it('renders nothing when fallback component returns null', () => {
const NullFallback = () => null
render(
<ErrorBoundary fallback={NullFallback}>
<Thrower msg="null-fallback" />
</ErrorBoundary>
)
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})

it('handles error with undefined error message gracefully', () => {
const ThrowerUndefined: React.FC = () => {
const error = new Error()
error.message = undefined as any
throw error
}

render(
<ErrorBoundary>
<ThrowerUndefined />
</ErrorBoundary>
)
const alert = screen.getByRole('alert')
expect(alert).toBeInTheDocument()
expect(alert.querySelector('pre')).toBeInTheDocument()
})

it('renders error message with component stack when available', () => {
const original = ErrorBoundary.prototype.componentDidCatch
ErrorBoundary.prototype.componentDidCatch = function (error, info) {
console.error(`${error.message} - ${info.componentStack}`)
this.setState({ componentStack: 'at <Thrower />' })
this.props.onError?.(error, info)
}

render(
<ErrorBoundary>
<Thrower msg="stacked" />
</ErrorBoundary>
)

const pre = screen.getByRole('alert').querySelector('pre')!
expect(pre).toHaveTextContent('stacked')
expect(pre).toHaveTextContent(' - at <Thrower />')

ErrorBoundary.prototype.componentDidCatch = original
})

it('resets error state when resetKeys arrays have different lengths', () => {
const { rerender } = render(
<ErrorBoundary resetKeys={[1, 2, 3]}>
<Thrower />
</ErrorBoundary>
)
expect(screen.getByRole('alert')).toBeInTheDocument()

// Different length arrays should reset
rerender(
<ErrorBoundary resetKeys={[1, 2]}>
<Ok />
</ErrorBoundary>
)
expect(screen.getByTestId('ok')).toBeInTheDocument()
})
})
76 changes: 76 additions & 0 deletions src/components/error/error-boundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import React from 'react'
import ErrorIcon from '../../icons/GlyphSmallCancel'

interface ErrorBoundaryProps {
/**
* Component that receives { error } (optional).
*/
fallback?: React.ComponentType<{ error?: Error }>
/**
* When these values change, the boundary resets.
*/
resetKeys?: ReadonlyArray<unknown>
/**
* Called when an error is caught
*/
onError?: (error: Error, info: React.ErrorInfo) => void
}

interface ErrorBoundaryState {
error?: Error
componentStack?: string
}

const FallbackComponent: React.FC<{ error?: Error; componentStack?: string }> = ({ error, componentStack }) => (
<div role='alert' className='pa3 br2 ba b--red bg-washed-red flex items-center'>
<span className='mr2'>
<ErrorIcon className='h2 w2' aria-hidden />
</span>

<pre
className='ma0 f6 code lh-copy overflow-auto'
style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
title={error?.message}
>
{error?.message ?? ''}
{componentStack ? ` - ${componentStack}` : ''}
</pre>
</div>
)

function shallowEq (a?: ReadonlyArray<unknown>, b?: ReadonlyArray<unknown>) {
if (a === b) return true
if (!a || !b || a.length !== b.length) return false
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false
return true
}

class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { error: undefined }

static getDerivedStateFromError (error: Error): Partial<ErrorBoundaryState> {
return { error }
}

componentDidCatch (error: Error, info: React.ErrorInfo) {
console.error(`${error.message} - ${info.componentStack}`)
// only use the first line of the component stack for rendering the fallback
this.setState({ componentStack: info.componentStack.split('\n')[0].trim() })
this.props.onError?.(error, info)
}

componentDidUpdate (prevProps: ErrorBoundaryProps) {
// Auto-reset when resetKeys change and we’re currently showing a fallback
if (this.state.error && !shallowEq(prevProps.resetKeys, this.props.resetKeys)) {
this.setState({ error: undefined })
}
}

render () {
const { error, componentStack } = this.state
const { children, fallback: Fallback = FallbackComponent } = this.props
return error != null ? <Fallback error={error} componentStack={componentStack} /> : (children as React.ReactElement | null)
}
}

export default ErrorBoundary