Skip to content

Commit d0c99b2

Browse files
committed
Be more defensive when recursing through error cause chain
1 parent e37e6e7 commit d0c99b2

File tree

2 files changed

+54
-3
lines changed

2 files changed

+54
-3
lines changed

packages/react/src/errorboundary.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,22 @@ const INITIAL_STATE = {
6767
};
6868

6969
function setCause(error: Error & { cause?: Error }, cause: Error): void {
70-
if (error.cause) {
71-
return setCause(error.cause, cause);
70+
const seenErrors = new Map<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;
7283
}
73-
error.cause = cause;
84+
85+
recurse(error, cause);
7486
}
7587

7688
/**

packages/react/test/errorboundary.test.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,45 @@ describe('ErrorBoundary', () => {
341341
expect(cause.message).toEqual(thirdError.message);
342342
});
343343

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+
344383
it('calls `beforeCapture()` when an error occurs', () => {
345384
const mockBeforeCapture = jest.fn();
346385

0 commit comments

Comments
 (0)