Skip to content

Commit d586072

Browse files
sophiebitsAndyPengc12
authored andcommitted
Fix input tracking bug (facebook#26627)
In facebook@2019ddc, we changed to set .defaultValue before .value on updates. In some cases, setting .defaultValue causes .value to change, and since we only set .value if it has the wrong value, this resulted in us not assigning to .value, which resulted in inputValueTracking not knowing the right value. See new test added. My fix here is to (a) move the value setting back up first and (b) narrowing the fix in the aforementioned PR to newly remove the value attribute only if it defaultValue was previously present in props. The second half is necessary because for types where the value property and attribute are indelibly linked (hidden checkbox radio submit image reset button, i.e. spec modes default or default/on from https://html.spec.whatwg.org/multipage/input.html#dom-input-value-default), we can't remove the value attribute after setting .value, because that will undo the assignment we just did! That is, not having (b) makes all of those types fail to handle updating props.value. This code is incredibly hard to think about but I think this is right (or at least, as right as the old code was) because we set .value here only if the nextProps.value != null, and we now remove defaultValue only if lastProps.defaultValue != null. These can't happen at the same time because we have long warned if value and defaultValue are simultaneously specified, and also if a component switches between controlled and uncontrolled. Also, it fixes the test in facebook#26626.
1 parent 976cc42 commit d586072

File tree

4 files changed

+82
-38
lines changed

4 files changed

+82
-38
lines changed

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

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1297,32 +1297,36 @@ export function updateProperties(
12971297
let type = null;
12981298
let value = null;
12991299
let defaultValue = null;
1300+
let lastDefaultValue = null;
13001301
let checked = null;
13011302
let defaultChecked = null;
13021303
for (const propKey in lastProps) {
13031304
const lastProp = lastProps[propKey];
1304-
if (
1305-
lastProps.hasOwnProperty(propKey) &&
1306-
lastProp != null &&
1307-
!nextProps.hasOwnProperty(propKey)
1308-
) {
1305+
if (lastProps.hasOwnProperty(propKey) && lastProp != null) {
13091306
switch (propKey) {
13101307
case 'checked': {
1311-
const checkedValue = nextProps.defaultChecked;
1312-
const inputElement: HTMLInputElement = (domElement: any);
1313-
inputElement.checked =
1314-
!!checkedValue &&
1315-
typeof checkedValue !== 'function' &&
1316-
checkedValue !== 'symbol';
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+
}
13171316
break;
13181317
}
13191318
case 'value': {
13201319
// This is handled by updateWrapper below.
13211320
break;
13221321
}
1322+
case 'defaultValue': {
1323+
lastDefaultValue = lastProp;
1324+
}
13231325
// defaultChecked and defaultValue are ignored by setProp
1326+
// Fallthrough
13241327
default: {
1325-
setProp(domElement, tag, propKey, null, nextProps, lastProp);
1328+
if (!nextProps.hasOwnProperty(propKey))
1329+
setProp(domElement, tag, propKey, null, nextProps, lastProp);
13261330
}
13271331
}
13281332
}
@@ -1473,6 +1477,7 @@ export function updateProperties(
14731477
domElement,
14741478
value,
14751479
defaultValue,
1480+
lastDefaultValue,
14761481
checked,
14771482
defaultChecked,
14781483
type,
@@ -1809,6 +1814,7 @@ export function updatePropertiesWithDiff(
18091814
const type = nextProps.type;
18101815
const value = nextProps.value;
18111816
const defaultValue = nextProps.defaultValue;
1817+
const lastDefaultValue = lastProps.defaultValue;
18121818
const checked = nextProps.checked;
18131819
const defaultChecked = nextProps.defaultChecked;
18141820
for (let i = 0; i < updatePayload.length; i += 2) {
@@ -1934,6 +1940,7 @@ export function updatePropertiesWithDiff(
19341940
domElement,
19351941
value,
19361942
defaultValue,
1943+
lastDefaultValue,
19371944
checked,
19381945
defaultChecked,
19391946
type,

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

Lines changed: 26 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -85,19 +85,41 @@ export function updateInput(
8585
element: Element,
8686
value: ?string,
8787
defaultValue: ?string,
88+
lastDefaultValue: ?string,
8889
checked: ?boolean,
8990
defaultChecked: ?boolean,
9091
type: ?string,
9192
) {
9293
const node: HTMLInputElement = (element: any);
9394

95+
if (value != null) {
96+
if (type === 'number') {
97+
if (
98+
// $FlowFixMe[incompatible-type]
99+
(value === 0 && node.value === '') ||
100+
// We explicitly want to coerce to number here if possible.
101+
// eslint-disable-next-line
102+
node.value != (value: any)
103+
) {
104+
node.value = toString(getToStringValue(value));
105+
}
106+
} else if (node.value !== toString(getToStringValue(value))) {
107+
node.value = toString(getToStringValue(value));
108+
}
109+
} else if (type === 'submit' || type === 'reset') {
110+
// Submit/reset inputs need the attribute removed completely to avoid
111+
// blank-text buttons.
112+
node.removeAttribute('value');
113+
return;
114+
}
115+
94116
if (disableInputAttributeSyncing) {
95117
// When not syncing the value attribute, React only assigns a new value
96118
// whenever the defaultValue React prop has changed. When not present,
97119
// React does nothing
98120
if (defaultValue != null) {
99121
setDefaultValue(node, type, getToStringValue(defaultValue));
100-
} else {
122+
} else if (lastDefaultValue != null) {
101123
node.removeAttribute('value');
102124
}
103125
} else {
@@ -110,7 +132,7 @@ export function updateInput(
110132
setDefaultValue(node, type, getToStringValue(value));
111133
} else if (defaultValue != null) {
112134
setDefaultValue(node, type, getToStringValue(defaultValue));
113-
} else {
135+
} else if (lastDefaultValue != null) {
114136
node.removeAttribute('value');
115137
}
116138
}
@@ -135,27 +157,6 @@ export function updateInput(
135157
if (checked != null && node.checked !== !!checked) {
136158
node.checked = checked;
137159
}
138-
139-
if (value != null) {
140-
if (type === 'number') {
141-
if (
142-
// $FlowFixMe[incompatible-type]
143-
(value === 0 && node.value === '') ||
144-
// We explicitly want to coerce to number here if possible.
145-
// eslint-disable-next-line
146-
node.value != (value: any)
147-
) {
148-
node.value = toString(getToStringValue(value));
149-
}
150-
} else if (node.value !== toString(getToStringValue(value))) {
151-
node.value = toString(getToStringValue(value));
152-
}
153-
} else if (type === 'submit' || type === 'reset') {
154-
// Submit/reset inputs need the attribute removed completely to avoid
155-
// blank-text buttons.
156-
node.removeAttribute('value');
157-
return;
158-
}
159160
}
160161

161162
export function initInput(
@@ -286,6 +287,7 @@ export function restoreControlledInputState(element: Element, props: Object) {
286287
rootNode,
287288
props.value,
288289
props.defaultValue,
290+
props.defaultValue,
289291
props.checked,
290292
props.defaultChecked,
291293
props.type,
@@ -341,6 +343,7 @@ export function restoreControlledInputState(element: Element, props: Object) {
341343
otherNode,
342344
otherProps.value,
343345
otherProps.defaultValue,
346+
otherProps.defaultValue,
344347
otherProps.checked,
345348
otherProps.defaultChecked,
346349
otherProps.type,

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1166,7 +1166,11 @@ describe('DOMPropertyOperations', () => {
11661166
).toErrorDev(
11671167
'A component is changing a controlled input to be uncontrolled',
11681168
);
1169-
expect(container.firstChild.hasAttribute('value')).toBe(false);
1169+
if (disableInputAttributeSyncing) {
1170+
expect(container.firstChild.hasAttribute('value')).toBe(false);
1171+
} else {
1172+
expect(container.firstChild.getAttribute('value')).toBe('foo');
1173+
}
11701174
expect(container.firstChild.value).toBe('foo');
11711175
});
11721176

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

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1952,7 +1952,11 @@ describe('ReactDOMInput', () => {
19521952
expect(renderInputWithStringThenWithUndefined).toErrorDev(
19531953
'A component is changing a controlled input to be uncontrolled.',
19541954
);
1955-
expect(input.getAttribute('value')).toBe(null);
1955+
if (disableInputAttributeSyncing) {
1956+
expect(input.getAttribute('value')).toBe(null);
1957+
} else {
1958+
expect(input.getAttribute('value')).toBe('latest');
1959+
}
19561960
});
19571961

19581962
it('preserves the value property', () => {
@@ -1998,7 +2002,11 @@ describe('ReactDOMInput', () => {
19982002
'or `undefined` for uncontrolled components.',
19992003
'A component is changing a controlled input to be uncontrolled.',
20002004
]);
2001-
expect(input.hasAttribute('value')).toBe(false);
2005+
if (disableInputAttributeSyncing) {
2006+
expect(input.getAttribute('value')).toBe(null);
2007+
} else {
2008+
expect(input.getAttribute('value')).toBe('latest');
2009+
}
20022010
});
20032011

20042012
it('preserves the value property', () => {
@@ -2183,4 +2191,26 @@ describe('ReactDOMInput', () => {
21832191
ReactDOM.render(<input type="text" defaultValue={null} />, container);
21842192
expect(node.defaultValue).toBe('');
21852193
});
2194+
2195+
it('should notice input changes when reverting back to original value', () => {
2196+
const log = [];
2197+
function onChange(e) {
2198+
log.push(e.target.value);
2199+
}
2200+
ReactDOM.render(
2201+
<input type="text" value="" onChange={onChange} />,
2202+
container,
2203+
);
2204+
ReactDOM.render(
2205+
<input type="text" value="a" onChange={onChange} />,
2206+
container,
2207+
);
2208+
2209+
const node = container.firstChild;
2210+
setUntrackedValue.call(node, '');
2211+
dispatchEventOnNode(node, 'input');
2212+
2213+
expect(log).toEqual(['']);
2214+
expect(node.value).toBe('a');
2215+
});
21862216
});

0 commit comments

Comments
 (0)