diff --git a/src/browser/__tests__/validateDOMNesting-test.js b/src/browser/__tests__/validateDOMNesting-test.js new file mode 100644 index 0000000000000..4aaafeb243bbf --- /dev/null +++ b/src/browser/__tests__/validateDOMNesting-test.js @@ -0,0 +1,79 @@ +/** + * 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. + * + * @emails react-core + */ + +'use strict'; + +var isTagValidInContext; + +// https://html.spec.whatwg.org/multipage/syntax.html#special +var specialTags = [ + 'address', 'applet', 'area', 'article', 'aside', 'base', 'basefont', + 'bgsound', 'blockquote', 'body', 'br', 'button', 'caption', 'center', 'col', + 'colgroup', 'dd', 'details', 'dir', 'div', 'dl', 'dt', 'embed', 'fieldset', + 'figcaption', 'figure', 'footer', 'form', 'frame', 'frameset', 'h1', 'h2', + 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hgroup', 'hr', 'html', 'iframe', + 'img', 'input', 'isindex', 'li', 'link', 'listing', 'main', 'marquee', 'menu', + 'menuitem', 'meta', 'nav', 'noembed', 'noframes', 'noscript', 'object', 'ol', + 'p', 'param', 'plaintext', 'pre', 'script', 'section', 'select', 'source', + 'style', 'summary', 'table', 'tbody', 'td', 'template', 'textarea', 'tfoot', + 'th', 'thead', 'title', 'tr', 'track', 'ul', 'wbr', 'xmp' +]; + +// https://html.spec.whatwg.org/multipage/syntax.html#formatting +var formattingTags = [ + 'a', 'b', 'big', 'code', 'em', 'font', 'i', 'nobr', 's', 'small', 'strike', + 'strong', 'tt', 'u' +]; + +function isTagStackValid(stack) { + for (var i = 0; i < stack.length; i++) { + if (!isTagValidInContext(stack[i], stack.slice(0, i))) { + //console.log('invalid', stack[i], stack.slice(0, i)); + return false; + } + } + return true; +} + +describe('ReactContextValidator', function() { + beforeEach(function() { + require('mock-modules').dumpCache(); + + isTagValidInContext = require('validateDOMNesting').isTagValidInContext; + }); + + it('allows any tag with no context', function() { + // With renderToString (for example), we don't know where we're mounting the + // tag so we must err on the side of leniency. + specialTags.concat(formattingTags, ['mysterytag']).forEach(function(tag) { + expect(isTagValidInContext(tag, [])).toBe(true); + }); + }); + + it('allows valid nestings', function() { + expect(isTagStackValid(['table', 'tbody', 'tr', 'td', 'b'])).toBe(true); + expect(isTagStackValid(['body', 'datalist', 'option'])).toBe(true); + expect(isTagStackValid(['div', 'a', 'object', 'a'])).toBe(true); + expect(isTagStackValid(['div', 'p', 'button', 'p'])).toBe(true); + expect(isTagStackValid(['p', 'svg', 'foreignObject', 'p'])).toBe(true); + + // Invalid, but not changed by browser parsing so we allow them + expect(isTagStackValid(['div', 'ul', 'ul', 'li'])).toBe(true); + expect(isTagStackValid(['div', 'label', 'div'])).toBe(true); + }); + + it('prevents problematic nestings', function() { + expect(isTagStackValid(['a', 'a'])).toBe(false); + expect(isTagStackValid(['form', 'form'])).toBe(false); + expect(isTagStackValid(['p', 'p'])).toBe(false); + expect(isTagStackValid(['table', 'tr'])).toBe(false); + }); +}); diff --git a/src/browser/ui/ReactDOMComponent.js b/src/browser/ui/ReactDOMComponent.js index 4e45365732829..fc3a1e447b7e9 100644 --- a/src/browser/ui/ReactDOMComponent.js +++ b/src/browser/ui/ReactDOMComponent.js @@ -177,7 +177,8 @@ function processChildContext(context, tagName) { if (__DEV__) { // Pass down our tag name to child components for validation purposes context = assign({}, context); - context[validateDOMNesting.parentTagContextKey] = tagName; + var stack = context[validateDOMNesting.tagStackContextKey] || []; + context[validateDOMNesting.tagStackContextKey] = stack.concat([tagName]); } return context; } @@ -227,9 +228,9 @@ ReactDOMComponent.Mixin = { assertValidProps(this, this._currentElement.props); if (__DEV__) { - if (context[validateDOMNesting.parentTagContextKey]) { + if (context[validateDOMNesting.tagStackContextKey]) { validateDOMNesting( - context[validateDOMNesting.parentTagContextKey], + context[validateDOMNesting.tagStackContextKey], this._tag, this._currentElement ); @@ -318,6 +319,7 @@ ReactDOMComponent.Mixin = { CONTENT_TYPES[typeof props.children] ? props.children : null; var childrenToUse = contentToUse != null ? null : props.children; if (contentToUse != null) { + // TODO: Validate that text is allowed as a child of this node ret = escapeTextContentForBrowser(contentToUse); } else if (childrenToUse != null) { var mountImages = this.mountChildren( diff --git a/src/browser/ui/ReactDOMTextComponent.js b/src/browser/ui/ReactDOMTextComponent.js index af6369df97ab8..7dbcb524b9976 100644 --- a/src/browser/ui/ReactDOMTextComponent.js +++ b/src/browser/ui/ReactDOMTextComponent.js @@ -67,9 +67,9 @@ assign(ReactDOMTextComponent.prototype, { */ mountComponent: function(rootID, transaction, context) { if (__DEV__) { - if (context[validateDOMNesting.parentTagContextKey]) { + if (context[validateDOMNesting.tagStackContextKey]) { validateDOMNesting( - context[validateDOMNesting.parentTagContextKey], + context[validateDOMNesting.tagStackContextKey], 'span', null ); diff --git a/src/browser/ui/ReactMount.js b/src/browser/ui/ReactMount.js index e3240c1941969..9e64fd86d0486 100644 --- a/src/browser/ui/ReactMount.js +++ b/src/browser/ui/ReactMount.js @@ -266,8 +266,8 @@ function mountComponentIntoNode( var context = emptyObject; if (__DEV__) { context = {}; - context[validateDOMNesting.parentTagContextKey] = - container.nodeName.toLowerCase(); + context[validateDOMNesting.tagStackContextKey] = + [container.nodeName.toLowerCase()]; } var markup = ReactReconciler.mountComponent( componentInstance, rootID, transaction, context diff --git a/src/browser/validateDOMNesting.js b/src/browser/validateDOMNesting.js index 831923ac7b936..e759b3eba60b8 100644 --- a/src/browser/validateDOMNesting.js +++ b/src/browser/validateDOMNesting.js @@ -17,154 +17,267 @@ 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' - ]; + // This validation code was written based on the HTML5 parsing spec: + // https://html.spec.whatwg.org/multipage/syntax.html#has-an-element-in-scope + // + // Note: this does not catch all invalid nesting, nor does it try to (as it's + // not clear what practical benefit doing so provides); instead, we warn only + // for cases where the parser will give a parse tree differing from what React + // intended. For example,
is invalid but we don't warn + // because it still parses correctly; we do warn for other cases like nested + //

