Refactor some controlled component stuff (#26573)

This is mainly renaming some stuff. The behavior change is
hasOwnProperty to nullish check.

I had a bigger refactor that was a dead-end but might as well land this
part and see if I can pick it up later.
This commit is contained in:
Sebastian Markbåge 2023-04-09 18:06:16 -04:00 committed by GitHub
parent 657698e48d
commit e5146cb525
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 312 additions and 280 deletions

View File

@ -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(
}
// <select> value update needs to occur after <option> children
// reconciliation
ReactDOMSelectPostUpdateWrapper(domElement, nextProps);
updateSelect(domElement, lastProps, nextProps);
return;
}
case 'textarea': {
@ -1303,7 +1350,7 @@ export function updateProperties(
}
}
}
ReactDOMTextareaUpdateWrapper(domElement, nextProps);
updateTextarea(domElement, nextProps);
return;
}
case 'option': {
@ -2263,38 +2310,47 @@ export function diffHydratedProperties(
listenToNonDelegatedEvent('toggle', domElement);
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);
// 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));
validateInputProps(domElement, props);
// For input and textarea we current always set the value property at
// post mount to force it to diverge from attributes. However, for
// option and select we don't quite do the same thing and select
// is not resilient to the DOM state changing so we don't do that here.
// TODO: Consider not doing this for input and textarea.
ReactDOMInputPostMountWrapper(domElement, props, true);
initInput(domElement, props, true);
break;
case 'option':
ReactDOMOptionValidateProps(domElement, props);
validateOptionProps(domElement, props);
break;
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);
validateSelectProps(domElement, props);
break;
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);
// 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);
break;
}
@ -2472,13 +2528,13 @@ export function restoreControlledState(
): void {
switch (tag) {
case 'input':
ReactDOMInputRestoreControlledState(domElement, props);
restoreControlledInputState(domElement, props);
return;
case 'textarea':
ReactDOMTextareaRestoreControlledState(domElement, props);
restoreControlledTextareaState(domElement, props);
return;
case 'select':
ReactDOMSelectRestoreControlledState(domElement, props);
restoreControlledSelectState(domElement, props);
return;
}
}

View File

