diff --git a/src/browser/ReactComponentBrowserEnvironment.js b/src/browser/ReactComponentBrowserEnvironment.js index af8e97999dd2d..3eb169ac8a140 100644 --- a/src/browser/ReactComponentBrowserEnvironment.js +++ b/src/browser/ReactComponentBrowserEnvironment.js @@ -29,6 +29,11 @@ var ReactReconcileTransaction = require('ReactReconcileTransaction'); var getReactRootElementInContainer = require('getReactRootElementInContainer'); var invariant = require('invariant'); +if (__DEV__) { + var getNodeNameFromReactMarkup = require('getNodeNameFromReactMarkup'); + var validateNodeNesting = require('validateNodeNesting'); +} + var ELEMENT_NODE_TYPE = 1; var DOC_NODE_TYPE = 9; @@ -133,6 +138,11 @@ var ReactComponentBrowserEnvironment = { 'See renderComponentToString() for server rendering.' ); + if (__DEV__) { + var nodeName = getNodeNameFromReactMarkup(markup); + validateNodeNesting(container.nodeName, nodeName); + } + // Asynchronously inject markup by ensuring that the container is not in // the document when settings its `innerHTML`. var parent = container.parentNode; diff --git a/src/browser/ReactDOM.js b/src/browser/ReactDOM.js index b7eeecf004dea..a337f5f745b25 100644 --- a/src/browser/ReactDOM.js +++ b/src/browser/ReactDOM.js @@ -72,12 +72,12 @@ var ReactDOM = objMapKeyVal({ a: false, abbr: false, address: false, - area: false, + area: true, article: false, aside: false, audio: false, b: false, - base: false, + base: true, bdi: false, bdo: false, big: false, @@ -127,7 +127,7 @@ var ReactDOM = objMapKeyVal({ label: false, legend: false, li: false, - link: false, + link: true, main: false, map: false, mark: false, @@ -156,7 +156,7 @@ var ReactDOM = objMapKeyVal({ section: false, select: false, small: false, - source: false, + source: true, span: false, strong: false, style: false, @@ -178,7 +178,7 @@ var ReactDOM = objMapKeyVal({ ul: false, 'var': false, video: false, - wbr: false, + wbr: true, // SVG circle: false, diff --git a/src/browser/ReactDOMComponent.js b/src/browser/ReactDOMComponent.js index 8ef1fb884e689..a8c94c100ef52 100644 --- a/src/browser/ReactDOMComponent.js +++ b/src/browser/ReactDOMComponent.js @@ -34,6 +34,11 @@ var keyOf = require('keyOf'); var merge = require('merge'); var mixInto = require('mixInto'); +if (__DEV__) { + var getNodeNameFromReactMarkup = require('getNodeNameFromReactMarkup'); + var validateNodeNesting = require('validateNodeNesting'); +} + var deleteListener = ReactEventEmitter.deleteListener; var listenTo = ReactEventEmitter.listenTo; var registrationNameModules = ReactEventEmitter.registrationNameModules; @@ -114,9 +119,20 @@ ReactDOMComponent.Mixin = { mountDepth ); assertValidProps(this.props); + var contentMarkup = this._createContentMarkup(transaction); + if (__DEV__) { + if (contentMarkup) { + var tagName = this.tagName; + for (var i = 0, l = contentMarkup.length; i < l; i++) { + var markup = contentMarkup[i]; + var nodeName = getNodeNameFromReactMarkup(markup); + validateNodeNesting(tagName, nodeName); + } + } + } return ( this._createOpenTagMarkupAndPutListeners(transaction) + - this._createContentMarkup(transaction) + + (contentMarkup ? contentMarkup.join('') : '') + this._tagClose ); } @@ -172,30 +188,30 @@ ReactDOMComponent.Mixin = { * * @private * @param {ReactReconcileTransaction} transaction - * @return {string} Content markup. + * @return {array} Content markup or list of content markup. */ _createContentMarkup: function(transaction) { // Intentional use of != to avoid catching zero/false. var innerHTML = this.props.dangerouslySetInnerHTML; if (innerHTML != null) { if (innerHTML.__html != null) { - return innerHTML.__html; + return [innerHTML.__html]; } } else { var contentToUse = CONTENT_TYPES[typeof this.props.children] ? this.props.children : null; var childrenToUse = contentToUse != null ? null : this.props.children; if (contentToUse != null) { - return escapeTextForBrowser(contentToUse); + return [escapeTextForBrowser(contentToUse)]; } else if (childrenToUse != null) { var mountImages = this.mountChildren( childrenToUse, transaction ); - return mountImages.join(''); + return mountImages; } } - return ''; + return null; }, receiveComponent: function(nextComponent, transaction) { diff --git a/src/browser/dom/DOMChildrenOperations.js b/src/browser/dom/DOMChildrenOperations.js index 9e48b1ae9c36a..ce7179abc1c38 100644 --- a/src/browser/dom/DOMChildrenOperations.js +++ b/src/browser/dom/DOMChildrenOperations.js @@ -24,6 +24,10 @@ var ReactMultiChildUpdateTypes = require('ReactMultiChildUpdateTypes'); var getTextContentAccessor = require('getTextContentAccessor'); +if (__DEV__) { + var validateNodeNesting = require('validateNodeNesting'); +} + /** * The DOM property to use when setting text content. * @@ -41,6 +45,9 @@ var textContentAccessor = getTextContentAccessor(); * @internal */ function insertChildAt(parentNode, childNode, index) { + if (__DEV__) { + validateNodeNesting(parentNode.nodeName, childNode.nodeName); + } var childNodes = parentNode.childNodes; if (childNodes[index] === childNode) { return; @@ -121,6 +128,11 @@ var DOMChildrenOperations = { ); break; case ReactMultiChildUpdateTypes.TEXT_CONTENT: + if (__DEV__) { + if (update.textContent !== '') { + validateNodeNesting(update.parentNode.nodeName, '#text'); + } + } update.parentNode[textContentAccessor] = update.textContent; break; case ReactMultiChildUpdateTypes.REMOVE_NODE: diff --git a/src/browser/dom/Danger.js b/src/browser/dom/Danger.js index ccefcde8302bb..676a86c4092c0 100644 --- a/src/browser/dom/Danger.js +++ b/src/browser/dom/Danger.js @@ -26,26 +26,16 @@ var ExecutionEnvironment = require('ExecutionEnvironment'); var createNodesFromMarkup = require('createNodesFromMarkup'); var emptyFunction = require('emptyFunction'); var getMarkupWrap = require('getMarkupWrap'); +var getNodeNameFromReactMarkup = require('getNodeNameFromReactMarkup'); var invariant = require('invariant'); +if (__DEV__) { + var validateNodeNesting = require('validateNodeNesting'); +} + var OPEN_TAG_NAME_EXP = /^(<[^ \/>]+)/; var RESULT_INDEX_ATTR = 'data-danger-index'; -/** - * Extracts the `nodeName` from a string of markup. - * - * 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). - * - * @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(' ')); -} - var Danger = { /** @@ -72,7 +62,7 @@ var Danger = { markupList[i], 'dangerouslyRenderMarkup(...): Missing markup.' ); - nodeName = getNodeName(markupList[i]); + nodeName = getNodeNameFromReactMarkup(markupList[i]); nodeName = getMarkupWrap(nodeName) ? nodeName : '*'; markupByNodeName[nodeName] = markupByNodeName[nodeName] || []; markupByNodeName[nodeName][i] = markupList[i]; @@ -179,6 +169,9 @@ var Danger = { ); var newChild = createNodesFromMarkup(markup, emptyFunction)[0]; + if (__DEV__) { + validateNodeNesting(oldChild.parentNode.nodeName, newChild.nodeName); + } oldChild.parentNode.replaceChild(newChild, oldChild); } diff --git a/src/core/__tests__/ReactCompositeComponentDOMMinimalism-test.js b/src/core/__tests__/ReactCompositeComponentDOMMinimalism-test.js index 4e956f13f5a88..99bd7b2ec0926 100644 --- a/src/core/__tests__/ReactCompositeComponentDOMMinimalism-test.js +++ b/src/core/__tests__/ReactCompositeComponentDOMMinimalism-test.js @@ -95,9 +95,9 @@ describe('ReactCompositeComponentDOMMinimalism', function() { it('should not render extra nodes for non-interpolated text', function() { var instance = ( - + + This text causes no children in span, just innerHTML + ); ReactTestUtils.renderIntoDocument(instance); @@ -108,7 +108,7 @@ describe('ReactCompositeComponentDOMMinimalism', function() { .toBeDOMComponentWithTag('div') .toBeDOMComponentWithChildCount(1) .expectRenderedChildAt(0) - .toBeDOMComponentWithTag('ul') + .toBeDOMComponentWithTag('span') .toBeDOMComponentWithNoChildren(); }); diff --git a/src/core/__tests__/ReactDOMComponent-test.js b/src/core/__tests__/ReactDOMComponent-test.js index 68aa1d0fbada5..a220f8377b741 100644 --- a/src/core/__tests__/ReactDOMComponent-test.js +++ b/src/core/__tests__/ReactDOMComponent-test.js @@ -204,6 +204,19 @@ describe('ReactDOMComponent', function() { stub.receiveComponent({props: {}}, transaction); expect(nodeValueSetter.mock.calls.length).toBe(1); }); + + it("should warn on invalid markup nesting", function() { + spyOn(console, 'warn'); + expect(console.warn.argsForCall.length).toBe(0); + var stub = ReactTestUtils.renderIntoDocument( +
+ ); + + expect(console.warn.argsForCall.length).toBe(1); + expect(console.warn.argsForCall[0][0]).toBe( + 'validateNodeNesting(...):
cannot contain a node.' + ); + }); }); describe('createOpenTagMarkup', function() { @@ -260,10 +273,11 @@ describe('ReactDOMComponent', function() { beforeEach(function() { require('mock-modules').dumpCache(); - var mixInto = require('mixInto'); var ReactDOMComponent = require('ReactDOMComponent'); var ReactReconcileTransaction = require('ReactReconcileTransaction'); + var mixInto = require('mixInto'); + var NodeStub = function(initialProps) { this.props = initialProps || {}; this._rootNodeID = 'test'; @@ -272,11 +286,12 @@ describe('ReactDOMComponent', function() { genMarkup = function(props) { var transaction = new ReactReconcileTransaction(); - return (new NodeStub(props))._createContentMarkup(transaction); + var markup = (new NodeStub(props))._createContentMarkup(transaction); + return markup.join(''); }; this.addMatchers({ - toHaveInnerhtml: function(html) { + toHaveInnerHTML: function(html) { var expected = '^' + quoteRegexp(html) + '$'; return this.actual.match(new RegExp(expected)); } @@ -287,7 +302,7 @@ describe('ReactDOMComponent', function() { var innerHTML = {__html: 'testContent'}; expect( genMarkup({ dangerouslySetInnerHTML: innerHTML }) - ).toHaveInnerhtml('testContent'); + ).toHaveInnerHTML('testContent'); }); }); diff --git a/src/core/__tests__/refs-test.js b/src/core/__tests__/refs-test.js index fd9ff2eb12853..78fb590a40e12 100644 --- a/src/core/__tests__/refs-test.js +++ b/src/core/__tests__/refs-test.js @@ -44,7 +44,7 @@ var ClickCounter = React.createClass({ var i; for (i=0; i < this.state.count; i++) { children.push( -
+ var flow = [ + 'a', 'abbr', 'address', 'area', 'article', 'aside', 'audio', 'b', 'bdi', + 'bdo', 'blockquote', 'br', 'button', 'canvas', 'cite', 'code', 'data', + 'datalist', 'del', 'details', 'dfn', 'div', 'dl', 'em', 'embed', + 'fieldset', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'header', 'hr', 'i', 'iframe', 'img', 'input', 'ins', 'kbd', 'keygen', + 'label', 'link', 'main', 'map', 'mark', 'menu', 'meta', 'meter', 'nav', + 'noscript', 'object', 'ol', 'output', 'p', 'pre', 'progress', 'q', 'ruby', + 's', 'samp', 'script', 'section', 'select', 'small', 'span', 'strong', + 'style', 'sub', 'sup', 'svg', 'table', 'textarea', 'time', 'u', 'ul', + 'var', 'video', 'wbr', '#text' + ]; + + // Phrasing elements are inline elements that can appear in a + var phrase = [ + 'a', 'abbr', 'area', 'audio', 'b', 'bdi', 'bdo', 'br', 'button', 'canvas', + 'cite', 'code', 'data', 'datalist', 'del', 'dfn', 'em', 'embed', 'i', + 'iframe', 'img', 'input', 'ins', 'kbd', 'keygen', 'label', 'link', 'map', + 'mark', 'meta', 'meter', 'noscript', 'object', 'output', 'progress', 'q', + 'ruby', 's', 'samp', 'script', 'select', 'small', 'span', 'strong', 'sub', + 'sup', 'svg', 'textarea', 'time', 'u', 'var', 'video', 'wbr', '#text' + ]; + + // Metadata elements can appear in + var metadata = [ + 'base', 'link', 'meta', 'noscript', 'script', 'style', 'title' + ]; + + // By default, we assume that flow elements can contain other flow elements + // and phrasing elements can contain other phrasing elements. Here are the + // exceptions: + var allowedChildren = { + 'a': flow, + 'audio': ['source', 'track'].concat(flow), + 'body': flow, + 'button': phrase, + 'caption': flow, + 'canvas': flow, + 'colgroup': ['col'], + 'dd': flow, + 'del': flow, + 'details': ['summary'].concat(flow), + 'dl': ['dt', 'dd'], + 'dt': flow, + 'fieldset': flow, + 'figcaption': flow, + 'figure': ['figcaption'].concat(flow), + 'h1': phrase, + 'h2': phrase, + 'h3': phrase, + 'h4': phrase, + 'h5': phrase, + 'h6': phrase, + 'head': metadata, + 'hgroup': ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'], + 'html': ['body', 'head'], + 'iframe': [], + 'ins': flow, + 'label': phrase, + 'legend': phrase, + 'li': flow, + 'map': flow, + 'noscript': ['*'], + 'object': ['param'].concat(flow), + 'ol': ['li'], + 'optgroup': ['option'], + 'p': phrase, + 'pre': phrase, + 'rp': phrase, + 'rt': phrase, + 'ruby': ['rp', 'rt', '#text'], + 'script': ['#text'], + 'select': ['option', 'optgroup'], + 'style': ['#text'], + 'summary': phrase, + 'table': ['caption', 'colgroup', 'tbody', 'tfoot', 'thead'], + 'tbody': ['tr'], + 'td': flow, + 'textarea': ['#text'], + 'tfoot': ['tr'], + 'th': flow, + 'thead': ['tr'], + 'title': ['#text'], + 'tr': ['td', 'th'], + 'ul': ['li'], + 'video': ['source', 'track'].concat(flow), + + // SVG + // TODO: Validate nesting of all svg elements + 'svg': [ + 'circle', 'defs', 'g', 'line', 'linearGradient', 'path', 'polygon', + 'polyline', 'radialGradient', 'rect', 'stop', 'text' + ], + + // Self-closing tags + 'area': [], + 'base': [], + 'br': [], + 'col': [], + 'embed': [], + 'hr': [], + 'img': [], + 'input': [], + 'keygen': [], + 'link': [], + 'menuitem': [], + 'meta': [], + 'param': [], + 'source': [], + 'track': [], + 'wbr': [] + }; + + var allowedChildrenMap = {}; + for (var el in allowedChildren) { + if (allowedChildren.hasOwnProperty(el)) { + allowedChildrenMap[el] = createObjectFrom(allowedChildren[el]); + } + } + + // Fall back to phrasing first because all phrasing elements are flow + // elements as well + var phraseMap = createObjectFrom(phrase); + for (var i = 0, l = phrase.length; i < l; i++) { + if (!allowedChildrenMap.hasOwnProperty(phrase[i])) { + allowedChildrenMap[phrase[i]] = phraseMap; + } + } + + var flowMap = createObjectFrom(flow); + for (var i = 0, l = flow.length; i < l; i++) { + if (!allowedChildrenMap.hasOwnProperty(flow[i])) { + allowedChildrenMap[flow[i]] = flowMap; + } + } + + var nodeCanContainNode = function(parentNodeName, childNodeName) { + var allowed = allowedChildrenMap[parentNodeName]; + if (allowed == null) { + return true; + } + + + var result = allowed[childNodeName] || allowed['*']; + return !!result; + }; + + validateNodeNesting = function(parentNodeName, childNodeName) { + if (__DEV__) { + parentNodeName = parentNodeName.toLowerCase(); + childNodeName = childNodeName.toLowerCase(); + if (!nodeCanContainNode(parentNodeName, childNodeName)) { + var message = + 'validateNodeNesting(...): <' + parentNodeName + '> cannot ' + + 'contain a <' + childNodeName + '> node.'; + if (parentNodeName === 'table' && childNodeName === 'tr') { + message += + ' Add a to your code to match the DOM tree generated by ' + + 'the browser.'; + } + console.warn(message); + } + } + }; +} + +module.exports = validateNodeNesting;