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(
{'foo'}
{'bar'}
, el); + + var container = ReactDOM.findDOMNode(inst); + var childDiv = container.childNodes[1]; + var childNodes; + + inst = ReactDOM.render(
{null}
{null}
, el); + container = ReactDOM.findDOMNode(inst); + childNodes = container.childNodes; + expect(childNodes.length).toBe(1); + expect(childNodes[0]).toBe(childDiv); + + inst = ReactDOM.render(
{'foo'}
{'bar'}
, 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;