Commit bf5e2cd58fe for woocommerce

commit bf5e2cd58fe986a5b412b1810112ccfc18ce39bc
Author: Francesco <frosso@users.noreply.github.com>
Date:   Thu Sep 10 09:37:12 2026 +0200

    Build valid autocomplete values on block checkout fields (#68394)

diff --git a/plugins/woocommerce/changelog/fix-checkout-autofill-section-prefix b/plugins/woocommerce/changelog/fix-checkout-autofill-section-prefix
new file mode 100644
index 00000000000..89a85071d8e
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-checkout-autofill-section-prefix
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Stop wrapping block checkout autofill hints in a billing/shipping prefix the HTML autofill grammar rejects. Browsers offer saved values on the email field again, and a field an extension set to "off" stays opted out
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 2c7ee9a5b7f..579cb6d4f8b 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
@@ -231,10 +231,16 @@ export const AddressAutocomplete = ( {
 				inputElement.setAttribute( 'autocomplete', 'none' );
 			} else {
 				inputElement.removeAttribute( 'data-1p-ignore' );
-				inputElement.setAttribute(
-					'autocomplete',
-					props.autoComplete || ''
-				);
+				// An empty value is not valid autofill grammar; browsers
+				// guess as if the attribute were absent.
+				if ( props.autoComplete ) {
+					inputElement.setAttribute(
+						'autocomplete',
+						props.autoComplete
+					);
+				} else {
+					inputElement.removeAttribute( 'autocomplete' );
+				}
 			}

 			const parentElement = inputElement.parentElement;
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/address-autocomplete/test/address-autocomplete.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/address-autocomplete/test/address-autocomplete.tsx
index 3af7c030aac..be6f95cdf70 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/address-autocomplete/test/address-autocomplete.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/address-autocomplete/test/address-autocomplete.tsx
@@ -113,4 +113,30 @@ describe( 'Address Autocomplete Component', () => {
 			} );
 		} );
 	} );
