diff --git a/.changeset/pretty-brooms-float.md b/.changeset/pretty-brooms-float.md new file mode 100644 index 000000000..ab3ae71b9 --- /dev/null +++ b/.changeset/pretty-brooms-float.md @@ -0,0 +1,5 @@ +--- +"@suspensive/react": minor +--- + +feat(react): prevent recursive expose fallback when fallback throw error diff --git a/examples/visualization/package.json b/examples/visualization/package.json index 772274345..ed1f7637c 100644 --- a/examples/visualization/package.json +++ b/examples/visualization/package.json @@ -23,6 +23,7 @@ "next": "catalog:", "react": "catalog:react19", "react-dom": "catalog:react19", + "react-error-boundary": "^5.0.0", "sharp": "catalog:", "zod": "^3.24.1" }, diff --git a/examples/visualization/src/app/layout.tsx b/examples/visualization/src/app/layout.tsx index 7322bca64..783ba9d93 100644 --- a/examples/visualization/src/app/layout.tsx +++ b/examples/visualization/src/app/layout.tsx @@ -31,6 +31,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
  • {``} shouldCatch prop
  • +
  • + {``}'s fallback Error +
  • {``} shouldCatch prop render phase diff --git a/examples/visualization/src/app/react/ErrorBoundary/ErrorInFallback/page.tsx b/examples/visualization/src/app/react/ErrorBoundary/ErrorInFallback/page.tsx new file mode 100644 index 000000000..536022c7e --- /dev/null +++ b/examples/visualization/src/app/react/ErrorBoundary/ErrorInFallback/page.tsx @@ -0,0 +1,71 @@ +'use client' + +import { ErrorBoundary as SuspensiveErrorBoundary } from '@suspensive/react' +import { type PropsWithChildren, useCallback, useEffect, useRef, useState } from 'react' +import { ErrorBoundary as ReactErrorBoundary } from 'react-error-boundary' +import { Area } from '~/components/uis' + +export const useTimeout = (fn: () => void, ms: number) => { + const fnRef = useRef(fn) + fnRef.current = fn + const fnPreserved = useCallback(() => fnRef.current(), []) + useEffect(() => { + const id = setTimeout(fnPreserved, ms) + return () => clearTimeout(id) + }, [fnPreserved, ms]) +} + +export const Throw = { + Error: ({ message, after = 0, children }: PropsWithChildren<{ message: string; after?: number }>) => { + const [isNeedThrow, setIsNeedThrow] = useState(after === 0) + if (isNeedThrow) { + throw new Error(message) + } + useTimeout(() => setIsNeedThrow(true), after) + return <>{children} + }, +} + +export default function Page() { + return ( +
    + + <>This is expected}> + { + console.log("@suspensive/react's ErrorBoundary fallback") + return ( + + SuspensiveErrorBoundary's fallback before error + + ) + }} + > + + SuspensiveErrorBoundary's children before error + + + + + + + <>This is expected}> + { + console.log("react-error-boundary's ErrorBoundary fallback") + return ( + + ReactErrorBoundary's fallback before error + + ) + }} + > + + ReactErrorBoundary's children before error + + + + +
    + ) +} diff --git a/packages/react/package.json b/packages/react/package.json index d1cb4f959..1a6ba9243 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -53,7 +53,8 @@ "@suspensive/tsconfig": "workspace:*", "@suspensive/tsup": "workspace:*", "@types/react": "catalog:react19", - "react": "catalog:react19" + "react": "catalog:react19", + "react-error-boundary": "^5.0.0" }, "peerDependencies": { "react": "^18 || ^19" diff --git a/packages/react/src/ErrorBoundary.spec.tsx b/packages/react/src/ErrorBoundary.spec.tsx index 7436a5bf6..013168e80 100644 --- a/packages/react/src/ErrorBoundary.spec.tsx +++ b/packages/react/src/ErrorBoundary.spec.tsx @@ -2,6 +2,7 @@ import { act, render, screen, waitFor } from '@testing-library/react' import { userEvent } from '@testing-library/user-event' import ms from 'ms' import { type ComponentRef, createElement, createRef } from 'react' +import { ErrorBoundary as ReactErrorBoundary } from 'react-error-boundary' import { ErrorBoundary, type ErrorBoundaryFallbackProps, @@ -313,6 +314,54 @@ describe('', () => { await waitFor(() => expect(screen.queryByText(errorText)).toBeInTheDocument()) } ) + + it('should re-throw error in fallback', async () => { + render( + <>This is expected}> + ( + + ErrorBoundary's fallback before error + + )} + > + + ErrorBoundary's children before error + + + + ) + + expect(screen.queryByText("ErrorBoundary's children before error")).toBeInTheDocument() + await waitFor(() => expect(screen.queryByText("ErrorBoundary's fallback before error")).toBeInTheDocument()) + await waitFor(() => expect(screen.queryByText('This is expected')).toBeInTheDocument()) + }) + it('should not re-throw error in fallback (react-error-boundary)', async () => { + render( + <>This is expected}> + ( + + ErrorBoundary(react-error-boundary)'s fallback before error + + )} + > + + ErrorBoundary(react-error-boundary)'s children before error + + + + ) + + expect(screen.queryByText("ErrorBoundary(react-error-boundary)'s children before error")).toBeInTheDocument() + await waitFor(() => + expect(screen.queryByText("ErrorBoundary(react-error-boundary)'s fallback before error")).toBeInTheDocument() + ) + await waitFor(() => expect(screen.queryByText('This is expected')).not.toBeInTheDocument()) + await waitFor(() => + expect(screen.queryByText("ErrorBoundary(react-error-boundary)'s fallback before error")).toBeInTheDocument() + ) + }) }) describe('', () => { diff --git a/packages/react/src/ErrorBoundary.tsx b/packages/react/src/ErrorBoundary.tsx index 5b2624c50..4a3ce37a8 100644 --- a/packages/react/src/ErrorBoundary.tsx +++ b/packages/react/src/ErrorBoundary.tsx @@ -110,6 +110,9 @@ class BaseErrorBoundary extends Component checkErrorBoundary(shouldCatch, error)) : checkErrorBoundary(shouldCatch, error) @@ -124,12 +127,12 @@ class BaseErrorBoundary extends Component - } else { - childrenOrFallback = fallback - } + const Fallback = fallback + childrenOrFallback = ( + + {typeof Fallback === 'function' ? : Fallback} + + ) } return ( @@ -138,6 +141,22 @@ class BaseErrorBoundary extends Component { + componentDidCatch(originalError: Error) { + throw originalError instanceof SuspensiveError ? originalError : new ErrorInFallback(originalError) + } + render() { + return this.props.children + } +} + /** * This component provides a simple and reusable wrapper that you can use to wrap around your components. Any rendering errors in your components hierarchy can then be gracefully handled. * @see {@link https://suspensive.org/docs/react/ErrorBoundary Suspensive Docs} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 210e6c1bf..217efe66b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -461,6 +461,9 @@ importers: react-dom: specifier: catalog:react19 version: 19.0.0(react@19.0.0) + react-error-boundary: + specifier: ^5.0.0 + version: 5.0.0(react@19.0.0) sharp: specifier: 'catalog:' version: 0.33.5 @@ -596,6 +599,9 @@ importers: react: specifier: catalog:react19 version: 19.0.0 + react-error-boundary: + specifier: ^5.0.0 + version: 5.0.0(react@19.0.0) packages/react-dom: devDependencies: @@ -2378,7 +2384,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {'0': node >=0.10.0} + engines: {node: '>=0.10.0'} '@expo/cli@0.22.8': resolution: {integrity: sha512-MpHrfPKcHL+b1wwx+WiniEL5n33tl964tN8EW1K4okW3/tAPgbu3R00NZs6OExH9PZGQP8OKhCXhZttbK2jUnA==} @@ -3888,9 +3894,6 @@ packages: '@types/react@18.3.12': resolution: {integrity: sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==} - '@types/react@19.0.4': - resolution: {integrity: sha512-3O4QisJDYr1uTUMZHA2YswiQZRq+Pd8D+GdVFYikTutYsTz+QZgWkAPnP7rx9txoI6EXKcPiluMqWPFV3tT9Wg==} - '@types/react@19.0.7': resolution: {integrity: sha512-MoFsEJKkAtZCrC1r6CM8U22GzhG7u2Wir8ons/aCKH6MBdD1ibV24zOSSkdZVUKqN5i396zG5VKLYZ3yaUZdLA==} @@ -8992,6 +8995,11 @@ packages: peerDependencies: react: ^19.0.0 + react-error-boundary@5.0.0: + resolution: {integrity: sha512-tnjAxG+IkpLephNcePNA7v6F/QpWLH8He65+DmedchDwg162JZqx4NmbXj0mlAYVVEd81OW7aFhmbsScYfiAFQ==} + peerDependencies: + react: '>=16.13.1' + react-fast-compare@3.2.2: resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} @@ -15044,7 +15052,7 @@ snapshots: '@types/react-dom@18.3.1': dependencies: - '@types/react': 19.0.4 + '@types/react': 19.0.7 '@types/react-dom@19.0.3(@types/react@19.0.7)': dependencies: @@ -15059,10 +15067,6 @@ snapshots: '@types/prop-types': 15.7.13 csstype: 3.1.3 - '@types/react@19.0.4': - dependencies: - csstype: 3.1.3 - '@types/react@19.0.7': dependencies: csstype: 3.1.3 @@ -15721,7 +15725,7 @@ snapshots: babel-plugin-jest-hoist@29.6.3: dependencies: '@babel/template': 7.25.9 - '@babel/types': 7.26.0 + '@babel/types': 7.26.3 '@types/babel__core': 7.20.5 '@types/babel__traverse': 7.20.6 @@ -18780,7 +18784,7 @@ snapshots: istanbul-lib-instrument@5.2.1: dependencies: '@babel/core': 7.26.0 - '@babel/parser': 7.26.2 + '@babel/parser': 7.26.3 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 semver: 6.3.1 @@ -21415,6 +21419,11 @@ snapshots: react: 19.0.0 scheduler: 0.25.0 + react-error-boundary@5.0.0(react@19.0.0): + dependencies: + '@babel/runtime': 7.26.0 + react: 19.0.0 + react-fast-compare@3.2.2: {} react-freeze@1.0.4(react@19.0.0):