From 82581fcd86019bf56b01861bdfe2277c0dab79c2 Mon Sep 17 00:00:00 2001 From: DukeDeSouth Date: Sat, 7 Feb 2026 20:00:42 -0500 Subject: [PATCH] fix: detect autofilled values on focus for controlled inputs (#1159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When browsers autofill form fields (e.g., username/password saved in browser, password managers, iOS Chrome autofill), they may set the input value without firing input or change events. This causes controlled components to have stale state while the user sees filled data. The fix adds value change detection on focusin events for text inputs. When a user focuses an autofilled field, React now checks if the DOM value differs from its tracked value and fires onChange if so. This uses the existing value tracking infrastructure (updateValueIfChanged) which safely deduplicates — if the value hasn't changed, no event fires. This covers the majority of autofill scenarios because: - Browsers expose autofill values after first user interaction - focusin fires when the user clicks/taps any field - Each field gets checked as the user interacts with the form Closes https://github.com/facebook/react/issues/1159 Co-authored-by: Cursor --- .../src/events/plugins/ChangeEventPlugin.js | 7 ++ .../src/__tests__/ReactDOMInput-test.js | 74 +++++++++++++++++++ 2 files changed, 81 insertions(+) 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']); + }); });