Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(react): prevent recursive exposing fallback when fallback throw error #1409

Merged
merged 16 commits into from
Feb 1, 2025
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
5 changes: 5 additions & 0 deletions .changeset/pretty-brooms-float.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@suspensive/react": minor
---

feat(react): prevent recursive expose fallback when fallback throw error
1 change: 1 addition & 0 deletions examples/visualization/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
3 changes: 3 additions & 0 deletions examples/visualization/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<li>
<Link href="/react/ErrorBoundary/shouldCatch">{`<ErrorBoundary/>`} shouldCatch prop</Link>
</li>
<li>
<Link href="/react/ErrorBoundary/ErrorInFallback">{`<ErrorBoundary/>`}'s fallback Error</Link>
</li>
<li>
<Link href="/react/ErrorBoundary/shouldCatch/renderPhase">
{`<ErrorBoundary/>`} shouldCatch prop render phase
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<Area title="@suspensive/react">
<SuspensiveErrorBoundary fallback={() => <>This is expected</>}>
<SuspensiveErrorBoundary
fallback={() => {
console.log("@suspensive/react's ErrorBoundary fallback")
return (
<Throw.Error message={'error message in fallback'} after={1000}>
SuspensiveErrorBoundary's fallback before error
</Throw.Error>
)
}}
>
<Throw.Error message={'error message in children'} after={1000}>
SuspensiveErrorBoundary's children before error
</Throw.Error>
</SuspensiveErrorBoundary>
</SuspensiveErrorBoundary>
</Area>

<Area title="react-error-boundary">
<ReactErrorBoundary fallbackRender={() => <>This is expected</>}>
<ReactErrorBoundary
fallbackRender={() => {
console.log("react-error-boundary's ErrorBoundary fallback")
return (
<Throw.Error message={'error message in fallback'} after={1000}>
ReactErrorBoundary's fallback before error
</Throw.Error>
)
}}
>
<Throw.Error message={'error message in children'} after={1000}>
ReactErrorBoundary's children before error
</Throw.Error>
</ReactErrorBoundary>
</ReactErrorBoundary>
</Area>
</div>
)
}
3 changes: 2 additions & 1 deletion packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
49 changes: 49 additions & 0 deletions packages/react/src/ErrorBoundary.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -313,6 +314,54 @@ describe('<ErrorBoundary/>', () => {
await waitFor(() => expect(screen.queryByText(errorText)).toBeInTheDocument())
}
)

it('should re-throw error in fallback', async () => {
render(
<ErrorBoundary fallback={() => <>This is expected</>}>
<ErrorBoundary
fallback={() => (
<Throw.Error message={ERROR_MESSAGE} after={100}>
ErrorBoundary's fallback before error
</Throw.Error>
)}
>
<Throw.Error message={ERROR_MESSAGE} after={100}>
ErrorBoundary's children before error
</Throw.Error>
</ErrorBoundary>
</ErrorBoundary>
)

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(
<ReactErrorBoundary fallbackRender={() => <>This is expected</>}>
<ReactErrorBoundary
fallbackRender={() => (
<Throw.Error message={ERROR_MESSAGE} after={100}>
ErrorBoundary(react-error-boundary)'s fallback before error
</Throw.Error>
)}
>
<Throw.Error message={ERROR_MESSAGE} after={100}>
ErrorBoundary(react-error-boundary)'s children before error
</Throw.Error>
</ReactErrorBoundary>
</ReactErrorBoundary>
)

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('<ErrorBoundary.Consumer/>', () => {
Expand Down
31 changes: 25 additions & 6 deletions packages/react/src/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,9 @@ class BaseErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState
if (error instanceof SuspensiveError) {
throw error
}
if (error instanceof ErrorInFallback) {
throw error.originalError
}
const isCatch = Array.isArray(shouldCatch)
? shouldCatch.some((shouldCatch) => checkErrorBoundary(shouldCatch, error))
: checkErrorBoundary(shouldCatch, error)
Expand All @@ -124,12 +127,12 @@ class BaseErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState
throw error
}

if (typeof fallback === 'function') {
const FallbackComponent = fallback
childrenOrFallback = <FallbackComponent error={error} reset={this.reset} />
} else {
childrenOrFallback = fallback
}
const Fallback = fallback
childrenOrFallback = (
<FallbackBoundary>
{typeof Fallback === 'function' ? <Fallback error={error} reset={this.reset} /> : Fallback}
</FallbackBoundary>
)
}

return (
Expand All @@ -138,6 +141,22 @@ class BaseErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState
}
}

class ErrorInFallback extends Error {
originalError: Error
constructor(originalError: Error) {
super()
this.originalError = originalError
}
}
class FallbackBoundary extends Component<{ children: ReactNode }> {
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}
Expand Down
31 changes: 20 additions & 11 deletions pnpm-lock.yaml

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

Loading