diff --git a/src/renderers/dom/client/ReactDOMComponentTree.js b/src/renderers/dom/client/ReactDOMComponentTree.js
index be0320cae36e9..5cf24f4835619 100644
--- a/src/renderers/dom/client/ReactDOMComponentTree.js
+++ b/src/renderers/dom/client/ReactDOMComponentTree.js
@@ -13,6 +13,7 @@
var DOMProperty = require('DOMProperty');
var ReactDOMComponentFlags = require('ReactDOMComponentFlags');
+var ReactNativeComponent = require('ReactNativeComponent');
var invariant = require('invariant');
@@ -87,10 +88,12 @@ function precacheChildNodes(inst, node) {
}
// We assume the child nodes are in the same order as the child instances.
for (; childNode !== null; childNode = childNode.nextSibling) {
- if (childNode.nodeType === 1 &&
- childNode.getAttribute(ATTR_NAME) === String(childID) ||
- childNode.nodeType === 8 &&
- childNode.nodeValue === ' react-empty: ' + childID + ' ') {
+ if ((childNode.nodeType === 1 &&
+ childNode.getAttribute(ATTR_NAME) === String(childID)) ||
+ (childNode.nodeType === 3 &&
+ ReactNativeComponent.isTextComponent(childInst)) ||
+ (childNode.nodeType === 8 &&
+ childNode.nodeValue === ' react-empty: ' + childID + ' ')) {
precacheNode(childInst, childNode);
continue outer;
}
@@ -148,6 +151,13 @@ function getInstanceFromNode(node) {
}
}
+/**
+ * Given a DOM node, return true if it has a direct internal instance.
+ */
+function nodeHasInstance(node) {
+ return !!node[internalInstanceKey];
+}
+
/**
* Given a ReactDOMComponent or ReactDOMTextComponent, return the corresponding
* DOM node.
@@ -188,6 +198,7 @@ var ReactDOMComponentTree = {
getClosestInstanceFromNode: getClosestInstanceFromNode,
getInstanceFromNode: getInstanceFromNode,
getNodeFromInstance: getNodeFromInstance,
+ nodeHasInstance: nodeHasInstance,
precacheChildNodes: precacheChildNodes,
precacheNode: precacheNode,
uncacheNode: uncacheNode,
diff --git a/src/renderers/dom/client/__tests__/ReactDOMComponentTree-test.js b/src/renderers/dom/client/__tests__/ReactDOMComponentTree-test.js
index d3f5df1179435..89ec35d58e75f 100644
--- a/src/renderers/dom/client/__tests__/ReactDOMComponentTree-test.js
+++ b/src/renderers/dom/client/__tests__/ReactDOMComponentTree-test.js
@@ -100,7 +100,9 @@ describe('ReactDOMComponentTree', function() {
expect(renderAndGetInstance('main')._currentElement.type).toBe('main');
// This one's a text component!
- expect(renderAndGetInstance('span')._stringText).toBe('goodbye.');
+ var root = renderAndQuery(null);
+ var inst = ReactDOMComponentTree.getInstanceFromNode(root.children[0].childNodes[2]);
+ expect(inst._stringText).toBe('goodbye.');
expect(renderAndGetClosest('b')._currentElement.type).toBe('main');
expect(renderAndGetClosest('img')._currentElement.type).toBe('main');
diff --git a/src/renderers/dom/client/utils/setTextContent.js b/src/renderers/dom/client/utils/setTextContent.js
index d15ce89eac9fd..ce8d17ca05dd8 100644
--- a/src/renderers/dom/client/utils/setTextContent.js
+++ b/src/renderers/dom/client/utils/setTextContent.js
@@ -12,9 +12,32 @@
'use strict';
var ExecutionEnvironment = require('ExecutionEnvironment');
+var ReactDOMComponentTree = require('ReactDOMComponentTree');
+
var escapeTextContentForBrowser = require('escapeTextContentForBrowser');
var setInnerHTML = require('setInnerHTML');
+var nodeHasInstance = ReactDOMComponentTree.nodeHasInstance;
+
+// If setTextContent is called on a text node, we first remove adjacent text
+// nodes that may have been split off from the original by the browser. See
+// e.g. https://bugzilla.mozilla.org/show_bug.cgi?id=194231
+function removeAdjacentTextNodesImpl(node, accessor) {
+ while (true) {
+ var sibling = node[accessor];
+ if (sibling && sibling.nodeType === 3 && !nodeHasInstance(sibling)) {
+ node.parentNode.removeChild(sibling);
+ } else {
+ break;
+ }
+ }
+}
+
+function removeAdjacentTextNodes(node) {
+ removeAdjacentTextNodesImpl(node, 'prevSibling');
+ removeAdjacentTextNodesImpl(node, 'nextSibling');
+}
+
/**
* Set the textContent property of a node, ensuring that whitespace is preserved
* even in IE8. innerText is a poor substitute for textContent and, among many
@@ -26,12 +49,18 @@ var setInnerHTML = require('setInnerHTML');
* @internal
*/
var setTextContent = function(node, text) {
+ if (node.nodeType === 3) {
+ removeAdjacentTextNodes(node);
+ }
node.textContent = text;
};
if (ExecutionEnvironment.canUseDOM) {
if (!('textContent' in document.documentElement)) {
setTextContent = function(node, text) {
+ if (node.nodeType === 3) {
+ removeAdjacentTextNodes(node);
+ }
setInnerHTML(node, escapeTextContentForBrowser(text));
};
}
diff --git a/src/renderers/dom/client/validateDOMNesting.js b/src/renderers/dom/client/validateDOMNesting.js
index 22cfbd18cd193..d479ea9af9a44 100644
--- a/src/renderers/dom/client/validateDOMNesting.js
+++ b/src/renderers/dom/client/validateDOMNesting.js
@@ -384,6 +384,11 @@ if (__DEV__) {
}
didWarn[warnKey] = true;
+ var tagDisplayName = childTag;
+ if (childTag !== '#text') {
+ tagDisplayName = '<' + childTag + '>';
+ }
+
if (invalidParent) {
var info = '';
if (ancestorTag === 'table' && childTag === 'tr') {
@@ -393,9 +398,9 @@ if (__DEV__) {
}
warning(
false,
- 'validateDOMNesting(...): <%s> cannot appear as a child of <%s>. ' +
+ 'validateDOMNesting(...): %s cannot appear as a child of <%s>. ' +
'See %s.%s',
- childTag,
+ tagDisplayName,
ancestorTag,
ownerInfo,
info
@@ -403,9 +408,9 @@ if (__DEV__) {
} else {
warning(
false,
- 'validateDOMNesting(...): <%s> cannot appear as a descendant of ' +
+ 'validateDOMNesting(...): %s cannot appear as a descendant of ' +
'<%s>. See %s.',
- childTag,
+ tagDisplayName,
ancestorTag,
ownerInfo
);
diff --git a/src/renderers/dom/server/__tests__/ReactServerRendering-test.js b/src/renderers/dom/server/__tests__/ReactServerRendering-test.js
index 10c683d42396e..6244d7431477b 100644
--- a/src/renderers/dom/server/__tests__/ReactServerRendering-test.js
+++ b/src/renderers/dom/server/__tests__/ReactServerRendering-test.js
@@ -103,8 +103,7 @@ describe('ReactServerRendering', function() {
ID_ATTRIBUTE_NAME + '="[^"]+" ' +
ReactMarkupChecksum.CHECKSUM_ATTR_NAME + '="[^"]+">' +
'' +
- 'My name is ' +
- 'child' +
+ 'My name is child' +
'' +
''
);
@@ -153,8 +152,7 @@ describe('ReactServerRendering', function() {
'' +
- 'Component name: ' +
- 'TestComponent' +
+ 'Component name: TestComponent' +
''
);
expect(lifecycle).toEqual(
diff --git a/src/renderers/dom/shared/ReactDOMTextComponent.js b/src/renderers/dom/shared/ReactDOMTextComponent.js
index 63d022bf33512..4eca4219770b9 100644
--- a/src/renderers/dom/shared/ReactDOMTextComponent.js
+++ b/src/renderers/dom/shared/ReactDOMTextComponent.js
@@ -13,7 +13,6 @@
var DOMChildrenOperations = require('DOMChildrenOperations');
var DOMLazyTree = require('DOMLazyTree');
-var DOMPropertyOperations = require('DOMPropertyOperations');
var ReactDOMComponentTree = require('ReactDOMComponentTree');
var ReactPerf = require('ReactPerf');
@@ -85,7 +84,7 @@ assign(ReactDOMTextComponent.prototype, {
if (parentInfo) {
// parentInfo should always be present except for the top-level
// component when server rendering
- validateDOMNesting('span', this, parentInfo);
+ validateDOMNesting('#text', this, parentInfo);
}
}
@@ -94,26 +93,11 @@ assign(ReactDOMTextComponent.prototype, {
this._nativeParent = nativeParent;
if (transaction.useCreateElement) {
var ownerDocument = nativeContainerInfo._ownerDocument;
- var el = ownerDocument.createElement('span');
+ var el = ownerDocument.createTextNode(this._stringText);
ReactDOMComponentTree.precacheNode(this, el);
- var lazyTree = DOMLazyTree(el);
- DOMLazyTree.queueText(lazyTree, this._stringText);
- return lazyTree;
+ return DOMLazyTree(el);
} else {
- var escapedText = escapeTextContentForBrowser(this._stringText);
-
- if (transaction.renderToStaticMarkup) {
- // Normally we'd wrap this in a `span` for the reasons stated above, but
- // since this is a situation where React won't take over (static pages),
- // we can simply return the text as it is.
- return escapedText;
- }
-
- return (
- '' +
- escapedText +
- ''
- );
+ return escapeTextContentForBrowser(this._stringText);
}
},
diff --git a/src/renderers/dom/shared/__tests__/ReactDOMComponent-test.js b/src/renderers/dom/shared/__tests__/ReactDOMComponent-test.js
index 66178eb2362aa..8fc61b186f343 100644
--- a/src/renderers/dom/shared/__tests__/ReactDOMComponent-test.js
+++ b/src/renderers/dom/shared/__tests__/ReactDOMComponent-test.js
@@ -1172,8 +1172,8 @@ describe('ReactDOMComponent', function() {
'match the DOM tree generated by the browser.'
);
expect(console.error.argsForCall[1][0]).toBe(
- 'Warning: validateDOMNesting(...): cannot appear as a child ' +
- 'of . See Foo > table > span.'
+ 'Warning: validateDOMNesting(...): #text cannot appear as a child ' +
+ 'of . See Foo > table > #text.'
);
});
diff --git a/src/renderers/dom/shared/__tests__/ReactDOMTextComponent-test.js b/src/renderers/dom/shared/__tests__/ReactDOMTextComponent-test.js
index c15b5174d10b4..ec472837e5fc7 100644
--- a/src/renderers/dom/shared/__tests__/ReactDOMTextComponent-test.js
+++ b/src/renderers/dom/shared/__tests__/ReactDOMTextComponent-test.js
@@ -13,26 +13,95 @@
var React;
var ReactDOM;
+var ReactDOMServer;
describe('ReactDOMTextComponent', function() {
beforeEach(function() {
React = require('React');
ReactDOM = require('ReactDOM');
+ ReactDOMServer = require('ReactDOMServer');
});
it('updates a mounted text component in place', function() {
var el = document.createElement('div');
- var inst = ReactDOM.render({'foo'}{'bar'}
, el);
-
- var foo = ReactDOM.findDOMNode(inst).children[0];
- var bar = ReactDOM.findDOMNode(inst).children[1];
- expect(foo.tagName).toBe('SPAN');
- expect(bar.tagName).toBe('SPAN');
-
- inst = ReactDOM.render({'baz'}{'qux'}
, el);
- // After the update, the spans should have stayed in place (as opposed to
- // getting unmounted and remounted)
- expect(ReactDOM.findDOMNode(inst).children[0]).toBe(foo);
- expect(ReactDOM.findDOMNode(inst).children[1]).toBe(bar);
+ var inst = ReactDOM.render({'foo'}{'bar'}
, el);
+
+ var foo = ReactDOM.findDOMNode(inst).childNodes[1];
+ var bar = ReactDOM.findDOMNode(inst).childNodes[2];
+ expect(foo.data).toBe('foo');
+ expect(bar.data).toBe('bar');
+
+ inst = ReactDOM.render({'baz'}{'qux'}
, el);
+ // After the update, the text nodes should have stayed in place (as opposed
+ // to getting unmounted and remounted)
+ expect(ReactDOM.findDOMNode(inst).childNodes[1]).toBe(foo);
+ expect(ReactDOM.findDOMNode(inst).childNodes[2]).toBe(bar);
+ expect(foo.data).toBe('baz');
+ expect(bar.data).toBe('qux');
+ });
+
+ it('can be toggled in and out of the markup', function() {
+ var el = document.createElement('div');
+ var inst = ReactDOM.render(, el);
+
+ var container = ReactDOM.findDOMNode(inst);
+ var childDiv = container.childNodes[1];
+ var childNodes;
+
+ inst = ReactDOM.render(, el);
+ container = ReactDOM.findDOMNode(inst);
+ childNodes = container.childNodes;
+ expect(childNodes.length).toBe(1);
+ expect(childNodes[0]).toBe(childDiv);
+
+ inst = ReactDOM.render(, el);
+ container = ReactDOM.findDOMNode(inst);
+ childNodes = container.childNodes;
+ expect(childNodes.length).toBe(3);
+ expect(childNodes[0].data).toBe('foo');
+ expect(childNodes[1]).toBe(childDiv);
+ expect(childNodes[2].data).toBe('bar');
+ });
+
+ it('can reconcile text merged by Node.normalize()', function() {
+ var el = document.createElement('div');
+ var inst = ReactDOM.render({'foo'}{'bar'}{'baz'}
, el);
+
+ var container = ReactDOM.findDOMNode(inst);
+ container.normalize();
+
+ inst = ReactDOM.render({'bar'}{'baz'}{'qux'}
, el);
+ container = ReactDOM.findDOMNode(inst);
+ expect(container.textContent).toBe('barbazqux');
+ });
+
+ it('can reconcile text from pre-rendered markup', function() {
+ var el = document.createElement('div');
+ var reactEl = {'foo'}{'bar'}{'baz'}
;
+ el.innerHTML = ReactDOMServer.renderToString(reactEl);
+
+ ReactDOM.render(reactEl, el);
+ expect(el.textContent).toBe('foobarbaz');
+
+ reactEl = {''}{''}{''}
;
+ el.innerHTML = ReactDOMServer.renderToString(reactEl);
+
+ ReactDOM.render(reactEl, el);
+ expect(el.textContent).toBe('');
+ });
+
+ it('can reconcile text arbitrarily split into multiple nodes', function() {
+ var el = document.createElement('div');
+ var inst = ReactDOM.render({'foobarbaz'}
, el);
+
+ var container = ReactDOM.findDOMNode(inst);
+ var textNode = container.childNodes[1];
+ textNode.textContent = 'foo';
+ container.appendChild(document.createTextNode('bar'));
+ container.appendChild(document.createTextNode('baz'));
+
+ inst = ReactDOM.render({'barbazqux'}
, el);
+ container = ReactDOM.findDOMNode(inst);
+ expect(container.textContent).toBe('barbazqux');
});
});
diff --git a/src/renderers/shared/reconciler/ReactChildReconciler.js b/src/renderers/shared/reconciler/ReactChildReconciler.js
index 8d277f7c04ebc..11f607404e373 100644
--- a/src/renderers/shared/reconciler/ReactChildReconciler.js
+++ b/src/renderers/shared/reconciler/ReactChildReconciler.js
@@ -14,6 +14,7 @@
var ReactReconciler = require('ReactReconciler');
var instantiateReactComponent = require('instantiateReactComponent');
+var isOrphanedTextComponent = require('isOrphanedTextComponent');
var shouldUpdateReactComponent = require('shouldUpdateReactComponent');
var traverseAllChildren = require('traverseAllChildren');
var warning = require('warning');
@@ -89,6 +90,9 @@ var ReactChildReconciler = {
continue;
}
prevChild = prevChildren && prevChildren[name];
+ if (isOrphanedTextComponent(prevChild)) {
+ prevChild = null;
+ }
var prevElement = prevChild && prevChild._currentElement;
var nextElement = nextChildren[name];
if (prevChild != null &&
diff --git a/src/renderers/shared/reconciler/__tests__/ReactMultiChildText-test.js b/src/renderers/shared/reconciler/__tests__/ReactMultiChildText-test.js
index f5129ff417b4e..487ae487f8abf 100644
--- a/src/renderers/shared/reconciler/__tests__/ReactMultiChildText-test.js
+++ b/src/renderers/shared/reconciler/__tests__/ReactMultiChildText-test.js
@@ -55,15 +55,8 @@ var expectChildren = function(d, children) {
var child = children[i];
if (typeof child === 'string') {
- textNode = outerNode.childNodes[i].firstChild;
-
- if (child === '') {
- expect(textNode).toBe(null);
- } else {
- expect(textNode).not.toBe(null);
- expect(textNode.nodeType).toBe(3);
- expect(textNode.data).toBe('' + child);
- }
+ textNode = outerNode.childNodes[i];
+ expect(textNode.data).toBe('' + child);
} else {
var elementDOMNode = outerNode.childNodes[i];
expect(elementDOMNode.tagName).toBe('DIV');
diff --git a/src/renderers/shared/reconciler/isOrphanedTextComponent.js b/src/renderers/shared/reconciler/isOrphanedTextComponent.js
new file mode 100644
index 0000000000000..802e560818b29
--- /dev/null
+++ b/src/renderers/shared/reconciler/isOrphanedTextComponent.js
@@ -0,0 +1,41 @@
+/**
+ * Copyright 2016-present, Facebook, Inc.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree. An additional grant
+ * of patent rights can be found in the PATENTS file in the same directory.
+ *
+ * @providesModule isOrphanedTextComponent
+ */
+
+'use strict';
+
+var ExecutionEnvironment = require('ExecutionEnvironment');
+
+var isOrphanedTextComponent;
+
+if (ExecutionEnvironment.canUseDOM) {
+ var ReactNativeComponent = require('ReactNativeComponent');
+
+ // Returns true if `inst` is a text component that doesn't exist in the DOM,
+ // but presumably should according to the caller. This is used to detect
+ // cases where the browser may have merged adjacent text nodes, e.g. using
+ // Node.normalize().
+ isOrphanedTextComponent = function(inst) {
+ if (inst &&
+ ReactNativeComponent.isTextComponent(inst) &&
+ inst._nativeNode &&
+ inst._nativeNode.parentNode == null) {
+ inst.unmountComponent();
+ return true;
+ }
+ return false;
+ };
+} else {
+ isOrphanedTextComponent = function() {
+ return false;
+ };
+}
+
+module.exports = isOrphanedTextComponent;