Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Replace console.error() with a throw in setTimeout() as last resort exception logging #13310

Merged
merged 5 commits into from
Aug 2, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions packages/react-dom/src/__tests__/ReactErrorLoggingRecovery-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* Copyright (c) 2013-present, Facebook, Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @emails react-core
* @jest-environment node
*/

// This is a regression test for https://github.com/facebook/react/issues/13188.
// It reproduces a combination of conditions that led to a problem.

if (global.window) {
throw new Error('This test must run in a Node environment.');
}

// The issue only reproduced when React was loaded before JSDOM.
const React = require('react');
const ReactDOM = require('react-dom');

// Unlike other tests, we want to enable error logging.
// Note this is not a real Error prototype property,
// it's only set in our Jest environment.
// eslint-disable-next-line no-extend-native
Error.prototype.suppressReactErrorLogging = false;

// Initialize JSDOM separately.
// We don't use our normal JSDOM setup because we want to load React first.
const {JSDOM} = require('jsdom');
global.requestAnimationFrame = setTimeout;
global.cancelAnimationFrame = clearTimeout;
const jsdom = new JSDOM(`<div id="app-root"></div>`);
global.window = jsdom.window;
global.document = jsdom.window.document;
global.navigator = jsdom.window.navigator;

class Bad extends React.Component {
componentDidUpdate() {
throw new Error('no');
}
render() {
return null;
}
}

describe('ReactErrorLoggingRecovery', () => {
let originalConsoleError = console.error;

beforeEach(() => {
console.error = error => {
throw new Error('Buggy console.error');
};
});

afterEach(() => {
console.error = originalConsoleError;
});

it('should recover from errors in console.error', function() {
const div = document.createElement('div');
let didCatch = false;
try {
ReactDOM.render(<Bad />, div);
ReactDOM.render(<Bad />, div);
} catch (e) {
expect(e.message).toBe('no');
didCatch = true;
}
expect(didCatch).toBe(true);
ReactDOM.render(<span>Hello</span>, div);
expect(div.firstChild.textContent).toBe('Hello');

// Verify the console.error bug is surfaced
expect(() => {
jest.runAllTimers();
}).toThrow('Buggy console.error');
});
});
7 changes: 6 additions & 1 deletion packages/react-reconciler/src/ReactFiberCommitWork.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,12 @@ export function logError(boundary: Fiber, errorInfo: CapturedValue<mixed>) {
// A cycle may still occur if logCapturedError renders a component that throws.
const suppressLogging = e && e.suppressReactErrorLogging;
if (!suppressLogging) {
console.error(e);
// Rethrow it from a clean stack because this function is assumed to never throw.
// We can't safely call console.error() here because it could *also* throw if overriden.
// https://github.com/facebook/react/issues/13188
setTimeout(() => {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess setTimeout could also be buggy. Let's not think about this.

throw e;
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since you set suppressReactErrorLogging to false, will this execute after the test above? Should you use jest.useFakeTimers() and force it to run with something like jest.runAllTimers()?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't really intend to test this part although I guess I could. I only wanted to ensure that we don't blow up the reconciler by rethrowing.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a check.

Copy link
Contributor

@nhunzaker nhunzaker Aug 2, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool, my primary concern was just confusion in test reporting if the error showed up randomly in the test suite because Node or Jest saw an uncaught error.

No need to chase this down too much.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah cool. Posted too late! I didn't realize that fake timers were already setup, so this wouldn't have run anyway.

}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,11 +144,10 @@ describe('ReactIncrementalErrorLogging', () => {

expect(logCapturedErrorCalls.length).toBe(1);

// The error thrown in logCapturedError should also be logged
expect(console.error).toHaveBeenCalledTimes(1);
expect(console.error.calls.argsFor(0)[0].message).toContain(
'logCapturedError error',
);
// The error thrown in logCapturedError should be rethrown with a clean stack
expect(() => {
jest.runAllTimers();
}).toThrow('logCapturedError error');
} finally {
jest.unmock('../ReactFiberErrorLogger');
}
Expand Down
5 changes: 5 additions & 0 deletions scripts/jest/config.build.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ const packages = readdirSync(packagesRoot).filter(dir => {
if (dir.charAt(0) === '.') {
return false;
}
if (dir === 'events') {
// There's an actual Node package called "events"
// that's used by jsdom so we don't want to alias that.
return false;
}
const packagePath = join(packagesRoot, dir, 'package.json');
return statSync(packagePath).isFile();
});
Expand Down