Validate node nesting, take 3

Nicer version of #644 and #735. Fixes #101. Context is neat.
This commit is contained in:
Ben Alpert
2015-03-20 16:10:02 -07:00
parent 7fe5a3aadd
commit f9abf493b4
8 changed files with 330 additions and 14 deletions
+1
View File
@@ -84,6 +84,7 @@ var ReactDOM = mapObject({
h6: 'h6',
head: 'head',
header: 'header',
hgroup: 'hgroup',
hr: 'hr',
html: 'html',
i: 'i',
+27 -2
View File
@@ -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)
);
},
/**
+11
View File
@@ -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);
+7 -4
View File
@@ -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
);
}
+8 -1
View File
@@ -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);
@@ -373,11 +373,11 @@ describe('ReactDOMComponent', function() {
var container = document.createElement('div');
React.render(<menuitem />, container);
React.render(<menu><menuitem /></menu>, container);
expect(container.innerHTML).toContain('</menuitem>');
React.render(<menuitem>children</menuitem>, container);
React.render(<menu><menuitem>children</menuitem></menu>, 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><img /><div'
);
});
});
describe('nesting validation', function() {
var React;
var ReactTestUtils;
beforeEach(function() {
React = require('React');
ReactTestUtils = require('ReactTestUtils');
});
it('warns on invalid nesting', () => {
spyOn(console, 'warn');
ReactTestUtils.renderIntoDocument(<div><tr /></div>);
expect(console.warn.calls.length).toBe(1);
expect(console.warn.calls[0].args[0]).toBe(
'Warning: validateDOMNesting(...): <div> cannot contain a <tr> node.'
);
});
it('warns on invalid nesting at root', () => {
spyOn(console, 'warn');
var p = document.createElement('p');
React.render(<tr />, p);
expect(console.warn.calls.length).toBe(1);
expect(console.warn.calls[0].args[0]).toBe(
'Warning: validateDOMNesting(...): <p> cannot contain a <tr> node.'
);
});
it('warns nicely for table rows', () => {
spyOn(console, 'warn');
var Foo = React.createClass({
render: function() {
return <table><tr /></table>;
}
});
ReactTestUtils.renderIntoDocument(<Foo />);
expect(console.warn.calls.length).toBe(1);
expect(console.warn.calls[0].args[0]).toBe(
'Warning: validateDOMNesting(...): <table> cannot contain a <tr> ' +
'node. Add a <tbody> to your code to match the DOM tree generated by ' +
'the browser. Check the render method of `Foo`.'
);
});
});
});
+194
View File
@@ -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 <div>
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 <span>
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 <head>
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 <tbody> 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;
+31 -4
View File
@@ -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 (
<html ref="html">
<head ref="head">
<title>hello</title>
</head>
<body ref="body">
hello, world
</body>
</html>
);
}
});
var markup = React.renderToString(<Root />);
var testDocument = getTestDocument(markup);
var component = React.render(<Root />, 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);
});
});