From caed129d07e0c1103a9c6f586c3c0291b69bdd0d Mon Sep 17 00:00:00 2001 From: Jonghyeon Ko Date: Mon, 15 Jan 2024 21:41:28 +0900 Subject: [PATCH] feat(react): add `shouldCatch` prop on ErrorBoundary (#569) fix #567 fix #564 fix #450 Specially thanks to @sungh0lim @tooooo1 # new prop of ErrorBoundary: `shoudCatch` ![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 // We can ignore them by reversing shouldCatch !(error instanceof CustomError)}> !isAxiosError(error)}> !(error instanceof CustomError), (error) => isAxiosError(error), !isTrueRandomly]}> ) } ``` ## 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 Co-authored-by: Sungho Lim <70130721+sungh0lim@users.noreply.github.com> Co-authored-by: Chungil Jung --- .changeset/perfect-kangaroos-brake.md | 5 + configs/test-utils/src/CustomError.ts | 6 ++ configs/test-utils/src/CustomNotError.ts | 5 + configs/test-utils/src/index.ts | 2 + packages/react/src/ErrorBoundary.spec.tsx | 94 +++++++++++++++++- packages/react/src/ErrorBoundary.test-d.tsx | 38 ++++++++ packages/react/src/ErrorBoundary.tsx | 96 ++++++++++++------- .../src/utility-types/ConstructorType.ts | 1 + packages/react/src/utility-types/index.ts | 1 + pnpm-lock.yaml | 12 ++- websites/visualization/package.json | 1 + websites/visualization/src/app/layout.tsx | 9 +- .../react/ErrorBoundary/shouldCatch/page.tsx | 67 +++++++++++++ .../src/components/uis/index.tsx | 24 +++-- 14 files changed, 306 insertions(+), 55 deletions(-) create mode 100644 .changeset/perfect-kangaroos-brake.md create mode 100644 configs/test-utils/src/CustomError.ts create mode 100644 configs/test-utils/src/CustomNotError.ts create mode 100644 packages/react/src/ErrorBoundary.test-d.tsx create mode 100644 packages/react/src/utility-types/ConstructorType.ts create mode 100644 websites/visualization/src/app/react/ErrorBoundary/shouldCatch/page.tsx diff --git a/.changeset/perfect-kangaroos-brake.md b/.changeset/perfect-kangaroos-brake.md new file mode 100644 index 000000000..d20708afd --- /dev/null +++ b/.changeset/perfect-kangaroos-brake.md @@ -0,0 +1,5 @@ +--- +'@suspensive/react': minor +--- + +feat(react): add `shouldCatch` prop of ErrorBoundary diff --git a/configs/test-utils/src/CustomError.ts b/configs/test-utils/src/CustomError.ts new file mode 100644 index 000000000..65a252e3f --- /dev/null +++ b/configs/test-utils/src/CustomError.ts @@ -0,0 +1,6 @@ +export class CustomError extends Error { + constructor(...args: ConstructorParameters) { + super(...args) + console.error(...args) + } +} diff --git a/configs/test-utils/src/CustomNotError.ts b/configs/test-utils/src/CustomNotError.ts new file mode 100644 index 000000000..e0282cecf --- /dev/null +++ b/configs/test-utils/src/CustomNotError.ts @@ -0,0 +1,5 @@ +export class CustomNotError { + constructor(public message?: string) { + console.log(message) + } +} diff --git a/configs/test-utils/src/index.ts b/configs/test-utils/src/index.ts index 280415204..d98561788 100644 --- a/configs/test-utils/src/index.ts +++ b/configs/test-utils/src/index.ts @@ -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' diff --git a/packages/react/src/ErrorBoundary.spec.tsx b/packages/react/src/ErrorBoundary.spec.tsx index 8713541d0..f25481ad0 100644 --- a/packages/react/src/ErrorBoundary.spec.tsx +++ b/packages/react/src/ErrorBoundary.spec.tsx @@ -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' @@ -167,6 +167,98 @@ describe('', () => { 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( + <>{error.message} of Parent} onError={onErrorParent}> + error instanceof CustomError]} + fallback={({ error }) => <>{error.message} of Child} + onError={onErrorChild} + > + {createElement(() => { + throw new Error(ERROR_MESSAGE) + })} + + + ) + + 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( + <>{error.message} of Parent} onError={onErrorParent}> + <>{error.message} of Child} + onError={onErrorChild} + > + {createElement(() => { + throw new Error(ERROR_MESSAGE) + })} + + + ) + + 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( + <>{error.message} of Parent} onError={onErrorParent}> + error instanceof CustomError} + fallback={({ error }) => <>{error.message} of Child} + onError={onErrorChild} + > + {createElement(() => { + throw new Error(ERROR_MESSAGE) + })} + + + ) + + 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( + <>{error.message} of Parent} onError={onErrorParent}> + <>{error.message} of Child} + onError={onErrorChild} + > + {createElement(() => { + throw new Error(ERROR_MESSAGE) + })} + + + ) + + expect(onErrorChild).toBeCalledTimes(0) + expect(onErrorParent).toBeCalledTimes(1) + await waitFor(() => expect(screen.queryByText(`${ERROR_MESSAGE} of Parent`)).toBeInTheDocument()) + }) }) describe('useErrorBoundary', () => { diff --git a/packages/react/src/ErrorBoundary.test-d.tsx b/packages/react/src/ErrorBoundary.test-d.tsx new file mode 100644 index 000000000..5312d2149 --- /dev/null +++ b/packages/react/src/ErrorBoundary.test-d.tsx @@ -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('', () => { + it('should pass only boolean or ErrorConstructor or ShouldCatchCallback or ShouldCatch[]', () => { + type ShouldCatchCallback = (error: Error) => boolean + type ShouldCatch = boolean | ConstructorType | ShouldCatchCallback + expectTypeOf['shouldCatch']>().toEqualTypeOf< + undefined | ShouldCatch | [ShouldCatch, ...ShouldCatch[]] + >() + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const ShouldCatchByMany = () => ( + Error + CustomNotError, + CustomError, + (error) => error instanceof CustomError, + Math.random() > 0.5, + ]} + fallback={({ error }) => <>{error.message} of Child} + > + ) + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const ShouldCatchByOne = () => ( + Error + shouldCatch={CustomNotError} + fallback={({ error }) => <>{error.message} of Child} + > + ) + }) +}) diff --git a/packages/react/src/ErrorBoundary.tsx b/packages/react/src/ErrorBoundary.tsx index 461bc7b58..c20d63080 100644 --- a/packages/react/src/ErrorBoundary.tsx +++ b/packages/react/src/ErrorBoundary.tsx @@ -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, @@ -33,34 +33,48 @@ export interface ErrorBoundaryFallbackProps { reset: () => void } -export interface ErrorBoundaryProps extends PropsWithDevMode { - /** - * 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 +type ShouldCatchCallback = (error: Error) => boolean +type ShouldCatch = ConstructorType | 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 + /** + * @experimental This is experimental feature. + * @default true + */ + shouldCatch?: ShouldCatch | [ShouldCatch, ...ShouldCatch[]] + }>, + ErrorBoundaryDevModeOptions +> + type ErrorBoundaryState = - | { - isError: true - error: TError - } - | { - isError: false - error: null - } + | { isError: true; error: TError } + | { isError: false; error: null } const initialErrorBoundaryState: ErrorBoundaryState = { isError: false, @@ -91,24 +105,33 @@ class BaseErrorBoundary extends Component 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 = + childrenOrFallback = } else { childrenOrFallback = fallback } } + return ( {childrenOrFallback} @@ -122,7 +145,7 @@ class BaseErrorBoundary extends Component( - ({ devMode, fallback, children, onError, onReset, resetKeys }, ref) => { + ({ devMode, fallback, children, onError, onReset, resetKeys, ...props }, ref) => { const group = useContext(ErrorBoundaryGroupContext) ?? { resetKey: 0 } const baseErrorBoundaryRef = useRef(null) useImperativeHandle(ref, () => ({ @@ -131,6 +154,7 @@ export const ErrorBoundary = forwardRef<{ reset(): void }, ErrorBoundaryProps>( return ( = new (...args: any[]) => TClass diff --git a/packages/react/src/utility-types/index.ts b/packages/react/src/utility-types/index.ts index 7870c8b39..23feb20b6 100644 --- a/packages/react/src/utility-types/index.ts +++ b/packages/react/src/utility-types/index.ts @@ -1,2 +1,3 @@ export type { PropsWithDevMode } from './PropsWithDevMode' export type { OmitKeyOf } from './OmitKeyOf' +export type { ConstructorType } from './ConstructorType' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 73129ee6a..2e2647eb1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -418,6 +418,9 @@ importers: axios: specifier: ^1.6.5 version: 1.6.5 + clsx: + specifier: ^2.1.0 + version: 2.1.0 next: specifier: ^14.0.3 version: 14.0.3(@babel/core@7.23.3)(react-dom@18.2.0)(react@18.2.0) @@ -906,7 +909,6 @@ packages: /@commitlint/config-validator@18.4.4: resolution: {integrity: sha512-/QI8KIg/h7O0Eus36fPcEcO3QPBcdXuGfZeCF5m15k0EB2bcU8s6pHNTNEa6xz9PrAefHCL+yzRJj7w20T6Mow==} engines: {node: '>=v18'} - requiresBuild: true dependencies: '@commitlint/types': 18.4.4 ajv: 8.12.0 @@ -927,7 +929,6 @@ packages: /@commitlint/execute-rule@18.4.4: resolution: {integrity: sha512-a37Nd3bDQydtg9PCLLWM9ZC+GO7X5i4zJvrggJv5jBhaHsXeQ9ZWdO6ODYR+f0LxBXXNYK3geYXJrCWUCP8JEg==} engines: {node: '>=v18'} - requiresBuild: true dev: true /@commitlint/format@18.4.4: @@ -959,7 +960,6 @@ packages: /@commitlint/load@18.4.4(@types/node@18.19.7)(typescript@5.3.3): resolution: {integrity: sha512-RaDIa9qwOw2xRJ3Jr2DBXd14rmnHJIX2XdZF4kmoF1rgsg/+7cvrExLSUNAkQUNimyjCn1b/bKX2Omm+GdY0XQ==} engines: {node: '>=v18'} - requiresBuild: true dependencies: '@commitlint/config-validator': 18.4.4 '@commitlint/execute-rule': 18.4.4 @@ -1004,7 +1004,6 @@ packages: /@commitlint/resolve-extends@18.4.4: resolution: {integrity: sha512-RRpIHSbRnFvmGifVk21Gqazf1QF/yeP+Kkg/e3PlkegcOKd/FGOXp/Kx9cvSO2K7ucSn4GD/oBvgasFoy+NCAw==} engines: {node: '>=v18'} - requiresBuild: true dependencies: '@commitlint/config-validator': 18.4.4 '@commitlint/types': 18.4.4 @@ -3269,6 +3268,11 @@ packages: engines: {node: '>=6'} dev: false + /clsx@2.1.0: + resolution: {integrity: sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==} + engines: {node: '>=6'} + dev: false + /color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} dependencies: diff --git a/websites/visualization/package.json b/websites/visualization/package.json index 47581731d..182a65f29 100644 --- a/websites/visualization/package.json +++ b/websites/visualization/package.json @@ -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" diff --git a/websites/visualization/src/app/layout.tsx b/websites/visualization/src/app/layout.tsx index 2993a1e55..8a2c17264 100644 --- a/websites/visualization/src/app/layout.tsx +++ b/websites/visualization/src/app/layout.tsx @@ -21,10 +21,11 @@ export default function RootLayout({ children }: { children: React.ReactNode }) - 🔗 Go to @suspensive/react - DevMode - 🔗 Go to @suspensive/react - Delay - 🔗 Go to @suspensive/react-await - Await - 🔗 Go to @suspensive/react-image - SuspenseImage + 🔗 @suspensive/react - DevMode + 🔗 @suspensive/react - Delay + 🔗 @suspensive/react - ErrorBoundary shouldCatch + 🔗 @suspensive/react-image - SuspenseImage + 🔗 @suspensive/react-await - Await
{children}
diff --git a/websites/visualization/src/app/react/ErrorBoundary/shouldCatch/page.tsx b/websites/visualization/src/app/react/ErrorBoundary/shouldCatch/page.tsx new file mode 100644 index 000000000..5c97e0986 --- /dev/null +++ b/websites/visualization/src/app/react/ErrorBoundary/shouldCatch/page.tsx @@ -0,0 +1,67 @@ +'use client' + +import { ErrorBoundary, Suspense } from '@suspensive/react' +import { useSuspenseQuery } from '@suspensive/react-query' +import { useQueryErrorResetBoundary } from '@tanstack/react-query' +import { AxiosError, isAxiosError } from 'axios' +import { Area, Box, Button } from '~/components/uis' +import { api } from '~/utils' + +export default function Page() { + const queryError = useQueryErrorResetBoundary() + + return ( + ( + + Global: unknown error: {error.message} + + + )} + > + + ( + + Page: AxiosError: Network error: {error.message} + + + )} + > + + !isAxiosError(error)} // shouldCatch if not AxiosError + onReset={queryError.reset} + fallback={({ error, reset }) => ( + + Section: {error.message} + + + )} + > + + + + + + + + + ) +} + +const AxiosErrorOrJustErrorMaker = () => { + if (Math.random() > 0.5) { + throw new Error('sometimes not AxiosError') + } + + useSuspenseQuery({ + queryKey: ['ErrorBoundary', 'shouldCatch'], + queryFn: () => api.delay(1000, { percentage: 0 }), + }) + + return <>success +} diff --git a/websites/visualization/src/components/uis/index.tsx b/websites/visualization/src/components/uis/index.tsx index e0ec034ad..29a062605 100644 --- a/websites/visualization/src/components/uis/index.tsx +++ b/websites/visualization/src/components/uis/index.tsx @@ -1,4 +1,5 @@ import type { ErrorBoundaryFallbackProps } from '@suspensive/react' +import { clsx } from 'clsx' import { type ComponentPropsWithoutRef, type PropsWithChildren, forwardRef } from 'react' export const Button = forwardRef>(function Button(props, ref) { @@ -38,7 +39,7 @@ export const Box = { Success: forwardRef>(function Success(props, ref) { return (
@@ -47,7 +48,7 @@ export const Box = { Error: forwardRef>(function Error(props, ref) { return (
@@ -56,7 +57,7 @@ export const Box = { Default: forwardRef>(function Default(props, ref) { return (
@@ -64,13 +65,16 @@ export const Box = { }), } -export const Area = ({ title, children }: PropsWithChildren<{ title: string }>) => ( -
-
{title}
-
{children}
-
-) - +export const Area = ({ title, children, className }: PropsWithChildren<{ title: string; className?: string }>) => { + return ( +
+
{title}
+
+ {children} +
+
+ ) +} export const RejectedFallback = (props: ErrorBoundaryFallbackProps) => (
Error: {props.error.message}