Skip to content

Commit

Permalink
feat(react): add shouldCatch prop on ErrorBoundary (#569)
Browse files Browse the repository at this point in the history
fix #567 
fix #564
fix #450 

Specially thanks to @sungh0lim @tooooo1

# new prop of ErrorBoundary: `shoudCatch`

<!--
    A clear and concise description of what this pr is about.
 -->


![image](https://github.com/suspensive/react/assets/61593290/ef9cf54a-da73-4dfb-aa5a-d8191c1c7d2d)


```tsx
import { isAxios } from 'axios'

class CustomError extends Error {
  // ...
}

const Example = () => {
  const isTrueRandomly = Math.random() > 0.5

  return (
    <>
      // We can catch them by shouldCatch
      <ErrorBoundary shouldCatch={CustomError}>
        <Child />
      </ErrorBoundary>
      <ErrorBoundary shouldCatch={isAxiosError}>
        <Child />
      </ErrorBoundary>
      <ErrorBoundary shouldCatch={isTrueRandomly}>
        <Child />
      </ErrorBoundary>
      <ErrorBoundary shouldCatch={[CustomError, isAxiosError, isTrueRandomly]}>
        <Child />
      </ErrorBoundary>

      // We can ignore them by reversing shouldCatch
      <ErrorBoundary shouldCatch={(error) => !(error instanceof CustomError)}>
        <Child />
      </ErrorBoundary>
      <ErrorBoundary shouldCatch={(error) => !isAxiosError(error)}>
        <Child />
      </ErrorBoundary>
      <ErrorBoundary shouldCatch={!isTrueRandomly}>
        <Child />
      </ErrorBoundary>
      <ErrorBoundary shouldCatch={[(error) => !(error instanceof CustomError), (error) => isAxiosError(error), !isTrueRandomly]}>
        <Child />
      </ErrorBoundary>
    </>
  )
}
```

## PR Checklist

- [x] I did below actions if need

1. I read the [Contributing
Guide](https://github.com/suspensive/react/blob/main/CONTRIBUTING.md)
2. I added documents and tests.

---------

Co-authored-by: Minsoo Kim <[email protected]>
Co-authored-by: Sungho Lim <[email protected]>
Co-authored-by: Chungil Jung <[email protected]>
  • Loading branch information
4 people authored Jan 15, 2024
1 parent 97faf61 commit caed129
Show file tree
Hide file tree
Showing 14 changed files with 306 additions and 55 deletions.
5 changes: 5 additions & 0 deletions .changeset/perfect-kangaroos-brake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@suspensive/react': minor
---

feat(react): add `shouldCatch` prop of ErrorBoundary
6 changes: 6 additions & 0 deletions configs/test-utils/src/CustomError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export class CustomError extends Error {
constructor(...args: ConstructorParameters<ErrorConstructor>) {
super(...args)
console.error(...args)
}
}
5 changes: 5 additions & 0 deletions configs/test-utils/src/CustomNotError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export class CustomNotError {
constructor(public message?: string) {
console.log(message)
}
}
2 changes: 2 additions & 0 deletions configs/test-utils/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export { CustomError } from './CustomError'
export { CustomNotError } from './CustomNotError'
export { Suspend } from './Suspend'
export { ThrowError, ThrowNull } from './ThrowError'
export { sleep } from './sleep'
Expand Down
94 changes: 93 additions & 1 deletion packages/react/src/ErrorBoundary.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ERROR_MESSAGE, FALLBACK, TEXT, ThrowError, ThrowNull } from '@suspensive/test-utils'
import { CustomError, ERROR_MESSAGE, FALLBACK, TEXT, ThrowError, ThrowNull } from '@suspensive/test-utils'
import { act, render, screen, waitFor } from '@testing-library/react'
import ms from 'ms'
import { type ComponentRef, createElement, createRef } from 'react'
Expand Down Expand Up @@ -167,6 +167,98 @@ describe('<ErrorBoundary/>', () => {
expect(screen.queryByText(ERROR_MESSAGE)).not.toBeInTheDocument()
expect(onReset).toHaveBeenCalledTimes(1)
})

it('should catch Error by many criteria', async () => {
const onErrorParent = vi.fn()
const onErrorChild = vi.fn()

render(
<ErrorBoundary fallback={({ error }) => <>{error.message} of Parent</>} onError={onErrorParent}>
<ErrorBoundary
shouldCatch={[CustomError, (error) => error instanceof CustomError]}
fallback={({ error }) => <>{error.message} of Child</>}
onError={onErrorChild}
>
{createElement(() => {
throw new Error(ERROR_MESSAGE)
})}
</ErrorBoundary>
</ErrorBoundary>
)

expect(onErrorChild).toBeCalledTimes(0)
expect(onErrorParent).toBeCalledTimes(1)
await waitFor(() => expect(screen.queryByText(`${ERROR_MESSAGE} of Parent`)).toBeInTheDocument())
})

it('should catch Error by one criteria(ErrorConstructor)', async () => {
const onErrorParent = vi.fn()
const onErrorChild = vi.fn()

render(
<ErrorBoundary fallback={({ error }) => <>{error.message} of Parent</>} onError={onErrorParent}>
<ErrorBoundary
shouldCatch={CustomError}
fallback={({ error }) => <>{error.message} of Child</>}
onError={onErrorChild}
>
{createElement(() => {
throw new Error(ERROR_MESSAGE)
})}
</ErrorBoundary>
</ErrorBoundary>
)

expect(onErrorChild).toBeCalledTimes(0)
expect(onErrorParent).toBeCalledTimes(1)
await waitFor(() => expect(screen.queryByText(`${ERROR_MESSAGE} of Parent`)).toBeInTheDocument())
})

it('should catch Error by one criteria(ShouldCatchCallback)', async () => {
const onErrorParent = vi.fn()
const onErrorChild = vi.fn()

render(
<ErrorBoundary fallback={({ error }) => <>{error.message} of Parent</>} onError={onErrorParent}>
<ErrorBoundary
shouldCatch={(error) => error instanceof CustomError}
fallback={({ error }) => <>{error.message} of Child</>}
onError={onErrorChild}
>
{createElement(() => {
throw new Error(ERROR_MESSAGE)
})}
</ErrorBoundary>
</ErrorBoundary>
)

expect(onErrorChild).toBeCalledTimes(0)
expect(onErrorParent).toBeCalledTimes(1)
await waitFor(() => expect(screen.queryByText(`${ERROR_MESSAGE} of Parent`)).toBeInTheDocument())
})

it('should catch Error by one criteria(boolean)', async () => {
const onErrorParent = vi.fn()
const onErrorChild = vi.fn()

render(
<ErrorBoundary fallback={({ error }) => <>{error.message} of Parent</>} onError={onErrorParent}>
<ErrorBoundary
shouldCatch={false}
fallback={({ error }) => <>{error.message} of Child</>}
onError={onErrorChild}
>
{createElement(() => {
throw new Error(ERROR_MESSAGE)
})}
</ErrorBoundary>
</ErrorBoundary>
)

expect(onErrorChild).toBeCalledTimes(0)
expect(onErrorParent).toBeCalledTimes(1)
await waitFor(() => expect(screen.queryByText(`${ERROR_MESSAGE} of Parent`)).toBeInTheDocument())
})
})

