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):