diff --git a/src/renderers/dom/fiber/ReactDOMFiberComponent.js b/src/renderers/dom/fiber/ReactDOMFiberComponent.js new file mode 100644 index 0000000000..18ed0a1fda --- /dev/null +++ b/src/renderers/dom/fiber/ReactDOMFiberComponent.js @@ -0,0 +1,1303 @@ +/** + * Copyright 2013-present, 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 ReactDOMFiberComponent + */ + +/* global hasOwnProperty:true */ + +'use strict'; + +var AutoFocusUtils = require('AutoFocusUtils'); +var CSSPropertyOperations = require('CSSPropertyOperations'); +var DOMLazyTree = require('DOMLazyTree'); +var DOMNamespaces = require('DOMNamespaces'); +var DOMProperty = require('DOMProperty'); +var DOMPropertyOperations = require('DOMPropertyOperations'); +var EventPluginHub = require('EventPluginHub'); +var EventPluginRegistry = require('EventPluginRegistry'); +var ReactBrowserEventEmitter = require('ReactBrowserEventEmitter'); +var ReactDOMComponentFlags = require('ReactDOMComponentFlags'); +var ReactDOMComponentTree = require('ReactDOMComponentTree'); +var ReactDOMInput = require('ReactDOMInput'); +var ReactDOMOption = require('ReactDOMOption'); +var ReactDOMSelect = require('ReactDOMSelect'); +var ReactDOMTextarea = require('ReactDOMTextarea'); +var ReactInstrumentation = require('ReactInstrumentation'); +var ReactMultiChild = require('ReactMultiChild'); +var ReactServerRenderingTransaction = require('ReactServerRenderingTransaction'); + +var emptyFunction = require('emptyFunction'); +var escapeTextContentForBrowser = require('escapeTextContentForBrowser'); +var invariant = require('invariant'); +var isEventSupported = require('isEventSupported'); +var shallowEqual = require('shallowEqual'); +var inputValueTracking = require('inputValueTracking'); +var validateDOMNesting = require('validateDOMNesting'); +var warning = require('warning'); +var didWarnShadyDOM = false; + +var Flags = ReactDOMComponentFlags; +var deleteListener = EventPluginHub.deleteListener; +var getNode = ReactDOMComponentTree.getNodeFromInstance; +var listenTo = ReactBrowserEventEmitter.listenTo; +var registrationNameModules = EventPluginRegistry.registrationNameModules; + +// For quickly matching children type, to test if can be treated as content. +var CONTENT_TYPES = {'string': true, 'number': true}; + +var STYLE = 'style'; +var HTML = '__html'; +var RESERVED_PROPS = { + children: null, + dangerouslySetInnerHTML: null, + suppressContentEditableWarning: null, +}; + +// Node type for document fragments (Node.DOCUMENT_FRAGMENT_NODE). +var DOC_FRAGMENT_TYPE = 11; + + +function getDeclarationErrorAddendum(internalInstance) { + if (internalInstance) { + var owner = internalInstance._currentElement._owner || null; + if (owner) { + var name = owner.getName(); + if (name) { + return ' This DOM node was rendered by `' + name + '`.'; + } + } + } + return ''; +} + +function friendlyStringify(obj) { + if (typeof obj === 'object') { + if (Array.isArray(obj)) { + return '[' + obj.map(friendlyStringify).join(', ') + ']'; + } else { + var pairs = []; + for (var key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + var keyEscaped = /^[a-z$_][\w$_]*$/i.test(key) ? + key : + JSON.stringify(key); + pairs.push(keyEscaped + ': ' + friendlyStringify(obj[key])); + } + } + return '{' + pairs.join(', ') + '}'; + } + } else if (typeof obj === 'string') { + return JSON.stringify(obj); + } else if (typeof obj === 'function') { + return '[function object]'; + } + // Differs from JSON.stringify in that undefined because undefined and that + // inf and nan don't become null + return String(obj); +} + +var styleMutationWarning = {}; + +function checkAndWarnForMutatedStyle(style1, style2, component) { + if (style1 == null || style2 == null) { + return; + } + if (shallowEqual(style1, style2)) { + return; + } + + var componentName = component._tag; + var owner = component._currentElement._owner; + var ownerName; + if (owner) { + ownerName = owner.getName(); + } + + var hash = ownerName + '|' + componentName; + + if (styleMutationWarning.hasOwnProperty(hash)) { + return; + } + + styleMutationWarning[hash] = true; + + warning( + false, + '`%s` was passed a style object that has previously been mutated. ' + + 'Mutating `style` is deprecated. Consider cloning it beforehand. Check ' + + 'the `render` %s. Previous style: %s. Mutated style: %s.', + componentName, + owner ? 'of `' + ownerName + '`' : 'using <' + componentName + '>', + friendlyStringify(style1), + friendlyStringify(style2) + ); +} + +/** + * @param {object} component + * @param {?object} props + */ +function assertValidProps(component, props) { + if (!props) { + return; + } + // Note the use of `==` which checks for null or undefined. + if (voidElementTags[component._tag]) { + invariant( + props.children == null && props.dangerouslySetInnerHTML == null, + '%s is a void element tag and must neither have `children` nor ' + + 'use `dangerouslySetInnerHTML`.%s', + component._tag, + component._currentElement._owner ? + ' Check the render method of ' + + component._currentElement._owner.getName() + '.' : + '' + ); + } + if (props.dangerouslySetInnerHTML != null) { + invariant( + props.children == null, + 'Can only set one of `children` or `props.dangerouslySetInnerHTML`.' + ); + invariant( + typeof props.dangerouslySetInnerHTML === 'object' && + HTML in props.dangerouslySetInnerHTML, + '`props.dangerouslySetInnerHTML` must be in the form `{__html: ...}`. ' + + 'Please visit https://fb.me/react-invariant-dangerously-set-inner-html ' + + 'for more information.' + ); + } + if (__DEV__) { + warning( + props.innerHTML == null, + 'Directly setting property `innerHTML` is not permitted. ' + + 'For more information, lookup documentation on `dangerouslySetInnerHTML`.' + ); + warning( + props.suppressContentEditableWarning || + !props.contentEditable || + props.children == null, + 'A component is `contentEditable` and contains `children` managed by ' + + 'React. It is now your responsibility to guarantee that none of ' + + 'those nodes are unexpectedly modified or duplicated. This is ' + + 'probably not intentional.' + ); + warning( + props.onFocusIn == null && + props.onFocusOut == null, + 'React uses onFocus and onBlur instead of onFocusIn and onFocusOut. ' + + 'All React events are normalized to bubble, so onFocusIn and onFocusOut ' + + 'are not needed/supported by React.' + ); + } + invariant( + props.style == null || typeof props.style === 'object', + 'The `style` prop expects a mapping from style properties to values, ' + + 'not a string. For example, style={{marginRight: spacing + \'em\'}} when ' + + 'using JSX.%s', + getDeclarationErrorAddendum(component) + ); +} + +function enqueuePutListener(inst, registrationName, listener, transaction) { + if (transaction instanceof ReactServerRenderingTransaction) { + return; + } + if (__DEV__) { + // IE8 has no API for event capturing and the `onScroll` event doesn't + // bubble. + warning( + registrationName !== 'onScroll' || isEventSupported('scroll', true), + 'This browser doesn\'t support the `onScroll` event' + ); + } + var containerInfo = inst._hostContainerInfo; + var isDocumentFragment = containerInfo._node && containerInfo._node.nodeType === DOC_FRAGMENT_TYPE; + var doc = isDocumentFragment ? containerInfo._node : containerInfo._ownerDocument; + listenTo(registrationName, doc); + transaction.getReactMountReady().enqueue(putListener, { + inst: inst, + registrationName: registrationName, + listener: listener, + }); +} + +// TODO: This is coming from future #8192. Dedupe this and enqueuePutListener. +function ensureListeningTo(inst, registrationName, transaction) { + if (transaction instanceof ReactServerRenderingTransaction) { + return; + } + if (__DEV__) { + // IE8 has no API for event capturing and the `onScroll` event doesn't + // bubble. + warning( + registrationName !== 'onScroll' || isEventSupported('scroll', true), + 'This browser doesn\'t support the `onScroll` event' + ); + } + var containerInfo = inst._hostContainerInfo; + var isDocumentFragment = containerInfo._node && containerInfo._node.nodeType === DOC_FRAGMENT_TYPE; + var doc = isDocumentFragment ? containerInfo._node : containerInfo._ownerDocument; + listenTo(registrationName, doc); +} + +function putListener() { + var listenerToPut = this; + EventPluginHub.putListener( + listenerToPut.inst, + listenerToPut.registrationName, + listenerToPut.listener + ); +} + +function inputPostMount() { + var inst = this; + ReactDOMInput.postMountWrapper(inst); +} + +function textareaPostMount() { + var inst = this; + ReactDOMTextarea.postMountWrapper(inst); +} + +function optionPostMount() { + var inst = this; + ReactDOMOption.postMountWrapper(inst); +} + +var setAndValidateContentChildDev = emptyFunction; +if (__DEV__) { + setAndValidateContentChildDev = function(content) { + var hasExistingContent = this._contentDebugID != null; + var debugID = this._debugID; + // This ID represents the inlined child that has no backing instance: + var contentDebugID = -debugID; + + if (content == null) { + if (hasExistingContent) { + ReactInstrumentation.debugTool.onUnmountComponent(this._contentDebugID); + } + this._contentDebugID = null; + return; + } + + validateDOMNesting(null, String(content), this, this._ancestorInfo); + this._contentDebugID = contentDebugID; + if (hasExistingContent) { + ReactInstrumentation.debugTool.onBeforeUpdateComponent(contentDebugID, content); + ReactInstrumentation.debugTool.onUpdateComponent(contentDebugID); + } else { + ReactInstrumentation.debugTool.onBeforeMountComponent(contentDebugID, content, debugID); + ReactInstrumentation.debugTool.onMountComponent(contentDebugID); + ReactInstrumentation.debugTool.onSetChildren(debugID, [contentDebugID]); + } + }; +} + +// There are so many media events, it makes sense to just +// maintain a list rather than create a `trapBubbledEvent` for each +var mediaEvents = { + topAbort: 'abort', + topCanPlay: 'canplay', + topCanPlayThrough: 'canplaythrough', + topDurationChange: 'durationchange', + topEmptied: 'emptied', + topEncrypted: 'encrypted', + topEnded: 'ended', + topError: 'error', + topLoadedData: 'loadeddata', + topLoadedMetadata: 'loadedmetadata', + topLoadStart: 'loadstart', + topPause: 'pause', + topPlay: 'play', + topPlaying: 'playing', + topProgress: 'progress', + topRateChange: 'ratechange', + topSeeked: 'seeked', + topSeeking: 'seeking', + topStalled: 'stalled', + topSuspend: 'suspend', + topTimeUpdate: 'timeupdate', + topVolumeChange: 'volumechange', + topWaiting: 'waiting', +}; + +function trackInputValue() { + inputValueTracking.track(this); +} + +function trapClickOnNonInteractiveElement() { + // Mobile Safari does not fire properly bubble click events on + // non-interactive elements, which means delegated click listeners do not + // fire. The workaround for this bug involves attaching an empty click + // listener on the target node. + // http://www.quirksmode.org/blog/archives/2010/09/click_event_del.html + // Just set it using the onclick property so that we don't have to manage any + // bookkeeping for it. Not sure if we need to clear it when the listener is + // removed. + // TODO: Only do this for the relevant Safaris maybe? + var node = getNode(this); + node.onclick = emptyFunction; +} + +function trapBubbledEventsLocal() { + var inst = this; + // If a component renders to null or if another component fatals and causes + // the state of the tree to be corrupted, `node` here can be null. + invariant(inst._rootNodeID, 'Must be mounted to trap events'); + var node = getNode(inst); + invariant( + node, + 'trapBubbledEvent(...): Requires node to be rendered.' + ); + + switch (inst._tag) { + case 'iframe': + case 'object': + inst._wrapperState.listeners = [ + ReactBrowserEventEmitter.trapBubbledEvent( + 'topLoad', + 'load', + node + ), + ]; + break; + case 'video': + case 'audio': + + inst._wrapperState.listeners = []; + // Create listener for each media event + for (var event in mediaEvents) { + if (mediaEvents.hasOwnProperty(event)) { + inst._wrapperState.listeners.push( + ReactBrowserEventEmitter.trapBubbledEvent( + event, + mediaEvents[event], + node + ) + ); + } + } + break; + case 'source': + inst._wrapperState.listeners = [ + ReactBrowserEventEmitter.trapBubbledEvent( + 'topError', + 'error', + node + ), + ]; + break; + case 'img': + inst._wrapperState.listeners = [ + ReactBrowserEventEmitter.trapBubbledEvent( + 'topError', + 'error', + node + ), + ReactBrowserEventEmitter.trapBubbledEvent( + 'topLoad', + 'load', + node + ), + ]; + break; + case 'form': + inst._wrapperState.listeners = [ + ReactBrowserEventEmitter.trapBubbledEvent( + 'topReset', + 'reset', + node + ), + ReactBrowserEventEmitter.trapBubbledEvent( + 'topSubmit', + 'submit', + node + ), + ]; + break; + case 'input': + case 'select': + case 'textarea': + inst._wrapperState.listeners = [ + ReactBrowserEventEmitter.trapBubbledEvent( + 'topInvalid', + 'invalid', + node + ), + ]; + break; + } +} + +function postUpdateSelectWrapper() { + ReactDOMSelect.postUpdateWrapper(this); +} + +// For HTML, certain tags should omit their close tag. We keep a whitelist for +// those special-case 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. +}; + +var newlineEatingTags = { + 'listing': true, + 'pre': true, + 'textarea': true, +}; + +// For HTML, certain tags cannot have children. This has the same purpose as +// `omittedCloseTags` except that `menuitem` should still have its closing tag. + +var voidElementTags = Object.assign({ + 'menuitem': true, +}, omittedCloseTags); + +// We accept any tag to be rendered but since this gets injected into arbitrary +// HTML, we want to make sure that it's a safe tag. +// http://www.w3.org/TR/REC-xml/#NT-Name + +var VALID_TAG_REGEX = /^[a-zA-Z][a-zA-Z:_\.\-\d]*$/; // Simplified subset +var validatedTagCache = {}; +var hasOwnProperty = {}.hasOwnProperty; + +function validateDangerousTag(tag) { + if (!hasOwnProperty.call(validatedTagCache, tag)) { + invariant(VALID_TAG_REGEX.test(tag), 'Invalid tag: %s', tag); + validatedTagCache[tag] = true; + } +} + +function isCustomComponent(tagName, props) { + return tagName.indexOf('-') >= 0 || props.is != null; +} + +var globalIdCounter = 1; + +/** + * 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 ReactMultiChild + */ +function ReactDOMComponent(element) { + var tag = element.type; + validateDangerousTag(tag); + this._currentElement = element; + this._tag = tag.toLowerCase(); + this._namespaceURI = null; + this._renderedChildren = null; + this._previousStyle = null; + this._previousStyleCopy = null; + this._hostNode = null; + this._hostParent = null; + this._rootNodeID = 0; + this._domID = 0; + this._hostContainerInfo = null; + this._wrapperState = null; + this._topLevelWrapper = null; + this._flags = 0; + if (__DEV__) { + this._ancestorInfo = null; + setAndValidateContentChildDev.call(this, null); + } +} + +ReactDOMComponent.displayName = 'ReactDOMComponent'; + +ReactDOMComponent.Mixin = { + + /** + * Generates root tag markup then recurses. This method has side effects and + * is not idempotent. + * + * @internal + * @param {ReactReconcileTransaction|ReactServerRenderingTransaction} transaction + * @param {?ReactDOMComponent} the parent component instance + * @param {?object} info about the host container + * @param {object} context + * @return {string} The computed markup. + */ + mountComponent: function( + transaction, + hostParent, + hostContainerInfo, + context + ) { + this._rootNodeID = globalIdCounter++; + this._domID = hostContainerInfo._idCounter++; + this._hostParent = hostParent; + this._hostContainerInfo = hostContainerInfo; + + var props = this._currentElement.props; + + switch (this._tag) { + case 'audio': + case 'form': + case 'iframe': + case 'img': + case 'link': + case 'object': + case 'source': + case 'video': + this._wrapperState = { + listeners: null, + }; + transaction.getReactMountReady().enqueue(trapBubbledEventsLocal, this); + break; + case 'input': + ReactDOMInput.mountWrapper(this, props, hostParent); + props = ReactDOMInput.getHostProps(this, props); + transaction.getReactMountReady().enqueue(trackInputValue, this); + transaction.getReactMountReady().enqueue(trapBubbledEventsLocal, this); + // For controlled components we always need to ensure we're listening + // to onChange. Even if there is no listener. + ensureListeningTo(this, 'onChange', transaction); + break; + case 'option': + ReactDOMOption.mountWrapper(this, props, hostParent); + props = ReactDOMOption.getHostProps(this, props); + break; + case 'select': + ReactDOMSelect.mountWrapper(this, props, hostParent); + props = ReactDOMSelect.getHostProps(this, props); + transaction.getReactMountReady().enqueue(trapBubbledEventsLocal, this); + // For controlled components we always need to ensure we're listening + // to onChange. Even if there is no listener. + ensureListeningTo(this, 'onChange', transaction); + break; + case 'textarea': + ReactDOMTextarea.mountWrapper(this, props, hostParent); + props = ReactDOMTextarea.getHostProps(this, props); + transaction.getReactMountReady().enqueue(trackInputValue, this); + transaction.getReactMountReady().enqueue(trapBubbledEventsLocal, this); + // For controlled components we always need to ensure we're listening + // to onChange. Even if there is no listener. + ensureListeningTo(this, 'onChange', transaction); + break; + } + + assertValidProps(this, props); + + // We create tags in the namespace of their parent container, except HTML + // tags get no namespace. + var namespaceURI; + var parentTag; + if (hostParent != null) { + namespaceURI = hostParent._namespaceURI; + parentTag = hostParent._tag; + } else if (hostContainerInfo._tag) { + namespaceURI = hostContainerInfo._namespaceURI; + parentTag = hostContainerInfo._tag; + } + if (namespaceURI == null || + namespaceURI === DOMNamespaces.svg && parentTag === 'foreignobject') { + namespaceURI = DOMNamespaces.html; + } + if (namespaceURI === DOMNamespaces.html) { + if (this._tag === 'svg') { + namespaceURI = DOMNamespaces.svg; + } else if (this._tag === 'math') { + namespaceURI = DOMNamespaces.mathml; + } + } + this._namespaceURI = namespaceURI; + + if (__DEV__) { + var parentInfo; + if (hostParent != null) { + parentInfo = hostParent._ancestorInfo; + } else if (hostContainerInfo._tag) { + parentInfo = hostContainerInfo._ancestorInfo; + } + if (parentInfo) { + // parentInfo should always be present except for the top-level + // component when server rendering + validateDOMNesting(this._tag, null, this, parentInfo); + } + this._ancestorInfo = + validateDOMNesting.updatedAncestorInfo(parentInfo, this._tag, this); + } + + var mountImage; + var type = this._currentElement.type; + if (transaction.useCreateElement) { + var ownerDocument = hostContainerInfo._ownerDocument; + var el; + if (namespaceURI === DOMNamespaces.html) { + if (this._tag === 'script') { + // Create the script via .innerHTML so its "parser-inserted" flag is + // set to true and it does not execute + var div = ownerDocument.createElement('div'); + div.innerHTML = `<${type}>`; + el = div.removeChild(div.firstChild); + } else if (props.is) { + el = ownerDocument.createElement(type, props.is); + } else { + // Separate else branch instead of using `props.is || undefined` above becuase of a Firefox bug. + // See discussion in https://github.com/facebook/react/pull/6896 + // and discussion in https://bugzilla.mozilla.org/show_bug.cgi?id=1276240 + el = ownerDocument.createElement(type); + } + } else { + el = ownerDocument.createElementNS( + namespaceURI, + type + ); + } + var isCustomComponentTag = isCustomComponent(this._tag, props); + if (__DEV__ && isCustomComponentTag && !didWarnShadyDOM && el.shadyRoot) { + var owner = this._currentElement._owner; + var name = owner && owner.getName() || 'A component'; + warning( + false, + '%s is using shady DOM. Using shady DOM with React can ' + + 'cause things to break subtly.', + name + ); + didWarnShadyDOM = true; + } + ReactDOMComponentTree.precacheNode(this, el); + this._flags |= Flags.hasCachedChildNodes; + if (!this._hostParent) { + DOMPropertyOperations.setAttributeForRoot(el); + } + this._updateDOMProperties(null, props, transaction, isCustomComponentTag); + var lazyTree = DOMLazyTree(el); + this._createInitialChildren(transaction, props, context, lazyTree); + mountImage = lazyTree; + } else { + var tagOpen = this._createOpenTagMarkupAndPutListeners(transaction, props); + var tagContent = this._createContentMarkup(transaction, props, context); + if (!tagContent && omittedCloseTags[this._tag]) { + mountImage = tagOpen + '/>'; + } else { + mountImage = tagOpen + '>' + tagContent + ''; + } + } + + switch (this._tag) { + case 'input': + transaction.getReactMountReady().enqueue( + inputPostMount, + this + ); + if (props.autoFocus) { + transaction.getReactMountReady().enqueue( + AutoFocusUtils.focusDOMComponent, + this + ); + } + break; + case 'textarea': + transaction.getReactMountReady().enqueue( + textareaPostMount, + this + ); + if (props.autoFocus) { + transaction.getReactMountReady().enqueue( + AutoFocusUtils.focusDOMComponent, + this + ); + } + break; + case 'select': + if (props.autoFocus) { + transaction.getReactMountReady().enqueue( + AutoFocusUtils.focusDOMComponent, + this + ); + } + break; + case 'button': + if (props.autoFocus) { + transaction.getReactMountReady().enqueue( + AutoFocusUtils.focusDOMComponent, + this + ); + } + break; + case 'option': + transaction.getReactMountReady().enqueue( + optionPostMount, + this + ); + break; + default: + if (typeof props.onClick === 'function') { + transaction.getReactMountReady().enqueue( + trapClickOnNonInteractiveElement, + this + ); + } + break; + } + + return mountImage; + }, + + /** + * Creates markup for the open tag and all attributes. + * + * This method has side effects because events get registered. + * + * Iterating over object properties is faster than iterating over arrays. + * @see http://jsperf.com/obj-vs-arr-iteration + * + * @private + * @param {ReactReconcileTransaction|ReactServerRenderingTransaction} transaction + * @param {object} props + * @return {string} Markup of opening tag. + */ + _createOpenTagMarkupAndPutListeners: function(transaction, props) { + var ret = '<' + this._currentElement.type; + + for (var propKey in props) { + if (!props.hasOwnProperty(propKey)) { + continue; + } + var propValue = props[propKey]; + if (propValue == null) { + continue; + } + if (registrationNameModules.hasOwnProperty(propKey)) { + if (propValue) { + enqueuePutListener(this, propKey, propValue, transaction); + } + } else { + if (propKey === STYLE) { + if (propValue) { + if (__DEV__) { + // See `_updateDOMProperties`. style block + this._previousStyle = propValue; + } + propValue = this._previousStyleCopy = Object.assign({}, props.style); + } + propValue = CSSPropertyOperations.createMarkupForStyles(propValue, this); + } + var markup = null; + if (this._tag != null && isCustomComponent(this._tag, props)) { + if (!RESERVED_PROPS.hasOwnProperty(propKey)) { + markup = DOMPropertyOperations.createMarkupForCustomAttribute(propKey, propValue); + } + } else { + markup = DOMPropertyOperations.createMarkupForProperty(propKey, propValue); + } + if (markup) { + ret += ' ' + markup; + } + } + } + + // For static pages, no need to put React ID and checksum. Saves lots of + // bytes. + if (transaction.renderToStaticMarkup) { + return ret; + } + + if (!this._hostParent) { + ret += ' ' + DOMPropertyOperations.createMarkupForRoot(); + } + ret += ' ' + DOMPropertyOperations.createMarkupForID(this._domID); + return ret; + }, + + /** + * Creates markup for the content between the tags. + * + * @private + * @param {ReactReconcileTransaction|ReactServerRenderingTransaction} transaction + * @param {object} props + * @param {object} context + * @return {string} Content markup. + */ + _createContentMarkup: function(transaction, props, context) { + var ret = ''; + + // Intentional use of != to avoid catching zero/false. + var innerHTML = props.dangerouslySetInnerHTML; + if (innerHTML != null) { + if (innerHTML.__html != null) { + ret = innerHTML.__html; + } + } else { + var contentToUse = + 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); + if (__DEV__) { + setAndValidateContentChildDev.call(this, contentToUse); + } + } else if (childrenToUse != null) { + var mountImages = this.mountChildren( + childrenToUse, + transaction, + context + ); + ret = mountImages.join(''); + } + } + if (newlineEatingTags[this._tag] && ret.charAt(0) === '\n') { + // text/html ignores the first character in these tags if it's a newline + // Prefer to break application/xml over text/html (for now) by adding + // a newline specifically to get eaten by the parser. (Alternately for + // textareas, replacing "^\n" with "\r\n" doesn't get eaten, and the first + // \r is normalized out by HTMLTextAreaElement#value.) + // See: + // See: + // See: + // See: Parsing of "textarea" "listing" and "pre" elements + // from + return '\n' + ret; + } else { + return ret; + } + }, + + _createInitialChildren: function(transaction, props, context, lazyTree) { + // Intentional use of != to avoid catching zero/false. + var innerHTML = props.dangerouslySetInnerHTML; + if (innerHTML != null) { + if (innerHTML.__html != null) { + DOMLazyTree.queueHTML(lazyTree, innerHTML.__html); + } + } else { + var contentToUse = + CONTENT_TYPES[typeof props.children] ? props.children : null; + var childrenToUse = contentToUse != null ? null : props.children; + // TODO: Validate that text is allowed as a child of this node + if (contentToUse != null) { + // Avoid setting textContent when the text is empty. In IE11 setting + // textContent on a text area will cause the placeholder to not + // show within the textarea until it has been focused and blurred again. + // https://github.com/facebook/react/issues/6731#issuecomment-254874553 + if (contentToUse !== '') { + if (__DEV__) { + setAndValidateContentChildDev.call(this, contentToUse); + } + DOMLazyTree.queueText(lazyTree, contentToUse); + } + } else if (childrenToUse != null) { + var mountImages = this.mountChildren( + childrenToUse, + transaction, + context + ); + for (var i = 0; i < mountImages.length; i++) { + DOMLazyTree.queueChild(lazyTree, mountImages[i]); + } + } + } + }, + + /** + * Receives a next element and updates the component. + * + * @internal + * @param {ReactElement} nextElement + * @param {ReactReconcileTransaction|ReactServerRenderingTransaction} transaction + * @param {object} context + */ + receiveComponent: function(nextElement, transaction, context) { + var prevElement = this._currentElement; + this._currentElement = nextElement; + this.updateComponent(transaction, prevElement, nextElement, context); + }, + + /** + * Updates a DOM component after it has already been allocated and + * attached to the DOM. Reconciles the root DOM node, then recurses. + * + * @param {ReactReconcileTransaction} transaction + * @param {ReactElement} prevElement + * @param {ReactElement} nextElement + * @internal + * @overridable + */ + updateComponent: function(transaction, prevElement, nextElement, context) { + var lastProps = prevElement.props; + var nextProps = this._currentElement.props; + + switch (this._tag) { + case 'input': + lastProps = ReactDOMInput.getHostProps(this, lastProps); + nextProps = ReactDOMInput.getHostProps(this, nextProps); + break; + case 'option': + lastProps = ReactDOMOption.getHostProps(this, lastProps); + nextProps = ReactDOMOption.getHostProps(this, nextProps); + break; + case 'select': + lastProps = ReactDOMSelect.getHostProps(this, lastProps); + nextProps = ReactDOMSelect.getHostProps(this, nextProps); + break; + case 'textarea': + lastProps = ReactDOMTextarea.getHostProps(this, lastProps); + nextProps = ReactDOMTextarea.getHostProps(this, nextProps); + break; + default: + if (typeof lastProps.onClick !== 'function' && + typeof nextProps.onClick === 'function') { + transaction.getReactMountReady().enqueue( + trapClickOnNonInteractiveElement, + this + ); + } + break; + } + + assertValidProps(this, nextProps); + var isCustomComponentTag = isCustomComponent(this._tag, nextProps); + this._updateDOMProperties(lastProps, nextProps, transaction, isCustomComponentTag); + this._updateDOMChildren( + lastProps, + nextProps, + transaction, + context + ); + + switch (this._tag) { + case 'input': + // Update the wrapper around inputs *after* updating props. This has to + // happen after `_updateDOMProperties`. Otherwise HTML5 input validations + // raise warnings and prevent the new value from being assigned. + ReactDOMInput.updateWrapper(this); + break; + case 'textarea': + ReactDOMTextarea.updateWrapper(this); + break; + case 'select': + // host component that allows setting these optional + * props: `checked`, `value`, `defaultChecked`, and `defaultValue`. + * + * If `checked` or `value` are not supplied (or null/undefined), user actions + * that affect the checked state or value will trigger updates to the element. + * + * If they are supplied (and not null/undefined), the rendered element will not + * trigger updates to the element. Instead, the props must change in order for + * the rendered element to be updated. + * + * The rendered element will be initialized as unchecked (or `defaultChecked`) + * with an empty value (or `defaultValue`). + * + * @see http://www.w3.org/TR/2012/WD-html5-20121025/the-input-element.html + */ +var ReactDOMInput = { + getHostProps: function(inst, props) { + var value = props.value; + var checked = props.checked; + + var hostProps = Object.assign({ + // Make sure we set .type before any other properties (setting .value + // before .type means .value is lost in IE11 and below) + type: undefined, + // Make sure we set .step before .value (setting .value before .step + // means .value is rounded on mount, based upon step precision) + step: undefined, + // Make sure we set .min & .max before .value (to ensure proper order + // in corner cases such as min or max deriving from value, e.g. Issue #7170) + min: undefined, + max: undefined, + }, props, { + defaultChecked: undefined, + defaultValue: undefined, + value: value != null ? value : inst._wrapperState.initialValue, + checked: checked != null ? checked : inst._wrapperState.initialChecked, + }); + + return hostProps; + }, + + mountWrapper: function(inst, props) { + if (__DEV__) { + ReactControlledValuePropTypes.checkPropTypes( + 'input', + props, + inst._currentElement._owner + ); + + var owner = inst._currentElement._owner; + + if ( + props.checked !== undefined && + props.defaultChecked !== undefined && + !didWarnCheckedDefaultChecked + ) { + warning( + false, + '%s contains an input of type %s with both checked and defaultChecked props. ' + + 'Input elements must be either controlled or uncontrolled ' + + '(specify either the checked prop, or the defaultChecked prop, but not ' + + 'both). Decide between using a controlled or uncontrolled input ' + + 'element and remove one of these props. More info: ' + + 'https://fb.me/react-controlled-components', + owner && owner.getName() || 'A component', + props.type + ); + didWarnCheckedDefaultChecked = true; + } + if ( + props.value !== undefined && + props.defaultValue !== undefined && + !didWarnValueDefaultValue + ) { + warning( + false, + '%s contains an input of type %s with both value and defaultValue props. ' + + 'Input elements must be either controlled or uncontrolled ' + + '(specify either the value prop, or the defaultValue prop, but not ' + + 'both). Decide between using a controlled or uncontrolled input ' + + 'element and remove one of these props. More info: ' + + 'https://fb.me/react-controlled-components', + owner && owner.getName() || 'A component', + props.type + ); + didWarnValueDefaultValue = true; + } + } + + var defaultValue = props.defaultValue; + inst._wrapperState = { + initialChecked: props.checked != null ? props.checked : props.defaultChecked, + initialValue: props.value != null ? props.value : defaultValue, + listeners: null, + }; + + if (__DEV__) { + inst._wrapperState.controlled = isControlled(props); + } + }, + + updateWrapper: function(inst) { + var props = inst._currentElement.props; + + if (__DEV__) { + var controlled = isControlled(props); + var owner = inst._currentElement._owner; + + if (!inst._wrapperState.controlled && controlled && !didWarnUncontrolledToControlled) { + warning( + false, + '%s is changing an uncontrolled input of type %s to be controlled. ' + + 'Input elements should not switch from uncontrolled to controlled (or vice versa). ' + + 'Decide between using a controlled or uncontrolled input ' + + 'element for the lifetime of the component. More info: https://fb.me/react-controlled-components', + owner && owner.getName() || 'A component', + props.type + ); + didWarnUncontrolledToControlled = true; + } + if (inst._wrapperState.controlled && !controlled && !didWarnControlledToUncontrolled) { + warning( + false, + '%s is changing a controlled input of type %s to be uncontrolled. ' + + 'Input elements should not switch from controlled to uncontrolled (or vice versa). ' + + 'Decide between using a controlled or uncontrolled input ' + + 'element for the lifetime of the component. More info: https://fb.me/react-controlled-components', + owner && owner.getName() || 'A component', + props.type + ); + didWarnControlledToUncontrolled = true; + } + } + + var checked = props.checked; + if (checked != null) { + DOMPropertyOperations.setValueForProperty( + ReactDOMComponentTree.getNodeFromInstance(inst), + 'checked', + checked || false + ); + } + + var node = ReactDOMComponentTree.getNodeFromInstance(inst); + var value = props.value; + if (value != null) { + + // Cast `value` to a string to ensure the value is set correctly. While + // browsers typically do this as necessary, jsdom doesn't. + var newValue = '' + value; + + // To avoid side effects (such as losing text selection), only set value if changed + if (newValue !== node.value) { + node.value = newValue; + } + } else { + if (props.value == null && props.defaultValue != null) { + // In Chrome, assigning defaultValue to certain input types triggers input validation. + // For number inputs, the display value loses trailing decimal points. For email inputs, + // Chrome raises "The specified value is not a valid email address". + // + // Here we check to see if the defaultValue has actually changed, avoiding these problems + // when the user is inputting text + // + // https://github.com/facebook/react/issues/7253 + if (node.defaultValue !== '' + props.defaultValue) { + node.defaultValue = '' + props.defaultValue; + } + } + if (props.checked == null && props.defaultChecked != null) { + node.defaultChecked = !!props.defaultChecked; + } + } + }, + + postMountWrapper: function(inst) { + var props = inst._currentElement.props; + + // This is in postMount because we need access to the DOM node, which is not + // available until after the component has mounted. + var node = ReactDOMComponentTree.getNodeFromInstance(inst); + + // Detach value from defaultValue. We won't do anything if we're working on + // submit or reset inputs as those values & defaultValues are linked. They + // are not resetable nodes so this operation doesn't matter and actually + // removes browser-default values (eg "Submit Query") when no value is + // provided. + + switch (props.type) { + case 'submit': + case 'reset': + break; + case 'color': + case 'date': + case 'datetime': + case 'datetime-local': + case 'month': + case 'time': + case 'week': + // This fixes the no-show issue on iOS Safari and Android Chrome: + // https://github.com/facebook/react/issues/7233 + node.value = ''; + node.value = node.defaultValue; + break; + default: + node.value = node.value; + break; + } + + // Normally, we'd just do `node.checked = node.checked` upon initial mount, less this bug + // this is needed to work around a chrome bug where setting defaultChecked + // will sometimes influence the value of checked (even after detachment). + // Reference: https://bugs.chromium.org/p/chromium/issues/detail?id=608416 + // We need to temporarily unset name to avoid disrupting radio button groups. + var name = node.name; + if (name !== '') { + node.name = ''; + } + node.defaultChecked = !node.defaultChecked; + node.defaultChecked = !node.defaultChecked; + if (name !== '') { + node.name = name; + } + }, + + restoreControlledState: function(inst) { + if (inst._rootNodeID) { + // DOM component is still mounted; update + ReactDOMInput.updateWrapper(inst); + } + var props = inst._currentElement.props; + updateNamedCousins(inst, props); + }, +}; + +function updateNamedCousins(thisInstance, props) { + var name = props.name; + if (props.type === 'radio' && name != null) { + var rootNode = ReactDOMComponentTree.getNodeFromInstance(thisInstance); + var queryRoot = rootNode; + + while (queryRoot.parentNode) { + queryRoot = queryRoot.parentNode; + } + + // If `rootNode.form` was non-null, then we could try `form.elements`, + // but that sometimes behaves strangely in IE8. We could also try using + // `form.getElementsByName`, but that will only return direct children + // and won't include inputs that use the HTML5 `form=` attribute. Since + // the input might not even be in a form. It might not even be in the + // document. Let's just use the local `querySelectorAll` to ensure we don't + // miss anything. + var group = queryRoot.querySelectorAll( + 'input[name=' + JSON.stringify('' + name) + '][type="radio"]'); + + for (var i = 0; i < group.length; i++) { + var otherNode = group[i]; + if (otherNode === rootNode || + otherNode.form !== rootNode.form) { + continue; + } + // This will throw if radio buttons rendered by different copies of React + // and the same name are rendered into the same form (same as #1939). + // That's probably okay; we don't support it just as we don't support + // mixing React radio buttons with non-React ones. + var otherInstance = ReactDOMComponentTree.getInstanceFromNode(otherNode); + invariant( + otherInstance, + 'ReactDOMInput: Mixing React and non-React radio inputs with the ' + + 'same `name` is not supported.' + ); + // If this is a controlled radio button group, forcing the input that + // was previously checked to update will cause it to be come re-checked + // as appropriate. + if (otherInstance._rootNodeID) { + ReactDOMInput.updateWrapper(otherInstance); + } + } + } +} + +module.exports = ReactDOMInput; diff --git a/src/renderers/dom/fiber/wrappers/ReactDOMFiberOption.js b/src/renderers/dom/fiber/wrappers/ReactDOMFiberOption.js new file mode 100644 index 0000000000..1fbc1fddde --- /dev/null +++ b/src/renderers/dom/fiber/wrappers/ReactDOMFiberOption.js @@ -0,0 +1,128 @@ +/** + * Copyright 2013-present, 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 ReactDOMFiberOption + */ + +'use strict'; + +var React = require('React'); +var ReactDOMComponentTree = require('ReactDOMComponentTree'); +var ReactDOMSelect = require('ReactDOMSelect'); + +var warning = require('warning'); +var didWarnInvalidOptionChildren = false; + +function flattenChildren(children) { + var content = ''; + + // Flatten children and warn if they aren't strings or numbers; + // invalid types are ignored. + React.Children.forEach(children, function(child) { + if (child == null) { + return; + } + if (typeof child === 'string' || typeof child === 'number') { + content += child; + } else if (!didWarnInvalidOptionChildren) { + didWarnInvalidOptionChildren = true; + warning( + false, + 'Only strings and numbers are supported as