diff --git a/packages/react-dom-bindings/src/client/ReactDOMComponent.js b/packages/react-dom-bindings/src/client/ReactDOMComponent.js index 4deb8943a196f..4e1ecda7857ff 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMComponent.js +++ b/packages/react-dom-bindings/src/client/ReactDOMComponent.js @@ -7,8 +7,6 @@ * @flow */ -import type {InputWithWrapperState} from './ReactDOMInput'; - import { registrationNameDependencies, possibleRegistrationNames, @@ -17,6 +15,7 @@ import { import {canUseDOM} from 'shared/ExecutionEnvironment'; import {checkHtmlStringCoercion} from 'shared/CheckStringCoercion'; import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion'; +import {checkControlledValueProps} from '../shared/ReactControlledValuePropTypes'; import { getValueForAttribute, @@ -27,27 +26,24 @@ import { setValueForNamespacedAttribute, } from './DOMPropertyOperations'; import { - initWrapperState as ReactDOMInputInitWrapperState, - postMountWrapper as ReactDOMInputPostMountWrapper, - updateChecked as ReactDOMInputUpdateChecked, - updateWrapper as ReactDOMInputUpdateWrapper, - restoreControlledState as ReactDOMInputRestoreControlledState, + validateInputProps, + initInput, + updateInputChecked, + updateInput, + restoreControlledInputState, } from './ReactDOMInput'; +import {initOption, validateOptionProps} from './ReactDOMOption'; import { - postMountWrapper as ReactDOMOptionPostMountWrapper, - validateProps as ReactDOMOptionValidateProps, -} from './ReactDOMOption'; -import { - initWrapperState as ReactDOMSelectInitWrapperState, - postMountWrapper as ReactDOMSelectPostMountWrapper, - restoreControlledState as ReactDOMSelectRestoreControlledState, - postUpdateWrapper as ReactDOMSelectPostUpdateWrapper, + validateSelectProps, + initSelect, + restoreControlledSelectState, + updateSelect, } from './ReactDOMSelect'; import { - initWrapperState as ReactDOMTextareaInitWrapperState, - postMountWrapper as ReactDOMTextareaPostMountWrapper, - updateWrapper as ReactDOMTextareaUpdateWrapper, - restoreControlledState as ReactDOMTextareaRestoreControlledState, + validateTextareaProps, + initTextarea, + updateTextarea, + restoreControlledTextareaState, } from './ReactDOMTextarea'; import {track} from './inputValueTracking'; import setInnerHTML from './setInnerHTML'; @@ -79,6 +75,8 @@ import { listenToNonDelegatedEvent, } from '../events/DOMPluginEventSystem'; +let didWarnControlledToUncontrolled = false; +let didWarnUncontrolledToControlled = false; let didWarnInvalidHydration = false; let canDiffStyleForHydrationWarning; if (__DEV__) { @@ -805,7 +803,9 @@ export function setInitialProperties( break; } case 'input': { - ReactDOMInputInitWrapperState(domElement, props); + if (__DEV__) { + checkControlledValueProps('input', props); + } // We listen to this event in case to ensure emulated bubble // listeners still fire for the invalid event. listenToNonDelegatedEvent('invalid', domElement); @@ -834,10 +834,10 @@ export function setInitialProperties( break; } case 'checked': { - const node = ((domElement: any): InputWithWrapperState); const checked = - propValue != null ? propValue : node._wrapperState.initialChecked; - node.checked = + propValue != null ? propValue : props.defaultChecked; + const inputElement: HTMLInputElement = (domElement: any); + inputElement.checked = !!checked && typeof checked !== 'function' && checked !== 'symbol'; @@ -866,11 +866,14 @@ export function setInitialProperties( // TODO: Make sure we check if this is still unmounted or do any clean // up necessary since we never stop tracking anymore. track((domElement: any)); - ReactDOMInputPostMountWrapper(domElement, props, false); + validateInputProps(domElement, props); + initInput(domElement, props, false); return; } case 'select': { - ReactDOMSelectInitWrapperState(domElement, props); + if (__DEV__) { + checkControlledValueProps('select', props); + } // We listen to this event in case to ensure emulated bubble // listeners still fire for the invalid event. listenToNonDelegatedEvent('invalid', domElement); @@ -893,11 +896,14 @@ export function setInitialProperties( } } } - ReactDOMSelectPostMountWrapper(domElement, props); + validateSelectProps(domElement, props); + initSelect(domElement, props); return; } case 'textarea': { - ReactDOMTextareaInitWrapperState(domElement, props); + if (__DEV__) { + checkControlledValueProps('textarea', props); + } // We listen to this event in case to ensure emulated bubble // listeners still fire for the invalid event. listenToNonDelegatedEvent('invalid', domElement); @@ -936,11 +942,12 @@ export function setInitialProperties( // TODO: Make sure we check if this is still unmounted or do any clean // up necessary since we never stop tracking anymore. track((domElement: any)); - ReactDOMTextareaPostMountWrapper(domElement, props); + validateTextareaProps(domElement, props); + initTextarea(domElement, props); return; } case 'option': { - ReactDOMOptionValidateProps(domElement, props); + validateOptionProps(domElement, props); for (const propKey in props) { if (!props.hasOwnProperty(propKey)) { continue; @@ -963,7 +970,7 @@ export function setInitialProperties( } } } - ReactDOMOptionPostMountWrapper(domElement, props); + initOption(domElement, props); return; } case 'dialog': { @@ -1213,17 +1220,17 @@ export function updateProperties( // In the middle of an update, it is possible to have multiple checked. // When a checked radio tries to change name, browser makes another radio's checked false. if (nextProps.type === 'radio' && nextProps.name != null) { - ReactDOMInputUpdateChecked(domElement, nextProps); + updateInputChecked(domElement, nextProps); } for (let i = 0; i < updatePayload.length; i += 2) { const propKey = updatePayload[i]; const propValue = updatePayload[i + 1]; switch (propKey) { case 'checked': { - const node = ((domElement: any): InputWithWrapperState); const checked = - propValue != null ? propValue : node._wrapperState.initialChecked; - node.checked = + propValue != null ? propValue : nextProps.defaultChecked; + const inputElement: HTMLInputElement = (domElement: any); + inputElement.checked = !!checked && typeof checked !== 'function' && checked !== 'symbol'; @@ -1249,10 +1256,50 @@ export function updateProperties( } } } + + if (__DEV__) { + const wasControlled = + lastProps.type === 'checkbox' || lastProps.type === 'radio' + ? lastProps.checked != null + : lastProps.value != null; + const isControlled = + nextProps.type === 'checkbox' || nextProps.type === 'radio' + ? nextProps.checked != null + : nextProps.value != null; + + if ( + !wasControlled && + isControlled && + !didWarnUncontrolledToControlled + ) { + console.error( + 'A component is changing an uncontrolled input to be controlled. ' + + 'This is likely caused by the value changing from undefined to ' + + 'a defined value, which should not happen. ' + + 'Decide between using a controlled or uncontrolled input ' + + 'element for the lifetime of the component. More info: https://reactjs.org/link/controlled-components', + ); + didWarnUncontrolledToControlled = true; + } + if ( + wasControlled && + !isControlled && + !didWarnControlledToUncontrolled + ) { + console.error( + 'A component is changing a controlled input to be uncontrolled. ' + + 'This is likely caused by the value changing from a defined to ' + + 'undefined, which should not happen. ' + + 'Decide between using a controlled or uncontrolled input ' + + 'element for the lifetime of the component. More info: https://reactjs.org/link/controlled-components', + ); + didWarnControlledToUncontrolled = true; + } + } // Update the wrapper around inputs *after* updating props. This has to // happen after updating the rest of props. Otherwise HTML5 input validations // raise warnings and prevent the new value from being assigned. - ReactDOMInputUpdateWrapper(domElement, nextProps); + updateInput(domElement, nextProps); return; } case 'select': { @@ -1272,7 +1319,7 @@ export function updateProperties( } // host component that allows setting these optional @@ -61,10 +39,11 @@ function isControlled(props: any) { * See http://www.w3.org/TR/2012/WD-html5-20121025/the-input-element.html */ -export function initWrapperState(element: Element, props: Object) { +export function validateInputProps(element: Element, props: Object) { if (__DEV__) { - checkControlledValueProps('input', props); - + // Normally we check for undefined and null the same, but explicitly specifying both + // properties, at all is probably worth warning for. We could move this either direction + // and just make it ok to pass null or just check hasOwnProperty. if ( props.checked !== undefined && props.defaultChecked !== undefined && @@ -100,98 +79,30 @@ export function initWrapperState(element: Element, props: Object) { didWarnValueDefaultValue = true; } } - - const node = ((element: any): InputWithWrapperState); - const defaultValue = props.defaultValue == null ? '' : props.defaultValue; - const initialChecked = - props.checked != null ? props.checked : props.defaultChecked; - node._wrapperState = { - initialChecked: - typeof initialChecked !== 'function' && - typeof initialChecked !== 'symbol' && - !!initialChecked, - initialValue: getToStringValue( - props.value != null ? props.value : defaultValue, - ), - controlled: isControlled(props), - }; } -export function updateChecked(element: Element, props: Object) { - const node = ((element: any): InputWithWrapperState); +export function updateInputChecked(element: Element, props: Object) { + const node: HTMLInputElement = (element: any); const checked = props.checked; if (checked != null) { node.checked = checked; } } -export function updateWrapper(element: Element, props: Object) { - const node = ((element: any): InputWithWrapperState); - if (__DEV__) { - const controlled = isControlled(props); - - if ( - !node._wrapperState.controlled && - controlled && - !didWarnUncontrolledToControlled - ) { - console.error( - 'A component is changing an uncontrolled input to be controlled. ' + - 'This is likely caused by the value changing from undefined to ' + - 'a defined value, which should not happen. ' + - 'Decide between using a controlled or uncontrolled input ' + - 'element for the lifetime of the component. More info: https://reactjs.org/link/controlled-components', - ); - didWarnUncontrolledToControlled = true; - } - if ( - node._wrapperState.controlled && - !controlled && - !didWarnControlledToUncontrolled - ) { - console.error( - 'A component is changing a controlled input to be uncontrolled. ' + - 'This is likely caused by the value changing from a defined to ' + - 'undefined, which should not happen. ' + - 'Decide between using a controlled or uncontrolled input ' + - 'element for the lifetime of the component. More info: https://reactjs.org/link/controlled-components', - ); - didWarnControlledToUncontrolled = true; - } - } - - updateChecked(element, props); +export function updateInput(element: Element, props: Object) { + const node: HTMLInputElement = (element: any); const value = getToStringValue(props.value); const type = props.type; - if (value != null) { - if (type === 'number') { - if ( - // $FlowFixMe[incompatible-type] - (value === 0 && node.value === '') || - // We explicitly want to coerce to number here if possible. - // eslint-disable-next-line - node.value != (value: any) - ) { - node.value = toString((value: any)); - } - } else if (node.value !== toString((value: any))) { - node.value = toString((value: any)); - } - } else if (type === 'submit' || type === 'reset') { - // Submit/reset inputs need the attribute removed completely to avoid - // blank-text buttons. - node.removeAttribute('value'); - return; - } - if (disableInputAttributeSyncing) { // When not syncing the value attribute, React only assigns a new value // whenever the defaultValue React prop has changed. When not present, // React does nothing - if (props.hasOwnProperty('defaultValue')) { + if (props.defaultValue != null) { setDefaultValue(node, props.type, getToStringValue(props.defaultValue)); + } else { + node.removeAttribute('value'); } } else { // When syncing the value attribute, the value comes from a cascade of @@ -199,10 +110,12 @@ export function updateWrapper(element: Element, props: Object) { // 1. The value React property // 2. The defaultValue React property // 3. Otherwise there should be no change - if (props.hasOwnProperty('value')) { + if (props.value != null) { setDefaultValue(node, props.type, value); - } else if (props.hasOwnProperty('defaultValue')) { + } else if (props.defaultValue != null) { setDefaultValue(node, props.type, getToStringValue(props.defaultValue)); + } else { + node.removeAttribute('value'); } } @@ -222,18 +135,39 @@ export function updateWrapper(element: Element, props: Object) { node.defaultChecked = !!props.defaultChecked; } } + + updateInputChecked(element, props); + + if (value != null) { + if (type === 'number') { + if ( + // $FlowFixMe[incompatible-type] + (value === 0 && node.value === '') || + // We explicitly want to coerce to number here if possible. + // eslint-disable-next-line + node.value != (value: any) + ) { + node.value = toString((value: any)); + } + } else if (node.value !== toString((value: any))) { + node.value = toString((value: any)); + } + } else if (type === 'submit' || type === 'reset') { + // Submit/reset inputs need the attribute removed completely to avoid + // blank-text buttons. + node.removeAttribute('value'); + return; + } } -export function postMountWrapper( +export function initInput( element: Element, props: Object, isHydrating: boolean, ) { - const node = ((element: any): InputWithWrapperState); + const node: HTMLInputElement = (element: any); - // Do not assign value if it is already set. This prevents user text input - // from being lost during SSR hydration. - if (props.hasOwnProperty('value') || props.hasOwnProperty('defaultValue')) { + if (props.value != null || props.defaultValue != null) { const type = props.type; const isButton = type === 'submit' || type === 'reset'; @@ -243,7 +177,14 @@ export function postMountWrapper( return; } - const initialValue = toString(node._wrapperState.initialValue); + const defaultValue = + props.defaultValue != null + ? toString(getToStringValue(props.defaultValue)) + : ''; + const initialValue = + props.value != null + ? toString(getToStringValue(props.value)) + : defaultValue; // Do not assign value if it is already set. This prevents user text input // from being lost during SSR hydration. @@ -282,9 +223,8 @@ export function postMountWrapper( if (disableInputAttributeSyncing) { // When not syncing the value attribute, assign the value attribute // directly from the defaultValue React property (when present) - const defaultValue = getToStringValue(props.defaultValue); - if (defaultValue != null) { - node.defaultValue = toString(defaultValue); + if (props.defaultValue != null) { + node.defaultValue = defaultValue; } } else { // Otherwise, the value attribute is synchronized to the property, @@ -304,20 +244,27 @@ export function postMountWrapper( node.name = ''; } + const defaultChecked = + props.checked != null ? props.checked : props.defaultChecked; + const initialChecked = + typeof defaultChecked !== 'function' && + typeof defaultChecked !== 'symbol' && + !!defaultChecked; + // The checked property never gets assigned. It must be manually set. // We don't want to do this when hydrating so that existing user input isn't // modified // TODO: I'm pretty sure this is a bug because initialValueTracking won't be // correct for the hydration case then. if (!isHydrating) { - node.checked = !!node._wrapperState.initialChecked; + node.checked = !!initialChecked; } if (disableInputAttributeSyncing) { // Only assign the checked attribute if it is defined. This saves // a DOM write when controlling the checked attribute isn't needed // (text inputs, submit/reset) - if (props.hasOwnProperty('defaultChecked')) { + if (props.defaultChecked != null) { node.defaultChecked = !node.defaultChecked; node.defaultChecked = !!props.defaultChecked; } @@ -329,7 +276,7 @@ export function postMountWrapper( // 2. The defaultChecked React property when present // 3. Otherwise, false node.defaultChecked = !node.defaultChecked; - node.defaultChecked = !!node._wrapperState.initialChecked; + node.defaultChecked = !!initialChecked; } if (name !== '') { @@ -337,13 +284,13 @@ export function postMountWrapper( } } -export function restoreControlledState(element: Element, props: Object) { - const node = ((element: any): InputWithWrapperState); - updateWrapper(node, props); +export function restoreControlledInputState(element: Element, props: Object) { + const node: HTMLInputElement = (element: any); + updateInput(node, props); updateNamedCousins(node, props); } -function updateNamedCousins(rootNode: InputWithWrapperState, props: any) { +function updateNamedCousins(rootNode: HTMLInputElement, props: any) { const name = props.name; if (props.type === 'radio' && name != null) { let queryRoot: Element = rootNode; @@ -391,7 +338,7 @@ function updateNamedCousins(rootNode: InputWithWrapperState, props: any) { // 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. - updateWrapper(otherNode, otherProps); + updateInput(otherNode, otherProps); } } } @@ -405,7 +352,7 @@ function updateNamedCousins(rootNode: InputWithWrapperState, props: any) { // // https://github.com/facebook/react/issues/7253 export function setDefaultValue( - node: InputWithWrapperState, + node: HTMLInputElement, type: ?string, value: ToStringValue, ) { @@ -414,9 +361,7 @@ export function setDefaultValue( type !== 'number' || getActiveElement(node.ownerDocument) !== node ) { - if (value == null) { - node.defaultValue = toString(node._wrapperState.initialValue); - } else if (node.defaultValue !== toString(value)) { + if (node.defaultValue !== toString(value)) { node.defaultValue = toString(value); } } diff --git a/packages/react-dom-bindings/src/client/ReactDOMOption.js b/packages/react-dom-bindings/src/client/ReactDOMOption.js index f3b8f12a7f16a..7907609ad88fe 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMOption.js +++ b/packages/react-dom-bindings/src/client/ReactDOMOption.js @@ -18,7 +18,7 @@ let didWarnInvalidInnerHTML = false; * Implements an