@samitouri / QOS-React / commits / fd69c239a0

Use defaultValue instead of setAttribute('value') (#11534)

* Use defaultValue instead of setAttribute('value') This commit replaces the method of synchronizing an input's value attribute from using setAttribute to assigning defaultValue. This has several benefits: - Fixes issue where IE10+ and Edge password icon disappears (#7328) - Fixes issue where toggling input types hides display value on dates in Safari (unreported) - Removes mutationMethod behaviors from DOMPropertyOperations * initialValue in Input wrapperState is always a string * The value property is assigned before the value attribute. Fix related tests. * Remove initial value tests in ReactDOMInput I added these tests after removing the `value` mutation method. However they do not add any additional value over existing tests. * Improve clarity of value checks in ReactDOMInput.postMountWrapper * Remove value and defaultValue from InputWithWrapperState type They are already included in the type definition for HTMLInputElement * Inline stringification of value in ReactDOMInput Avoids eagier stringification and makes usage more consistent. * Use consistent value/defaultValue presence in postMountHook Other methods in ReactDOMInput check for null instead of hasOwnProperty. * Add missing semicolon * Remove unused value argument in ReactDOMInput test * Address cases where a value switches to undefined When a controlled input value switches to undefined, it reverts back to the initial state of the controlled input. We didn't have test coverage for this case, so I've added two describe blocks to cover both null and undefined.

Nathan Hunzaker committed Nov 30, 2017 at 16:24 UTC fd69c239a0bb8fde412aa7c1c0cea9c7fda287a9
6 files changed +147 -133
fixtures/dom/src/components/fixtures/number-inputs/index.js
+3 -3
@@ -27,9 +27,9 @@ function NumberInputs() {
27 <NumberTestCase />
28
29 <p className="footnote">
30 - <b>Notes:</b> Chrome and Safari clear trailing decimals on blur. React
31 - makes this concession so that the value attribute remains in sync with
32 - the value property.
30 + <b>Notes:</b> Modern Chrome and Safari {'<='} 6 clear trailing
31 + decimals on blur. React makes this concession so that the value
32 + attribute remains in sync with the value property.
33 </p>
34 </TestCase>
35
packages/react-dom/src/__tests__/ReactDOMInput-test.js
+86 -15
@@ -1249,15 +1249,20 @@ describe('ReactDOMInput', () => {
1249 var originalCreateElement = document.createElement;
1250 spyOnDevAndProd(document, 'createElement').and.callFake(function(type) {
1251 var el = originalCreateElement.apply(this, arguments);
1252 + var value = '';
1253 +
1254 if (type === 'input') {
1255 Object.defineProperty(el, 'value', {
1254 - get: function() {},
1255 - set: function() {
1256 - log.push('set value');
1256 + get: function() {
1257 + return value;
1258 + },
1259 + set: function(val) {
1260 + value = '' + val;
1261 + log.push('set property value');
1262 },
1263 });
1259 - spyOnDevAndProd(el, 'setAttribute').and.callFake(function(name, value) {
1260 - log.push('set ' + name);
1264 + spyOnDevAndProd(el, 'setAttribute').and.callFake(function(name) {
1265 + log.push('set attribute ' + name);
1266 });
1267 }
1268 return el;
@@ -1267,14 +1272,14 @@ describe('ReactDOMInput', () => {
1272 <input value="0" type="range" min="0" max="100" step="1" />,
1273 );
1274 expect(log).toEqual([
1270 - 'set type',
1271 - 'set step',
1272 - 'set min',
1273 - 'set max',
1274 - 'set value',
1275 - 'set value',
1276 - 'set checked',
1277 - 'set checked',
1275 + 'set attribute type',
1276 + 'set attribute min',
1277 + 'set attribute max',
1278 + 'set attribute step',
1279 + 'set property value',
1280 + 'set attribute value',
1281 + 'set attribute checked',
1282 + 'set attribute checked',
1283 ]);
1284 });
1285
@@ -1313,9 +1318,14 @@ describe('ReactDOMInput', () => {
1318 var originalCreateElement = document.createElement;
1319 spyOnDevAndProd(document, 'createElement').and.callFake(function(type) {
1320 var el = originalCreateElement.apply(this, arguments);
1321 + var value = '';
1322 if (type === 'input') {
1323 Object.defineProperty(el, 'value', {
1324 + get: function() {
1325 + return value;
1326 + },
1327 set: function(val) {
1328 + value = '' + val;
1329 log.push(`node.value = ${strify(val)}`);
1330 },
1331 });
@@ -1331,9 +1341,8 @@ describe('ReactDOMInput', () => {
1341 );
1342 expect(log).toEqual([
1343 'node.setAttribute("type", "date")',
1344 + 'node.value = "1980-01-01"',
1345 'node.setAttribute("value", "1980-01-01")',
1335 - 'node.value = ""',
1336 - 'node.value = ""',
1346 'node.setAttribute("checked", "")',
1347 'node.setAttribute("checked", "")',
1348 ]);
@@ -1420,4 +1429,66 @@ describe('ReactDOMInput', () => {
1429 expect(node.getAttribute('value')).toBe('1');
1430 });
1431 });
1432 +
1433 + describe('setting a controlled input to undefined', () => {
1434 + var input;
1435 +
1436 + beforeEach(() => {
1437 + class Input extends React.Component {
1438 + state = {value: 'first'};
1439 + render() {
1440 + return (
1441 + <input
1442 + onChange={e => this.setState({value: e.target.value})}
1443 + value={this.state.value}
1444 + />
1445 + );
1446 + }
1447 + }
1448 +
1449 + var stub = ReactTestUtils.renderIntoDocument(<Input />);
1450 + input = ReactDOM.findDOMNode(stub);
1451 + ReactTestUtils.Simulate.change(input, {target: {value: 'latest'}});
1452 + ReactTestUtils.Simulate.change(input, {target: {value: undefined}});
1453 + });
1454 +
1455 + it('reverts the value attribute to the initial value', () => {
1456 + expect(input.getAttribute('value')).toBe('first');
1457 + });
1458 +
1459 + it('preserves the value property', () => {
1460 + expect(input.value).toBe('latest');
1461 + });
1462 + });
1463 +
1464 + describe('setting a controlled input to null', () => {
1465 + var input;
1466 +
1467 + beforeEach(() => {
1468 + class Input extends React.Component {
1469 + state = {value: 'first'};
1470 + render() {
1471 + return (
1472 + <input
1473 + onChange={e => this.setState({value: e.target.value})}
1474 + value={this.state.value}
1475 + />
1476 + );
1477 + }
1478 + }
1479 +
1480 + var stub = ReactTestUtils.renderIntoDocument(<Input />);
1481 + input = ReactDOM.findDOMNode(stub);
1482 + ReactTestUtils.Simulate.change(input, {target: {value: 'latest'}});
1483 + ReactTestUtils.Simulate.change(input, {target: {value: null}});
1484 + });
1485 +
1486 + it('reverts the value attribute to the initial value', () => {
1487 + expect(input.getAttribute('value')).toBe('first');
1488 + });
1489 +
1490 + it('preserves the value property', () => {
1491 + expect(input.value).toBe('latest');
1492 + });
1493 + });
1494 });
packages/react-dom/src/client/DOMPropertyOperations.js
+3 -10
@@ -73,8 +73,7 @@ export function getValueForProperty(node, name, expected) {
73 if (__DEV__) {
74 var propertyInfo = getPropertyInfo(name);
75 if (propertyInfo) {
76 - var mutationMethod = propertyInfo.mutationMethod;
77 - if (mutationMethod || propertyInfo.mustUseProperty) {
76 + if (propertyInfo.mustUseProperty) {
77 return node[propertyInfo.propertyName];
78 } else {
79 var attributeName = propertyInfo.attributeName;
@@ -157,10 +156,7 @@ export function setValueForProperty(node, name, value) {
156 var propertyInfo = getPropertyInfo(name);
157
158 if (propertyInfo && shouldSetAttribute(name, value)) {
160 - var mutationMethod = propertyInfo.mutationMethod;
161 - if (mutationMethod) {
162 - mutationMethod(node, value);
163 - } else if (shouldIgnoreValue(propertyInfo, value)) {
159 + if (shouldIgnoreValue(propertyInfo, value)) {
160 deleteValueForProperty(node, name);
161 return;
162 } else if (propertyInfo.mustUseProperty) {
@@ -233,10 +229,7 @@ export function deleteValueForAttribute(node, name) {
229 export function deleteValueForProperty(node, name) {
230 var propertyInfo = getPropertyInfo(name);
231 if (propertyInfo) {
236 - var mutationMethod = propertyInfo.mutationMethod;
237 - if (mutationMethod) {
238 - mutationMethod(node, undefined);
239 - } else if (propertyInfo.mustUseProperty) {
232 + if (propertyInfo.mustUseProperty) {
233 var propName = propertyInfo.propertyName;
234 if (propertyInfo.hasBooleanValue) {
235 node[propName] = false;
packages/react-dom/src/client/ReactDOMFiberInput.js
+55 -65
@@ -19,7 +19,7 @@ import * as inputValueTracking from './inputValueTracking';
19
20 type InputWithWrapperState = HTMLInputElement & {
21 _wrapperState: {
22 - initialValue: ?string,
22 + initialValue: string,
23 initialChecked: ?boolean,
24 controlled?: boolean,
25 },
@@ -58,30 +58,14 @@ function isControlled(props) {
58
59 export function getHostProps(element: Element, props: Object) {
60 var node = ((element: any): InputWithWrapperState);
61 - var value = props.value;
61 var checked = props.checked;
62
64 - var hostProps = Object.assign(
65 - {
66 - // Make sure we set .type before any other properties (setting .value
67 - // before .type means .value is lost in IE11 and below)
68 - type: undefined,
69 - // Make sure we set .step before .value (setting .value before .step
70 - // means .value is rounded on mount, based upon step precision)
71 - step: undefined,
72 - // Make sure we set .min & .max before .value (to ensure proper order
73 - // in corner cases such as min or max deriving from value, e.g. Issue #7170)
74 - min: undefined,
75 - max: undefined,
76 - },
77 - props,
78 - {
79 - defaultChecked: undefined,
80 - defaultValue: undefined,
81 - value: value != null ? value : node._wrapperState.initialValue,
82 - checked: checked != null ? checked : node._wrapperState.initialChecked,
83 - },
84 - );
63 + var hostProps = Object.assign({}, props, {
64 + defaultChecked: undefined,
65 + defaultValue: undefined,
66 + value: undefined,
67 + checked: checked != null ? checked : node._wrapperState.initialChecked,
68 + });
69
70 return hostProps;
71 }
@@ -132,7 +116,7 @@ export function initWrapperState(element: Element, props: Object) {
116 }
117 }
118
135 - var defaultValue = props.defaultValue;
119 + var defaultValue = props.defaultValue == null ? '' : props.defaultValue;
120 var node = ((element: any): InputWithWrapperState);
121 node._wrapperState = {
122 initialChecked:
@@ -215,54 +199,34 @@ export function updateWrapper(element: Element, props: Object) {
199 // browsers typically do this as necessary, jsdom doesn't.
200 node.value = '' + value;
201 }
218 - } else {
219 - if (props.value == null && props.defaultValue != null) {
220 - // In Chrome, assigning defaultValue to certain input types triggers input validation.
221 - // For number inputs, the display value loses trailing decimal points. For email inputs,
222 - // Chrome raises "The specified value <x> is not a valid email address".
223 - //
224 - // Here we check to see if the defaultValue has actually changed, avoiding these problems
225 - // when the user is inputting text
226 - //
227 - // https://github.com/facebook/react/issues/7253
228 - if (node.defaultValue !== '' + props.defaultValue) {
229 - node.defaultValue = '' + props.defaultValue;
230 - }
231 - }
232 - if (props.checked == null && props.defaultChecked != null) {
233 - node.defaultChecked = !!props.defaultChecked;
234 - }
202 + synchronizeDefaultValue(node, props.type, value);
203 + } else if (
204 + props.hasOwnProperty('value') ||
205 + props.hasOwnProperty('defaultValue')
206 + ) {
207 + synchronizeDefaultValue(node, props.type, props.defaultValue);
208 + }
209 +
210 + if (props.checked == null && props.defaultChecked != null) {
211 + node.defaultChecked = !!props.defaultChecked;
212 }
213 }
214
215 export function postMountWrapper(element: Element, props: Object) {
216 var node = ((element: any): InputWithWrapperState);
217 + var initialValue = node._wrapperState.initialValue;
218
241 - // Detach value from defaultValue. We won't do anything if we're working on
242 - // submit or reset inputs as those values & defaultValues are linked. They
243 - // are not resetable nodes so this operation doesn't matter and actually
244 - // removes browser-default values (eg "Submit Query") when no value is
245 - // provided.
219 + if (props.value != null || props.defaultValue != null) {
220 + // Do not assign value if it is already set. This prevents user text input
221 + // from being lost during SSR hydration.
222 + if (node.value === '') {
223 + node.value = initialValue;
224 + }
225
247 - switch (props.type) {
248 - case 'submit':
249 - case 'reset':
250 - break;
251 - case 'color':
252 - case 'date':
253 - case 'datetime':
254 - case 'datetime-local':
255 - case 'month':
256 - case 'time':
257 - case 'week':
258 - // This fixes the no-show issue on iOS Safari and Android Chrome:
259 - // https://github.com/facebook/react/issues/7233
260 - node.value = '';
261 - node.value = node.defaultValue;
262 - break;
263 - default:
264 - node.value = node.value;
265 - break;
226 + // value must be assigned before defaultValue. This fixes an issue where the
227 + // visually displayed value of date inputs disappears on mobile Safari and Chrome:
228 + // https://github.com/facebook/react/issues/7233
229 + node.defaultValue = initialValue;
230 }
231
232 // Normally, we'd just do `node.checked = node.checked` upon initial mount, less this bug
@@ -334,3 +298,29 @@ function updateNamedCousins(rootNode, props) {
298 }
299 }
300 }
301 +
302 +// In Chrome, assigning defaultValue to certain input types triggers input validation.
303 +// For number inputs, the display value loses trailing decimal points. For email inputs,
304 +// Chrome raises "The specified value <x> is not a valid email address".
305 +//
306 +// Here we check to see if the defaultValue has actually changed, avoiding these problems
307 +// when the user is inputting text
308 +//
309 +// https://github.com/facebook/react/issues/7253
310 +function synchronizeDefaultValue(
311 + node: InputWithWrapperState,
312 + type: ?string,
313 + value: *,
314 +) {
315 + if (
316 + // Focused number inputs synchronize on blur. See ChangeEventPlugin.js
317 + (type !== 'number' || node.ownerDocument.activeElement !== node) &&
318 + node.defaultValue !== '' + value
319 + ) {
320 + if (value != null) {
321 + node.defaultValue = '' + value;
322 + } else {
323 + node.defaultValue = node._wrapperState.initialValue;
324 + }
325 + }
326 +}
packages/react-dom/src/shared/DOMProperty.js
-12
@@ -54,9 +54,6 @@ var DOMPropertyInjection = {
54 * DOMPropertyNames: similar to DOMAttributeNames but for DOM properties.
55 * Property names not specified use the normalized name.
56 *
57 - * DOMMutationMethods: Properties that require special mutation methods. If
58 - * `value` is undefined, the mutation method should unset the property.
59 - *
57 * @param {object} domPropertyConfig the config as described above.
58 */
59 injectDOMPropertyConfig: function(domPropertyConfig) {
@@ -64,7 +61,6 @@ var DOMPropertyInjection = {
61 var Properties = domPropertyConfig.Properties || {};
62 var DOMAttributeNamespaces = domPropertyConfig.DOMAttributeNamespaces || {};
63 var DOMAttributeNames = domPropertyConfig.DOMAttributeNames || {};
67 - var DOMMutationMethods = domPropertyConfig.DOMMutationMethods || {};
64
65 for (var propName in Properties) {
66 invariant(
@@ -83,7 +79,6 @@ var DOMPropertyInjection = {
79 attributeName: lowerCased,
80 attributeNamespace: null,
81 propertyName: propName,
86 - mutationMethod: null,
82
83 mustUseProperty: checkMask(propConfig, Injection.MUST_USE_PROPERTY),
84 hasBooleanValue: checkMask(propConfig, Injection.HAS_BOOLEAN_VALUE),
@@ -121,10 +116,6 @@ var DOMPropertyInjection = {
116 propertyInfo.attributeNamespace = DOMAttributeNamespaces[propName];
117 }
118
124 - if (DOMMutationMethods.hasOwnProperty(propName)) {
125 - propertyInfo.mutationMethod = DOMMutationMethods[propName];
126 - }
127 -
119 // Downcase references to whitelist properties to check for membership
120 // without case-sensitivity. This allows the whitelist to pick up
121 // `allowfullscreen`, which should be written using the property configuration
@@ -154,9 +145,6 @@ export const ROOT_ATTRIBUTE_NAME = 'data-reactroot';
145 * propertyName:
146 * Used on DOM node instances. (This includes properties that mutate due to
147 * external factors.)
157 - * mutationMethod:
158 - * If non-null, used instead of the property or `setAttribute()` after
159 - * initial render.
148 * mustUseProperty:
149 * Whether the property must be accessed and mutated as an object property.
150 * hasBooleanValue:
packages/react-dom/src/shared/HTMLDOMPropertyConfig.js
-28
@@ -83,34 +83,6 @@ var HTMLDOMPropertyConfig = {
83 htmlFor: 'for',
84 httpEquiv: 'http-equiv',
85 },
86 - DOMMutationMethods: {
87 - value: function(node, value) {
88 - if (value == null) {
89 - return node.removeAttribute('value');
90 - }
91 -
92 - // Number inputs get special treatment due to some edge cases in
93 - // Chrome. Let everything else assign the value attribute as normal.
94 - // https://github.com/facebook/react/issues/7253#issuecomment-236074326
95 - if (node.type !== 'number' || node.hasAttribute('value') === false) {
96 - node.setAttribute('value', '' + value);
97 - } else if (
98 - node.validity &&
99 - !node.validity.badInput &&
100 - node.ownerDocument.activeElement !== node
101 - ) {
102 - // Don't assign an attribute if validation reports bad
103 - // input. Chrome will clear the value. Additionally, don't
104 - // operate on inputs that have focus, otherwise Chrome might
105 - // strip off trailing decimal places and cause the user's
106 - // cursor position to jump to the beginning of the input.
107 - //
108 - // In ReactDOMInput, we have an onBlur event that will trigger
109 - // this function again when focus is lost.
110 - node.setAttribute('value', '' + value);
111 - }
112 - },
113 - },
86 };
87
88 export default HTMLDOMPropertyConfig;