Skip to content

Commit 00fba50

Browse files
authored
ref(react): Rely on error.cause to link ErrorBoundary errors (#4005)
1 parent bdcf133 commit 00fba50

File tree

2 files changed

+37
-97
lines changed

2 files changed

+37
-97
lines changed

packages/react/src/errorboundary.tsx

Lines changed: 16 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,4 @@
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';
1+
import { captureException, ReportDialogOptions, Scope, showReportDialog, withScope } from '@sentry/browser';
112
import { logger, parseSemver } from '@sentry/utils';
123
import hoistNonReactStatics from 'hoist-non-react-statics';
134
import * as React from 'react';
@@ -53,7 +44,7 @@ export type ErrorBoundaryProps = {
5344
};
5445

5546
type ErrorBoundaryState = {
56-
componentStack: string | null;
47+
componentStack: React.ErrorInfo['componentStack'] | null;
5748
error: Error | null;
5849
eventId: string | null;
5950
};
@@ -64,43 +55,6 @@ const INITIAL_STATE = {
6455
eventId: null,
6556
};
6657

67-
/**
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-
10458
/**
10559
* A ErrorBoundary component that logs errors to Sentry. Requires React >= 16.
10660
* NOTE: If you are a Sentry user, and you are seeing this stack frame, it means the
@@ -110,14 +64,26 @@ function captureReactErrorBoundaryError(error: Error, componentStack: string): s
11064
class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
11165
public state: ErrorBoundaryState = INITIAL_STATE;
11266

113-
public componentDidCatch(error: Error, { componentStack }: React.ErrorInfo): void {
67+
public componentDidCatch(error: Error & { cause?: Error }, { componentStack }: React.ErrorInfo): void {
11468
const { beforeCapture, onError, showDialog, dialogOptions } = this.props;
11569

11670
withScope(scope => {
71+
// If on React version >= 17, create stack trace from componentStack param and links
72+
// to to the original error using `error.cause` otherwise relies on error param for stacktrace.
73+
// Linking errors requires the `LinkedErrors` integration be enabled.
74+
if (reactVersion.major && reactVersion.major >= 17) {
75+
const errorBoundaryError = new Error(error.message);
76+
errorBoundaryError.name = `React ErrorBoundary ${errorBoundaryError.name}`;
77+
errorBoundaryError.stack = componentStack;
78+
79+
// Using the `LinkedErrors` integration to link the errors together.
80+
error.cause = errorBoundaryError;
81+
}
82+
11783
if (beforeCapture) {
11884
beforeCapture(scope, error, componentStack);
11985
}
120-
const eventId = captureReactErrorBoundaryError(error, componentStack);
86+
const eventId = captureException(error, { contexts: { react: { componentStack } } });
12187
if (onError) {
12288
onError(error, componentStack, eventId);
12389
}

packages/react/test/errorboundary.test.tsx

Lines changed: 21 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,16 @@ import { useState } from 'react';
66

77
import { ErrorBoundary, ErrorBoundaryProps, UNKNOWN_COMPONENT, withErrorBoundary } from '../src/errorboundary';
88

9-
const mockCaptureEvent = jest.fn();
9+
const mockCaptureException = jest.fn();
1010
const mockShowReportDialog = jest.fn();
1111
const EVENT_ID = 'test-id-123';
1212

1313
jest.mock('@sentry/browser', () => {
1414
const actual = jest.requireActual('@sentry/browser');
1515
return {
1616
...actual,
17-
captureEvent: (event: Event) => {
18-
mockCaptureEvent(event);
17+
captureException: (...args: unknown[]) => {
18+
mockCaptureException(...args);
1919
return EVENT_ID;
2020
},
2121
showReportDialog: (options: any) => {
@@ -74,7 +74,7 @@ describe('ErrorBoundary', () => {
7474
jest.spyOn(console, 'error').mockImplementation();
7575

7676
afterEach(() => {
77-
mockCaptureEvent.mockClear();
77+
mockCaptureException.mockClear();
7878
mockShowReportDialog.mockClear();
7979
});
8080

@@ -220,60 +220,34 @@ describe('ErrorBoundary', () => {
220220
);
221221

222222
expect(mockOnError).toHaveBeenCalledTimes(0);
223-
expect(mockCaptureEvent).toHaveBeenCalledTimes(0);
223+
expect(mockCaptureException).toHaveBeenCalledTimes(0);
224224

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

228228
expect(mockOnError).toHaveBeenCalledTimes(1);
229229
expect(mockOnError).toHaveBeenCalledWith(expect.any(Error), expect.any(String), expect.any(String));
230230

231-
expect(mockCaptureEvent).toHaveBeenCalledTimes(1);
232-
233-
// We do a detailed assert on the stacktrace as a regression test against future
234-
// react changes (that way we can update the docs if frames change in a major way).
235-
const event = mockCaptureEvent.mock.calls[0][0];
236-
expect(event.exception.values).toHaveLength(2);
237-
expect(event.level).toBe(Severity.Error);
238-
239-
expect(event.exception.values[0].type).toEqual('React ErrorBoundary Error');
240-
expect(event.exception.values[0].stacktrace.frames).toEqual([
241-
{
242-
colno: expect.any(Number),
243-
filename: expect.stringContaining('errorboundary.test.tsx'),
244-
function: 'TestApp',
245-
in_app: true,
246-
lineno: expect.any(Number),
247-
},
248-
{
249-
colno: expect.any(Number),
250-
filename: expect.stringContaining('errorboundary.tsx'),
251-
function: 'ErrorBoundary',
252-
in_app: true,
253-
lineno: expect.any(Number),
254-
},
255-
{
256-
colno: expect.any(Number),
257-
filename: expect.stringContaining('errorboundary.test.tsx'),
258-
function: 'Bam',
259-
in_app: true,
260-
lineno: expect.any(Number),
261-
},
262-
{
263-
colno: expect.any(Number),
264-
filename: expect.stringContaining('errorboundary.test.tsx'),
265-
function: 'Boo',
266-
in_app: true,
267-
lineno: expect.any(Number),
268-
},
269-
]);
231+
expect(mockCaptureException).toHaveBeenCalledTimes(1);
232+
expect(mockCaptureException).toHaveBeenLastCalledWith(expect.any(Error), {
233+
contexts: { react: { componentStack: expect.any(String) } },
234+
});
235+
236+
expect(mockOnError.mock.calls[0][0]).toEqual(mockCaptureException.mock.calls[0][0]);
237+
238+
// Check if error.cause -> react component stack
239+
const error = mockCaptureException.mock.calls[0][0];
240+
const cause = error.cause;
241+
expect(cause.stack).toEqual(mockCaptureException.mock.calls[0][1].contexts.react.componentStack);
242+
expect(cause.name).toContain('React ErrorBoundary');
243+
expect(cause.message).toEqual(error.message);
270244
});
271245

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

275249
const testBeforeCapture = (...args: any[]) => {
276-
expect(mockCaptureEvent).toHaveBeenCalledTimes(0);
250+
expect(mockCaptureException).toHaveBeenCalledTimes(0);
277251
mockBeforeCapture(...args);
278252
};
279253

@@ -284,14 +258,14 @@ describe('ErrorBoundary', () => {
284258
);
285259

286260
expect(mockBeforeCapture).toHaveBeenCalledTimes(0);
287-
expect(mockCaptureEvent).toHaveBeenCalledTimes(0);
261+
expect(mockCaptureException).toHaveBeenCalledTimes(0);
288262

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

292266
expect(mockBeforeCapture).toHaveBeenCalledTimes(1);
293267
expect(mockBeforeCapture).toHaveBeenLastCalledWith(expect.any(Scope), expect.any(Error), expect.any(String));
294-
expect(mockCaptureEvent).toHaveBeenCalledTimes(1);
268+
expect(mockCaptureException).toHaveBeenCalledTimes(1);
295269
});
296270

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

0 commit comments

Comments
 (0)