diff --git a/src/core/ReactDOMIDOperations.js b/src/core/ReactDOMIDOperations.js index 7f54ac9ee2..3b7d21748c 100644 --- a/src/core/ReactDOMIDOperations.js +++ b/src/core/ReactDOMIDOperations.js @@ -172,12 +172,17 @@ var ReactDOMIDOperations = { }, /** - * TODO: We only actually *need* to purge the cache when we remove elements. - * Detect if any elements were removed instead of blindly purging. + * Updates a component's children by processing a series of updates. + * + * @param {array} updates List of update configurations. + * @param {array} markup List of markup strings. + * @internal */ - manageChildrenByParentID: function(parentID, domOperations) { - var parent = ReactMount.getNode(parentID); - DOMChildrenOperations.manageChildren(parent, domOperations); + dangerouslyProcessChildrenUpdates: function(updates, markup) { + for (var i = 0; i < updates.length; i++) { + updates[i].parentNode = ReactMount.getNode(updates[i].parentID); + } + DOMChildrenOperations.processUpdates(updates, markup); } }; diff --git a/src/core/ReactInstanceHandles.js b/src/core/ReactInstanceHandles.js index 15bfe216e2..02a148e82b 100644 --- a/src/core/ReactInstanceHandles.js +++ b/src/core/ReactInstanceHandles.js @@ -229,14 +229,24 @@ function traverseParentPath(start, stop, cb, arg, skipFirst, skipLast) { */ var ReactInstanceHandles = { - separator: SEPARATOR, - createReactRootID: function() { return getReactRootIDString( Math.ceil(Math.random() * GLOBAL_MOUNT_POINT_MAX) ); }, + /** + * Constructs a React ID by joining a root ID with a name. + * + * @param {string} rootID Root ID of a parent component. + * @param {string} name A component's name (as flattened children). + * @return {string} A React ID. + * @internal + */ + createReactID: function(rootID, name) { + return rootID + SEPARATOR + name; + }, + /** * Gets the DOM ID of the React component that is the root of the tree that * contains the React component with the supplied DOM ID. diff --git a/src/core/ReactMultiChild.js b/src/core/ReactMultiChild.js index 9b4a64572c..3531f93517 100644 --- a/src/core/ReactMultiChild.js +++ b/src/core/ReactMultiChild.js @@ -14,194 +14,356 @@ * limitations under the License. * * @providesModule ReactMultiChild + * @typechecks static-only */ "use strict"; var ReactComponent = require('ReactComponent'); +var ReactMultiChildUpdateTypes = require('ReactMultiChildUpdateTypes'); /** - * Given a `curChild` and `newChild`, determines if `curChild` should be managed - * as it exists, as opposed to being destroyed and/or replaced. + * Given a `curChild` and `newChild`, determines if `curChild` should be + * updated as opposed to being destroyed or replaced. + * * @param {?ReactComponent} curChild * @param {?ReactComponent} newChild - * @return {!boolean} Whether or not `curChild` should be updated with - * `newChild`'s props + * @return {boolean} True if `curChild` should be updated with `newChild`. + * @protected */ -function shouldManageExisting(curChild, newChild) { +function shouldUpdateChild(curChild, newChild) { return curChild && newChild && curChild.constructor === newChild.constructor; } /** - * `ReactMultiChild` provides common functionality for components that have - * multiple children. Standard `ReactCompositeComponent`s do not currently have - * multiple children. `ReactNativeComponent`s do, however. Other specially - * reconciled components will also have multiple children. Contains three - * internally used properties that are used to keep track of state throughout - * the `updateMultiChild` process. + * Updating children of a component may trigger recursive updates. The depth is + * used to batch recursive updates to render markup more efficiently. * - * @class ReactMultiChild + * @type {number} + * @private */ +var updateDepth = 0; /** - * @lends `ReactMultiChildMixin`. + * Queue of update configuration objects. + * + * Each object has a `type` property that is in `ReactMultiChildUpdateTypes`. + * + * @type {array} + * @private */ -var ReactMultiChildMixin = { +var updateQueue = []; - enqueueMarkupAt: function(markup, insertAt) { - this.domOperations = this.domOperations || []; - this.domOperations.push({insertMarkup: markup, finalIndex: insertAt}); - }, +/** + * Queue of markup to be rendered. + * + * @type {array} + * @private + */ +var markupQueue = []; - enqueueMove: function(originalIndex, finalIndex) { - this.domOperations = this.domOperations || []; - this.domOperations.push({moveFrom: originalIndex, finalIndex: finalIndex}); - }, +/** + * Enqueues markup to be rendered and inserted at a supplied index. + * + * @param {string} parentID ID of the parent component. + * @param {string} markup Markup that renders into an element. + * @param {number} toIndex Destination index. + * @private + */ +function enqueueMarkup(parentID, markup, toIndex) { + // NOTE: Null values reduce hidden classes. + updateQueue.push({ + parentID: parentID, + parentNode: null, + type: ReactMultiChildUpdateTypes.INSERT_MARKUP, + markupIndex: markupQueue.push(markup) - 1, + fromIndex: null, + toIndex: toIndex + }); +} - enqueueUnmountChildByName: function(name, removeChild) { - if (ReactComponent.isValidComponent(removeChild)) { - this.domOperations = this.domOperations || []; - this.domOperations.push({removeAt: removeChild._domIndex}); - removeChild.unmountComponent && removeChild.unmountComponent(); - delete this._renderedChildren[name]; - } - }, +/** + * Enqueues moving an existing element to another index. + * + * @param {string} parentID ID of the parent component. + * @param {number} fromIndex Source index of the existing element. + * @param {number} toIndex Destination index of the element. + * @private + */ +function enqueueMove(parentID, fromIndex, toIndex) { + // NOTE: Null values reduce hidden classes. + updateQueue.push({ + parentID: parentID, + parentNode: null, + type: ReactMultiChildUpdateTypes.MOVE_EXISTING, + markupIndex: null, + fromIndex: fromIndex, + toIndex: toIndex + }); +} - /** - * Process any pending DOM operations that have been accumulated when updating - * the UI. By default, we execute the injected `DOMIDOperations` module's - * `manageChildrenByParentID` which does executes the DOM operations without - * any animation. It can be used as a reference implementation for special - * animation based implementations. - * - * @abstract - */ - processChildDOMOperationsQueue: function() { - if (this.domOperations) { - ReactComponent.DOMIDOperations - .manageChildrenByParentID(this._rootNodeID, this.domOperations); - this.domOperations = null; - } - }, +/** + * Enqueues removing an element at an index. + * + * @param {string} parentID ID of the parent component. + * @param {number} fromIndex Index of the element to remove. + * @private + */ +function enqueueRemove(parentID, fromIndex) { + // NOTE: Null values reduce hidden classes. + updateQueue.push({ + parentID: parentID, + parentNode: null, + type: ReactMultiChildUpdateTypes.REMOVE_NODE, + markupIndex: null, + fromIndex: fromIndex, + toIndex: null + }); +} - unmountMultiChild: function() { - var renderedChildren = this._renderedChildren; - for (var name in renderedChildren) { - if (renderedChildren.hasOwnProperty(name) && renderedChildren[name]) { - var renderedChild = renderedChildren[name]; - renderedChild.unmountComponent && renderedChild.unmountComponent(); - } - } - this._renderedChildren = null; - }, - - /** - * Generates markup for a component that holds multiple children. #todo: Allow - * all `ReactMultiChildMixin`s to support having arrays of children without a - * container node. This current implementation may assume that children exist - * at domIndices [0, parentNode.length]. - * - * Has side effects of (likely) causing events to be registered. Also, every - * component instance may only be rendered once. - * - * @param {?Object} children Flattened children object. - * @return {!String} The rendered markup. - */ - mountMultiChild: function(children, transaction) { - var accum = ''; - var index = 0; - for (var name in children) { - var child = children[name]; - if (children.hasOwnProperty(name) && child) { - accum += child.mountComponent( - this._rootNodeID + '.' + name, - transaction - ); - child._domIndex = index; - index++; - } - } - this._renderedChildren = children; // children are in just the right form! - this.domOperations = null; - return accum; - }, - - /** - * Reconciles new children with old children in three phases. - * - * - Adds new content while updating existing children that should remain. - * - Remove children that are no longer present in the next children. - * - As a very last step, moves existing dom structures around. - * - (Comment 1) `curChildrenDOMIndex` is the largest index of the current - * rendered children that appears in the next children and did not need to - * be "moved". - * - (Comment 2) This is the key insight. If any non-removed child's previous - * index is less than `curChildrenDOMIndex` it must be moved. - * - * @param {?Object} children Flattened children object. - */ - updateMultiChild: function(nextChildren, transaction) { - if (!nextChildren && !this._renderedChildren) { - return; - } else if (nextChildren && !this._renderedChildren) { - this._renderedChildren = {}; // lazily allocate backing store with nothing - } else if (!nextChildren && this._renderedChildren) { - nextChildren = {}; - } - var rootDomIdDot = this._rootNodeID + '.'; - var markupBuffer = null; // Accumulate adjacent new children markup. - var numPendingInsert = 0; // How many root nodes are waiting in markupBuffer - var loopDomIndex = 0; // Index of loop through new children. - var curChildrenDOMIndex = 0; // See (Comment 1) - for (var name in nextChildren) { - if (!nextChildren.hasOwnProperty(name)) {continue;} - var curChild = this._renderedChildren[name]; - var nextChild = nextChildren[name]; - if (shouldManageExisting(curChild, nextChild)) { - if (markupBuffer) { - this.enqueueMarkupAt(markupBuffer, loopDomIndex - numPendingInsert); - markupBuffer = null; - } - numPendingInsert = 0; - if (curChild._domIndex < curChildrenDOMIndex) { // (Comment 2) - this.enqueueMove(curChild._domIndex, loopDomIndex); - } - curChildrenDOMIndex = Math.max(curChild._domIndex, curChildrenDOMIndex); - curChild.receiveProps(nextChild.props, transaction); - curChild._domIndex = loopDomIndex; - } else { - if (curChild) { // !shouldUpdate && curChild => delete - this.enqueueUnmountChildByName(name, curChild); - curChildrenDOMIndex = - Math.max(curChild._domIndex, curChildrenDOMIndex); - } - if (nextChild) { // !shouldUpdate && nextChild => insert - this._renderedChildren[name] = nextChild; - var nextMarkup = - nextChild.mountComponent(rootDomIdDot + name, transaction); - markupBuffer = markupBuffer ? markupBuffer + nextMarkup : nextMarkup; - numPendingInsert++; - nextChild._domIndex = loopDomIndex; - } - } - loopDomIndex = nextChild ? loopDomIndex + 1 : loopDomIndex; - } - if (markupBuffer) { - this.enqueueMarkupAt(markupBuffer, loopDomIndex - numPendingInsert); - } - for (var childName in this._renderedChildren) { // from other direction - if (!this._renderedChildren.hasOwnProperty(childName)) { continue; } - var child = this._renderedChildren[childName]; - if (child && !nextChildren[childName]) { - this.enqueueUnmountChildByName(childName, child); - } - } - this.processChildDOMOperationsQueue(); +/** + * Processes any enqueued updates. + * + * @private + */ +function processQueue() { + if (updateQueue.length) { + ReactComponent.DOMIDOperations.dangerouslyProcessChildrenUpdates( + updateQueue, + markupQueue + ); + clearQueue(); } -}; +} +/** + * Clears any enqueued updates. + * + * @private + */ +function clearQueue() { + updateQueue.length = 0; + markupQueue.length = 0; +} + +/** + * ReactMultiChild are capable of reconciling multiple children. + * + * @class ReactMultiChild + * @internal + */ var ReactMultiChild = { - Mixin: ReactMultiChildMixin + + /** + * Provides common functionality for components that must reconcile multiple + * children. This is used by `ReactNativeComponent` to mount, update, and + * unmount child components. + * + * @lends {ReactMultiChild.prototype} + */ + Mixin: { + + /** + * Generates a "mount image" for each of the supplied children. In the case + * of `ReactNativeComponent`, a mount image is a string of markup. + * + * @param {?object} children As returned by `flattenChildren`. + * @return {array} An array of mounted representations. + * @internal + */ + mountChildren: function(children, transaction) { + var mountImages = []; + var index = 0; + for (var name in children) { + var child = children[name]; + if (children.hasOwnProperty(name) && child) { + var mountImage = child.mountComponent( + // Inlined for performance, see `ReactID.createReactID`. + this._rootNodeID + '.' + name, + transaction + ); + child._mountImage = mountImage; + child._mountIndex = index; + mountImages.push(mountImage); + index++; + } + } + this._renderedChildren = children; + return mountImages; + }, + + /** + * Updates the rendered children with new children. + * + * @param {?object} nextChildren As returned by `flattenChildren`. + * @param {ReactReconcileTransaction} transaction + * @internal + */ + updateChildren: function(nextChildren, transaction) { + updateDepth++; + try { + this._updateChildren(nextChildren, transaction); + } catch (error) { + updateDepth--; + updateDepth || clearQueue(); + throw error; + } + updateDepth--; + updateDepth || processQueue(); + }, + + /** + * Improve performance by isolating this hot code path from the try/catch + * block in `updateChildren`. + * + * @param {?object} nextChildren As returned by `flattenChildren`. + * @param {ReactReconcileTransaction} transaction + * @final + * @protected + */ + _updateChildren: function(nextChildren, transaction) { + var prevChildren = this._renderedChildren; + if (!nextChildren && !prevChildren) { + return; + } + var name; + // `nextIndex` will increment for each child in `nextChildren`, but + // `lastIndex` will be the last index visited in `prevChildren`. + var lastIndex = 0; + var nextIndex = 0; + for (name in nextChildren) { + if (!nextChildren.hasOwnProperty(name)) { + continue; + } + var prevChild = prevChildren && prevChildren[name]; + var nextChild = nextChildren[name]; + if (shouldUpdateChild(prevChild, nextChild)) { + this.moveChild(prevChild, nextIndex, lastIndex); + lastIndex = Math.max(prevChild._mountIndex, lastIndex); + prevChild.receiveProps(nextChild.props, transaction); + prevChild._mountIndex = nextIndex; + } else { + if (prevChild) { + this._unmountChildByName(prevChild, name); + lastIndex = Math.max(prevChild._mountIndex, lastIndex); + } + if (nextChild) { + this._mountChildByNameAtIndex( + nextChild, name, nextIndex, transaction + ); + } + } + if (nextChild) { + nextIndex++; + } + } + // Remove children that are no longer present. + for (name in prevChildren) { + if (prevChildren.hasOwnProperty(name) && + prevChildren[name] && + !(nextChildren && nextChildren[name])) { + this._unmountChildByName(prevChildren[name], name); + } + } + }, + + /** + * Unmounts all rendered children. This should be used to clean up children + * when this component is unmounted. + * + * @internal + */ + unmountChildren: function() { + var renderedChildren = this._renderedChildren; + for (var name in renderedChildren) { + var renderedChild = renderedChildren[name]; + if (renderedChild && renderedChild.unmountComponent) { + renderedChild.unmountComponent(); + } + } + this._renderedChildren = null; + }, + + /** + * Moves a child component to the supplied index. + * + * @param {ReactComponent} child Component to move. + * @param {number} toIndex Destination index of the element. + * @param {number} lastIndex Last index visited of the siblings of `child`. + * @protected + */ + moveChild: function(child, toIndex, lastIndex) { + // If the index of `child` is less than `lastIndex`, then it needs to + // be moved. Otherwise, we do not need to move it because a child will be + // inserted or moved before `child`. + if (child._mountIndex < lastIndex) { + enqueueMove(this._rootNodeID, child._mountIndex, toIndex); + } + }, + + /** + * Creates a child component. + * + * @param {ReactComponent} child Component to create. + * @protected + */ + createChild: function(child) { + enqueueMarkup(this._rootNodeID, child._mountImage, child._mountIndex); + }, + + /** + * Removes a child component. + * + * @param {ReactComponent} child Child to remove. + * @protected + */ + removeChild: function(child) { + enqueueRemove(this._rootNodeID, child._mountIndex); + }, + + /** + * Mounts a child with the supplied name. + * + * NOTE: This is part of `updateChildren` and is here for readability. + * + * @param {ReactComponent} child Component to mount. + * @param {string} name Name of the child. + * @param {number} index Index at which to insert the child. + * @param {ReactReconcileTransaction} transaction + * @private + */ + _mountChildByNameAtIndex: function(child, name, index, transaction) { + // Inlined for performance, see `ReactID.createReactID`. + var rootID = this._rootNodeID + '.' + name; + var mountImage = child.mountComponent(rootID, transaction); + child._mountImage = mountImage; + child._mountIndex = index; + this.createChild(child); + this._renderedChildren = this._renderedChildren || {}; + this._renderedChildren[name] = child; + }, + + /** + * Unmounts a rendered child by name. + * + * NOTE: This is part of `updateChildren` and is here for readability. + * + * @param {ReactComponent} child Component to unmount. + * @param {string} name Name of the child in `this._renderedChildren`. + * @private + */ + _unmountChildByName: function(child, name) { + if (ReactComponent.isValidComponent(child)) { + this.removeChild(child); + child._mountImage = null; + child._mountIndex = null; + child.unmountComponent(); + delete this._renderedChildren[name]; + } + } + + } + }; module.exports = ReactMultiChild; diff --git a/src/core/ReactMultiChildUpdateTypes.js b/src/core/ReactMultiChildUpdateTypes.js new file mode 100644 index 0000000000..05f40c6408 --- /dev/null +++ b/src/core/ReactMultiChildUpdateTypes.js @@ -0,0 +1,35 @@ +/** + * 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 ReactMultiChildUpdateTypes + */ + +var keyMirror = require('keyMirror'); + +/** + * When a component's children are updated, a series of update configuration + * objects are created in order to batch and serialize the required changes. + * + * Enumerates all the possible types of update configurations. + * + * @internal + */ +var ReactMultiChildUpdateTypes = keyMirror({ + INSERT_MARKUP: null, + MOVE_EXISTING: null, + REMOVE_NODE: null +}); + +module.exports = ReactMultiChildUpdateTypes; diff --git a/src/core/ReactNativeComponent.js b/src/core/ReactNativeComponent.js index 11c1c69fad..cbdc408192 100644 --- a/src/core/ReactNativeComponent.js +++ b/src/core/ReactNativeComponent.js @@ -160,10 +160,11 @@ ReactNativeComponent.Mixin = { if (contentToUse != null) { return escapeTextForBrowser(contentToUse); } else if (childrenToUse != null) { - return this.mountMultiChild( + var mountImages = this.mountChildren( flattenChildren(childrenToUse), transaction ); + return mountImages.join(''); } } return ''; @@ -320,7 +321,7 @@ ReactNativeComponent.Mixin = { if (contentToUse != null) { var childrenRemoved = lastUsedChildren != null && childrenToUse == null; if (childrenRemoved) { - this.updateMultiChild(null, transaction); + this.updateChildren(null, transaction); } if (lastUsedContent !== contentToUse) { ReactComponent.DOMIDOperations.updateTextContentByID( @@ -336,7 +337,7 @@ ReactNativeComponent.Mixin = { '' ); } - this.updateMultiChild(flattenChildren(nextProps.children), transaction); + this.updateChildren(flattenChildren(nextProps.children), transaction); } }, @@ -349,7 +350,7 @@ ReactNativeComponent.Mixin = { unmountComponent: function() { ReactEventEmitter.deleteAllListeners(this._rootNodeID); ReactComponent.Mixin.unmountComponent.call(this); - this.unmountMultiChild(); + this.unmountChildren(); } }; diff --git a/src/core/__tests__/ReactMultiChild-test.js b/src/core/__tests__/ReactMultiChild-test.js new file mode 100644 index 0000000000..8451506237 --- /dev/null +++ b/src/core/__tests__/ReactMultiChild-test.js @@ -0,0 +1,63 @@ +/** + * 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 + */ + +describe('ReactMultiChild', function() { + var React; + var setInnerHTML; + + beforeEach(function() { + require('mock-modules').dumpCache(); + React = require('React'); + + var innerHTMLDescriptor = Object.getOwnPropertyDescriptor( + Element.prototype, + 'innerHTML' + ); + Object.defineProperty(Element.prototype, 'innerHTML', { + set: setInnerHTML = jasmine.createSpy().andCallFake( + innerHTMLDescriptor.set + ) + }); + }); + + it('should only set `innerHTML` once on update', function() { + var container = document.createElement('div'); + + React.renderComponent( +
+

+

+

+
, + container + ); + expect(setInnerHTML).toHaveBeenCalled(); + var callCountOnMount = setInnerHTML.callCount; + + React.renderComponent( +
+

+

+

+
, + container + ); + expect(setInnerHTML.callCount).toBe(callCountOnMount + 1); + }); +}); diff --git a/src/core/__tests__/ReactMultiChildReconcile-test.js b/src/core/__tests__/ReactMultiChildReconcile-test.js index 4d47e89207..72a7e9a080 100644 --- a/src/core/__tests__/ReactMultiChildReconcile-test.js +++ b/src/core/__tests__/ReactMultiChildReconcile-test.js @@ -94,7 +94,7 @@ var FriendsStatusDisplay = React.createClass({ var child = statusDisplays[name]; var isPresent = !!child; if (isPresent) { - orderOfUsernames[child._domIndex] = getOriginalKey(name); + orderOfUsernames[child._mountIndex] = getOriginalKey(name); } } var res = {}; diff --git a/src/dom/DOMChildrenOperations.js b/src/dom/DOMChildrenOperations.js index 98e2bb9b17..fba16b0d90 100644 --- a/src/dom/DOMChildrenOperations.js +++ b/src/dom/DOMChildrenOperations.js @@ -14,131 +14,108 @@ * limitations under the License. * * @providesModule DOMChildrenOperations + * @typechecks static-only */ -// Empty blocks improve readability so disable that warning -// jshint -W035 - "use strict"; var Danger = require('Danger'); +var ReactMultiChildUpdateTypes = require('ReactMultiChildUpdateTypes'); -var insertNodeAt = require('insertNodeAt'); -var keyOf = require('keyOf'); -var throwIf = require('throwIf'); - -var NON_INCREASING_OPERATIONS; -if (__DEV__) { - NON_INCREASING_OPERATIONS = - 'DOM child management operations must be provided in order ' + - 'of increasing destination index. This is likely an issue with ' + - 'the core framework.'; +/** + * Inserts `childNode` as a child of `parentNode` at the `index`. + * + * @param {DOMElement} parentNode Parent node in which to insert. + * @param {DOMElement} childNode Child node to insert. + * @param {number} index Index at which to insert the child. + * @internal + */ +function insertChildAt(parentNode, childNode, index) { + var childNodes = parentNode.childNodes; + if (childNodes[index] === childNode) { + return; + } + // If `childNode` is already a child of `parentNode`, remove it so that + // computing `childNodes[index]` takes into account the removal. + if (childNode.parentNode === parentNode) { + parentNode.removeChild(childNode); + } + if (index >= childNodes.length) { + parentNode.appendChild(childNode); + } else { + parentNode.insertBefore(childNode, childNodes[index]); + } } -var MOVE_NODE_AT_ORIG_INDEX = keyOf({moveFrom: null}); -var INSERT_MARKUP = keyOf({insertMarkup: null}); -var REMOVE_AT = keyOf({removeAt: null}); - /** - * In order to carry out movement of DOM nodes without knowing their IDs, we - * have to first store knowledge about nodes' original indices before beginning - * to carry out the sequence of operations. Once we begin the sequence, the DOM - * indices in future instructions are no longer valid. - * - * @param {Element} parent Parent DOM node. - * @param {Object} childOperations Description of child operations. - * @return {Array?} Sparse array containing elements by their current index in - * the DOM. - */ -var _getNodesByOriginalIndex = function(parent, childOperations) { - var nodesByOriginalIndex; // Sparse array. - var childOperation; - var origIndex; - for (var i = 0; i < childOperations.length; i++) { - childOperation = childOperations[i]; - if (MOVE_NODE_AT_ORIG_INDEX in childOperation) { - nodesByOriginalIndex = nodesByOriginalIndex || []; - origIndex = childOperation.moveFrom; - nodesByOriginalIndex[origIndex] = parent.childNodes[origIndex]; - } else if (REMOVE_AT in childOperation) { - nodesByOriginalIndex = nodesByOriginalIndex || []; - origIndex = childOperation.removeAt; - nodesByOriginalIndex[origIndex] = parent.childNodes[origIndex]; - } - } - return nodesByOriginalIndex; -}; - -/** - * Removes DOM elements from their parent, or moved. - * @param {Element} parent Parent DOM node. - * @param {Array} nodesByOriginalIndex Child nodes by their original index - * (potentially sparse.) - */ -var _removeChildrenByOriginalIndex = function(parent, nodesByOriginalIndex) { - for (var j = 0; j < nodesByOriginalIndex.length; j++) { - var nodeToRemove = nodesByOriginalIndex[j]; - if (nodeToRemove) { // We used a sparse array. - parent.removeChild(nodesByOriginalIndex[j]); - } - } -}; - -/** - * Once all nodes that will be removed or moved - are removed from the parent - * node, we can begin the process of placing nodes into their final locations. - * We must perform all operations in the order of the final DOM index - - * otherwise, we couldn't count on the fact that an insertion at index X, will - * remain at index X. This will iterate through the child operations, adding - * content where needed, skip over removals (they've already been removed) and - * insert "moved" Elements that were previously removed. The "moved" elements - * are only temporarily removed from the parent, so that index calculations can - * be manageable and perform well in the cases that matter. - */ -var _placeNodesAtDestination = - function(parent, childOperations, nodesByOriginalIndex) { - var origNode; - var finalIndex; - var lastFinalIndex = -1; - var childOperation; - for (var k = 0; k < childOperations.length; k++) { - childOperation = childOperations[k]; - if (MOVE_NODE_AT_ORIG_INDEX in childOperation) { - origNode = nodesByOriginalIndex[childOperation.moveFrom]; - finalIndex = childOperation.finalIndex; - insertNodeAt(parent, origNode, finalIndex); - if (__DEV__) { - throwIf(finalIndex <= lastFinalIndex, NON_INCREASING_OPERATIONS); - lastFinalIndex = finalIndex; - } - } else if (REMOVE_AT in childOperation) { - } else if (INSERT_MARKUP in childOperation) { - finalIndex = childOperation.finalIndex; - var markup = childOperation.insertMarkup; - Danger.dangerouslyInsertMarkupAt(parent, markup, finalIndex); - if (__DEV__) { - throwIf(finalIndex <= lastFinalIndex, NON_INCREASING_OPERATIONS); - lastFinalIndex = finalIndex; - } - } - } - }; - -var manageChildren = function(parent, childOperations) { - var nodesByOriginalIndex = _getNodesByOriginalIndex(parent, childOperations); - if (nodesByOriginalIndex) { - _removeChildrenByOriginalIndex(parent, nodesByOriginalIndex); - } - _placeNodesAtDestination(parent, childOperations, nodesByOriginalIndex); -}; - -/** - * Also reexport all of the dangerous functions. It helps to have all dangerous - * functions located in a single module `Danger`. + * Operations for updating with DOM children. */ var DOMChildrenOperations = { + dangerouslyReplaceNodeWithMarkup: Danger.dangerouslyReplaceNodeWithMarkup, - manageChildren: manageChildren + + /** + * Updates a component's children by processing a series of updates. The + * update configurations are each expected to have a `parentNode` property. + * + * @param {array} updates List of update configurations. + * @param {array} markupList List of markup strings. + * @internal + */ + processUpdates: function(updates, markupList) { + var update; + // Mapping from parent IDs to initial child orderings. + var initialChildren = null; + // List of children that will be moved or removed. + var updatedChildren = null; + + for (var i = 0; update = updates[i]; i++) { + if (update.type !== ReactMultiChildUpdateTypes.INSERT_MARKUP) { + var updatedIndex = update.fromIndex; + var updatedChild = update.parentNode.childNodes[updatedIndex]; + var parentID = update.parentID; + + initialChildren = initialChildren || {}; + initialChildren[parentID] = initialChildren[parentID] || []; + initialChildren[parentID][updatedIndex] = updatedChild; + + updatedChildren = updatedChildren || []; + updatedChildren.push(updatedChild); + } + } + + var renderedMarkup = Danger.dangerouslyRenderMarkup(markupList); + + // Remove updated children first so that `toIndex` is consistent. + if (updatedChildren) { + for (var j = 0; j < updatedChildren.length; j++) { + updatedChildren[j].parentNode.removeChild(updatedChildren[j]); + } + } + + for (var k = 0; update = updates[k]; k++) { + switch (update.type) { + case ReactMultiChildUpdateTypes.INSERT_MARKUP: + insertChildAt( + update.parentNode, + renderedMarkup[update.markupIndex], + update.toIndex + ); + break; + case ReactMultiChildUpdateTypes.MOVE_EXISTING: + insertChildAt( + update.parentNode, + initialChildren[update.parentID][update.fromIndex], + update.toIndex + ); + break; + case ReactMultiChildUpdateTypes.REMOVE_NODE: + // Already removed by the for-loop above. + break; + } + } + } + }; module.exports = DOMChildrenOperations; diff --git a/src/dom/Danger.js b/src/dom/Danger.js index 7ec63bfcd2..2c18695a2b 100644 --- a/src/dom/Danger.js +++ b/src/dom/Danger.js @@ -14,6 +14,7 @@ * limitations under the License. * * @providesModule Danger + * @typechecks static-only */ /*jslint evil: true, sub: true */ @@ -22,33 +23,7 @@ var ExecutionEnvironment = require('ExecutionEnvironment'); -var throwIf = require('throwIf'); - -var DOM_UNSUPPORTED; -var NO_MARKUP_PARENT; -var NO_MULTI_MARKUP; -if (__DEV__) { - DOM_UNSUPPORTED = - 'You may not insert markup into the document while you are in a worker ' + - 'thread. It\'s not you, it\'s me. This is likely the fault of the ' + - 'framework. Please report this immediately.'; - NO_MARKUP_PARENT = - 'You have attempted to inject markup without a suitable parent. This is ' + - 'likely the fault of the framework - please report immediately.'; - NO_MULTI_MARKUP = - 'The framework has attempted to either insert zero or multiple markup ' + - 'roots into a single location when it should not. This is a serious ' + - 'error - a fault of the framework - please report immediately.'; -} - -var validateMarkupParams; -if (__DEV__) { - validateMarkupParams = function(parentNode, markup) { - throwIf(!ExecutionEnvironment.canUseDOM, DOM_UNSUPPORTED); - throwIf(!parentNode || !parentNode.tagName, NO_MARKUP_PARENT); - throwIf(!markup, NO_MULTI_MARKUP); - }; -} +var invariant = require('invariant'); /** * Dummy container used to render all markup. @@ -103,22 +78,35 @@ if (dummyNode) { } /** - * Renders markup into nodes. The returned HTMLCollection is live and should be - * used immediately (or at least before the next invocation to `renderMarkup`). + * Extracts the `nodeName` from a string of markup. This does not require a + * regular expression match because we make assumptions about React-generated + * markup (i.e. there are no spaces surrounding the opening tag and there is at + * least an ID attribute). * * NOTE: Extracting the `nodeName` does not require a regular expression match * because we make assumptions about React-generated markup (i.e. there are no * spaces surrounding the opening tag and there is at least one attribute). - * @see http://jsperf.com/extract-nodename * - * @param {string} markup + * @param {string} markup String of markup. + * @return {string} Node name of the supplied markup. + * @see http://jsperf.com/extract-nodename + */ +function getNodeName(markup) { + return markup.substring(1, markup.indexOf(' ')); +} + +/** + * Renders markup into nodes. The returned HTMLCollection is live and should be + * used immediately (or at least before the next invocation to `renderMarkup`). + * + * @param {string} markup Markup for one or more nodes with the same `nodeName`. + * @param {?string} nodeName Optional, the lowercase node name of the markup. * @return {*} An HTMLCollection. */ -function renderMarkup(markup) { +function renderMarkup(markup, nodeName) { + nodeName = nodeName || getNodeName(markup); var node = dummyNode; - var nodeName = markup.substring(1, markup.indexOf(' ')); - - var wrap = markupWrap[nodeName.toLowerCase()] || defaultWrap; + var wrap = markupWrap[nodeName] || defaultWrap; if (wrap) { node.innerHTML = wrap[1] + markup + wrap[2]; @@ -132,99 +120,80 @@ function renderMarkup(markup) { return node.childNodes; } -/** - * Inserts node after 'after'. If 'after' is null, inserts it after nothing, - * which is inserting it at the beginning. - * - * @param {Element} elem Parent element. - * @param {Element} insert Element to insert. - * @param {Element} after Element to insert after. - * @return {Element} Element that was inserted. - */ -function insertNodeAfterNode(elem, insert, after) { - if (__DEV__) { - throwIf(!ExecutionEnvironment.canUseDOM, DOM_UNSUPPORTED); - } - if (after) { - if (after.nextSibling) { - return elem.insertBefore(insert, after.nextSibling); - } else { - return elem.appendChild(insert); - } - } else { - return elem.insertBefore(insert, elem.firstChild); - } -} - -/** - * Slow: Should only be used when it is known there are a few (or one) element - * in the node list. - * @param {Element} parentRootDomNode Parent element. - * @param {HTMLCollection} htmlCollection HTMLCollection to insert. - * @param {Element} after Element to insert the node list after. - */ -function inefficientlyInsertHTMLCollectionAfter( - parentRootDomNode, - htmlCollection, - after) { - - if (__DEV__) { - throwIf(!ExecutionEnvironment.canUseDOM, DOM_UNSUPPORTED); - } - var ret; - var originalLength = htmlCollection.length; - // Access htmlCollection[0] because htmlCollection shrinks as we remove items. - // `insertNodeAfterNode` will remove items from the htmlCollection. - for (var i = 0; i < originalLength; i++) { - ret = - insertNodeAfterNode(parentRootDomNode, htmlCollection[0], ret || after); - } -} - -/** - * Super-dangerously inserts markup into existing DOM structure. Seriously, you - * don't want to use this module unless you are building a framework. This - * requires that the markup that you are inserting represents the root of a - * tree. We do not support the case where there `markup` represents several - * roots. - * - * @param {Element} parentNode Parent DOM element. - * @param {string} markup Markup to dangerously insert. - * @param {number} index Position to insert markup at. - */ -function dangerouslyInsertMarkupAt(parentNode, markup, index) { - if (__DEV__) { - validateMarkupParams(parentNode, markup); - } - var htmlCollection = renderMarkup(markup); - var afterNode = index ? parentNode.childNodes[index - 1] : null; - inefficientlyInsertHTMLCollectionAfter(parentNode, htmlCollection, afterNode); -} - -/** - * Replaces a node with a string of markup at its current position within its - * parent. `childNode` must be in the document (or at least within a parent - * node). The string of markup must represent a tree of markup with a single - * root. - * - * @param {Element} childNode Child node to replace. - * @param {string} markup Markup to dangerously replace child with. - */ -function dangerouslyReplaceNodeWithMarkup(childNode, markup) { - var parentNode = childNode.parentNode; - if (__DEV__) { - validateMarkupParams(parentNode, markup); - } - var htmlCollection = renderMarkup(markup); - if (__DEV__) { - throwIf(htmlCollection.length !== 1, NO_MULTI_MARKUP); - } - parentNode.replaceChild(htmlCollection[0], childNode); -} - var Danger = { - dangerouslyInsertMarkupAt: dangerouslyInsertMarkupAt, - dangerouslyReplaceNodeWithMarkup: dangerouslyReplaceNodeWithMarkup + + /** + * Renders markup into an array of nodes. The markup is expected to render + * into a list of root nodes. Also, the length of `parentNodes` and `markup` + * should be the same. + * + * @param {array} markupList List of markup strings to render. + * @return {array} List of rendered nodes. + * @internal + */ + dangerouslyRenderMarkup: function(markupList) { + invariant( + ExecutionEnvironment.canUseDOM, + 'dangerouslyRenderMarkup(...): Cannot render markup in a Worker ' + + 'thread. This is likely a bug in the framework. Please report ' + + 'immediately.' + ); + var nodeName; + var markupByNodeName = {}; + // Group markup by `nodeName` if a wrap is necessary, else by '*'. + for (var i = 0; i < markupList.length; i++) { + invariant( + markupList[i], + 'dangerouslyRenderMarkup(...): Missing markup.' + ); + nodeName = getNodeName(markupList[i]); + nodeName = markupWrap[nodeName] ? nodeName : '*'; + markupByNodeName[nodeName] = markupByNodeName[nodeName] || []; + markupByNodeName[nodeName][i] = markupList[i]; + } + var renderedMarkup = []; + for (nodeName in markupByNodeName) { + if (!markupByNodeName.hasOwnProperty(nodeName)) { + continue; + } + var markupListByNodeName = markupByNodeName[nodeName]; + var markup = markupListByNodeName.join(''); + // Render each group of markup. + var childNode = renderMarkup(markup, nodeName)[0]; + // Restore the initial ordering. + for (var j = 0; j < markupListByNodeName.length; j++) { + // `markupListByNodeName` may be a sparse array. + if (markupListByNodeName[j]) { + invariant(childNode, 'dangerouslyRenderMarkup(...): Missing node.'); + renderedMarkup[j] = childNode; + childNode = childNode.nextSibling; + } + } + invariant(!childNode, 'dangerouslyRenderMarkupO(...): Unexpected nodes.'); + } + return renderedMarkup; + }, + + /** + * Replaces a node with a string of markup at its current position within its + * parent. The markup must render into a single root node. + * + * @param {DOMElement} oldChild Child node to replace. + * @param {string} markup Markup to render in place of the child node. + * @internal + */ + dangerouslyReplaceNodeWithMarkup: function(oldChild, markup) { + invariant( + ExecutionEnvironment.canUseDOM, + 'dangerouslyReplaceNodeWithMarkup(...): Cannot render markup in a ' + + 'worker thread. This is likely a bug in the framework. Please report ' + + 'immediately.' + ); + invariant(markup, 'dangerouslyReplaceNodeWithMarkup(...): Missing markup.'); + var newChild = renderMarkup(markup)[0]; + oldChild.parentNode.replaceChild(newChild, oldChild); + } + }; module.exports = Danger; diff --git a/src/dom/__tests__/Danger-test.js b/src/dom/__tests__/Danger-test.js index 480c42f5d7..62bccb41d1 100644 --- a/src/dom/__tests__/Danger-test.js +++ b/src/dom/__tests__/Danger-test.js @@ -23,7 +23,7 @@ var React = require('React'); describe('Danger', function() { - describe('dangerouslyInsertMarkupAt', function() { + describe('dangerouslyRenderMarkup', function() { var Danger; var transaction; @@ -37,33 +37,71 @@ describe('Danger', function() { it('should render markup', function() { var markup = (
).mountComponent('.rX', transaction); - var parent = document.createElement('div'); + var output = Danger.dangerouslyRenderMarkup([markup])[0]; - Danger.dangerouslyInsertMarkupAt(parent, markup, 0); - - expect(parent.innerHTML).toBe('
'); + expect(output.nodeName).toBe('DIV'); }); it('should render markup with props', function() { var markup = (
).mountComponent('.rX', transaction); - var parent = document.createElement('div'); + var output = Danger.dangerouslyRenderMarkup([markup])[0]; - Danger.dangerouslyInsertMarkupAt(parent, markup, 0); - - expect(parent.innerHTML).toBe( - '
' - ); + expect(output.nodeName).toBe('DIV'); + expect(output.className).toBe('foo'); }); it('should render wrapped markup', function() { var markup = ().mountComponent('.rX', transaction); - var parent = document.createElement('div'); + var output = Danger.dangerouslyRenderMarkup([markup])[0]; - Danger.dangerouslyInsertMarkupAt(parent, markup, 0); - - expect(parent.innerHTML).toBe(''); + expect(output.nodeName).toBe('TH'); }); + it('should render lists of markup with similar `nodeName`', function() { + var renderedMarkup = Danger.dangerouslyRenderMarkup( + ['

1

', '

2

', '

3

'] + ); + + expect(renderedMarkup.length).toBe(3); + + expect(renderedMarkup[0].nodeName).toBe('P'); + expect(renderedMarkup[1].nodeName).toBe('P'); + expect(renderedMarkup[2].nodeName).toBe('P'); + + expect(renderedMarkup[0].innerHTML).toBe('1'); + expect(renderedMarkup[1].innerHTML).toBe('2'); + expect(renderedMarkup[2].innerHTML).toBe('3'); + }); + + it('should render lists of markup with different `nodeName`', function() { + var renderedMarkup = Danger.dangerouslyRenderMarkup( + ['

1

', '2', '

3

'] + ); + + expect(renderedMarkup.length).toBe(3); + + expect(renderedMarkup[0].nodeName).toBe('P'); + expect(renderedMarkup[1].nodeName).toBe('TR'); + expect(renderedMarkup[2].nodeName).toBe('P'); + + expect(renderedMarkup[0].innerHTML).toBe('1'); + expect(renderedMarkup[1].innerHTML).toBe('2'); + expect(renderedMarkup[2].innerHTML).toBe('3'); + }); + + it('should throw when rendering invalid markup', function() { + expect(function() { + Danger.dangerouslyRenderMarkup(['']); + }).toThrow( + 'Invariant Violation: dangerouslyRenderMarkup(...): Missing markup.' + ); + + expect(function() { + Danger.dangerouslyRenderMarkup(['

']); + }).toThrow( + 'Invariant Violation: dangerouslyRenderMarkupO(...): Unexpected nodes.' + ); + }); }); }); diff --git a/src/dom/insertNodeAt.js b/src/dom/insertNodeAt.js deleted file mode 100644 index 15b294a884..0000000000 --- a/src/dom/insertNodeAt.js +++ /dev/null @@ -1,46 +0,0 @@ -/** - * 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 insertNodeAt - */ - -"use strict"; - -/** - * Inserts `node` at a particular child index. Other nodes move to make room. - * @param {!Element} root The parent root node to insert into. - * @param {!node} node The node to insert. - * @param {!number} atIndex The index in `root` that `node` should exist at. - */ -function insertNodeAt(root, node, atIndex) { - var childNodes = root.childNodes; - // Remove from parent so that if node is already child of root, - // `childNodes[atIndex]` already takes into account the removal. - var curAtIndex = root.childNodes[atIndex]; - if (curAtIndex === node) { - return node; - } - if (node.parentNode) { - node.parentNode.removeChild(node); - } - if (atIndex >= childNodes.length) { - root.appendChild(node); - } else { - root.insertBefore(node, childNodes[atIndex]); - } - return node; -} - -module.exports = insertNodeAt;