Skip to content

Commit 8e78e6e

Browse files
authored
fix(react): Handle case where error.cause already defined (#7557)
If `error.cause` is already defined, attempt to walk down the error chain to set the `ReactErrorBoundary` error.
1 parent 09ee30b commit 8e78e6e

File tree

2 files changed

+111
-11
lines changed

2 files changed

+111
-11
lines changed

packages/react/src/errorboundary.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,25 @@ const INITIAL_STATE = {
6666
eventId: null,
6767
};
6868

69+
function setCause(error: Error & { cause?: Error }, cause: Error): void {
70+
const seenErrors = new WeakMap<Error, boolean>();
71+
72+
function recurse(error: Error & { cause?: Error }, cause: Error): void {
73+
// If we've already seen the error, there is a recursive loop somewhere in the error's
74+
// cause chain. Let's just bail out then to prevent a stack overflow.
75+
if (seenErrors.has(error)) {
76+
return;
77+
}
78+
if (error.cause) {
79+
seenErrors.set(error, true);
80+
return recurse(error.cause, cause);
81+
}
82+
error.cause = cause;
83+
}
84+
85+
recurse(error, cause);
86+
}
87+
6988
/**
7089
* A ErrorBoundary component that logs errors to Sentry. Requires React >= 16.
7190
* NOTE: If you are a Sentry user, and you are seeing this stack frame, it means the
@@ -93,7 +112,7 @@ class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundarySta
93112
errorBoundaryError.stack = componentStack;
94113

95114
// Using the `LinkedErrors` integration to link the errors together.
96-
error.cause = errorBoundaryError;
115+
setCause(error, errorBoundaryError);
97116
}
98117

99118
if (beforeCapture) {

packages/react/test/errorboundary.test.tsx

Lines changed: 91 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,8 @@ import { fireEvent, render, screen } from '@testing-library/react';
33
import * as React from 'react';
44
import { useState } from 'react';
55

6-
import type {
7-
ErrorBoundaryProps} from '../src/errorboundary';
8-
import {
9-
ErrorBoundary,
10-
isAtLeastReact17,
11-
UNKNOWN_COMPONENT,
12-
withErrorBoundary,
13-
} from '../src/errorboundary';
6+
import type { ErrorBoundaryProps } from '../src/errorboundary';
7+
import { ErrorBoundary, isAtLeastReact17, UNKNOWN_COMPONENT, withErrorBoundary } from '../src/errorboundary';
148

159
const mockCaptureException = jest.fn();
1610
const mockShowReportDialog = jest.fn();
@@ -39,7 +33,13 @@ function Bam(): JSX.Element {
3933
return <Boo title={title} />;
4034
}
4135

42-
const TestApp: React.FC<ErrorBoundaryProps> = ({ children, ...props }) => {
36+
interface TestAppProps extends ErrorBoundaryProps {
37+
errorComp?: JSX.Element;
38+
}
39+
40+
const TestApp: React.FC<TestAppProps> = ({ children, errorComp, ...props }) => {
41+
// eslint-disable-next-line no-param-reassign
42+
const customErrorComp = errorComp || <Bam />;
4343
const [isError, setError] = React.useState(false);
4444
return (
4545
<ErrorBoundary
@@ -51,7 +51,7 @@ const TestApp: React.FC<ErrorBoundaryProps> = ({ children, ...props }) => {
5151
}
5252
}}
5353
>
54-
{isError ? <Bam /> : children}
54+
{isError ? customErrorComp : children}
5555
<button
5656
data-testid="errorBtn"
5757
onClick={() => {
@@ -299,6 +299,87 @@ describe('ErrorBoundary', () => {
299299
expect(error.cause).not.toBeDefined();
300300
});
301301

302+
it('handles when `error.cause` is nested', () => {
303+
const mockOnError = jest.fn();
304+
305+
function CustomBam(): JSX.Element {
306+
const firstError = new Error('bam');
307+
const secondError = new Error('bam2');
308+
const thirdError = new Error('bam3');
309+
// @ts-ignore Need to set cause on error
310+
secondError.cause = firstError;
311+
// @ts-ignore Need to set cause on error
312+
thirdError.cause = secondError;
313+
throw thirdError;
314+
}
315+
316+
render(
317+
<TestApp fallback={<p>You have hit an error</p>} onError={mockOnError} errorComp={<CustomBam />}>
318+
<h1>children</h1>
319+
</TestApp>,
320+
);
321+
322+
expect(mockOnError).toHaveBeenCalledTimes(0);
323+
expect(mockCaptureException).toHaveBeenCalledTimes(0);
324+
325+
const btn = screen.getByTestId('errorBtn');
326+
fireEvent.click(btn);
327+
328+
expect(mockCaptureException).toHaveBeenCalledTimes(1);
329+
expect(mockCaptureException).toHaveBeenLastCalledWith(expect.any(Error), {
330+
contexts: { react: { componentStack: expect.any(String) } },
331+
});
332+
333+
expect(mockOnError.mock.calls[0][0]).toEqual(mockCaptureException.mock.calls[0][0]);
334+
335+
const thirdError = mockCaptureException.mock.calls[0][0];
336+
const secondError = thirdError.cause;
337+
const firstError = secondError.cause;
338+
const cause = firstError.cause;
339+
expect(cause.stack).toEqual(mockCaptureException.mock.calls[0][1].contexts.react.componentStack);
340+
expect(cause.name).toContain('React ErrorBoundary');
341+
expect(cause.message).toEqual(thirdError.message);
342+
});
343+
344+
it('handles when `error.cause` is recursive', () => {
345+
const mockOnError = jest.fn();
346+
347+
function CustomBam(): JSX.Element {
348+
const firstError = new Error('bam');
349+
const secondError = new Error('bam2');
350+
// @ts-ignore Need to set cause on error
351+
firstError.cause = secondError;
352+
// @ts-ignore Need to set cause on error
353+
secondError.cause = firstError;
354+
throw firstError;
355+
}
356+
357+
render(
358+
<TestApp fallback={<p>You have hit an error</p>} onError={mockOnError} errorComp={<CustomBam />}>
359+
<h1>children</h1>
360+
</TestApp>,
361+
);
362+
363+
expect(mockOnError).toHaveBeenCalledTimes(0);
364+
expect(mockCaptureException).toHaveBeenCalledTimes(0);
365+
366+
const btn = screen.getByTestId('errorBtn');
367+
fireEvent.click(btn);
368+
369+
expect(mockCaptureException).toHaveBeenCalledTimes(1);
370+
expect(mockCaptureException).toHaveBeenLastCalledWith(expect.any(Error), {
371+
contexts: { react: { componentStack: expect.any(String) } },
372+
});
373+
374+
expect(mockOnError.mock.calls[0][0]).toEqual(mockCaptureException.mock.calls[0][0]);
375+
376+
const error = mockCaptureException.mock.calls[0][0];
377+
const cause = error.cause;
378+
// We need to make sure that recursive error.cause does not cause infinite loop
379+
expect(cause.stack).not.toEqual(mockCaptureException.mock.calls[0][1].contexts.react.componentStack);
380+
expect(cause.name).not.toContain('React ErrorBoundary');
381+
});
382+
302383
it('calls `beforeCapture()` when an error occurs', () => {
303384
const mockBeforeCapture = jest.fn();
304385

0 commit comments

Comments
 (0)