describe('useErrorBoundary', () => {
Expand Down
38 changes: 38 additions & 0 deletions packages/react/src/ErrorBoundary.test-d.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { CustomError, CustomNotError } from '@suspensive/test-utils'
import type { ComponentProps } from 'react'
import { describe, expectTypeOf, it } from 'vitest'
import { ErrorBoundary } from './ErrorBoundary'
import type { ConstructorType } from './utility-types'

describe('<ErrorBoundary/>', () => {
it('should pass only boolean or ErrorConstructor or ShouldCatchCallback or ShouldCatch[]', () => {
type ShouldCatchCallback = (error: Error) => boolean
type ShouldCatch = boolean | ConstructorType<Error> | ShouldCatchCallback
expectTypeOf<ComponentProps<typeof ErrorBoundary>['shouldCatch']>().toEqualTypeOf<
undefined | ShouldCatch | [ShouldCatch, ...ShouldCatch[]]
>()

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const ShouldCatchByMany = () => (
<ErrorBoundary
shouldCatch={[
// @ts-expect-error CustomNotError should be new (...args) => Error
CustomNotError,
CustomError,
(error) => error instanceof CustomError,
Math.random() > 0.5,
]}
fallback={({ error }) => <>{error.message} of Child</>}
></ErrorBoundary>
)

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const ShouldCatchByOne = () => (
<ErrorBoundary
// @ts-expect-error CustomNotError should be new (...args) => Error
shouldCatch={CustomNotError}
fallback={({ error }) => <>{error.message} of Child</>}
></ErrorBoundary>
)
})
})
96 changes: 60 additions & 36 deletions packages/react/src/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
import { useDevModeObserve } from './contexts'
import { Delay } from './Delay'
import { ErrorBoundaryGroupContext } from './ErrorBoundaryGroup'
import type { PropsWithDevMode } from './utility-types'
import type { ConstructorType, PropsWithDevMode } from './utility-types'
import { assert, hasResetKeysChanged } from './utils'
import {
useErrorBoundaryFallbackProps_this_hook_should_be_called_in_ErrorBoundary_props_fallback,
Expand All @@ -33,34 +33,48 @@ export interface ErrorBoundaryFallbackProps<TError extends Error = Error> {
reset: () => void
}

export interface ErrorBoundaryProps extends PropsWithDevMode<PropsWithChildren, ErrorBoundaryDevModeOptions> {
/**
* an array of elements for the ErrorBoundary to check each render. If any of those elements change between renders, then the ErrorBoundary will reset the state which will re-render the children
*/
resetKeys?: unknown[]
/**
* when ErrorBoundary is reset by resetKeys or fallback's props.reset, onReset will be triggered
*/
onReset?(): void
/**
* when ErrorBoundary catch error, onError will be triggered
*/
onError?(error: Error, info: ErrorInfo): void
/**
* when ErrorBoundary catch error, fallback will be render instead of children
*/
fallback: ReactNode | FunctionComponent<ErrorBoundaryFallbackProps>
type ShouldCatchCallback = (error: Error) => boolean
type ShouldCatch = ConstructorType<Error> | ShouldCatchCallback | boolean
const checkErrorBoundary = (shouldCatch: ShouldCatch, error: Error) => {
if (typeof shouldCatch === 'boolean') {
return shouldCatch
}
if (shouldCatch.prototype instanceof Error) {
return error instanceof shouldCatch
}
return (shouldCatch as ShouldCatchCallback)(error)
}

export type ErrorBoundaryProps = PropsWithDevMode<
PropsWithChildren<{
/**
* an array of elements for the ErrorBoundary to check each render. If any of those elements change between renders, then the ErrorBoundary will reset the state which will re-render the children
*/
resetKeys?: unknown[]
/**
* when ErrorBoundary is reset by resetKeys or fallback's props.reset, onReset will be triggered
*/
onReset?(): void
/**
* when ErrorBoundary catch error, onError will be triggered
*/
onError?(error: Error, info: ErrorInfo): void
/**
* when ErrorBoundary catch error, fallback will be render instead of children
*/
fallback: ReactNode | FunctionComponent<ErrorBoundaryFallbackProps>
/**
* @experimental This is experimental feature.

Check warning on line 67 in packages/react/src/ErrorBoundary.tsx

View workflow job for this annotation

GitHub Actions / Check quality (lint)

Invalid JSDoc tag name "experimental"
* @default true
*/
shouldCatch?: ShouldCatch | [ShouldCatch, ...ShouldCatch[]]
}>,
ErrorBoundaryDevModeOptions
>

type ErrorBoundaryState<TError extends Error = Error> =
| {
isError: true
error: TError
}
| {
isError: false
error: null
}
| { isError: true; error: TError }
| { isError: false; error: null }

const initialErrorBoundaryState: ErrorBoundaryState = {
isError: false,
Expand Down Expand Up @@ -91,24 +105,33 @@ class BaseErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState
}

render() {
const { children, fallback } = this.props
const { children, fallback, shouldCatch = true } = this.props
const { isError, error } = this.state

let childrenOrFallback = children

if (this.state.isError && typeof fallback === 'undefined') {
if (process.env.NODE_ENV !== 'production') {
console.error('ErrorBoundary of @suspensive/react requires a defined fallback')
if (isError) {
const isCatch = Array.isArray(shouldCatch)
? shouldCatch.some((shouldCatch) => checkErrorBoundary(shouldCatch, error))
: checkErrorBoundary(shouldCatch, error)
if (!isCatch) {
throw error
}
if (typeof fallback === 'undefined') {
if (process.env.NODE_ENV !== 'production') {
console.error('ErrorBoundary of @suspensive/react requires a defined fallback')
}
throw error
}
throw this.state.error
}

let childrenOrFallback = children
if (this.state.isError) {
if (typeof fallback === 'function') {
const FallbackComponent = fallback
childrenOrFallback = <FallbackComponent error={this.state.error} reset={this.reset} />
childrenOrFallback = <FallbackComponent error={error} reset={this.reset} />
} else {
childrenOrFallback = fallback
}
}

return (
<ErrorBoundaryContext.Provider value={{ ...this.state, reset: this.reset }}>
{childrenOrFallback}
Expand All @@ -122,7 +145,7 @@ class BaseErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState
* @see {@link https://suspensive.org/docs/react/ErrorBoundary}
*/
export const ErrorBoundary = forwardRef<{ reset(): void }, ErrorBoundaryProps>(
({ devMode, fallback, children, onError, onReset, resetKeys }, ref) => {
({ devMode, fallback, children, onError, onReset, resetKeys, ...props }, ref) => {
const group = useContext(ErrorBoundaryGroupContext) ?? { resetKey: 0 }
const baseErrorBoundaryRef = useRef<BaseErrorBoundary>(null)
useImperativeHandle(ref, () => ({
Expand All @@ -131,6 +154,7 @@ export const ErrorBoundary = forwardRef<{ reset(): void }, ErrorBoundaryProps>(

return (
<BaseErrorBoundary
{...props}
fallback={fallback}
onError={onError}
onReset={onReset}
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/utility-types/ConstructorType.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type ConstructorType<TClass> = new (...args: any[]) => TClass
1 change: 1 addition & 0 deletions packages/react/src/utility-types/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export type { PropsWithDevMode } from './PropsWithDevMode'
export type { OmitKeyOf } from './OmitKeyOf'
export type { ConstructorType } from './ConstructorType'
12 changes: 8 additions & 4 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions websites/visualization/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"@tanstack/react-query": "^4.36.1",
"@tanstack/react-query-devtools": "^4.36.1",
"axios": "^1.6.5",
"clsx": "^2.1.0",
"next": "^14.0.3",
"react": "^18.2.0",
"react-dom": "^18.2.0"
Expand Down
9 changes: 5 additions & 4 deletions websites/visualization/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,11 @@ export default function RootLayout({ children }: { children: React.ReactNode })
</div>
</Link>
</nav>
<Link href="/react/DevMode">πŸ”— Go to @suspensive/react - DevMode</Link>
<Link href="/react/Delay">πŸ”— Go to @suspensive/react - Delay</Link>
<Link href="/react-await/Await">πŸ”— Go to @suspensive/react-await - Await</Link>
<Link href="/react-image/SuspenseImage">πŸ”— Go to @suspensive/react-image - SuspenseImage</Link>
<Link href="/react/DevMode">πŸ”— @suspensive/react - DevMode</Link>
<Link href="/react/Delay">πŸ”— @suspensive/react - Delay</Link>
<Link href="/react/ErrorBoundary/shouldCatch">πŸ”— @suspensive/react - ErrorBoundary shouldCatch</Link>
<Link href="/react-image/SuspenseImage">πŸ”— @suspensive/react-image - SuspenseImage</Link>
<Link href="/react-await/Await">πŸ”— @suspensive/react-await - Await</Link>
<div className="flex flex-1 items-center justify-center">{children}</div>
</Providers>
</body>
Expand Down
Loading

3 comments on commit caed129

@vercel
Copy link

@vercel vercel bot commented on caed129 Jan 15, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vercel
Copy link

@vercel vercel bot commented on caed129 Jan 15, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

visualization – ./websites/visualization

visualization-suspensive.vercel.app
visualization-git-main-suspensive.vercel.app
visualization.suspensive.org

@vercel
Copy link

@vercel vercel bot commented on caed129 Jan 15, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

docs-v2 – ./docs/v2

docs-v2-suspensive.vercel.app
docs-v2-sigma.vercel.app
docs-v2-git-main-suspensive.vercel.app
v2.suspensive.org

Please sign in to comment.