Batch Child Markup Generation

Setting `innerHTML` is slow: http://jsperf.com/react-child-creation/2

This reduces the number of times we set `innerHTML` by batching markup generation in a component tree.

As usual, I cleaned up the `ReactMultiChild` module significantly.

== Children Reconciliation ==

When a `ReactNativeComponent` reconciles, it compares currently rendered children, `prevChildren`, with the new children, `nextChildren`. It figures out the shortest series of updates required to render `nextChildren` where each update is one of:

 - Create nodes for a new child and insert it at an index.
 - Update an existing node and, if necessary, move it to an index.
 - Remove an existing node.

This serializable series of updates is sent to `ReactDOMIDOperations` where the actions are actually acted on.

== Problem ==

There are two problems:

 # When a `ReactNativeComponent` renders new children, it sets `innerHTML` once for each contiguous set of children.
 # Each `ReactNativeComponent` renders its children in isolation, so two components that both render new children will do so separately.

For example, consider the following update:

  React.renderComponent(<div><p><span /></p><p><span /></p></div>, ...);
  React.renderComponent(<div><p><img /><span /><img /></p><p><img /><span /><img /></p></div>, ...);

This will trigger setting `innerHTML` four times.

== Solution ==

Instead of enqueuing the series of updates per component, this diff changes `ReactMultiChild` to enqueue updates per component tree (which works by counting recursive calls to `updateChildren`). Once all updates in the tree are accounted for, we render all markup using a single `innerHTML` set.
This commit is contained in:
Tim Yung
2013-07-26 12:48:07 -07:00
committed by Paul O’Shannessy
parent 2e37f65bdc
commit adffa9b0f4
11 changed files with 688 additions and 474 deletions
+10 -5
View File
@@ -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<object>} updates List of update configurations.
* @param {array<string>} 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);
}
};
+12 -2
View File
@@ -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.
+323 -161
View File
@@ -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<object>}
* @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<string>}
* @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;
+35
View File
@@ -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;
+5 -4
View File
@@ -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();
}
};
@@ -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(
<div>
<p><span /></p>
<p><span /></p>
<p><span /></p>
</div>,
container
);
expect(setInnerHTML).toHaveBeenCalled();
var callCountOnMount = setInnerHTML.callCount;
React.renderComponent(
<div>
<p><span /><span /></p>
<p><span /><span /></p>
<p><span /><span /></p>
</div>,
container
);
expect(setInnerHTML.callCount).toBe(callCountOnMount + 1);
});
});
@@ -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 = {};
+90 -113
View File
@@ -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<object>} updates List of update configurations.
* @param {array<string>} 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;
+96 -127
View File
@@ -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<string>} markupList List of markup strings to render.
* @return {array<DOMElement>} 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;
+53 -15
View File
@@ -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 = (<div />).mountComponent('.rX', transaction);
var parent = document.createElement('div');
var output = Danger.dangerouslyRenderMarkup([markup])[0];
Danger.dangerouslyInsertMarkupAt(parent, markup, 0);
expect(parent.innerHTML).toBe('<div data-reactid=".rX"></div>');
expect(output.nodeName).toBe('DIV');
});
it('should render markup with props', function() {
var markup = (<div className="foo" />).mountComponent('.rX', transaction);
var parent = document.createElement('div');
var output = Danger.dangerouslyRenderMarkup([markup])[0];
Danger.dangerouslyInsertMarkupAt(parent, markup, 0);
expect(parent.innerHTML).toBe(
'<div class="foo" data-reactid=".rX"></div>'
);
expect(output.nodeName).toBe('DIV');
expect(output.className).toBe('foo');
});
it('should render wrapped markup', function() {
var markup = (<th />).mountComponent('.rX', transaction);
var parent = document.createElement('div');
var output = Danger.dangerouslyRenderMarkup([markup])[0];
Danger.dangerouslyInsertMarkupAt(parent, markup, 0);
expect(parent.innerHTML).toBe('<th data-reactid=".rX"></th>');
expect(output.nodeName).toBe('TH');
});
it('should render lists of markup with similar `nodeName`', function() {
var renderedMarkup = Danger.dangerouslyRenderMarkup(
['<p>1</p>', '<p>2</p>', '<p>3</p>']
);
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(
['<p>1</p>', '<tr>2</tr>', '<p>3</p>']
);
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(['<p></p><p></p>']);
}).toThrow(
'Invariant Violation: dangerouslyRenderMarkupO(...): Unexpected nodes.'
);
});
});
});
-46
View File
@@ -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;