Commit 367a4f16f33 for woocommerce

commit 367a4f16f33a73c1356804b32d8e271a728706e7
Author: Mike Jolley <mike.jolley@me.com>
Date:   Thu Apr 9 11:09:19 2026 +0100

    Fix locale filter breaking address forms when hiding country or state fields (#63928)

    * Fix block checkout locale filter hiding country field breaks all address fields

    When using woocommerce_get_country_locale to set country as hidden, the
    form's hidden-field clearing effect would blank the country value to ''.
    Since country is the lookup key for all locale overrides, this caused
    every address field to lose its locale config and revert to defaults.

    - Expose store base country as `baseCountry` setting from Cart and
      Checkout block PHP classes
    - When country is hidden via locale, set its value to the store's base
      country instead of clearing it, ensuring locale resolution continues
      working for all other fields
    - Apply the same base country fallback in emptyHiddenAddressFields
    - Add e2e test with locale filter plugin that hides country + address
      fields, verifying fields are hidden and checkout completes successfully

    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

    * Fix classic checkout locale filter not hiding country, state, and phone fields

    - Add country and phone to get_country_locale_field_selectors() so
      address-i18n.js can process locale overrides for these fields
    - Pass base_country to wc_address_i18n_params for the country fallback
    - Restructure hidden field logic in address-i18n.js: locale hidden now
      applies to all fields including state (previously skipped entirely),
      while .show() still skips state since country-select.js manages its
      visibility
    - When country is hidden, set value to store base country instead of
      clearing it

    Covers classic shortcode checkout, My Account edit address, and the
    shipping calculator.

    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

    * Add changelog entry for locale hidden country field fix

    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

    * Fix lint issues in locale-hide-country test plugin

    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

    * Fix prettier formatting in locale-hide-country e2e test

    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

    * Simplify: extract shared hidden field helper and guard baseCountry registration

    - Extract getHiddenFieldValue() in address.ts to deduplicate the
      country-fallback logic between form.tsx and emptyHiddenAddressFields
    - Add early return in form.tsx useEffect when no fields are hidden
    - Guard baseCountry asset registration with exists() in Cart.php and
      Checkout.php to prevent double-registration

    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

    * Fix shipping calculator toggle using locale-hidden count instead of :visible

    The calculator form starts collapsed (display:none), so :visible on child
    rows always reports false regardless of locale state. Track locale-hidden
    field count during the iteration loop and use that to toggle the button.

    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

    * Add phone to default address fields so locale handler has proper metadata

    Phone was missing from get_default_address_fields(), so the locale
    handler in address-i18n.js had no default locale entry for it. This
    caused the handler to strip the required marker from phone fields
    when iterating locale selectors.

    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

    * Fix state showing as optional when country is hidden in classic checkout

    When the locale handler hides the country field and sets it to the
    store base country, the value change was not triggering a re-run of
    country-select.js. This meant the locale for other fields (especially
    state required/label) was resolved against the initial country value
    rather than the base country.

    Now defers a change trigger on the country input after the locale
    loop completes, causing country-select.js to re-fire
    country_to_state_changing with the correct country value.

    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

    * Simplify: deduplicate val assignment and cache phone visibility

    - Collapse redundant $input.val(newVal) from both if/else branches
      into a single assignment with a prevVal comparison for clarity
    - Cache CartCheckoutUtils::get_phone_field_visibility() result in a
      local variable in get_default_address_fields()

    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

    * Remove afterEach hook from locale-hide-country e2e test

    The blocks e2e suite restores the database between tests, making
    explicit plugin deactivation in afterEach unnecessary. The
    playwright/no-hooks rule only allows beforeEach.

    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

    * Inline phone visibility call and fix fragile test assumption

    Use CartCheckoutUtils::get_phone_field_visibility() inline, consistent
    with how company and address_2 fields are handled. Fix the Totals test
    to explicitly set phone to optional rather than relying on the default.

    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

    * Fix phone required state in default address fields and update test

    Use the phone visibility setting for the required state in
    get_default_address_fields(), consistent with company and address_2.
    Fix the Totals test to explicitly set phone to optional and reset
    the locale cache, since the default is required for classic checkout.

    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

    * Ignore locale hidden for country field instead of falling back to base country

    Country is the lookup key for locale resolution — hiding it via
    woocommerce_get_country_locale creates a chicken-and-egg problem and
    can leave shoppers stuck with no way to change their country. Instead
    of making hidden-country work with a base country fallback, ignore the
    hidden flag for country entirely. Merchants who sell to a single
    country should use "Sell to specific countries" instead.

    This simplifies the implementation by removing the baseCountry
    plumbing (PHP asset registration, JS constants, hidden field value
    helper) and the deferred country change trigger in address-i18n.js.

    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

    * Enforce country as always required in locale processing

    Country is the lookup key for locale resolution and must never be
    optional. Both block checkout (prepare-form-fields.ts) and classic
    checkout (address-i18n.js) now enforce required=true for the country
    field regardless of what the locale config says.

    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

    * Clear locale-hidden field values in useCheckoutAddress

    The address card and form both consume addresses from
    useCheckoutAddress. Previously, hidden field values from a stored
    address would display in the address card because useCustomerData
    returns raw store data. Apply emptyHiddenAddressFields to both
    billing and shipping addresses so locale-hidden values are cleared
    before reaching any consumer.

    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

    * Enforce country as always visible/required in locale and clean up

    - Move country locale enforcement to PHP get_country_locale() as the
      single source of truth — country can never be hidden or optional.
    - Remove redundant JS-side enforcement in prepareFormFields.
    - Revert address-i18n.js to trunk except for fixing state hiding
      (locale can hide state, but show is managed by country-select.js).
    - Remove redundant emptyHiddenAddressFields from checkout-processor
      since useCheckoutAddress already cleans addresses.
    - Add useShallowEqual for stable useMemo dependencies.
    - Fix address card double-comma when hidden fields leave empty parts.

    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

    * Update changelog entry to reflect current approach

    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/wooplug-5521-fix-locale-hidden-country-field b/plugins/woocommerce/changelog/wooplug-5521-fix-locale-hidden-country-field
new file mode 100644
index 00000000000..9c598ade628
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-5521-fix-locale-hidden-country-field
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix locale filter breaking address forms when hiding fields. Country is now enforced as always visible and required since it is the lookup key for locale resolution. Also fixes state field not being hideable by locale in classic checkout, and fixes address card displaying stale values for hidden fields.
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/use-checkout-address.ts b/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/use-checkout-address.ts
index 1fe7bb50cb2..e2c018db5c7 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/use-checkout-address.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/use-checkout-address.ts
@@ -8,7 +8,9 @@ import {
 	BillingAddress,
 	getSetting,
 } from '@woocommerce/settings';
-import { useCallback } from '@wordpress/element';
+import { useCallback, useMemo } from '@wordpress/element';
+import { emptyHiddenAddressFields } from '@woocommerce/base-utils';
+import { useShallowEqual } from '@woocommerce/base-hooks';
 import { useDispatch, useSelect } from '@wordpress/data';
 import { checkoutStore } from '@woocommerce/block-data';

@@ -65,12 +67,24 @@ export const useCheckoutAddress = (): CheckoutAddress => {
 		setEditingShippingAddress,
 	} = useDispatch( checkoutStore );
 	const {
-		billingAddress,
+		billingAddress: rawBillingAddress,
 		setBillingAddress,
-		shippingAddress,
+		shippingAddress: rawShippingAddress,
 		setShippingAddress,
 	} = useCustomerData();

+	const stableBillingAddress = useShallowEqual( rawBillingAddress );
+	const stableShippingAddress = useShallowEqual( rawShippingAddress );
+
+	const billingAddress = useMemo(
+		() => emptyHiddenAddressFields( stableBillingAddress ),
+		[ stableBillingAddress ]
+	);
+	const shippingAddress = useMemo(
+		() => emptyHiddenAddressFields( stableShippingAddress ),
+		[ stableShippingAddress ]
+	);
+
 	const setEmail = useCallback(
 		( value: string ) =>
 			void setBillingAddress( {
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/context/providers/cart-checkout/checkout-processor.ts b/plugins/woocommerce/client/blocks/assets/js/base/context/providers/cart-checkout/checkout-processor.ts
index c6b91e6a1ec..83777e5eacf 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/context/providers/cart-checkout/checkout-processor.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/base/context/providers/cart-checkout/checkout-processor.ts
@@ -10,10 +10,7 @@ import {
 	useState,
 	useMemo,
 } from '@wordpress/element';
-import {
-	emptyHiddenAddressFields,
-	removeAllNotices,
-} from '@woocommerce/base-utils';
+import { removeAllNotices } from '@woocommerce/base-utils';
 import { useDispatch, useSelect, select as selectStore } from '@wordpress/data';
 import {
 	checkoutStore,
@@ -257,13 +254,11 @@ const CheckoutProcessor = () => {
 			  }
 			: {};

-		const billingAddressData = emptyHiddenAddressFields(
-			currentBillingAddress.current
-		);
+		const billingAddressData = currentBillingAddress.current;

 		const shippingAddressData = useBillingAsShipping
 			? billingAddressData
-			: emptyHiddenAddressFields( currentShippingAddress.current );
+			: currentShippingAddress.current;

 		const data = {
 			additional_fields: additionalFields,
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/utils.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/utils.ts
index 3e11d58c350..9069516ba22 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/utils.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/utils.ts
@@ -116,10 +116,17 @@ export const formatAddress = (
 		parsedAddress = parsedAddress.replace( token, value );
 	} );
 	const addressParts = parsedAddress
-		.replace( /^,\s|,\s$/g, '' )
-		.replace( /\n{2,}/, '\n' )
+		.trim()
+		.replace( /\n{2,}/g, '\n' )
 		.split( '\n' )
-		.filter( Boolean );
+		.map( ( part ) =>
+			part
+				.split( ', ' )
+				.map( ( segment ) => segment.trim() )
+				.filter( Boolean )
+				.join( ', ' )
+		)
+		.filter( ( part ) => part.length > 0 );

 	return { name: parsedName, address: addressParts };
 };
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/plugins/locale-hide-country.php b/plugins/woocommerce/client/blocks/tests/e2e/plugins/locale-hide-country.php
new file mode 100644
index 00000000000..123ca88e135
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/tests/e2e/plugins/locale-hide-country.php
@@ -0,0 +1,25 @@
+<?php
+/**
+ * Plugin Name: WooCommerce Blocks Test Locale Hide Country
+ * Description: Uses woocommerce_get_country_locale to hide the country field and other address fields.
+ * Plugin URI: https://github.com/woocommerce/woocommerce
+ * Author: WooCommerce
+ *
+ * @package woocommerce-blocks-test-locale-hide-country
+ */
+
+declare(strict_types=1);
+
+add_filter(
+	'woocommerce_get_country_locale',
+	function ( $locales ) {
+		$hidden_fields = array( 'country', 'city', 'postcode', 'address_1', 'address_2', 'state', 'phone' );
+		foreach ( $locales as $country => $locale ) {
+			foreach ( $hidden_fields as $field ) {
+				$locales[ $country ][ $field ]['hidden']   = true;
+				$locales[ $country ][ $field ]['required'] = false;
+			}
+		}
+		return $locales;
+	}
+);
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/tests/checkout/checkout-block-locale-hide-country.block_theme.spec.ts b/plugins/woocommerce/client/blocks/tests/e2e/tests/checkout/checkout-block-locale-hide-country.block_theme.spec.ts
new file mode 100644
index 00000000000..398e43150f5
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/tests/e2e/tests/checkout/checkout-block-locale-hide-country.block_theme.spec.ts
@@ -0,0 +1,90 @@
+/**
+ * External dependencies
+ */
+import { expect, test as base, guestFile } from '@woocommerce/e2e-utils';
+
+/**
+ * Internal dependencies
+ */
+import { SIMPLE_PHYSICAL_PRODUCT_NAME } from './constants';
+import { CheckoutPage } from './checkout.page';
+
+const test = base.extend< { checkoutPageObject: CheckoutPage } >( {
+	checkoutPageObject: async ( { page, requestUtils }, use ) => {
+		const pageObject = new CheckoutPage( {
+			page,
+			requestUtils,
+		} );
+		await use( pageObject );
+	},
+} );
+
+test.describe( 'Checkout Block → Locale hides address fields but not country', () => {
+	test.use( { storageState: guestFile } );
+
+	test.beforeEach( async ( { requestUtils, frontendUtils } ) => {
+		await requestUtils.activatePlugin(
+			'woocommerce-blocks-test-locale-hide-country'
+		);
+		await requestUtils.rest( {
+			method: 'PUT',
+			path: 'wc/v3/settings/account/woocommerce_enable_guest_checkout',
+			data: { value: 'yes' },
+		} );
+
+		await frontendUtils.goToShop();
+		await frontendUtils.addToCart( SIMPLE_PHYSICAL_PRODUCT_NAME );
+		await frontendUtils.goToCheckout();
+	} );
+
+	test( 'Country remains visible even when locale tries to hide it', async ( {
+		page,
+	} ) => {
+		const shippingForm = page.getByRole( 'group', {
+			name: 'Shipping address',
+		} );
+
+		await expect( shippingForm ).toBeVisible();
+
+		// Country field should remain visible — locale hidden is ignored
+		// because country is the lookup key for locale resolution.
+		await expect(
+			shippingForm.getByLabel( 'Country/Region' )
+		).toBeVisible();
+
+		// Other locale-hidden fields should be hidden.
+		await expect( shippingForm.getByLabel( 'City' ) ).toBeHidden();
+		await expect(
+			shippingForm.getByLabel( 'Address', { exact: true } )
+		).toBeHidden();
+		await expect( shippingForm.getByLabel( 'Phone' ) ).toBeHidden();
+
+		// Name fields should still be visible (not hidden by locale).
+		await expect( shippingForm.getByLabel( 'First name' ) ).toBeVisible();
+		await expect( shippingForm.getByLabel( 'Last name' ) ).toBeVisible();
+	} );
+
+	test( 'Can complete checkout with locale-hidden address fields', async ( {
+		page,
+		checkoutPageObject,
+	} ) => {
+		const shippingForm = page.getByRole( 'group', {
+			name: 'Shipping address',
+		} );
+
+		await expect( shippingForm ).toBeVisible();
+
+		// Fill the visible fields (name, email, and country).
+		await page
+			.getByLabel( 'Email address' )
+			.fill( 'test-locale@example.com' );
+		await shippingForm.getByLabel( 'First name' ).fill( 'John' );
+		await shippingForm.getByLabel( 'Last name' ).fill( 'Doe' );
+
+		await checkoutPageObject.placeOrder();
+
+		await expect(
+			page.getByText( 'Thank you. Your order has been received.' )
+		).toBeVisible();
+	} );
+} );
diff --git a/plugins/woocommerce/client/legacy/js/frontend/address-i18n.js b/plugins/woocommerce/client/legacy/js/frontend/address-i18n.js
index 42846799b98..61f58d282b2 100644
--- a/plugins/woocommerce/client/legacy/js/frontend/address-i18n.js
+++ b/plugins/woocommerce/client/legacy/js/frontend/address-i18n.js
@@ -91,13 +91,12 @@ jQuery( function( $ ) {
 					field.data( 'priority', fieldLocale.priority );
 				}

-				// Hidden fields.
-				if ( 'state' !== key ) {
-					if ( typeof fieldLocale.hidden !== 'undefined' && true === fieldLocale.hidden ) {
-						field.hide().find( ':input' ).val( '' );
-					} else {
-						field.show();
-					}
+				// Hidden fields. State visibility (show) is managed by
+				// country-select.js, but locale can still hide it.
+				if ( true === fieldLocale.hidden ) {
+					field.hide().find( ':input' ).val( '' );
+				} else if ( 'state' !== key ) {
+					field.show();
 				}

 				// Class changes.
diff --git a/plugins/woocommerce/includes/class-wc-countries.php b/plugins/woocommerce/includes/class-wc-countries.php
index cb0b493cb9f..bc174fe4dca 100644
--- a/plugins/woocommerce/includes/class-wc-countries.php
+++ b/plugins/woocommerce/includes/class-wc-countries.php
@@ -838,8 +838,21 @@ class WC_Countries {
 				'autocomplete' => 'postal-code',
 				'priority'     => 90,
 			),
+			'phone'      => array(
+				'label'        => __( 'Phone', 'woocommerce' ),
+				'required'     => 'required' === CartCheckoutUtils::get_phone_field_visibility(),
+				'type'         => 'tel',
+				'class'        => array( 'form-row-wide' ),
+				'validate'     => array( 'phone' ),
+				'autocomplete' => 'tel',
+				'priority'     => 100,
+			),
 		);

+		if ( 'hidden' === CartCheckoutUtils::get_phone_field_visibility() ) {
+			unset( $fields['phone'] );
+		}
+
 		if ( 'hidden' === CartCheckoutUtils::get_company_field_visibility() ) {
 			unset( $fields['company'] );
 		}
@@ -867,6 +880,8 @@ class WC_Countries {
 			'state'     => '#billing_state_field, #shipping_state_field, #calc_shipping_state_field',
 			'postcode'  => '#billing_postcode_field, #shipping_postcode_field, #calc_shipping_postcode_field',
 			'city'      => '#billing_city_field, #shipping_city_field, #calc_shipping_city_field',
+			'country'   => '#billing_country_field, #shipping_country_field, #calc_shipping_country_field',
+			'phone'     => '#billing_phone_field, #shipping_phone_field',
 		);
 		return apply_filters( 'woocommerce_country_locale_field_selectors', $locale_fields );
 	}
@@ -1706,6 +1721,16 @@ class WC_Countries {

 			$this->locale['default']                   = apply_filters( 'woocommerce_get_country_locale_base', $this->locale['default'] );
 			$this->locale[ $this->get_base_country() ] = apply_filters( 'woocommerce_get_country_locale_base', $this->locale[ $this->get_base_country() ] );
+
+			// Country cannot be hidden or optional via locale — it is the lookup key for locale resolution.
+			// Merchants who sell to a single country should use "Sell to specific countries" instead.
+			foreach ( $this->locale as &$locale_entry ) {
+				if ( isset( $locale_entry['country'] ) ) {
+					$locale_entry['country']['hidden']   = false;
+					$locale_entry['country']['required'] = true;
+				}
+			}
+			unset( $locale_entry );
 		}

 		return $this->locale;
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/OrderConfirmation/Totals.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/OrderConfirmation/Totals.php
index e3f54db7e3e..2ce61c4d8a7 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/OrderConfirmation/Totals.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/OrderConfirmation/Totals.php
@@ -136,6 +136,11 @@ class Totals extends \WP_UnitTestCase {

 		update_option( 'woocommerce_enable_guest_checkout', 'yes' );
 		update_option( 'woocommerce_enable_signup_and_login_from_checkout', 'yes' );
+		// Phone defaults to 'required' for classic checkout (no option set).
+		// Set it to optional since the test sends empty phone values.
+		update_option( 'woocommerce_checkout_phone_field', 'optional' );
+		// Reset cached locale so the option change takes effect.
+		WC()->countries->locale = array();

 		$request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout' );
 		$request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );