/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
// TODO: direct imports like some-package/src/* are bad. Fix me.
import {getCurrentFiberOwnerNameInDevOrNull} from 'react-reconciler/src/ReactCurrentFiber';
import {getFiberCurrentPropsFromNode} from './ReactDOMComponentTree';
import {getToStringValue, toString} from './ToStringValue';
import {updateValueIfChanged} from './inputValueTracking';
import getActiveElement from './getActiveElement';
import {disableInputAttributeSyncing} from 'shared/ReactFeatureFlags';
import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion';
import type {ToStringValue} from './ToStringValue';
import escapeSelectorAttributeValueInsideDoubleQuotes from './escapeSelectorAttributeValueInsideDoubleQuotes';
let didWarnValueDefaultValue = false;
let didWarnCheckedDefaultChecked = false;
/**
* Implements an <input> 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
*/
export function validateInputProps(element: Element, props: Object) {
if (__DEV__) {
// 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 &&
!didWarnCheckedDefaultChecked
) {
console.error(
'%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://reactjs.org/link/controlled-components',
getCurrentFiberOwnerNameInDevOrNull() || 'A component',
props.type,
);
didWarnCheckedDefaultChecked = true;
}
if (
props.value !== undefined &&
props.defaultValue !== undefined &&
!didWarnValueDefaultValue
) {
console.error(
'%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://reactjs.org/link/controlled-components',
getCurrentFiberOwnerNameInDevOrNull() || 'A component',
props.type,
);
didWarnValueDefaultValue = true;
}
}
}
export function updateInput(
element: Element,
value: ?string,
defaultValue: ?string,
lastDefaultValue: ?string,
checked: ?boolean,
defaultChecked: ?boolean,
type: ?string,
name: ?string,
) {
const node: HTMLInputElement = (element: any);
// Temporarily disconnect the input from any radio buttons.
// Changing the type or name as the same time as changing the checked value
// needs to be atomically applied. We can only ensure that by disconnecting
// the name while do the mutations and then reapply the name after that's done.
node.name = '';
if (
type != null &&
typeof type !== 'function' &&
typeof type !== 'symbol' &&
typeof type !== 'boolean'
) {
if (__DEV__) {
checkAttributeStringCoercion(type, 'type');
}
node.type = type;
} else {
node.removeAttribute('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(getToStringValue(value));
}
} else if (node.value !== toString(getToStringValue(value))) {
node.value = toString(getToStringValue(value));
}
} else if (type === 'submit' || type === 'reset') {
// Submit/reset inputs need the attribute removed completely to avoid
// blank-text buttons.
node.removeAttribute('value');
}
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 (defaultValue != null) {
setDefaultValue(node, type, getToStringValue(defaultValue));
} else if (lastDefaultValue != null) {
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 (value != null) {
setDefaultValue(node, type, getToStringValue(value));
} else if (defaultValue != null) {
setDefaultValue(node, type, getToStringValue(defaultValue));
} else if (lastDefaultValue != null) {
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 (defaultChecked == null) {
node.removeAttribute('checked');
} else {
node.defaultChecked = !!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 (checked == null && defaultChecked != null) {
node.defaultChecked = !!defaultChecked;
}
}
if (checked != null) {
// Important to set this even if it's not a change in order to update input
// value tracking with radio buttons
// TODO: Should really update input value tracking for the whole radio
// button group in an effect or something (similar to #27024)
node.checked =
checked && typeof checked !== 'function' && typeof checked !== 'symbol';
}
if (
name != null &&
typeof name !== 'function' &&
typeof name !== 'symbol' &&
typeof name !== 'boolean'
) {
if (__DEV__) {
checkAttributeStringCoercion(name, 'name');
}
node.name = toString(getToStringValue(name));
} else {
node.removeAttribute('name');
}
}
export function initInput(
element: Element,
value: ?string,
defaultValue: ?string,
checked: ?boolean,
defaultChecked: ?boolean,
type: ?string,
name: ?string,
isHydrating: boolean,
) {
const node: HTMLInputElement = (element: any);
if (
type != null &&
typeof type !== 'function' &&
typeof type !== 'symbol' &&
typeof type !== 'boolean'
) {
if (__DEV__) {
checkAttributeStringCoercion(type, 'type');
}
node.type = type;
}
if (value != null || defaultValue != null) {
const isButton = type === 'submit' || type === 'reset';
// Avoid setting value attribute on submit/reset inputs as it overrides the
// default value provided by the browser. See: #12872
if (isButton && (value === undefined || value === null)) {
return;
}
const defaultValueStr =
defaultValue != null ? toString(getToStringValue(defaultValue)) : '';
const initialValue =
value != null ? toString(getToStringValue(value)) : defaultValueStr;
// Do not assign value if it is already set. This prevents user text input
// from being lost during SSR hydration.
if (!isHydrating) {
if (disableInputAttributeSyncing) {
// When not syncing the value attribute, the value property points
// directly to the React prop. Only assign it if it exists.
if (value != null) {
// Always assign on buttons so that it is possible to assign an
// empty string to clear button text.
//
// Otherwise, do not re-assign the value property if is empty. This
// potentially avoids a DOM write and prevents Firefox (~60.0.1) from
// prematurely marking required inputs as invalid. Equality is compared
// to the current value in case the browser provided value is not an
// empty string.
if (isButton || toString(getToStringValue(value)) !== node.value) {
node.value = toString(getToStringValue(value));
}
}
} else {
// When syncing the value attribute, the value property should use
// the wrapperState._initialValue property. This uses:
//
// 1. The value React property when present
// 2. The defaultValue React property when present
// 3. An empty string
if (initialValue !== node.value) {
node.value = initialValue;
}
}
}
if (disableInputAttributeSyncing) {
// When not syncing the value attribute, assign the value attribute
// directly from the defaultValue React property (when present)
if (defaultValue != null) {
node.defaultValue = defaultValueStr;
}
} else {
// Otherwise, the value attribute is synchronized to the property,
// so we assign defaultValue to the same thing as the value property
// assignment step above.
node.defaultValue = initialValue;
}
}
// 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.
const checkedOrDefault = checked != null ? checked : defaultChecked;
// TODO: This 'function' or 'symbol' check isn't replicated in other places
// so this semantic is inconsistent.
const initialChecked =
typeof checkedOrDefault !== 'function' &&
typeof checkedOrDefault !== 'symbol' &&
!!checkedOrDefault;
if (isHydrating) {
// Detach .checked from .defaultChecked but leave user input alone
node.checked = node.checked;
} else {
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 (defaultChecked != null) {
node.defaultChecked = !node.defaultChecked;
node.defaultChecked = !!defaultChecked;
}
} else {
// When syncing the checked attribute, both the checked property and
// attribute are assigned at the same time using defaultChecked. This uses:
//
// 1. The checked React property when present
// 2. The defaultChecked React property when present
// 3. Otherwise, false
node.defaultChecked = !node.defaultChecked;
node.defaultChecked = !!initialChecked;
}
// Name needs to be set at the end so that it applies atomically to connected radio buttons.
if (
name != null &&
typeof name !== 'function' &&
typeof name !== 'symbol' &&
typeof name !== 'boolean'
) {
if (__DEV__) {
checkAttributeStringCoercion(name, 'name');
}
node.name = name;
}
}
export function restoreControlledInputState(element: Element, props: Object) {
const rootNode: HTMLInputElement = (element: any);
updateInput(
rootNode,
props.value,
props.defaultValue,
props.defaultValue,
props.checked,
props.defaultChecked,
props.type,
props.name,
);
const name = props.name;
if (props.type === 'radio' && name != null) {
let queryRoot: Element = rootNode;
while (queryRoot.parentNode) {
queryRoot = ((queryRoot.parentNode: any): Element);
}
// 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.
if (__DEV__) {
checkAttributeStringCoercion(name, 'name');
}
const group = queryRoot.querySelectorAll(
'input[name="' +
escapeSelectorAttributeValueInsideDoubleQuotes('' + name) +
'"][type="radio"]',
);
for (let i = 0; i < group.length; i++) {
const otherNode = ((group[i]: any): HTMLInputElement);
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.
const otherProps: any = getFiberCurrentPropsFromNode(otherNode);
if (!otherProps) {
throw new Error(
'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.
updateInput(
otherNode,
otherProps.value,
otherProps.defaultValue,
otherProps.defaultValue,
otherProps.checked,
otherProps.defaultChecked,
otherProps.type,
otherProps.name,
);
}
// If any updateInput() call set .checked to true, an input in this group
// (often, `rootNode` itself) may have become unchecked
for (let i = 0; i < group.length; i++) {
const otherNode = ((group[i]: any): HTMLInputElement);
if (otherNode.form !== rootNode.form) {
continue;
}
updateValueIfChanged(otherNode);
}
}
}
// 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 <x> 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
export function setDefaultValue(
node: HTMLInputElement,
type: ?string,
value: ToStringValue,
) {
if (
// Focused number inputs synchronize on blur. See ChangeEventPlugin.js
type !== 'number' ||
getActiveElement(node.ownerDocument) !== node
) {
if (node.defaultValue !== toString(value)) {
node.defaultValue = toString(value);
}
}
}