diff --git a/packages/react-dom-bindings/src/client/ReactDOMInput.js b/packages/react-dom-bindings/src/client/ReactDOMInput.js index b6e665e12883..249571008e5a 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMInput.js +++ b/packages/react-dom-bindings/src/client/ReactDOMInput.js @@ -27,6 +27,21 @@ import {queueChangeEvent} from '../events/ReactDOMEventReplaying'; let didWarnValueDefaultValue = false; let didWarnCheckedDefaultChecked = false; +// Input types whose UI provides a Reset/Clear button on iOS Safari and +// Chrome. For these, the browser uses the `defaultValue` as the Reset +// target, so we must not keep `defaultValue` in sync with `value` after +// the initial mount. +function isDateOrTimeInputType(type: ?string): boolean { + return ( + type === 'date' || + type === 'time' || + type === 'datetime' || + type === 'datetime-local' || + type === 'month' || + type === 'week' + ); +} + /** * Implements an host component that allows setting these optional * props: `checked`, `value`, `defaultChecked`, and `defaultValue`. @@ -154,7 +169,17 @@ export function updateInput( // 2. The defaultValue React property // 3. Otherwise there should be no change if (value != null) { - setDefaultValue(node, type, getToStringValue(value)); + // For date/time-style inputs we intentionally do not keep the + // `defaultValue` (i.e. the `value` HTML attribute) in sync with the + // current `value` property after the initial mount. iOS Safari/Chrome + // use the `defaultValue` as the Reset/Clear target; if we keep it in + // sync, pressing Reset becomes a no-op at the DOM level and the + // browser-fired native `change` event arrives with a value that + // matches the value tracker, causing React to suppress the synthetic + // `onChange`. See https://github.com/facebook/react/issues/23299. + if (!isDateOrTimeInputType(type)) { + setDefaultValue(node, type, getToStringValue(value)); + } } else if (defaultValue != null) { setDefaultValue(node, type, getToStringValue(defaultValue)); } else if (lastDefaultValue != null) { diff --git a/packages/react-dom/src/__tests__/ReactDOMInput-test.js b/packages/react-dom/src/__tests__/ReactDOMInput-test.js index 04bd96fe2e83..d6a7afb0ba61 100644 --- a/packages/react-dom/src/__tests__/ReactDOMInput-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMInput-test.js @@ -640,6 +640,66 @@ describe('ReactDOMInput', () => { }); }); + // Regression test for https://github.com/facebook/react/issues/23299. + // For controlled date/time-style inputs, React must not keep + // `node.defaultValue` in sync with `value` on every update. iOS Safari and + // Chrome use `defaultValue` as the target of the Reset/Clear button; if + // they stay in sync, pressing Reset becomes a DOM no-op and the synthetic + // `onChange` is suppressed because the value tracker sees no change. + it('should not sync defaultValue with value on controlled time inputs', async () => { + let lastChangeValue = null; + let changeCount = 0; + class Stub extends React.Component { + state = {value: '00:30'}; + handleChange = e => { + changeCount++; + lastChangeValue = e.target.value; + this.setState({value: e.target.value}); + }; + render() { + return ( + + ); + } + } + + await act(() => { + root.render(); + }); + const node = container.firstChild; + + // Simulate the user editing the input to "00:31". + setUntrackedValue.call(node, '00:31'); + await act(() => { + dispatchEventOnNode(node, 'input'); + }); + expect(changeCount).toBe(1); + expect(lastChangeValue).toBe('00:31'); + expect(node.value).toBe('00:31'); + + // The Reset/Clear button on iOS reverts `node.value` to + // `node.defaultValue`. For the fix to be meaningful, React must NOT have + // synced `defaultValue` up to the new "00:31" — it should still be the + // initial value ("00:30") rendered at mount. + if (!disableInputAttributeSyncing) { + expect(node.defaultValue).toBe('00:30'); + } + + // Now simulate iOS dispatching a native `change` event after the Reset + // operation reverted the value. + setUntrackedValue.call(node, node.defaultValue); + await act(() => { + dispatchEventOnNode(node, 'change'); + }); + + expect(changeCount).toBe(2); + expect(lastChangeValue).toBe('00:30'); + }); + it('should take `defaultValue` when changing to uncontrolled input', async () => { await act(() => { root.render();