Skip to content

Commit 3c27178

Browse files
authored
Update tracked value after resetting radio group (facebook#27394)
Fixes facebook#26876, I think. Review each commit separately (all assertions pass in main already, except the last assertInputTrackingIsClean in "should control radio buttons"). I'm actually a little confused on two things here: * All the isCheckedDirty assertions are true. But I don't think we set .checked unconditionally? So how does this happen? * facebook#26876 (comment) claims that facebook/react@d962f35...1f248bd contains the faulty change, but it doesn't appear to change the restoration logic that I've touched here. (One difference outside restoration is that updateProperties did previously set `.checked` when `nextProp !== lastProp` whereas the new logic in updateInput is to set it when `node.checked !== !!checked`.) But it seems to me like we need this call here anyway, and if it fixes it then it fixes it? I think technically speaking we probably should do all the updateInput() calls and then all the updateValueIfChanged() calls—in particular I think if clicking A changed the checked radio button from B to C then the code as I have it would be incorrect, but that also seems unlikely so idk whether to care. cc @zhengjitf @Luk-z who did some investigation on the original issue
1 parent 2b3d582 commit 3c27178

File tree

2 files changed

+86
-16
lines changed

2 files changed

+86
-16
lines changed

packages/react-dom-bindings/src/client/ReactDOMInput.js

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -388,10 +388,6 @@ export function restoreControlledInputState(element: Element, props: Object) {
388388
);
389389
}
390390

391-
// We need update the tracked value on the named cousin since the value
392-
// was changed but the input saw no event or value set
393-
updateValueIfChanged(otherNode);
394-
395391
// If this is a controlled radio button group, forcing the input that
396392
// was previously checked to update will cause it to be come re-checked
397393
// as appropriate.
@@ -406,6 +402,16 @@ export function restoreControlledInputState(element: Element, props: Object) {
406402
otherProps.name,
407403
);
408404
}
405+
406+
// If any updateInput() call set .checked to true, an input in this group
407+
// (often, `rootNode` itself) may have become unchecked
408+
for (let i = 0; i < group.length; i++) {
409+
const otherNode = ((group[i]: any): HTMLInputElement);
410+
if (otherNode.form !== rootNode.form) {
411+
continue;
412+
}
413+
updateValueIfChanged(otherNode);
414+
}
409415
}
410416
}
411417

packages/react-dom/src/__tests__/ReactDOMInput-test.js

