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): Allow for scope to be accessed before error #2753

Merged
merged 4 commits into from Jul 20, 2020
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -7,6 +7,7 @@
- [tracing] feat: `Add @sentry/tracing` (#2719)
- [gatsby] fix: Make APM optional in gatsby package (#2752)
- [tracing] ref: Use idleTimout if no activities occur in idle transaction (#2752)
- [react] feat: Add `beforeCapture` option to ErrorBoundary (#2753)

## 5.19.2

Expand Down
34 changes: 21 additions & 13 deletions packages/react/src/errorboundary.tsx
@@ -1,4 +1,4 @@
import * as Sentry from '@sentry/browser';
import { captureException, ReportDialogOptions, Scope, showReportDialog, withScope } from '@sentry/browser';
import * as hoistNonReactStatic from 'hoist-non-react-statics';
import * as React from 'react';

Expand All @@ -18,7 +18,7 @@ export type ErrorBoundaryProps = {
* Options to be passed into the Sentry report dialog.
* No-op if {@link showDialog} is false.
*/
dialogOptions?: Sentry.ReportDialogOptions;
dialogOptions?: ReportDialogOptions;
// tslint:disable no-null-undefined-union
/**
* A fallback component that gets rendered when the error boundary encounters an error.
Expand All @@ -38,6 +38,8 @@ export type ErrorBoundaryProps = {
onReset?(error: Error | null, componentStack: string | null, eventId: string | null): void;
/** Called on componentWillUnmount() */
onUnmount?(error: Error | null, componentStack: string | null, eventId: string | null): void;
/** Called before error is sent to Sentry, allows for you to add tags or context using the scope */
AbhiPrasad marked this conversation as resolved.
Show resolved Hide resolved
beforeCapture?(scope: Scope, error: Error | null, componentStack: string | null): void;
};

type ErrorBoundaryState = {
Expand All @@ -60,18 +62,24 @@ class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundarySta
public state: ErrorBoundaryState = INITIAL_STATE;

public componentDidCatch(error: Error, { componentStack }: React.ErrorInfo): void {
const eventId = Sentry.captureException(error, { contexts: { react: { componentStack } } });
const { onError, showDialog, dialogOptions } = this.props;
if (onError) {
onError(error, componentStack, eventId);
}
if (showDialog) {
Sentry.showReportDialog({ ...dialogOptions, eventId });
}
const { beforeCapture, onError, showDialog, dialogOptions } = this.props;

withScope(scope => {
if (beforeCapture) {
beforeCapture(scope, error, componentStack);
}
const eventId = captureException(error, { contexts: { react: { componentStack } } });
if (onError) {
onError(error, componentStack, eventId);
}
if (showDialog) {
showReportDialog({ ...dialogOptions, eventId });
}

// componentDidCatch is used over getDerivedStateFromError
// so that componentStack is accessible through state.
this.setState({ error, componentStack, eventId });
// componentDidCatch is used over getDerivedStateFromError
// so that componentStack is accessible through state.
this.setState({ error, componentStack, eventId });
});
}

public componentDidMount(): void {
Expand Down
48 changes: 39 additions & 9 deletions packages/react/test/errorboundary.test.tsx
@@ -1,3 +1,4 @@
import { Scope } from '@sentry/browser';
import { fireEvent, render, screen } from '@testing-library/react';
import * as React from 'react';

Expand All @@ -7,15 +8,19 @@ const mockCaptureException = jest.fn();
const mockShowReportDialog = jest.fn();
const EVENT_ID = 'test-id-123';

jest.mock('@sentry/browser', () => ({
captureException: (err: any, ctx: any) => {
mockCaptureException(err, ctx);
return EVENT_ID;
},
showReportDialog: (options: any) => {
mockShowReportDialog(options);
},
}));
jest.mock('@sentry/browser', () => {
const actual = jest.requireActual('@sentry/browser');
return {
...actual,
captureException: (err: any, ctx: any) => {
mockCaptureException(err, ctx);
return EVENT_ID;
},
showReportDialog: (options: any) => {
mockShowReportDialog(options);
},
};
});

const TestApp: React.FC<ErrorBoundaryProps> = ({ children, ...props }) => {
const [isError, setError] = React.useState(false);
Expand Down Expand Up @@ -197,6 +202,31 @@ describe('ErrorBoundary', () => {
});
});

it('calls `beforeCapture()` when an error occurs', () => {
const mockBeforeCapture = jest.fn();

const testBeforeCapture = (...args: any[]) => {
expect(mockCaptureException).toHaveBeenCalledTimes(0);
mockBeforeCapture(...args);
};

render(
<TestApp fallback={<p>You have hit an error</p>} beforeCapture={testBeforeCapture}>
<h1>children</h1>
</TestApp>,
);

expect(mockBeforeCapture).toHaveBeenCalledTimes(0);
expect(mockCaptureException).toHaveBeenCalledTimes(0);

const btn = screen.getByTestId('errorBtn');
fireEvent.click(btn);

expect(mockBeforeCapture).toHaveBeenCalledTimes(1);
expect(mockBeforeCapture).toHaveBeenLastCalledWith(expect.any(Scope), expect.any(Error), expect.any(String));
expect(mockCaptureException).toHaveBeenCalledTimes(1);
});

it('shows a Sentry Report Dialog with correct options', () => {
const options = { title: 'custom title' };
render(
Expand Down