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();