Lines changed: 76 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,43 @@ describe('ReactDOMInput', () => {
3636
return copy.value === node.value;
3737
}
3838

39+
function isCheckedDirty(node) {
40+
// Return the "dirty checked flag" as defined in the HTML spec.
41+
if (node.checked !== node.defaultChecked) {
42+
return true;
43+
}
44+
const copy = node.cloneNode();
45+
copy.type = 'checkbox';
46+
copy.defaultChecked = !copy.defaultChecked;
47+
return copy.checked === node.checked;
48+
}
49+
50+
function getTrackedAndCurrentInputValue(elem: HTMLElement): [mixed, mixed] {
51+
const tracker = elem._valueTracker;
52+
if (!tracker) {
53+
throw new Error('No input tracker');
54+
}
55+
return [
56+
tracker.getValue(),
57+
elem.nodeName === 'INPUT' &&
58+
(elem.type === 'checkbox' || elem.type === 'radio')
59+
? String(elem.checked)
60+
: elem.value,
61+
];
62+
}
63+
64+
function assertInputTrackingIsCurrent(parent) {
65+
parent.querySelectorAll('input, textarea, select').forEach(input => {
66+
const [trackedValue, currentValue] =
67+
getTrackedAndCurrentInputValue(input);
68+
if (trackedValue !== currentValue) {
69+
throw new Error(
70+
`Input ${input.outerHTML} is currently ${currentValue} but tracker thinks it's ${trackedValue}`,
71+
);
72+
}
73+
});
74+
}
75+
3976
beforeEach(() => {
4077
jest.resetModules();
4178

@@ -1119,13 +1156,15 @@ describe('ReactDOMInput', () => {
11191156
name="fruit"
11201157
checked={true}
11211158
onChange={emptyFunction}
1159+
data-which="a"
11221160
/>
11231161
A
11241162
<input
11251163
ref={this.bRef}
11261164
type="radio"
11271165
name="fruit"
11281166
onChange={emptyFunction}
1167+
data-which="b"
11291168
/>
11301169
B
11311170
<form>
@@ -1135,6 +1174,7 @@ describe('ReactDOMInput', () => {
11351174
name="fruit"
11361175
defaultChecked={true}
11371176
onChange={emptyFunction}
1177+
data-which="c"
11381178
/>
11391179
</form>
11401180
</div>
@@ -1162,6 +1202,11 @@ describe('ReactDOMInput', () => {
11621202
expect(cNode.hasAttribute('checked')).toBe(true);
11631203
}
11641204

1205+
expect(isCheckedDirty(aNode)).toBe(true);
1206+
expect(isCheckedDirty(bNode)).toBe(true);
1207+
expect(isCheckedDirty(cNode)).toBe(true);
1208+
assertInputTrackingIsCurrent(container);
1209+
11651210
setUntrackedChecked.call(bNode, true);
11661211
expect(aNode.checked).toBe(false);
11671212
expect(cNode.checked).toBe(true);
@@ -1183,6 +1228,11 @@ describe('ReactDOMInput', () => {
11831228
// The original state should have been restored
11841229
expect(aNode.checked).toBe(true);
11851230
expect(cNode.checked).toBe(true);
1231+
1232+
expect(isCheckedDirty(aNode)).toBe(true);
1233+
expect(isCheckedDirty(bNode)).toBe(true);
1234+
expect(isCheckedDirty(cNode)).toBe(true);
1235+
assertInputTrackingIsCurrent(container);
11861236
});
11871237

11881238
it('should check the correct radio when the selected name moves', () => {
@@ -1219,11 +1269,15 @@ describe('ReactDOMInput', () => {
12191269
const stub = ReactDOM.render(<App />, container);
12201270
const buttonNode = ReactDOM.findDOMNode(stub).childNodes[0];
12211271
const firstRadioNode = ReactDOM.findDOMNode(stub).childNodes[1];
1272+
expect(isCheckedDirty(firstRadioNode)).toBe(true);
12221273
expect(firstRadioNode.checked).toBe(false);
1274+
assertInputTrackingIsCurrent(container);
12231275
dispatchEventOnNode(buttonNode, 'click');
12241276
expect(firstRadioNode.checked).toBe(true);
1277+
assertInputTrackingIsCurrent(container);
12251278
dispatchEventOnNode(buttonNode, 'click');
12261279
expect(firstRadioNode.checked).toBe(false);
1280+
assertInputTrackingIsCurrent(container);
12271281
});
12281282

12291283
it("shouldn't get tricked by changing radio names, part 2", () => {
@@ -1246,12 +1300,13 @@ describe('ReactDOMInput', () => {
12461300
</div>,
12471301
container,
12481302
);
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-
);
1303+
const one = container.querySelector('input[name="a"][value="1"]');
1304+
const two = container.querySelector('input[name="a"][value="2"]');
1305+
expect(one.checked).toBe(true);
1306+
expect(two.checked).toBe(false);
1307+
expect(isCheckedDirty(one)).toBe(true);
1308+
expect(isCheckedDirty(two)).toBe(true);
1309+
assertInputTrackingIsCurrent(container);
12551310

12561311
ReactDOM.render(
12571312
<div>
@@ -1272,12 +1327,11 @@ describe('ReactDOMInput', () => {
12721327
</div>,
12731328
container,
12741329
);
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-
);
1330+
expect(one.checked).toBe(true);
1331+
expect(two.checked).toBe(true);
1332+
expect(isCheckedDirty(one)).toBe(true);
1333+
expect(isCheckedDirty(two)).toBe(true);
1334+
assertInputTrackingIsCurrent(container);
12811335
});
12821336

12831337
it('should control radio buttons if the tree updates during render', () => {
@@ -1339,6 +1393,9 @@ describe('ReactDOMInput', () => {
13391393

13401394
expect(aNode.checked).toBe(false);
13411395
expect(bNode.checked).toBe(true);
1396+
expect(isCheckedDirty(aNode)).toBe(true);
1397+
expect(isCheckedDirty(bNode)).toBe(true);
1398+
assertInputTrackingIsCurrent(container);
13421399

13431400
setUntrackedChecked.call(aNode, true);
13441401
// This next line isn't necessary in a proper browser environment, but
@@ -1352,6 +1409,9 @@ describe('ReactDOMInput', () => {
13521409
// The original state should have been restored
13531410
expect(aNode.checked).toBe(false);
13541411
expect(bNode.checked).toBe(true);
1412+
expect(isCheckedDirty(aNode)).toBe(true);
1413+
expect(isCheckedDirty(bNode)).toBe(true);
1414+
assertInputTrackingIsCurrent(container);
13551415
});
13561416

13571417
it('should warn with value and no onChange handler and readOnly specified', () => {
@@ -1734,6 +1794,8 @@ describe('ReactDOMInput', () => {
17341794
<input type="radio" checked={false} onChange={() => null} />,
17351795
container,
17361796
);
1797+
const input = container.querySelector('input');
1798+
expect(isCheckedDirty(input)).toBe(true);
17371799
ReactDOM.render(
17381800
<input
17391801
type="radio"
@@ -1744,6 +1806,8 @@ describe('ReactDOMInput', () => {
17441806
/>,
17451807
container,
17461808
);
1809+
expect(isCheckedDirty(input)).toBe(true);
1810+
assertInputTrackingIsCurrent(container);
17471811
});
17481812

17491813
it('should warn if radio checked false changes to become uncontrolled', () => {

0 commit comments

Comments
 (0)