tags where the beginning of the second element implicitly closes the + // first, causing a confusing mess. - // 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' + // https://html.spec.whatwg.org/multipage/syntax.html#special + var specialTags = [ + 'address', 'applet', 'area', 'article', 'aside', 'base', 'basefont', + 'bgsound', 'blockquote', 'body', 'br', 'button', 'caption', 'center', 'col', + 'colgroup', 'dd', 'details', 'dir', 'div', 'dl', 'dt', 'embed', 'fieldset', + 'figcaption', 'figure', 'footer', 'form', 'frame', 'frameset', 'h1', 'h2', + 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hgroup', 'hr', 'html', 'iframe', + 'img', 'input', 'isindex', 'li', 'link', 'listing', 'main', 'marquee', + 'menu', 'menuitem', 'meta', 'nav', 'noembed', 'noframes', 'noscript', + 'object', 'ol', 'p', 'param', 'plaintext', 'pre', 'script', 'section', + 'select', 'source', 'style', 'summary', 'table', 'tbody', 'td', 'template', + 'textarea', 'tfoot', 'th', 'thead', 'title', 'tr', 'track', 'ul', 'wbr', + 'xmp' ]; - // Metadata elements can appear in - var metadata = [ - 'base', 'link', 'meta', 'noscript', 'script', 'style', 'title' + /** + * Return whether `stack` contains `tag` and the last occurrence of `tag` is + * deeper than any element in the `scope` array. + * + * https://html.spec.whatwg.org/multipage/syntax.html#has-an-element-in-the-specific-scope + * + * Examples: + * stackHasTagInSpecificScope(['p', 'quote'], 'p', ['button']) is true + * stackHasTagInSpecificScope(['p', 'button'], 'p', ['button']) is false + * + * @param {Array} stack + * @param {string} tag + * @param {Array} scope + */ + var stackHasTagInSpecificScope = function(stack, tag, scope) { + for (var i = stack.length - 1; i >= 0; i--) { + if (stack[i] === tag) { + return true; + } + if (scope.indexOf(stack[i]) !== -1) { + return false; + } + } + return false; + }; + + // https://html.spec.whatwg.org/multipage/syntax.html#has-an-element-in-scope + var inScopeTags = [ + 'applet', 'caption', 'html', 'table', 'td', 'th', 'marquee', 'object', + 'template', + + // https://html.spec.whatwg.org/multipage/syntax.html#html-integration-point + // TODO: Distinguish by namespace here + 'foreignObject', 'desc', 'title' ]; + var stackHasTagInScope = function(stack, tag) { + return stackHasTagInSpecificScope(stack, tag, inScopeTags); + }; - // 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': [] + // https://html.spec.whatwg.org/multipage/syntax.html#has-an-element-in-button-scope + var buttonScopeTags = inScopeTags.concat(['button']); + var stackHasTagInButtonScope = function(stack, tag) { + return stackHasTagInSpecificScope(stack, tag, buttonScopeTags); }; - 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]; + // See rules for 'li', 'dd', 'dt' start tags in + // https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-inbody + var listItemTagAllowed = function(tags, stack) { + // tags is ['li'] or ['dd, 'dt'] + for (var i = stack.length - 1; i >= 0; i--) { + if (tags.indexOf(stack[i]) !== -1) { + return false; + } else if ( + specialTags.indexOf(stack[i]) !== -1 && + stack[i] !== 'address' && stack[i] !== 'div' && stack[i] !== 'p' + ) { + return true; + } + } + return true; + }; + + // https://html.spec.whatwg.org/multipage/syntax.html#generate-implied-end-tags + var impliedEndTags = + ['dd', 'dt', 'li', 'option', 'optgroup', 'p', 'rp', 'rt']; + + /** + * Returns whether we allow putting `tag` in the document if the current stack + * of open tags is `openTagStack`. + * + * Examples: + * isTagValidInContext('tr', [..., 'table', 'tbody']) is true + * isTagValidInContext('tr', [..., 'table']) is false + * + * @param {string} tag Lowercase HTML tag name or node name like '#text' + * @param {Array} openTagStack + */ + var isTagValidInContext = function(tag, openTagStack) { + var currentTag = openTagStack[openTagStack.length - 1]; + + // First, let's check if we're in an unusual parsing mode... + switch (currentTag) { + // https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-inselect + case 'select': + return tag === 'option' || tag === 'optgroup' || tag === '#text'; + case 'optgroup': + return tag === 'option' || tag === '#text'; + // Strictly speaking, seeing an