+
+	it( 'removes the autocomplete attribute instead of blanking it when the field has no hint', () => {
+		const { container } = render(
+			<AddressAutocomplete
+				addressType="billing"
+				onChange={ () => {} }
+				id="billing_address_1"
+			/>
+		);
+
+		const input = container.querySelector(
+			'#billing_address_1'
+		) as HTMLInputElement;
+
+		input.setAttribute( 'data-disable-autocomplete', 'on' );
+
+		return waitFor( () => {
+			expect( input.getAttribute( 'autocomplete' ) ).toBe( 'none' );
+		} ).then( () => {
+			input.setAttribute( 'data-disable-autocomplete', 'off' );
+
+			return waitFor( () => {
+				expect( input.hasAttribute( 'autocomplete' ) ).toBe( false );
+			} );
+		} );
+	} );
 } );
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/address-line-2-field.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/address-line-2-field.tsx
index f03576b1fb1..75ce9ab5732 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/address-line-2-field.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/address-line-2-field.tsx
@@ -84,7 +84,7 @@ const AddressLine2Field = ( {
 						className="wc-block-components-address-form__address_2-hidden-input"
 						aria-hidden="true"
 						aria-label={ field.label }
-						autoComplete={ field.autocomplete }
+						autoComplete={ props?.autoComplete }
 						id={ props?.id }
 						value={ value }
 						onChange={ ( event ) =>
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/address-line-fields.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/address-line-fields.tsx
index ca2e8d44b63..4f511511912 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/address-line-fields.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/address-line-fields.tsx
@@ -51,8 +51,14 @@ const AddressLineFields = ( {
 		}
 	);

-	const Address1Component =
-		serverProviders.length > 0 ? AddressAutocomplete : ValidatedTextInput;
+	// Address autocomplete keys its providers by billing/shipping, so it only
+	// applies to an address form.
+	const isAddressForm =
+		addressType === 'billing' || addressType === 'shipping';
+	const useAutocomplete = serverProviders.length > 0 && isAddressForm;
+	const Address1Component = useAutocomplete
+		? AddressAutocomplete
+		: ValidatedTextInput;

 	return (
 		<>
@@ -60,7 +66,7 @@ const AddressLineFields = ( {
 				<Address1Component
 					{ ...address1FieldProps }
 					type={ address1.field.type }
-					{ ...( serverProviders.length > 0 ? { addressType } : {} ) }
+					{ ...( useAutocomplete ? { addressType } : {} ) }
 					className={ `wc-block-components-address-form__address_1` }
 					value={ address1.value }
 					onChange={ ( newValue: string ) =>
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/test/autocomplete.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/test/autocomplete.tsx
new file mode 100644
index 00000000000..eed647fbc06
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/test/autocomplete.tsx
@@ -0,0 +1,154 @@
+/**
+ * External dependencies
+ */
+import { render, screen, fireEvent } from '@testing-library/react';
+import { CheckoutProvider } from '@woocommerce/base-context';
+import {
+	ADDRESS_FORM_KEYS,
+	CONTACT_FORM_KEYS,
+} from '@woocommerce/block-settings';
+import type {
+	AddressFormType,
+	AddressFormValues,
+	Field,
+} from '@woocommerce/settings';
+import type { ReactElement } from 'react';
+
+/**
+ * Internal dependencies
+ */
+import AddressLineFields from '../address-line-fields';
+import Form from '../form';
+
+const renderInCheckoutProvider = ( ui: ReactElement ) =>
+	render( <CheckoutProvider>{ ui }</CheckoutProvider> );
+
+const address1Field = (
+	autocomplete: string
+): { field: Field & { key: 'address_1' }; value: string } => ( {
+	field: {
+		index: 0,
+		key: 'address_1',
+		required: true,
+		label: 'Address',
+		optionalLabel: 'Address (optional)',
+		type: 'text',
+		hidden: false,
+		validation: [],
+		autocomplete,
+	},
+	value: '',
+} );
+
+const address2Field: { field: Field & { key: 'address_2' }; value: string } = {
+	field: {
+		index: 1,
+		key: 'address_2',
+		required: false,
+		label: 'Apartment, suite, etc.',
+		optionalLabel: 'Apartment, suite, etc. (optional)',
+		type: 'text',
+		hidden: false,
+		validation: [],
+		autocomplete: 'address-line2',
+	},
+	value: '',
+};
+
+const renderAddressLines = ( {
+	addressType,
+	autocomplete = 'address-line1',
+}: {
+	addressType: AddressFormType;
+	autocomplete?: string;
+} ) =>
+	renderInCheckoutProvider(
+		<AddressLineFields
+			formId="test"
+			address1={ address1Field( autocomplete ) }
+			address2={ address2Field }
+			addressType={ addressType }
+			onChange={ jest.fn() }
+		/>
+	);
+
+describe( 'Checkout field autocomplete attribute', () => {
+	it( 'prefixes a billing address field with the billing section', () => {
+		renderAddressLines( { addressType: 'billing' } );
+
+		expect( screen.getByLabelText( 'Address' ) ).toHaveAttribute(
+			'autocomplete',
+			'section-billing billing address-line1'
+		);
+	} );
+
+	it( 'prefixes a shipping address field with the shipping section', () => {
+		renderAddressLines( { addressType: 'shipping' } );
+
+		expect( screen.getByLabelText( 'Address' ) ).toHaveAttribute(
+			'autocomplete',
+			'section-shipping shipping address-line1'
+		);
+	} );
+
+	it( 'leaves a field opted out of autofill as "off"', () => {
+		renderAddressLines( { addressType: 'billing', autocomplete: 'off' } );
+
+		expect( screen.getByLabelText( 'Address' ) ).toHaveAttribute(
+			'autocomplete',
+			'off'
+		);
+	} );
+
+	it( 'gives the hidden address_2 catcher the value it computed for the visible sibling', () => {
+		renderAddressLines( { addressType: 'billing' } );
+
+		const hiddenInput = screen.getByLabelText( 'Apartment, suite, etc.' );
+		expect( hiddenInput ).toHaveAttribute( 'aria-hidden', 'true' );
+		expect( hiddenInput ).toHaveAttribute(
+			'autocomplete',
+			'section-billing billing address-line2'
+		);
+
+		fireEvent.change( hiddenInput, { target: { value: '4B' } } );
+
+		expect(
+			screen.getByLabelText( 'Apartment, suite, etc. (optional)' )
+		).toHaveAttribute(
+			'autocomplete',
+			'section-billing billing address-line2'
+		);
+	} );
+
+	it( 'keeps the prefix on the country select, which renders through Select', () => {
+		renderInCheckoutProvider(
+			<Form
+				addressType="billing"
+				fields={ ADDRESS_FORM_KEYS }
+				values={ { country: 'GB' } as AddressFormValues }
+				onChange={ jest.fn() }
+			/>
+		);
+
+		expect( screen.getByLabelText( /Country\/Region/ ) ).toHaveAttribute(
+			'autocomplete',
+			'section-billing billing country'
+		);
+	} );
+
+	it( 'does not prefix contact fields, whose address type is not valid autofill grammar', () => {
+		renderInCheckoutProvider(
+			<Form
+				addressType="contact"
+				fields={ CONTACT_FORM_KEYS }
+				values={ { email: '' } }
+				onChange={ jest.fn() }
+			/>
+		);
+
+		expect( screen.getByLabelText( 'Email address' ) ).toHaveAttribute(
+			'autocomplete',
+			'email'
+		);
+	} );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/test/utils.ts b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/test/utils.ts
new file mode 100644
index 00000000000..aa933dc5940
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/test/utils.ts
@@ -0,0 +1,81 @@
+/**
+ * Internal dependencies
+ */
+import { getAutoCompleteValue } from '../utils';
+
+describe( 'getAutoCompleteValue', () => {
+	it( 'prefixes billing and shipping fields with the section and address type', () => {
+		expect( getAutoCompleteValue( 'address-line1', 'billing' ) ).toBe(
+			'section-billing billing address-line1'
+		);
+		expect( getAutoCompleteValue( 'address-line1', 'shipping' ) ).toBe(
+			'section-shipping shipping address-line1'
+		);
+	} );
+
+	it( 'leaves the value bare for address types the autofill grammar does not allow', () => {
+		expect( getAutoCompleteValue( 'email', 'contact' ) ).toBe( 'email' );
+		expect( getAutoCompleteValue( 'email', 'order' ) ).toBe( 'email' );
+		expect( getAutoCompleteValue( 'email', 'anything-else' ) ).toBe(
+			'email'
+		);
+	} );
+
+	it( 'passes on and off through untouched', () => {
+		expect( getAutoCompleteValue( 'off', 'billing' ) ).toBe( 'off' );
+		expect( getAutoCompleteValue( 'on', 'shipping' ) ).toBe( 'on' );
+		expect( getAutoCompleteValue( 'OFF', 'billing' ) ).toBe( 'OFF' );
+		expect( getAutoCompleteValue( ' off ', 'billing' ) ).toBe( 'off' );
+	} );
+
+	it( 'only matches on and off exactly, not values that begin with them', () => {
+		expect( getAutoCompleteValue( 'organization', 'billing' ) ).toBe(
+			'section-billing billing organization'
+		);
+		expect( getAutoCompleteValue( 'one-time-code', 'billing' ) ).toBe(
+			'section-billing billing one-time-code'
+		);
+	} );
+
+	it( 'leaves a value that already carries its own tokens alone', () => {
+		// Prefixing would push it past the token limit and the browser would
+		// drop the whole hint.
+		expect(
+			getAutoCompleteValue( 'shipping address-line1', 'billing' )
+		).toBe( 'shipping address-line1' );
+		expect(
+			getAutoCompleteValue( 'section-work address-line2', 'billing' )
+		).toBe( 'section-work address-line2' );
+	} );
+
+	it( 'normalises the address type it puts in the attribute', () => {
+		expect( getAutoCompleteValue( 'address-line1', ' Billing ' ) ).toBe(
+			'section-billing billing address-line1'
+		);
+	} );
+
+	it( 'returns undefined when the field has no autofill hint', () => {
+		expect( getAutoCompleteValue( undefined, 'billing' ) ).toBeUndefined();
+		expect( getAutoCompleteValue( '', 'billing' ) ).toBeUndefined();
+		expect( getAutoCompleteValue( '   ', 'billing' ) ).toBeUndefined();
+	} );
+
+	it( 'survives a registered field supplying a non-string value', () => {
+		// woocommerce_register_additional_checkout_field() keeps unknown option
+		// values verbatim, so these reach the browser as-is.
+		const nonStrings = [ 123, true, [ 'address-line1' ], { a: 1 }, null ];
+
+		nonStrings.forEach( ( autocomplete ) => {
+			expect(
+				getAutoCompleteValue(
+					autocomplete as unknown as string,
+					'billing'
+				)
+			).toBeUndefined();
+		} );
+
+		expect( getAutoCompleteValue( 'email', 0 as unknown as string ) ).toBe(
+			'email'
+		);
+	} );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/types.ts b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/types.ts
index 1c62f9ef072..480efcf2b67 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/types.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/types.ts
@@ -4,7 +4,7 @@
 import type {
 	FormFields,
 	AddressFormValues,
-	AddressFormType,
+	FormType,
 	AddressForm,
 	ContactFormValues,
 	OrderFormValues,
@@ -20,8 +20,8 @@ export interface FormProps<
 > {
 	// Id for component.
 	id?: string;
-	// Type of form (billing or shipping).
-	addressType?: AddressFormType;
+	// Type of form (billing, shipping, contact or order).
+	addressType?: FormType;
 	// aria-describedby attribute to add to the input.
 	ariaDescribedBy?: string;
 	// Array of fields in form.
@@ -60,7 +60,7 @@ export interface AddressLineFieldsProps
 		value: AddressFormValues[ 'address_2' ];
 	};
 	// Overwriting the address type for the fields.
-	addressType: AddressFormType;
+	addressType: FormType;
 	// Called with the new address data when the address form changes. This is only called when all required fields are filled and there are no validation errors.
 	onChange: ( key: 'address_1' | 'address_2', value: string ) => void;
 }
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/utils.ts b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/utils.ts
index 98e9baa720c..b6c90cac604 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/utils.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/utils.ts
@@ -24,6 +24,62 @@ export interface FieldProps {
 	className: string;
 }

+const SECTIONED_ADDRESS_TYPES = [ 'billing', 'shipping' ];
+
+/**
+ * Build the `autocomplete` attribute value for a checkout field.
+ *
+ * Only `shipping` and `billing` are valid in the address type slot, `on`/`off`
+ * cannot be combined with any other token, and a value that already carries
+ * tokens would overflow the limit. Browsers drop a value they cannot parse and
+ * guess instead, so anything else is passed through as it came.
+ *
+ * @see https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofill
+ *
+ * @param autocomplete     Autofill field name from the field config.
+ * @param fieldAddressType Address type of the form the field belongs to.
+ * @return The attribute value, or undefined when the field has no hint.
+ */
+export const getAutoCompleteValue = (
+	autocomplete: string | undefined,
+	fieldAddressType: string
+): string | undefined => {
+	// Registered field config reaches us from PHP unvalidated, so neither
+	// argument is guaranteed to be the string its type claims.
+	if ( typeof autocomplete !== 'string' ) {
+		return undefined;
+	}
+
+	const value = autocomplete.trim();
+
+	if ( ! value ) {
+		return undefined;
+	}
+
+	const lowerCaseValue = value.toLowerCase();
+
+	if ( lowerCaseValue === 'on' || lowerCaseValue === 'off' ) {
+		return value;
+	}
+
+	// A field that already supplies its own tokens would overflow the token
+	// limit once prefixed, and the browser drops the whole value.
+	if ( value.includes( ' ' ) ) {
+		return value;
+	}
+
+	const addressType =
+		typeof fieldAddressType === 'string'
+			? fieldAddressType.trim().toLowerCase()
+			: '';
+
+	if ( ! SECTIONED_ADDRESS_TYPES.includes( addressType ) ) {
+		return value;
+	}
+
+	return `section-${ addressType } ${ addressType } ${ value }`;
+};
+
 export const createFieldProps = (
 	field: KeyedParsedFormFields[ number ],
 	formId: string,
@@ -34,12 +90,7 @@ export const createFieldProps = (
 	name: `${ fieldAddressType }_${ field?.key }`,
 	label: ( field?.required ? field?.label : field?.optionalLabel ) || '',
 	autoCapitalize: field?.autocapitalize,
-	// Prefix autocomplete value with section and address type per HTML spec.
-	// Format: section-<name> [shipping|billing] <autofill-field>
-	// e.g., 'address-level1' becomes 'section-billing billing address-level1'
-	autoComplete: field?.autocomplete
-		? `section-${ fieldAddressType } ${ fieldAddressType } ${ field.autocomplete }`
-		: undefined,
+	autoComplete: getAutoCompleteValue( field?.autocomplete, fieldAddressType ),
 	errorMessage: field?.errorMessage || '',
 	required: field?.required,
 	placeholder: field?.placeholder,