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}
+
+ );
+};
+
+function Bam(): JSX.Element {
+ throw new Error('boom');
+}
+
+describe('ErrorBoundary', () => {
+ jest.spyOn(console, 'error').mockImplementation();
+
+ afterEach(() => {
+ mockCaptureException.mockClear();
+ mockShowReportDialog.mockClear();
+ });
+
+ it('renders null if not given a valid `fallback` prop', () => {
+ const { container } = render(
+ // @ts-ignore
+
+
+ ,
+ );
+
+ expect(container.innerHTML).toBe('');
+ });
+
+ it('renders a fallback on error', () => {
+ const { container } = render(
+ // @ts-ignore
+ Error Component}>
+
+ ,
+ );
+ expect(container.innerHTML).toBe('Error Component
');
+ });
+
+ it('calls `onMount` when mounted', () => {
+ const mockOnMount = jest.fn();
+ render(
+ Error Component} onMount={mockOnMount}>
+ children
+ ,
+ );
+
+ expect(mockOnMount).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls `onUnmount` when unmounted', () => {
+ const mockOnUnmount = jest.fn();
+ const { unmount } = render(
+ Error Component} onUnmount={mockOnUnmount}>
+ children
+ ,
+ );
+
+ expect(mockOnUnmount).toHaveBeenCalledTimes(0);
+ unmount();
+ expect(mockOnUnmount).toHaveBeenCalledTimes(1);
+ expect(mockOnUnmount).toHaveBeenCalledWith(null, null);
+ });
+
+ it('renders children correctly when there is no error', () => {
+ const { container } = render(
+ Error Component}>
+ children
+ ,
+ );
+
+ expect(container.innerHTML).toBe('children
');
+ });
+
+ describe('fallback', () => {
+ it('renders a fallback component', async () => {
+ const { container } = render(
+ You have hit an error
}>
+ children
+ ,
+ );
+
+ expect(container.innerHTML).toContain('children
');
+
+ const btn = screen.getByTestId('errorBtn');
+ fireEvent.click(btn);
+
+ expect(container.innerHTML).not.toContain('children
');
+ expect(container.innerHTML).toBe('You have hit an error
');
+ });
+
+ it('renders a render props component', async () => {
+ let errorString = '';
+ let compStack = '';
+ const { container } = render(
+ {
+ if (error && componentStack) {
+ errorString = error.toString();
+ compStack = componentStack;
+ }
+ return Fallback here
;
+ }}
+ >
+ children
+ ,
+ );
+
+ expect(container.innerHTML).toContain('children
');
+
+ const btn = screen.getByTestId('errorBtn');
+ fireEvent.click(btn);
+
+ expect(container.innerHTML).not.toContain('children
Fallback here');
+
+ expect(errorString).toBe('Error: boom');
+ expect(compStack).toBe(`
+ in Bam (created by TestApp)
+ in ErrorBoundary (created by TestApp)
+ in TestApp`);
+ });
+ });
+
+ describe('error', () => {
+ it('calls `componentDidCatch() when an error occurs`', () => {
+ const mockOnError = jest.fn();
+ render(
+ You have hit an error
} onError={mockOnError}>
+ children
+ ,
+ );
+
+ expect(mockOnError).toHaveBeenCalledTimes(0);
+ expect(mockCaptureException).toHaveBeenCalledTimes(0);
+
+ const btn = screen.getByTestId('errorBtn');
+ fireEvent.click(btn);
+
+ expect(mockOnError).toHaveBeenCalledTimes(1);
+ expect(mockOnError).toHaveBeenCalledWith(expect.any(Error), expect.any(String));
+
+ expect(mockCaptureException).toHaveBeenCalledTimes(1);
+ expect(mockCaptureException).toHaveBeenCalledWith(expect.any(Error), {
+ contexts: { react: { componentStack: expect.any(String) } },
+ });
+ });
+
+ it('shows a Sentry Report Dialog with correct options', () => {
+ const options = { title: 'custom title' };
+ render(
+ You have hit an error
} showDialog dialogOptions={options}>
+ children
+ ,
+ );
+
+ expect(mockShowReportDialog).toHaveBeenCalledTimes(0);
+
+ const btn = screen.getByTestId('errorBtn');
+ fireEvent.click(btn);
+
+ expect(mockShowReportDialog).toHaveBeenCalledTimes(1);
+ expect(mockShowReportDialog).toHaveBeenCalledWith(options);
+ });
+
+ it('resets to initial state when reset', () => {
+ const mockOnReset = jest.fn();
+ const { container } = render(
+ }
+ >
+ children
+ ,
+ );
+
+ expect(container.innerHTML).toContain('children
');
+ expect(mockOnReset).toHaveBeenCalledTimes(0);
+
+ const btn = screen.getByTestId('errorBtn');
+ fireEvent.click(btn);
+
+ expect(container.innerHTML).toContain('