@ -12,7 +12,6 @@ import {getCurrentFiberOwnerNameInDevOrNull} from 'react-reconciler/src/ReactCur
import {getFiberCurrentPropsFromNode} from './ReactDOMComponentTree';
import {getToStringValue, toString} from './ToStringValue';
import {checkControlledValueProps} from '../shared/ReactControlledValuePropTypes';
import {updateValueIfChanged} from './inputValueTracking';
import getActiveElement from './getActiveElement';
import {disableInputAttributeSyncing} from 'shared/ReactFeatureFlags';
@ -20,29 +19,8 @@ import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion';
import type {ToStringValue} from './ToStringValue';
export type InputWithWrapperState = HTMLInputElement & {
_wrapperState: {
initialValue: ToStringValue,
initialChecked: ?boolean,
controlled?: boolean,
...
},
checked: boolean,
value: string,
defaultChecked: boolean,
defaultValue: string,
...
};
let didWarnValueDefaultValue = false;
let didWarnCheckedDefaultChecked = false;
let didWarnControlledToUncontrolled = false;
let didWarnUncontrolledToControlled = false;
function isControlled(props: any) {
const usesChecked = props.type === 'checkbox' || props.type === 'radio';
return usesChecked ? props.checked != null : props.value != null;
}
/**
* Implements an <input> 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,71 +79,65 @@ 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 (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.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
// properties:
// 1. The value React property
// 2. The defaultValue React property
// 3. Otherwise there should be no change
if (props.value != null) {
setDefaultValue(node, props.type, value);
} else if (props.defaultValue != null) {
setDefaultValue(node, props.type, getToStringValue(props.defaultValue));
} else {
node.removeAttribute('value');
}
}
if (disableInputAttributeSyncing) {
// When not syncing the checked attribute, the attribute is directly
// controllable from the defaultValue React property. It needs to be
// updated as new props come in.
if (props.defaultChecked == null) {
node.removeAttribute('checked');
} else {
node.defaultChecked = !!props.defaultChecked;
}
} else {
// When syncing the checked attribute, it only changes when it needs
// to be removed, such as transitioning from a checkbox into a text input
if (props.checked == null && props.defaultChecked != null) {
node.defaultChecked = !!props.defaultChecked;
}
}
updateInputChecked(element, props);
if (value != null) {
if (type === 'number') {
if (
@ -185,55 +158,16 @@ export function updateWrapper(element: Element, props: Object) {
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')) {
setDefaultValue(node, props.type, getToStringValue(props.defaultValue));
}
} else {
// When syncing the value attribute, the value comes from a cascade of
// properties:
// 1. The value React property
// 2. The defaultValue React property
// 3. Otherwise there should be no change
if (props.hasOwnProperty('value')) {
setDefaultValue(node, props.type, value);
} else if (props.hasOwnProperty('defaultValue')) {
setDefaultValue(node, props.type, getToStringValue(props.defaultValue));
}
}
if (disableInputAttributeSyncing) {
// When not syncing the checked attribute, the attribute is directly
// controllable from the defaultValue React property. It needs to be
// updated as new props come in.
if (props.defaultChecked == null) {
node.removeAttribute('checked');
} else {
node.defaultChecked = !!props.defaultChecked;
}
} else {
// When syncing the checked attribute, it only changes when it needs
// to be removed, such as transitioning from a checkbox into a text input
if (props.checked == null && props.defaultChecked != null) {
node.defaultChecked = !!props.defaultChecked;
}
}
}
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);
}
}

View File

@ -18,7 +18,7 @@ let didWarnInvalidInnerHTML = false;
* Implements an <option> host component that warns when `selected` is set.
*/
export function validateProps(element: Element, props: Object) {
export function validateOptionProps(element: Element, props: Object) {
if (__DEV__) {
// If a value is not provided, then the children must be simple.
if (props.value == null) {
@ -60,7 +60,7 @@ export function validateProps(element: Element, props: Object) {
}
}
export function postMountWrapper(element: Element, props: Object) {
export function initOption(element: Element, props: Object) {
// value="" should make a value attribute (#6219)
if (props.value != null) {
element.setAttribute('value', toString(getToStringValue(props.value)));

View File

@ -10,7 +10,6 @@
// TODO: direct imports like some-package/src/* are bad. Fix me.
import {getCurrentFiberOwnerNameInDevOrNull} from 'react-reconciler/src/ReactCurrentFiber';
import {checkControlledValueProps} from '../shared/ReactControlledValuePropTypes';
import {getToStringValue, toString} from './ToStringValue';
import isArray from 'shared/isArray';
@ -20,10 +19,6 @@ if (__DEV__) {
didWarnValueDefaultValue = false;
}
type SelectWithWrapperState = HTMLSelectElement & {
_wrapperState: {wasMultiple: boolean},
};
function getDeclarationErrorAddendum() {
const ownerName = getCurrentFiberOwnerNameInDevOrNull();
if (ownerName) {
@ -39,8 +34,6 @@ const valuePropNames = ['value', 'defaultValue'];
*/
function checkSelectPropTypes(props: any) {
if (__DEV__) {
checkControlledValueProps('select', props);
for (let i = 0; i < valuePropNames.length; i++) {
const propName = valuePropNames[i];
if (props[propName] == null) {
@ -129,17 +122,9 @@ function updateOptions(
* selected.
*/
export function initWrapperState(element: Element, props: Object) {
const node = ((element: any): SelectWithWrapperState);
export function validateSelectProps(element: Element, props: Object) {
if (__DEV__) {
checkSelectPropTypes(props);
}
node._wrapperState = {
wasMultiple: !!props.multiple,
};
if (__DEV__) {
if (
props.value !== undefined &&
props.defaultValue !== undefined &&
@ -157,8 +142,8 @@ export function initWrapperState(element: Element, props: Object) {
}
}
export function postMountWrapper(element: Element, props: Object) {
const node = ((element: any): SelectWithWrapperState);
export function initSelect(element: Element, props: Object) {
const node: HTMLSelectElement = (element: any);
node.multiple = !!props.multiple;
const value = props.value;
if (value != null) {
@ -168,10 +153,13 @@ export function postMountWrapper(element: Element, props: Object) {
}
}
export function postUpdateWrapper(element: Element, props: Object) {
const node = ((element: any): SelectWithWrapperState);
const wasMultiple = node._wrapperState.wasMultiple;
node._wrapperState.wasMultiple = !!props.multiple;
export function updateSelect(
element: Element,
prevProps: Object,
props: Object,
) {
const node: HTMLSelectElement = (element: any);
const wasMultiple = !!prevProps.multiple;
const value = props.value;
if (value != null) {
@ -187,8 +175,8 @@ export function postUpdateWrapper(element: Element, props: Object) {
}
}
export function restoreControlledState(element: Element, props: Object) {
const node = ((element: any): SelectWithWrapperState);
export function restoreControlledSelectState(element: Element, props: Object) {
const node: HTMLSelectElement = (element: any);
const value = props.value;
if (value != null) {

View File

@ -9,18 +9,12 @@
import isArray from 'shared/isArray';
import {checkControlledValueProps} from '../shared/ReactControlledValuePropTypes';
import {getCurrentFiberOwnerNameInDevOrNull} from 'react-reconciler/src/ReactCurrentFiber';
import {getToStringValue, toString} from './ToStringValue';
import type {ToStringValue} from './ToStringValue';
import {disableTextareaChildren} from 'shared/ReactFeatureFlags';
let didWarnValDefaultVal = false;
export type TextAreaWithWrapperState = HTMLTextAreaElement & {
_wrapperState: {initialValue: ToStringValue},
};
/**
* Implements a <textarea> host component that allows setting `value`, and
* `defaultValue`. This differs from the traditional DOM API because value is
@ -37,10 +31,8 @@ export type TextAreaWithWrapperState = HTMLTextAreaElement & {
* `defaultValue` if specified, or the children content (deprecated).
*/
export function initWrapperState(element: Element, props: Object) {
const node = ((element: any): TextAreaWithWrapperState);
export function validateTextareaProps(element: Element, props: Object) {
if (__DEV__) {
checkControlledValueProps('textarea', props);
if (
props.value !== undefined &&
props.defaultValue !== undefined &&
@ -57,7 +49,41 @@ export function initWrapperState(element: Element, props: Object) {
);
didWarnValDefaultVal = true;
}
if (props.children != null && props.value == null) {
console.error(
'Use the `defaultValue` or `value` props instead of setting ' +
'children on <textarea>.',
);
}
}
}
export function updateTextarea(element: Element, props: Object) {
const node: HTMLTextAreaElement = (element: any);
const value = getToStringValue(props.value);
const defaultValue = getToStringValue(props.defaultValue);
if (defaultValue != null) {
node.defaultValue = toString(defaultValue);
} else {
node.defaultValue = '';
}
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.
const newValue = toString(value);
// To avoid side effects (such as losing text selection), only set value if changed
if (newValue !== node.value) {
node.value = newValue;
}
// TOOO: This should respect disableInputAttributeSyncing flag.
if (props.defaultValue == null && node.defaultValue !== newValue) {
node.defaultValue = newValue;
}
}
}
export function initTextarea(element: Element, props: Object) {
const node: HTMLTextAreaElement = (element: any);
let initialValue = props.value;
@ -65,12 +91,6 @@ export function initWrapperState(element: Element, props: Object) {
if (initialValue == null) {
let {children, defaultValue} = props;
if (children != null) {
if (__DEV__) {
console.error(
'Use the `defaultValue` or `value` props instead of setting ' +
'children on <textarea>.',
);
}
if (!disableTextareaChildren) {
if (defaultValue != null) {
throw new Error(
@ -97,34 +117,7 @@ export function initWrapperState(element: Element, props: Object) {
const stringValue = getToStringValue(initialValue);
node.defaultValue = (stringValue: any); // This will be toString:ed.
node._wrapperState = {
initialValue: stringValue,
};
}
export function updateWrapper(element: Element, props: Object) {
const node = ((element: any): TextAreaWithWrapperState);
const value = getToStringValue(props.value);
const defaultValue = getToStringValue(props.defaultValue);
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.
const newValue = toString(value);
// To avoid side effects (such as losing text selection), only set value if changed
if (newValue !== node.value) {
node.value = newValue;
}
if (props.defaultValue == null && node.defaultValue !== newValue) {
node.defaultValue = newValue;
}
}
if (defaultValue != null) {
node.defaultValue = toString(defaultValue);
}
}
export function postMountWrapper(element: Element, props: Object) {
const node = ((element: any): TextAreaWithWrapperState);
// This is in postMount because we need access to the DOM node, which is not
// available until after the component has mounted.
const textContent = node.textContent;
@ -133,14 +126,17 @@ export function postMountWrapper(element: Element, props: Object) {
// initial value. In IE10/IE11 there is a bug where the placeholder attribute
// will populate textContent as well.
// https://developer.microsoft.com/microsoft-edge/platform/issues/101525/
if (textContent === node._wrapperState.initialValue) {
if (textContent === stringValue) {
if (textContent !== '' && textContent !== null) {
node.value = textContent;
}
}
}
export function restoreControlledState(element: Element, props: Object) {
export function restoreControlledTextareaState(
element: Element,
props: Object,
) {
// DOM component is still mounted; update
updateWrapper(element, props);
updateTextarea(element, props);
}

View File

@ -123,7 +123,6 @@ export function track(node: ElementWithValueTracker) {
return;
}
// TODO: Once it's just Fiber we can move this to node._wrapperState
node._valueTracker = trackValueOnNode(node);
}

View File

@ -263,16 +263,17 @@ function getTargetInstForInputOrChangeEvent(
}
}
function handleControlledInputBlur(node: HTMLInputElement) {
const state = (node: any)._wrapperState;
if (!state || !state.controlled || node.type !== 'number') {
function handleControlledInputBlur(node: HTMLInputElement, props: any) {
if (node.type !== 'number') {
return;
}
if (!disableInputAttributeSyncing) {
// If controlled, assign the value attribute to the current value on blur
setDefaultValue((node: any), 'number', (node: any).value);
const isControlled = props.value != null;
if (isControlled) {
// If controlled, assign the value attribute to the current value on blur
setDefaultValue((node: any), 'number', (node: any).value);
}
}
}
@ -335,8 +336,12 @@ function extractEvents(
}
// When blurring, set the value attribute for number inputs
if (domEventName === 'focusout') {
handleControlledInputBlur(((targetNode: any): HTMLInputElement));
if (domEventName === 'focusout' && targetInst) {
// These props aren't necessarily the most current but we warn for changing
// between controlled and uncontrolled, so it doesn't matter and the previous
// code was also broken for changes.
const props = targetInst.memoizedProps;
handleControlledInputBlur(((targetNode: any): HTMLInputElement), props);
}
}

View File

@ -1166,11 +1166,7 @@ describe('DOMPropertyOperations', () => {
).toErrorDev(
'A component is changing a controlled input to be uncontrolled',
);
if (disableInputAttributeSyncing) {
expect(container.firstChild.hasAttribute('value')).toBe(false);
} else {
expect(container.firstChild.getAttribute('value')).toBe('foo');
}
expect(container.firstChild.hasAttribute('value')).toBe(false);
expect(container.firstChild.value).toBe('foo');
});

View File

@ -1083,10 +1083,17 @@ describe('ReactDOMComponent', () => {
);
expect(nodeValueSetter).toHaveBeenCalledTimes(1);
ReactDOM.render(
<input type="checkbox" onChange={onChange} checked={false} />,
container,
expect(() => {
ReactDOM.render(
<input type="checkbox" onChange={onChange} checked={false} />,
container,
);
}).toErrorDev(
' 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.',
);
// TODO: Non-null values are updated twice on inputs. This is should ideally be fixed.
expect(nodeValueSetter).toHaveBeenCalledTimes(3);

View File

@ -1952,11 +1952,7 @@ describe('ReactDOMInput', () => {
expect(renderInputWithStringThenWithUndefined).toErrorDev(
'A component is changing a controlled input to be uncontrolled.',
);
if (disableInputAttributeSyncing) {
expect(input.getAttribute('value')).toBe(null);
} else {
expect(input.getAttribute('value')).toBe('first');
}
expect(input.getAttribute('value')).toBe(null);
});
it('preserves the value property', () => {
@ -2002,11 +1998,7 @@ describe('ReactDOMInput', () => {
'or `undefined` for uncontrolled components.',
'A component is changing a controlled input to be uncontrolled.',
]);
if (disableInputAttributeSyncing) {
expect(input.hasAttribute('value')).toBe(false);
} else {
expect(input.getAttribute('value')).toBe('first');
}
expect(input.hasAttribute('value')).toBe(false);
});
it('preserves the value property', () => {
@ -2165,4 +2157,30 @@ describe('ReactDOMInput', () => {
expect(node.hasAttribute('value')).toBe(false);
});
});
it('should remove previous `defaultValue`', () => {
const node = ReactDOM.render(
<input type="text" defaultValue="0" />,
container,
);
expect(node.value).toBe('0');
expect(node.defaultValue).toBe('0');
ReactDOM.render(<input type="text" />, container);
expect(node.defaultValue).toBe('');
});
it('should treat `defaultValue={null}` as missing', () => {
const node = ReactDOM.render(
<input type="text" defaultValue="0" />,
container,
);
expect(node.value).toBe('0');
expect(node.defaultValue).toBe('0');
ReactDOM.render(<input type="text" defaultValue={null} />, container);
expect(node.defaultValue).toBe('');
});
});

View File

@ -736,4 +736,26 @@ describe('ReactDOMTextarea', () => {
expect(node.value).toBe('foo');
});
});
it('should remove previous `defaultValue`', () => {
const container = document.createElement('div');
const node = ReactDOM.render(<textarea defaultValue="0" />, container);
expect(node.value).toBe('0');
expect(node.defaultValue).toBe('0');
ReactDOM.render(<textarea />, container);
expect(node.defaultValue).toBe('');
});
it('should treat `defaultValue={null}` as missing', () => {
const container = document.createElement('div');
const node = ReactDOM.render(<textarea defaultValue="0" />, container);
expect(node.value).toBe('0');
expect(node.defaultValue).toBe('0');
ReactDOM.render(<textarea defaultValue={null} />, container);
expect(node.defaultValue).toBe('');
});
});