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",