Validate callbacks just before they are invoked

This delays the type check until the point when the engine will have to
perform a type check regardless.

Because the error is now thrown during the commit phase rather than
when the callback is originally scheduled, we warn in DEV when
scheduling so it's easier to track down.
This commit is contained in:
Andrew Clark
2017-02-09 15:35:59 -08:00
parent 662170673f
commit d3aeca1ff8
9 changed files with 208 additions and 80 deletions
+72 -21
View File
@@ -901,6 +901,8 @@ describe('ReactUpdates', () => {
});
it('throws in setState if the update callback is not a function', () => {
spyOn(console, 'error');
function Foo() {
this.a = 1;
this.b = 2;
@@ -917,20 +919,37 @@ describe('ReactUpdates', () => {
var component = ReactTestUtils.renderIntoDocument(<A />);
expect(() => component.setState({}, 'no')).toThrowError(
'setState(...): Expected the last optional `callback` argument ' +
'to be a function. Instead received: string.'
'Invalid argument passed as callback. Expected a function. Instead ' +
'received: no',
);
expect(() => component.setState({}, {})).toThrowError(
'setState(...): Expected the last optional `callback` argument ' +
'to be a function. Instead received: Object.'
expectDev(console.error.calls.argsFor(0)[0]).toContain(
'setState(...): Expected the last optional `callback` argument to be ' +
'a function. Instead received: no.'
);
component = ReactTestUtils.renderIntoDocument(<A />);
expect(() => component.setState({}, {foo: 'bar'})).toThrowError(
'Invalid argument passed as callback. Expected a function. Instead ' +
'received: [object Object]',
);
expectDev(console.error.calls.argsFor(1)[0]).toContain(
'setState(...): Expected the last optional `callback` argument to be ' +
'a function. Instead received: [object Object].'
);
component = ReactTestUtils.renderIntoDocument(<A />);
expect(() => component.setState({}, new Foo())).toThrowError(
'setState(...): Expected the last optional `callback` argument ' +
'to be a function. Instead received: Foo (keys: a, b).'
'Invalid argument passed as callback. Expected a function. Instead ' +
'received: [object Object]',
);
expectDev(console.error.calls.argsFor(2)[0]).toContain(
'setState(...): Expected the last optional `callback` argument to be ' +
'a function. Instead received: [object Object].'
);
expect(console.error.calls.count()).toBe(3);
});
it('throws in replaceState if the update callback is not a function', () => {
spyOn(console, 'error');
function Foo() {
this.a = 1;
this.b = 2;
@@ -946,20 +965,37 @@ describe('ReactUpdates', () => {
var component = ReactTestUtils.renderIntoDocument(<A />);
expect(() => component.replaceState({}, 'no')).toThrowError(
'replaceState(...): Expected the last optional `callback` argument ' +
'to be a function. Instead received: string.'
'Invalid argument passed as callback. Expected a function. Instead ' +
'received: no',
);
expect(() => component.replaceState({}, {})).toThrowError(
'replaceState(...): Expected the last optional `callback` argument ' +
'to be a function. Instead received: Object.'
expectDev(console.error.calls.argsFor(0)[0]).toContain(
'replaceState(...): Expected the last optional `callback` argument to be ' +
'a function. Instead received: no.'
);
component = ReactTestUtils.renderIntoDocument(<A />);
expect(() => component.replaceState({}, {foo: 'bar'})).toThrowError(
'Invalid argument passed as callback. Expected a function. Instead ' +
'received: [object Object]',
);
expectDev(console.error.calls.argsFor(1)[0]).toContain(
'replaceState(...): Expected the last optional `callback` argument to be ' +
'a function. Instead received: [object Object].'
);
component = ReactTestUtils.renderIntoDocument(<A />);
expect(() => component.replaceState({}, new Foo())).toThrowError(
'replaceState(...): Expected the last optional `callback` argument ' +
'to be a function. Instead received: Foo (keys: a, b).'
'Invalid argument passed as callback. Expected a function. Instead ' +
'received: [object Object]',
);
expectDev(console.error.calls.argsFor(2)[0]).toContain(
'replaceState(...): Expected the last optional `callback` argument to be ' +
'a function. Instead received: [object Object].'
);
expect(console.error.calls.count()).toBe(3);
});
it('throws in forceUpdate if the update callback is not a function', () => {
spyOn(console, 'error');
function Foo() {
this.a = 1;
this.b = 2;
@@ -976,17 +1012,32 @@ describe('ReactUpdates', () => {
var component = ReactTestUtils.renderIntoDocument(<A />);
expect(() => component.forceUpdate('no')).toThrowError(
'forceUpdate(...): Expected the last optional `callback` argument ' +
'to be a function. Instead received: string.'
'Invalid argument passed as callback. Expected a function. Instead ' +
'received: no',
);
expect(() => component.forceUpdate({})).toThrowError(
'forceUpdate(...): Expected the last optional `callback` argument ' +
'to be a function. Instead received: Object.'
expectDev(console.error.calls.argsFor(0)[0]).toContain(
'forceUpdate(...): Expected the last optional `callback` argument to be ' +
'a function. Instead received: no.'
);
component = ReactTestUtils.renderIntoDocument(<A />);
expect(() => component.forceUpdate({foo: 'bar'})).toThrowError(
'Invalid argument passed as callback. Expected a function. Instead ' +
'received: [object Object]',
);
expectDev(console.error.calls.argsFor(1)[0]).toContain(
'forceUpdate(...): Expected the last optional `callback` argument to be ' +
'a function. Instead received: [object Object].'
);
component = ReactTestUtils.renderIntoDocument(<A />);
expect(() => component.forceUpdate(new Foo())).toThrowError(
'forceUpdate(...): Expected the last optional `callback` argument ' +
'to be a function. Instead received: Foo (keys: a, b).'
'Invalid argument passed as callback. Expected a function. Instead ' +
'received: [object Object]',
);
expectDev(console.error.calls.argsFor(2)[0]).toContain(
'forceUpdate(...): Expected the last optional `callback` argument to be ' +
'a function. Instead received: [object Object].'
);
expect(console.error.calls.count()).toBe(3);
});
it('does not update one component twice in a batch (#2410)', () => {
@@ -118,6 +118,8 @@ describe('ReactDOM', () => {
});
it('throws in render() if the mount callback is not a function', () => {
spyOn(console, 'error');
function Foo() {
this.a = 1;
this.b = 2;
@@ -133,20 +135,35 @@ describe('ReactDOM', () => {
var myDiv = document.createElement('div');
expect(() => ReactDOM.render(<A />, myDiv, 'no')).toThrowError(
'render(...): Expected the last optional `callback` argument ' +
'to be a function. Instead received: string.'
'Invalid argument passed as callback. Expected a function. Instead ' +
'received: no',
);
expect(() => ReactDOM.render(<A />, myDiv, {})).toThrowError(
'render(...): Expected the last optional `callback` argument ' +
'to be a function. Instead received: Object.'
expectDev(console.error.calls.argsFor(0)[0]).toContain(
'render(...): Expected the last optional `callback` argument to be ' +
'a function. Instead received: no.'
);
expect(() => ReactDOM.render(<A />, myDiv, {foo: 'bar'})).toThrowError(
'Invalid argument passed as callback. Expected a function. Instead ' +
'received: [object Object]',
);
expectDev(console.error.calls.argsFor(1)[0]).toContain(
'render(...): Expected the last optional `callback` argument to be ' +
'a function. Instead received: [object Object].'
);
expect(() => ReactDOM.render(<A />, myDiv, new Foo())).toThrowError(
'render(...): Expected the last optional `callback` argument ' +
'to be a function. Instead received: Foo (keys: a, b).'
'Invalid argument passed as callback. Expected a function. Instead ' +
'received: [object Object]',
);
expectDev(console.error.calls.argsFor(2)[0]).toContain(
'render(...): Expected the last optional `callback` argument to be ' +
'a function. Instead received: [object Object].'
);
expect(console.error.calls.count()).toBe(3);
});
it('throws in render() if the update callback is not a function', () => {
spyOn(console, 'error');
function Foo() {
this.a = 1;
this.b = 2;
@@ -162,19 +179,33 @@ describe('ReactDOM', () => {
var myDiv = document.createElement('div');
ReactDOM.render(<A />, myDiv);
expect(() => ReactDOM.render(<A />, myDiv, 'no')).toThrowError(
'render(...): Expected the last optional `callback` argument ' +
'to be a function. Instead received: string.'
'Invalid argument passed as callback. Expected a function. Instead ' +
'received: no',
);
expect(() => ReactDOM.render(<A />, myDiv, {})).toThrowError(
'render(...): Expected the last optional `callback` argument ' +
'to be a function. Instead received: Object.'
expectDev(console.error.calls.argsFor(0)[0]).toContain(
'render(...): Expected the last optional `callback` argument to be ' +
'a function. Instead received: no.'
);
ReactDOM.render(<A />, myDiv); // Re-mount
expect(() => ReactDOM.render(<A />, myDiv, {foo: 'bar'})).toThrowError(
'Invalid argument passed as callback. Expected a function. Instead ' +
'received: [object Object]',
);
expectDev(console.error.calls.argsFor(1)[0]).toContain(
'render(...): Expected the last optional `callback` argument to be ' +
'a function. Instead received: [object Object].'
);
ReactDOM.render(<A />, myDiv); // Re-mount
expect(() => ReactDOM.render(<A />, myDiv, new Foo())).toThrowError(
'render(...): Expected the last optional `callback` argument ' +
'to be a function. Instead received: Foo (keys: a, b).'
'Invalid argument passed as callback. Expected a function. Instead ' +
'received: [object Object]',
);
expectDev(console.error.calls.argsFor(2)[0]).toContain(
'render(...): Expected the last optional `callback` argument to be ' +
'a function. Instead received: [object Object].'
);
expect(console.error.calls.count()).toBe(3);
});
it('preserves focus', () => {
+11 -1
View File
@@ -384,6 +384,7 @@ var ReactMount = {
if (callback) {
componentInstance._pendingCallbacks = [function() {
validateCallback(callback);
callback.call(componentInstance._renderedComponent.getPublicInstance());
}];
}
@@ -433,7 +434,15 @@ var ReactMount = {
},
_renderSubtreeIntoContainer: function(parentComponent, nextElement, container, callback) {
validateCallback(callback, 'ReactDOM.render');
callback = callback === undefined ? null : callback;
if (__DEV__) {
warning(
callback === null || typeof callback === 'function',
'render(...): Expected the last optional `callback` argument to be a ' +
'function. Instead received: %s.',
String(callback)
);
}
if (!React.isValidElement(nextElement)) {
if (typeof nextElement === 'string') {
invariant(
@@ -489,6 +498,7 @@ var ReactMount = {
if (shouldUpdateReactComponent(prevElement, nextElement)) {
var publicInst = prevComponent._renderedComponent.getPublicInstance();
var updatedCallback = callback && function() {
validateCallback(callback);
callback.call(publicInst);
};
ReactMount._updateRootComponent(
@@ -35,11 +35,23 @@ var { getComponentName, isMounted } = require('ReactFiberTreeReflection');
var ReactInstanceMap = require('ReactInstanceMap');
var emptyObject = require('emptyObject');
var shallowEqual = require('shallowEqual');
var warning = require('warning');
var invariant = require('invariant');
const isArray = Array.isArray;
if (__DEV__) {
var warning = require('warning');
var warnOnInvalidCallback = function(callback : mixed, callerName : string) {
warning(
callback === null || typeof callback === 'function',
'%s(...): Expected the last optional `callback` argument to be a ' +
'function. Instead received: %s.',
callerName,
String(callback)
);
};
}
module.exports = function(
scheduleUpdate : (fiber : Fiber, priorityLevel : PriorityLevel) => void,
getPriorityContext : () => PriorityLevel,
@@ -53,19 +65,31 @@ module.exports = function(
enqueueSetState(instance, partialState, callback) {
const fiber = ReactInstanceMap.get(instance);
const priorityLevel = getPriorityContext();
addUpdate(fiber, partialState, callback || null, priorityLevel);
callback = callback === undefined ? null : callback;
if (__DEV__) {
warnOnInvalidCallback(callback, 'setState');
}
addUpdate(fiber, partialState, callback, priorityLevel);
scheduleUpdate(fiber, priorityLevel);
},
enqueueReplaceState(instance, state, callback) {
const fiber = ReactInstanceMap.get(instance);
const priorityLevel = getPriorityContext();
addReplaceUpdate(fiber, state, callback || null, priorityLevel);
callback = callback === undefined ? null : callback;
if (__DEV__) {
warnOnInvalidCallback(callback, 'replaceState');
}
addReplaceUpdate(fiber, state, callback, priorityLevel);
scheduleUpdate(fiber, priorityLevel);
},
enqueueForceUpdate(instance, callback) {
const fiber = ReactInstanceMap.get(instance);
const priorityLevel = getPriorityContext();
addForceUpdate(fiber, callback || null, priorityLevel);
callback = callback === undefined ? null : callback;
if (__DEV__) {
warnOnInvalidCallback(callback, 'forceUpdate');
}
addForceUpdate(fiber, callback, priorityLevel);
scheduleUpdate(fiber, priorityLevel);
},
};
@@ -30,6 +30,7 @@ var { createFiberRoot } = require('ReactFiberRoot');
var ReactFiberScheduler = require('ReactFiberScheduler');
if (__DEV__) {
var warning = require('warning');
var ReactFiberInstrumentation = require('ReactFiberInstrumentation');
}
@@ -145,6 +146,15 @@ module.exports = function<T, P, I, TI, PI, C, CX, PL>(
function scheduleTopLevelUpdate(current : Fiber, element : ReactNodeList, callback : ?Function) {
const priorityLevel = getPriorityContext();
const nextState = { element };
callback = callback === undefined ? null : callback;
if (__DEV__) {
warning(
callback === null || typeof callback === 'function',
'render(...): Expected the last optional `callback` argument to be a ' +
'function. Instead received: %s.',
String(callback)
);
}
addTopLevelUpdate(current, nextState, callback || null, priorityLevel);
scheduleUpdate(current, priorityLevel);
}
@@ -25,14 +25,17 @@ const {
TaskPriority,
} = require('ReactPriorityLevel');
const validateCallback = require('validateCallback');
const warning = require('warning');
const invariant = require('invariant');
if (__DEV__) {
var warning = require('warning');
}
type PartialState<State, Props> =
$Subtype<State> |
(prevState: State, props: Props) => $Subtype<State>;
type Callback = () => void;
// Callbacks are not validated until invocation
type Callback = mixed;
type Update = {
priorityLevel: PriorityLevel,
@@ -215,8 +218,6 @@ function findInsertionPosition(queue, update) : Update | null {
//
// If the update is cloned, it returns the cloned update.
function insertUpdate(fiber : Fiber, update : Update, methodName : string) : Update | null {
validateCallback(update.callback, methodName);
const queue1 = ensureUpdateQueue(fiber);
const queue2 = fiber.alternate ? ensureUpdateQueue(fiber.alternate) : null;
@@ -287,7 +288,7 @@ function insertUpdate(fiber : Fiber, update : Update, methodName : string) : Upd
function addUpdate(
fiber : Fiber,
partialState : PartialState<any, any> | null,
callback : Callback | null,
callback : mixed,
priorityLevel : PriorityLevel
) : void {
const update = {
@@ -446,7 +447,7 @@ function beginUpdateQueue(
}
// Second condition ignores top-level unmount callbacks if they are not the
// last update in the queue, since a subsequent update will cause a remount.
if (update.callback && !(update.isTopLevelUnmount && update.next)) {
if (update.callback !== null && !(update.isTopLevelUnmount && update.next !== null)) {
callbackList = callbackList || [];
callbackList.push(update.callback);
workInProgress.effectTag |= CallbackEffect;
@@ -476,6 +477,12 @@ function commitCallbacks(finishedWork : Fiber, queue : UpdateQueue, context : mi
}
for (let i = 0; i < callbackList.length; i++) {
const callback = callbackList[i];
invariant(
typeof callback === 'function',
'Invalid argument passed as callback. Expected a function. Instead ' +
'received: %s',
String(callback)
);
callback.call(context);
}
}
@@ -15,6 +15,7 @@
var PooledClass = require('PooledClass');
var invariant = require('invariant');
var validateCallback = require('validateCallback');
/**
* A specialized pseudo-event module to help keep track of components waiting to
@@ -70,6 +71,7 @@ class CallbackQueue<T> {
this._callbacks = null;
this._contexts = null;
for (var i = 0; i < callbacks.length; i++) {
validateCallback(callbacks[i]);
callbacks[i].call(contexts[i], arg);
}
callbacks.length = 0;
@@ -16,8 +16,18 @@ var ReactInstanceMap = require('ReactInstanceMap');
var ReactInstrumentation = require('ReactInstrumentation');
var ReactUpdates = require('ReactUpdates');
var warning = require('warning');
var validateCallback = require('validateCallback');
if (__DEV__) {
var warning = require('warning');
var warnOnInvalidCallback = function(callback : mixed, callerName : string) {
warning(
callback === null || typeof callback === 'function',
'%s(...): Expected the last optional `callback` argument to be a ' +
'function. Instead received: %s.',
callerName,
String(callback)
);
};
}
function enqueueUpdate(internalInstance) {
ReactUpdates.enqueueUpdate(internalInstance);
@@ -134,7 +144,8 @@ var ReactUpdateQueue = {
}
if (callback) {
validateCallback(callback, callerName);
callback = callback === undefined ? null : callback;
warnOnInvalidCallback(callback, callerName);
if (internalInstance._pendingCallbacks) {
internalInstance._pendingCallbacks.push(callback);
} else {
@@ -174,7 +185,8 @@ var ReactUpdateQueue = {
internalInstance._pendingReplaceState = true;
if (callback) {
validateCallback(callback, callerName);
callback = callback === undefined ? null : callback;
warnOnInvalidCallback(callback, callerName);
if (internalInstance._pendingCallbacks) {
internalInstance._pendingCallbacks.push(callback);
} else {
@@ -222,7 +234,8 @@ var ReactUpdateQueue = {
queue.push(partialState);
if (callback) {
validateCallback(callback, callerName);
callback = callback === undefined ? null : callback;
warnOnInvalidCallback(callback, callerName);
if (internalInstance._pendingCallbacks) {
internalInstance._pendingCallbacks.push(callback);
} else {
+7 -27
View File
@@ -14,33 +14,13 @@
const invariant = require('invariant');
function formatUnexpectedArgument(arg: any) {
let type = typeof arg;
if (type !== 'object') {
return type;
}
let displayName = arg.constructor && arg.constructor.name || type;
let keys = Object.keys(arg);
if (keys.length > 0 && keys.length < 20) {
return `${displayName} (keys: ${keys.join(', ')})`;
}
return displayName;
}
function validateCallback(callback: ?Function, callerName: string) {
if (
callback !== null &&
callback !== undefined &&
typeof callback !== 'function'
) {
invariant(
false,
'%s(...): Expected the last optional `callback` argument to be a ' +
'function. Instead received: %s.',
callerName,
formatUnexpectedArgument(callback)
);
}
function validateCallback(callback: ?Function) {
invariant(
!callback || typeof callback === 'function',
'Invalid argument passed as callback. Expected a function. Instead ' +
'received: %s',
String(callback)
);
}
module.exports = validateCallback;