From c6665e346030f73b7ebb615ce1affe8396b0dab8 Mon Sep 17 00:00:00 2001 From: ngavalas Date: Fri, 14 Jun 2013 16:23:06 -0700 Subject: [PATCH 1/5] Adds optional callback to `setState` This commit adds an optional callback as a second argument to `setState`, to be called after `setState` runs. We never guarantee synchronous execution of `setState`, and as per @phunt, we don't want to make that guarantee because we may eventually batch calls to `setState`. @jwalke agrees with him. --- src/core/ReactCompositeComponent.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/core/ReactCompositeComponent.js b/src/core/ReactCompositeComponent.js index ac2e57d93cb..03f17019f1b 100644 --- a/src/core/ReactCompositeComponent.js +++ b/src/core/ReactCompositeComponent.js @@ -525,13 +525,21 @@ var ReactCompositeComponentMixin = { * There is no guarantee that `this.state` will be immediately updated, so * accessing `this.state` after calling this method may return the old value. * + * There is no guarantee that calls to `setState` will run synchronously, + * as they may eventually be batched together. You can provide an optional + * callback that will be executed when the call to setState is actually + * completed. + * * @param {object} partialState Next partial state to be merged with state. + * @param {?function} callback Called after state is updated. * @final * @protected */ - setState: function(partialState) { + setState: function(partialState, callback) { // Merge with `_pendingState` if it exists, otherwise with existing state. this.replaceState(merge(this._pendingState || this.state, partialState)); + // If `callback` is callable, do it. + typeof callback === 'function' && callback(); }, /** From f3aac85d01d87bca593170f6ae0935c9aaf9084c Mon Sep 17 00:00:00 2001 From: ngavalas Date: Fri, 14 Jun 2013 16:37:20 -0700 Subject: [PATCH 2/5] Updated docs and check for truthiness Change api docs to reflect presence of the new argument. In addition, callback was change to require only a "truthy" value. --- docs/docs/api.md | 6 ++++-- src/core/ReactCompositeComponent.js | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/docs/api.md b/docs/docs/api.md index fd29070ecbc..cc73f2e3a37 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -108,15 +108,17 @@ Transfer properties from this component to a target component that have not alre #### setState ```javascript -setState(object nextState) +setState(object nextState_[, function callback]_) ``` -Merges nextState with the current state. This is the primary method you use to trigger UI updates from event handlers and server request callbacks. +Merges nextState with the current state. This is the primary method you use to trigger UI updates from event handlers and server request callbacks. In addition, you can supply an optional callback function that is executed once `setState` is completed. **Note:** *NEVER* mutate `this.state` directly. As calling `setState()` afterwards may replace the mutation you made. Treat `this.state` as if it were immutable. **Note:** `setState()` does not immediately mutate `this.state` but creates a pending state transition. Accessing `this.state` after calling this method can potentially return the existing value. +**Note**: There is no guarantee of synchronous operation of calls to `setState` and calls may eventually be batched for performance gains. + #### replaceState ```javascript diff --git a/src/core/ReactCompositeComponent.js b/src/core/ReactCompositeComponent.js index 03f17019f1b..7a98769c0d0 100644 --- a/src/core/ReactCompositeComponent.js +++ b/src/core/ReactCompositeComponent.js @@ -538,8 +538,8 @@ var ReactCompositeComponentMixin = { setState: function(partialState, callback) { // Merge with `_pendingState` if it exists, otherwise with existing state. this.replaceState(merge(this._pendingState || this.state, partialState)); - // If `callback` is callable, do it. - typeof callback === 'function' && callback(); + // If `callback` is truthy, do it. + callback && callback(); }, /** From c81cc2e6d5dfe6f26ed1f5c971e162b62fcad709 Mon Sep 17 00:00:00 2001 From: ngavalas Date: Fri, 14 Jun 2013 16:41:02 -0700 Subject: [PATCH 3/5] markdown syntax Small problem with markdown syntax in syntax-highlighted block. --- docs/docs/api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/api.md b/docs/docs/api.md index cc73f2e3a37..1ae5e97b37f 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -108,7 +108,7 @@ Transfer properties from this component to a target component that have not alre #### setState ```javascript -setState(object nextState_[, function callback]_) +setState(object nextState[, function callback]) ``` Merges nextState with the current state. This is the primary method you use to trigger UI updates from event handlers and server request callbacks. In addition, you can supply an optional callback function that is executed once `setState` is completed. From 7a0f2d71bbfd6ea03eb239830855d992c1ecb841 Mon Sep 17 00:00:00 2001 From: ngavalas Date: Sun, 16 Jun 2013 22:45:36 -0700 Subject: [PATCH 4/5] Add callbacks to all public-facing state/props methods All public facing {set,replace,force}{props,state} methods now support callbacks. --- src/core/ReactComponent.js | 10 +++++++--- src/core/ReactCompositeComponent.js | 15 ++++++++++----- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/core/ReactComponent.js b/src/core/ReactComponent.js index b9aa2330c49..3353310b1f8 100644 --- a/src/core/ReactComponent.js +++ b/src/core/ReactComponent.js @@ -231,21 +231,23 @@ var ReactComponent = { * Sets a subset of the props. * * @param {object} partialProps Subset of the next props. + * @param {?function} callback Called after props are updated. * @final * @public */ - setProps: function(partialProps) { - this.replaceProps(merge(this.props, partialProps)); + setProps: function(partialProps, callback) { + this.replaceProps(merge(this.props, partialProps), callback); }, /** * Replaces all of the props. * * @param {object} props New props. + * @param {?function} callback Called after props are updated. * @final * @public */ - replaceProps: function(props) { + replaceProps: function(props, callback) { invariant( !this.props[OWNER], 'replaceProps(...): You called `setProps` or `replaceProps` on a ' + @@ -257,6 +259,8 @@ var ReactComponent = { var transaction = ReactComponent.ReactReconcileTransaction.getPooled(); transaction.perform(this.receiveProps, this, props, transaction); ReactComponent.ReactReconcileTransaction.release(transaction); + + callback && callback(); }, /** diff --git a/src/core/ReactCompositeComponent.js b/src/core/ReactCompositeComponent.js index 7a98769c0d0..78041c2a054 100644 --- a/src/core/ReactCompositeComponent.js +++ b/src/core/ReactCompositeComponent.js @@ -537,9 +537,7 @@ var ReactCompositeComponentMixin = { */ setState: function(partialState, callback) { // Merge with `_pendingState` if it exists, otherwise with existing state. - this.replaceState(merge(this._pendingState || this.state, partialState)); - // If `callback` is truthy, do it. - callback && callback(); + this.replaceState(merge(this._pendingState || this.state, partialState), callback); }, /** @@ -550,10 +548,11 @@ var ReactCompositeComponentMixin = { * accessing `this.state` after calling this method may return the old value. * * @param {object} completeState Next state. + * @param {?function} callback Called after state is updated. * @final * @protected */ - replaceState: function(completeState) { + replaceState: function(completeState, callback) { var compositeLifeCycleState = this._compositeLifeCycleState; invariant( this.isMounted() || @@ -590,6 +589,9 @@ var ReactCompositeComponentMixin = { this._compositeLifeCycleState = null; } + + // If callback is 'truthy', execute it + callback && callback(); }, /** @@ -712,10 +714,11 @@ var ReactCompositeComponentMixin = { * This will not invoke `shouldUpdateComponent`, but it will invoke * `componentWillUpdate` and `componentDidUpdate`. * + * @param {?function} callback Called after update is complete. * @final * @protected */ - forceUpdate: function() { + forceUpdate: function(callback) { var compositeLifeCycleState = this._compositeLifeCycleState; invariant( this.isMounted(), @@ -736,6 +739,8 @@ var ReactCompositeComponentMixin = { transaction ); ReactComponent.ReactReconcileTransaction.release(transaction); + + callback && callback(); }, /** From d9e99d468851a5e0c23c8d205bd953ee67819231 Mon Sep 17 00:00:00 2001 From: Ben Alpert Date: Thu, 27 Jun 2013 01:24:22 -0700 Subject: [PATCH 5/5] Batch together calls to setState, setProps, etc The end of ReactUpdates-test.js is probably most illuminating for seeing how this works. --- src/core/ReactComponent.js | 69 +++++- src/core/ReactCompositeComponent.js | 128 +++++----- src/core/ReactEventEmitter.js | 7 +- src/core/ReactMount.js | 19 +- src/core/ReactNativeComponent.js | 39 ++-- src/core/ReactUpdates.js | 98 ++++++++ src/core/__tests__/ReactUpdates-test.js | 295 ++++++++++++++++++++++++ src/core/__tests__/refs-test.js | 4 +- 8 files changed, 552 insertions(+), 107 deletions(-) create mode 100644 src/core/ReactUpdates.js create mode 100644 src/core/__tests__/ReactUpdates-test.js diff --git a/src/core/ReactComponent.js b/src/core/ReactComponent.js index e4f3ad46785..32c9b0b9ac5 100644 --- a/src/core/ReactComponent.js +++ b/src/core/ReactComponent.js @@ -26,6 +26,7 @@ var ReactID = require('ReactID'); var ReactMount = require('ReactMount'); var ReactOwner = require('ReactOwner'); var ReactReconcileTransaction = require('ReactReconcileTransaction'); +var ReactUpdates = require('ReactUpdates'); var invariant = require('invariant'); var keyMirror = require('keyMirror'); @@ -266,7 +267,11 @@ var ReactComponent = { * @public */ setProps: function(partialProps, callback) { - this.replaceProps(merge(this.props, partialProps), callback); + // Merge with `_pendingProps` if it exists, otherwise with existing props. + this.replaceProps( + merge(this._pendingProps || this.props, partialProps), + callback + ); }, /** @@ -286,11 +291,8 @@ var ReactComponent = { '`render` method to pass the correct value as props to the component ' + 'where it is created.' ); - var transaction = ReactComponent.ReactReconcileTransaction.getPooled(); - transaction.perform(this.receiveProps, this, props, transaction); - ReactComponent.ReactReconcileTransaction.release(transaction); - - callback && callback(); + this._pendingProps = props; + ReactUpdates.enqueueUpdate(this, callback); }, /** @@ -310,6 +312,9 @@ var ReactComponent = { // All components start unmounted. this._lifeCycleState = ComponentLifeCycle.UNMOUNTED; + this._pendingProps = null; + this._pendingCallbacks = null; + // Children can be more than one argument var childrenLength = arguments.length - 1; if (childrenLength === 1) { @@ -396,17 +401,59 @@ var ReactComponent = { this.isMounted(), 'receiveProps(...): Can only update a mounted component.' ); + this._pendingProps = nextProps; + this._performUpdateIfNecessary(transaction); + }, + + /** + * Call `_performUpdateIfNecessary` within a new transaction. + * + * @param {ReactReconcileTransaction} transaction + * @internal + */ + performUpdateIfNecessary: function() { + var transaction = ReactComponent.ReactReconcileTransaction.getPooled(); + transaction.perform(this._performUpdateIfNecessary, this, transaction); + ReactComponent.ReactReconcileTransaction.release(transaction); + }, + + /** + * If `_pendingProps` is set, update the component. + * + * @param {ReactReconcileTransaction} transaction + * @internal + */ + _performUpdateIfNecessary: function(transaction) { + if (this._pendingProps == null) { + return; + } + var prevProps = this.props; + this.props = this._pendingProps; + this._pendingProps = null; + this.updateComponent(transaction, prevProps); + }, + + /** + * Updates the component's currently mounted representation. + * + * @param {ReactReconcileTransaction} transaction + * @param {object} prevProps + * @internal + */ + updateComponent: function(transaction, prevProps) { var props = this.props; // If either the owner or a `ref` has changed, make sure the newest owner // has stored a reference to `this`, and the previous owner (if different) // has forgotten the reference to `this`. - if (nextProps[OWNER] !== props[OWNER] || nextProps.ref !== props.ref) { - if (props.ref != null) { - ReactOwner.removeComponentAsRefFrom(this, props.ref, props[OWNER]); + if (props[OWNER] !== prevProps[OWNER] || props.ref !== prevProps.ref) { + if (prevProps.ref != null) { + ReactOwner.removeComponentAsRefFrom( + this, prevProps.ref, prevProps[OWNER] + ); } // Correct, even if the owner is the same, and only the ref has changed. - if (nextProps.ref != null) { - ReactOwner.addComponentAsRefTo(this, nextProps.ref, nextProps[OWNER]); + if (props.ref != null) { + ReactOwner.addComponentAsRefTo(this, props.ref, props[OWNER]); } } }, diff --git a/src/core/ReactCompositeComponent.js b/src/core/ReactCompositeComponent.js index 524c828c13b..6f430a2f4ab 100644 --- a/src/core/ReactCompositeComponent.js +++ b/src/core/ReactCompositeComponent.js @@ -22,6 +22,7 @@ var ReactComponent = require('ReactComponent'); var ReactCurrentOwner = require('ReactCurrentOwner'); var ReactOwner = require('ReactOwner'); var ReactPropTransferer = require('ReactPropTransferer'); +var ReactUpdates = require('ReactUpdates'); var invariant = require('invariant'); var keyMirror = require('keyMirror'); @@ -507,6 +508,7 @@ var ReactCompositeComponentMixin = { this.state = this.getInitialState ? this.getInitialState() : null; this._pendingState = null; + this._pendingForceUpdate = false; if (this.componentWillMount) { this.componentWillMount(); @@ -558,31 +560,6 @@ var ReactCompositeComponentMixin = { // TODO: this.state = null; }, - /** - * Updates the rendered DOM nodes given a new set of props. - * - * @param {object} nextProps Next set of properties. - * @param {ReactReconcileTransaction} transaction - * @final - * @internal - */ - receiveProps: function(nextProps, transaction) { - this._processProps(nextProps); - ReactComponent.Mixin.receiveProps.call(this, nextProps, transaction); - - this._compositeLifeCycleState = CompositeLifeCycle.RECEIVING_PROPS; - if (this.componentWillReceiveProps) { - this.componentWillReceiveProps(nextProps, transaction); - } - this._compositeLifeCycleState = CompositeLifeCycle.RECEIVING_STATE; - // When receiving props, calls to `setState` by `componentWillReceiveProps` - // will set `this._pendingState` without triggering a re-render. - var nextState = this._pendingState || this.state; - this._pendingState = null; - this._receivePropsAndState(nextProps, nextState, transaction); - this._compositeLifeCycleState = null; - }, - /** * Sets a subset of the state. Always use this or `replaceState` to mutate * state. You should treat `this.state` as immutable. @@ -602,7 +579,10 @@ var ReactCompositeComponentMixin = { */ setState: function(partialState, callback) { // Merge with `_pendingState` if it exists, otherwise with existing state. - this.replaceState(merge(this._pendingState || this.state, partialState), callback); + this.replaceState( + merge(this._pendingState || this.state, partialState), + callback + ); }, /** @@ -618,33 +598,9 @@ var ReactCompositeComponentMixin = { * @protected */ replaceState: function(completeState, callback) { - var compositeLifeCycleState = this._compositeLifeCycleState; validateLifeCycleOnReplaceState.call(null, this); this._pendingState = completeState; - - // Do not trigger a state transition if we are in the middle of mounting or - // receiving props because both of those will already be doing this. - if (compositeLifeCycleState !== CompositeLifeCycle.MOUNTING && - compositeLifeCycleState !== CompositeLifeCycle.RECEIVING_PROPS) { - this._compositeLifeCycleState = CompositeLifeCycle.RECEIVING_STATE; - - var nextState = this._pendingState; - this._pendingState = null; - - var transaction = ReactComponent.ReactReconcileTransaction.getPooled(); - transaction.perform( - this._receivePropsAndState, - this, - this.props, - nextState, - transaction - ); - ReactComponent.ReactReconcileTransaction.release(transaction); - this._compositeLifeCycleState = null; - } - - // If callback is 'truthy', execute it - callback && callback(); + ReactUpdates.enqueueUpdate(this, callback); }, /** @@ -674,18 +630,52 @@ var ReactCompositeComponentMixin = { } }, + performUpdateIfNecessary: function() { + var compositeLifeCycleState = this._compositeLifeCycleState; + // Do not trigger a state transition if we are in the middle of mounting or + // receiving props because both of those will already be doing this. + if (compositeLifeCycleState === CompositeLifeCycle.MOUNTING || + compositeLifeCycleState === CompositeLifeCycle.RECEIVING_PROPS) { + return; + } + ReactComponent.Mixin.performUpdateIfNecessary.call(this); + }, + /** - * Receives next props and next state, and negotiates whether or not the - * component should update as a result. + * If any of `_pendingProps`, `_pendingState`, or `_pendingForceUpdate` is + * set, update the component. * - * @param {object} nextProps Next object to set as props. - * @param {?object} nextState Next object to set as state. * @param {ReactReconcileTransaction} transaction - * @private + * @internal */ - _receivePropsAndState: function(nextProps, nextState, transaction) { - if (!this.shouldComponentUpdate || + _performUpdateIfNecessary: function(transaction) { + if (this._pendingProps == null && + this._pendingState == null && + !this._pendingForceUpdate) { + return; + } + + var nextProps = this.props; + if (this._pendingProps != null) { + nextProps = this._pendingProps; + this._processProps(nextProps); + this._pendingProps = null; + + this._compositeLifeCycleState = CompositeLifeCycle.RECEIVING_PROPS; + if (this.componentWillReceiveProps) { + this.componentWillReceiveProps(nextProps, transaction); + } + } + + this._compositeLifeCycleState = CompositeLifeCycle.RECEIVING_STATE; + + var nextState = this._pendingState || this.state; + this._pendingState = null; + + if (this._pendingForceUpdate || + !this.shouldComponentUpdate || this.shouldComponentUpdate(nextProps, nextState)) { + this._pendingForceUpdate = false; // Will set `this.props` and `this.state`. this._performComponentUpdate(nextProps, nextState, transaction); } else { @@ -694,6 +684,8 @@ var ReactCompositeComponentMixin = { this.props = nextProps; this.state = nextState; } + + this._compositeLifeCycleState = null; }, /** @@ -716,7 +708,7 @@ var ReactCompositeComponentMixin = { this.props = nextProps; this.state = nextState; - this.updateComponent(transaction); + this.updateComponent(transaction, prevProps, prevState); if (this.componentDidUpdate) { transaction.getReactOnDOMReady().enqueue( @@ -733,10 +725,13 @@ var ReactCompositeComponentMixin = { * Sophisticated clients may wish to override this. * * @param {ReactReconcileTransaction} transaction + * @param {object} prevProps + * @param {?object} prevState * @internal * @overridable */ - updateComponent: function(transaction) { + updateComponent: function(transaction, prevProps, prevState) { + ReactComponent.Mixin.updateComponent.call(this, transaction, prevProps); var currentComponent = this._renderedComponent; var nextComponent = this._renderValidatedComponent(); if (currentComponent.constructor === nextComponent.constructor) { @@ -783,17 +778,8 @@ var ReactCompositeComponentMixin = { 'forceUpdate(...): Cannot force an update while unmounting component ' + 'or during an existing state transition (such as within `render`).' ); - var transaction = ReactComponent.ReactReconcileTransaction.getPooled(); - transaction.perform( - this._performComponentUpdate, - this, - this.props, - this.state, - transaction - ); - ReactComponent.ReactReconcileTransaction.release(transaction); - - callback && callback(); + this._pendingForceUpdate = true; + ReactUpdates.enqueueUpdate(this, callback); }, /** diff --git a/src/core/ReactEventEmitter.js b/src/core/ReactEventEmitter.js index aa6a81e2368..4f52e383d6b 100644 --- a/src/core/ReactEventEmitter.js +++ b/src/core/ReactEventEmitter.js @@ -23,6 +23,7 @@ var EventConstants = require('EventConstants'); var EventListener = require('EventListener'); var EventPluginHub = require('EventPluginHub'); var ExecutionEnvironment = require('ExecutionEnvironment'); +var ReactUpdates = require('ReactUpdates'); var ViewportMetrics = require('ViewportMetrics'); var invariant = require('invariant'); @@ -320,8 +321,10 @@ var ReactEventEmitter = { ); // Event queue being processed in the same cycle allows `preventDefault`. - EventPluginHub.enqueueEvents(events); - EventPluginHub.processEventQueue(); + ReactUpdates.batchedUpdates(function() { + EventPluginHub.enqueueEvents(events); + EventPluginHub.processEventQueue(); + }); }, registrationNames: EventPluginHub.registrationNames, diff --git a/src/core/ReactMount.js b/src/core/ReactMount.js index 14f93055b93..677fcd922bb 100644 --- a/src/core/ReactMount.js +++ b/src/core/ReactMount.js @@ -105,11 +105,16 @@ var ReactMount = { * @param {ReactComponent} prevComponent component instance already in the DOM * @param {ReactComponent} nextComponent component instance to render * @param {DOMElement} container container to render into + * @param {?function} callback function triggered on completion */ - _updateRootComponent: function(prevComponent, nextComponent, container) { + _updateRootComponent: function( + prevComponent, + nextComponent, + container, + callback) { var nextProps = nextComponent.props; ReactMount.scrollMonitor(container, function() { - prevComponent.replaceProps(nextProps); + prevComponent.replaceProps(nextProps, callback); }); return prevComponent; }, @@ -157,9 +162,10 @@ var ReactMount = { * * @param {ReactComponent} nextComponent Component instance to render. * @param {DOMElement} container DOM element to render into. + * @param {?function} callback function triggered on completion * @return {ReactComponent} Component instance rendered in `container`. */ - renderComponent: function(nextComponent, container) { + renderComponent: function(nextComponent, container, callback) { var registeredComponent = instanceByReactRootID[getReactRootID(container)]; if (registeredComponent) { @@ -167,7 +173,8 @@ var ReactMount = { return ReactMount._updateRootComponent( registeredComponent, nextComponent, - container + container, + callback ); } else { ReactMount.unmountAndReleaseReactRootNode(container); @@ -181,11 +188,13 @@ var ReactMount = { var shouldReuseMarkup = containerHasReactMarkup && !registeredComponent; - return ReactMount._renderNewRootComponent( + var component = ReactMount._renderNewRootComponent( nextComponent, container, shouldReuseMarkup ); + callback && callback(); + return component; }, /** diff --git a/src/core/ReactNativeComponent.js b/src/core/ReactNativeComponent.js index ef073dff68c..122029a64b9 100644 --- a/src/core/ReactNativeComponent.js +++ b/src/core/ReactNativeComponent.js @@ -168,20 +168,24 @@ ReactNativeComponent.Mixin = { return ''; }, + receiveProps: function(nextProps, transaction) { + assertValidProps(nextProps); + ReactComponent.Mixin.receiveProps.call(this, nextProps, transaction); + }, + /** - * Controls a native DOM component after it has already been allocated and + * Updates a native DOM component after it has already been allocated and * attached to the DOM. Reconciles the root DOM node, then recurses. * - * @internal - * @param {object} nextProps * @param {ReactReconcileTransaction} transaction + * @param {object} prevProps + * @internal + * @overridable */ - receiveProps: function(nextProps, transaction) { - ReactComponent.Mixin.receiveProps.call(this, nextProps, transaction); - assertValidProps(nextProps); - this._updateDOMProperties(nextProps); - this._updateDOMChildren(nextProps, transaction); - this.props = nextProps; + updateComponent: function(transaction, prevProps) { + ReactComponent.Mixin.updateComponent.call(this, transaction, prevProps); + this._updateDOMProperties(prevProps); + this._updateDOMChildren(prevProps, transaction); }, /** @@ -196,10 +200,10 @@ ReactNativeComponent.Mixin = { * TODO: Benchmark areas that can be improved with caching. * * @private - * @param {object} nextProps + * @param {object} lastProps */ - _updateDOMProperties: function(nextProps) { - var lastProps = this.props; + _updateDOMProperties: function(lastProps) { + var nextProps = this.props; var propKey; var styleName; var styleUpdates; @@ -293,20 +297,21 @@ ReactNativeComponent.Mixin = { * Reconciles the children with the various properties that affect the * children content. * - * @param {object} nextProps + * @param {object} lastProps * @param {ReactReconcileTransaction} transaction */ - _updateDOMChildren: function(nextProps, transaction) { - var lastUsedContent = - CONTENT_TYPES[typeof this.props.children] ? this.props.children : null; + _updateDOMChildren: function(lastProps, transaction) { + var nextProps = this.props; + var lastUsedContent = + CONTENT_TYPES[typeof lastProps.children] ? lastProps.children : null; var contentToUse = CONTENT_TYPES[typeof nextProps.children] ? nextProps.children : null; // Note the use of `!=` which checks for null or undefined. var lastUsedChildren = - lastUsedContent != null ? null : this.props.children; + lastUsedContent != null ? null : lastProps.children; var childrenToUse = contentToUse != null ? null : nextProps.children; if (contentToUse != null) { diff --git a/src/core/ReactUpdates.js b/src/core/ReactUpdates.js new file mode 100644 index 00000000000..9dc21d96b29 --- /dev/null +++ b/src/core/ReactUpdates.js @@ -0,0 +1,98 @@ +/** + * Copyright 2013 Facebook, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * @providesModule ReactUpdates + */ + +"use strict"; + +var invariant = require('invariant'); + +var isBatchingUpdates = false; + +var dirtyComponents = []; + +/** + * Call the provided function in a context within which calls to `setState` and + * friends are batched such that components aren't updated unnecessarily. + */ +function batchedUpdates(callback) { + invariant( + !isBatchingUpdates, + 'batchedUpates(...): Attempt to batch updates in a context where updates ' + + 'are already being batched.' + ); + isBatchingUpdates = true; + + callback(); + // TODO: Sort components by depth such that parent components update first + for (var i = 0; i < dirtyComponents.length; i++) { + // If a component is unmounted before pending changes apply, ignore them + // TODO: Queue unmounts in the same list to avoid this happening at all + var component = dirtyComponents[i]; + if (component.isMounted()) { + // If performUpdateIfNecessary happens to enqueue any new updates, we + // shouldn't execute the callbacks until the next render happens, so + // stash the callbacks first + var callbacks = component._pendingCallbacks; + component._pendingCallbacks = null; + component.performUpdateIfNecessary(); + if (callbacks) { + for (var j = 0; j < callbacks.length; j++) { + callbacks[j](); + } + } + } + } + dirtyComponents.length = 0; + + isBatchingUpdates = false; +} + +/** + * Mark a component as needing a rerender, adding an optional callback to a + * list of functions which will be executed once the rerender occurs. + */ +function enqueueUpdate(component, callback) { + invariant( + !callback || typeof callback === "function", + 'enqueueUpdate(...): You called `setProps`, `replaceProps`, ' + + '`setState`, `replaceState`, or `forceUpdate` with a callback that ' + + 'isn\'t callable.' + ); + + if (!isBatchingUpdates) { + component.performUpdateIfNecessary(); + callback && callback(); + return; + } + + dirtyComponents.push(component); + + if (callback) { + if (component._pendingCallbacks) { + component._pendingCallbacks.push(callback); + } else { + component._pendingCallbacks = [callback]; + } + } +} + +var ReactUpdates = { + batchedUpdates: batchedUpdates, + enqueueUpdate: enqueueUpdate +}; + +module.exports = ReactUpdates; diff --git a/src/core/__tests__/ReactUpdates-test.js b/src/core/__tests__/ReactUpdates-test.js new file mode 100644 index 00000000000..c48d5bb86c5 --- /dev/null +++ b/src/core/__tests__/ReactUpdates-test.js @@ -0,0 +1,295 @@ +/** + * Copyright 2013 Facebook, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * @jsx React.DOM + * @emails react-core + */ + +"use strict"; + +var React; +var ReactTestUtils; +var ReactUpdates; + +describe('ReactUpdates', function() { + beforeEach(function() { + React = require('React'); + ReactTestUtils = require('ReactTestUtils'); + ReactUpdates = require('ReactUpdates'); + }); + + it('should batch state when updating state twice', function() { + var updateCount = 0; + var Component = React.createClass({ + getInitialState: function() { + return {x: 0}; + }, + componentDidUpdate: function() { + updateCount++; + }, + render: function() { + return
{this.state.x}
; + } + }); + + var instance = ReactTestUtils.renderIntoDocument(); + expect(instance.state.x).toBe(0); + + ReactUpdates.batchedUpdates(function() { + instance.setState({x: 1}); + instance.setState({x: 2}); + expect(instance.state.x).toBe(0); + expect(updateCount).toBe(0); + }); + + expect(instance.state.x).toBe(2); + expect(updateCount).toBe(1); + }); + + it('should batch state when updating two different state keys', function() { + var updateCount = 0; + var Component = React.createClass({ + getInitialState: function() { + return {x: 0, y: 0}; + }, + componentDidUpdate: function() { + updateCount++; + }, + render: function() { + return
({this.state.x}, {this.state.y})
; + } + }); + + var instance = ReactTestUtils.renderIntoDocument(); + expect(instance.state.x).toBe(0); + expect(instance.state.y).toBe(0); + + ReactUpdates.batchedUpdates(function() { + instance.setState({x: 1}); + instance.setState({y: 2}); + expect(instance.state.x).toBe(0); + expect(instance.state.y).toBe(0); + expect(updateCount).toBe(0); + }); + + expect(instance.state.x).toBe(1); + expect(instance.state.y).toBe(2); + expect(updateCount).toBe(1); + }); + + it('should batch state and props together', function() { + var updateCount = 0; + var Component = React.createClass({ + getInitialState: function() { + return {y: 0}; + }, + componentDidUpdate: function() { + updateCount++; + }, + render: function() { + return
({this.props.x}, {this.state.y})
; + } + }); + + var instance = ReactTestUtils.renderIntoDocument(); + expect(instance.props.x).toBe(0); + expect(instance.state.y).toBe(0); + + ReactUpdates.batchedUpdates(function() { + instance.setProps({x: 1}); + instance.setState({y: 2}); + expect(instance.props.x).toBe(0); + expect(instance.state.y).toBe(0); + expect(updateCount).toBe(0); + }); + + expect(instance.props.x).toBe(1); + expect(instance.state.y).toBe(2); + expect(updateCount).toBe(1); + }); + + it('should batch parent/child state updates together', function() { + var parentUpdateCount = 0; + var Parent = React.createClass({ + getInitialState: function() { + return {x: 0}; + }, + componentDidUpdate: function() { + parentUpdateCount++; + }, + render: function() { + return
; + } + }); + var childUpdateCount = 0; + var Child = React.createClass({ + getInitialState: function() { + return {y: 0}; + }, + componentDidUpdate: function() { + childUpdateCount++; + }, + render: function() { + return
{this.props.x + this.state.y}
; + } + }); + + var instance = ReactTestUtils.renderIntoDocument(); + var child = instance.refs.child; + expect(instance.state.x).toBe(0); + expect(child.state.y).toBe(0); + + ReactUpdates.batchedUpdates(function() { + instance.setState({x: 1}); + child.setState({y: 2}); + expect(instance.state.x).toBe(0); + expect(child.state.y).toBe(0); + expect(parentUpdateCount).toBe(0); + expect(childUpdateCount).toBe(0); + }); + + expect(instance.state.x).toBe(1); + expect(child.state.y).toBe(2); + expect(parentUpdateCount).toBe(1); + expect(childUpdateCount).toBe(1); + }); + + it('should batch child/parent state updates together', function() { + var parentUpdateCount = 0; + var Parent = React.createClass({ + getInitialState: function() { + return {x: 0}; + }, + componentDidUpdate: function() { + parentUpdateCount++; + }, + render: function() { + return
; + } + }); + var childUpdateCount = 0; + var Child = React.createClass({ + getInitialState: function() { + return {y: 0}; + }, + componentDidUpdate: function() { + childUpdateCount++; + }, + render: function() { + return
{this.props.x + this.state.y}
; + } + }); + + var instance = ReactTestUtils.renderIntoDocument(); + var child = instance.refs.child; + expect(instance.state.x).toBe(0); + expect(child.state.y).toBe(0); + + ReactUpdates.batchedUpdates(function() { + child.setState({y: 2}); + instance.setState({x: 1}); + expect(instance.state.x).toBe(0); + expect(child.state.y).toBe(0); + expect(parentUpdateCount).toBe(0); + expect(childUpdateCount).toBe(0); + }); + + expect(instance.state.x).toBe(1); + expect(child.state.y).toBe(2); + expect(parentUpdateCount).toBe(1); + + // When we update the child first, we currently incur two updates because + // we aren't smart about what order to process the components in. + // TODO: Reduce the update count here to 1 + expect(childUpdateCount).toBe(2); + }); + + it('should support chained state updates', function() { + var updateCount = 0; + var Component = React.createClass({ + getInitialState: function() { + return {x: 0}; + }, + componentDidUpdate: function() { + updateCount++; + }, + render: function() { + return
{this.state.x}
; + } + }); + + var instance = ReactTestUtils.renderIntoDocument(); + expect(instance.state.x).toBe(0); + + var innerCallbackRun = false; + ReactUpdates.batchedUpdates(function() { + instance.setState({x: 1}, function() { + instance.setState({x: 2}, function() { + innerCallbackRun = true; + expect(instance.state.x).toBe(2); + expect(updateCount).toBe(2); + }); + expect(instance.state.x).toBe(1); + expect(updateCount).toBe(1); + }); + expect(instance.state.x).toBe(0); + expect(updateCount).toBe(0); + }); + + expect(innerCallbackRun).toBeTruthy(); + expect(instance.state.x).toBe(2); + expect(updateCount).toBe(2); + }); + + it('should batch forceUpdate together', function() { + var shouldUpdateCount = 0; + var updateCount = 0; + var Component = React.createClass({ + getInitialState: function() { + return {x: 0}; + }, + shouldComponentUpdate: function() { + shouldUpdateCount++; + }, + componentDidUpdate: function() { + updateCount++; + }, + render: function() { + return
{this.state.x}
; + } + }); + + var instance = ReactTestUtils.renderIntoDocument(); + expect(instance.state.x).toBe(0); + + var callbacksRun = 0; + ReactUpdates.batchedUpdates(function() { + instance.setState({x: 1}, function() { + callbacksRun++; + }); + instance.forceUpdate(function() { + callbacksRun++; + }); + expect(instance.state.x).toBe(0); + expect(updateCount).toBe(0); + }); + + expect(callbacksRun).toBe(2); + // shouldComponentUpdate shouldn't be called since we're forcing + expect(shouldUpdateCount).toBe(0); + expect(instance.state.x).toBe(1); + expect(updateCount).toBe(1); + }); +}); diff --git a/src/core/__tests__/refs-test.js b/src/core/__tests__/refs-test.js index 461cb857f6e..505c2c3fbb7 100644 --- a/src/core/__tests__/refs-test.js +++ b/src/core/__tests__/refs-test.js @@ -46,7 +46,9 @@ var ClickCounter = React.createClass({ } return ( + onClick={function() { + this.setState({count: this.state.count + 1}); + }.bind(this)}> {children} );