diff --git a/packages/react-dom-bindings/src/events/plugins/ChangeEventPlugin.js b/packages/react-dom-bindings/src/events/plugins/ChangeEventPlugin.js index fff7d0a1bb8c..421ab9565709 100644 --- a/packages/react-dom-bindings/src/events/plugins/ChangeEventPlugin.js +++ b/packages/react-dom-bindings/src/events/plugins/ChangeEventPlugin.js @@ -258,6 +258,13 @@ function getTargetInstForInputOrChangeEvent( if (domEventName === 'input' || domEventName === 'change') { return getInstIfValueChanged(targetInst); } + // Detect browser autofill: browsers may set input values without firing + // input or change events (e.g., Chrome on iOS, password managers). + // When the user focuses an autofilled field, check if the value changed. + // See https://github.com/facebook/react/issues/1159 + if (domEventName === 'focusin') { + return getInstIfValueChanged(targetInst); + } } function handleControlledInputBlur(node: HTMLInputElement, props: any) { diff --git a/packages/react-dom/src/__tests__/ReactDOMInput-test.js b/packages/react-dom/src/__tests__/ReactDOMInput-test.js index 04bd96fe2e83..91af87e7c0b2 100644 --- a/packages/react-dom/src/__tests__/ReactDOMInput-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMInput-test.js @@ -3109,4 +3109,78 @@ describe('ReactDOMInput', () => { expect(log).toEqual(['']); expect(node.value).toBe('a'); }); + + // @see https://github.com/facebook/react/issues/1159 + it('should detect autofilled value on focus for controlled inputs', async () => { + const log = []; + function onChange(e) { + log.push(e.target.value); + } + await act(() => { + root.render(); + }); + + const node = container.firstChild; + + // Simulate browser autofill: set value bypassing React's tracked setter. + // This mimics what happens when the browser fills a field without firing + // input or change events (e.g., Chrome on iOS, password managers). + setUntrackedValue.call(node, 'autofilled@example.com'); + + // No input or change event was fired, so React hasn't noticed yet. + expect(log).toEqual([]); + + // When the user focuses the field, React should detect the changed value. + await act(() => { + dispatchEventOnNode(node, 'focusin'); + }); + + expect(log).toEqual(['autofilled@example.com']); + }); + + it('should not fire extra onChange on focus when value has not changed', async () => { + const log = []; + function onChange(e) { + log.push(e.target.value); + } + await act(() => { + root.render(); + }); + + const node = container.firstChild; + + // Focus without changing value — should not fire onChange. + await act(() => { + dispatchEventOnNode(node, 'focusin'); + }); + + expect(log).toEqual([]); + }); + + it('should not fire duplicate onChange on focus after input event', async () => { + const log = []; + function onChange(e) { + log.push(e.target.value); + } + await act(() => { + root.render(); + }); + + const node = container.firstChild; + + // Simulate autofill that fires an input event (normal browser behavior). + setUntrackedValue.call(node, 'test@test.com'); + await act(() => { + dispatchEventOnNode(node, 'input'); + }); + + expect(log).toEqual(['test@test.com']); + + // Focus should not fire a second onChange since the tracker is in sync. + await act(() => { + dispatchEventOnNode(node, 'focusin'); + }); + + expect(log).toEqual(['test@test.com']); + }); });