diff --git a/.changeset/early-hornets-relax.md b/.changeset/early-hornets-relax.md new file mode 100644 index 000000000..f2d9cc760 --- /dev/null +++ b/.changeset/early-hornets-relax.md @@ -0,0 +1,5 @@ +--- +"@suspensive/react": minor +--- + +feat(react): add ErrorBoundary.Observer diff --git a/docs/suspensive.org/src/content/en/docs/react/ErrorBoundary.mdx b/docs/suspensive.org/src/content/en/docs/react/ErrorBoundary.mdx index 22ee3fc07..1fc0ea480 100644 --- a/docs/suspensive.org/src/content/en/docs/react/ErrorBoundary.mdx +++ b/docs/suspensive.org/src/content/en/docs/react/ErrorBoundary.mdx @@ -13,22 +13,22 @@ This component can handle any errors in children. `@suspensive/react`'s `` provides a declarative, feature-rich alternative to React's class-based error boundaries and popular error boundary libraries like `react-error-boundary` and `@sentry/react`. -| Feature | @suspensive/react | react-error-boundary | @sentry/react | React Class Component | -| ---------------------------------------- | ----------------- | ---------------------- | ---------------------- | ---------------------- | -| Basic error catching | ✅ | ✅ | ✅ | ✅ | -| Fallback UI with error & reset | ✅ | ✅ | ✅ | ⚠️ (Manual) | -| Reset with resetKeys | ✅ | ✅ | ❌ | ⚠️ (Manual) | -| onReset callback | ✅ | ✅ | ✅ | ⚠️ (Manual) | -| onError callback | ✅ | ✅ | ✅ | ✅ (componentDidCatch) | -| Conditional error catching (shouldCatch) | ✅ | ❌ | ❌ | ⚠️ (Manual) | -| Fallback error handling | ✅ (To parent) | ❌ (Recursive) | ❌ (Recursive) | ⚠️ (Manual) | -| useErrorBoundary hook | ✅ | ✅ | ❌ | ❌ | -| useErrorBoundaryFallbackProps hook | ✅ | ❌ | ❌ | ❌ | -| ErrorBoundaryGroup | ✅ | ❌ | ❌ | ❌ | -| HOC support | ✅ (with) | ✅ (withErrorBoundary) | ✅ (withErrorBoundary) | ❌ | -| TypeScript error type inference | ✅ (Advanced) | ✅ (Basic) | ✅ (Basic) | ⚠️ (Manual) | -| Declarative API | ✅ | ✅ | ✅ | ❌ | -| Automatic error reporting | ❌ | ❌ | ✅ (To Sentry) | ❌ | +| Feature | @suspensive/react | react-error-boundary | @sentry/react | React Class Component | +| ---------------------------------------- | ------------------------------------ | ---------------------- | ---------------------- | ---------------------- | +| Basic error catching | ✅ | ✅ | ✅ | ✅ | +| Fallback UI with error & reset | ✅ | ✅ | ✅ | ⚠️ (Manual) | +| Reset with resetKeys | ✅ | ✅ | ❌ | ⚠️ (Manual) | +| onReset callback | ✅ | ✅ | ✅ | ⚠️ (Manual) | +| onError callback | ✅ | ✅ | ✅ | ✅ (componentDidCatch) | +| Conditional error catching (shouldCatch) | ✅ | ❌ | ❌ | ⚠️ (Manual) | +| Fallback error handling | ✅ (To parent) | ❌ (Recursive) | ❌ (Recursive) | ⚠️ (Manual) | +| useErrorBoundary hook | ✅ | ✅ | ❌ | ❌ | +| useErrorBoundaryFallbackProps hook | ✅ | ❌ | ❌ | ❌ | +| ErrorBoundaryGroup | ✅ | ❌ | ❌ | ❌ | +| HOC support | ✅ (ErrorBoundary.with) | ✅ (withErrorBoundary) | ✅ (withErrorBoundary) | ❌ | +| TypeScript error type inference | ✅ (Advanced) | ✅ (Basic) | ✅ (Basic) | ⚠️ (Manual) | +| Declarative API | ✅ | ✅ | ✅ | ❌ | +| Automatic error reporting | ✅ (ErrorBoundary.Observer + Sentry) | ❌ | ✅ (To Sentry) | ❌ | @@ -227,11 +227,27 @@ const SentryExample = () => ( ) -// @suspensive/react - with manual Sentry integration +// @suspensive/react - with ErrorBoundary.Observer import { ErrorBoundary } from '@suspensive/react' import * as Sentry from '@sentry/react' const SuspensiveExample = () => ( + + ( +
+ + {error.message} +
+ )} + > + +
+
+) + +// or with onError prop directly +const SuspensiveWithOnErrorExample = () => ( (
@@ -240,8 +256,7 @@ const SuspensiveExample = () => (
)} onError={(error, errorInfo) => { - const eventId = Sentry.captureReactException(error, errorInfo) - console.log('Error caught:', eventId) + Sentry.captureReactException(error, errorInfo) }} > @@ -252,9 +267,9 @@ const SuspensiveExample = () => ( Main differences: - `@sentry/react` automatically reports errors to Sentry +- `@suspensive/react` provides `ErrorBoundary.Observer` to observe errors from all nested `` components at once, or `onError` prop for individual error handling - `@suspensive/react` provides more flexible error handling with `shouldCatch` - `resetError` → `reset` (in fallback props) -- Manual Sentry integration gives you more control over what gets reported
@@ -894,6 +909,82 @@ const Example = ErrorBoundary.with({ fallback: ErrorBoundaryFallback }, () => { }) ``` +## ErrorBoundary.Observer + + + +This is an experimental feature. + + + +`ErrorBoundary.Observer` is a component that observes errors from all nested `` components. It does not catch or handle errors — it simply observes them, making it ideal for integrating with error reporting tools like Sentry. + +Previously, you had to attach `onError` prop individually to each ``. To observe errors across the entire app, you had to repeatedly pass the same handler to every ErrorBoundary. + +```tsx +import { ErrorBoundary } from '@suspensive/react' +import * as Sentry from '@sentry/react' + +// Before: repeated onError on every ErrorBoundary +function App() { + return ( + } + onError={Sentry.captureReactException} // repeated every time + > + } + onError={Sentry.captureReactException} // repeated every time + > + + + + ) +} +``` + +With `ErrorBoundary.Observer`, you can observe errors from all nested `` components at once. Both `ErrorBoundary.Observer`'s `onError` and each ``'s own `onError` will be called when an error is caught. + +```tsx /ErrorBoundary.Observer/ +import { ErrorBoundary } from '@suspensive/react' +import * as Sentry from '@sentry/react' + +// After: single Observer wraps all ErrorBoundaries +function App() { + return ( + + }> + }> + + + + + ) +} +``` + +### Nested ErrorBoundary.Observer + +Multiple `ErrorBoundary.Observer` components can be nested, and all `ErrorBoundary.Observer`'s `onError` will be called in bubble order (from inner to outer) when an error occurs. + +```tsx /ErrorBoundary.Observer/ +import { ErrorBoundary } from '@suspensive/react' + +const Example = () => ( + console.log('outer', error)}> + console.log('inner', error)}> + <>{error.message}}> + + + + +) +// When an error occurs, the order is: +// 1. ErrorBoundary's onError (local) +// 2. inner ErrorBoundary.Observer's onError +// 3. outer ErrorBoundary.Observer's onError +``` + ## useErrorBoundary ### useErrorBoundary().setError diff --git a/docs/suspensive.org/src/content/ko/docs/react/ErrorBoundary.mdx b/docs/suspensive.org/src/content/ko/docs/react/ErrorBoundary.mdx index 108dde9fd..b0173d3d8 100644 --- a/docs/suspensive.org/src/content/ko/docs/react/ErrorBoundary.mdx +++ b/docs/suspensive.org/src/content/ko/docs/react/ErrorBoundary.mdx @@ -13,22 +13,22 @@ import { Callout, Sandpack } from '@/components' `@suspensive/react`의 ``는 React의 클래스 기반 에러 경계와 인기 있는 `react-error-boundary` 및 `@sentry/react`와 같은 에러 경계 라이브러리에 대한 선언적이고 기능이 풍부한 대안을 제공합니다. -| 기능 | @suspensive/react | react-error-boundary | @sentry/react | React 클래스 컴포넌트 | -| ------------------------------------ | ----------------- | ---------------------- | ---------------------- | ---------------------- | -| 기본 에러 캐칭 | ✅ | ✅ | ✅ | ✅ | -| 에러 및 리셋 기능이 있는 Fallback UI | ✅ | ✅ | ✅ | ⚠️ (수동) | -| resetKeys로 리셋 | ✅ | ✅ | ❌ | ⚠️ (수동) | -| onReset 콜백 | ✅ | ✅ | ✅ | ⚠️ (수동) | -| onError 콜백 | ✅ | ✅ | ✅ | ✅ (componentDidCatch) | -| 조건부 에러 캐칭 (shouldCatch) | ✅ | ❌ | ❌ | ⚠️ (수동) | -| Fallback 에러 처리 | ✅ (부모로 전달) | ❌ (재귀적) | ❌ (재귀적) | ⚠️ (수동) | -| useErrorBoundary 훅 | ✅ | ✅ | ❌ | ❌ | -| useErrorBoundaryFallbackProps 훅 | ✅ | ❌ | ❌ | ❌ | -| ErrorBoundaryGroup | ✅ | ❌ | ❌ | ❌ | -| HOC 지원 | ✅ (with) | ✅ (withErrorBoundary) | ✅ (withErrorBoundary) | ❌ | -| TypeScript 에러 타입 추론 | ✅ (고급) | ✅ (기본) | ✅ (기본) | ⚠️ (수동) | -| 선언적 API | ✅ | ✅ | ✅ | ❌ | -| 자동 에러 보고 | ❌ | ❌ | ✅ (Sentry로) | ❌ | +| 기능 | @suspensive/react | react-error-boundary | @sentry/react | React 클래스 컴포넌트 | +| ------------------------------------ | ------------------------------------ | ---------------------- | ---------------------- | ---------------------- | +| 기본 에러 캐칭 | ✅ | ✅ | ✅ | ✅ | +| 에러 및 리셋 기능이 있는 Fallback UI | ✅ | ✅ | ✅ | ⚠️ (수동) | +| resetKeys로 리셋 | ✅ | ✅ | ❌ | ⚠️ (수동) | +| onReset 콜백 | ✅ | ✅ | ✅ | ⚠️ (수동) | +| onError 콜백 | ✅ | ✅ | ✅ | ✅ (componentDidCatch) | +| 조건부 에러 캐칭 (shouldCatch) | ✅ | ❌ | ❌ | ⚠️ (수동) | +| Fallback 에러 처리 | ✅ (부모로 전달) | ❌ (재귀적) | ❌ (재귀적) | ⚠️ (수동) | +| useErrorBoundary 훅 | ✅ | ✅ | ❌ | ❌ | +| useErrorBoundaryFallbackProps 훅 | ✅ | ❌ | ❌ | ❌ | +| ErrorBoundaryGroup | ✅ | ❌ | ❌ | ❌ | +| HOC 지원 | ✅ (ErrorBoundary.with) | ✅ (withErrorBoundary) | ✅ (withErrorBoundary) | ❌ | +| TypeScript 에러 타입 추론 | ✅ (고급) | ✅ (기본) | ✅ (기본) | ⚠️ (수동) | +| 선언적 API | ✅ | ✅ | ✅ | ❌ | +| 자동 에러 보고 | ✅ (ErrorBoundary.Observer + Sentry) | ❌ | ✅ (Sentry로) | ❌ | @@ -227,11 +227,27 @@ const SentryExample = () => ( ) -// @suspensive/react - 수동 Sentry 통합과 함께 +// @suspensive/react - ErrorBoundary.Observer 사용 import { ErrorBoundary } from '@suspensive/react' import * as Sentry from '@sentry/react' const SuspensiveExample = () => ( + + ( +
+ + {error.message} +
+ )} + > + +
+
+) + +// 또는 onError prop을 직접 사용 +const SuspensiveWithOnErrorExample = () => ( (
@@ -240,8 +256,7 @@ const SuspensiveExample = () => (
)} onError={(error, errorInfo) => { - const eventId = Sentry.captureReactException(error, errorInfo) - console.log('에러 캐치:', eventId) + Sentry.captureReactException(error, errorInfo) }} > @@ -252,9 +267,9 @@ const SuspensiveExample = () => ( 주요 차이점: - `@sentry/react`는 자동으로 Sentry에 에러를 보고합니다 +- `@suspensive/react`는 `ErrorBoundary.Observer`를 통해 중첩된 모든 ``의 에러를 한 번에 관찰하거나, `onError` prop으로 개별 에러를 처리할 수 있습니다 - `@suspensive/react`는 `shouldCatch`를 통해 더 유연한 에러 처리를 제공합니다 - `resetError` → `reset` (fallback props에서) -- 수동 Sentry 통합은 무엇을 보고할지 더 많은 제어를 제공합니다
@@ -897,6 +912,82 @@ const Example = ErrorBoundary.with({ fallback: ErrorBoundaryFallback }, () => { }) ``` +## ErrorBoundary.Observer + + + +이 기능은 실험적(experimental) 기능입니다. + + + +`ErrorBoundary.Observer`는 중첩된 모든 `` 컴포넌트의 에러를 관찰하는 컴포넌트입니다. 에러를 잡거나 처리하지 않고 단순히 관찰만 하므로, Sentry와 같은 에러 리포팅 도구와 통합하는 데 이상적입니다. + +기존에는 각 ``마다 `onError` prop을 개별적으로 달아야 했습니다. 앱 전체에서 에러를 일괄 관찰하려면 모든 ErrorBoundary에 동일한 핸들러를 반복해서 전달해야 하는 불편함이 있었습니다. + +```tsx +import { ErrorBoundary } from '@suspensive/react' +import * as Sentry from '@sentry/react' + +// Before: 매번 onError를 반복해서 전달 +function App() { + return ( + } + onError={Sentry.captureReactException} // 매번 반복 + > + } + onError={Sentry.captureReactException} // 매번 반복 + > + + + + ) +} +``` + +`ErrorBoundary.Observer`를 사용하면 중첩된 모든 ``의 에러를 한 번에 관찰할 수 있습니다. `ErrorBoundary.Observer`의 `onError`와 각 ``의 `onError`가 에러 발생 시 모두 호출됩니다. + +```tsx /ErrorBoundary.Observer/ +import { ErrorBoundary } from '@suspensive/react' +import * as Sentry from '@sentry/react' + +// After: 하나의 Observer로 모든 ErrorBoundary를 감싸기 +function App() { + return ( + + }> + }> + + + + + ) +} +``` + +### 중첩된 ErrorBoundary.Observer + +여러 `ErrorBoundary.Observer` 컴포넌트를 중첩할 수 있으며, 에러가 발생하면 모든 `ErrorBoundary.Observer`의 `onError`가 버블 순서(안쪽에서 바깥쪽)로 호출됩니다. + +```tsx /ErrorBoundary.Observer/ +import { ErrorBoundary } from '@suspensive/react' + +const Example = () => ( + console.log('outer', error)}> + console.log('inner', error)}> + <>{error.message}}> + + + + +) +// 에러 발생 시 호출 순서: +// 1. ErrorBoundary의 onError (로컬) +// 2. inner ErrorBoundary.Observer의 onError +// 3. outer ErrorBoundary.Observer의 onError +``` + ## useErrorBoundary ### useErrorBoundary().setError diff --git a/packages/react/src/ErrorBoundary.spec.tsx b/packages/react/src/ErrorBoundary.spec.tsx index c651b4c14..49cc9df8a 100644 --- a/packages/react/src/ErrorBoundary.spec.tsx +++ b/packages/react/src/ErrorBoundary.spec.tsx @@ -671,6 +671,90 @@ describe('useErrorBoundaryFallbackProps', () => { }) }) +describe('', () => { + beforeEach(() => vi.useFakeTimers()) + + afterEach(() => { + vi.useRealTimers() + Throw.reset() + }) + + it('should call Observer onError when ErrorBoundary catches error', async () => { + const captureOnError = vi.fn() + const localOnError = vi.fn() + + render( + + {FALLBACK}} onError={localOnError}> + + {TEXT} + + + + ) + + expect(captureOnError).toHaveBeenCalledTimes(0) + expect(localOnError).toHaveBeenCalledTimes(0) + await act(() => vi.advanceTimersByTime(100)) + expect(captureOnError).toHaveBeenCalledTimes(1) + expect(localOnError).toHaveBeenCalledTimes(1) + }) + + it('should call local onError before Observer onError', async () => { + const callOrder: string[] = [] + + render( + callOrder.push('observer')}> + {FALLBACK}} onError={() => callOrder.push('local')}> + + {TEXT} + + + + ) + + await act(() => vi.advanceTimersByTime(100)) + expect(callOrder).toEqual(['local', 'observer']) + }) + + it('should call nested Observer onError in bubble order (inner to outer)', async () => { + const callOrder: string[] = [] + + render( + callOrder.push('outer')}> + callOrder.push('inner')}> + {FALLBACK}} onError={() => callOrder.push('local')}> + + {TEXT} + + + + + ) + + await act(() => vi.advanceTimersByTime(100)) + expect(callOrder).toEqual(['local', 'inner', 'outer']) + }) + + it('should work without local onError on ErrorBoundary', async () => { + const captureOnError = vi.fn() + + render( + + {FALLBACK}}> + + {TEXT} + + + + ) + + await act(() => vi.advanceTimersByTime(100)) + expect(captureOnError).toHaveBeenCalledTimes(1) + expect(screen.queryByText(FALLBACK)).toBeInTheDocument() + }) +}) + describe('ErrorBoundary.with', () => { beforeEach(() => vi.useFakeTimers()) diff --git a/packages/react/src/ErrorBoundary.tsx b/packages/react/src/ErrorBoundary.tsx index 538a4a880..ee0a36c14 100644 --- a/packages/react/src/ErrorBoundary.tsx +++ b/packages/react/src/ErrorBoundary.tsx @@ -11,6 +11,7 @@ import { type ReactNode, createContext, forwardRef, + useCallback, useContext, useImperativeHandle, useMemo, @@ -238,16 +239,25 @@ export const ErrorBoundary = Object.assign( ) { const { fallback, children, onError, onReset, resetKeys, shouldCatch } = props const group = useContext(ErrorBoundaryGroupContext) ?? { resetKey: 0 } + const errorObserver = useContext(ErrorObserverContext) const baseErrorBoundaryRef = useRef>(null) useImperativeHandle(ref, () => ({ reset: () => baseErrorBoundaryRef.current?.reset(), })) + const handleOnError = useCallback( + (error: InferError, info: ErrorInfo) => { + onError?.(error, info) + errorObserver?.(error, info) + }, + [errorObserver, onError] + ) + return ( shouldCatch={shouldCatch} fallback={fallback} - onError={onError} + onError={handleOnError} onReset={onReset} resetKeys={[group.resetKey, ...(resetKeys || [])]} ref={baseErrorBoundaryRef} @@ -280,9 +290,46 @@ export const ErrorBoundary = Object.assign( Consumer: ({ children }: { children: (errorBoundary: ReturnType) => ReactNode }) => ( <>{children(useErrorBoundary())} ), + /** + * A component that captures errors from all nested ErrorBoundary components. + * Both Observer's onError and each ErrorBoundary's own onError will be called when an error is caught. + * + * @example + * ```tsx + * import * as Sentry from '@sentry/react' + * + * function App() { + * return ( + * + * }> + * + * + * + * ) + * } + * ``` + * + * @experimental This is experimental feature. + * @see {@link https://suspensive.org/docs/react/ErrorBoundary Suspensive Docs} + */ + Observer: ({ onError, children }: PropsWithChildren<{ onError: (error: Error, info: ErrorInfo) => void }>) => { + const parent = useContext(ErrorObserverContext) + const handleError = useCallback( + (error: Error, info: ErrorInfo) => { + onError(error, info) + parent?.(error, info) + }, + [parent, onError] + ) + return {children} + }, } ) +const ErrorObserverContext = Object.assign(createContext<((error: Error, info: ErrorInfo) => void) | null>(null), { + displayName: 'ErrorBoundary.ErrorObserverContext', +}) + const ErrorBoundaryContext = Object.assign(createContext<(ErrorBoundaryHandle & ErrorBoundaryState) | null>(null), { displayName: 'ErrorBoundaryContext', })