/** * 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 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 && node.checked !== !!checked) { node.checked = checked; } 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; // 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 = !!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.', ); } // We need update the tracked value on the named cousin since the value // was changed but the input saw no event or value set updateValueIfChanged(otherNode); // 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, ); } } } // 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 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); } } }