mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
Validate node nesting, take 3
Nicer version of #644 and #735. Fixes #101. Context is neat.
This commit is contained in:
@@ -84,6 +84,7 @@ var ReactDOM = mapObject({
|
||||
h6: 'h6',
|
||||
head: 'head',
|
||||
header: 'header',
|
||||
hgroup: 'hgroup',
|
||||
hr: 'hr',
|
||||
html: 'html',
|
||||
i: 'i',
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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`.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user