diff --git a/src/browser/ReactDOM.js b/src/browser/ReactDOM.js index 0f6c808f0fee0..c8747bdf2fd18 100644 --- a/src/browser/ReactDOM.js +++ b/src/browser/ReactDOM.js @@ -84,6 +84,7 @@ var ReactDOM = mapObject({ h6: 'h6', head: 'head', header: 'header', + hgroup: 'hgroup', hr: 'hr', html: 'html', i: 'i', diff --git a/src/browser/ui/ReactDOMComponent.js b/src/browser/ui/ReactDOMComponent.js index 4df78e8270108..4645301a5fe53 100644 --- a/src/browser/ui/ReactDOMComponent.js +++ b/src/browser/ui/ReactDOMComponent.js @@ -29,6 +29,7 @@ var escapeTextContentForBrowser = require('escapeTextContentForBrowser'); var invariant = require('invariant'); var isEventSupported = require('isEventSupported'); var keyOf = require('keyOf'); +var validateDOMNesting = require('validateDOMNesting'); var warning = require('warning'); var deleteListener = ReactBrowserEventEmitter.deleteListener; @@ -172,6 +173,15 @@ function validateDangerousTag(tag) { } } +function processChildContext(context, tagName) { + if (__DEV__) { + // Pass down our tag name to child components for validation purposes + context = assign({}, context); + context[validateDOMNesting.parentTagContextKey] = tagName; + } + return context; +} + /** * Creates a new React class that is idempotent and capable of containing other * React components. It accepts event listeners and DOM properties that are @@ -213,7 +223,18 @@ ReactDOMComponent.Mixin = { */ mountComponent: function(rootID, transaction, context) { this._rootNodeID = rootID; + assertValidProps(this, this._currentElement.props); + if (__DEV__) { + if (context[validateDOMNesting.parentTagContextKey]) { + validateDOMNesting( + context[validateDOMNesting.parentTagContextKey], + this._tag, + this._currentElement + ); + } + } + var tagOpen = this._createOpenTagMarkupAndPutListeners(transaction); var tagContent = this._createContentMarkup(transaction, context); if (!tagContent && omittedCloseTags[this._tag]) { @@ -301,7 +322,7 @@ ReactDOMComponent.Mixin = { var mountImages = this.mountChildren( childrenToUse, transaction, - context + processChildContext(context, this._tag) ); ret = mountImages.join(''); } @@ -342,7 +363,11 @@ ReactDOMComponent.Mixin = { updateComponent: function(transaction, prevElement, nextElement, context) { assertValidProps(this, this._currentElement.props); this._updateDOMProperties(prevElement.props, transaction); - this._updateDOMChildren(prevElement.props, transaction, context); + this._updateDOMChildren( + prevElement.props, + transaction, + processChildContext(context, this._tag) + ); }, /** diff --git a/src/browser/ui/ReactDOMTextComponent.js b/src/browser/ui/ReactDOMTextComponent.js index d561646a12d2f..af6369df97ab8 100644 --- a/src/browser/ui/ReactDOMTextComponent.js +++ b/src/browser/ui/ReactDOMTextComponent.js @@ -19,6 +19,7 @@ var ReactDOMComponent = require('ReactDOMComponent'); var assign = require('Object.assign'); var escapeTextContentForBrowser = require('escapeTextContentForBrowser'); +var validateDOMNesting = require('validateDOMNesting'); /** * Text nodes violate a couple assumptions that React makes about components: @@ -65,6 +66,16 @@ assign(ReactDOMTextComponent.prototype, { * @internal */ mountComponent: function(rootID, transaction, context) { + if (__DEV__) { + if (context[validateDOMNesting.parentTagContextKey]) { + validateDOMNesting( + context[validateDOMNesting.parentTagContextKey], + 'span', + null + ); + } + } + this._rootNodeID = rootID; var escapedText = escapeTextContentForBrowser(this._stringText); diff --git a/src/browser/ui/ReactDefaultInjection.js b/src/browser/ui/ReactDefaultInjection.js index 771be23117b75..9551c10f25db2 100644 --- a/src/browser/ui/ReactDefaultInjection.js +++ b/src/browser/ui/ReactDefaultInjection.js @@ -38,6 +38,7 @@ var ReactElement = require('ReactElement'); var ReactEventListener = require('ReactEventListener'); var ReactInjection = require('ReactInjection'); var ReactInstanceHandles = require('ReactInstanceHandles'); +var ReactInstanceMap = require('ReactInstanceMap'); var ReactMount = require('ReactMount'); var ReactReconcileTransaction = require('ReactReconcileTransaction'); var SelectEventPlugin = require('SelectEventPlugin'); @@ -51,12 +52,14 @@ function autoGenerateWrapperClass(type) { return ReactClass.createClass({ tagName: type.toUpperCase(), render: function() { + // Copy owner down for debugging info + var internalInstance = ReactInstanceMap.get(this); return new ReactElement( type, - null, - null, - null, - null, + null, // key + null, // ref + internalInstance._currentElement._owner, // owner + null, // context this.props ); } diff --git a/src/browser/ui/ReactMount.js b/src/browser/ui/ReactMount.js index c4fe6280fe1d8..87b025096d516 100644 --- a/src/browser/ui/ReactMount.js +++ b/src/browser/ui/ReactMount.js @@ -32,6 +32,7 @@ var instantiateReactComponent = require('instantiateReactComponent'); var invariant = require('invariant'); var setInnerHTML = require('setInnerHTML'); var shouldUpdateReactComponent = require('shouldUpdateReactComponent'); +var validateDOMNesting = require('validateDOMNesting'); var warning = require('warning'); var SEPARATOR = ReactInstanceHandles.SEPARATOR; @@ -246,8 +247,14 @@ function mountComponentIntoNode( container, transaction, shouldReuseMarkup) { + var context = emptyObject; + if (__DEV__) { + context = {}; + context[validateDOMNesting.parentTagContextKey] = + container.nodeName.toLowerCase(); + } var markup = ReactReconciler.mountComponent( - componentInstance, rootID, transaction, emptyObject + componentInstance, rootID, transaction, context ); componentInstance._isTopLevel = true; ReactMount._mountImageIntoNode(markup, container, shouldReuseMarkup); diff --git a/src/browser/ui/__tests__/ReactDOMComponent-test.js b/src/browser/ui/__tests__/ReactDOMComponent-test.js index 2f99e484531da..990f9dceedc1c 100644 --- a/src/browser/ui/__tests__/ReactDOMComponent-test.js +++ b/src/browser/ui/__tests__/ReactDOMComponent-test.js @@ -373,11 +373,11 @@ describe('ReactDOMComponent', function() { var container = document.createElement('div'); - React.render(, container); + React.render(, container); expect(container.innerHTML).toContain(''); - React.render(children, container); + React.render(children, container); expect(console.warn.argsForCall.length).toBe(1); expect(console.warn.argsForCall[0][0]).toContain('void element'); @@ -651,6 +651,54 @@ describe('ReactDOMComponent', function() { 'Invariant Violation: Invalid tag: div> { + spyOn(console, 'warn'); + ReactTestUtils.renderIntoDocument(
); + + expect(console.warn.calls.length).toBe(1); + expect(console.warn.calls[0].args[0]).toBe( + 'Warning: validateDOMNesting(...):
cannot contain a node.' + ); + }); + it('warns on invalid nesting at root', () => { + spyOn(console, 'warn'); + var p = document.createElement('p'); + React.render(, p); + + expect(console.warn.calls.length).toBe(1); + expect(console.warn.calls[0].args[0]).toBe( + 'Warning: validateDOMNesting(...):

cannot contain a node.' + ); + }); + + it('warns nicely for table rows', () => { + spyOn(console, 'warn'); + var Foo = React.createClass({ + render: function() { + return
; + } + }); + ReactTestUtils.renderIntoDocument(); + + expect(console.warn.calls.length).toBe(1); + expect(console.warn.calls[0].args[0]).toBe( + 'Warning: validateDOMNesting(...): cannot contain a ' + + 'node. Add a to your code to match the DOM tree generated by ' + + 'the browser. Check the render method of `Foo`.' + ); + }); }); + }); diff --git a/src/browser/validateDOMNesting.js b/src/browser/validateDOMNesting.js new file mode 100644 index 0000000000000..831923ac7b936 --- /dev/null +++ b/src/browser/validateDOMNesting.js @@ -0,0 +1,194 @@ +/** + * Copyright 2015, 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 validateDOMNesting + */ + +'use strict'; + +var emptyFunction = require('emptyFunction'); +var warning = require('warning'); + +var validateDOMNesting = emptyFunction; + +if (__DEV__) { + // The below rules were created from the HTML5 spec and using + // https://github.com/facebook/xhp-lib/blob/1.6.0/src/html.php + + // Flow elements are block or inline elements that can appear in a
+ 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 = { + '#document': ['html'], + + '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, + 'menu': ['li', 'menuitem'].concat(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 i, l; + var allowedChildrenMap = {}; + for (i = 0, l = flow.length; i < l; i++) { + allowedChildrenMap[flow[i]] = flow; + } + for (i = 0, l = phrase.length; i < l; i++) { + allowedChildrenMap[phrase[i]] = flow; + } + for (var el in allowedChildren) { + if (allowedChildren.hasOwnProperty(el)) { + allowedChildrenMap[el] = allowedChildren[el]; + } + } + + var nodeCanContainNode = function(parentTag, childTag) { + var allowed = allowedChildrenMap[parentTag]; + if (!allowed || !allowedChildrenMap[childTag]) { + // We don't recognize one of the tags; err on the side of not warning + return true; + } + + var result = allowed === '*' || allowed.indexOf(childTag) !== -1; + return !!result; + }; + + validateDOMNesting = function(parentTag, childTag, element) { + if (!nodeCanContainNode(parentTag, childTag)) { + var info = ''; + if (parentTag === 'table' && childTag === 'tr') { + info += + ' Add a
to your code to match the DOM tree generated by ' + + 'the browser.'; + } + if (element && element._owner) { + var name = element._owner.getName(); + if (name) { + info += ` Check the render method of \`${name}\`.`; + } + } + + warning( + false, + 'validateDOMNesting(...): <%s> cannot contain a <%s> node.%s', + parentTag, + childTag, + info + ); + } + }; + + validateDOMNesting.parentTagContextKey = + '__validateDOMNesting_parentTag$' + Math.random().toString(36).slice(2); +} + +module.exports = validateDOMNesting; diff --git a/src/test/__tests__/ReactTestUtils-test.js b/src/test/__tests__/ReactTestUtils-test.js index 08789cda29e7f..e258a706b69b6 100644 --- a/src/test/__tests__/ReactTestUtils-test.js +++ b/src/test/__tests__/ReactTestUtils-test.js @@ -208,6 +208,8 @@ describe('ReactTestUtils', function() { }); it('should support injected wrapper components as DOM components', function() { + var getTestDocument = require('getTestDocument'); + var injectedDOMComponents = [ 'button', 'form', @@ -216,10 +218,7 @@ describe('ReactTestUtils', function() { 'input', 'option', 'select', - 'textarea', - 'html', - 'head', - 'body' + 'textarea' ]; injectedDOMComponents.forEach(function(type) { @@ -229,5 +228,33 @@ describe('ReactTestUtils', function() { expect(component.tagName).toBe(type.toUpperCase()); expect(ReactTestUtils.isDOMComponent(component)).toBe(true); }); + + // Full-page components (html, head, body) can't be rendered into a div + // directly... + var Root = React.createClass({ + render: function() { + return ( + + + hello + + + hello, world + + + ); + } + }); + + var markup = React.renderToString(); + var testDocument = getTestDocument(markup); + var component = React.render(, testDocument); + + expect(component.refs.html.tagName).toBe('HTML'); + expect(component.refs.head.tagName).toBe('HEAD'); + expect(component.refs.body.tagName).toBe('BODY'); + expect(ReactTestUtils.isDOMComponent(component.refs.html)).toBe(true); + expect(ReactTestUtils.isDOMComponent(component.refs.head)).toBe(true); + expect(ReactTestUtils.isDOMComponent(component.refs.body)).toBe(true); }); });