Skip to content

Commit

Permalink
fix(react): Handle case where error.cause already defined (#7557)
Browse files Browse the repository at this point in the history
If `error.cause` is already defined, attempt to walk down the error chain to set the `ReactErrorBoundary` error.
  • Loading branch information
AbhiPrasad committed Mar 22, 2023
1 parent 09ee30b commit 8e78e6e
Show file tree
Hide file tree
Showing 2 changed files with 111 additions and 11 deletions.
21 changes: 20 additions & 1 deletion packages/react/src/errorboundary.tsx
Expand Up @@ -66,6 +66,25 @@ const INITIAL_STATE = {
eventId: null,
};

function setCause(error: Error & { cause?: Error }, cause: Error): void {
const seenErrors = new WeakMap<Error, boolean>();

function recurse(error: Error & { cause?: Error }, cause: Error): void {
// If we've already seen the error, there is a recursive loop somewhere in the error's
// cause chain. Let's just bail out then to prevent a stack overflow.
if (seenErrors.has(error)) {
return;
}
if (error.cause) {
seenErrors.set(error, true);
return recurse(error.cause, cause);
}
error.cause = cause;
}

recurse(error, cause);
}

/**
* A ErrorBoundary component that logs errors to Sentry. Requires React >= 16.
* NOTE: If you are a Sentry user, and you are seeing this stack frame, it means the
Expand Down Expand Up @@ -93,7 +112,7 @@ class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundarySta
errorBoundaryError.stack = componentStack;

// Using the `LinkedErrors` integration to link the errors together.
error.cause = errorBoundaryError;
setCause(error, errorBoundaryError);
}

if (beforeCapture) {
Expand Down
101 changes: 91 additions & 10 deletions packages/react/test/errorboundary.test.tsx
Expand Up @@ -3,14 +3,8 @@ import { fireEvent, render, screen } from '@testing-library/react';
import * as React from 'react';
import { useState } from 'react';

import type {
ErrorBoundaryProps} from '../src/errorboundary';
import {
ErrorBoundary,
isAtLeastReact17,
UNKNOWN_COMPONENT,
withErrorBoundary,
} from '../src/errorboundary';
import type { ErrorBoundaryProps } from '../src/errorboundary';
import { ErrorBoundary, isAtLeastReact17, UNKNOWN_COMPONENT, withErrorBoundary } from '../src/errorboundary';

const mockCaptureException = jest.fn();
const mockShowReportDialog = jest.fn();
Expand Down Expand Up @@ -39,7 +33,13 @@ function Bam(): JSX.Element {
return <Boo title={title} />;
}

const TestApp: React.FC<ErrorBoundaryProps> = ({ children, ...props }) => {
interface TestAppProps extends ErrorBoundaryProps {
errorComp?: JSX.Element;
}

const TestApp: React.FC<TestAppProps> = ({ children, errorComp, ...props }) => {
// eslint-disable-next-line no-param-reassign
const customErrorComp = errorComp || <Bam />;
const [isError, setError] = React.useState(false);
return (
<ErrorBoundary
Expand All @@ -51,7 +51,7 @@ const TestApp: React.FC<ErrorBoundaryProps> = ({ children, ...props }) => {
}
}}
>
{isError ? <Bam /> : children}
{isError ? customErrorComp : children}
<button
data-testid="errorBtn"
onClick={() => {
Expand Down Expand Up @@ -299,6 +299,87 @@ describe('ErrorBoundary', () => {
expect(error.cause).not.toBeDefined();
});

it('handles when `error.cause` is nested', () => {
const mockOnError = jest.fn();

function CustomBam(): JSX.Element {
const firstError = new Error('bam');
const secondError = new Error('bam2');
const thirdError = new Error('bam3');
// @ts-ignore Need to set cause on error
secondError.cause = firstError;
// @ts-ignore Need to set cause on error
thirdError.cause = secondError;
throw thirdError;
}

render(
<TestApp fallback={<p>You have hit an error</p>} onError={mockOnError} errorComp={<CustomBam />}>
<h1>children</h1>
</TestApp>,
);

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

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

expect(mockCaptureException).toHaveBeenCalledTimes(1);
expect(mockCaptureException).toHaveBeenLastCalledWith(expect.any(Error), {
contexts: { react: { componentStack: expect.any(String) } },
});

expect(mockOnError.mock.calls[0][0]).toEqual(mockCaptureException.mock.calls[0][0]);

const thirdError = mockCaptureException.mock.calls[0][0];
const secondError = thirdError.cause;
const firstError = secondError.cause;
const cause = firstError.cause;
expect(cause.stack).toEqual(mockCaptureException.mock.calls[0][1].contexts.react.componentStack);
expect(cause.name).toContain('React ErrorBoundary');
expect(cause.message).toEqual(thirdError.message);
});

it('handles when `error.cause` is recursive', () => {
const mockOnError = jest.fn();

function CustomBam(): JSX.Element {
const firstError = new Error('bam');
const secondError = new Error('bam2');
// @ts-ignore Need to set cause on error
firstError.cause = secondError;
// @ts-ignore Need to set cause on error
secondError.cause = firstError;
throw firstError;
}

render(
<TestApp fallback={<p>You have hit an error</p>} onError={mockOnError} errorComp={<CustomBam />}>
<h1>children</h1>
</TestApp>,
);

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

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

expect(mockCaptureException).toHaveBeenCalledTimes(1);
expect(mockCaptureException).toHaveBeenLastCalledWith(expect.any(Error), {
contexts: { react: { componentStack: expect.any(String) } },
});

expect(mockOnError.mock.calls[0][0]).toEqual(mockCaptureException.mock.calls[0][0]);

const error = mockCaptureException.mock.calls[0][0];
const cause = error.cause;
// We need to make sure that recursive error.cause does not cause infinite loop
expect(cause.stack).not.toEqual(mockCaptureException.mock.calls[0][1].contexts.react.componentStack);
expect(cause.name).not.toContain('React ErrorBoundary');
});

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

Expand Down

0 comments on commit 8e78e6e

Please sign in to comment.