Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion packages/react-dom-bindings/src/client/ReactDOMInput.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <input> host component that allows setting these optional
* props: `checked`, `value`, `defaultChecked`, and `defaultValue`.
Expand Down Expand Up @@ -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) {
Expand Down
60 changes: 60 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMInput-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<input
type="time"
value={this.state.value}
onChange={this.handleChange}
/>
);
}
}

await act(() => {
root.render(<Stub />);
});
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(<input type="text" value="0" readOnly={true} />);
Expand Down