@samitouri / QOS-React-2 / commits / 1f248bdd71

Switching checked to null should leave the current value (#26667)

I accidentally made a behavior change in the refactor. It turns out that when switching off `checked` to an uncontrolled component, we used to revert to the concept of "initialChecked" which used to be stored on state. When there's a diff to this computed prop and the value of props.checked is null, then we end up in a case where it sets `checked` to `initialChecked`: https://github.com/facebook/react/blob/5cbe6258bc436b1683080a6d978c27849f1d9a22/packages/react-dom-bindings/src/client/ReactDOMInput.js#L69 Since we never changed `initialChecked` and it's not relevant if non-null `checked` changes value, the only way this "change" could trigger was if we move from having `checked` to having null. This wasn't really consistent with how `value` works, where we instead leave the current value in place regardless. So this is a "bug fix" that changes `checked` to be consistent with `value` and just leave the current value in place. This case should already have a warning in it regardless since it's going from controlled to uncontrolled. Related to that, there was also another issue observed in https://github.com/facebook/react/pull/26596#discussion_r1162295872 and https://github.com/facebook/react/pull/26588 We need to atomically apply mutations on radio buttons. I fixed this by setting the name to empty before doing mutations to value/checked/type in updateInput, and then set the name to whatever it should be. Setting the name is what ends up atomically applying the changes. --------- Co-authored-by: Sophie Alpert <git@sophiebits.com>

Sebastian Markbåge committed Apr 19, 2023 at 11:46 UTC 1f248bdd7199979b050e4040ceecfe72dd977fd1
5 files changed +169 -140
packages/react-dom-bindings/src/client/ReactDOMComponent.js
+14 -112
@@ -834,6 +834,7 @@ export function setInitialProperties(
834 // listeners still fire for the invalid event.
835 listenToNonDelegatedEvent('invalid', domElement);
836
837 + let name = null;
838 let type = null;
839 let value = null;
840 let defaultValue = null;
@@ -848,31 +849,16 @@ export function setInitialProperties(
849 continue;
850 }
851 switch (propKey) {
852 + case 'name': {
853 + name = propValue;
854 + break;
855 + }
856 case 'type': {
852 - // Fast path since 'type' is very common on inputs
853 - if (
854 - propValue != null &&
855 - typeof propValue !== 'function' &&
856 - typeof propValue !== 'symbol' &&
857 - typeof propValue !== 'boolean'
858 - ) {
859 - type = propValue;
860 - if (__DEV__) {
861 - checkAttributeStringCoercion(propValue, propKey);
862 - }
863 - domElement.setAttribute(propKey, propValue);
864 - }
857 + type = propValue;
858 break;
859 }
860 case 'checked': {
861 checked = propValue;
869 - const checkedValue =
870 - propValue != null ? propValue : props.defaultChecked;
871 - const inputElement: HTMLInputElement = (domElement: any);
872 - inputElement.checked =
873 - !!checkedValue &&
874 - typeof checkedValue !== 'function' &&
875 - checkedValue !== 'symbol';
862 break;
863 }
864 case 'defaultChecked': {
@@ -904,7 +890,6 @@ export function setInitialProperties(
890 }
891 // TODO: Make sure we check if this is still unmounted or do any clean
892 // up necessary since we never stop tracking anymore.
907 - track((domElement: any));
893 validateInputProps(domElement, props);
894 initInput(
895 domElement,
@@ -913,8 +898,10 @@ export function setInitialProperties(
898 checked,
899 defaultChecked,
900 type,
901 + name,
902 false,
903 );
904 + track((domElement: any));
905 return;
906 }
907 case 'select': {
@@ -1010,9 +997,9 @@ export function setInitialProperties(
997 }
998 // TODO: Make sure we check if this is still unmounted or do any clean
999 // up necessary since we never stop tracking anymore.
1013 - track((domElement: any));
1000 validateTextareaProps(domElement, props);
1001 initTextarea(domElement, value, defaultValue, children);
1002 + track((domElement: any));
1003 return;
1004 }
1005 case 'option': {
@@ -1305,14 +1292,6 @@ export function updateProperties(
1292 if (lastProps.hasOwnProperty(propKey) && lastProp != null) {
1293 switch (propKey) {
1294 case 'checked': {
1308 - if (!nextProps.hasOwnProperty(propKey)) {
1309 - const checkedValue = nextProps.defaultChecked;
1310 - const inputElement: HTMLInputElement = (domElement: any);
1311 - inputElement.checked =
1312 - !!checkedValue &&
1313 - typeof checkedValue !== 'function' &&
1314 - checkedValue !== 'symbol';
1315 - }
1295 break;
1296 }
1297 case 'value': {
@@ -1341,22 +1320,6 @@ export function updateProperties(
1320 switch (propKey) {
1321 case 'type': {
1322 type = nextProp;
1344 - // Fast path since 'type' is very common on inputs
1345 - if (nextProp !== lastProp) {
1346 - if (
1347 - nextProp != null &&
1348 - typeof nextProp !== 'function' &&
1349 - typeof nextProp !== 'symbol' &&
1350 - typeof nextProp !== 'boolean'
1351 - ) {
1352 - if (__DEV__) {
1353 - checkAttributeStringCoercion(nextProp, propKey);
1354 - }
1355 - domElement.setAttribute(propKey, nextProp);
1356 - } else {
1357 - domElement.removeAttribute(propKey);
1358 - }
1359 - }
1323 break;
1324 }
1325 case 'name': {
@@ -1365,15 +1328,6 @@ export function updateProperties(
1328 }
1329 case 'checked': {
1330 checked = nextProp;
1368 - if (nextProp !== lastProp) {
1369 - const checkedValue =
1370 - nextProp != null ? nextProp : nextProps.defaultChecked;
1371 - const inputElement: HTMLInputElement = (domElement: any);
1372 - inputElement.checked =
1373 - !!checkedValue &&
1374 - typeof checkedValue !== 'function' &&
1375 - checkedValue !== 'symbol';
1376 - }
1331 break;
1332 }
1333 case 'defaultChecked': {
@@ -1453,23 +1407,6 @@ export function updateProperties(
1407 }
1408 }
1409
1456 - // Update checked *before* name.
1457 - // In the middle of an update, it is possible to have multiple checked.
1458 - // When a checked radio tries to change name, browser makes another radio's checked false.
1459 - if (
1460 - name != null &&
1461 - typeof name !== 'function' &&
1462 - typeof name !== 'symbol' &&
1463 - typeof name !== 'boolean'
1464 - ) {
1465 - if (__DEV__) {
1466 - checkAttributeStringCoercion(name, 'name');
1467 - }
1468 - domElement.setAttribute('name', name);
1469 - } else {
1470 - domElement.removeAttribute('name');
1471 - }
1472 -
1410 // Update the wrapper around inputs *after* updating props. This has to
1411 // happen after updating the rest of props. Otherwise HTML5 input validations
1412 // raise warnings and prevent the new value from being assigned.
@@ -1481,6 +1418,7 @@ export function updateProperties(
1418 checked,
1419 defaultChecked,
1420 type,
1421 + name,
1422 );
1423 return;
1424 }
@@ -1822,33 +1760,12 @@ export function updatePropertiesWithDiff(
1760 const propValue = updatePayload[i + 1];
1761 switch (propKey) {
1762 case 'type': {
1825 - // Fast path since 'type' is very common on inputs
1826 - if (
1827 - propValue != null &&
1828 - typeof propValue !== 'function' &&
1829 - typeof propValue !== 'symbol' &&
1830 - typeof propValue !== 'boolean'
1831 - ) {
1832 - if (__DEV__) {
1833 - checkAttributeStringCoercion(propValue, propKey);
1834 - }
1835 - domElement.setAttribute(propKey, propValue);
1836 - } else {
1837 - domElement.removeAttribute(propKey);
1838 - }
1763 break;
1764 }
1765 case 'name': {
1766 break;
1767 }
1768 case 'checked': {
1845 - const checkedValue =
1846 - propValue != null ? propValue : nextProps.defaultChecked;
1847 - const inputElement: HTMLInputElement = (domElement: any);
1848 - inputElement.checked =
1849 - !!checkedValue &&
1850 - typeof checkedValue !== 'function' &&
1851 - checkedValue !== 'symbol';
1769 break;
1770 }
1771 case 'defaultChecked': {
@@ -1916,23 +1833,6 @@ export function updatePropertiesWithDiff(
1833 }
1834 }
1835
1919 - // Update checked *before* name.
1920 - // In the middle of an update, it is possible to have multiple checked.
1921 - // When a checked radio tries to change name, browser makes another radio's checked false.
1922 - if (
1923 - name != null &&
1924 - typeof name !== 'function' &&
1925 - typeof name !== 'symbol' &&
1926 - typeof name !== 'boolean'
1927 - ) {
1928 - if (__DEV__) {
1929 - checkAttributeStringCoercion(name, 'name');
1930 - }
1931 - domElement.setAttribute('name', name);
1932 - } else {
1933 - domElement.removeAttribute('name');
1934 - }
1935 -
1836 // Update the wrapper around inputs *after* updating props. This has to
1837 // happen after updating the rest of props. Otherwise HTML5 input validations
1838 // raise warnings and prevent the new value from being assigned.
@@ -1944,6 +1844,7 @@ export function updatePropertiesWithDiff(
1844 checked,
1845 defaultChecked,
1846 type,
1847 + name,
1848 );
1849 return;
1850 }
@@ -2970,7 +2871,6 @@ export function diffHydratedProperties(
2871 listenToNonDelegatedEvent('invalid', domElement);
2872 // TODO: Make sure we check if this is still unmounted or do any clean
2873 // up necessary since we never stop tracking anymore.
2973 - track((domElement: any));
2874 validateInputProps(domElement, props);
2875 // For input and textarea we current always set the value property at
2876 // post mount to force it to diverge from attributes. However, for
@@ -2984,8 +2884,10 @@ export function diffHydratedProperties(
2884 props.checked,
2885 props.defaultChecked,
2886 props.type,
2887 + props.name,
2888 true,
2889 );
2890 + track((domElement: any));
2891 break;
2892 case 'option':
2893 validateOptionProps(domElement, props);
@@ -3008,9 +2910,9 @@ export function diffHydratedProperties(
2910 listenToNonDelegatedEvent('invalid', domElement);
2911 // TODO: Make sure we check if this is still unmounted or do any clean
2912 // up necessary since we never stop tracking anymore.
3011 - track((domElement: any));
2913 validateTextareaProps(domElement, props);
2914 initTextarea(domElement, props.value, props.defaultValue, props.children);
2915 + track((domElement: any));
2916 break;
2917 }
2918
packages/react-dom-bindings/src/client/ReactDOMInput.js
+60 -5
@@ -89,9 +89,30 @@ export function updateInput(
89 checked: ?boolean,
90 defaultChecked: ?boolean,
91 type: ?string,
92 + name: ?string,
93 ) {
94 const node: HTMLInputElement = (element: any);
95
96 + // Temporarily disconnect the input from any radio buttons.
97 + // Changing the type or name as the same time as changing the checked value
98 + // needs to be atomically applied. We can only ensure that by disconnecting
99 + // the name while do the mutations and then reapply the name after that's done.
100 + node.name = '';
101 +
102 + if (
103 + type != null &&
104 + typeof type !== 'function' &&
105 + typeof type !== 'symbol' &&
106 + typeof type !== 'boolean'
107 + ) {
108 + if (__DEV__) {
109 + checkAttributeStringCoercion(type, 'type');
110 + }
111 + node.type = type;
112 + } else {
113 + node.removeAttribute('type');
114 + }
115 +
116 if (value != null) {
117 if (type === 'number') {
118 if (
@@ -157,6 +178,20 @@ export function updateInput(
178 if (checked != null && node.checked !== !!checked) {
179 node.checked = checked;
180 }
181 +
182 + if (
183 + name != null &&
184 + typeof name !== 'function' &&
185 + typeof name !== 'symbol' &&
186 + typeof name !== 'boolean'
187 + ) {
188 + if (__DEV__) {
189 + checkAttributeStringCoercion(name, 'name');
190 + }
191 + node.name = name;
192 + } else {
193 + node.removeAttribute('name');
194 + }
195 }
196
197 export function initInput(
@@ -166,10 +201,23 @@ export function initInput(
201 checked: ?boolean,
202 defaultChecked: ?boolean,
203 type: ?string,
204 + name: ?string,
205 isHydrating: boolean,
206 ) {
207 const node: HTMLInputElement = (element: any);
208
209 + if (
210 + type != null &&
211 + typeof type !== 'function' &&
212 + typeof type !== 'symbol' &&
213 + typeof type !== 'boolean'
214 + ) {
215 + if (__DEV__) {
216 + checkAttributeStringCoercion(type, 'type');
217 + }
218 + node.type = type;
219 + }
220 +
221 if (value != null || defaultValue != null) {
222 const isButton = type === 'submit' || type === 'reset';
223
@@ -235,10 +283,6 @@ export function initInput(
283 // will sometimes influence the value of checked (even after detachment).
284 // Reference: https://bugs.chromium.org/p/chromium/issues/detail?id=608416
285 // We need to temporarily unset name to avoid disrupting radio button groups.
238 - const name = node.name;
239 - if (name !== '') {
240 - node.name = '';
241 - }
286
287 const checkedOrDefault = checked != null ? checked : defaultChecked;
288 // TODO: This 'function' or 'symbol' check isn't replicated in other places
@@ -276,7 +320,16 @@ export function initInput(
320 node.defaultChecked = !!initialChecked;
321 }
322
279 - if (name !== '') {
323 + // Name needs to be set at the end so that it applies atomically to connected radio buttons.
324 + if (
325 + name != null &&
326 + typeof name !== 'function' &&
327 + typeof name !== 'symbol' &&
328 + typeof name !== 'boolean'
329 + ) {
330 + if (__DEV__) {
331 + checkAttributeStringCoercion(name, 'name');
332 + }
333 node.name = name;
334 }
335 }
@@ -291,6 +344,7 @@ export function restoreControlledInputState(element: Element, props: Object) {
344 props.checked,
345 props.defaultChecked,
346 props.type,
347 + props.name,
348 );
349 const name = props.name;
350 if (props.type === 'radio' && name != null) {
@@ -347,6 +401,7 @@ export function restoreControlledInputState(element: Element, props: Object) {
401 otherProps.checked,
402 otherProps.defaultChecked,
403 otherProps.type,
404 + otherProps.name,
405 );
406 }
407 }
packages/react-dom/src/__tests__/ReactDOMComponent-test.js
+4 -3
@@ -1143,7 +1143,8 @@ describe('ReactDOMComponent', () => {
1143 'the value changing from a defined to undefined, which should not happen. Decide between ' +
1144 'using a controlled or uncontrolled input element for the lifetime of the component.',
1145 );
1146 - expect(nodeValueSetter).toHaveBeenCalledTimes(1);
1146 + // This leaves the current checked value in place, just like text inputs.
1147 + expect(nodeValueSetter).toHaveBeenCalledTimes(0);
1148
1149 expect(() => {
1150 ReactDOM.render(
@@ -1156,13 +1157,13 @@ describe('ReactDOMComponent', () => {
1157 'using a controlled or uncontrolled input element for the lifetime of the component.',
1158 );
1159
1159 - expect(nodeValueSetter).toHaveBeenCalledTimes(2);
1160 + expect(nodeValueSetter).toHaveBeenCalledTimes(1);
1161
1162 ReactDOM.render(
1163 <input type="checkbox" onChange={onChange} checked={true} />,
1164 container,
1165 );
1165 - expect(nodeValueSetter).toHaveBeenCalledTimes(3);
1166 + expect(nodeValueSetter).toHaveBeenCalledTimes(2);
1167 });
1168
1169 it('should ignore attribute list for elements with the "is" attribute', () => {
packages/react-dom/src/__tests__/ReactDOMInput-test.js
+87 -4
@@ -1191,7 +1191,7 @@ describe('ReactDOMInput', () => {
1191 updated: false,
1192 };
1193 onClick = () => {
1194 - this.setState({updated: true});
1194 + this.setState({updated: !this.state.updated});
1195 };
1196 render() {
1197 const {updated} = this.state;
@@ -1222,6 +1222,62 @@ describe('ReactDOMInput', () => {
1222 expect(firstRadioNode.checked).toBe(false);
1223 dispatchEventOnNode(buttonNode, 'click');
1224 expect(firstRadioNode.checked).toBe(true);
1225 + dispatchEventOnNode(buttonNode, 'click');
1226 + expect(firstRadioNode.checked).toBe(false);
1227 + });
1228 +
1229 + it("shouldn't get tricked by changing radio names, part 2", () => {
1230 + ReactDOM.render(
1231 + <div>
1232 + <input
1233 + type="radio"
1234 + name="a"
1235 + value="1"
1236 + checked={true}
1237 + onChange={() => {}}
1238 + />
1239 + <input
1240 + type="radio"
1241 + name="a"
1242 + value="2"
1243 + checked={false}
1244 + onChange={() => {}}
1245 + />
1246 + </div>,
1247 + container,
1248 + );
1249 + expect(container.querySelector('input[name="a"][value="1"]').checked).toBe(
1250 + true,
1251 + );
1252 + expect(container.querySelector('input[name="a"][value="2"]').checked).toBe(
1253 + false,
1254 + );
1255 +
1256 + ReactDOM.render(
1257 + <div>
1258 + <input
1259 + type="radio"
1260 + name="a"
1261 + value="1"
1262 + checked={true}
1263 + onChange={() => {}}
1264 + />
1265 + <input
1266 + type="radio"
1267 + name="b"
1268 + value="2"
1269 + checked={true}
1270 + onChange={() => {}}
1271 + />
1272 + </div>,
1273 + container,
1274 + );
1275 + expect(container.querySelector('input[name="a"][value="1"]').checked).toBe(
1276 + true,
1277 + );
1278 + expect(container.querySelector('input[name="b"][value="2"]').checked).toBe(
1279 + true,
1280 + );
1281 });
1282
1283 it('should control radio buttons if the tree updates during render', () => {
@@ -1720,8 +1776,18 @@ describe('ReactDOMInput', () => {
1776 ) {
1777 const el = originalCreateElement.apply(this, arguments);
1778 let value = '';
1779 + let typeProp = '';
1780
1781 if (type === 'input') {
1782 + Object.defineProperty(el, 'type', {
1783 + get: function () {
1784 + return typeProp;
1785 + },
1786 + set: function (val) {
1787 + typeProp = String(val);
1788 + log.push('set property type');
1789 + },
1790 + });
1791 Object.defineProperty(el, 'value', {
1792 get: function () {
1793 return value;
@@ -1751,10 +1817,10 @@ describe('ReactDOMInput', () => {
1817 );
1818
1819 expect(log).toEqual([
1754 - 'set attribute type',
1820 'set attribute min',
1821 'set attribute max',
1822 'set attribute step',
1823 + 'set property type',
1824 'set property value',
1825 ]);
1826 });
@@ -1810,6 +1876,14 @@ describe('ReactDOMInput', () => {
1876 HTMLInputElement.prototype,
1877 'value',
1878 ).set;
1879 + const getType = Object.getOwnPropertyDescriptor(
1880 + HTMLInputElement.prototype,
1881 + 'type',
1882 + ).get;
1883 + const setType = Object.getOwnPropertyDescriptor(
1884 + HTMLInputElement.prototype,
1885 + 'type',
1886 + ).set;
1887 if (type === 'input') {
1888 Object.defineProperty(el, 'defaultValue', {
1889 get: function () {
@@ -1829,6 +1903,15 @@ describe('ReactDOMInput', () => {
1903 setValue.call(this, val);
1904 },
1905 });
1906 + Object.defineProperty(el, 'type', {
1907 + get: function () {
1908 + return getType.call(this);
1909 + },
1910 + set: function (val) {
1911 + log.push(`node.type = ${strify(val)}`);
1912 + setType.call(this, val);
1913 + },
1914 + });
1915 spyOnDevAndProd(el, 'setAttribute').mockImplementation(function (
1916 name,
1917 val,
@@ -1843,14 +1926,14 @@ describe('ReactDOMInput', () => {
1926
1927 if (disableInputAttributeSyncing) {
1928 expect(log).toEqual([
1846 - 'node.setAttribute("type", "date")',
1929 + 'node.type = "date"',
1930 'node.defaultValue = "1980-01-01"',
1931 // TODO: it's possible this reintroduces the bug because we don't assign `value` at all.
1932 // Need to check this on mobile Safari and Chrome.
1933 ]);
1934 } else {
1935 expect(log).toEqual([
1853 - 'node.setAttribute("type", "date")',
1936 + 'node.type = "date"',
1937 // value must be assigned before defaultValue. This fixes an issue where the
1938 // visually displayed value of date inputs disappears on mobile Safari and Chrome:
1939 // https://github.com/facebook/react/issues/7233
packages/react-dom/src/events/plugins/__tests__/ChangeEventPlugin-test.js
+4 -16
@@ -12,7 +12,6 @@
12 let React;
13 let ReactDOM;
14 let ReactDOMClient;
15 -let ReactFeatureFlags;
15 let Scheduler;
16 let act;
17 let waitForAll;
@@ -39,7 +38,6 @@ describe('ChangeEventPlugin', () => {
38
39 beforeEach(() => {
40 jest.resetModules();
42 - ReactFeatureFlags = require('shared/ReactFeatureFlags');
41 // TODO pull this into helper method, reduce repetition.
42 // mock the browser APIs which are used in schedule:
43 // - calling 'window.postMessage' should actually fire postmessage handlers
@@ -100,13 +98,8 @@ describe('ChangeEventPlugin', () => {
98 node.dispatchEvent(new Event('input', {bubbles: true, cancelable: true}));
99 node.dispatchEvent(new Event('change', {bubbles: true, cancelable: true}));
100
103 - if (ReactFeatureFlags.disableInputAttributeSyncing) {
104 - // TODO: figure out why. This might be a bug.
105 - expect(called).toBe(1);
106 - } else {
107 - // There should be no React change events because the value stayed the same.
108 - expect(called).toBe(0);
109 - }
101 + // There should be no React change events because the value stayed the same.
102 + expect(called).toBe(0);
103 });
104
105 it('should consider initial text value to be current (capture)', () => {
@@ -124,13 +117,8 @@ describe('ChangeEventPlugin', () => {
117 node.dispatchEvent(new Event('input', {bubbles: true, cancelable: true}));
118 node.dispatchEvent(new Event('change', {bubbles: true, cancelable: true}));
119
127 - if (ReactFeatureFlags.disableInputAttributeSyncing) {
128 - // TODO: figure out why. This might be a bug.
129 - expect(called).toBe(1);
130 - } else {
131 - // There should be no React change events because the value stayed the same.
132 - expect(called).toBe(0);
133 - }
120 + // There should be no React change events because the value stayed the same.
121 + expect(called).toBe(0);
122 });
123
124 it('should not invoke a change event for textarea same value', () => {