main
js 484 lines 17.1 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 *
7 * @flow
8 */
9
10 // TODO: direct imports like some-package/src/* are bad. Fix me.
11 import {getCurrentFiberOwnerNameInDevOrNull} from 'react-reconciler/src/ReactCurrentFiber';
12
13 import {getFiberCurrentPropsFromNode} from './ReactDOMComponentTree';
14 import {getToStringValue, toString} from './ToStringValue';
15 import {track, trackHydrated, updateValueIfChanged} from './inputValueTracking';
16 import {
17 disableInputAttributeSyncing,
18 enableHydrationChangeEvent,
19 } from 'shared/ReactFeatureFlags';
20 import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion';
21
22 import type {ToStringValue} from './ToStringValue';
23 import escapeSelectorAttributeValueInsideDoubleQuotes from './escapeSelectorAttributeValueInsideDoubleQuotes';
24 import {queueChangeEvent} from '../events/ReactDOMEventReplaying';
25
26 let didWarnValueDefaultValue = false;
27 let didWarnCheckedDefaultChecked = false;
28
29 /**
30 * Implements an <input> host component that allows setting these optional
31 * props: `checked`, `value`, `defaultChecked`, and `defaultValue`.
32 *
33 * If `checked` or `value` are not supplied (or null/undefined), user actions
34 * that affect the checked state or value will trigger updates to the element.
35 *
36 * If they are supplied (and not null/undefined), the rendered element will not
37 * trigger updates to the element. Instead, the props must change in order for
38 * the rendered element to be updated.
39 *
40 * The rendered element will be initialized as unchecked (or `defaultChecked`)
41 * with an empty value (or `defaultValue`).
42 *
43 * See http://www.w3.org/TR/2012/WD-html5-20121025/the-input-element.html
44 */
45
46 export function validateInputProps(element: Element, props: Object) {
47 if (__DEV__) {
48 // Normally we check for undefined and null the same, but explicitly specifying both
49 // properties, at all is probably worth warning for. We could move this either direction
50 // and just make it ok to pass null or just check hasOwnProperty.
51 if (
52 props.checked !== undefined &&
53 props.defaultChecked !== undefined &&
54 !didWarnCheckedDefaultChecked
55 ) {
56 console.error(
57 '%s contains an input of type %s with both checked and defaultChecked props. ' +
58 'Input elements must be either controlled or uncontrolled ' +
59 '(specify either the checked prop, or the defaultChecked prop, but not ' +
60 'both). Decide between using a controlled or uncontrolled input ' +
61 'element and remove one of these props. More info: ' +
62 'https://react.dev/link/controlled-components',
63 getCurrentFiberOwnerNameInDevOrNull() || 'A component',
64 props.type,
65 );
66 didWarnCheckedDefaultChecked = true;
67 }
68 if (
69 props.value !== undefined &&
70 props.defaultValue !== undefined &&
71 !didWarnValueDefaultValue
72 ) {
73 console.error(
74 '%s contains an input of type %s with both value and defaultValue props. ' +
75 'Input elements must be either controlled or uncontrolled ' +
76 '(specify either the value prop, or the defaultValue prop, but not ' +
77 'both). Decide between using a controlled or uncontrolled input ' +
78 'element and remove one of these props. More info: ' +
79 'https://react.dev/link/controlled-components',
80 getCurrentFiberOwnerNameInDevOrNull() || 'A component',
81 props.type,
82 );
83 didWarnValueDefaultValue = true;
84 }
85 }
86 }
87
88 export function updateInput(
89 element: Element,
90 value: ?string,
91 defaultValue: ?string,
92 lastDefaultValue: ?string,
93 checked: ?boolean,
94 defaultChecked: ?boolean,
95 type: ?string,
96 name: ?string,
97 ) {
98 const node: HTMLInputElement = element as any;
99
100 // Temporarily disconnect the input from any radio buttons.
101 // Changing the type or name as the same time as changing the checked value
102 // needs to be atomically applied. We can only ensure that by disconnecting
103 // the name while do the mutations and then reapply the name after that's done.
104 node.name = '';
105
106 if (
107 type != null &&
108 typeof type !== 'function' &&
109 typeof type !== 'symbol' &&
110 typeof type !== 'boolean'
111 ) {
112 if (__DEV__) {
113 checkAttributeStringCoercion(type, 'type');
114 }
115 node.type = type;
116 } else {
117 node.removeAttribute('type');
118 }
119
120 if (value != null) {
121 if (type === 'number') {
122 if (
123 // "" == 0, so a cleared field wouldn't otherwise be restored to 0.
124 // $FlowFixMe[incompatible-type]
125 // $FlowFixMe[invalid-compare]
126 (value === 0 && node.value === '') ||
127 // We explicitly want to coerce to number here if possible, so that
128 // other spellings of the same number (e.g. "0.0" mid-edit) aren't
129 // clobbered while the user types.
130 // eslint-disable-next-line
131 node.value != (value as any)
132 ) {
133 node.value = toString(getToStringValue(value));
134 }
135 } else if (node.value !== toString(getToStringValue(value))) {
136 node.value = toString(getToStringValue(value));
137 }
138 } else if (type === 'submit' || type === 'reset') {
139 // Submit/reset inputs need the attribute removed completely to avoid
140 // blank-text buttons.
141 node.removeAttribute('value');
142 }
143
144 if (disableInputAttributeSyncing) {
145 // When not syncing the value attribute, React only assigns a new value
146 // whenever the defaultValue React prop has changed. When not present,
147 // React does nothing
148 if (defaultValue != null) {
149 setDefaultValue(node, getToStringValue(defaultValue));
150 } else if (lastDefaultValue != null) {
151 node.removeAttribute('value');
152 }
153 } else {
154 // When syncing the value attribute, the value comes from a cascade of
155 // properties:
156 // 1. The value React property
157 // 2. The defaultValue React property
158 // 3. Otherwise there should be no change
159 if (value != null) {
160 if (
161 type === 'number' &&
162 // We explicitly want to coerce to number here if possible.
163 // eslint-disable-next-line
164 node.value == (value as any)
165 ) {
166 // node.value may be a different spelling of the same number (e.g.
167 // "0.0" for 0). Mirror what's displayed, like the value setter does.
168 // Not redundant with the assignment above: browsers sanitize invalid
169 // assigned values to "", in which case we sync the React value below.
170 setDefaultValue(node, getToStringValue(node.value));
171 } else {
172 setDefaultValue(node, getToStringValue(value));
173 }
174 } else if (defaultValue != null) {
175 setDefaultValue(node, getToStringValue(defaultValue));
176 } else if (lastDefaultValue != null) {
177 node.removeAttribute('value');
178 }
179 }
180
181 if (disableInputAttributeSyncing) {
182 // When not syncing the checked attribute, the attribute is directly
183 // controllable from the defaultValue React property. It needs to be
184 // updated as new props come in.
185 if (defaultChecked == null) {
186 node.removeAttribute('checked');
187 } else {
188 node.defaultChecked = !!defaultChecked;
189 }
190 } else {
191 // When syncing the checked attribute, it only changes when it needs
192 // to be removed, such as transitioning from a checkbox into a text input
193 if (checked == null && defaultChecked != null) {
194 node.defaultChecked = !!defaultChecked;
195 }
196 }
197
198 if (checked != null) {
199 // Important to set this even if it's not a change in order to update input
200 // value tracking with radio buttons
201 // TODO: Should really update input value tracking for the whole radio
202 // button group in an effect or something (similar to #27024)
203 node.checked =
204 checked && typeof checked !== 'function' && typeof checked !== 'symbol';
205 }
206
207 if (
208 name != null &&
209 typeof name !== 'function' &&
210 typeof name !== 'symbol' &&
211 typeof name !== 'boolean'
212 ) {
213 if (__DEV__) {
214 checkAttributeStringCoercion(name, 'name');
215 }
216 node.name = toString(getToStringValue(name));
217 } else {
218 node.removeAttribute('name');
219 }
220 }
221
222 export function initInput(
223 element: Element,
224 value: ?string,
225 defaultValue: ?string,
226 checked: ?boolean,
227 defaultChecked: ?boolean,
228 type: ?string,
229 name: ?string,
230 isHydrating: boolean,
231 ) {
232 const node: HTMLInputElement = element as any;
233
234 if (
235 type != null &&
236 typeof type !== 'function' &&
237 typeof type !== 'symbol' &&
238 typeof type !== 'boolean'
239 ) {
240 if (__DEV__) {
241 checkAttributeStringCoercion(type, 'type');
242 }
243 node.type = type;
244 }
245
246 if (value != null || defaultValue != null) {
247 const isButton = type === 'submit' || type === 'reset';
248
249 // Avoid setting value attribute on submit/reset inputs as it overrides the
250 // default value provided by the browser. See: #12872
251 if (isButton && (value === undefined || value === null)) {
252 // We track the value just in case it changes type later on.
253 track(element as any);
254 return;
255 }
256
257 const defaultValueStr =
258 defaultValue != null ? toString(getToStringValue(defaultValue)) : '';
259 const initialValue =
260 value != null ? toString(getToStringValue(value)) : defaultValueStr;
261
262 // Do not assign value if it is already set. This prevents user text input
263 // from being lost during SSR hydration.
264 if (!isHydrating || enableHydrationChangeEvent) {
265 if (disableInputAttributeSyncing) {
266 // When not syncing the value attribute, the value property points
267 // directly to the React prop. Only assign it if it exists.
268 if (value != null) {
269 // Always assign on buttons so that it is possible to assign an
270 // empty string to clear button text.
271 //
272 // Otherwise, do not re-assign the value property if is empty. This
273 // potentially avoids a DOM write and prevents Firefox (~60.0.1) from
274 // prematurely marking required inputs as invalid. Equality is compared
275 // to the current value in case the browser provided value is not an
276 // empty string.
277 if (isButton || toString(getToStringValue(value)) !== node.value) {
278 node.value = toString(getToStringValue(value));
279 }
280 }
281 } else {
282 // When syncing the value attribute, the value property should use
283 // the wrapperState._initialValue property. This uses:
284 //
285 // 1. The value React property when present
286 // 2. The defaultValue React property when present
287 // 3. An empty string
288 if (initialValue !== node.value) {
289 node.value = initialValue;
290 }
291 }
292 }
293
294 if (disableInputAttributeSyncing) {
295 // When not syncing the value attribute, assign the value attribute
296 // directly from the defaultValue React property (when present)
297 if (defaultValue != null) {
298 node.defaultValue = defaultValueStr;
299 }
300 } else {
301 // Otherwise, the value attribute is synchronized to the property,
302 // so we assign defaultValue to the same thing as the value property
303 // assignment step above.
304 node.defaultValue = initialValue;
305 }
306 }
307
308 // Normally, we'd just do `node.checked = node.checked` upon initial mount, less this bug
309 // this is needed to work around a chrome bug where setting defaultChecked
310 // will sometimes influence the value of checked (even after detachment).
311 // Reference: https://bugs.chromium.org/p/chromium/issues/detail?id=608416
312 // We need to temporarily unset name to avoid disrupting radio button groups.
313
314 const checkedOrDefault = checked != null ? checked : defaultChecked;
315 // TODO: This 'function' or 'symbol' check isn't replicated in other places
316 // so this semantic is inconsistent.
317 const initialChecked =
318 typeof checkedOrDefault !== 'function' &&
319 typeof checkedOrDefault !== 'symbol' &&
320 !!checkedOrDefault;
321
322 if (isHydrating && !enableHydrationChangeEvent) {
323 // Detach .checked from .defaultChecked but leave user input alone
324 node.checked = node.checked;
325 } else {
326 node.checked = !!initialChecked;
327 }
328
329 if (disableInputAttributeSyncing) {
330 // Only assign the checked attribute if it is defined. This saves
331 // a DOM write when controlling the checked attribute isn't needed
332 // (text inputs, submit/reset)
333 if (defaultChecked != null) {
334 node.defaultChecked = !node.defaultChecked;
335 node.defaultChecked = !!defaultChecked;
336 }
337 } else {
338 // When syncing the checked attribute, both the checked property and
339 // attribute are assigned at the same time using defaultChecked. This uses:
340 //
341 // 1. The checked React property when present
342 // 2. The defaultChecked React property when present
343 // 3. Otherwise, false
344 node.defaultChecked = !node.defaultChecked;
345 node.defaultChecked = !!initialChecked;
346 }
347
348 // Name needs to be set at the end so that it applies atomically to connected radio buttons.
349 if (
350 name != null &&
351 typeof name !== 'function' &&
352 typeof name !== 'symbol' &&
353 typeof name !== 'boolean'
354 ) {
355 if (__DEV__) {
356 checkAttributeStringCoercion(name, 'name');
357 }
358 node.name = name;
359 }
360 track(element as any);
361 }
362
363 export function hydrateInput(
364 element: Element,
365 value: ?string,
366 defaultValue: ?string,
367 checked: ?boolean,
368 defaultChecked: ?boolean,
369 ): void {
370 const node: HTMLInputElement = element as any;
371
372 const defaultValueStr =
373 defaultValue != null ? toString(getToStringValue(defaultValue)) : '';
374 const initialValue =
375 value != null ? toString(getToStringValue(value)) : defaultValueStr;
376
377 const checkedOrDefault = checked != null ? checked : defaultChecked;
378 // TODO: This 'function' or 'symbol' check isn't replicated in other places
379 // so this semantic is inconsistent.
380 const initialChecked =
381 typeof checkedOrDefault !== 'function' &&
382 typeof checkedOrDefault !== 'symbol' &&
383 !!checkedOrDefault;
384
385 // Detach .checked from .defaultChecked but leave user input alone
386 node.checked = node.checked;
387
388 const changed = trackHydrated(node as any, initialValue, initialChecked);
389 if (changed) {
390 // If the current value is different, that suggests that the user
391 // changed it before hydration. Queue a replay of the change event.
392 // For radio buttons the change event only fires on the selected one.
393 if (node.type !== 'radio' || node.checked) {
394 queueChangeEvent(node);
395 }
396 }
397 }
398
399 export function restoreControlledInputState(element: Element, props: Object) {
400 const rootNode: HTMLInputElement = element as any;
401 updateInput(
402 rootNode,
403 props.value,
404 props.defaultValue,
405 props.defaultValue,
406 props.checked,
407 props.defaultChecked,
408 props.type,
409 props.name,
410 );
411 const name = props.name;
412 if (props.type === 'radio' && name != null) {
413 let queryRoot: Element = rootNode;
414
415 while (queryRoot.parentNode) {
416 queryRoot = queryRoot.parentNode as any as Element;
417 }
418
419 // If `rootNode.form` was non-null, then we could try `form.elements`,
420 // but that sometimes behaves strangely in IE8. We could also try using
421 // `form.getElementsByName`, but that will only return direct children
422 // and won't include inputs that use the HTML5 `form=` attribute. Since
423 // the input might not even be in a form. It might not even be in the
424 // document. Let's just use the local `querySelectorAll` to ensure we don't
425 // miss anything.
426 if (__DEV__) {
427 checkAttributeStringCoercion(name, 'name');
428 }
429 const group = queryRoot.querySelectorAll(
430 'input[name="' +
431 escapeSelectorAttributeValueInsideDoubleQuotes('' + name) +
432 '"][type="radio"]',
433 );
434
435 for (let i = 0; i < group.length; i++) {
436 const otherNode = group[i] as any as HTMLInputElement;
437 if (otherNode === rootNode || otherNode.form !== rootNode.form) {
438 continue;
439 }
440 // This will throw if radio buttons rendered by different copies of React
441 // and the same name are rendered into the same form (same as #1939).
442 // That's probably okay; we don't support it just as we don't support
443 // mixing React radio buttons with non-React ones.
444 const otherProps: any = getFiberCurrentPropsFromNode(otherNode);
445
446 if (!otherProps) {
447 throw new Error(
448 'ReactDOMInput: Mixing React and non-React radio inputs with the ' +
449 'same `name` is not supported.',
450 );
451 }
452
453 // If this is a controlled radio button group, forcing the input that
454 // was previously checked to update will cause it to be come re-checked
455 // as appropriate.
456 updateInput(
457 otherNode,
458 otherProps.value,
459 otherProps.defaultValue,
460 otherProps.defaultValue,
461 otherProps.checked,
462 otherProps.defaultChecked,
463 otherProps.type,
464 otherProps.name,
465 );
466 }
467
468 // If any updateInput() call set .checked to true, an input in this group
469 // (often, `rootNode` itself) may have become unchecked
470 for (let i = 0; i < group.length; i++) {
471 const otherNode = group[i] as any as HTMLInputElement;
472 if (otherNode.form !== rootNode.form) {
473 continue;
474 }
475 updateValueIfChanged(otherNode);
476 }
477 }
478 }
479
480 function setDefaultValue(node: HTMLInputElement, value: ToStringValue) {
481 if (node.defaultValue !== toString(value)) {
482 node.defaultValue = toString(value);
483 }
484 }