@samitouri / QOS-React-2 / commits / e5146cb525

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.

Sebastian Markbåge committed Apr 9, 2023 at 18:06 UTC e5146cb5250be1a4e66511af91549859b36ed488
11 files changed +299 -267
packages/react-dom-bindings/src/client/ReactDOMComponent.js
+102 -46
@@ -7,8 +7,6 @@
7 * @flow
8 */
9
10 -import type {InputWithWrapperState} from './ReactDOMInput';
11 -
10 import {
11 registrationNameDependencies,
12 possibleRegistrationNames,
@@ -17,6 +15,7 @@ import {
15 import {canUseDOM} from 'shared/ExecutionEnvironment';
16 import {checkHtmlStringCoercion} from 'shared/CheckStringCoercion';
17 import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion';
18 +import {checkControlledValueProps} from '../shared/ReactControlledValuePropTypes';
19
20 import {
21 getValueForAttribute,
@@ -27,27 +26,24 @@ import {
26 setValueForNamespacedAttribute,
27 } from './DOMPropertyOperations';
28 import {
30 - initWrapperState as ReactDOMInputInitWrapperState,
31 - postMountWrapper as ReactDOMInputPostMountWrapper,
32 - updateChecked as ReactDOMInputUpdateChecked,
33 - updateWrapper as ReactDOMInputUpdateWrapper,
34 - restoreControlledState as ReactDOMInputRestoreControlledState,
29 + validateInputProps,
30 + initInput,
31 + updateInputChecked,
32 + updateInput,
33 + restoreControlledInputState,
34 } from './ReactDOMInput';
35 +import {initOption, validateOptionProps} from './ReactDOMOption';
36 import {
37 - postMountWrapper as ReactDOMOptionPostMountWrapper,
38 - validateProps as ReactDOMOptionValidateProps,
39 -} from './ReactDOMOption';
40 -import {
41 - initWrapperState as ReactDOMSelectInitWrapperState,
42 - postMountWrapper as ReactDOMSelectPostMountWrapper,
43 - restoreControlledState as ReactDOMSelectRestoreControlledState,
44 - postUpdateWrapper as ReactDOMSelectPostUpdateWrapper,
37 + validateSelectProps,
38 + initSelect,
39 + restoreControlledSelectState,
40 + updateSelect,
41 } from './ReactDOMSelect';
42 import {
47 - initWrapperState as ReactDOMTextareaInitWrapperState,
48 - postMountWrapper as ReactDOMTextareaPostMountWrapper,
49 - updateWrapper as ReactDOMTextareaUpdateWrapper,
50 - restoreControlledState as ReactDOMTextareaRestoreControlledState,
43 + validateTextareaProps,
44 + initTextarea,
45 + updateTextarea,
46 + restoreControlledTextareaState,
47 } from './ReactDOMTextarea';
48 import {track} from './inputValueTracking';
49 import setInnerHTML from './setInnerHTML';
@@ -79,6 +75,8 @@ import {
75 listenToNonDelegatedEvent,
76 } from '../events/DOMPluginEventSystem';
77
78 +let didWarnControlledToUncontrolled = false;
79 +let didWarnUncontrolledToControlled = false;
80 let didWarnInvalidHydration = false;
81 let canDiffStyleForHydrationWarning;
82 if (__DEV__) {
@@ -805,7 +803,9 @@ export function setInitialProperties(
803 break;
804 }
805 case 'input': {
808 - ReactDOMInputInitWrapperState(domElement, props);
806 + if (__DEV__) {
807 + checkControlledValueProps('input', props);
808 + }
809 // We listen to this event in case to ensure emulated bubble
810 // listeners still fire for the invalid event.
811 listenToNonDelegatedEvent('invalid', domElement);
@@ -834,10 +834,10 @@ export function setInitialProperties(
834 break;
835 }
836 case 'checked': {
837 - const node = ((domElement: any): InputWithWrapperState);
837 const checked =
839 - propValue != null ? propValue : node._wrapperState.initialChecked;
840 - node.checked =
838 + propValue != null ? propValue : props.defaultChecked;
839 + const inputElement: HTMLInputElement = (domElement: any);
840 + inputElement.checked =
841 !!checked &&
842 typeof checked !== 'function' &&
843 checked !== 'symbol';
@@ -866,11 +866,14 @@ export function setInitialProperties(
866 // TODO: Make sure we check if this is still unmounted or do any clean
867 // up necessary since we never stop tracking anymore.
868 track((domElement: any));
869 - ReactDOMInputPostMountWrapper(domElement, props, false);
869 + validateInputProps(domElement, props);
870 + initInput(domElement, props, false);
871 return;
872 }
873 case 'select': {
873 - ReactDOMSelectInitWrapperState(domElement, props);
874 + if (__DEV__) {
875 + checkControlledValueProps('select', props);
876 + }
877 // We listen to this event in case to ensure emulated bubble
878 // listeners still fire for the invalid event.
879 listenToNonDelegatedEvent('invalid', domElement);
@@ -893,11 +896,14 @@ export function setInitialProperties(
896 }
897 }
898 }
896 - ReactDOMSelectPostMountWrapper(domElement, props);
899 + validateSelectProps(domElement, props);
900 + initSelect(domElement, props);
901 return;
902 }
903 case 'textarea': {
900 - ReactDOMTextareaInitWrapperState(domElement, props);
904 + if (__DEV__) {
905 + checkControlledValueProps('textarea', props);
906 + }
907 // We listen to this event in case to ensure emulated bubble
908 // listeners still fire for the invalid event.
909 listenToNonDelegatedEvent('invalid', domElement);
@@ -936,11 +942,12 @@ export function setInitialProperties(
942 // TODO: Make sure we check if this is still unmounted or do any clean
943 // up necessary since we never stop tracking anymore.
944 track((domElement: any));
939 - ReactDOMTextareaPostMountWrapper(domElement, props);
945 + validateTextareaProps(domElement, props);
946 + initTextarea(domElement, props);
947 return;
948 }
949 case 'option': {
943 - ReactDOMOptionValidateProps(domElement, props);
950 + validateOptionProps(domElement, props);
951 for (const propKey in props) {
952 if (!props.hasOwnProperty(propKey)) {
953 continue;
@@ -963,7 +970,7 @@ export function setInitialProperties(
970 }
971 }
972 }
966 - ReactDOMOptionPostMountWrapper(domElement, props);
973 + initOption(domElement, props);
974 return;
975 }
976 case 'dialog': {
@@ -1213,17 +1220,17 @@ export function updateProperties(
1220 // In the middle of an update, it is possible to have multiple checked.
1221 // When a checked radio tries to change name, browser makes another radio's checked false.
1222 if (nextProps.type === 'radio' && nextProps.name != null) {
1216 - ReactDOMInputUpdateChecked(domElement, nextProps);
1223 + updateInputChecked(domElement, nextProps);
1224 }
1225 for (let i = 0; i < updatePayload.length; i += 2) {
1226 const propKey = updatePayload[i];
1227 const propValue = updatePayload[i + 1];
1228 switch (propKey) {
1229 case 'checked': {
1223 - const node = ((domElement: any): InputWithWrapperState);
1230 const checked =
1225 - propValue != null ? propValue : node._wrapperState.initialChecked;
1226 - node.checked =
1231 + propValue != null ? propValue : nextProps.defaultChecked;
1232 + const inputElement: HTMLInputElement = (domElement: any);
1233 + inputElement.checked =
1234 !!checked &&
1235 typeof checked !== 'function' &&
1236 checked !== 'symbol';
@@ -1249,10 +1256,50 @@ export function updateProperties(
1256 }
1257 }
1258 }
1259 +
1260 + if (__DEV__) {
1261 + const wasControlled =
1262 + lastProps.type === 'checkbox' || lastProps.type === 'radio'
1263 + ? lastProps.checked != null
1264 + : lastProps.value != null;
1265 + const isControlled =
1266 + nextProps.type === 'checkbox' || nextProps.type === 'radio'
1267 + ? nextProps.checked != null
1268 + : nextProps.value != null;
1269 +
1270 + if (
1271 + !wasControlled &&
1272 + isControlled &&
1273 + !didWarnUncontrolledToControlled
1274 + ) {
1275 + console.error(
1276 + 'A component is changing an uncontrolled input to be controlled. ' +
1277 + 'This is likely caused by the value changing from undefined to ' +
1278 + 'a defined value, which should not happen. ' +
1279 + 'Decide between using a controlled or uncontrolled input ' +
1280 + 'element for the lifetime of the component. More info: https://reactjs.org/link/controlled-components',
1281 + );
1282 + didWarnUncontrolledToControlled = true;
1283 + }
1284 + if (
1285 + wasControlled &&
1286 + !isControlled &&
1287 + !didWarnControlledToUncontrolled
1288 + ) {
1289 + console.error(
1290 + 'A component is changing a controlled input to be uncontrolled. ' +
1291 + 'This is likely caused by the value changing from a defined to ' +
1292 + 'undefined, which should not happen. ' +
1293 + 'Decide between using a controlled or uncontrolled input ' +
1294 + 'element for the lifetime of the component. More info: https://reactjs.org/link/controlled-components',
1295 + );
1296 + didWarnControlledToUncontrolled = true;
1297 + }
1298 + }
1299 // Update the wrapper around inputs *after* updating props. This has to
1300 // happen after updating the rest of props. Otherwise HTML5 input validations
1301 // raise warnings and prevent the new value from being assigned.
1255 - ReactDOMInputUpdateWrapper(domElement, nextProps);
1302 + updateInput(domElement, nextProps);
1303 return;
1304 }
1305 case 'select': {
@@ -1272,7 +1319,7 @@ export function updateProperties(
1319 }
1320 // <select> value update needs to occur after <option> children
1321 // reconciliation
1275 - ReactDOMSelectPostUpdateWrapper(domElement, nextProps);
1322 + updateSelect(domElement, lastProps, nextProps);
1323 return;
1324 }
1325 case 'textarea': {
@@ -1303,7 +1350,7 @@ export function updateProperties(
1350 }
1351 }
1352 }
1306 - ReactDOMTextareaUpdateWrapper(domElement, nextProps);
1353 + updateTextarea(domElement, nextProps);
1354 return;
1355 }
1356 case 'option': {
@@ -2263,38 +2310,47 @@ export function diffHydratedProperties(
2310 listenToNonDelegatedEvent('toggle', domElement);
2311 break;
2312 case 'input':
2266 - ReactDOMInputInitWrapperState(domElement, props);
2313 + if (__DEV__) {
2314 + checkControlledValueProps('input', props);
2315 + }
2316 // We listen to this event in case to ensure emulated bubble
2317 // listeners still fire for the invalid event.
2318 listenToNonDelegatedEvent('invalid', domElement);
2319 // TODO: Make sure we check if this is still unmounted or do any clean
2320 // up necessary since we never stop tracking anymore.
2321 track((domElement: any));
2322 + validateInputProps(domElement, props);
2323 // For input and textarea we current always set the value property at
2324 // post mount to force it to diverge from attributes. However, for
2325 // option and select we don't quite do the same thing and select
2326 // is not resilient to the DOM state changing so we don't do that here.
2327 // TODO: Consider not doing this for input and textarea.
2278 - ReactDOMInputPostMountWrapper(domElement, props, true);
2328 + initInput(domElement, props, true);
2329 break;
2330 case 'option':
2281 - ReactDOMOptionValidateProps(domElement, props);
2331 + validateOptionProps(domElement, props);
2332 break;
2333 case 'select':
2284 - ReactDOMSelectInitWrapperState(domElement, props);
2334 + if (__DEV__) {
2335 + checkControlledValueProps('select', props);
2336 + }
2337 // We listen to this event in case to ensure emulated bubble
2338 // listeners still fire for the invalid event.
2339 listenToNonDelegatedEvent('invalid', domElement);
2340 + validateSelectProps(domElement, props);
2341 break;
2342 case 'textarea':
2290 - ReactDOMTextareaInitWrapperState(domElement, props);
2343 + if (__DEV__) {
2344 + checkControlledValueProps('textarea', props);
2345 + }
2346 // We listen to this event in case to ensure emulated bubble
2347 // listeners still fire for the invalid event.
2348 listenToNonDelegatedEvent('invalid', domElement);
2349 // TODO: Make sure we check if this is still unmounted or do any clean
2350 // up necessary since we never stop tracking anymore.
2351 track((domElement: any));
2297 - ReactDOMTextareaPostMountWrapper(domElement, props);
2352 + validateTextareaProps(domElement, props);
2353 + initTextarea(domElement, props);
2354 break;
2355 }
2356
@@ -2472,13 +2528,13 @@ export function restoreControlledState(
2528 ): void {
2529 switch (tag) {
2530 case 'input':
2475 - ReactDOMInputRestoreControlledState(domElement, props);
2531 + restoreControlledInputState(domElement, props);
2532 return;
2533 case 'textarea':
2478 - ReactDOMTextareaRestoreControlledState(domElement, props);
2534 + restoreControlledTextareaState(domElement, props);
2535 return;
2536 case 'select':
2481 - ReactDOMSelectRestoreControlledState(domElement, props);
2537 + restoreControlledSelectState(domElement, props);
2538 return;
2539 }
2540 }
packages/react-dom-bindings/src/client/ReactDOMInput.js
+68 -123
@@ -12,7 +12,6 @@ import {getCurrentFiberOwnerNameInDevOrNull} from 'react-reconciler/src/ReactCur
12
13 import {getFiberCurrentPropsFromNode} from './ReactDOMComponentTree';
14 import {getToStringValue, toString} from './ToStringValue';
15 -import {checkControlledValueProps} from '../shared/ReactControlledValuePropTypes';
15 import {updateValueIfChanged} from './inputValueTracking';
16 import getActiveElement from './getActiveElement';
17 import {disableInputAttributeSyncing} from 'shared/ReactFeatureFlags';
@@ -20,29 +19,8 @@ import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion';
19
20 import type {ToStringValue} from './ToStringValue';
21
23 -export type InputWithWrapperState = HTMLInputElement & {
24 - _wrapperState: {
25 - initialValue: ToStringValue,
26 - initialChecked: ?boolean,
27 - controlled?: boolean,
28 - ...
29 - },
30 - checked: boolean,
31 - value: string,
32 - defaultChecked: boolean,
33 - defaultValue: string,
34 - ...
35 -};
36 -
22 let didWarnValueDefaultValue = false;
23 let didWarnCheckedDefaultChecked = false;
39 -let didWarnControlledToUncontrolled = false;
40 -let didWarnUncontrolledToControlled = false;
41 -
42 -function isControlled(props: any) {
43 - const usesChecked = props.type === 'checkbox' || props.type === 'radio';
44 - return usesChecked ? props.checked != null : props.value != null;
45 -}
24
25 /**
26 * Implements an <input> host component that allows setting these optional
@@ -61,10 +39,11 @@ function isControlled(props: any) {
39 * See http://www.w3.org/TR/2012/WD-html5-20121025/the-input-element.html
40 */
41
64 -export function initWrapperState(element: Element, props: Object) {
42 +export function validateInputProps(element: Element, props: Object) {
43 if (__DEV__) {
66 - checkControlledValueProps('input', props);
67 -
44 + // Normally we check for undefined and null the same, but explicitly specifying both
45 + // properties, at all is probably worth warning for. We could move this either direction
46 + // and just make it ok to pass null or just check hasOwnProperty.
47 if (
48 props.checked !== undefined &&
49 props.defaultChecked !== undefined &&
@@ -100,98 +79,30 @@ export function initWrapperState(element: Element, props: Object) {
79 didWarnValueDefaultValue = true;
80 }
81 }
103 -
104 - const node = ((element: any): InputWithWrapperState);
105 - const defaultValue = props.defaultValue == null ? '' : props.defaultValue;
106 - const initialChecked =
107 - props.checked != null ? props.checked : props.defaultChecked;
108 - node._wrapperState = {
109 - initialChecked:
110 - typeof initialChecked !== 'function' &&
111 - typeof initialChecked !== 'symbol' &&
112 - !!initialChecked,
113 - initialValue: getToStringValue(
114 - props.value != null ? props.value : defaultValue,
115 - ),
116 - controlled: isControlled(props),
117 - };
82 }
83
120 -export function updateChecked(element: Element, props: Object) {
121 - const node = ((element: any): InputWithWrapperState);
84 +export function updateInputChecked(element: Element, props: Object) {
85 + const node: HTMLInputElement = (element: any);
86 const checked = props.checked;
87 if (checked != null) {
88 node.checked = checked;
89 }
90 }
91
128 -export function updateWrapper(element: Element, props: Object) {
129 - const node = ((element: any): InputWithWrapperState);
130 - if (__DEV__) {
131 - const controlled = isControlled(props);
132 -
133 - if (
134 - !node._wrapperState.controlled &&
135 - controlled &&
136 - !didWarnUncontrolledToControlled
137 - ) {
138 - console.error(
139 - 'A component is changing an uncontrolled input to be controlled. ' +
140 - 'This is likely caused by the value changing from undefined to ' +
141 - 'a defined value, which should not happen. ' +
142 - 'Decide between using a controlled or uncontrolled input ' +
143 - 'element for the lifetime of the component. More info: https://reactjs.org/link/controlled-components',
144 - );
145 - didWarnUncontrolledToControlled = true;
146 - }
147 - if (
148 - node._wrapperState.controlled &&
149 - !controlled &&
150 - !didWarnControlledToUncontrolled
151 - ) {
152 - console.error(
153 - 'A component is changing a controlled input to be uncontrolled. ' +
154 - 'This is likely caused by the value changing from a defined to ' +
155 - 'undefined, which should not happen. ' +
156 - 'Decide between using a controlled or uncontrolled input ' +
157 - 'element for the lifetime of the component. More info: https://reactjs.org/link/controlled-components',
158 - );
159 - didWarnControlledToUncontrolled = true;
160 - }
161 - }
162 -
163 - updateChecked(element, props);
92 +export function updateInput(element: Element, props: Object) {
93 + const node: HTMLInputElement = (element: any);
94
95 const value = getToStringValue(props.value);
96 const type = props.type;
97
168 - if (value != null) {
169 - if (type === 'number') {
170 - if (
171 - // $FlowFixMe[incompatible-type]
172 - (value === 0 && node.value === '') ||
173 - // We explicitly want to coerce to number here if possible.
174 - // eslint-disable-next-line
175 - node.value != (value: any)
176 - ) {
177 - node.value = toString((value: any));
178 - }
179 - } else if (node.value !== toString((value: any))) {
180 - node.value = toString((value: any));
181 - }
182 - } else if (type === 'submit' || type === 'reset') {
183 - // Submit/reset inputs need the attribute removed completely to avoid
184 - // blank-text buttons.
185 - node.removeAttribute('value');
186 - return;
187 - }
188 -
98 if (disableInputAttributeSyncing) {
99 // When not syncing the value attribute, React only assigns a new value
100 // whenever the defaultValue React prop has changed. When not present,
101 // React does nothing
193 - if (props.hasOwnProperty('defaultValue')) {
102 + if (props.defaultValue != null) {
103 setDefaultValue(node, props.type, getToStringValue(props.defaultValue));
104 + } else {
105 + node.removeAttribute('value');
106 }
107 } else {
108 // When syncing the value attribute, the value comes from a cascade of
@@ -199,10 +110,12 @@ export function updateWrapper(element: Element, props: Object) {
110 // 1. The value React property
111 // 2. The defaultValue React property
112 // 3. Otherwise there should be no change
202 - if (props.hasOwnProperty('value')) {
113 + if (props.value != null) {
114 setDefaultValue(node, props.type, value);
204 - } else if (props.hasOwnProperty('defaultValue')) {
115 + } else if (props.defaultValue != null) {
116 setDefaultValue(node, props.type, getToStringValue(props.defaultValue));
117 + } else {
118 + node.removeAttribute('value');
119 }
120 }
121
@@ -222,18 +135,39 @@ export function updateWrapper(element: Element, props: Object) {
135 node.defaultChecked = !!props.defaultChecked;
136 }
137 }
138 +
139 + updateInputChecked(element, props);
140 +
141 + if (value != null) {
142 + if (type === 'number') {
143 + if (
144 + // $FlowFixMe[incompatible-type]
145 + (value === 0 && node.value === '') ||
146 + // We explicitly want to coerce to number here if possible.
147 + // eslint-disable-next-line
148 + node.value != (value: any)
149 + ) {
150 + node.value = toString((value: any));
151 + }
152 + } else if (node.value !== toString((value: any))) {
153 + node.value = toString((value: any));
154 + }
155 + } else if (type === 'submit' || type === 'reset') {
156 + // Submit/reset inputs need the attribute removed completely to avoid
157 + // blank-text buttons.
158 + node.removeAttribute('value');
159 + return;
160 + }
161 }
162
227 -export function postMountWrapper(
163 +export function initInput(
164 element: Element,
165 props: Object,
166 isHydrating: boolean,
167 ) {
232 - const node = ((element: any): InputWithWrapperState);
168 + const node: HTMLInputElement = (element: any);
169
234 - // Do not assign value if it is already set. This prevents user text input
235 - // from being lost during SSR hydration.
236 - if (props.hasOwnProperty('value') || props.hasOwnProperty('defaultValue')) {
170 + if (props.value != null || props.defaultValue != null) {
171 const type = props.type;
172 const isButton = type === 'submit' || type === 'reset';
173
@@ -243,7 +177,14 @@ export function postMountWrapper(
177 return;
178 }
179
246 - const initialValue = toString(node._wrapperState.initialValue);
180 + const defaultValue =
181 + props.defaultValue != null
182 + ? toString(getToStringValue(props.defaultValue))
183 + : '';
184 + const initialValue =
185 + props.value != null
186 + ? toString(getToStringValue(props.value))
187 + : defaultValue;
188
189 // Do not assign value if it is already set. This prevents user text input
190 // from being lost during SSR hydration.
@@ -282,9 +223,8 @@ export function postMountWrapper(
223 if (disableInputAttributeSyncing) {
224 // When not syncing the value attribute, assign the value attribute
225 // directly from the defaultValue React property (when present)
285 - const defaultValue = getToStringValue(props.defaultValue);
286 - if (defaultValue != null) {
287 - node.defaultValue = toString(defaultValue);
226 + if (props.defaultValue != null) {
227 + node.defaultValue = defaultValue;
228 }
229 } else {
230 // Otherwise, the value attribute is synchronized to the property,
@@ -304,20 +244,27 @@ export function postMountWrapper(
244 node.name = '';
245 }
246
247 + const defaultChecked =
248 + props.checked != null ? props.checked : props.defaultChecked;
249 + const initialChecked =
250 + typeof defaultChecked !== 'function' &&
251 + typeof defaultChecked !== 'symbol' &&
252 + !!defaultChecked;
253 +
254 // The checked property never gets assigned. It must be manually set.
255 // We don't want to do this when hydrating so that existing user input isn't
256 // modified
257 // TODO: I'm pretty sure this is a bug because initialValueTracking won't be
258 // correct for the hydration case then.
259 if (!isHydrating) {
313 - node.checked = !!node._wrapperState.initialChecked;
260 + node.checked = !!initialChecked;
261 }
262
263 if (disableInputAttributeSyncing) {
264 // Only assign the checked attribute if it is defined. This saves
265 // a DOM write when controlling the checked attribute isn't needed
266 // (text inputs, submit/reset)
320 - if (props.hasOwnProperty('defaultChecked')) {
267 + if (props.defaultChecked != null) {
268 node.defaultChecked = !node.defaultChecked;
269 node.defaultChecked = !!props.defaultChecked;
270 }
@@ -329,7 +276,7 @@ export function postMountWrapper(
276 // 2. The defaultChecked React property when present
277 // 3. Otherwise, false
278 node.defaultChecked = !node.defaultChecked;
332 - node.defaultChecked = !!node._wrapperState.initialChecked;
279 + node.defaultChecked = !!initialChecked;
280 }
281
282 if (name !== '') {
@@ -337,13 +284,13 @@ export function postMountWrapper(
284 }
285 }
286
340 -export function restoreControlledState(element: Element, props: Object) {
341 - const node = ((element: any): InputWithWrapperState);
342 - updateWrapper(node, props);
287 +export function restoreControlledInputState(element: Element, props: Object) {
288 + const node: HTMLInputElement = (element: any);
289 + updateInput(node, props);
290 updateNamedCousins(node, props);
291 }
292
346 -function updateNamedCousins(rootNode: InputWithWrapperState, props: any) {
293 +function updateNamedCousins(rootNode: HTMLInputElement, props: any) {
294 const name = props.name;
295 if (props.type === 'radio' && name != null) {
296 let queryRoot: Element = rootNode;
@@ -391,7 +338,7 @@ function updateNamedCousins(rootNode: InputWithWrapperState, props: any) {
338 // If this is a controlled radio button group, forcing the input that
339 // was previously checked to update will cause it to be come re-checked
340 // as appropriate.
394 - updateWrapper(otherNode, otherProps);
341 + updateInput(otherNode, otherProps);
342 }
343 }
344 }
@@ -405,7 +352,7 @@ function updateNamedCousins(rootNode: InputWithWrapperState, props: any) {
352 //
353 // https://github.com/facebook/react/issues/7253
354 export function setDefaultValue(
408 - node: InputWithWrapperState,
355 + node: HTMLInputElement,
356 type: ?string,
357 value: ToStringValue,
358 ) {
@@ -414,9 +361,7 @@ export function setDefaultValue(
361 type !== 'number' ||
362 getActiveElement(node.ownerDocument) !== node
363 ) {
417 - if (value == null) {
418 - node.defaultValue = toString(node._wrapperState.initialValue);
419 - } else if (node.defaultValue !== toString(value)) {
364 + if (node.defaultValue !== toString(value)) {
365 node.defaultValue = toString(value);
366 }
367 }
packages/react-dom-bindings/src/client/ReactDOMOption.js
+2 -2
@@ -18,7 +18,7 @@ let didWarnInvalidInnerHTML = false;
18 * Implements an <option> host component that warns when `selected` is set.
19 */
20
21 -export function validateProps(element: Element, props: Object) {
21 +export function validateOptionProps(element: Element, props: Object) {
22 if (__DEV__) {
23 // If a value is not provided, then the children must be simple.
24 if (props.value == null) {
@@ -60,7 +60,7 @@ export function validateProps(element: Element, props: Object) {
60 }
61 }
62
63 -export function postMountWrapper(element: Element, props: Object) {
63 +export function initOption(element: Element, props: Object) {
64 // value="" should make a value attribute (#6219)
65 if (props.value != null) {
66 element.setAttribute('value', toString(getToStringValue(props.value)));
packages/react-dom-bindings/src/client/ReactDOMSelect.js
+12 -24
@@ -10,7 +10,6 @@
10 // TODO: direct imports like some-package/src/* are bad. Fix me.
11 import {getCurrentFiberOwnerNameInDevOrNull} from 'react-reconciler/src/ReactCurrentFiber';
12
13 -import {checkControlledValueProps} from '../shared/ReactControlledValuePropTypes';
13 import {getToStringValue, toString} from './ToStringValue';
14 import isArray from 'shared/isArray';
15
@@ -20,10 +19,6 @@ if (__DEV__) {
19 didWarnValueDefaultValue = false;
20 }
21
23 -type SelectWithWrapperState = HTMLSelectElement & {
24 - _wrapperState: {wasMultiple: boolean},
25 -};
26 -
22 function getDeclarationErrorAddendum() {
23 const ownerName = getCurrentFiberOwnerNameInDevOrNull();
24 if (ownerName) {
@@ -39,8 +34,6 @@ const valuePropNames = ['value', 'defaultValue'];
34 */
35 function checkSelectPropTypes(props: any) {
36 if (__DEV__) {
42 - checkControlledValueProps('select', props);
43 -
37 for (let i = 0; i < valuePropNames.length; i++) {
38 const propName = valuePropNames[i];
39 if (props[propName] == null) {
@@ -129,17 +122,9 @@ function updateOptions(
122 * selected.
123 */
124
132 -export function initWrapperState(element: Element, props: Object) {
133 - const node = ((element: any): SelectWithWrapperState);
125 +export function validateSelectProps(element: Element, props: Object) {
126 if (__DEV__) {
127 checkSelectPropTypes(props);
136 - }
137 -
138 - node._wrapperState = {
139 - wasMultiple: !!props.multiple,
140 - };
141 -
142 - if (__DEV__) {
128 if (
129 props.value !== undefined &&
130 props.defaultValue !== undefined &&
@@ -157,8 +142,8 @@ export function initWrapperState(element: Element, props: Object) {
142 }
143 }
144
160 -export function postMountWrapper(element: Element, props: Object) {
161 - const node = ((element: any): SelectWithWrapperState);
145 +export function initSelect(element: Element, props: Object) {
146 + const node: HTMLSelectElement = (element: any);
147 node.multiple = !!props.multiple;
148 const value = props.value;
149 if (value != null) {
@@ -168,10 +153,13 @@ export function postMountWrapper(element: Element, props: Object) {
153 }
154 }
155
171 -export function postUpdateWrapper(element: Element, props: Object) {
172 - const node = ((element: any): SelectWithWrapperState);
173 - const wasMultiple = node._wrapperState.wasMultiple;
174 - node._wrapperState.wasMultiple = !!props.multiple;
156 +export function updateSelect(
157 + element: Element,
158 + prevProps: Object,
159 + props: Object,
160 +) {
161 + const node: HTMLSelectElement = (element: any);
162 + const wasMultiple = !!prevProps.multiple;
163
164 const value = props.value;
165 if (value != null) {
@@ -187,8 +175,8 @@ export function postUpdateWrapper(element: Element, props: Object) {
175 }
176 }
177
190 -export function restoreControlledState(element: Element, props: Object) {
191 - const node = ((element: any): SelectWithWrapperState);
178 +export function restoreControlledSelectState(element: Element, props: Object) {
179 + const node: HTMLSelectElement = (element: any);
180 const value = props.value;
181
182 if (value != null) {
packages/react-dom-bindings/src/client/ReactDOMTextarea.js
+41 -45
@@ -9,18 +9,12 @@
9
10 import isArray from 'shared/isArray';
11
12 -import {checkControlledValueProps} from '../shared/ReactControlledValuePropTypes';
12 import {getCurrentFiberOwnerNameInDevOrNull} from 'react-reconciler/src/ReactCurrentFiber';
13 import {getToStringValue, toString} from './ToStringValue';
15 -import type {ToStringValue} from './ToStringValue';
14 import {disableTextareaChildren} from 'shared/ReactFeatureFlags';
15
16 let didWarnValDefaultVal = false;
17
20 -export type TextAreaWithWrapperState = HTMLTextAreaElement & {
21 - _wrapperState: {initialValue: ToStringValue},
22 -};
23 -
18 /**
19 * Implements a <textarea> host component that allows setting `value`, and
20 * `defaultValue`. This differs from the traditional DOM API because value is
@@ -37,10 +31,8 @@ export type TextAreaWithWrapperState = HTMLTextAreaElement & {
31 * `defaultValue` if specified, or the children content (deprecated).
32 */
33
40 -export function initWrapperState(element: Element, props: Object) {
41 - const node = ((element: any): TextAreaWithWrapperState);
34 +export function validateTextareaProps(element: Element, props: Object) {
35 if (__DEV__) {
43 - checkControlledValueProps('textarea', props);
36 if (
37 props.value !== undefined &&
38 props.defaultValue !== undefined &&
@@ -57,7 +49,41 @@ export function initWrapperState(element: Element, props: Object) {
49 );
50 didWarnValDefaultVal = true;
51 }
52 + if (props.children != null && props.value == null) {
53 + console.error(
54 + 'Use the `defaultValue` or `value` props instead of setting ' +
55 + 'children on <textarea>.',
56 + );
57 + }
58 + }
59 +}
60 +
61 +export function updateTextarea(element: Element, props: Object) {
62 + const node: HTMLTextAreaElement = (element: any);
63 + const value = getToStringValue(props.value);
64 + const defaultValue = getToStringValue(props.defaultValue);
65 + if (defaultValue != null) {
66 + node.defaultValue = toString(defaultValue);
67 + } else {
68 + node.defaultValue = '';
69 + }
70 + if (value != null) {
71 + // Cast `value` to a string to ensure the value is set correctly. While
72 + // browsers typically do this as necessary, jsdom doesn't.
73 + const newValue = toString(value);
74 + // To avoid side effects (such as losing text selection), only set value if changed
75 + if (newValue !== node.value) {
76 + node.value = newValue;
77 + }
78 + // TOOO: This should respect disableInputAttributeSyncing flag.
79 + if (props.defaultValue == null && node.defaultValue !== newValue) {
80 + node.defaultValue = newValue;
81 + }
82 }
83 +}
84 +
85 +export function initTextarea(element: Element, props: Object) {
86 + const node: HTMLTextAreaElement = (element: any);
87
88 let initialValue = props.value;
89
@@ -65,12 +91,6 @@ export function initWrapperState(element: Element, props: Object) {
91 if (initialValue == null) {
92 let {children, defaultValue} = props;
93 if (children != null) {
68 - if (__DEV__) {
69 - console.error(
70 - 'Use the `defaultValue` or `value` props instead of setting ' +
71 - 'children on <textarea>.',
72 - );
73 - }
94 if (!disableTextareaChildren) {
95 if (defaultValue != null) {
96 throw new Error(
@@ -97,34 +117,7 @@ export function initWrapperState(element: Element, props: Object) {
117
118 const stringValue = getToStringValue(initialValue);
119 node.defaultValue = (stringValue: any); // This will be toString:ed.
100 - node._wrapperState = {
101 - initialValue: stringValue,
102 - };
103 -}
104 -
105 -export function updateWrapper(element: Element, props: Object) {
106 - const node = ((element: any): TextAreaWithWrapperState);
107 - const value = getToStringValue(props.value);
108 - const defaultValue = getToStringValue(props.defaultValue);
109 - if (value != null) {
110 - // Cast `value` to a string to ensure the value is set correctly. While
111 - // browsers typically do this as necessary, jsdom doesn't.
112 - const newValue = toString(value);
113 - // To avoid side effects (such as losing text selection), only set value if changed
114 - if (newValue !== node.value) {
115 - node.value = newValue;
116 - }
117 - if (props.defaultValue == null && node.defaultValue !== newValue) {
118 - node.defaultValue = newValue;
119 - }
120 - }
121 - if (defaultValue != null) {
122 - node.defaultValue = toString(defaultValue);
123 - }
124 -}
120
126 -export function postMountWrapper(element: Element, props: Object) {
127 - const node = ((element: any): TextAreaWithWrapperState);
121 // This is in postMount because we need access to the DOM node, which is not
122 // available until after the component has mounted.
123 const textContent = node.textContent;
@@ -133,14 +126,17 @@ export function postMountWrapper(element: Element, props: Object) {
126 // initial value. In IE10/IE11 there is a bug where the placeholder attribute
127 // will populate textContent as well.
128 // https://developer.microsoft.com/microsoft-edge/platform/issues/101525/
136 - if (textContent === node._wrapperState.initialValue) {
129 + if (textContent === stringValue) {
130 if (textContent !== '' && textContent !== null) {
131 node.value = textContent;
132 }
133 }
134 }
135
143 -export function restoreControlledState(element: Element, props: Object) {
136 +export function restoreControlledTextareaState(
137 + element: Element,
138 + props: Object,
139 +) {
140 // DOM component is still mounted; update
145 - updateWrapper(element, props);
141 + updateTextarea(element, props);
142 }
packages/react-dom-bindings/src/client/inputValueTracking.js
-1
@@ -123,7 +123,6 @@ export function track(node: ElementWithValueTracker) {
123 return;
124 }
125
126 - // TODO: Once it's just Fiber we can move this to node._wrapperState
126 node._valueTracker = trackValueOnNode(node);
127 }
128
packages/react-dom-bindings/src/events/plugins/ChangeEventPlugin.js
+13 -8
@@ -263,16 +263,17 @@ function getTargetInstForInputOrChangeEvent(
263 }
264 }
265
266 -function handleControlledInputBlur(node: HTMLInputElement) {
267 - const state = (node: any)._wrapperState;
268 -
269 - if (!state || !state.controlled || node.type !== 'number') {
266 +function handleControlledInputBlur(node: HTMLInputElement, props: any) {
267 + if (node.type !== 'number') {
268 return;
269 }
270
271 if (!disableInputAttributeSyncing) {
274 - // If controlled, assign the value attribute to the current value on blur
275 - setDefaultValue((node: any), 'number', (node: any).value);
272 + const isControlled = props.value != null;
273 + if (isControlled) {
274 + // If controlled, assign the value attribute to the current value on blur
275 + setDefaultValue((node: any), 'number', (node: any).value);
276 + }
277 }
278 }
279
@@ -335,8 +336,12 @@ function extractEvents(
336 }
337
338 // When blurring, set the value attribute for number inputs
338 - if (domEventName === 'focusout') {
339 - handleControlledInputBlur(((targetNode: any): HTMLInputElement));
339 + if (domEventName === 'focusout' && targetInst) {
340 + // These props aren't necessarily the most current but we warn for changing
341 + // between controlled and uncontrolled, so it doesn't matter and the previous
342 + // code was also broken for changes.
343 + const props = targetInst.memoizedProps;
344 + handleControlledInputBlur(((targetNode: any): HTMLInputElement), props);
345 }
346 }
347
packages/react-dom/src/__tests__/DOMPropertyOperations-test.js
+1 -5
@@ -1166,11 +1166,7 @@ describe('DOMPropertyOperations', () => {
1166 ).toErrorDev(
1167 'A component is changing a controlled input to be uncontrolled',
1168 );
1169 - if (disableInputAttributeSyncing) {
1170 - expect(container.firstChild.hasAttribute('value')).toBe(false);
1171 - } else {
1172 - expect(container.firstChild.getAttribute('value')).toBe('foo');
1173 - }
1169 + expect(container.firstChild.hasAttribute('value')).toBe(false);
1170 expect(container.firstChild.value).toBe('foo');
1171 });
1172
packages/react-dom/src/__tests__/ReactDOMComponent-test.js
+10 -3
@@ -1083,10 +1083,17 @@ describe('ReactDOMComponent', () => {
1083 );
1084 expect(nodeValueSetter).toHaveBeenCalledTimes(1);
1085
1086 - ReactDOM.render(
1087 - <input type="checkbox" onChange={onChange} checked={false} />,
1088 - container,
1086 + expect(() => {
1087 + ReactDOM.render(
1088 + <input type="checkbox" onChange={onChange} checked={false} />,
1089 + container,
1090 + );
1091 + }).toErrorDev(
1092 + ' A component is changing an uncontrolled input to be controlled. This is likely caused by ' +
1093 + 'the value changing from undefined to a defined value, which should not happen. Decide between ' +
1094 + 'using a controlled or uncontrolled input element for the lifetime of the component.',
1095 );
1096 +
1097 // TODO: Non-null values are updated twice on inputs. This is should ideally be fixed.
1098 expect(nodeValueSetter).toHaveBeenCalledTimes(3);
1099
packages/react-dom/src/__tests__/ReactDOMInput-test.js
+28 -10
@@ -1952,11 +1952,7 @@ describe('ReactDOMInput', () => {
1952 expect(renderInputWithStringThenWithUndefined).toErrorDev(
1953 'A component is changing a controlled input to be uncontrolled.',
1954 );
1955 - if (disableInputAttributeSyncing) {
1956 - expect(input.getAttribute('value')).toBe(null);
1957 - } else {
1958 - expect(input.getAttribute('value')).toBe('first');
1959 - }
1955 + expect(input.getAttribute('value')).toBe(null);
1956 });
1957
1958 it('preserves the value property', () => {
@@ -2002,11 +1998,7 @@ describe('ReactDOMInput', () => {
1998 'or `undefined` for uncontrolled components.',
1999 'A component is changing a controlled input to be uncontrolled.',
2000 ]);
2005 - if (disableInputAttributeSyncing) {
2006 - expect(input.hasAttribute('value')).toBe(false);
2007 - } else {
2008 - expect(input.getAttribute('value')).toBe('first');
2009 - }
2001 + expect(input.hasAttribute('value')).toBe(false);
2002 });
2003
2004 it('preserves the value property', () => {
@@ -2165,4 +2157,30 @@ describe('ReactDOMInput', () => {
2157 expect(node.hasAttribute('value')).toBe(false);
2158 });
2159 });
2160 +
2161 + it('should remove previous `defaultValue`', () => {
2162 + const node = ReactDOM.render(
2163 + <input type="text" defaultValue="0" />,
2164 + container,
2165 + );
2166 +
2167 + expect(node.value).toBe('0');
2168 + expect(node.defaultValue).toBe('0');
2169 +
2170 + ReactDOM.render(<input type="text" />, container);
2171 + expect(node.defaultValue).toBe('');
2172 + });
2173 +
2174 + it('should treat `defaultValue={null}` as missing', () => {
2175 + const node = ReactDOM.render(
2176 + <input type="text" defaultValue="0" />,
2177 + container,
2178 + );
2179 +
2180 + expect(node.value).toBe('0');
2181 + expect(node.defaultValue).toBe('0');
2182 +
2183 + ReactDOM.render(<input type="text" defaultValue={null} />, container);
2184 + expect(node.defaultValue).toBe('');
2185 + });
2186 });
packages/react-dom/src/__tests__/ReactDOMTextarea-test.js
+22
@@ -736,4 +736,26 @@ describe('ReactDOMTextarea', () => {
736 expect(node.value).toBe('foo');
737 });
738 });
739 +
740 + it('should remove previous `defaultValue`', () => {
741 + const container = document.createElement('div');
742 + const node = ReactDOM.render(<textarea defaultValue="0" />, container);
743 +
744 + expect(node.value).toBe('0');
745 + expect(node.defaultValue).toBe('0');
746 +
747 + ReactDOM.render(<textarea />, container);
748 + expect(node.defaultValue).toBe('');
749 + });
750 +
751 + it('should treat `defaultValue={null}` as missing', () => {
752 + const container = document.createElement('div');
753 + const node = ReactDOM.render(<textarea defaultValue="0" />, container);
754 +
755 + expect(node.value).toBe('0');
756 + expect(node.defaultValue).toBe('0');
757 +
758 + ReactDOM.render(<textarea defaultValue={null} />, container);
759 + expect(node.defaultValue).toBe('');
760 + });
761 });