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,