diff --git a/src/core/ReactComponent.js b/src/core/ReactComponent.js index e4f3ad4678..32c9b0b9ac 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 524c828c13..6f430a2f4a 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 aa6a81e236..4f52e383d6 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 14f93055b9..677fcd922b 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 ef073dff68..122029a64b 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 0000000000..9dc21d96b2 --- /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 0000000000..c48d5bb86c --- /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