diff --git a/src/renderers/dom/client/ReactDOMComponentTree.js b/src/renderers/dom/client/ReactDOMComponentTree.js index be0320cae36e9..ec95b26c6322d 100644 --- a/src/renderers/dom/client/ReactDOMComponentTree.js +++ b/src/renderers/dom/client/ReactDOMComponentTree.js @@ -87,10 +87,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 === 8 && + childNode.nodeValue === ' react-text: ' + childID + ' ') || + (childNode.nodeType === 8 && + childNode.nodeValue === ' react-empty: ' + childID + ' ')) { precacheNode(childInst, childNode); continue outer; } 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/DOMChildrenOperations.js b/src/renderers/dom/client/utils/DOMChildrenOperations.js index ce266352d723b..40bf13e490872 100644 --- a/src/renderers/dom/client/utils/DOMChildrenOperations.js +++ b/src/renderers/dom/client/utils/DOMChildrenOperations.js @@ -21,6 +21,11 @@ var setInnerHTML = require('setInnerHTML'); var setTextContent = require('setTextContent'); function getNodeAfter(parentNode, node) { + // Special case for text components, which return [open, close] comments + // from getNativeNode. + if (Array.isArray(node)) { + node = node[1]; + } return node ? node.nextSibling : parentNode.firstChild; } @@ -45,6 +50,78 @@ function insertLazyTreeChildAt(parentNode, childTree, referenceNode) { DOMLazyTree.insertTreeBefore(parentNode, childTree, referenceNode); } +function moveChild(parentNode, childNode, referenceNode) { + if (Array.isArray(childNode)) { + moveDelimitedText(parentNode, childNode[0], childNode[1], referenceNode); + } else { + insertChildAt(parentNode, childNode, referenceNode); + } +} + +function removeChild(parentNode, childNode) { + if (Array.isArray(childNode)) { + var closingComment = childNode[1]; + childNode = childNode[0]; + removeDelimitedText(parentNode, childNode, closingComment); + parentNode.removeChild(closingComment); + } + parentNode.removeChild(childNode); +} + +function moveDelimitedText( + parentNode, + openingComment, + closingComment, + referenceNode +) { + var node = openingComment; + while (true) { + var nextNode = node.nextSibling; + insertChildAt(parentNode, node, referenceNode); + if (node === closingComment) { + break; + } + node = nextNode; + } +} + +function removeDelimitedText(parentNode, startNode, closingComment) { + while (true) { + var node = startNode.nextSibling; + if (node === closingComment) { + // The closing comment is removed by ReactMultiChild. + break; + } else { + parentNode.removeChild(node); + } + } +} + +function replaceDelimitedText(openingComment, closingComment, stringText) { + var parentNode = openingComment.parentNode; + var nodeAfterComment = openingComment.nextSibling; + if (nodeAfterComment === closingComment) { + // There are no text nodes between the opening and closing comments; insert + // a new one if stringText isn't empty. + if (stringText) { + insertChildAt( + parentNode, + document.createTextNode(stringText), + nodeAfterComment + ); + } + } else { + if (stringText) { + // Set the text content of the first node after the opening comment, and + // remove all following nodes up until the closing comment. + setTextContent(nodeAfterComment, stringText); + removeDelimitedText(parentNode, nodeAfterComment, closingComment); + } else { + removeDelimitedText(parentNode, openingComment, closingComment); + } + } +} + /** * Operations for updating with DOM children. */ @@ -54,6 +131,8 @@ var DOMChildrenOperations = { updateTextContent: setTextContent, + replaceDelimitedText: replaceDelimitedText, + /** * Updates a component's children by processing a series of updates. The * update configurations are each expected to have a `parentNode` property. @@ -73,7 +152,7 @@ var DOMChildrenOperations = { ); break; case ReactMultiChildUpdateTypes.MOVE_EXISTING: - insertChildAt( + moveChild( parentNode, update.fromNode, getNodeAfter(parentNode, update.afterNode) @@ -92,7 +171,7 @@ var DOMChildrenOperations = { ); break; case ReactMultiChildUpdateTypes.REMOVE_NODE: - parentNode.removeChild(update.fromNode); + removeChild(parentNode, update.fromNode); break; } } @@ -102,6 +181,7 @@ var DOMChildrenOperations = { ReactPerf.measureMethods(DOMChildrenOperations, 'DOMChildrenOperations', { updateTextContent: 'updateTextContent', + replaceDelimitedText: 'replaceDelimitedText', }); module.exports = DOMChildrenOperations; 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..a32ba08c08e89 100644 --- a/src/renderers/dom/server/__tests__/ReactServerRendering-test.js +++ b/src/renderers/dom/server/__tests__/ReactServerRendering-test.js @@ -103,8 +103,8 @@ describe('ReactServerRendering', function() { ID_ATTRIBUTE_NAME + '="[^"]+" ' + ReactMarkupChecksum.CHECKSUM_ATTR_NAME + '="[^"]+">' + '' + - 'My name is ' + - 'child' + + 'My name is ' + + 'child' + '' + '' ); @@ -153,8 +153,8 @@ 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 b145ede10ef2e..60f3f7a04d535 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'); @@ -21,16 +20,14 @@ var assign = require('Object.assign'); var escapeTextContentForBrowser = require('escapeTextContentForBrowser'); var validateDOMNesting = require('validateDOMNesting'); -var getNode = ReactDOMComponentTree.getNodeFromInstance; - /** * Text nodes violate a couple assumptions that React makes about components: * * - When mounting text into the DOM, adjacent text nodes are merged. * - Text nodes cannot be assigned a React root ID. * - * This component is used to wrap strings in elements so that they can undergo - * the same reconciliation that is applied to elements. + * This component is used to wrap strings between comment nodes so that they + * can undergo the same reconciliation that is applied to elements. * * TODO: Investigate representing React components in the DOM with text nodes. * @@ -49,6 +46,8 @@ var ReactDOMTextComponent = function(text) { // Properties this._domID = null; this._mountIndex = 0; + this._openingComment = null; + this._commentNodes = null; }; assign(ReactDOMTextComponent.prototype, { @@ -77,34 +76,44 @@ 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); } } var domID = nativeContainerInfo._idCounter++; + var openingValue = ' react-text: ' + domID + ' '; + var closingValue = ' /react-text '; this._domID = domID; this._nativeParent = nativeParent; if (transaction.useCreateElement) { var ownerDocument = nativeContainerInfo._ownerDocument; - var el = ownerDocument.createElement('span'); - ReactDOMComponentTree.precacheNode(this, el); - var lazyTree = DOMLazyTree(el); - DOMLazyTree.queueText(lazyTree, this._stringText); + var openingComment = ownerDocument.createComment(openingValue); + var closingComment = ownerDocument.createComment(closingValue); + var lazyTree = DOMLazyTree(ownerDocument.createDocumentFragment()); + DOMLazyTree.queueChild(lazyTree, DOMLazyTree(openingComment)); + if (this._stringText) { + DOMLazyTree.queueChild( + lazyTree, + DOMLazyTree(ownerDocument.createTextNode(this._stringText)) + ); + } + DOMLazyTree.queueChild(lazyTree, DOMLazyTree(closingComment)); + this._openingComment = openingComment; + ReactDOMComponentTree.precacheNode(this, closingComment); return lazyTree; } 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. + // Normally we'd wrap this between comment nodes 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 + - '' + '' + escapedText + + '' ); } }, @@ -125,16 +134,29 @@ assign(ReactDOMTextComponent.prototype, { // and/or updateComponent to do the actual update for consistency with // other component types? this._stringText = nextStringText; - DOMChildrenOperations.updateTextContent(getNode(this), nextStringText); + var commentNodes = this.getNativeNode(); + DOMChildrenOperations.replaceDelimitedText( + commentNodes[0], + commentNodes[1], + nextStringText + ); } } }, getNativeNode: function() { - return getNode(this); + var nativeNode = this._commentNodes; + if (nativeNode) { + return nativeNode; + } + nativeNode = [this._openingComment, this._nativeNode]; + this._commentNodes = nativeNode; + return nativeNode; }, unmountComponent: function() { + this._openingComment = null; + this._commentNodes = null; ReactDOMComponentTree.uncacheNode(this); }, diff --git a/src/renderers/dom/shared/__tests__/ReactDOMComponent-test.js b/src/renderers/dom/shared/__tests__/ReactDOMComponent-test.js index 8a48544397838..96e586b06f6ce 100644 --- a/src/renderers/dom/shared/__tests__/ReactDOMComponent-test.js +++ b/src/renderers/dom/shared/__tests__/ReactDOMComponent-test.js @@ -1260,8 +1260,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..5c3e9a2e62ddd 100644 --- a/src/renderers/dom/shared/__tests__/ReactDOMTextComponent-test.js +++ b/src/renderers/dom/shared/__tests__/ReactDOMTextComponent-test.js @@ -13,26 +13,96 @@ 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[2]; + var bar = ReactDOM.findDOMNode(inst).childNodes[5]; + 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[2]).toBe(foo); + expect(ReactDOM.findDOMNode(inst).childNodes[5]).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[3]; + 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(7); + expect(childNodes[1].data).toBe('foo'); + expect(childNodes[3]).toBe(childDiv); + expect(childNodes[5].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 childNodes = container.childNodes; + var textNode = childNodes[2]; + textNode.textContent = 'foo'; + container.insertBefore(document.createTextNode('bar'), childNodes[3]); + container.insertBefore(document.createTextNode('baz'), childNodes[3]); + + inst = ReactDOM.render(
{'barbazqux'}
, el); + container = ReactDOM.findDOMNode(inst); + expect(container.textContent).toBe('barbazqux'); }); }); diff --git a/src/renderers/shared/reconciler/__tests__/ReactMultiChildText-test.js b/src/renderers/shared/reconciler/__tests__/ReactMultiChildText-test.js index f5129ff417b4e..bb409c30ab0ed 100644 --- a/src/renderers/shared/reconciler/__tests__/ReactMultiChildText-test.js +++ b/src/renderers/shared/reconciler/__tests__/ReactMultiChildText-test.js @@ -49,24 +49,40 @@ var expectChildren = function(d, children) { expect(textNode.data).toBe('' + children); } } else { - expect(outerNode.childNodes.length).toBe(children.length); + var openingCommentNode; + var closingCommentNode; + var mountIndex = 0; for (var i = 0; i < children.length; i++) { var child = children[i]; if (typeof child === 'string') { - textNode = outerNode.childNodes[i].firstChild; + openingCommentNode = outerNode.childNodes[mountIndex]; + + expect(openingCommentNode.nodeType).toBe(8); + expect(openingCommentNode.nodeValue).toMatch(' react-text: [0-9]+ '); if (child === '') { - expect(textNode).toBe(null); + textNode = null; + closingCommentNode = openingCommentNode.nextSibling; + mountIndex += 2; } else { - expect(textNode).not.toBe(null); + textNode = openingCommentNode.nextSibling; + closingCommentNode = textNode.nextSibling; + mountIndex += 3; + } + + if (textNode) { expect(textNode.nodeType).toBe(3); expect(textNode.data).toBe('' + child); } + + expect(closingCommentNode.nodeType).toBe(8); + expect(closingCommentNode.nodeValue).toBe(' /react-text '); } else { - var elementDOMNode = outerNode.childNodes[i]; + var elementDOMNode = outerNode.childNodes[mountIndex]; expect(elementDOMNode.tagName).toBe('DIV'); + mountIndex++; } } } @@ -194,4 +210,38 @@ describe('ReactMultiChildText', function() { ReactTestUtils.renderIntoDocument(

{['A', 'B']}

); }).not.toThrow(); }); + + it('should reorder keyed text nodes', function() { + spyOn(console, 'error'); + + var container = document.createElement('div'); + ReactDOM.render( +
{new Map([['a', 'alpha'], ['b', 'beta']])}
, + container + ); + + var childNodes = container.firstChild.childNodes; + var alpha1 = childNodes[0]; + var alpha2 = childNodes[1]; + var alpha3 = childNodes[2]; + var beta1 = childNodes[3]; + var beta2 = childNodes[4]; + var beta3 = childNodes[5]; + + ReactDOM.render( +
{new Map([['b', 'beta'], ['a', 'alpha']])}
, + container + ); + + childNodes = container.firstChild.childNodes; + expect(childNodes[0]).toBe(beta1); + expect(childNodes[1]).toBe(beta2); + expect(childNodes[2]).toBe(beta3); + expect(childNodes[3]).toBe(alpha1); + expect(childNodes[4]).toBe(alpha2); + expect(childNodes[5]).toBe(alpha3); + + // Using Maps as children gives a single warning + expect(console.error.calls.length).toBe(1); + }); }); diff --git a/src/test/ReactDefaultPerf.js b/src/test/ReactDefaultPerf.js index 185541172d1f9..9671e3d4f4529 100644 --- a/src/test/ReactDefaultPerf.js +++ b/src/test/ReactDefaultPerf.js @@ -27,7 +27,7 @@ function addValue(obj, key, val) { obj[key] = (obj[key] || 0) + val; } -// Composites don't have any built-in ID: we have to make our own +// Composite/text components don't have any built-in ID: we have to make our own var compositeIDMap; var compositeIDCounter = 17000; function getIDOfComposite(inst) { @@ -43,6 +43,14 @@ function getIDOfComposite(inst) { } } +function getID(inst) { + if (inst.hasOwnProperty('_rootNodeID')) { + return inst._rootNodeID; + } else { + return getIDOfComposite(inst); + } +} + var ReactDefaultPerf = { _allMeasurements: [], // last item in the list is the current one _mountStack: [0], @@ -224,8 +232,10 @@ var ReactDefaultPerf = { } else if (fnName === 'replaceNodeWithMarkup') { // Old node is already unmounted; can't get its instance id = ReactDOMComponentTree.getInstanceFromNode(args[1].node)._rootNodeID; + } else if (fnName === 'replaceDelimitedText') { + id = getID(ReactDOMComponentTree.getInstanceFromNode(args[1])); } else if (typeof id === 'object') { - id = ReactDOMComponentTree.getInstanceFromNode(args[0])._rootNodeID; + id = getID(ReactDOMComponentTree.getInstanceFromNode(args[0])); } ReactDefaultPerf._recordWrite( id, @@ -291,7 +301,7 @@ var ReactDefaultPerf = { fnName === 'receiveComponent')) { rv = func.apply(this, args); - entry.hierarchy[this._rootNodeID] = + entry.hierarchy[getID(this)] = ReactDefaultPerf._compositeStack.slice(); return rv; } else { diff --git a/src/test/ReactDefaultPerfAnalysis.js b/src/test/ReactDefaultPerfAnalysis.js index ab707e298a756..80452c4ddfe37 100644 --- a/src/test/ReactDefaultPerfAnalysis.js +++ b/src/test/ReactDefaultPerfAnalysis.js @@ -27,6 +27,7 @@ var DOM_OPERATION_TYPES = { 'deleteValueForProperty': 'remove attribute', 'setValueForStyles': 'update styles', 'replaceNodeWithMarkup': 'replace', + 'replaceDelimitedText': 'replace', 'updateTextContent': 'set textContent', };