diff --git a/src/browser/dom/__tests__/DOMChildrenOperations-test.js b/src/browser/dom/__tests__/DOMChildrenOperations-test.js
new file mode 100644
index 0000000000000..24b6710c19f96
--- /dev/null
+++ b/src/browser/dom/__tests__/DOMChildrenOperations-test.js
@@ -0,0 +1,169 @@
+/**
+ * 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 = require('React');
+var ReactTestUtils = require('ReactTestUtils');
+
+describe('DOMChildrenOperations', function() {
+ describe('processUpdates', function() {
+ var iframeItem1 = {iframe: true, key: 'iframe1'};
+ var iframeItem2 = {iframe: true, key: 'iframe2'};
+ var listOfItems = [];
+
+ for (var i = 0; i < 20; i++) {
+ if (i % 2) {
+ listOfItems.push({index: i});
+ } else {
+ listOfItems.push({key: i, index: i});
+ }
+ }
+
+ var TestComponent = React.createClass({
+ getInitialState: function() {
+ return {items: this.props.items};
+ },
+ mutateChildren: function() {
+ var temp = listOfItems.slice();
+ var items = this.props.items.slice();
+ var count = Math.floor(Math.random() * 15) + 5;
+
+ if (this.props.reload) {
+ for (var i = 0; i < count; i++) {
+ items.unshift(items.pop());
+ }
+ }
+
+ for (var i = 0; i < count; i++) {
+ var from = Math.floor(Math.random() * temp.length);
+ var to = Math.floor(Math.random() * (items.length + 1));
+ var item = temp.splice(from, 1)[0];
+ items.splice(to, 0, item);
+ }
+
+ this.setState({
+ items: items
+ });
+ },
+ expectImmovableReloaded: function() {
+ var items = this.state.items;
+ var childNodes = this.getDOMNode().childNodes;
+ var reloaded = false;
+
+ for (var i = 0; i < items.length; i++) {
+ if (items[i].iframe) {
+ reloaded = reloaded ||
+ !(childNodes[i].contentWindow &&
+ childNodes[i].contentWindow.reactKey);
+ }
+ }
+
+ expect(reloaded).toBe(true);
+ },
+ componentDidMount: function() {
+ var items = this.state.items;
+ var childNodes = this.getDOMNode().childNodes;
+
+ for (var i = 0; i < items.length; i++) {
+ if (items[i].iframe) {
+ childNodes[i].contentWindow.reactKey = items[i].key;
+ }
+ }
+ },
+ componentDidUpdate: function() {
+ var items = this.state.items;
+ var childNodes = this.getDOMNode().childNodes;
+
+ expect(childNodes.length).toBe(items.length);
+
+ for (var i = 0; i < items.length; i++) {
+ if (items[i].iframe) {
+ if (!this.props.reload) {
+ expect(
+ childNodes[i].contentWindow &&
+ childNodes[i].contentWindow.reactKey
+ ).toBe(items[i].key);
+ }
+ } else {
+ expect(childNodes[i].textContent).toBe(i + ':' + items[i].index);
+ }
+ }
+ },
+ render: function() {
+ return (
+
+ {this.state.items.map(function(item, i) {
+ if (item.iframe) {
+ return
;
+ } else {
+ return
{i + ':' + item.index}
;
+ }
+ })}
+
+ );
+ }
+ });
+
+ afterEach(function() {
+ document.documentElement.removeChild(
+ document.documentElement.lastChild
+ );
+ });
+
+ var iterations = 100;
+
+ it('should mutate 100 nodes without fault', function() {
+ var component = ReactTestUtils.renderAttachedIntoDocument(
+
+ );
+ for (var i = 0; i < iterations; i++) {
+ component.mutateChildren();
+ }
+ });
+
+ it('should mutate and not reload the immovable object', function() {
+ var component = ReactTestUtils.renderAttachedIntoDocument(
+
+ );
+ for (var i = 0; i < iterations; i++) {
+ component.mutateChildren();
+ }
+ });
+
+ it('should mutate and not reload any immovable object', function() {
+ var component = ReactTestUtils.renderAttachedIntoDocument(
+
+ );
+ for (var i = 0; i < iterations; i++) {
+ component.mutateChildren();
+ }
+ });
+
+ it('should mutate and reload the immovable objects', function() {
+ var component = ReactTestUtils.renderAttachedIntoDocument(
+
+ );
+ for (var i = 0; i < iterations; i++) {
+ component.mutateChildren();
+ }
+ component.expectImmovableReloaded();
+ });
+ });
+});
diff --git a/src/browser/ui/dom/DOMChildrenOperations.js b/src/browser/ui/dom/DOMChildrenOperations.js
index 8326e3c1216d0..b12b967d3cb54 100644
--- a/src/browser/ui/dom/DOMChildrenOperations.js
+++ b/src/browser/ui/dom/DOMChildrenOperations.js
@@ -19,11 +19,21 @@
"use strict";
+var invariant = require('invariant');
+
var Danger = require('Danger');
var ReactMultiChildUpdateTypes = require('ReactMultiChildUpdateTypes');
var getTextContentAccessor = require('getTextContentAccessor');
+var immovableTagNames = {
+ 'IFRAME': true,
+ 'OBJECT': true,
+ 'EMBED': true,
+ 'VIDEO': true,
+ 'AUDIO': true
+};
+
/**
* The DOM property to use when setting text content.
*
@@ -88,6 +98,53 @@ if (textContentAccessor === 'textContent') {
};
}
+/**
+ * Same purpose as `insertBefore` but does so without moving `childNode` or
+ * knowing where it is located, instead moving its siblings from one side to the
+ * other. `childNode` will be moved to the end if there is no node at
+ * `referenceIndex´. This behavior avoids resetting iframe/audio/video/etc.
+ *
+ * @param {DOMElement} parentNode Parent node of the child.
+ * @param {DOMElement} childNode Child node to move.
+ * @param {number} referenceIndex Index to which the child should move to.
+ * @internal
+ */
+function moveChildBefore(parentNode, childNode, referenceIndex) {
+ // We only support moving right as the current implementation of
+ // `ReactMultiChild` always moves components by inserting them further right.
+ var referenceNode = parentNode.childNodes[referenceIndex];
+ while (childNode.previousSibling !== referenceNode) {
+ invariant(
+ referenceNode == null || childNode.nextSibling != null,
+ 'moveChildBefore: tried to move childNode to referenceIndex, but did ' +
+ 'not encounter the node at referenceIndex on the way. The node at ' +
+ 'referenceIndex must come after childNode or not be in the DOM at all. ' +
+ 'This is at present an intentional limitation of this implementation.'
+ );
+ parentNode.insertBefore(childNode.nextSibling, childNode);
+ }
+}
+
+/**
+ * Detects if `node` is or has any descendants that must not be moved.
+ * `getElementsByTagName` relies on special LiveNodeLists which are super fast.
+ *
+ * iframe+object+embed reload and video+audio pause if they are in any way
+ * detached from the DOM and must be considered immovable.
+ *
+ * @param {DOMElement} node Node to test if it is or has immovable descendants.
+ * @return {boolean}
+ * @internal
+ */
+function hasImmovableDescendants(node) {
+ for (var tagName in immovableTagNames) {
+ if (node.getElementsByTagName(tagName)[0]) {
+ return true;
+ }
+ }
+ return !!immovableTagNames[node.nodeName];
+}
+
/**
* Operations for updating with DOM children.
*/
@@ -106,23 +163,107 @@ var DOMChildrenOperations = {
* @internal
*/
processUpdates: function(updates, markupList) {
- var update;
+ var update, updatedChild, parentID;
+
// Mapping from parent IDs to initial child orderings.
var initialChildren = null;
// List of children that will be moved or removed.
var updatedChildren = null;
+ // Mapping of parents that has immovable descendants.
+ var immovableParents = {};
+ // Mapping of parents to updates, for immovable parents.
+ var immovableUpdates = null;
+ // Mapping of parents to inserted children count, for immovable parents.
+ var immovableInsertionCount = null;
+
+ // Test if there are any has immovable nodes at all in the document.
+ // Optimally, this should be done on the React root node instead.
+ var rootHasImmovableDescendants =
+ hasImmovableDescendants(document.documentElement);
+
for (var i = 0; update = updates[i]; i++) {
+ updatedChild = update.parentNode.childNodes[update.fromIndex];
+ parentID = update.parentID;
+
+ /**
+ * This implementation for solving immovable nodes relies on two
+ * assumptions that make it extra fast and simple.
+ *
+ * 1. `ReactMultiChild` sends updates in strict ascending `toIndex` order.
+ * 2. `ReactMultiChild` removes from the left and inserts to the right.
+ *
+ * Since immovable nodes must not be detached, we need to push them right
+ * and out of the way so that the indexes remain consistent as is
+ * expected. We do this by counting all insertions taking place before the
+ * immovable node. This allows us to compute its actual toIndex, and thus
+ * its expected previous sibling, after all updated nodes have been
+ * detached. This saves us from having to detach it and performs the
+ * minimal number of moves necessary.
+ */
+
+ if (rootHasImmovableDescendants) {
+ // Test if parent has immovable descendants and cache results.
+ if (immovableParents[parentID] === undefined) {
+ immovableParents[parentID] =
+ hasImmovableDescendants(update.parentNode);
+
+ if (immovableParents[parentID]) {
+ immovableInsertionCount = immovableInsertionCount || {};
+ immovableInsertionCount[parentID] = 0;
+ }
+ }
+
+ if (immovableParents[parentID]) {
+ if (update.type === ReactMultiChildUpdateTypes.MOVE_EXISTING) {
+ // Test if child has immovable descendants and cache results.
+ if (hasImmovableDescendants(updatedChild)) {
+ immovableUpdates = immovableUpdates || {};
+ immovableUpdates[parentID] = immovableUpdates[parentID] || [];
+
+ var lastImmovableUpdate =
+ immovableUpdates[parentID][immovableUpdates[parentID].length - 1];
+
+ // Determine if crossing the boundary of another immovable object.
+ // Assumption: `ReactMultiChild` moves nodes by `toIndex` in
+ // ascending order.
+ if (!lastImmovableUpdate ||
+ lastImmovableUpdate.fromIndex < update.fromIndex) {
+ update.type = ReactMultiChildUpdateTypes.SHIFT_IMMOVABLE;
+ update.toIndex -= immovableInsertionCount[parentID];
+ immovableUpdates[parentID].push(update);
+ } else if (__DEV__) {
+ // Unstoppable force meets immovable object, this is a violation,
+ // our only way out is treat it as movable and let it break/reset.
+ console.warn(
+ 'React has moved an iframe/object/embed/video/audio-node ' +
+ 'over to the other side of another such node. This is a ' +
+ 'valid operation, but the contents of the node has been ' +
+ 'reset/reloaded/paused. This is a "flaw" in HTML that cannot ' +
+ 'be avoided.'
+ );
+ }
+ }
+ }
+
+ if (update.type === ReactMultiChildUpdateTypes.INSERT_MARKUP ||
+ update.type === ReactMultiChildUpdateTypes.MOVE_EXISTING) {
+ // Insertion of a movable object in an immovable parent, count it.
+ immovableInsertionCount[parentID]++;
+ }
+ }
+ }
+
if (update.type === ReactMultiChildUpdateTypes.MOVE_EXISTING ||
+ update.type === ReactMultiChildUpdateTypes.SHIFT_IMMOVABLE ||
update.type === ReactMultiChildUpdateTypes.REMOVE_NODE) {
- var updatedIndex = update.fromIndex;
- var updatedChild = update.parentNode.childNodes[updatedIndex];
- var parentID = update.parentID;
-
initialChildren = initialChildren || {};
initialChildren[parentID] = initialChildren[parentID] || [];
- initialChildren[parentID][updatedIndex] = updatedChild;
+ initialChildren[parentID][update.fromIndex] = updatedChild;
+ }
+ if (update.type === ReactMultiChildUpdateTypes.MOVE_EXISTING ||
+ update.type === ReactMultiChildUpdateTypes.REMOVE_NODE) {
updatedChildren = updatedChildren || [];
updatedChildren.push(updatedChild);
}
@@ -132,12 +273,27 @@ var DOMChildrenOperations = {
// 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 j = 0; updatedChild = updatedChildren[j]; j++) {
+ updatedChild.parentNode.removeChild(updatedChild);
+ }
+ }
+
+ // We found immovable nodes, move them into position immediately.
+ if (immovableUpdates) {
+ for (parentID in immovableUpdates) {
+ for (var k = immovableUpdates[parentID].length - 1; k >= 0; k--) {
+ update = immovableUpdates[parentID][k];
+
+ moveChildBefore(
+ update.parentNode,
+ initialChildren[parentID][update.fromIndex],
+ update.toIndex
+ );
+ }
}
}
- for (var k = 0; update = updates[k]; k++) {
+ for (var l = 0; update = updates[l]; l++) {
switch (update.type) {
case ReactMultiChildUpdateTypes.INSERT_MARKUP:
insertChildAt(
@@ -153,15 +309,18 @@ var DOMChildrenOperations = {
update.toIndex
);
break;
+ //case ReactMultiChildUpdateTypes.SHIFT_IMMOVABLE:
+ // Already moved into position by the loops above.
+ //break;
case ReactMultiChildUpdateTypes.TEXT_CONTENT:
updateTextContent(
update.parentNode,
update.textContent
);
break;
- case ReactMultiChildUpdateTypes.REMOVE_NODE:
+ //case ReactMultiChildUpdateTypes.REMOVE_NODE:
// Already removed by the for-loop above.
- break;
+ //break;
}
}
}
diff --git a/src/core/ReactMultiChildUpdateTypes.js b/src/core/ReactMultiChildUpdateTypes.js
index 18cfd065e8490..e5a4567b3f2d7 100644
--- a/src/core/ReactMultiChildUpdateTypes.js
+++ b/src/core/ReactMultiChildUpdateTypes.js
@@ -31,6 +31,7 @@ var keyMirror = require('keyMirror');
var ReactMultiChildUpdateTypes = keyMirror({
INSERT_MARKUP: null,
MOVE_EXISTING: null,
+ SHIFT_IMMOVABLE: null,
REMOVE_NODE: null,
TEXT_CONTENT: null
});
diff --git a/src/test/ReactTestUtils.js b/src/test/ReactTestUtils.js
index cac11e9bbae7f..e4b3c091e3b0a 100644
--- a/src/test/ReactTestUtils.js
+++ b/src/test/ReactTestUtils.js
@@ -57,6 +57,12 @@ var ReactTestUtils = {
return React.renderComponent(instance, div);
},
+ renderAttachedIntoDocument: function(instance) {
+ var div = document.createElement('div');
+ document.documentElement.appendChild(div);
+ return React.renderComponent(instance, div);
+ },
+
isDescriptor: function(descriptor) {
return ReactDescriptor.isValidDescriptor(descriptor);
},