diff --git a/CHANGELOG.md b/CHANGELOG.md index ff10c368cceb..4187506b8ccf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott - [react] feat: Add @sentry/react package (#2631) - +- [react] feat: Add Error Boundary component (#2647) ## 5.17.0 diff --git a/packages/react/README.md b/packages/react/README.md index 8df1272d44af..c4430a6af6e0 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -7,6 +7,8 @@ # Official Sentry SDK for ReactJS +Note this library is in active development and not ready for production usage. + ## Links - [Official SDK Docs](https://docs.sentry.io/quickstart/) diff --git a/packages/react/package.json b/packages/react/package.json index 6154493f260b..a0e8501e6cf5 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -27,16 +27,15 @@ "react-dom": "^16.0.0" }, "devDependencies": { + "@testing-library/react": "^10.0.6", "@types/hoist-non-react-statics": "^3.3.1", "@types/react": "^16.9.35", - "@types/react-test-renderer": "^16.9.2", "jest": "^24.7.1", "npm-run-all": "^4.1.2", "prettier": "^1.17.0", "prettier-check": "^2.0.0", "react": "^16.0.0", "react-dom": "^16.0.0", - "react-test-renderer": "^16.13.1", "rimraf": "^2.6.3", "tslint": "^5.16.0", "tslint-react": "^5.0.0", diff --git a/packages/react/src/errorboundary.tsx b/packages/react/src/errorboundary.tsx new file mode 100644 index 000000000000..0c0fda21e258 --- /dev/null +++ b/packages/react/src/errorboundary.tsx @@ -0,0 +1,115 @@ +import * as Sentry from '@sentry/browser'; +import * as hoistNonReactStatic from 'hoist-non-react-statics'; +import * as React from 'react'; + +export const UNKNOWN_COMPONENT = 'unknown'; + +export type FallbackRender = (fallback: { + error: Error | null; + componentStack: string | null; + resetError(): void; +}) => React.ReactNode; + +export type ErrorBoundaryProps = { + showDialog?: boolean; + dialogOptions?: Sentry.ReportDialogOptions; + // tslint:disable-next-line: no-null-undefined-union + fallback?: React.ReactNode | FallbackRender; + onError?(error: Error, componentStack: string): void; + onMount?(): void; + onReset?(error: Error | null, componentStack: string | null): void; + onUnmount?(error: Error | null, componentStack: string | null): void; +}; + +type ErrorBoundaryState = { + componentStack: string | null; + error: Error | null; +}; + +const INITIAL_STATE = { + componentStack: null, + error: null, +}; + +class ErrorBoundary extends React.Component { + public state: ErrorBoundaryState = INITIAL_STATE; + + public componentDidCatch(error: Error, { componentStack }: React.ErrorInfo): void { + Sentry.captureException(error, { contexts: { react: { componentStack } } }); + const { onError, showDialog, dialogOptions } = this.props; + if (onError) { + onError(error, componentStack); + } + if (showDialog) { + Sentry.showReportDialog(dialogOptions); + } + + // componentDidCatch is used over getDerivedStateFromError + // so that componentStack is accessible through state. + this.setState({ error, componentStack }); + } + + public componentDidMount(): void { + const { onMount } = this.props; + if (onMount) { + onMount(); + } + } + + public componentWillUnmount(): void { + const { error, componentStack } = this.state; + const { onUnmount } = this.props; + if (onUnmount) { + onUnmount(error, componentStack); + } + } + + public resetErrorBoundary = () => { + const { onReset } = this.props; + if (onReset) { + onReset(this.state.error, this.state.componentStack); + } + this.setState(INITIAL_STATE); + }; + + public render(): React.ReactNode { + const { fallback } = this.props; + const { error, componentStack } = this.state; + + if (error) { + if (React.isValidElement(fallback)) { + return fallback; + } + if (typeof fallback === 'function') { + return fallback({ error, componentStack, resetError: this.resetErrorBoundary }) as FallbackRender; + } + + // Fail gracefully if no fallback provided + return null; + } + + return this.props.children; + } +} + +function withErrorBoundary

( + WrappedComponent: React.ComponentType

, + errorBoundaryOptions: ErrorBoundaryProps, +): React.FC

{ + const componentDisplayName = WrappedComponent.displayName || WrappedComponent.name || UNKNOWN_COMPONENT; + + const Wrapped: React.FC

= (props: P) => ( + + + + ); + + Wrapped.displayName = `errorBoundary(${componentDisplayName})`; + + // Copy over static methods from Wrapped component to Profiler HOC + // See: https://reactjs.org/docs/higher-order-components.html#static-methods-must-be-copied-over + hoistNonReactStatic(Wrapped, WrappedComponent); + return Wrapped; +} + +export { ErrorBoundary, withErrorBoundary }; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 93fa1c640dcc..aa30b551dddc 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,3 +1,4 @@ export * from '@sentry/browser'; export { Profiler, withProfiler } from './profiler'; +export { ErrorBoundary, withErrorBoundary } from './errorboundary'; diff --git a/packages/react/src/profiler.tsx b/packages/react/src/profiler.tsx index 16373639c18a..00f404ce5a65 100644 --- a/packages/react/src/profiler.tsx +++ b/packages/react/src/profiler.tsx @@ -39,38 +39,33 @@ function afterNextFrame(callback: Function): void { timeout = window.setTimeout(done, 100); } -const getInitActivity = (componentDisplayName: string): number | null => { +const getInitActivity = (name: string): number | null => { const tracingIntegration = getCurrentHub().getIntegration(TRACING_GETTER); if (tracingIntegration !== null) { // tslint:disable-next-line:no-unsafe-any - const activity = (tracingIntegration as any).constructor.pushActivity(componentDisplayName, { - description: `<${componentDisplayName}>`, + return (tracingIntegration as any).constructor.pushActivity(name, { + description: `<${name}>`, op: 'react', }); - - // tslint:disable-next-line: no-unsafe-any - return activity; } logger.warn( - `Unable to profile component ${componentDisplayName} due to invalid Tracing Integration. Please make sure to setup the Tracing integration.`, + `Unable to profile component ${name} due to invalid Tracing Integration. Please make sure to setup the Tracing integration.`, ); return null; }; -interface ProfilerProps { - componentDisplayName?: string; -} +export type ProfilerProps = { + name: string; +}; class Profiler extends React.Component { public activity: number | null; public constructor(props: ProfilerProps) { super(props); - const { componentDisplayName = UNKNOWN_COMPONENT } = this.props; - - this.activity = getInitActivity(componentDisplayName); + this.activity = getInitActivity(this.props.name); } public componentDidMount(): void { @@ -103,7 +98,7 @@ function withProfiler

(WrappedComponent: React.ComponentType

const componentDisplayName = WrappedComponent.displayName || WrappedComponent.name || UNKNOWN_COMPONENT; const Wrapped: React.FC

= (props: P) => ( - + ); diff --git a/packages/react/test/errorboundary.test.tsx b/packages/react/test/errorboundary.test.tsx new file mode 100644 index 000000000000..8847c803aa25 --- /dev/null +++ b/packages/react/test/errorboundary.test.tsx @@ -0,0 +1,218 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import * as React from 'react'; + +import { ErrorBoundary, ErrorBoundaryProps } from '../src/errorboundary'; + +const mockCaptureException = jest.fn(); +const mockShowReportDialog = jest.fn(); + +jest.mock('@sentry/browser', () => ({ + captureException: (err: any, ctx: any) => { + mockCaptureException(err, ctx); + }, + showReportDialog: (options: any) => { + mockShowReportDialog(options); + }, +})); + +const TestApp: React.FC = ({ children, ...props }) => { + const [isError, setError] = React.useState(false); + return ( + + {isError ? : children} +