Use strings as the type for DOM elements

This makes ReactDOM a simple helper for creating ReactElements with the string tag as the type. The actual class is internal and created by instantiateReactComponent. Configurable using injection.

There's not a separate class for each tag. There's just a generic ReactDOMComponent which could take any tag name.

Invididual tags can be wrapped. When wrapping happens you can return the same tag again. If the wrapper returns the same string, then we fall back to the generic component. This avoids recursion in a single level wrapper.
This commit is contained in:
Sebastian Markbage
2014-10-02 23:05:11 -07:00
parent 35764c5ffb
commit 3aaccd2dc9
16 changed files with 328 additions and 215 deletions
+134 -159
View File
@@ -22,41 +22,23 @@
var ReactDescriptor = require('ReactDescriptor');
var ReactDescriptorValidator = require('ReactDescriptorValidator');
var ReactLegacyDescriptor = require('ReactLegacyDescriptor');
var ReactDOMComponent = require('ReactDOMComponent');
var mergeInto = require('mergeInto');
var mapObject = require('mapObject');
/**
* Creates a new React class that is idempotent and capable of containing other
* React components. It accepts event listeners and DOM properties that are
* valid according to `DOMProperty`.
* Create a factory that creates HTML tag descriptors.
*
* - Event listeners: `onClick`, `onMouseDown`, etc.
* - DOM properties: `className`, `name`, `title`, etc.
*
* The `style` property functions differently from the DOM API. It accepts an
* object mapping of style properties to values.
*
* @param {boolean} omitClose True if the close tag should be omitted.
* @param {string} tag Tag name (e.g. `div`).
* @private
*/
function createDOMComponentClass(omitClose, tag) {
var Constructor = function(props) {
// This constructor and it's argument is currently used by mocks.
};
Constructor.prototype = new ReactDOMComponent(tag, omitClose);
Constructor.prototype.constructor = Constructor;
Constructor.displayName = tag;
function createDOMFactory(tag) {
if (__DEV__) {
return ReactLegacyDescriptor.wrapFactory(
ReactDescriptorValidator.createFactory(Constructor)
ReactDescriptorValidator.createFactory(tag)
);
}
return ReactLegacyDescriptor.wrapFactory(
ReactDescriptor.createFactory(Constructor)
ReactDescriptor.createFactory(tag)
);
}
@@ -67,145 +49,138 @@ function createDOMComponentClass(omitClose, tag) {
* @public
*/
var ReactDOM = mapObject({
a: false,
abbr: false,
address: false,
area: true,
article: false,
aside: false,
audio: false,
b: false,
base: true,
bdi: false,
bdo: false,
big: false,
blockquote: false,
body: false,
br: true,
button: false,
canvas: false,
caption: false,
cite: false,
code: false,
col: true,
colgroup: false,
data: false,
datalist: false,
dd: false,
del: false,
details: false,
dfn: false,
dialog: false,
div: false,
dl: false,
dt: false,
em: false,
embed: true,
fieldset: false,
figcaption: false,
figure: false,
footer: false,
form: false, // NOTE: Injected, see `ReactDOMForm`.
h1: false,
h2: false,
h3: false,
h4: false,
h5: false,
h6: false,
head: false,
header: false,
hr: true,
html: false,
i: false,
iframe: false,
img: true,
input: true,
ins: false,
kbd: false,
keygen: true,
label: false,
legend: false,
li: false,
link: true,
main: false,
map: false,
mark: false,
menu: false,
menuitem: false, // NOTE: Close tag should be omitted, but causes problems.
meta: true,
meter: false,
nav: false,
noscript: false,
object: false,
ol: false,
optgroup: false,
option: false,
output: false,
p: false,
param: true,
picture: false,
pre: false,
progress: false,
q: false,
rp: false,
rt: false,
ruby: false,
s: false,
samp: false,
script: false,
section: false,
select: false,
small: false,
source: true,
span: false,
strong: false,
style: false,
sub: false,
summary: false,
sup: false,
table: false,
tbody: false,
td: false,
textarea: false, // NOTE: Injected, see `ReactDOMTextarea`.
tfoot: false,
th: false,
thead: false,
time: false,
title: false,
tr: false,
track: true,
u: false,
ul: false,
'var': false,
video: false,
wbr: true,
a: 'a',
abbr: 'abbr',
address: 'address',
area: 'area',
article: 'article',
aside: 'aside',
audio: 'audio',
b: 'b',
base: 'base',
bdi: 'bdi',
bdo: 'bdo',
big: 'big',
blockquote: 'blockquote',
body: 'body',
br: 'br',
button: 'button',
canvas: 'canvas',
caption: 'caption',
cite: 'cite',
code: 'code',
col: 'col',
colgroup: 'colgroup',
data: 'data',
datalist: 'datalist',
dd: 'dd',
del: 'del',
details: 'details',
dfn: 'dfn',
dialog: 'dialog',
div: 'div',
dl: 'dl',
dt: 'dt',
em: 'em',
embed: 'embed',
fieldset: 'fieldset',
figcaption: 'figcaption',
figure: 'figure',
footer: 'footer',
form: 'form',
h1: 'h1',
h2: 'h2',
h3: 'h3',
h4: 'h4',
h5: 'h5',
h6: 'h6',
head: 'head',
header: 'header',
hr: 'hr',
html: 'html',
i: 'i',
iframe: 'iframe',
img: 'img',
input: 'input',
ins: 'ins',
kbd: 'kbd',
keygen: 'keygen',
label: 'label',
legend: 'legend',
li: 'li',
link: 'link',
main: 'main',
map: 'map',
mark: 'mark',
menu: 'menu',
menuitem: 'menuitem',
meta: 'meta',
meter: 'meter',
nav: 'nav',
noscript: 'noscript',
object: 'object',
ol: 'ol',
optgroup: 'optgroup',
option: 'option',
output: 'output',
p: 'p',
param: 'param',
picture: 'picture',
pre: 'pre',
progress: 'progress',
q: 'q',
rp: 'rp',
rt: 'rt',
ruby: 'ruby',
s: 's',
samp: 'samp',
script: 'script',
section: 'section',
select: 'select',
small: 'small',
source: 'source',
span: 'span',
strong: 'strong',
style: 'style',
sub: 'sub',
summary: 'summary',
sup: 'sup',
table: 'table',
tbody: 'tbody',
td: 'td',
textarea: 'textarea',
tfoot: 'tfoot',
th: 'th',
thead: 'thead',
time: 'time',
title: 'title',
tr: 'tr',
track: 'track',
u: 'u',
ul: 'ul',
'var': 'var',
video: 'video',
wbr: 'wbr',
// SVG
circle: false,
defs: false,
ellipse: false,
g: false,
line: false,
linearGradient: false,
mask: false,
path: false,
pattern: false,
polygon: false,
polyline: false,
radialGradient: false,
rect: false,
stop: false,
svg: false,
text: false,
tspan: false
}, createDOMComponentClass);
circle: 'circle',
defs: 'defs',
ellipse: 'ellipse',
g: 'g',
line: 'line',
linearGradient: 'linearGradient',
mask: 'mask',
path: 'path',
pattern: 'pattern',
polygon: 'polygon',
polyline: 'polyline',
radialGradient: 'radialGradient',
rect: 'rect',
stop: 'stop',
svg: 'svg',
text: 'text',
tspan: 'tspan'
var injection = {
injectComponentClasses: function(componentClasses) {
mergeInto(ReactDOM, componentClasses);
}
};
ReactDOM.injection = injection;
}, createDOMFactory);
module.exports = ReactDOM;
+2 -2
View File
@@ -49,7 +49,7 @@ function renderComponentToString(component) {
transaction = ReactServerRenderingTransaction.getPooled(false);
return transaction.perform(function() {
var componentInstance = instantiateReactComponent(component);
var componentInstance = instantiateReactComponent(component, null);
var markup = componentInstance.mountComponent(id, transaction, 0);
return ReactMarkupChecksum.addChecksumToMarkup(markup);
}, null);
@@ -75,7 +75,7 @@ function renderComponentToStaticMarkup(component) {
transaction = ReactServerRenderingTransaction.getPooled(true);
return transaction.perform(function() {
var componentInstance = instantiateReactComponent(component);
var componentInstance = instantiateReactComponent(component, null);
return componentInstance.mountComponent(id, transaction, 0);
}, null);
} finally {
+39 -5
View File
@@ -101,18 +101,51 @@ function putListener(id, registrationName, listener, transaction) {
);
}
// For HTML, certain tags should omit their close tag. We keep a whitelist for
// those special cased tags.
var omittedCloseTags = {
'area': true,
'base': true,
'br': true,
'col': true,
'embed': true,
'hr': true,
'img': true,
'input': true,
'keygen': true,
'link': true,
'meta': true,
'param': true,
'source': true,
'track': true,
'wbr': true
// NOTE: menuitem's close tag should be omitted, but that causes problems.
};
/**
* Creates a new React class that is idempotent and capable of containing other
* React components. It accepts event listeners and DOM properties that are
* valid according to `DOMProperty`.
*
* - Event listeners: `onClick`, `onMouseDown`, etc.
* - DOM properties: `className`, `name`, `title`, etc.
*
* The `style` property functions differently from the DOM API. It accepts an
* object mapping of style properties to values.
*
* @constructor ReactDOMComponent
* @extends ReactComponent
* @extends ReactMultiChild
*/
function ReactDOMComponent(tag, omitClose) {
this._tagOpen = '<' + tag;
this._tagClose = omitClose ? '' : '</' + tag + '>';
function ReactDOMComponent(tag) {
// TODO: DANGEROUS this tag should be sanitized.
this._tag = tag;
this.tagName = tag.toUpperCase();
}
ReactDOMComponent.displayName = 'ReactDOMComponent';
ReactDOMComponent.Mixin = {
/**
@@ -136,10 +169,11 @@ ReactDOMComponent.Mixin = {
mountDepth
);
assertValidProps(this.props);
var closeTag = omittedCloseTags[this._tag] ? '' : '</' + this._tag + '>';
return (
this._createOpenTagMarkupAndPutListeners(transaction) +
this._createContentMarkup(transaction) +
this._tagClose
closeTag
);
}
),
@@ -158,7 +192,7 @@ ReactDOMComponent.Mixin = {
*/
_createOpenTagMarkupAndPutListeners: function(transaction) {
var props = this.props;
var ret = this._tagOpen;
var ret = '<' + this._tag;
for (var propKey in props) {
if (!props.hasOwnProperty(propKey)) {
+17 -13
View File
@@ -31,7 +31,7 @@ var ReactBrowserComponentMixin = require('ReactBrowserComponentMixin');
var ReactComponentBrowserEnvironment =
require('ReactComponentBrowserEnvironment');
var ReactDefaultBatchingStrategy = require('ReactDefaultBatchingStrategy');
var ReactDOM = require('ReactDOM');
var ReactDOMComponent = require('ReactDOMComponent');
var ReactDOMButton = require('ReactDOMButton');
var ReactDOMForm = require('ReactDOMForm');
var ReactDOMImg = require('ReactDOMImg');
@@ -76,18 +76,22 @@ function inject() {
BeforeInputEventPlugin: BeforeInputEventPlugin
});
ReactInjection.DOM.injectComponentClasses({
button: ReactDOMButton,
form: ReactDOMForm,
img: ReactDOMImg,
input: ReactDOMInput,
option: ReactDOMOption,
select: ReactDOMSelect,
textarea: ReactDOMTextarea,
ReactInjection.NativeComponent.injectGenericComponentClass(
ReactDOMComponent
);
html: createFullPageComponent(ReactDOM.html),
head: createFullPageComponent(ReactDOM.head),
body: createFullPageComponent(ReactDOM.body)
ReactInjection.NativeComponent.injectComponentClasses({
'button': ReactDOMButton,
'form': ReactDOMForm,
'img': ReactDOMImg,
'input': ReactDOMInput,
'option': ReactDOMOption,
'select': ReactDOMSelect,
'textarea': ReactDOMTextarea,
'html': createFullPageComponent('html'),
'head': createFullPageComponent('head'),
'body': createFullPageComponent('body')
});
// This needs to happen after createFullPageComponent() otherwise the mixin
@@ -97,7 +101,7 @@ function inject() {
ReactInjection.DOMProperty.injectDOMPropertyConfig(HTMLDOMPropertyConfig);
ReactInjection.DOMProperty.injectDOMPropertyConfig(SVGDOMPropertyConfig);
ReactInjection.EmptyComponent.injectEmptyComponent(ReactDOM.noscript);
ReactInjection.EmptyComponent.injectEmptyComponent('noscript');
ReactInjection.Updates.injectReconcileTransaction(
ReactComponentBrowserEnvironment.ReactReconcileTransaction
+2 -2
View File
@@ -22,9 +22,9 @@ var DOMProperty = require('DOMProperty');
var EventPluginHub = require('EventPluginHub');
var ReactComponent = require('ReactComponent');
var ReactCompositeComponent = require('ReactCompositeComponent');
var ReactDOM = require('ReactDOM');
var ReactEmptyComponent = require('ReactEmptyComponent');
var ReactBrowserEventEmitter = require('ReactBrowserEventEmitter');
var ReactNativeComponent = require('ReactNativeComponent');
var ReactPerf = require('ReactPerf');
var ReactRootIndex = require('ReactRootIndex');
var ReactUpdates = require('ReactUpdates');
@@ -35,8 +35,8 @@ var ReactInjection = {
DOMProperty: DOMProperty.injection,
EmptyComponent: ReactEmptyComponent.injection,
EventPluginHub: EventPluginHub.injection,
DOM: ReactDOM.injection,
EventEmitter: ReactBrowserEventEmitter.injection,
NativeComponent: ReactNativeComponent.injection,
Perf: ReactPerf.injection,
RootIndex: ReactRootIndex.injection,
Updates: ReactUpdates.injection
+1 -1
View File
@@ -307,7 +307,7 @@ var ReactMount = {
'componentDidUpdate.'
);
var componentInstance = instantiateReactComponent(nextComponent);
var componentInstance = instantiateReactComponent(nextComponent, null);
var reactRootID = ReactMount._registerComponent(
componentInstance,
container
@@ -33,16 +33,14 @@ var invariant = require('invariant');
* take advantage of React's reconciliation for styling and <title>
* management. So we just document it and throw in dangerous cases.
*
* @param {function} componentClass convenience constructor to wrap
* @param {string} tag The tag to wrap
* @return {function} convenience constructor of new component
*/
function createFullPageComponent(componentClass) {
var elementFactory = ReactDescriptor.createFactory(componentClass.type);
function createFullPageComponent(tag) {
var elementFactory = ReactDescriptor.createFactory(tag);
var FullPageComponent = ReactCompositeComponent.createClass({
displayName: 'ReactFullPageComponent' + (
componentClass.type.displayName || ''
),
displayName: 'ReactFullPageComponent' + tag,
componentWillUnmount: function() {
invariant(
+6 -2
View File
@@ -793,7 +793,8 @@ var ReactCompositeComponentMixin = {
}
this._renderedComponent = instantiateReactComponent(
this._renderValidatedComponent()
this._renderValidatedComponent(),
this._descriptor.type // The wrapping type
);
// Done with mounting, `setState` will now trigger UI changes.
@@ -1185,7 +1186,10 @@ var ReactCompositeComponentMixin = {
var thisID = this._rootNodeID;
var prevComponentID = prevComponentInstance._rootNodeID;
prevComponentInstance.unmountComponent();
this._renderedComponent = instantiateReactComponent(nextDescriptor);
this._renderedComponent = instantiateReactComponent(
nextDescriptor,
this._descriptor.type
);
var nextMarkup = this._renderedComponent.mountComponent(
thisID,
transaction,
+7 -4
View File
@@ -222,10 +222,13 @@ ReactDescriptor.cloneAndReplaceProps = function(oldDescriptor, newProps) {
* @public
*/
ReactDescriptor.isValidFactory = function(factory) {
return typeof factory === 'function' &&
typeof factory.type === 'function' &&
typeof factory.type.prototype.mountComponent === 'function' &&
typeof factory.type.prototype.receiveComponent === 'function';
return typeof factory === 'function' && (
typeof factory.type === 'string' || (
typeof factory.type === 'function' &&
typeof factory.type.prototype.mountComponent === 'function' &&
typeof factory.type.prototype.receiveComponent === 'function'
)
);
};
/**
+1 -1
View File
@@ -29,7 +29,7 @@ var nullComponentIdsRegistry = {};
var ReactEmptyComponentInjection = {
injectEmptyComponent: function(emptyComponent) {
component = ReactDescriptor.createFactory(emptyComponent.type);
component = ReactDescriptor.createFactory(emptyComponent);
}
};
+5 -2
View File
@@ -195,7 +195,7 @@ var ReactMultiChild = {
if (children.hasOwnProperty(name)) {
// The rendered children must be turned into instances as they're
// mounted.
var childInstance = instantiateReactComponent(child);
var childInstance = instantiateReactComponent(child, null);
children[name] = childInstance;
// Inlined for performance, see `ReactInstanceHandles.createReactID`.
var rootID = this._rootNodeID + name;
@@ -300,7 +300,10 @@ var ReactMultiChild = {
this._unmountChildByName(prevChild, name);
}
// The child must be instantiated before it's mounted.
var nextChildInstance = instantiateReactComponent(nextDescriptor);
var nextChildInstance = instantiateReactComponent(
nextDescriptor,
null
);
this._mountChildByNameAtIndex(
nextChildInstance, name, nextIndex, transaction
);
+76
View File
@@ -0,0 +1,76 @@
/**
* Copyright 2014 Facebook, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* @providesModule ReactNativeComponent
*/
"use strict";
var invariant = require('invariant');
var mergeInto = require('mergeInto');
var genericComponentClass = null;
// This registry keeps track of wrapper classes around native tags
var tagToComponentClass = {};
var ReactNativeComponentInjection = {
// This accepts a class that receives the tag string. This is a catch all
// that can render any kind of tag.
injectGenericComponentClass: function(componentClass) {
genericComponentClass = componentClass;
},
// This accepts a keyed object with classes as values. Each key represents a
// tag. That particular tag will use this class instead of the generic one.
injectComponentClasses: function(componentClasses) {
mergeInto(tagToComponentClass, componentClasses);
}
};
/**
* Create an internal class for a specific tag.
*
* @param {string} tag The tag for which to create an internal instance.
* @param {any} props The props passed to the instance constructor.
* @return {ReactComponent} component The injected empty component.
*/
function createInstanceForTag(tag, props, parentType) {
var componentClass = tagToComponentClass[tag];
if (componentClass == null) {
invariant(
genericComponentClass,
'There is no registered component for the tag %s',
tag
);
return new genericComponentClass(tag, props);
}
if (parentType === tag) {
// Avoid recursion
invariant(
genericComponentClass,
'There is no registered component for the tag %s',
tag
);
return new genericComponentClass(tag, props);
}
// Unwrap legacy factories
return new componentClass.type(props);
}
var ReactNativeComponent = {
createInstanceForTag: createInstanceForTag,
injection: ReactNativeComponentInjection,
};
module.exports = ReactNativeComponent;
+2
View File
@@ -141,6 +141,8 @@ var ReactPropTransferer = {
'don\'t own, %s. This usually means you are calling ' +
'transferPropsTo() on a component passed in as props or children.',
this.constructor.displayName,
typeof descriptor.type === 'string' ?
descriptor.type :
descriptor.type.displayName
);
@@ -297,4 +297,10 @@ describe('ReactDescriptor', function() {
expect(typeof Component.specialType.isRequired).toBe("function");
});
it('allows a DOM descriptor to be used with a string', function() {
var descriptor = React.createDescriptor('div', { className: 'foo' });
var instance = ReactTestUtils.renderIntoDocument(descriptor);
expect(instance.getDOMNode().tagName).toBe('DIV');
});
});
+24 -16
View File
@@ -23,6 +23,7 @@ var warning = require('warning');
var ReactDescriptor = require('ReactDescriptor');
var ReactLegacyDescriptor = require('ReactLegacyDescriptor');
var ReactNativeComponent = require('ReactNativeComponent');
var ReactEmptyComponent = require('ReactEmptyComponent');
/**
@@ -30,10 +31,11 @@ var ReactEmptyComponent = require('ReactEmptyComponent');
* mounted.
*
* @param {object} descriptor
* @return {object} A new instance of componentDescriptor's constructor.
* @param {*} parentCompositeType The composite type that resolved this.
* @return {object} A new instance of the descriptor's constructor.
* @protected
*/
function instantiateReactComponent(descriptor) {
function instantiateReactComponent(descriptor, parentCompositeType) {
var instance;
if (__DEV__) {
@@ -41,8 +43,6 @@ function instantiateReactComponent(descriptor) {
descriptor && (typeof descriptor.type === 'function' ||
typeof descriptor.type === 'string'),
'Only functions or strings can be mounted as React components.'
// Not really strings yet, but as soon as I solve the cyclic dep, they
// will be allowed here.
);
// Resolve mock instances
@@ -72,24 +72,32 @@ function instantiateReactComponent(descriptor) {
// there is no render function on the instance. We replace the whole
// component with an empty component instance instead.
descriptor = ReactEmptyComponent.getEmptyComponent();
instance = new descriptor.type(descriptor.props);
} else {
if (render._isMockFunction && !render._getMockImplementation()) {
// Auto-mocked components may have a prototype with a mocked render
// function. For those, we'll need to mock the result of the render
// since we consider undefined to be invalid results from render.
render.mockImplementation(
ReactEmptyComponent.getEmptyComponent
);
}
instance.construct(descriptor);
return instance;
} else if (render._isMockFunction && !render._getMockImplementation()) {
// Auto-mocked components may have a prototype with a mocked render
// function. For those, we'll need to mock the result of the render
// since we consider undefined to be invalid results from render.
render.mockImplementation(
ReactEmptyComponent.getEmptyComponent
);
}
instance.construct(descriptor);
return instance;
}
}
// Normal case for non-mocks
instance = new descriptor.type(descriptor.props);
// Special case string values
if (typeof descriptor.type === 'string') {
instance = ReactNativeComponent.createInstanceForTag(
descriptor.type,
descriptor.props,
parentCompositeType
);
} else {
// Normal case for non-mocks and non-strings
instance = new descriptor.type(descriptor.props);
}
if (__DEV__) {
warning(
+2 -2
View File
@@ -106,7 +106,7 @@ mergeInto(reactComponentExpect.prototype, {
toBeComponentOfType: function(convenienceConstructor) {
expect(
this.instance().constructor === convenienceConstructor.type
this.instance()._descriptor.type === convenienceConstructor.type
).toBe(true);
return this;
},
@@ -126,7 +126,7 @@ mergeInto(reactComponentExpect.prototype, {
toBeCompositeComponentWithType: function(convenienceConstructor) {
this.toBeCompositeComponent();
expect(
this.instance().constructor === convenienceConstructor.type
this.instance()._descriptor.type === convenienceConstructor.type
).toBe(true);
return this;
},