Commit 5647b6ffe2c for woocommerce

commit 5647b6ffe2cb243bcca32913423f02a183bd342e
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Fri Sep 11 23:22:52 2026 +0300

    Stop country lookups from recursing through locale filters that read country data (#68573)

    * fix(countries): stop lookups recursing through locale filters

    Since #68381, every WC_Countries lookup calls determine_locale(), so
    it runs the locale, determine_locale and pre_determine_locale
    callbacks. A callback that reads WooCommerce country data starts
    another lookup, which runs the callback again without limit, even in
    a request that never changes locale. switch_to_locale() adds a
    second chain: reloading WooCommerce translations while such a
    callback runs rebuilds the country list, whose translated strings
    re-enter the loader. Before #68381 such a callback ran once or not
    at all.

    Bound both chains. While any instance resolves the locale, a nested
    lookup reuses the last resolved locale, and anything it builds is
    discarded afterwards. A lookup that finds its own entry still being
    built returns an empty result, get_country_locale() records its
    target locale before rebuilding, and a private read_geo_property()
    reads countries and states like a plain property read, minus PHP's
    magic-getter re-entry null. Lookups outside a locale filter or a
    build behave exactly as before. The re-indent also documents the
    existing woocommerce_sort_countries hook, and the PHPStan baseline
    entry this resolves is removed.

    Refs WOOAIRR-248

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * perf(countries): snapshot geo caches only when a lookup nests

    The locale-resolution guard copied the resolving instance's
    geographical cache on every lookup, although the copy is needed only
    when a nested lookup happens. Every writer of the cache calls
    get_cache_locale() first, so take each snapshot on the first nested
    lookup instead. Lookups that never nest now skip the copy.

    The restore loop moves into restore_locale_resolution_snapshots(), so
    PHPStan no longer narrows the registry to the empty array set just
    before determine_locale(), which nested lookups fill.

    Refs WOOAIRR-248

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * fix(countries): keep locale settings intact when a lookup nests in a build

    get_country_locale() recorded its target locale before rebuilding, so
    a lookup made during the rebuild would not rebuild again. The rebuild
    narrows the settings to the allowed and shipping countries, and both
    of those read the country list. When a country filter callback asked
    for the locale settings, the list was still being built and came back
    empty, so the settings kept only the default and base country entries
    and the early stamp made that stick for the rest of the request. A
    German address then failed Store API validation, because the DE entry
    that makes the state field optional was gone. A locale filter
    callback that threw left the same stamp on a partial build.

    Bail out of the rebuild while the settings or the country list are
    being built, returning the previous settings. Build into a local and
    commit it with the stamp only once the build completes, so a throw
    leaves nothing behind. The specific-country lists skip codes the list
    does not have instead of indexing an empty list, which warned and
    stored null entries during a build. The rebuild now sits in a
    try/finally, which re-indents the settings array; the three locale
    hooks in it get the docblocks the hook sniff asks for.

    Refs WOOAIRR-248

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * fix(store-api): validate addresses when the default locale entry is gone

    validate_address_fields() looped over the default entry of the
    country locale settings without checking it exists or is an array.
    The public locale property and the locale filters let any callback
    drop or replace that entry, which turned address validation into an
    undefined-key warning or a foreach() TypeError.

    Fall back to the unfiltered default address fields when the entry is
    missing or not an array, so the default required fields are still
    enforced.

    Refs WOOAIRR-248

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/fix-wooairr-248-locale-filter-recursion b/plugins/woocommerce/changelog/fix-wooairr-248-locale-filter-recursion
new file mode 100644
index 00000000000..55b8c6777e4
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-wooairr-248-locale-filter-recursion
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Stop locale filters that read country, state, or continent data from recursing when geographical data is looked up.
diff --git a/plugins/woocommerce/includes/class-wc-countries.php b/plugins/woocommerce/includes/class-wc-countries.php
index 44b14d4897b..1e1ecd76778 100644
--- a/plugins/woocommerce/includes/class-wc-countries.php
+++ b/plugins/woocommerce/includes/class-wc-countries.php
@@ -50,15 +50,82 @@ class WC_Countries {
 	 */
 	private $country_locale_built_for = null;

+	/**
+	 * Whether get_country_locale() is building the country locale settings.
+	 *
+	 * @var bool
+	 */
+	private $country_locale_building = false;
+
+	/**
+	 * Locale most recently resolved by get_cache_locale().
+	 *
+	 * @var string|null
+	 */
+	private $last_cache_locale = null;
+
+	/**
+	 * Geographical cache entries being built, keyed by data type and locale.
+	 *
+	 * @var array<string, array<string, bool>>
+	 */
+	private $geo_cache_building = array();
+
+	/**
+	 * Geographical caches to restore when the current locale resolution ends, keyed by object ID.
+	 *
+	 * Null while no lookup is resolving the request locale.
+	 *
+	 * @var array<int, array{0: WC_Countries, 1: array}>|null
+	 */
+	private static $locale_resolution_snapshots = null;
+
 	/**
 	 * Get the active request locale for geographical cache entries.
 	 *
+	 * Locale filter callbacks may read country data. While determine_locale() runs, those nested lookups reuse
+	 * the last resolved locale instead of resolving it again, and whatever they cache is discarded afterwards.
+	 *
 	 * @return string
 	 */
 	private function get_cache_locale() {
-		$locale = determine_locale();
+		if ( null !== self::$locale_resolution_snapshots ) {
+			$object_id = spl_object_id( $this );

-		return is_string( $locale ) && $locale ? $locale : 'en_US';
+			// Every writer of $geo_cache calls this method first, so an instance is snapshotted before any nested write.
+			if ( ! isset( self::$locale_resolution_snapshots[ $object_id ] ) ) {
+				self::$locale_resolution_snapshots[ $object_id ] = array( $this, $this->geo_cache );
+			}
+
+			return $this->last_cache_locale ?? 'en_US';
+		}
+
+		self::$locale_resolution_snapshots = array();
+
+		try {
+			$locale = determine_locale();
+		} finally {
+			self::restore_locale_resolution_snapshots();
+		}
+
+		$this->last_cache_locale = is_string( $locale ) && $locale ? $locale : 'en_US';
+
+		return $this->last_cache_locale;
+	}
+
+	/**
+	 * Restore the geographical caches that nested lookups changed while the locale was resolving, and end the resolution.
+	 *
+	 * This is a separate method so PHPStan does not treat the registry as the empty array set before determine_locale().
+	 *
+	 * @return void
+	 */
+	private static function restore_locale_resolution_snapshots() {
+		foreach ( self::$locale_resolution_snapshots ?? array() as $snapshot ) {
+			$snapshot[0]->geo_cache = $snapshot[1];
+		}
+
+		self::$locale_resolution_snapshots = null;
 	}

 	/**
@@ -77,6 +144,19 @@ class WC_Countries {
 		}
 	}

+	/**
+	 * Read the countries or states property from inside the class.
+	 *
+	 * Uses a real property when the object has one, as a plain read would. Otherwise calls __get() directly, because
+	 * PHP returns null for a magic property that is already being read further up the stack (a locale filter can do that).
+	 *
+	 * @param string $key Property name.
+	 * @return mixed
+	 */
+	private function read_geo_property( $key ) {
+		return array_key_exists( $key, get_object_vars( $this ) ) ? $this->{$key} : $this->__get( $key );
+	}
+
 	/**
 	 * Get all countries.
 	 *
@@ -86,16 +166,34 @@ class WC_Countries {
 		$locale = $this->get_cache_locale();

 		if ( empty( $this->geo_cache['countries'][ $locale ] ) ) {
-			/**
-			 * Allows filtering of the list of countries in WC.
-			 *
-			 * @since 1.5.3
-			 *
-			 * @param array $countries
-			 */
-			$this->geo_cache['countries'][ $locale ] = apply_filters( 'woocommerce_countries', include WC()->plugin_path() . '/i18n/countries.php' );
-			if ( apply_filters( 'woocommerce_sort_countries', true ) ) {
-				wc_asort_by_locale( $this->geo_cache['countries'][ $locale ], $locale );
+			// A lookup made while this entry is being built (for example from a locale filter) gets no data instead of recursing.
+			if ( isset( $this->geo_cache_building['countries'][ $locale ] ) ) {
+				return array();
+			}
+
+			$this->geo_cache_building['countries'][ $locale ] = true;
+
+			try {
+				/**
+				 * Allows filtering of the list of countries in WC.
+				 *
+				 * @since 1.5.3
+				 *
+				 * @param array $countries
+				 */
+				$this->geo_cache['countries'][ $locale ] = apply_filters( 'woocommerce_countries', include WC()->plugin_path() . '/i18n/countries.php' );
+				/**
+				 * Filters whether the list of countries is sorted by name.
+				 *
+				 * @since 2.0.0
+				 *
+				 * @param bool $sort Whether to sort the countries. Default true.
+				 */
+				if ( apply_filters( 'woocommerce_sort_countries', true ) ) {
+					wc_asort_by_locale( $this->geo_cache['countries'][ $locale ], $locale );
+				}
+			} finally {
+				unset( $this->geo_cache_building['countries'][ $locale ] );
 			}
 		}

@@ -150,14 +248,24 @@ class WC_Countries {
 		$locale = $this->get_cache_locale();

 		if ( empty( $this->geo_cache['continents'][ $locale ] ) ) {
-			/**
-			 * Allows filtering of continents in WC.
-			 *
-			 * @since 2.6.0
-			 *
-			 * @param array[array] $continents
-			 */
-			$this->geo_cache['continents'][ $locale ] = apply_filters( 'woocommerce_continents', include WC()->plugin_path() . '/i18n/continents.php' );
+			if ( isset( $this->geo_cache_building['continents'][ $locale ] ) ) {
+				return array();
+			}
+
+			$this->geo_cache_building['continents'][ $locale ] = true;
+
+			try {
+				/**
+				 * Allows filtering of continents in WC.
+				 *
+				 * @since 2.6.0
+				 *
+				 * @param array[array] $continents
+				 */
+				$this->geo_cache['continents'][ $locale ] = apply_filters( 'woocommerce_continents', include WC()->plugin_path() . '/i18n/continents.php' );
+			} finally {
+				unset( $this->geo_cache_building['continents'][ $locale ] );
+			}
 		}

 		return $this->geo_cache['continents'][ $locale ];
@@ -259,14 +367,24 @@ class WC_Countries {
 		$locale = $this->get_cache_locale();

 		if ( ! isset( $this->geo_cache['states'][ $locale ] ) ) {
-			/**
-			 * Allows filtering of country states in WC.
-			 *
-			 * @since 1.5.3
-			 *
-			 * @param array $states
-			 */
-			$this->geo_cache['states'][ $locale ] = apply_filters( 'woocommerce_states', include WC()->plugin_path() . '/i18n/states.php' );
+			if ( isset( $this->geo_cache_building['states'][ $locale ] ) ) {
+				return is_null( $cc ) ? array() : false;
+			}
+
+			$this->geo_cache_building['states'][ $locale ] = true;
+
+			try {
+				/**
+				 * Allows filtering of country states in WC.
+				 *
+				 * @since 1.5.3
+				 *
+				 * @param array $states
+				 */
+				$this->geo_cache['states'][ $locale ] = apply_filters( 'woocommerce_states', include WC()->plugin_path() . '/i18n/states.php' );
+			} finally {
+				unset( $this->geo_cache_building['states'][ $locale ] );
+			}
 		}

 		if ( ! is_null( $cc ) ) {
@@ -346,7 +464,7 @@ class WC_Countries {
 	 * @return array
 	 */
 	public function get_allowed_countries() {
-		$countries         = $this->countries;
+		$countries         = $this->read_geo_property( 'countries' );
 		$allowed_countries = get_option( 'woocommerce_allowed_countries' );

 		if ( 'all_except' === $allowed_countries ) {
@@ -358,12 +476,16 @@ class WC_Countries {
 				}
 			}
 		} elseif ( 'specific' === $allowed_countries ) {
+			$all_countries = $countries;
 			$countries     = array();
 			$raw_countries = get_option( 'woocommerce_specific_allowed_countries', array() );

 			if ( $raw_countries ) {
 				foreach ( $raw_countries as $country ) {
-					$countries[ $country ] = $this->countries[ $country ];
+					// The list is empty while it is being built, so a lookup made from a country filter gets no entries.
+					if ( isset( $all_countries[ $country ] ) ) {
+						$countries[ $country ] = $all_countries[ $country ];
+					}
 				}
 			}
 		}
@@ -393,14 +515,18 @@ class WC_Countries {

 		// All indicates that all countries are allowed, regardless of where you sell to.
 		if ( 'all' === get_option( 'woocommerce_ship_to_countries' ) ) {
-			$countries = $this->countries;
+			$countries = $this->read_geo_property( 'countries' );
 		} elseif ( 'specific' === get_option( 'woocommerce_ship_to_countries' ) ) {
+			$all_countries = $this->read_geo_property( 'countries' );
 			$countries     = array();
 			$raw_countries = get_option( 'woocommerce_specific_ship_to_countries', array() );

 			if ( $raw_countries ) {
 				foreach ( $raw_countries as $country ) {
-					$countries[ $country ] = $this->countries[ $country ];
+					// The list is empty while it is being built, so a lookup made from a country filter gets no entries.
+					if ( isset( $all_countries[ $country ] ) ) {
+						$countries[ $country ] = $all_countries[ $country ];
+					}
 				}
 			}
 		}
@@ -421,7 +547,7 @@ class WC_Countries {
 	 */
 	public function get_allowed_country_states() {
 		if ( get_option( 'woocommerce_allowed_countries' ) !== 'specific' ) {
-			return $this->states;
+			return $this->read_geo_property( 'states' );
 		}

 		$states = array();
@@ -429,7 +555,7 @@ class WC_Countries {
 		$raw_countries = get_option( 'woocommerce_specific_allowed_countries' );

 		if ( $raw_countries ) {
-			$all_states = $this->states;
+			$all_states = $this->read_geo_property( 'states' );

 			foreach ( $raw_countries as $country ) {
 				if ( isset( $all_states[ $country ] ) ) {
@@ -452,7 +578,7 @@ class WC_Countries {
 		}

 		if ( get_option( 'woocommerce_ship_to_countries' ) !== 'specific' ) {
-			return $this->states;
+			return $this->read_geo_property( 'states' );
 		}

 		$states = array();
@@ -460,7 +586,7 @@ class WC_Countries {
 		$raw_countries = get_option( 'woocommerce_specific_ship_to_countries' );

 		if ( $raw_countries ) {
-			$all_states = $this->states;
+			$all_states = $this->read_geo_property( 'states' );

 			foreach ( $raw_countries as $country ) {
 				if ( ! empty( $all_states[ $country ] ) ) {
@@ -584,8 +710,8 @@ class WC_Countries {
 	 * @param bool   $escape           If we should escape HTML.
 	 */
 	public function country_dropdown_options( $selected_country = '', $selected_state = '', $escape = false ) {
-		if ( $this->countries ) {
-			foreach ( $this->countries as $key => $value ) {
+		if ( $this->read_geo_property( 'countries' ) ) {
+			foreach ( $this->read_geo_property( 'countries' ) as $key => $value ) {
 				$states = $this->get_states( $key );
 				if ( $states ) {
 					// Maybe default the selected state as the first one.
@@ -703,7 +829,7 @@ class WC_Countries {
 		$format = ( $country && isset( $formats[ $country ] ) ) ? $formats[ $country ] : $formats['default'];

 		// Handle full country name.
-		$full_country = ( isset( $this->countries[ $country ] ) ) ? $this->countries[ $country ] : $country;
+		$full_country = ( isset( $this->read_geo_property( 'countries' )[ $country ] ) ) ? $this->read_geo_property( 'countries' )[ $country ] : $country;

 		// Country is not needed if the same as base.
 		if ( $country === $this->get_base_country() && ! apply_filters( 'woocommerce_formatted_address_force_country_display', false ) ) {
@@ -711,7 +837,7 @@ class WC_Countries {
 		}

 		// Handle full state name.
-		$full_state = ( $country && $state && isset( $this->states[ $country ][ $state ] ) ) ? $this->states[ $country ][ $state ] : $state;
+		$full_state = ( $country && $state && isset( $this->read_geo_property( 'states' )[ $country ][ $state ] ) ) ? $this->read_geo_property( 'states' )[ $country ][ $state ] : $state;

 		// Substitute address parts into the string.
 		$replace = array_map(
@@ -934,853 +1060,892 @@ class WC_Countries {
 		$cache_locale = $this->get_cache_locale();

 		if ( empty( $this->locale ) || ( null !== $this->country_locale_built_for && $cache_locale !== $this->country_locale_built_for ) ) {
-			$this->locale = apply_filters(
-				'woocommerce_get_country_locale',
-				array(
-					'AE' => array(
-						'postcode' => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-						'state'    => array(
-							'required' => false,
-						),
-					),
-					'AF' => array(
-						'state' => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-					),
-					'AL' => array(
-						'state' => array(
-							'label' => __( 'County', 'woocommerce' ),
-						),
-					),
-					'AO' => array(
-						'postcode' => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-						'state'    => array(
-							'label' => __( 'Province', 'woocommerce' ),
-						),
-					),
-					'AT' => array(
-						'postcode' => array(
-							'priority' => 65,
-						),
-						'state'    => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-					),
-					'AU' => array(
-						'city'     => array(
-							'label' => __( 'Suburb', 'woocommerce' ),
-						),
-						'postcode' => array(
-							'label' => __( 'Postcode', 'woocommerce' ),
-						),
-						'state'    => array(
-							'label' => __( 'State', 'woocommerce' ),
-						),
-					),
-					'AX' => array(
-						'postcode' => array(
-							'priority' => 65,
-						),
-						'state'    => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-					),
-					'BA' => array(
-						'postcode' => array(
-							'priority' => 65,
-						),
-						'state'    => array(
-							'label'    => __( 'Canton', 'woocommerce' ),
-							'required' => false,
-							'hidden'   => true,
-						),
-					),
-					'BD' => array(
-						'postcode' => array(
-							'required' => false,
-						),
-						'state'    => array(
-							'label' => __( 'District', 'woocommerce' ),
-						),
-					),
-					'BE' => array(
-						'postcode' => array(
-							'priority' => 65,
-						),
-						'state'    => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-					),
-					'BG' => array(
-						'state' => array(
-							'required' => false,
-						),
-					),
-					'BH' => array(
-						'postcode' => array(
-							'required' => false,
-						),
-						'state'    => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-					),
-					'BI' => array(
-						'state' => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-					),
-					'BO' => array(
-						'postcode' => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-						'state'    => array(
-							'label' => __( 'Department', 'woocommerce' ),
-						),
-					),
-					'BS' => array(
-						'postcode' => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-					),
-					'BW' => array(
-						'postcode' => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-						'state'    => array(
-							'required' => false,
-							'hidden'   => true,
-							'label'    => __( 'District', 'woocommerce' ),
-						),
-					),
-					'BZ' => array(
-						'postcode' => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-						'state'    => array(
-							'required' => false,
-						),
-					),
-					'CA' => array(
-						'postcode' => array(
-							'label' => __( 'Postal code', 'woocommerce' ),
-						),
-						'state'    => array(
-							'label' => __( 'Province', 'woocommerce' ),
-						),
-					),
-					'CH' => array(
-						'postcode' => array(
-							'priority' => 65,
-						),
-						'state'    => array(
-							'label'    => __( 'Canton', 'woocommerce' ),
-							'required' => false,
-						),
-					),
-					'CL' => array(
-						'city'     => array(
-							'required' => true,
-						),
-						'postcode' => array(
-							'required' => false,
-							// Hidden for stores within Chile. @see https://github.com/woocommerce/woocommerce/issues/36546.
-							'hidden'   => 'CL' === $this->get_base_country(),
-						),
-						'state'    => array(
-							'label' => __( 'Region', 'woocommerce' ),
-						),
-					),
-					'CN' => array(
-						'state' => array(
-							'label' => __( 'Province', 'woocommerce' ),
-						),
-					),
-					'CO' => array(
-						'postcode' => array(
-							'required' => false,
-						),
-						'state'    => array(
-							'label' => __( 'Department', 'woocommerce' ),
-						),
-					),
-					'CR' => array(
-						'state' => array(
-							'label' => __( 'Province', 'woocommerce' ),
-						),
-					),
-					'CW' => array(
-						'postcode' => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-						'state'    => array(
-							'required' => false,
-						),
-					),
-					'CY' => array(
-						'state' => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-					),
-					'CZ' => array(
-						'state' => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-					),
-					'DE' => array(
-						'postcode' => array(
-							'priority' => 65,
-						),
-						'state'    => array(
-							'required' => false,
-						),
-					),
-					'DK' => array(
-						'postcode' => array(
-							'priority' => 65,
-						),
-						'state'    => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-					),
-					'DO' => array(
-						'state' => array(
-							'label' => __( 'Province', 'woocommerce' ),
-						),
-					),
-					'EC' => array(
-						'state' => array(
-							'label' => __( 'Province', 'woocommerce' ),
-						),
-					),
-					'EE' => array(
-						'postcode' => array(
-							'priority' => 65,
-						),
-						'state'    => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-					),
-					'ET' => array(
-						'state' => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-					),
-					'FI' => array(
-						'postcode' => array(
-							'priority' => 65,
-						),
-						'state'    => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-					),
-					'FR' => array(
-						'postcode' => array(
-							'priority' => 65,
-						),
-						'state'    => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-					),
-					'GG' => array(
-						'state' => array(
-							'required' => false,
-							'label'    => __( 'Parish', 'woocommerce' ),
-						),
-					),
-					'GH' => array(
-						'postcode' => array(
-							'required' => false,
-						),
-						'state'    => array(
-							'label' => __( 'Region', 'woocommerce' ),
-						),
-					),
-					'GP' => array(
-						'state' => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-					),
-					'GF' => array(
-						'state' => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-					),
-					'GR' => array(
-						'state' => array(
-							'required' => false,
-						),
-					),
-					'GT' => array(
-						'postcode' => array(
-							'required' => false,
-						),
-						'state'    => array(
-							'label' => __( 'Department', 'woocommerce' ),
-						),
-					),
-					'HK' => array(
-						'postcode' => array(
-							'required' => false,
-						),
-						'city'     => array(
-							'label' => __( 'Town / District', 'woocommerce' ),
-						),
-						'state'    => array(
-							'label' => __( 'Region', 'woocommerce' ),
-						),
-					),
-					'HN' => array(
-						'state' => array(
-							'label' => __( 'Department', 'woocommerce' ),
-						),
-					),
-					'HU' => array(
-						'last_name'  => array(
-							'class'    => array( 'form-row-first' ),
-							'priority' => 10,
-						),
-						'first_name' => array(
-							'class'    => array( 'form-row-last' ),
-							'priority' => 20,
-						),
-						'postcode'   => array(
-							'class'    => array( 'form-row-first', 'address-field' ),
-							'priority' => 65,
-						),
-						'city'       => array(
-							'class' => array( 'form-row-last', 'address-field' ),
-						),
-						'address_1'  => array(
-							'priority' => 71,
-						),
-						'address_2'  => array(
-							'priority' => 72,
-						),
-						'state'      => array(
-							'label'    => __( 'County', 'woocommerce' ),
-							'required' => false,
-						),
-					),
-					'ID' => array(
-						'state' => array(
-							'label' => __( 'Province', 'woocommerce' ),
-						),
-					),
-					'IE' => array(
-						'postcode' => array(
-							'required' => true,
-							'label'    => __( 'Eircode', 'woocommerce' ),
-						),
-						'state'    => array(
-							'label' => __( 'County', 'woocommerce' ),
-						),
-					),
-					'IS' => array(
-						'postcode' => array(
-							'priority' => 65,
-						),
-						'state'    => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-					),
-					'IL' => array(
-						'postcode' => array(
-							'priority' => 65,
-						),
-						'state'    => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-					),
-					'IM' => array(
-						'state' => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-					),
-					'IN' => array(
-						'postcode' => array(
-							'label' => __( 'PIN Code', 'woocommerce' ),
-						),
-						'state'    => array(
-							'label' => __( 'State', 'woocommerce' ),
-						),
-					),
-					'IR' => array(
-						'state'     => array(
-							'priority' => 50,
-						),
-						'city'      => array(
-							'priority' => 60,
-						),
-						'address_1' => array(
-							'priority' => 70,
-						),
-						'address_2' => array(
-							'priority' => 80,
-						),
-					),
-					'IT' => array(
-						'postcode' => array(
-							'priority' => 65,
-						),
-						'state'    => array(
-							'required' => true,
-							'label'    => __( 'Province', 'woocommerce' ),
-						),
-					),
-					'JM' => array(
-						'city'     => array(
-							'label' => __( 'Town / City / Post Office', 'woocommerce' ),
-						),
-						'postcode' => array(
-							'required' => false,
-							'label'    => __( 'Postal Code', 'woocommerce' ),
-						),
-						'state'    => array(
-							'required' => true,
-							'label'    => __( 'Parish', 'woocommerce' ),
-						),
-					),
-					'JP' => array(
-						'last_name'  => array(
-							'class'    => array( 'form-row-first' ),
-							'priority' => 10,
-						),
-						'first_name' => array(
-							'class'    => array( 'form-row-last' ),
-							'priority' => 20,
-						),
-						'postcode'   => array(
-							'class'    => array( 'form-row-first', 'address-field' ),
-							'priority' => 65,
-						),
-						'state'      => array(
-							'label'    => __( 'Prefecture', 'woocommerce' ),
-							'class'    => array( 'form-row-last', 'address-field' ),
-							'priority' => 66,
-						),
-						'city'       => array(
-							'priority' => 67,
-						),
-						'address_1'  => array(
-							'priority' => 68,
-						),
-						'address_2'  => array(
-							'priority' => 69,
-						),
-					),
-					'KN' => array(
-						'postcode' => array(
-							'required' => false,
-							'label'    => __( 'Postal code', 'woocommerce' ),
-						),
-						'state'    => array(
-							'required' => true,
-							'label'    => __( 'Parish', 'woocommerce' ),
-						),
-					),
-					'KR' => array(
-						'state' => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-					),
-					'KW' => array(
-						'state' => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-					),
-					'LV' => array(
-						'state' => array(
-							'label'    => __( 'Municipality', 'woocommerce' ),
-							'required' => false,
-						),
-					),
-					'LB' => array(
-						'state' => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-					),
-					'MF' => array(
-						'state' => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-					),
-					'MQ' => array(
-						'state' => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-					),
-					'MT' => array(
-						'state' => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-					),
-					'MZ' => array(
-						'postcode' => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-						'state'    => array(
-							'label' => __( 'Province', 'woocommerce' ),
-						),
-					),
-					'NI' => array(
-						'state' => array(
-							'label' => __( 'Department', 'woocommerce' ),
-						),
-					),
-					'NL' => array(
-						'postcode' => array(
-							'priority' => 65,
-						),
-						'state'    => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-					),
-					'NG' => array(
-						'postcode' => array(
-							'label'    => __( 'Postcode', 'woocommerce' ),
-							'required' => false,
-							'hidden'   => true,
-						),
-						'state'    => array(
-							'label' => __( 'State', 'woocommerce' ),
-						),
-					),
-					'NZ' => array(
-						'postcode' => array(
-							'label' => __( 'Postcode', 'woocommerce' ),
-						),
-						'state'    => array(
-							'required' => false,
-							'label'    => __( 'Region', 'woocommerce' ),
-						),
-					),
-					'NO' => array(
-						'postcode' => array(
-							'priority' => 65,
-						),
-						'state'    => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-					),
-					'NP' => array(
-						'state'    => array(
-							'label' => __( 'State / Zone', 'woocommerce' ),
-						),
-						'postcode' => array(
-							'required' => false,
-						),
-					),
-					'PA' => array(
-						'state' => array(
-							'label' => __( 'Province', 'woocommerce' ),
-						),
-					),
-					'PL' => array(
-						'postcode' => array(
-							'priority' => 65,
-						),
-						'state'    => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-					),
-					'PR' => array(
-						'city'  => array(
-							'label' => __( 'Municipality', 'woocommerce' ),
-						),
-						'state' => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-					),
-					'PT' => array(
-						'state' => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-					),
-					'PY' => array(
-						'state' => array(
-							'label' => __( 'Department', 'woocommerce' ),
-						),
-					),
-					'QA' => array(
-						'postcode' => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-						'state'    => array(
-							'required' => false,
-						),
-					),
-					'RE' => array(
-						'state' => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-					),
-					'RO' => array(
-						'state' => array(
-							'label'    => __( 'County', 'woocommerce' ),
-							'required' => true,
-						),
-					),
-					'RS' => array(
-						'city'     => array(
-							'required' => true,
-						),
-						'postcode' => array(
-							'required' => true,
-						),
-						'state'    => array(
-							'label'    => __( 'District', 'woocommerce' ),
-							'required' => false,
-						),
-					),
-					'RW' => array(
-						'state' => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-					),
-					'SG' => array(
-						'state' => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-						'city'  => array(
-							'required' => false,
-						),
-					),
-					'SK' => array(
-						'postcode' => array(
-							'priority' => 65,
-						),
-						'state'    => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-					),
-					'SI' => array(
-						'postcode' => array(
-							'priority' => 65,
-						),
-						'state'    => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-					),
-					'SR' => array(
-						'postcode' => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-					),
-					'SV' => array(
-						'state' => array(
-							'label' => __( 'Department', 'woocommerce' ),
-						),
-					),
-					'ES' => array(
-						'postcode' => array(
-							'priority' => 65,
-						),
-						'state'    => array(
-							'label' => __( 'Province', 'woocommerce' ),
-						),
-					),
-					'LI' => array(
-						'postcode' => array(
-							'priority' => 65,
-						),
-						'state'    => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-					),
-					'LK' => array(
-						'state' => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-					),
-					'LU' => array(
-						'state' => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-					),
-					'MD' => array(
-						'state' => array(
-							'label' => __( 'Municipality / District', 'woocommerce' ),
-						),
-					),
-					'SE' => array(
-						'postcode' => array(
-							'priority' => 65,
-						),
-						'state'    => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-					),
-					'TR' => array(
-						'postcode' => array(
-							'priority' => 65,
-						),
-						'state'    => array(
-							'label' => __( 'Province', 'woocommerce' ),
-						),
-					),
-					'UG' => array(
-						'postcode' => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-						'city'     => array(
-							'label'    => __( 'Town / Village', 'woocommerce' ),
-							'required' => true,
-						),
-						'state'    => array(
-							'label'    => __( 'District', 'woocommerce' ),
-							'required' => true,
-						),
-					),
-					'US' => array(
-						'postcode' => array(
-							'label' => __( 'ZIP Code', 'woocommerce' ),
-						),
-						'state'    => array(
-							'label' => __( 'State', 'woocommerce' ),
-						),
-					),
-					'UY' => array(
-						'state' => array(
-							'label' => __( 'Department', 'woocommerce' ),
-						),
-					),
-					'GB' => array(
-						'postcode' => array(
-							'label' => __( 'Postcode', 'woocommerce' ),
-						),
-						'state'    => array(
-							'label'    => __( 'County', 'woocommerce' ),
-							'required' => false,
-						),
-					),
-					'ST' => array(
-						'postcode' => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-						'state'    => array(
-							'label' => __( 'District', 'woocommerce' ),
-						),
-					),
-					'VN' => array(
-						'state'     => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-						'postcode'  => array(
-							'priority' => 65,
-							'required' => false,
-							'hidden'   => false,
-						),
-						'address_2' => array(
-							'required' => false,
-							'hidden'   => false,
-						),
-					),
-					'WS' => array(
-						'postcode' => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-					),
-					'YT' => array(
-						'state' => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-					),
-					'ZA' => array(
-						'state' => array(
-							'label' => __( 'Province', 'woocommerce' ),
-						),
-					),
-					'ZW' => array(
-						'postcode' => array(
-							'required' => false,
-							'hidden'   => true,
-						),
-					),
-				)
-			);
+			// The settings are narrowed to the country list, so a lookup made while either is being built gets the previous settings.
+			if ( $this->country_locale_building || isset( $this->geo_cache_building['countries'][ $cache_locale ] ) ) {
+				return $this->locale;
+			}

-			$this->locale = array_intersect_key( $this->locale, array_merge( $this->get_allowed_countries(), $this->get_shipping_countries() ) );
+			$this->country_locale_building = true;
+
+			try {
+				/**
+				 * Filters the address field settings of each country.
+				 *
+				 * @since 1.5.4
+				 *
+				 * @param array $locale Address field settings keyed by country code.
+				 */
+				$locale = apply_filters(
+					'woocommerce_get_country_locale',
+					array(
+						'AE' => array(
+							'postcode' => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+							'state'    => array(
+								'required' => false,
+							),
+						),
+						'AF' => array(
+							'state' => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+						),
+						'AL' => array(
+							'state' => array(
+								'label' => __( 'County', 'woocommerce' ),
+							),
+						),
+						'AO' => array(
+							'postcode' => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+							'state'    => array(
+								'label' => __( 'Province', 'woocommerce' ),
+							),
+						),
+						'AT' => array(
+							'postcode' => array(
+								'priority' => 65,
+							),
+							'state'    => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+						),
+						'AU' => array(
+							'city'     => array(
+								'label' => __( 'Suburb', 'woocommerce' ),
+							),
+							'postcode' => array(
+								'label' => __( 'Postcode', 'woocommerce' ),
+							),
+							'state'    => array(
+								'label' => __( 'State', 'woocommerce' ),
+							),
+						),
+						'AX' => array(
+							'postcode' => array(
+								'priority' => 65,
+							),
+							'state'    => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+						),
+						'BA' => array(
+							'postcode' => array(
+								'priority' => 65,
+							),
+							'state'    => array(
+								'label'    => __( 'Canton', 'woocommerce' ),
+								'required' => false,
+								'hidden'   => true,
+							),
+						),
+						'BD' => array(
+							'postcode' => array(
+								'required' => false,
+							),
+							'state'    => array(
+								'label' => __( 'District', 'woocommerce' ),
+							),
+						),
+						'BE' => array(
+							'postcode' => array(
+								'priority' => 65,
+							),
+							'state'    => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+						),
+						'BG' => array(
+							'state' => array(
+								'required' => false,
+							),
+						),
+						'BH' => array(
+							'postcode' => array(
+								'required' => false,
+							),
+							'state'    => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+						),
+						'BI' => array(
+							'state' => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+						),
+						'BO' => array(
+							'postcode' => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+							'state'    => array(
+								'label' => __( 'Department', 'woocommerce' ),
+							),
+						),
+						'BS' => array(
+							'postcode' => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+						),
+						'BW' => array(
+							'postcode' => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+							'state'    => array(
+								'required' => false,
+								'hidden'   => true,
+								'label'    => __( 'District', 'woocommerce' ),
+							),
+						),
+						'BZ' => array(
+							'postcode' => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+							'state'    => array(
+								'required' => false,
+							),
+						),
+						'CA' => array(
+							'postcode' => array(
+								'label' => __( 'Postal code', 'woocommerce' ),
+							),
+							'state'    => array(
+								'label' => __( 'Province', 'woocommerce' ),
+							),
+						),
+						'CH' => array(
+							'postcode' => array(
+								'priority' => 65,
+							),
+							'state'    => array(
+								'label'    => __( 'Canton', 'woocommerce' ),
+								'required' => false,
+							),
+						),
+						'CL' => array(
+							'city'     => array(
+								'required' => true,
+							),
+							'postcode' => array(
+								'required' => false,
+								// Hidden for stores within Chile. @see https://github.com/woocommerce/woocommerce/issues/36546.
+								'hidden'   => 'CL' === $this->get_base_country(),
+							),
+							'state'    => array(
+								'label' => __( 'Region', 'woocommerce' ),
+							),
+						),
+						'CN' => array(
+							'state' => array(
+								'label' => __( 'Province', 'woocommerce' ),
+							),
+						),
+						'CO' => array(
+							'postcode' => array(
+								'required' => false,
+							),
+							'state'    => array(
+								'label' => __( 'Department', 'woocommerce' ),
+							),
+						),
+						'CR' => array(
+							'state' => array(
+								'label' => __( 'Province', 'woocommerce' ),
+							),
+						),
+						'CW' => array(
+							'postcode' => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+							'state'    => array(
+								'required' => false,
+							),
+						),
+						'CY' => array(
+							'state' => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+						),
+						'CZ' => array(
+							'state' => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+						),
+						'DE' => array(
+							'postcode' => array(
+								'priority' => 65,
+							),
+							'state'    => array(
+								'required' => false,
+							),
+						),
+						'DK' => array(
+							'postcode' => array(
+								'priority' => 65,
+							),
+							'state'    => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+						),
+						'DO' => array(
+							'state' => array(
+								'label' => __( 'Province', 'woocommerce' ),
+							),
+						),
+						'EC' => array(
+							'state' => array(
+								'label' => __( 'Province', 'woocommerce' ),
+							),
+						),
+						'EE' => array(
+							'postcode' => array(
+								'priority' => 65,
+							),
+							'state'    => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+						),
+						'ET' => array(
+							'state' => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+						),
+						'FI' => array(
+							'postcode' => array(
+								'priority' => 65,
+							),
+							'state'    => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+						),
+						'FR' => array(
+							'postcode' => array(
+								'priority' => 65,
+							),
+							'state'    => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+						),
+						'GG' => array(
+							'state' => array(
+								'required' => false,
+								'label'    => __( 'Parish', 'woocommerce' ),
+							),
+						),
+						'GH' => array(
+							'postcode' => array(
+								'required' => false,
+							),
+							'state'    => array(
+								'label' => __( 'Region', 'woocommerce' ),
+							),
+						),
+						'GP' => array(
+							'state' => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+						),
+						'GF' => array(
+							'state' => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+						),
+						'GR' => array(
+							'state' => array(
+								'required' => false,
+							),
+						),
+						'GT' => array(
+							'postcode' => array(
+								'required' => false,
+							),
+							'state'    => array(
+								'label' => __( 'Department', 'woocommerce' ),
+							),
+						),
+						'HK' => array(
+							'postcode' => array(
+								'required' => false,
+							),
+							'city'     => array(
+								'label' => __( 'Town / District', 'woocommerce' ),
+							),
+							'state'    => array(
+								'label' => __( 'Region', 'woocommerce' ),
+							),
+						),
+						'HN' => array(
+							'state' => array(
+								'label' => __( 'Department', 'woocommerce' ),
+							),
+						),
+						'HU' => array(
+							'last_name'  => array(
+								'class'    => array( 'form-row-first' ),
+								'priority' => 10,
+							),
+							'first_name' => array(
+								'class'    => array( 'form-row-last' ),
+								'priority' => 20,
+							),
+							'postcode'   => array(
+								'class'    => array( 'form-row-first', 'address-field' ),
+								'priority' => 65,
+							),
+							'city'       => array(
+								'class' => array( 'form-row-last', 'address-field' ),
+							),
+							'address_1'  => array(
+								'priority' => 71,
+							),
+							'address_2'  => array(
+								'priority' => 72,
+							),
+							'state'      => array(
+								'label'    => __( 'County', 'woocommerce' ),
+								'required' => false,
+							),
+						),
+						'ID' => array(
+							'state' => array(
+								'label' => __( 'Province', 'woocommerce' ),
+							),
+						),
+						'IE' => array(
+							'postcode' => array(
+								'required' => true,
+								'label'    => __( 'Eircode', 'woocommerce' ),
+							),
+							'state'    => array(
+								'label' => __( 'County', 'woocommerce' ),
+							),
+						),
+						'IS' => array(
+							'postcode' => array(
+								'priority' => 65,
+							),
+							'state'    => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+						),
+						'IL' => array(
+							'postcode' => array(
+								'priority' => 65,
+							),
+							'state'    => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+						),
+						'IM' => array(
+							'state' => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+						),
+						'IN' => array(
+							'postcode' => array(
+								'label' => __( 'PIN Code', 'woocommerce' ),
+							),
+							'state'    => array(
+								'label' => __( 'State', 'woocommerce' ),
+							),
+						),
+						'IR' => array(
+							'state'     => array(
+								'priority' => 50,
+							),
+							'city'      => array(
+								'priority' => 60,
+							),
+							'address_1' => array(
+								'priority' => 70,
+							),
+							'address_2' => array(
+								'priority' => 80,
+							),
+						),
+						'IT' => array(
+							'postcode' => array(
+								'priority' => 65,
+							),
+							'state'    => array(
+								'required' => true,
+								'label'    => __( 'Province', 'woocommerce' ),
+							),
+						),
+						'JM' => array(
+							'city'     => array(
+								'label' => __( 'Town / City / Post Office', 'woocommerce' ),
+							),
+							'postcode' => array(
+								'required' => false,
+								'label'    => __( 'Postal Code', 'woocommerce' ),
+							),
+							'state'    => array(
+								'required' => true,
+								'label'    => __( 'Parish', 'woocommerce' ),
+							),
+						),
+						'JP' => array(
+							'last_name'  => array(
+								'class'    => array( 'form-row-first' ),
+								'priority' => 10,
+							),
+							'first_name' => array(
+								'class'    => array( 'form-row-last' ),
+								'priority' => 20,
+							),
+							'postcode'   => array(
+								'class'    => array( 'form-row-first', 'address-field' ),
+								'priority' => 65,
+							),
+							'state'      => array(
+								'label'    => __( 'Prefecture', 'woocommerce' ),
+								'class'    => array( 'form-row-last', 'address-field' ),
+								'priority' => 66,
+							),
+							'city'       => array(
+								'priority' => 67,
+							),
+							'address_1'  => array(
+								'priority' => 68,
+							),
+							'address_2'  => array(
+								'priority' => 69,
+							),
+						),
+						'KN' => array(
+							'postcode' => array(
+								'required' => false,
+								'label'    => __( 'Postal code', 'woocommerce' ),
+							),
+							'state'    => array(
+								'required' => true,
+								'label'    => __( 'Parish', 'woocommerce' ),
+							),
+						),
+						'KR' => array(
+							'state' => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+						),
+						'KW' => array(
+							'state' => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+						),
+						'LV' => array(
+							'state' => array(
+								'label'    => __( 'Municipality', 'woocommerce' ),
+								'required' => false,
+							),
+						),
+						'LB' => array(
+							'state' => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+						),
+						'MF' => array(
+							'state' => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+						),
+						'MQ' => array(
+							'state' => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+						),
+						'MT' => array(
+							'state' => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+						),
+						'MZ' => array(
+							'postcode' => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+							'state'    => array(
+								'label' => __( 'Province', 'woocommerce' ),
+							),
+						),
+						'NI' => array(
+							'state' => array(
+								'label' => __( 'Department', 'woocommerce' ),
+							),
+						),
+						'NL' => array(
+							'postcode' => array(
+								'priority' => 65,
+							),
+							'state'    => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+						),
+						'NG' => array(
+							'postcode' => array(
+								'label'    => __( 'Postcode', 'woocommerce' ),
+								'required' => false,
+								'hidden'   => true,
+							),
+							'state'    => array(
+								'label' => __( 'State', 'woocommerce' ),
+							),
+						),
+						'NZ' => array(
+							'postcode' => array(
+								'label' => __( 'Postcode', 'woocommerce' ),
+							),
+							'state'    => array(
+								'required' => false,
+								'label'    => __( 'Region', 'woocommerce' ),
+							),
+						),
+						'NO' => array(
+							'postcode' => array(
+								'priority' => 65,
+							),
+							'state'    => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+						),
+						'NP' => array(
+							'state'    => array(
+								'label' => __( 'State / Zone', 'woocommerce' ),
+							),
+							'postcode' => array(
+								'required' => false,
+							),
+						),
+						'PA' => array(
+							'state' => array(
+								'label' => __( 'Province', 'woocommerce' ),
+							),
+						),
+						'PL' => array(
+							'postcode' => array(
+								'priority' => 65,
+							),
+							'state'    => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+						),
+						'PR' => array(
+							'city'  => array(
+								'label' => __( 'Municipality', 'woocommerce' ),
+							),
+							'state' => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+						),
+						'PT' => array(
+							'state' => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+						),
+						'PY' => array(
+							'state' => array(
+								'label' => __( 'Department', 'woocommerce' ),
+							),
+						),
+						'QA' => array(
+							'postcode' => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+							'state'    => array(
+								'required' => false,
+							),
+						),
+						'RE' => array(
+							'state' => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+						),
+						'RO' => array(
+							'state' => array(
+								'label'    => __( 'County', 'woocommerce' ),
+								'required' => true,
+							),
+						),
+						'RS' => array(
+							'city'     => array(
+								'required' => true,
+							),
+							'postcode' => array(
+								'required' => true,
+							),
+							'state'    => array(
+								'label'    => __( 'District', 'woocommerce' ),
+								'required' => false,
+							),
+						),
+						'RW' => array(
+							'state' => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+						),
+						'SG' => array(
+							'state' => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+							'city'  => array(
+								'required' => false,
+							),
+						),
+						'SK' => array(
+							'postcode' => array(
+								'priority' => 65,
+							),
+							'state'    => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+						),
+						'SI' => array(
+							'postcode' => array(
+								'priority' => 65,
+							),
+							'state'    => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+						),
+						'SR' => array(
+							'postcode' => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+						),
+						'SV' => array(
+							'state' => array(
+								'label' => __( 'Department', 'woocommerce' ),
+							),
+						),
+						'ES' => array(
+							'postcode' => array(
+								'priority' => 65,
+							),
+							'state'    => array(
+								'label' => __( 'Province', 'woocommerce' ),
+							),
+						),
+						'LI' => array(
+							'postcode' => array(
+								'priority' => 65,
+							),
+							'state'    => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+						),
+						'LK' => array(
+							'state' => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+						),
+						'LU' => array(
+							'state' => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+						),
+						'MD' => array(
+							'state' => array(
+								'label' => __( 'Municipality / District', 'woocommerce' ),
+							),
+						),
+						'SE' => array(
+							'postcode' => array(
+								'priority' => 65,
+							),
+							'state'    => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+						),
+						'TR' => array(
+							'postcode' => array(
+								'priority' => 65,
+							),
+							'state'    => array(
+								'label' => __( 'Province', 'woocommerce' ),
+							),
+						),
+						'UG' => array(
+							'postcode' => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+							'city'     => array(
+								'label'    => __( 'Town / Village', 'woocommerce' ),
+								'required' => true,
+							),
+							'state'    => array(
+								'label'    => __( 'District', 'woocommerce' ),
+								'required' => true,
+							),
+						),
+						'US' => array(
+							'postcode' => array(
+								'label' => __( 'ZIP Code', 'woocommerce' ),
+							),
+							'state'    => array(
+								'label' => __( 'State', 'woocommerce' ),
+							),
+						),
+						'UY' => array(
+							'state' => array(
+								'label' => __( 'Department', 'woocommerce' ),
+							),
+						),
+						'GB' => array(
+							'postcode' => array(
+								'label' => __( 'Postcode', 'woocommerce' ),
+							),
+							'state'    => array(
+								'label'    => __( 'County', 'woocommerce' ),
+								'required' => false,
+							),
+						),
+						'ST' => array(
+							'postcode' => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+							'state'    => array(
+								'label' => __( 'District', 'woocommerce' ),
+							),
+						),
+						'VN' => array(
+							'state'     => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+							'postcode'  => array(
+								'priority' => 65,
+								'required' => false,
+								'hidden'   => false,
+							),
+							'address_2' => array(
+								'required' => false,
+								'hidden'   => false,
+							),
+						),
+						'WS' => array(
+							'postcode' => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+						),
+						'YT' => array(
+							'state' => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+						),
+						'ZA' => array(
+							'state' => array(
+								'label' => __( 'Province', 'woocommerce' ),
+							),
+						),
+						'ZW' => array(
+							'postcode' => array(
+								'required' => false,
+								'hidden'   => true,
+							),
+						),
+					)
+				);

-			// Default Locale Can be filtered to override fields in get_address_fields(). Countries with no specific locale will use default.
-			$this->locale['default'] = apply_filters( 'woocommerce_get_country_locale_default', $this->get_default_address_fields() );
+				$locale = array_intersect_key( $locale, array_merge( $this->get_allowed_countries(), $this->get_shipping_countries() ) );

-			// Filter default AND shop base locales to allow overrides via a single function. These will be used when changing countries on the checkout.
-			if ( ! isset( $this->locale[ $this->get_base_country() ] ) ) {
-				$this->locale[ $this->get_base_country() ] = $this->locale['default'];
-			}
+				/**
+				 * Filters the default address field settings, which get_address_fields() uses for countries without their own.
+				 *
+				 * @since 1.5.4
+				 *
+				 * @param array $fields Default address field settings.
+				 */
+				$locale['default'] = apply_filters( 'woocommerce_get_country_locale_default', $this->get_default_address_fields() );

-			$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() ] );
+				// Filter default AND shop base locales to allow overrides via a single function. These will be used when changing countries on the checkout.
+				if ( ! isset( $locale[ $this->get_base_country() ] ) ) {
+					$locale[ $this->get_base_country() ] = $locale['default'];
+				}

-			// 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;
+				/**
+				 * Filters the default and shop base country address field settings, which apply when the checkout country changes.
+				 *
+				 * @since 1.5.4
+				 *
+				 * @param array $fields Address field settings.
+				 */
+				$locale['default'] = apply_filters( 'woocommerce_get_country_locale_base', $locale['default'] );
+				/**
+				 * Filters the default and shop base country address field settings, which apply when the checkout country changes.
+				 *
+				 * @since 1.5.4
+				 *
+				 * @param array $fields Address field settings.
+				 */
+				$locale[ $this->get_base_country() ] = apply_filters( 'woocommerce_get_country_locale_base', $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 ( $locale as &$locale_entry ) {
+					if ( isset( $locale_entry['country'] ) ) {
+						$locale_entry['country']['hidden']   = false;
+						$locale_entry['country']['required'] = true;
+					}
 				}
-			}
-			unset( $locale_entry );
+				unset( $locale_entry );

-			$this->country_locale_built_for = $cache_locale;
+				$this->locale                   = $locale;
+				$this->country_locale_built_for = $cache_locale;
+			} finally {
+				$this->country_locale_building = false;
+			}
 		}

 		return $this->locale;
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 862b3c8a582..9db06415c6a 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -10662,11 +10662,6 @@ parameters:
 			count: 1
 			path: includes/class-wc-comments.php

-		-
-			message: '#^Access to an undefined property WC_Countries\:\:\$countries\.$#'
-			identifier: property.notFound
-			count: 5
-			path: includes/class-wc-countries.php

 		-
 			message: '#^Method WC_Countries\:\:country_dropdown_options\(\) has no return type specified\.$#'
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/OrderController.php b/plugins/woocommerce/src/StoreApi/Utilities/OrderController.php
index 9a444d7affe..9f6b22ba638 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/OrderController.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/OrderController.php
@@ -470,8 +470,14 @@ class OrderController {
 		$all_locales    = wc()->countries->get_country_locale();
 		$address        = $order->get_address( $address_type );
 		$current_locale = $all_locales[ $address['country'] ] ?? [];
+		$default_locale = $all_locales['default'] ?? null;

-		foreach ( $all_locales['default'] as $key => $value ) {
+		// A locale filter callback can drop or replace the default entry, so fall back to the unfiltered default fields.
+		if ( ! is_array( $default_locale ) ) {
+			$default_locale = wc()->countries->get_default_address_fields();
+		}
+
+		foreach ( $default_locale as $key => $value ) {
 			// If $current_locale[ $key ] is not empty, merge it with locale default, otherwise just use default locale.
 			$current_locale[ $key ] = ! empty( $current_locale[ $key ] )
 				? wp_parse_args( $current_locale[ $key ], $value )
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-countries-test.php b/plugins/woocommerce/tests/php/includes/class-wc-countries-test.php
index fc7c4f54f06..5542e33b39a 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-countries-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-countries-test.php
@@ -1,10 +1,87 @@
 <?php
 declare( strict_types=1 );

+use Automattic\WooCommerce\Enums\DefaultCustomerAddress;
+
 /**
  * Tests for the WC_Countries class.
  */
 class WC_Countries_Test extends \WC_Unit_Test_Case {
+	/**
+	 * Depth at which the geographical locale filter stops reading, so a recursion fails the test instead of the run.
+	 */
+	private const LOCALE_FILTER_DEPTH_LIMIT = 10;
+
+	/**
+	 * Countries instance read by the geographical locale filter, or null to create one on each call.
+	 *
+	 * @var WC_Countries|null
+	 */
+	private $locale_filter_countries = null;
+
+	/**
+	 * WC_Countries method the geographical locale filter calls.
+	 *
+	 * @var string
+	 */
+	private $locale_filter_lookup = 'get_countries';
+
+	/**
+	 * Current nesting of the geographical locale filter.
+	 *
+	 * @var int
+	 */
+	private $locale_filter_depth = 0;
+
+	/**
+	 * Deepest nesting of the geographical locale filter.
+	 *
+	 * @var int
+	 */
+	private $locale_filter_max_depth = 0;
+
+	/**
+	 * Data the geographical locale filter received from its lookups.
+	 *
+	 * @var array
+	 */
+	private $locale_filter_lookups = array();
+
+	/**
+	 * Build counts keyed by data type and switched-locale stamp.
+	 *
+	 * @var array<string, array<string, int>>
+	 */
+	private $switched_locale_stamps = array();
+
+	/**
+	 * Default customer locations the locale filter received.
+	 *
+	 * @var array
+	 */
+	private $locale_filter_default_locations = array();
+
+	/**
+	 * Countries object replaced on WC() by a test.
+	 *
+	 * @var WC_Countries|null
+	 */
+	private $original_countries = null;
+
+	/**
+	 * Locale switcher replaced by a test.
+	 *
+	 * @var WP_Locale_Switcher|null
+	 */
+	private $original_locale_switcher = null;
+
+	/**
+	 * Locale made available to the replacement locale switcher.
+	 *
+	 * @var string
+	 */
+	private $available_test_locale = '';
+
 	/**
 	 * Locale exposed through the WordPress request-locale filter.
 	 *
@@ -199,6 +276,102 @@ class WC_Countries_Test extends \WC_Unit_Test_Case {
 		return $locale;
 	}

+	/**
+	 * Read geographical data from a locale filter, as a "language by visitor country" extension might.
+	 *
+	 * @internal
+	 *
+	 * @param mixed $locale Current locale.
+	 * @return mixed
+	 */
+	public function read_geographical_data_in_locale_filter( $locale ) {
+		++$this->locale_filter_depth;
+		$this->locale_filter_max_depth = max( $this->locale_filter_max_depth, $this->locale_filter_depth );
+
+		if ( $this->locale_filter_depth < self::LOCALE_FILTER_DEPTH_LIMIT ) {
+			$countries                     = $this->locale_filter_countries ?? new WC_Countries();
+			$lookup                        = $this->locale_filter_lookup;
+			$this->locale_filter_lookups[] = $countries->$lookup();
+		}
+
+		--$this->locale_filter_depth;
+
+		return $locale;
+	}
+
+	/**
+	 * Read the default customer location from a locale filter, as a "language by visitor country" extension might.
+	 *
+	 * @internal
+	 *
+	 * @param mixed $locale Current locale.
+	 * @return mixed
+	 */
+	public function read_default_location_in_locale_filter( $locale ) {
+		++$this->locale_filter_depth;
+		$this->locale_filter_max_depth = max( $this->locale_filter_max_depth, $this->locale_filter_depth );
+
+		if ( $this->locale_filter_depth < self::LOCALE_FILTER_DEPTH_LIMIT ) {
+			$this->locale_filter_default_locations[] = wc_get_customer_default_location();
+		}
+
+		--$this->locale_filter_depth;
+
+		return $locale;
+	}
+
+	/**
+	 * Ask WordPress for the locale while geographical data is built, as the just-in-time translation loader does.
+	 *
+	 * @internal
+	 *
+	 * @param array $data Geographical data.
+	 * @return array
+	 */
+	public function determine_locale_while_building( $data ) {
+		determine_locale();
+		return $data;
+	}
+
+	/**
+	 * Stamp country names with the switched locale.
+	 *
+	 * @internal
+	 *
+	 * @param array $countries Country names.
+	 * @return array
+	 */
+	public function stamp_country_names_with_switched_locale( $countries ) {
+		$countries['US'] = $this->record_switched_locale_stamp( 'countries' );
+		return $countries;
+	}
+
+	/**
+	 * Stamp country locale settings with the switched locale.
+	 *
+	 * @internal
+	 *
+	 * @param array $locale Country locale settings.
+	 * @return array
+	 */
+	public function stamp_country_locale_with_switched_locale( $locale ) {
+		$locale['US']['postcode']['label'] = $this->record_switched_locale_stamp( 'country_locale' );
+		return $locale;
+	}
+
+	/**
+	 * Make the test locale available to the replacement locale switcher.
+	 *
+	 * @internal
+	 *
+	 * @param array $languages Available languages.
+	 * @return array
+	 */
+	public function add_available_test_locale( $languages ) {
+		$languages[] = $this->available_test_locale;
+		return $languages;
+	}
+
 	/**
 	 * Return the option value that enables all countries.
 	 *
@@ -236,6 +409,38 @@ class WC_Countries_Test extends \WC_Unit_Test_Case {
 		remove_filter( 'woocommerce_get_country_locale', array( $this, 'filter_country_locale' ) );
 		remove_filter( 'pre_option_woocommerce_allowed_countries', array( $this, 'return_all_countries' ) );
 		remove_filter( 'pre_option_woocommerce_ship_to_countries', array( $this, 'return_all_countries' ) );
+		remove_filter( 'locale', array( $this, 'read_geographical_data_in_locale_filter' ), 20 );
+		remove_filter( 'determine_locale', array( $this, 'read_geographical_data_in_locale_filter' ), 20 );
+		remove_filter( 'pre_determine_locale', array( $this, 'read_geographical_data_in_locale_filter' ), 20 );
+		remove_filter( 'woocommerce_countries', array( $this, 'determine_locale_while_building' ) );
+		remove_filter( 'woocommerce_states', array( $this, 'determine_locale_while_building' ) );
+		remove_filter( 'woocommerce_get_country_locale', array( $this, 'determine_locale_while_building' ) );
+		remove_filter( 'woocommerce_countries', array( $this, 'stamp_country_names_with_switched_locale' ) );
+		remove_filter( 'woocommerce_get_country_locale', array( $this, 'stamp_country_locale_with_switched_locale' ) );
+		remove_filter( 'get_available_languages', array( $this, 'add_available_test_locale' ) );
+		remove_filter( 'locale', array( $this, 'read_default_location_in_locale_filter' ), 20 );
+		remove_filter( 'determine_locale', array( $this, 'read_default_location_in_locale_filter' ), 20 );
+
+		if ( null !== $this->original_countries ) {
+			WC()->countries = $this->original_countries;
+		}
+
+		if ( null !== $this->original_locale_switcher ) {
+			restore_current_locale();
+			$GLOBALS['wp_locale_switcher'] = $this->original_locale_switcher; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restores the switcher replaced by the test.
+		}
+
+		$this->locale_filter_countries  = null;
+		$this->locale_filter_lookup     = 'get_countries';
+		$this->locale_filter_depth      = 0;
+		$this->locale_filter_max_depth  = 0;
+		$this->locale_filter_lookups    = array();
+		$this->switched_locale_stamps   = array();
+		$this->original_locale_switcher = null;
+		$this->available_test_locale    = '';
+
+		$this->locale_filter_default_locations = array();
+		$this->original_countries              = null;

 		$this->active_locale                   = 'en_US';
 		$this->geographical_filter_calls       = array();
@@ -451,6 +656,281 @@ class WC_Countries_Test extends \WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * @testdox A locale filter that reads geographical data runs once per lookup and gets complete data.
+	 *
+	 * @dataProvider provide_locale_filter_geographical_lookups
+	 *
+	 * @param string $hook   Locale hook the callback is attached to.
+	 * @param string $lookup WC_Countries method the callback calls.
+	 */
+	public function test_locale_filter_reading_geographical_data_does_not_recurse( $hook, $lookup ): void {
+		add_filter( 'determine_locale', array( $this, 'filter_active_locale' ) );
+		add_filter( $hook, array( $this, 'read_geographical_data_in_locale_filter' ), 20 );
+		$sut                           = new WC_Countries();
+		$this->locale_filter_countries = $sut;
+		$this->locale_filter_lookup    = $lookup;
+		$source_countries              = include WC()->plugin_path() . '/i18n/countries.php';
+		$source_states                 = include WC()->plugin_path() . '/i18n/states.php';
+
+		foreach ( array( 'cold', 'warm' ) as $cache_state ) {
+			$this->assertSame( $source_countries['DE'], $sut->get_countries()['DE'], "Countries should be complete on a $cache_state cache." );
+			$this->assertSame( $source_states['US']['CA'], $sut->get_states( 'US' )['CA'], "States should be complete on a $cache_state cache." );
+			$this->assertArrayHasKey( 'default', $sut->get_country_locale(), "Country locale settings should be complete on a $cache_state cache." );
+		}
+
+		$lookups  = $this->locale_filter_lookups;
+		$expected = $sut->$lookup();
+
+		$this->assertSame( 1, $this->locale_filter_max_depth, 'A lookup inside the locale filter should not run the filter again.' );
+		$this->assertNotEmpty( $lookups, 'The locale filter should have read geographical data.' );
+
+		foreach ( $lookups as $data ) {
+			$this->assertSame( $expected, $data, 'The locale filter should get the same data as a direct lookup.' );
+		}
+	}
+
+	/**
+	 * @testdox A locale filter that creates its own countries instance does not recurse.
+	 */
+	public function test_locale_filter_creating_countries_instances_does_not_recurse(): void {
+		add_filter( 'determine_locale', array( $this, 'read_geographical_data_in_locale_filter' ), 20 );
+		$sut = new WC_Countries();
+
+		$countries = $sut->get_countries();
+
+		$this->assertSame( 1, $this->locale_filter_max_depth, 'A lookup on a new instance inside the locale filter should not run the filter again.' );
+		$this->assertArrayHasKey( 'DE', $countries, 'Countries should be complete.' );
+		$this->assertArrayHasKey( 'DE', $this->locale_filter_lookups[0], 'The locale filter should get complete data from its own instance.' );
+	}
+
+	/**
+	 * @testdox Data built by a locale filter while the locale is being resolved is not cached for a guessed locale.
+	 */
+	public function test_geographical_data_built_while_resolving_the_locale_is_not_cached(): void {
+		$this->active_locale = 'fr_FR';
+		$this->register_geographical_filters();
+		add_filter( 'determine_locale', array( $this, 'read_geographical_data_in_locale_filter' ), 20 );
+		$sut                           = new WC_Countries();
+		$this->locale_filter_countries = $sut;
+
+		$this->assertSame( 'fr_FR', $sut->get_countries()['US'], 'Countries should use the resolved request locale.' );
+
+		$this->active_locale = 'en_US';
+
+		$this->assertSame( 'en_US', $sut->get_countries()['US'], 'Countries for another locale should not reuse data built while resolving an earlier locale.' );
+	}
+
+	/**
+	 * @testdox A locale filter that reads the default customer location gets it while WooCommerce reads allowed countries.
+	 *
+	 * @dataProvider provide_default_location_locale_filters
+	 *
+	 * @param string $hook              Locale hook the callback is attached to.
+	 * @param string $allowed_countries Allowed countries mode.
+	 */
+	public function test_locale_filter_reading_default_location_during_allowed_countries_lookup( $hook, $allowed_countries ): void {
+		update_option( 'woocommerce_allowed_countries', $allowed_countries );
+		update_option( 'woocommerce_specific_allowed_countries', array( 'US', 'CA' ) );
+		update_option( 'woocommerce_default_country', 'US:CA' );
+		update_option( 'woocommerce_default_customer_address', DefaultCustomerAddress::BASE );
+		$sut                      = new WC_Countries();
+		$this->original_countries = WC()->countries;
+		WC()->countries           = $sut;
+		add_filter( $hook, array( $this, 'read_default_location_in_locale_filter' ), 20 );
+
+		foreach ( array( 'cold', 'warm' ) as $cache_state ) {
+			$this->assertArrayHasKey( 'CA', $sut->get_allowed_countries(), "Allowed countries should be complete on a $cache_state cache." );
+		}
+
+		$this->assertSame( 1, $this->locale_filter_max_depth, 'Reading the default location inside the locale filter should not run the filter again.' );
+		$this->assertNotEmpty( $this->locale_filter_default_locations, 'The locale filter should have read the default customer location.' );
+
+		foreach ( $this->locale_filter_default_locations as $location ) {
+			$this->assertSame(
+				array(
+					'country' => 'US',
+					'state'   => 'CA',
+				),
+				$location,
+				'The locale filter should get the store base location.'
+			);
+		}
+	}
+
+	/**
+	 * @testdox Helpers use country and state properties that a subclass declares.
+	 */
+	public function test_helpers_use_geographical_properties_declared_by_a_subclass(): void {
+		$sut = new class() extends WC_Countries {
+			/**
+			 * Declared country names.
+			 *
+			 * @var array
+			 */
+			public $countries = array(
+				'US' => 'Declared US',
+				'CA' => 'Declared CA',
+			);
+
+			/**
+			 * Declared state names.
+			 *
+			 * @var array
+			 */
+			public $states = array(
+				'US' => array( 'CA' => 'Declared California' ),
+			);
+		};
+
+		$this->assert_helpers_use_geographical_properties( $sut, 'Declared' );
+	}
+
+	/**
+	 * @testdox Helpers use country and state properties assigned on an instance.
+	 */
+	public function test_helpers_use_geographical_properties_assigned_on_an_instance(): void {
+		$sut            = new WC_Countries();
+		$sut->countries = array(
+			'US' => 'Assigned US',
+			'CA' => 'Assigned CA',
+		);
+		$sut->states    = array(
+			'US' => array( 'CA' => 'Assigned California' ),
+		);
+
+		$this->assert_helpers_use_geographical_properties( $sut, 'Assigned' );
+	}
+
+	/**
+	 * @testdox Asking for the locale while geographical data is being built does not build it again.
+	 *
+	 * @dataProvider provide_geographical_builds
+	 *
+	 * @param string $build_hook Filter that runs while the data is being built.
+	 * @param string $lookup     WC_Countries method that builds the data.
+	 */
+	public function test_locale_lookup_while_building_geographical_data_does_not_build_it_again( $build_hook, $lookup ): void {
+		add_filter( 'determine_locale', array( $this, 'filter_active_locale' ) );
+		$sut = new WC_Countries();
+		$sut->$lookup();
+		$this->locale_filter_countries = $sut;
+		$this->locale_filter_lookup    = $lookup;
+		add_filter( 'locale', array( $this, 'read_geographical_data_in_locale_filter' ), 20 );
+		add_filter( $build_hook, array( $this, 'determine_locale_while_building' ) );
+		$this->active_locale = 'fr_FR';
+
+		$data = $sut->$lookup();
+
+		$this->assertLessThan( self::LOCALE_FILTER_DEPTH_LIMIT, $this->locale_filter_max_depth, 'Asking for the locale during a build should not start the same build again.' );
+		$this->assertNotEmpty( $data, 'The data built for the new locale should be complete.' );
+	}
+
+	/**
+	 * @testdox Country locale settings asked for while the country list is being built are rebuilt once the list is complete.
+	 */
+	public function test_country_locale_settings_survive_a_lookup_made_while_countries_are_built(): void {
+		$sut      = new WC_Countries();
+		$callback = function ( $countries ) use ( $sut ) {
+			$sut->get_country_locale();
+			return $countries;
+		};
+		add_filter( 'woocommerce_countries', $callback );
+
+		$sut->get_countries();
+
+		remove_filter( 'woocommerce_countries', $callback );
+
+		$this->assertArrayHasKey( 'DE', $sut->get_country_locale(), 'A lookup made while the country list is being built should not drop country locale entries.' );
+	}
+
+	/**
+	 * @testdox Specific country lists asked for while the country list is being built are empty, then complete.
+	 *
+	 * @dataProvider provide_specific_country_list_methods
+	 *
+	 * @param string $method                   Method under test.
+	 * @param string $mode_option              Country restriction mode option.
+	 * @param string $specific_countries_option Specific countries option.
+	 */
+	public function test_specific_country_lists_asked_for_while_countries_are_built_are_empty_then_complete( $method, $mode_option, $specific_countries_option ): void {
+		update_option( $mode_option, 'specific' );
+		update_option( $specific_countries_option, array( 'US', 'DE', 'CA' ) );
+		$sut      = new WC_Countries();
+		$nested   = null;
+		$callback = function ( $countries ) use ( $sut, $method, &$nested ) {
+			$nested = $sut->$method();
+			return $countries;
+		};
+		add_filter( 'woocommerce_countries', $callback );
+
+		$sut->get_countries();
+
+		remove_filter( 'woocommerce_countries', $callback );
+
+		$this->assertSame( array(), $nested, 'A lookup made while the country list is being built should get no countries rather than null entries.' );
+		$this->assertSame( array( 'US', 'DE', 'CA' ), array_keys( $sut->$method() ), 'The list should be complete once the country list is built.' );
+	}
+
+	/**
+	 * @testdox Country locale settings are rebuilt after a locale filter throws part-way through a build.
+	 */
+	public function test_country_locale_settings_are_rebuilt_after_a_locale_filter_throws(): void {
+		$sut      = new WC_Countries();
+		$callback = function () {
+			throw new \Exception( 'Locale filter failure.' );
+		};
+		add_filter( 'woocommerce_get_country_locale_default', $callback );
+
+		try {
+			$sut->get_country_locale();
+			$this->fail( 'The locale filter exception should reach the caller.' );
+		} catch ( \Exception $e ) {
+			$this->assertSame( 'Locale filter failure.', $e->getMessage() );
+		}
+
+		remove_filter( 'woocommerce_get_country_locale_default', $callback );
+
+		$locale = $sut->get_country_locale();
+
+		$this->assertArrayHasKey( 'default', $locale, 'A build that threw should not be reused.' );
+		$this->assertArrayHasKey( 'DE', $locale, 'The rebuild after a throw should be complete.' );
+	}
+
+	/**
+	 * @testdox Switching locales keeps per-locale caches while a locale filter reads geographical data.
+	 *
+	 * @dataProvider provide_switched_locale_lookups
+	 *
+	 * @param string $lookup WC_Countries method the locale filter calls.
+	 */
+	public function test_switching_locales_keeps_per_locale_caches_with_a_geographical_locale_filter( $lookup ): void {
+		$this->use_locale_switcher_with( 'fr_FR' );
+		add_filter( 'woocommerce_countries', array( $this, 'stamp_country_names_with_switched_locale' ) );
+		add_filter( 'woocommerce_get_country_locale', array( $this, 'stamp_country_locale_with_switched_locale' ) );
+		$sut = new WC_Countries();
+		$this->assert_switched_locale_stamps( $sut, 'site' );
+		$this->locale_filter_countries = $sut;
+		$this->locale_filter_lookup    = $lookup;
+		add_filter( 'locale', array( $this, 'read_geographical_data_in_locale_filter' ), 20 );
+
+		$this->assertTrue( switch_to_locale( 'fr_FR' ), 'The test locale should be available to switch to.' );
+		$this->assert_switched_locale_stamps( $sut, 'fr_FR' );
+
+		restore_previous_locale();
+		$this->assert_switched_locale_stamps( $sut, 'site' );
+
+		$this->assertSame(
+			array(
+				'site'  => 1,
+				'fr_FR' => 1,
+			),
+			$this->switched_locale_stamps['countries'],
+			'Countries should be built once per locale.'
+		);
+		$this->assertLessThan( self::LOCALE_FILTER_DEPTH_LIMIT, $this->locale_filter_max_depth, 'The locale filter should not recurse through the geographical lookups.' );
+	}
+
 	/**
 	 * Tests for `get_country_from_alpha_3_code`.
 	 *
@@ -550,6 +1030,18 @@ class WC_Countries_Test extends \WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * Specific-country list methods and their options.
+	 *
+	 * @return array<string, array<string>>
+	 */
+	public function provide_specific_country_list_methods() {
+		return array(
+			'allowed countries'  => array( 'get_allowed_countries', 'woocommerce_allowed_countries', 'woocommerce_specific_allowed_countries' ),
+			'shipping countries' => array( 'get_shipping_countries', 'woocommerce_ship_to_countries', 'woocommerce_specific_ship_to_countries' ),
+		);
+	}
+
 	/**
 	 * Specific-country state methods and their options.
 	 *
@@ -562,6 +1054,131 @@ class WC_Countries_Test extends \WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * Locale hooks and the geographical lookups a callback on them makes.
+	 *
+	 * @return array<string, array<string>>
+	 */
+	public function provide_locale_filter_geographical_lookups() {
+		return array(
+			'locale reads countries'               => array( 'locale', 'get_countries' ),
+			'locale reads states'                  => array( 'locale', 'get_states' ),
+			'locale reads country locale settings' => array( 'locale', 'get_country_locale' ),
+			'determine_locale reads countries'     => array( 'determine_locale', 'get_countries' ),
+			'determine_locale reads continents'    => array( 'determine_locale', 'get_continents' ),
+			'pre_determine_locale reads states'    => array( 'pre_determine_locale', 'get_states' ),
+		);
+	}
+
+	/**
+	 * Filters that run while geographical data is built, and the lookups that build it.
+	 *
+	 * @return array<string, array<string>>
+	 */
+	public function provide_geographical_builds() {
+		return array(
+			'country list'            => array( 'woocommerce_countries', 'get_countries' ),
+			'state list'              => array( 'woocommerce_states', 'get_states' ),
+			'country locale settings' => array( 'woocommerce_get_country_locale', 'get_country_locale' ),
+		);
+	}
+
+	/**
+	 * Locale hooks and allowed-country modes for a locale filter that reads the default customer location.
+	 *
+	 * @return array<string, array<string>>
+	 */
+	public function provide_default_location_locale_filters() {
+		return array(
+			'locale, all countries'                => array( 'locale', 'all' ),
+			'locale, specific countries'           => array( 'locale', 'specific' ),
+			'determine_locale, all countries'      => array( 'determine_locale', 'all' ),
+			'determine_locale, specific countries' => array( 'determine_locale', 'specific' ),
+		);
+	}
+
+	/**
+	 * Lookups a locale filter makes while locales are switched.
+	 *
+	 * @return array<string, array<string>>
+	 */
+	public function provide_switched_locale_lookups() {
+		return array(
+			'countries'               => array( 'get_countries' ),
+			'country locale settings' => array( 'get_country_locale' ),
+		);
+	}
+
+	/**
+	 * Replace the global locale switcher with one that can switch to the given locale.
+	 *
+	 * @param string $locale Locale to make available.
+	 */
+	private function use_locale_switcher_with( $locale ): void {
+		$this->available_test_locale = $locale;
+		add_filter( 'get_available_languages', array( $this, 'add_available_test_locale' ) );
+
+		$this->original_locale_switcher = $GLOBALS['wp_locale_switcher'];
+		$GLOBALS['wp_locale_switcher']  = new WP_Locale_Switcher(); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- The core switcher reads available languages once, on construction.
+		$GLOBALS['wp_locale_switcher']->init();
+	}
+
+	/**
+	 * Record a build for the switched locale and return its stamp.
+	 *
+	 * @param string $type Data type.
+	 * @return string
+	 */
+	private function record_switched_locale_stamp( $type ) {
+		$stamp = is_locale_switched() ? $GLOBALS['wp_locale_switcher']->get_switched_locale() : 'site';
+
+		if ( ! isset( $this->switched_locale_stamps[ $type ][ $stamp ] ) ) {
+			$this->switched_locale_stamps[ $type ][ $stamp ] = 0;
+		}
+
+		++$this->switched_locale_stamps[ $type ][ $stamp ];
+
+		return $stamp;
+	}
+
+	/**
+	 * Assert that the country and state helpers read the object's countries and states properties.
+	 *
+	 * @param WC_Countries $sut    The system under test.
+	 * @param string       $prefix Prefix of the property values.
+	 */
+	private function assert_helpers_use_geographical_properties( WC_Countries $sut, $prefix ): void {
+		add_filter( 'pre_option_woocommerce_allowed_countries', array( $this, 'return_all_countries' ) );
+		add_filter( 'pre_option_woocommerce_ship_to_countries', array( $this, 'return_all_countries' ) );
+		update_option( 'woocommerce_default_country', 'US:CA' );
+
+		$this->assertSame( "$prefix US", $sut->get_allowed_countries()['US'], 'Allowed countries should use the countries property.' );
+		$this->assertSame( "$prefix US", $sut->get_shipping_countries()['US'], 'Shipping countries should use the countries property.' );
+		$this->assertSame( "$prefix California", $sut->get_allowed_country_states()['US']['CA'], 'Allowed states should use the states property.' );
+		$this->assertSame( "$prefix California", $sut->get_shipping_country_states()['US']['CA'], 'Shipping states should use the states property.' );
+		$this->assertStringContainsString(
+			"$prefix CA",
+			$sut->get_formatted_address(
+				array(
+					'city'    => 'Toronto',
+					'country' => 'CA',
+				)
+			),
+			'Formatted addresses should use the countries property.'
+		);
+	}
+
+	/**
+	 * Assert that countries and country locale settings carry the expected switched-locale stamp.
+	 *
+	 * @param WC_Countries $sut   The system under test.
+	 * @param string       $stamp Expected stamp.
+	 */
+	private function assert_switched_locale_stamps( WC_Countries $sut, $stamp ): void {
+		$this->assertSame( $stamp, $sut->get_countries()['US'], "Countries should use the $stamp locale cache." );
+		$this->assertSame( $stamp, $sut->get_country_locale()['US']['postcode']['label'], "Country locale settings should use the $stamp locale." );
+	}
+
 	/**
 	 * Register the geographical data filters used by locale tests.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/OrderControllerTests.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/OrderControllerTests.php
index 16dd166b822..2d50ec9cbec 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/OrderControllerTests.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/OrderControllerTests.php
@@ -300,6 +300,48 @@ class OrderControllerTests extends \WC_Unit_Test_Case {
 		$this->sut->validate_order_before_payment( $order );
 	}

+	/**
+	 * @testdox validate_address_fields() enforces the default required fields when the default locale entry is missing or not an array.
+	 *
+	 * @dataProvider provide_broken_default_locale_entries
+	 *
+	 * @param mixed $default_entry Value to put in the default locale entry, or null to remove the entry.
+	 */
+	public function test_validate_address_fields_enforces_default_fields_without_a_default_locale_entry( $default_entry ): void {
+		$countries = wc()->countries;
+		$countries->get_country_locale();
+		if ( null === $default_entry ) {
+			unset( $countries->locale['default'] );
+		} else {
+			$countries->locale['default'] = $default_entry;
+		}
+		$order = WC_Helper_Order::create_order();
+		$order->set_billing_postcode( '' );
+		$errors = new \WP_Error();
+
+		try {
+			$this->sut->validate_address_fields( $order, 'billing', $errors );
+		} finally {
+			// Drop the broken settings so the next lookup rebuilds them.
+			$countries->locale = array();
+		}
+
+		$this->assertCount( 1, $errors->get_error_messages( 'billing' ), 'Only the missing default field should be reported.' );
+		$this->assertSame( 'postcode', $errors->get_error_data( 'billing' ), 'The default required fields should still be enforced.' );
+	}
+
+	/**
+	 * Broken values for the default entry of the country locale settings.
+	 *
+	 * @return array<string, array<mixed>>
+	 */
+	public function provide_broken_default_locale_entries(): array {
+		return array(
+			'entry removed'      => array( null ),
+			'entry not an array' => array( false ),
+		);
+	}
+
 	/**
 	 * test_validate_order_before_payment_valid_coupon.
 	 */