Support throwing null (#10213)

* Support throwing null

In JavaScript, you can throw values of any type, not just errors. That
includes null. We currently rely on null checks to determine if a user-
provided function has thrown. This refactors our error handling code to
keep track of an explicit boolean flag instead.

* Add DOM fixture test case for break on exception behavior

* preventDefault error events during feature test

We call invokeGuardedCallbackDev at startup as part of a feature test.
But we don't want those errors to log to the console.

* Add throwing null test case

* Use ReactFeatureFlags instead of ReactDOMFeatureFlags

React ART uses this, too.

* Non-errors in error logger

If a non-error is thrown, we'll coerce the value to a string and use
that as the message.
This commit is contained in:
Andrew Clark
2017-07-21 15:34:41 -07:00
committed by GitHub
parent b3943497c2
commit 4fcc25a229
11 changed files with 390 additions and 141 deletions

View File

@@ -59,6 +59,7 @@ class Header extends React.Component {
Input change events
</option>
<option value="/buttons">Buttons</option>
<option value="/error-handling">Error Handling</option>
</select>
</label>
<label htmlFor="react_version">

View File

@@ -0,0 +1,88 @@
const React = window.React;
import FixtureSet from '../../FixtureSet';
import TestCase from '../../TestCase';
function BadRender(props) {
throw props.error;
}
class ErrorBoundary extends React.Component {
state = {
shouldThrow: false,
didThrow: false,
error: null,
};
componentDidCatch(error) {
this.setState({error, didThrow: true});
}
triggerError = () => {
this.setState({
shouldThrow: true,
});
};
render() {
if (this.state.didThrow) {
if (this.state.error) {
return `Captured an error: ${this.state.error.message}`;
} else {
return `Captured an error: ${this.state.error}`;
}
}
if (this.state.shouldThrow) {
return <BadRender error={this.props.error} />;
}
return <button onClick={this.triggerError}>Trigger error</button>;
}
}
class Example extends React.Component {
state = {key: 0};
restart = () => {
this.setState(state => ({key: state.key + 1}));
};
render() {
return (
<div>
<ErrorBoundary error={this.props.error} key={this.state.key} />
<button onClick={this.restart}>Reset</button>
</div>
);
}
}
export default class ErrorHandlingTestCases extends React.Component {
render() {
return (
<FixtureSet title="Error handling" description="">
<TestCase
title="Break on uncaught exceptions"
description="In DEV, errors should be treated as uncaught, even though React catches them internally">
<TestCase.Steps>
<li>Open the browser DevTools</li>
<li>Make sure "Pause on exceptions" is enabled</li>
<li>Make sure "Pause on caught exceptions" is disabled</li>
<li>Click the "Trigger error" button</li>
<li>Click the reset button</li>
</TestCase.Steps>
<TestCase.ExpectedResult>
The DevTools should pause at the line where the error was thrown, in
the BadRender component. After resuming, the "Trigger error" button
should be replaced with "Captured an error: Oops!" Clicking reset
should reset the test case.
</TestCase.ExpectedResult>
<Example error={new Error('Oops!')} />
</TestCase>
<TestCase title="Throwing null" description="">
<TestCase.Steps>
<li>Click the "Trigger error" button</li>
<li>Click the reset button</li>
</TestCase.Steps>
<TestCase.ExpectedResult>
The "Trigger error" button should be replaced with "Captured an
error: null". Clicking reset should reset the test case.
</TestCase.ExpectedResult>
<Example error={null} />
</TestCase>
</FixtureSet>
);
}
}

View File

@@ -7,6 +7,7 @@ import InputChangeEvents from './input-change-events';
import NumberInputFixtures from './number-inputs';
import PasswordInputFixtures from './password-inputs';
import ButtonFixtures from './buttons';
import ErrorHandling from './error-handling';
/**
* A simple routing component that renders the appropriate
@@ -30,6 +31,8 @@ function FixturesPage() {
return <PasswordInputFixtures />;
case '/buttons':
return <ButtonFixtures />;
case '/error-handling':
return <ErrorHandling />;
default:
return <p>Please select a test fixture.</p>;
}

View File

@@ -2328,21 +2328,22 @@ src/renderers/shared/stack/reconciler/__tests__/Transaction-test.js
* should allow nesting of transactions
src/renderers/shared/utils/__tests__/ReactErrorUtils-test.js
* it should rethrow errors caught by invokeGuardedCallbackAndCatchFirstError (development)
* it should rethrow caught errors (development)
* should call the callback the passed arguments (development)
* should call the callback with the provided context (development)
* should return a caught error (development)
* should return null if no error is thrown (development)
* should catch errors (development)
* should return false from clearCaughtError if no error was thrown (development)
* can nest with same debug name (development)
* does not return nested errors (development)
* can be shimmed (development)
* it should rethrow errors caught by invokeGuardedCallbackAndCatchFirstError (production)
* it should rethrow caught errors (production)
* should call the callback the passed arguments (production)
* should call the callback with the provided context (production)
* should return a caught error (production)
* should return null if no error is thrown (production)
* should catch errors (production)
* should return false from clearCaughtError if no error was thrown (production)
* can nest with same debug name (production)
* does not return nested errors (production)
* catches null values
* can be shimmed (production)
src/renderers/shared/utils/__tests__/accumulateInto-test.js

View File

@@ -12,6 +12,7 @@ jest.mock('ReactFeatureFlags', () => {
const flags = require.requireActual('ReactFeatureFlags');
return Object.assign({}, flags, {
disableNewFiberFeatures: true,
forceInvokeGuardedCallbackDev: true,
});
});
jest.mock('ReactNativeFeatureFlags', () => {

View File

@@ -26,7 +26,11 @@ var {
} = ReactTypeOfWork;
var {commitCallbacks} = require('ReactFiberUpdateQueue');
var {onCommitUnmount} = require('ReactFiberDevToolsHook');
var {invokeGuardedCallback} = require('ReactErrorUtils');
var {
invokeGuardedCallback,
hasCaughtError,
clearCaughtError,
} = require('ReactErrorUtils');
var {
Placement,
@@ -70,14 +74,15 @@ module.exports = function<T, P, I, TI, PI, C, CX, PL>(
// Capture errors so they don't interrupt unmounting.
function safelyCallComponentWillUnmount(current, instance) {
if (__DEV__) {
const unmountError = invokeGuardedCallback(
invokeGuardedCallback(
null,
callComponentWillUnmountWithTimerInDev,
null,
current,
instance,
);
if (unmountError) {
if (hasCaughtError()) {
const unmountError = clearCaughtError();
captureError(current, unmountError);
}
} else {
@@ -93,8 +98,9 @@ module.exports = function<T, P, I, TI, PI, C, CX, PL>(
const ref = current.ref;
if (ref !== null) {
if (__DEV__) {
const refError = invokeGuardedCallback(null, ref, null, null);
if (refError !== null) {
invokeGuardedCallback(null, ref, null, null);
if (hasCaughtError()) {
const refError = clearCaughtError();
captureError(current, refError);
}
} else {

View File

@@ -29,18 +29,38 @@ function logCapturedError(capturedError: CapturedError): void {
return;
}
const error = (capturedError.error: any);
// Duck-typing
let message;
let name;
let stack;
if (
error !== null &&
typeof error.message === 'string' &&
typeof error.name === 'string' &&
typeof error.stack === 'string'
) {
message = error.message;
name = error.name;
stack = error.stack;
} else {
// A non-error was thrown.
message = '' + error;
name = 'Error';
stack = '';
}
if (__DEV__) {
const {
componentName,
componentStack,
error,
errorBoundaryName,
errorBoundaryFound,
willRetry,
} = capturedError;
const {message, name, stack} = error;
const errorSummary = message ? `${name}: ${message}` : name;
const componentNameMessage = componentName
@@ -85,10 +105,7 @@ function logCapturedError(capturedError: CapturedError): void {
`The error is located at: ${componentStack}\n\n` +
`The error was thrown at: ${formattedCallStack}`,
);
}
if (!__DEV__) {
const {error} = capturedError;
} else {
console.error(
`React caught an error thrown by one of your components.\n\n${error.stack}`,
);

View File

@@ -21,7 +21,7 @@ import type {HydrationContext} from 'ReactFiberHydrationContext';
export type CapturedError = {
componentName: ?string,
componentStack: string,
error: Error,
error: mixed,
errorBoundary: ?Object,
errorBoundaryFound: boolean,
errorBoundaryName: string | null,
@@ -38,7 +38,11 @@ var {
getStackAddendumByWorkInProgressFiber,
} = require('ReactFiberComponentTreeHook');
var {logCapturedError} = require('ReactFiberErrorLogger');
var {invokeGuardedCallback} = require('ReactErrorUtils');
var {
invokeGuardedCallback,
hasCaughtError,
clearCaughtError,
} = require('ReactErrorUtils');
var ReactFiberBeginWork = require('ReactFiberBeginWork');
var ReactFiberCompleteWork = require('ReactFiberCompleteWork');
@@ -222,7 +226,7 @@ module.exports = function<T, P, I, TI, PI, C, CX, PL>(
let failedBoundaries: Set<Fiber> | null = null;
// Error boundaries that captured an error during the current commit.
let commitPhaseBoundaries: Set<Fiber> | null = null;
let firstUncaughtError: Error | null = null;
let firstUncaughtError: mixed | null = null;
let didFatal: boolean = false;
let isCommitting: boolean = false;
@@ -468,17 +472,23 @@ module.exports = function<T, P, I, TI, PI, C, CX, PL>(
startCommitHostEffectsTimer();
}
while (nextEffect !== null) {
let error = null;
let didError = false;
let error;
if (__DEV__) {
error = invokeGuardedCallback(null, commitAllHostEffects, null);
invokeGuardedCallback(null, commitAllHostEffects, null);
if (hasCaughtError()) {
didError = true;
error = clearCaughtError();
}
} else {
try {
commitAllHostEffects();
} catch (e) {
didError = true;
error = e;
}
}
if (error !== null) {
if (didError) {
invariant(
nextEffect !== null,
'Should have next effect. This error is likely caused by a bug ' +
@@ -512,17 +522,23 @@ module.exports = function<T, P, I, TI, PI, C, CX, PL>(
startCommitLifeCyclesTimer();
}
while (nextEffect !== null) {
let error = null;
let didError = false;
let error;
if (__DEV__) {
error = invokeGuardedCallback(null, commitAllLifeCycles, null);
invokeGuardedCallback(null, commitAllLifeCycles, null);
if (hasCaughtError()) {
didError = true;
error = clearCaughtError();
}
} else {
try {
commitAllLifeCycles();
} catch (e) {
didError = true;
error = e;
}
}
if (error !== null) {
if (didError) {
invariant(
nextEffect !== null,
'Should have next effect. This error is likely caused by a bug ' +
@@ -951,26 +967,25 @@ module.exports = function<T, P, I, TI, PI, C, CX, PL>(
// reset it at the end.
const previousPriorityContext = priorityContext;
let error;
let didError = false;
let error = null;
if (__DEV__) {
error = invokeGuardedCallback(
null,
workLoop,
null,
minPriorityLevel,
deadline,
);
invokeGuardedCallback(null, workLoop, null, minPriorityLevel, deadline);
if (hasCaughtError()) {
didError = true;
error = clearCaughtError();
}
} else {
try {
workLoop(minPriorityLevel, deadline);
error = null;
} catch (e) {
didError = true;
error = e;
}
}
// An error was thrown during the render phase.
while (error !== null) {
while (didError) {
if (didFatal) {
// This was a fatal error. Don't attempt to recover from it.
firstUncaughtError = error;
@@ -1000,8 +1015,10 @@ module.exports = function<T, P, I, TI, PI, C, CX, PL>(
continue;
}
didError = false;
error = null;
if (__DEV__) {
error = invokeGuardedCallback(
invokeGuardedCallback(
null,
performWorkCatchBlock,
null,
@@ -1010,6 +1027,11 @@ module.exports = function<T, P, I, TI, PI, C, CX, PL>(
minPriorityLevel,
deadline,
);
if (hasCaughtError()) {
didError = true;
error = clearCaughtError();
continue;
}
} else {
try {
performWorkCatchBlock(
@@ -1020,16 +1042,11 @@ module.exports = function<T, P, I, TI, PI, C, CX, PL>(
);
error = null;
} catch (e) {
didError = true;
error = e;
continue;
}
}
if (error !== null) {
// Another error was thrown during the render phase. Continue the
// loop to handle the new error.
continue;
}
// We're finished working. Exit the error loop.
break;
}
@@ -1067,7 +1084,7 @@ module.exports = function<T, P, I, TI, PI, C, CX, PL>(
}
// Returns the boundary that captured the error, or null if the error is ignored
function captureError(failedWork: Fiber, error: Error): Fiber | null {
function captureError(failedWork: Fiber, error: mixed): Fiber | null {
// It is no longer valid because we exited the user code.
ReactCurrentOwner.current = null;
if (__DEV__) {

View File

@@ -14,69 +14,15 @@
const invariant = require('fbjs/lib/invariant');
let caughtError = null;
let invokeGuardedCallback = function(name, func, context, a, b, c, d, e, f) {
const funcArgs = Array.prototype.slice.call(arguments, 3);
try {
func.apply(context, funcArgs);
} catch (error) {
return error;
}
return null;
};
if (__DEV__) {
/**
* To help development we can get better devtools integration by simulating a
* real browser event.
*/
if (
typeof window !== 'undefined' &&
typeof window.dispatchEvent === 'function' &&
typeof document !== 'undefined' &&
typeof document.createEvent === 'function'
) {
const fakeNode = document.createElement('react');
let depth = 0;
invokeGuardedCallback = function(name, func, context, a, b, c, d, e, f) {
depth++;
const thisDepth = depth;
const funcArgs = Array.prototype.slice.call(arguments, 3);
const boundFunc = function() {
func.apply(context, funcArgs);
};
let fakeEventError = null;
const onFakeEventError = function(event) {
// Don't capture nested errors
if (depth === thisDepth) {
fakeEventError = event.error;
}
};
const evtType = `react-${name ? name : 'invokeguardedcallback'}-${depth}`;
window.addEventListener('error', onFakeEventError);
fakeNode.addEventListener(evtType, boundFunc, false);
const evt = document.createEvent('Event');
evt.initEvent(evtType, false, false);
fakeNode.dispatchEvent(evt);
fakeNode.removeEventListener(evtType, boundFunc, false);
window.removeEventListener('error', onFakeEventError);
depth--;
return fakeEventError;
};
}
}
let rethrowCaughtError = function() {
if (caughtError) {
const error = caughtError;
caughtError = null;
throw error;
}
};
const ReactErrorUtils = {
// Used by Fiber to simulate a try-catch.
_caughtError: null,
_hasCaughtError: false,
// Used by event system to capture/rethrow the first error.
_rethrowError: null,
_hasRethrowError: false,
injection: {
injectErrorUtils(injectedErrorUtils: Object) {
invariant(
@@ -106,8 +52,8 @@ const ReactErrorUtils = {
d: D,
e: E,
f: F,
): Error | null {
return invokeGuardedCallback.apply(this, arguments);
): void {
invokeGuardedCallback.apply(ReactErrorUtils, arguments);
},
/**
@@ -130,9 +76,13 @@ const ReactErrorUtils = {
e: E,
f: F,
): void {
const error = ReactErrorUtils.invokeGuardedCallback.apply(this, arguments);
if (error !== null && caughtError === null) {
caughtError = error;
ReactErrorUtils.invokeGuardedCallback.apply(this, arguments);
if (ReactErrorUtils.hasCaughtError()) {
const error = ReactErrorUtils.clearCaughtError();
if (!ReactErrorUtils._hasRethrowError) {
ReactErrorUtils._hasRethrowError = true;
ReactErrorUtils._rethrowError = error;
}
}
},
@@ -141,8 +91,151 @@ const ReactErrorUtils = {
* we will rethrow to be handled by the top level error handler.
*/
rethrowCaughtError: function() {
return rethrowCaughtError.apply(this, arguments);
return rethrowCaughtError.apply(ReactErrorUtils, arguments);
},
hasCaughtError: function() {
return ReactErrorUtils._hasCaughtError;
},
clearCaughtError: function() {
if (ReactErrorUtils._hasCaughtError) {
const error = ReactErrorUtils._caughtError;
ReactErrorUtils._caughtError = null;
ReactErrorUtils._hasCaughtError = false;
return error;
} else {
invariant(
false,
'clearCaughtError was called but no error was captured. This error ' +
'is likely caused by a bug in React. Please file an issue.',
);
}
},
};
let invokeGuardedCallback = function(name, func, context, a, b, c, d, e, f) {
ReactErrorUtils._hasCaughtError = false;
ReactErrorUtils._caughtError = null;
const funcArgs = Array.prototype.slice.call(arguments, 3);
try {
func.apply(context, funcArgs);
} catch (error) {
ReactErrorUtils._caughtError = error;
ReactErrorUtils._hasCaughtError = true;
}
};
if (__DEV__) {
const ReactFeatureFlags = require('ReactFeatureFlags');
if (
typeof window !== 'undefined' &&
typeof window.dispatchEvent === 'function' &&
typeof document !== 'undefined' &&
typeof document.createEvent === 'function'
) {
let preventDefault = true;
/**
* To help development we can get better devtools integration by simulating a
* real browser event.
*/
const fakeNode = document.createElement('react');
let depth = 0;
const invokeGuardedCallbackDev = function(
name,
func,
context,
a,
b,
c,
d,
e,
f,
) {
ReactErrorUtils._hasCaughtError = false;
ReactErrorUtils._caughtError = null;
depth++;
const thisDepth = depth;
const funcArgs = Array.prototype.slice.call(arguments, 3);
const boundFunc = function() {
func.apply(context, funcArgs);
};
const onFakeEventError = function(event) {
// Don't capture nested errors
if (depth === thisDepth) {
ReactErrorUtils._caughtError = event.error;
ReactErrorUtils._hasCaughtError = true;
}
if (preventDefault) {
event.preventDefault();
}
};
const evtType = `react-${name ? name : 'invokeguardedcallback'}-${depth}`;
window.addEventListener('error', onFakeEventError);
fakeNode.addEventListener(evtType, boundFunc, false);
const evt = document.createEvent('Event');
evt.initEvent(evtType, false, false);
fakeNode.dispatchEvent(evt);
fakeNode.removeEventListener(evtType, boundFunc, false);
window.removeEventListener('error', onFakeEventError);
depth--;
};
// Feature test the development version of invokeGuardedCallback
// before enabling.
let useInvokeGuardedCallbackDev;
if (ReactFeatureFlags.forceInvokeGuardedCallbackDev) {
// jsdom doesn't handle throwing null correctly (it fails when attempting
// to access the 'message' property) but we need the ability to test it.
// We use a feature flag to override the default feature test.
useInvokeGuardedCallbackDev = true;
} else {
try {
const err = new Error('test');
invokeGuardedCallbackDev(
null,
() => {
throw err;
},
null,
);
const A = ReactErrorUtils.clearCaughtError();
invokeGuardedCallbackDev(
null,
() => {
throw null;
},
null,
);
const B = ReactErrorUtils.clearCaughtError();
if (A === err && B === null) {
useInvokeGuardedCallbackDev = true;
}
} catch (e) {
useInvokeGuardedCallbackDev = false;
}
}
if (useInvokeGuardedCallbackDev) {
invokeGuardedCallback = invokeGuardedCallbackDev;
preventDefault = false;
}
}
}
let rethrowCaughtError = function() {
if (ReactErrorUtils._hasRethrowError) {
const error = ReactErrorUtils._rethrowError;
ReactErrorUtils._rethrowError = null;
ReactErrorUtils._hasRethrowError = false;
throw error;
}
};
module.exports = ReactErrorUtils;

View File

@@ -15,6 +15,8 @@
var ReactFeatureFlags = {
disableNewFiberFeatures: false,
enableAsyncSubtreeAPI: false,
// We set this to true when running unit tests
forceInvokeGuardedCallbackDev: false,
};
module.exports = ReactFeatureFlags;

View File

@@ -45,7 +45,7 @@ describe('ReactErrorUtils', () => {
});
function invokeGuardedCallbackTests(environment) {
it(`it should rethrow errors caught by invokeGuardedCallbackAndCatchFirstError (${environment})`, () => {
it(`it should rethrow caught errors (${environment})`, () => {
var err = new Error('foo');
var callback = function() {
throw err;
@@ -55,6 +55,7 @@ describe('ReactErrorUtils', () => {
callback,
null,
);
expect(ReactErrorUtils.hasCaughtError()).toBe(false);
expect(() => ReactErrorUtils.rethrowCaughtError()).toThrow(err);
});
@@ -82,7 +83,7 @@ describe('ReactErrorUtils', () => {
expect(context.didCall).toBe(true);
});
it(`should return a caught error (${environment})`, () => {
it(`should catch errors (${environment})`, () => {
const error = new Error();
const returnValue = ReactErrorUtils.invokeGuardedCallback(
'foo',
@@ -93,37 +94,39 @@ describe('ReactErrorUtils', () => {
'arg1',
'arg2',
);
expect(returnValue).toBe(error);
expect(returnValue).toBe(undefined);
expect(ReactErrorUtils.hasCaughtError()).toBe(true);
expect(ReactErrorUtils.clearCaughtError()).toBe(error);
});
it(`should return null if no error is thrown (${environment})`, () => {
it(`should return false from clearCaughtError if no error was thrown (${environment})`, () => {
var callback = jest.fn();
const returnValue = ReactErrorUtils.invokeGuardedCallback(
'foo',
callback,
null,
);
expect(returnValue).toBe(null);
ReactErrorUtils.invokeGuardedCallback('foo', callback, null);
expect(ReactErrorUtils.hasCaughtError()).toBe(false);
expect(ReactErrorUtils.clearCaughtError).toThrow('no error');
});
it(`can nest with same debug name (${environment})`, () => {
const err1 = new Error();
let err2;
const err3 = new Error();
const err4 = ReactErrorUtils.invokeGuardedCallback(
let err4;
ReactErrorUtils.invokeGuardedCallback(
'foo',
function() {
err2 = ReactErrorUtils.invokeGuardedCallback(
ReactErrorUtils.invokeGuardedCallback(
'foo',
function() {
throw err1;
},
null,
);
err2 = ReactErrorUtils.clearCaughtError();
throw err3;
},
null,
);
err4 = ReactErrorUtils.clearCaughtError();
expect(err2).toBe(err1);
expect(err4).toBe(err3);
@@ -132,36 +135,56 @@ describe('ReactErrorUtils', () => {
it(`does not return nested errors (${environment})`, () => {
const err1 = new Error();
let err2;
const err3 = ReactErrorUtils.invokeGuardedCallback(
ReactErrorUtils.invokeGuardedCallback(
'foo',
function() {
err2 = ReactErrorUtils.invokeGuardedCallback(
ReactErrorUtils.invokeGuardedCallback(
'foo',
function() {
throw err1;
},
null,
);
err2 = ReactErrorUtils.clearCaughtError();
},
null,
);
// Returns null because inner error was already captured
expect(ReactErrorUtils.hasCaughtError()).toBe(false);
expect(err3).toBe(null); // Returns null because inner error was already captured
expect(err2).toBe(err1);
});
if (environment === 'production') {
// jsdom doesn't handle this properly, but Chrome and Firefox should. Test
// this with a fixture.
it('catches null values', () => {
ReactErrorUtils.invokeGuardedCallback(
null,
function() {
throw null;
},
null,
);
expect(ReactErrorUtils.hasCaughtError()).toBe(true);
expect(ReactErrorUtils.clearCaughtError()).toBe(null);
});
}
it(`can be shimmed (${environment})`, () => {
const ops = [];
// Override the original invokeGuardedCallback
ReactErrorUtils.invokeGuardedCallback = function(name, func, context, a) {
ops.push(a);
try {
func.call(context, a);
} catch (error) {
return error;
}
return null;
};
ReactErrorUtils.injection.injectErrorUtils({
invokeGuardedCallback(name, func, context, a) {
ops.push(a);
try {
func.call(context, a);
} catch (error) {
this._hasCaughtError = true;
this._caughtError = error;
}
},
});
var err = new Error('foo');
var callback = function() {
@@ -174,9 +197,6 @@ describe('ReactErrorUtils', () => {
'somearg',
);
expect(() => ReactErrorUtils.rethrowCaughtError()).toThrow(err);
// invokeGuardedCallbackAndCatchFirstError and rethrowCaughtError close
// over ReactErrorUtils.invokeGuardedCallback so should use the
// shimmed version.
expect(ops).toEqual(['somearg']);
});
}