Skip to content

Commit 60e1982

Browse files
authored
feat(react): Add support for React 17 Error Boundaries (#3532)
This PR updates the @sentry/react error boundary component to use React 17's better component stack traces. The React component stack trace is generated from the componentStack property taken from the ErrorBoundary's componentDidCatch method.
1 parent 0c4fdf6 commit 60e1982

File tree

6 files changed

+145
-40
lines changed

6 files changed

+145
-40
lines changed

packages/gatsby/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
"jest": "^24.7.1",
4040
"npm-run-all": "^4.1.2",
4141
"prettier": "1.19.0",
42-
"react": "^16.13.1",
42+
"react": "^17.0.0",
4343
"rimraf": "^2.6.3",
4444
"typescript": "3.7.5"
4545
},

packages/gatsby/test/integration.test.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/* eslint-disable @typescript-eslint/no-explicit-any */
22
import { render } from '@testing-library/react';
3+
import { useEffect } from 'react';
34
import * as React from 'react';
45

56
import { onClientEntry } from '../gatsby-browser';
@@ -14,7 +15,7 @@ describe('useEffect', () => {
1415
let calls = 0;
1516

1617
onClientEntry(undefined, {
17-
beforeSend: event => {
18+
beforeSend: (event: any) => {
1819
expect(event).not.toBeUndefined();
1920
calls += 1;
2021

@@ -24,7 +25,7 @@ describe('useEffect', () => {
2425

2526
// eslint-disable-next-line @typescript-eslint/no-unused-vars
2627
function TestComponent() {
27-
React.useEffect(() => {
28+
useEffect(() => {
2829
const error = new Error('testing 123');
2930
(window as any).Sentry.captureException(error);
3031
});

packages/react/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@
4646
"jsdom": "^16.2.2",
4747
"npm-run-all": "^4.1.2",
4848
"prettier": "1.19.0",
49-
"react": "^16.0.0",
50-
"react-dom": "^16.0.0",
49+
"react": "^17.0.0",
50+
"react-dom": "^17.0.0",
5151
"react-router-3": "npm:[email protected]",
5252
"react-router-4": "npm:[email protected]",
5353
"react-router-5": "npm:[email protected]",

packages/react/src/errorboundary.tsx

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,19 @@
1-
import { captureException, ReportDialogOptions, Scope, showReportDialog, withScope } from '@sentry/browser';
1+
import {
2+
captureEvent,
3+
captureException,
4+
eventFromException,
5+
ReportDialogOptions,
6+
Scope,
7+
showReportDialog,
8+
withScope,
9+
} from '@sentry/browser';
10+
import { Event } from '@sentry/types';
11+
import { parseSemver } from '@sentry/utils';
212
import hoistNonReactStatics from 'hoist-non-react-statics';
313
import * as React from 'react';
414

15+
const reactVersion = parseSemver(React.version);
16+
517
export const UNKNOWN_COMPONENT = 'unknown';
618

719
export type FallbackRender = (errorData: {
@@ -53,8 +65,47 @@ const INITIAL_STATE = {
5365
};
5466

5567
/**
56-
* A ErrorBoundary component that logs errors to Sentry.
57-
* Requires React >= 16
68+
* Logs react error boundary errors to Sentry. If on React version >= 17, creates stack trace
69+
* from componentStack param, otherwise relies on error param for stacktrace.
70+
*
71+
* @param error An error captured by React Error Boundary
72+
* @param componentStack The component stacktrace
73+
*/
74+
function captureReactErrorBoundaryError(error: Error, componentStack: string): string {
75+
const errorBoundaryError = new Error(error.message);
76+
errorBoundaryError.name = `React ErrorBoundary ${errorBoundaryError.name}`;
77+
errorBoundaryError.stack = componentStack;
78+
79+
let errorBoundaryEvent: Event = {};
80+
void eventFromException({}, errorBoundaryError).then(e => {
81+
errorBoundaryEvent = e;
82+
});
83+
84+
if (
85+
errorBoundaryEvent.exception &&
86+
Array.isArray(errorBoundaryEvent.exception.values) &&
87+
reactVersion.major &&
88+
reactVersion.major >= 17
89+
) {
90+
let originalEvent: Event = {};
91+
void eventFromException({}, error).then(e => {
92+
originalEvent = e;
93+
});
94+
if (originalEvent.exception && Array.isArray(originalEvent.exception.values)) {
95+
originalEvent.exception.values = [...errorBoundaryEvent.exception.values, ...originalEvent.exception.values];
96+
}
97+
98+
return captureEvent(originalEvent);
99+
}
100+
101+
return captureException(error, { contexts: { react: { componentStack } } });
102+
}
103+
104+
/**
105+
* A ErrorBoundary component that logs errors to Sentry. Requires React >= 16.
106+
* NOTE: If you are a Sentry user, and you are seeing this stack frame, it means the
107+
* Sentry React SDK ErrorBoundary caught an error invoking your application code. This
108+
* is expected behavior and NOT indicative of a bug with the Sentry React SDK.
58109
*/
59110
class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
60111
public state: ErrorBoundaryState = INITIAL_STATE;
@@ -66,7 +117,7 @@ class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundarySta
66117
if (beforeCapture) {
67118
beforeCapture(scope, error, componentStack);
68119
}
69-
const eventId = captureException(error, { contexts: { react: { componentStack } } });
120+
const eventId = captureReactErrorBoundaryError(error, componentStack);
70121
if (onError) {
71122
onError(error, componentStack, eventId);
72123
}

packages/react/test/errorboundary.test.tsx

Lines changed: 67 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
11
import { Scope } from '@sentry/browser';
2+
import { Event, Severity } from '@sentry/types';
23
import { fireEvent, render, screen } from '@testing-library/react';
34
import * as React from 'react';
5+
import { useState } from 'react';
46

57
import { ErrorBoundary, ErrorBoundaryProps, UNKNOWN_COMPONENT, withErrorBoundary } from '../src/errorboundary';
68

7-
const mockCaptureException = jest.fn();
9+
const mockCaptureEvent = jest.fn();
810
const mockShowReportDialog = jest.fn();
911
const EVENT_ID = 'test-id-123';
1012

1113
jest.mock('@sentry/browser', () => {
1214
const actual = jest.requireActual('@sentry/browser');
1315
return {
1416
...actual,
15-
captureException: (err: any, ctx: any) => {
16-
mockCaptureException(err, ctx);
17+
captureEvent: (event: Event) => {
18+
mockCaptureEvent(event);
1719
return EVENT_ID;
1820
},
1921
showReportDialog: (options: any) => {
@@ -22,6 +24,15 @@ jest.mock('@sentry/browser', () => {
2224
};
2325
});
2426

27+
function Boo({ title }: { title: string }): JSX.Element {
28+
throw new Error(title);
29+
}
30+
31+
function Bam(): JSX.Element {
32+
const [title] = useState('boom');
33+
return <Boo title={title} />;
34+
}
35+
2536
const TestApp: React.FC<ErrorBoundaryProps> = ({ children, ...props }) => {
2637
const [isError, setError] = React.useState(false);
2738
return (
@@ -45,10 +56,6 @@ const TestApp: React.FC<ErrorBoundaryProps> = ({ children, ...props }) => {
4556
);
4657
};
4758

48-
function Bam(): JSX.Element {
49-
throw new Error('boom');
50-
}
51-
5259
describe('withErrorBoundary', () => {
5360
it('sets displayName properly', () => {
5461
const TestComponent = () => <h1>Hello World</h1>;
@@ -67,7 +74,7 @@ describe('ErrorBoundary', () => {
6774
jest.spyOn(console, 'error').mockImplementation();
6875

6976
afterEach(() => {
70-
mockCaptureException.mockClear();
77+
mockCaptureEvent.mockClear();
7178
mockShowReportDialog.mockClear();
7279
});
7380

@@ -170,10 +177,15 @@ describe('ErrorBoundary', () => {
170177
expect(container.innerHTML).toBe('<div>Fallback here</div>');
171178

172179
expect(errorString).toBe('Error: boom');
173-
expect(compStack).toBe(`
174-
in Bam (created by TestApp)
175-
in ErrorBoundary (created by TestApp)
176-
in TestApp`);
180+
/*
181+
at Boo (/path/to/sentry-javascript/packages/react/test/errorboundary.test.tsx:23:20)
182+
at Bam (/path/to/sentry-javascript/packages/react/test/errorboundary.test.tsx:40:11)
183+
at ErrorBoundary (/path/to/sentry-javascript/packages/react/src/errorboundary.tsx:2026:39)
184+
at TestApp (/path/to/sentry-javascript/packages/react/test/errorboundary.test.tsx:22:23)
185+
*/
186+
expect(compStack).toMatch(
187+
/\s+(at Boo) \(.*?\)\s+(at Bam) \(.*?\)\s+(at ErrorBoundary) \(.*?\)\s+(at TestApp) \(.*?\)/g,
188+
);
177189
expect(eventIdString).toBe(EVENT_ID);
178190
});
179191
});
@@ -188,25 +200,60 @@ describe('ErrorBoundary', () => {
188200
);
189201

190202
expect(mockOnError).toHaveBeenCalledTimes(0);
191-
expect(mockCaptureException).toHaveBeenCalledTimes(0);
203+
expect(mockCaptureEvent).toHaveBeenCalledTimes(0);
192204

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

196208
expect(mockOnError).toHaveBeenCalledTimes(1);
197209
expect(mockOnError).toHaveBeenCalledWith(expect.any(Error), expect.any(String), expect.any(String));
198210

199-
expect(mockCaptureException).toHaveBeenCalledTimes(1);
200-
expect(mockCaptureException).toHaveBeenCalledWith(expect.any(Error), {
201-
contexts: { react: { componentStack: expect.any(String) } },
202-
});
211+
expect(mockCaptureEvent).toHaveBeenCalledTimes(1);
212+
213+
// We do a detailed assert on the stacktrace as a regression test against future
214+
// react changes (that way we can update the docs if frames change in a major way).
215+
const event = mockCaptureEvent.mock.calls[0][0];
216+
expect(event.exception.values).toHaveLength(2);
217+
expect(event.level).toBe(Severity.Error);
218+
219+
expect(event.exception.values[0].type).toEqual('React ErrorBoundary Error');
220+
expect(event.exception.values[0].stacktrace.frames).toEqual([
221+
{
222+
colno: expect.any(Number),
223+
filename: expect.stringContaining('errorboundary.test.tsx'),
224+
function: 'TestApp',
225+
in_app: true,
226+
lineno: expect.any(Number),
227+
},
228+
{
229+
colno: expect.any(Number),
230+
filename: expect.stringContaining('errorboundary.tsx'),
231+
function: 'ErrorBoundary',
232+
in_app: true,
233+
lineno: expect.any(Number),
234+
},
235+
{
236+
colno: expect.any(Number),
237+
filename: expect.stringContaining('errorboundary.test.tsx'),
238+
function: 'Bam',
239+
in_app: true,
240+
lineno: expect.any(Number),
241+
},
242+
{
243+
colno: expect.any(Number),
244+
filename: expect.stringContaining('errorboundary.test.tsx'),
245+
function: 'Boo',
246+
in_app: true,
247+
lineno: expect.any(Number),
248+
},
249+
]);
203250
});
204251

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

208255
const testBeforeCapture = (...args: any[]) => {
209-
expect(mockCaptureException).toHaveBeenCalledTimes(0);
256+
expect(mockCaptureEvent).toHaveBeenCalledTimes(0);
210257
mockBeforeCapture(...args);
211258
};
212259

@@ -217,14 +264,14 @@ describe('ErrorBoundary', () => {
217264
);
218265

219266
expect(mockBeforeCapture).toHaveBeenCalledTimes(0);
220-
expect(mockCaptureException).toHaveBeenCalledTimes(0);
267+
expect(mockCaptureEvent).toHaveBeenCalledTimes(0);
221268

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

225272
expect(mockBeforeCapture).toHaveBeenCalledTimes(1);
226273
expect(mockBeforeCapture).toHaveBeenLastCalledWith(expect.any(Scope), expect.any(Error), expect.any(String));
227-
expect(mockCaptureException).toHaveBeenCalledTimes(1);
274+
expect(mockCaptureEvent).toHaveBeenCalledTimes(1);
228275
});
229276

230277
it('shows a Sentry Report Dialog with correct options', () => {

yarn.lock

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16583,15 +16583,14 @@ rc@^1.0.1, rc@^1.1.6:
1658316583
minimist "^1.2.0"
1658416584
strip-json-comments "~2.0.1"
1658516585

16586-
react-dom@^16.0.0:
16587-
version "16.14.0"
16588-
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.14.0.tgz#7ad838ec29a777fb3c75c3a190f661cf92ab8b89"
16589-
integrity sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw==
16586+
react-dom@^17.0.0:
16587+
version "17.0.2"
16588+
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23"
16589+
integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==
1659016590
dependencies:
1659116591
loose-envify "^1.1.0"
1659216592
object-assign "^4.1.1"
16593-
prop-types "^15.6.2"
16594-
scheduler "^0.19.1"
16593+
scheduler "^0.20.2"
1659516594

1659616595
react-error-boundary@^3.1.0:
1659716596
version "3.1.1"
@@ -16667,14 +16666,13 @@ react-test-renderer@^16.13.1:
1666716666
react-is "^16.8.6"
1666816667
scheduler "^0.19.1"
1666916668

16670-
react@^16.0.0, react@^16.13.1:
16671-
version "16.14.0"
16672-
resolved "https://registry.yarnpkg.com/react/-/react-16.14.0.tgz#94d776ddd0aaa37da3eda8fc5b6b18a4c9a3114d"
16673-
integrity sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==
16669+
react@^17.0.0:
16670+
version "17.0.2"
16671+
resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037"
16672+
integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==
1667416673
dependencies:
1667516674
loose-envify "^1.1.0"
1667616675
object-assign "^4.1.1"
16677-
prop-types "^15.6.2"
1667816676

1667916677
read-cmd-shim@^1.0.1:
1668016678
version "1.0.5"
@@ -17583,6 +17581,14 @@ scheduler@^0.19.1:
1758317581
loose-envify "^1.1.0"
1758417582
object-assign "^4.1.1"
1758517583

17584+
scheduler@^0.20.2:
17585+
version "0.20.2"
17586+
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.2.tgz#4baee39436e34aa93b4874bddcbf0fe8b8b50e91"
17587+
integrity sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==
17588+
dependencies:
17589+
loose-envify "^1.1.0"
17590+
object-assign "^4.1.1"
17591+
1758617592
schema-utils@^1.0.0:
1758717593
version "1.0.0"
1758817594
resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-1.0.0.tgz#0b79a93204d7b600d4b2850d1f66c2a34951c770"

0 commit comments

Comments
 (0)