Commit af33ce1c227 for woocommerce
commit af33ce1c2278317d2409f548cc811819f7151cba
Author: Mike Jolley <mike.jolley@me.com>
Date: Wed Apr 8 11:51:43 2026 +0100
Checkout: Prevent address autocomplete from triggering on browser autofill (#63902)
* Checkout: Prevent address autocomplete from triggering on browser autofill
The AddressAutocomplete component fired provider searches (e.g. Google Places)
whenever the address_1 field value changed — including when the browser's native
autofill populated the field. This caused confusing double-autocomplete UX,
potential data corruption, and wasted API calls.
Added three-layer detection to distinguish browser autofill from user typing:
1. Native InputEvent.inputType check — Chrome/Firefox autofill fires
"insertReplacementText" rather than "insertText"
2. :-webkit-autofill CSS pseudo-class check — Safari Contacts applies this
after character-by-character fill; search results are discarded if detected
3. Keyboard-event gating fallback — only trigger search when keydown events
precede the onChange, covering password managers and other extensions
Also added a search generation counter to discard stale results from superseded
searches, and fixed a missing @woocommerce/sanitize Jest module mapping that
was preventing all address-autocomplete tests from running.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Add changefile(s) from automation for the following project(s): woocommerce
* Fix eslint dependency-group comment in mock-provider
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Remove redundant keydown typing detection
The native input event listener already covers all inputType cases
(insertText, insertCompositionText, deleteContentBackward/Forward).
The keydown handler was unnecessary duplication.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Address CodeRabbit feedback: expand inputType allowlist and clear state on autofill
- Add paste, drag-drop, cut, undo/redo inputTypes to the user-typing allowlist
- Clear searchValue and suggestions when insertReplacementText autofill detected
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Simplify inputType check: denylist instead of allowlist
Treat any inputType that isn't insertReplacementText as user input
rather than maintaining an explicit allowlist of known inputTypes.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix prettier formatting in browser-autofill test
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/63902-wooplug-6341-address-autocomplete-cannot-distinguish-between-a-user b/plugins/woocommerce/changelog/63902-wooplug-6341-address-autocomplete-cannot-distinguish-between-a-user
new file mode 100644
index 00000000000..ba4253e6573
--- /dev/null
+++ b/plugins/woocommerce/changelog/63902-wooplug-6341-address-autocomplete-cannot-distinguish-between-a-user
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix address autocomplete dropdown appearing after browser autofill on checkout
\ No newline at end of file
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/address-autocomplete/address-autocomplete.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/address-autocomplete/address-autocomplete.tsx
index 72413f97f73..528909b261f 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/address-autocomplete/address-autocomplete.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/address-autocomplete/address-autocomplete.tsx
@@ -23,6 +23,24 @@ import './style.scss';
import { Suggestions } from './suggestions';
import { useUpdatePreferredAutocompleteProvider } from '../../../hooks/use-update-preferred-autocomplete-provider';
+// Maximum gap (ms) between a keydown event and the resulting React onChange.
+// After this window, userIsTypingRef resets to false so that programmatic
+// value changes (e.g. late-arriving autofill) are not mistaken for typing.
+const TYPING_RESET_MS = 200;
+
+/**
+ * Check whether an element is currently in the browser's autofill state.
+ * Safari Contacts applies :-webkit-autofill during/after its fill.
+ * Firefox does not support this selector and throws, so we catch that.
+ */
+function isAutofilled( el: HTMLInputElement ): boolean {
+ try {
+ return el.matches( ':-webkit-autofill' );
+ } catch {
+ return false;
+ }
+}
+
/**
* Address Autocomplete component.
*
@@ -123,6 +141,15 @@ export const AddressAutocomplete = ( {
const [ isSettingAddress, setIsSettingAddress ] = useState( false );
const suppressSearchTimeoutRef = useRef< NodeJS.Timeout | null >( null );
+ // Tracks whether the current value change originated from the user typing
+ // (as opposed to browser autofill). Browser autofill fires onChange but
+ // does NOT fire keydown events, and its native input event has
+ // inputType "insertReplacementText" rather than "insertText".
+ const userIsTypingRef = useRef( false );
+ const typingTimeoutRef = useRef< NodeJS.Timeout | null >( null );
+ const autofillDetectedRef = useRef( false );
+ const searchGenerationRef = useRef( 0 );
+
// Trigger search when searchValue changes
useEffect( () => {
if (
@@ -141,9 +168,23 @@ export const AddressAutocomplete = ( {
];
if ( provider ) {
+ const generation = ++searchGenerationRef.current;
provider
.search( searchValue, country )
.then( ( results ) => {
+ // Discard stale results from a superseded search.
+ if ( generation !== searchGenerationRef.current ) {
+ return;
+ }
+
+ // Discard results if the field entered browser autofill
+ // state while the search was in-flight (Safari Contacts).
+ const el = inputRef.current?.inputRef?.current;
+ if ( el && isAutofilled( el ) ) {
+ setSuggestions( [] );
+ return;
+ }
+
if ( results && results.length ) {
setSuggestions( results );
} else {
@@ -163,6 +204,9 @@ export const AddressAutocomplete = ( {
if ( suppressSearchTimeoutRef.current ) {
clearTimeout( suppressSearchTimeoutRef.current );
}
+ if ( typingTimeoutRef.current ) {
+ clearTimeout( typingTimeoutRef.current );
+ }
};
}, [] );
@@ -232,12 +276,66 @@ export const AddressAutocomplete = ( {
};
}, [ props.autoComplete ] );
+ // Detect browser autofill vs user typing via the native input event's
+ // inputType property. Browser autofill produces "insertReplacementText"
+ // while user typing produces "insertText" (or "insertCompositionText"
+ // for CJK/IME input). This listener sets flags that addressChangeHandler
+ // checks before triggering a provider search.
+ useEffect( () => {
+ const inputElement = inputRef.current?.inputRef?.current;
+ if ( ! inputElement ) {
+ return;
+ }
+
+ const handleNativeInput = ( event: Event ) => {
+ const inputEvent = event as InputEvent;
+ const { inputType } = inputEvent;
+
+ if ( inputType === 'insertReplacementText' ) {
+ // Browser autofill (Chrome/Firefox). Flag it so
+ // addressChangeHandler suppresses the search.
+ autofillDetectedRef.current = true;
+ } else if ( inputType ) {
+ // Any other known inputType is user-initiated (typing,
+ // paste, drag-drop, IME, undo/redo, etc.).
+ userIsTypingRef.current = true;
+ if ( typingTimeoutRef.current ) {
+ clearTimeout( typingTimeoutRef.current );
+ }
+ typingTimeoutRef.current = setTimeout( () => {
+ userIsTypingRef.current = false;
+ }, TYPING_RESET_MS );
+ }
+ };
+
+ inputElement.addEventListener( 'input', handleNativeInput );
+ return () => {
+ inputElement.removeEventListener( 'input', handleNativeInput );
+ };
+ }, [] );
+
const addressChangeHandler = ( value: string ) => {
props.onChange( value );
// Don't trigger search when we're programmatically setting the address
- // or when search is temporarily suppressed after address selection
- if ( ! isSettingAddress && ! suppressSearchTimeoutRef.current ) {
+ // or when search is temporarily suppressed after address selection.
+ if ( isSettingAddress || suppressSearchTimeoutRef.current ) {
+ return;
+ }
+
+ // Suppress search when browser autofill was detected via inputType
+ // (Chrome/Firefox fire "insertReplacementText" for autofill).
+ if ( autofillDetectedRef.current ) {
+ autofillDetectedRef.current = false;
+ setSearchValue( '' );
+ setSuggestions( [] );
+ return;
+ }
+
+ // Only trigger search when keyboard or IME activity preceded the
+ // change. This is the fallback for browsers that don't support
+ // inputType or :-webkit-autofill.
+ if ( userIsTypingRef.current ) {
setSearchValue( value );
}
};
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/address-autocomplete/test/browser-autofill.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/address-autocomplete/test/browser-autofill.tsx
new file mode 100644
index 00000000000..79996d9a7a4
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/address-autocomplete/test/browser-autofill.tsx
@@ -0,0 +1,283 @@
+/**
+ * External dependencies
+ */
+import { act, render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { useState } from '@wordpress/element';
+import * as wpData from '@wordpress/data';
+import { cartStore } from '@woocommerce/block-data';
+import type { StoreDescriptor } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import { AddressAutocomplete } from '../address-autocomplete';
+import {
+ createMockProvider,
+ installMockProvider,
+ type MockProvider,
+} from './utils/mock-provider';
+
+// --- Mocks (same pattern as integration.tsx) ---
+
+const mockUseCheckoutAddress = jest.fn();
+jest.mock( '@woocommerce/base-context', () => ( {
+ ...jest.requireActual( '@woocommerce/base-context' ),
+ useCheckoutAddress: () => mockUseCheckoutAddress(),
+} ) );
+
+jest.mock( '@wordpress/data', () => ( {
+ __esModule: true,
+ ...jest.requireActual( '@wordpress/data' ),
+ useSelect: jest.fn(),
+ useDispatch: jest.fn(),
+} ) );
+
+wpData.useSelect.mockImplementation(
+ jest.fn().mockImplementation( ( passedMapSelect ) => {
+ const mockedSelect = jest.fn().mockImplementation( ( storeName ) => {
+ if ( storeName === 'wc/store/cart' || storeName === cartStore ) {
+ return {
+ getCartData() {
+ return {
+ shippingAddress: { country: 'DE' },
+ billingAddress: { country: 'DE' },
+ };
+ },
+ };
+ }
+ return jest.requireActual( '@wordpress/data' ).select( storeName );
+ } );
+ return passedMapSelect( mockedSelect, {
+ dispatch: jest.requireActual( '@wordpress/data' ).dispatch,
+ } );
+ } )
+);
+
+wpData.useDispatch.mockImplementation( ( store: StoreDescriptor | string ) => {
+ if ( store === cartStore || store === 'wc/store/cart' ) {
+ return {
+ ...jest.requireActual( '@wordpress/data' ).useDispatch( store ),
+ setShippingAddress: jest.fn(),
+ setBillingAddress: jest.fn(),
+ };
+ }
+ return jest.requireActual( '@wordpress/data' ).useDispatch( store );
+} );
+
+jest.mock( '@woocommerce/settings', () => ( {
+ ...jest.requireActual( '@woocommerce/settings' ),
+ getSettingWithCoercion: jest
+ .fn()
+ .mockImplementation( ( value, fallback, typeguard ) => {
+ if ( value === 'addressAutocompleteProviders' ) {
+ return [
+ {
+ id: 'mock-test-provider',
+ name: 'Mock Test Provider',
+ branding_html: '<div>Mock Provider</div>',
+ },
+ ];
+ }
+ return jest
+ .requireActual( '@woocommerce/settings' )
+ .getSettingWithCoercion( value, fallback, typeguard );
+ } ),
+} ) );
+
+// --- Helper ---
+
+const TestAddressField = ( {
+ addressType = 'shipping' as const,
+}: {
+ addressType?: 'shipping' | 'billing';
+} ) => {
+ const [ value, setValue ] = useState( '' );
+ return (
+ <AddressAutocomplete
+ addressType={ addressType }
+ id={ `${ addressType }-address_1` }
+ label="Address 1"
+ onChange={ setValue }
+ value={ value }
+ />
+ );
+};
+
+/**
+ * Dispatch a native InputEvent with the given inputType on the element.
+ * This simulates what the browser does for autofill (insertReplacementText)
+ * or user typing (insertText).
+ */
+function fireNativeInputEvent(
+ element: HTMLInputElement,
+ value: string,
+ inputType: string
+) {
+ // Set the value on the native element (as the browser would).
+ const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
+ HTMLInputElement.prototype,
+ 'value'
+ )?.set;
+ nativeInputValueSetter?.call( element, value );
+
+ // Dispatch the native input event with the specified inputType.
+ const inputEvent = new InputEvent( 'input', {
+ bubbles: true,
+ cancelable: false,
+ inputType,
+ data: value,
+ } );
+ element.dispatchEvent( inputEvent );
+}
+
+// --- Tests ---
+
+describe( 'Browser autofill vs user typing — WOOPLUG-6341', () => {
+ let mockProvider: MockProvider;
+
+ beforeEach( () => {
+ mockUseCheckoutAddress.mockReturnValue( {
+ useShippingAsBilling: false,
+ useBillingAsShipping: false,
+ } );
+ mockProvider = createMockProvider();
+ installMockProvider( mockProvider );
+ } );
+
+ it( 'triggers search when user types into the field', async () => {
+ render( <TestAddressField /> );
+ const input = screen.getByLabelText( 'Address 1' );
+
+ await act( async () => {
+ await userEvent.type( input, '123 Main' );
+ } );
+
+ await waitFor(
+ () => {
+ expect( mockProvider.search ).toHaveBeenCalled();
+ },
+ { timeout: 3000 }
+ );
+ } );
+
+ it( 'should NOT trigger search when browser autofill fires insertReplacementText', async () => {
+ render( <TestAddressField /> );
+ const input = screen.getByLabelText( 'Address 1' ) as HTMLInputElement;
+
+ // Simulate browser autofill: dispatch a native input event with
+ // inputType "insertReplacementText", which is what Chrome/Firefox
+ // produce when the browser's saved-address autofill populates a field.
+ await act( async () => {
+ fireNativeInputEvent(
+ input,
+ '742 Evergreen Terrace, Springfield',
+ 'insertReplacementText'
+ );
+ } );
+
+ // Give the search effect time to fire.
+ await act( async () => {
+ await new Promise( ( resolve ) => setTimeout( resolve, 250 ) );
+ } );
+
+ expect( mockProvider.search ).not.toHaveBeenCalled();
+ } );
+
+ it( 'should NOT trigger search when value changes without keyboard input (fallback detection)', async () => {
+ render( <TestAddressField /> );
+ const input = screen.getByLabelText( 'Address 1' ) as HTMLInputElement;
+
+ // Simulate a programmatic value change with no inputType and no
+ // preceding keydown — e.g. a password manager or extension that
+ // sets the value via script. The fallback keyboard-gating should
+ // prevent search since userIsTypingRef was never set.
+ await act( async () => {
+ fireNativeInputEvent(
+ input,
+ '742 Evergreen Terrace, Springfield',
+ ''
+ );
+ } );
+
+ await act( async () => {
+ await new Promise( ( resolve ) => setTimeout( resolve, 250 ) );
+ } );
+
+ expect( mockProvider.search ).not.toHaveBeenCalled();
+ } );
+
+ it( 'should discard search results when :-webkit-autofill is detected (Safari Contacts)', async () => {
+ render( <TestAddressField /> );
+ const input = screen.getByLabelText( 'Address 1' ) as HTMLInputElement;
+
+ // Safari Contacts fills character-by-character with insertText events
+ // (indistinguishable from typing). A search may fire, but by the time
+ // results resolve the field will have :-webkit-autofill applied.
+ // Mock matches() to simulate this state so results are discarded.
+ const originalMatches = input.matches.bind( input );
+ input.matches = ( selector: string ) => {
+ if ( selector === ':-webkit-autofill' ) {
+ return true;
+ }
+ return originalMatches( selector );
+ };
+
+ // Type enough to trigger a search (simulates Safari Contacts fill).
+ await act( async () => {
+ await userEvent.type( input, '742 Evergreen' );
+ } );
+
+ await waitFor(
+ () => {
+ // The search should have been called (characters looked like typing).
+ expect( mockProvider.search ).toHaveBeenCalled();
+ },
+ { timeout: 3000 }
+ );
+
+ // But because :-webkit-autofill matched when results came back,
+ // no suggestions should be visible.
+ expect(
+ document.querySelector(
+ '.wc-block-components-address-autocomplete-suggestions'
+ )
+ ).toBeNull();
+
+ // Restore original matches.
+ input.matches = originalMatches;
+ } );
+
+ it( 'resumes search normally after autofill if user starts typing again', async () => {
+ render( <TestAddressField /> );
+ const input = screen.getByLabelText( 'Address 1' );
+
+ // First: browser autofill (should NOT trigger search)
+ await act( async () => {
+ fireNativeInputEvent(
+ input as HTMLInputElement,
+ '742 Evergreen Terrace',
+ 'insertReplacementText'
+ );
+ } );
+
+ await act( async () => {
+ await new Promise( ( resolve ) => setTimeout( resolve, 250 ) );
+ } );
+
+ mockProvider.search.mockClear();
+
+ // Then: user clears and types manually (SHOULD trigger search)
+ await act( async () => {
+ await userEvent.clear( input );
+ await userEvent.type( input, '456 Oak' );
+ } );
+
+ await waitFor(
+ () => {
+ expect( mockProvider.search ).toHaveBeenCalled();
+ },
+ { timeout: 3000 }
+ );
+ } );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/address-autocomplete/test/utils/mock-provider.ts b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/address-autocomplete/test/utils/mock-provider.ts
new file mode 100644
index 00000000000..8fac21de7e8
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/address-autocomplete/test/utils/mock-provider.ts
@@ -0,0 +1,74 @@
+/**
+ * A mock address autocomplete provider for testing.
+ *
+ * Returns deterministic results and exposes jest spies on all methods
+ * so tests can assert on search/select calls.
+ */
+
+/**
+ * External dependencies
+ */
+import type { ClientAddressAutocompleteProvider } from '@woocommerce/types';
+
+export const MOCK_PROVIDER_ID = 'mock-test-provider';
+
+export const MOCK_SEARCH_RESULTS = [
+ {
+ label: '123 Example St, Berlin, Germany',
+ id: 'result-1',
+ matchedSubstrings: [ { length: 3, offset: 0 } ],
+ },
+ {
+ label: '456 Sample Rd, Munich, Germany',
+ id: 'result-2',
+ matchedSubstrings: [ { length: 3, offset: 0 } ],
+ },
+];
+
+export const MOCK_SELECTED_ADDRESS = {
+ address_1: '123 Example St',
+ address_2: '',
+ city: 'Berlin',
+ state: 'BE',
+ postcode: '10115',
+ country: 'DE',
+};
+
+export interface MockProvider extends ClientAddressAutocompleteProvider {
+ search: jest.Mock;
+ select: jest.Mock;
+ canSearch: jest.Mock;
+}
+
+/**
+ * Creates a fresh mock provider with jest spies. Call this in beforeEach
+ * to get isolated spy state per test.
+ */
+export function createMockProvider(): MockProvider {
+ return {
+ id: MOCK_PROVIDER_ID,
+ canSearch: jest.fn().mockReturnValue( true ),
+ search: jest.fn().mockResolvedValue( MOCK_SEARCH_RESULTS ),
+ select: jest.fn().mockResolvedValue( MOCK_SELECTED_ADDRESS ),
+ };
+}
+
+/**
+ * Installs the given mock provider onto window.wc.addressAutocomplete
+ * as the active provider for both billing and shipping.
+ */
+export function installMockProvider( provider: MockProvider ): void {
+ window.wc = {
+ ...( window.wc || {} ),
+ addressAutocomplete: {
+ providers: { [ MOCK_PROVIDER_ID ]: provider },
+ activeProvider: {
+ billing: provider,
+ shipping: provider,
+ },
+ registerAddressAutocompleteProvider( p ) {
+ return !! p;
+ },
+ },
+ };
+}
diff --git a/plugins/woocommerce/client/blocks/tests/js/jest.config.json b/plugins/woocommerce/client/blocks/tests/js/jest.config.json
index 8875d80decf..e1d14755539 100644
--- a/plugins/woocommerce/client/blocks/tests/js/jest.config.json
+++ b/plugins/woocommerce/client/blocks/tests/js/jest.config.json
@@ -38,6 +38,7 @@
"@woocommerce/shared-hocs": "assets/js/shared/hocs",
"@woocommerce/blocks-test-utils/(.*)$": "tests/utils/$1",
"@woocommerce/blocks-test-utils": "tests/utils",
+ "@woocommerce/sanitize": "<rootDir>/node_modules/@woocommerce/sanitize/src",
"@woocommerce/types": "assets/js/types",
"@woocommerce/utils": "assets/js/utils",
"@woocommerce/test-utils/msw": "tests/js/config/msw-setup.js",