Commit 0c88c8f530f for woocommerce

commit 0c88c8f530fce47014ff34087d4522e4a887cc6b
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Mon Sep 7 17:20:55 2026 +0300

    Fix stale geographical data after locale changes (#68381)

    * fix(i18n): Refresh geographical data by locale

    Country, state, and continent arrays are translated and sorted when first loaded by a WC_Countries instance.

    A later WordPress locale switch reused those first values, so multilingual requests could return stale labels through country and shipping APIs.

    Cache each geographical dataset by one normalized active locale and use that same locale for collation. Preserve existing public methods, filters, return shapes, and lazy cache semantics while allowing restored locales to reuse their entries.

    Refs #42013

    * chore: Add locale-aware geography changelog

    Document active-locale refresh behavior for geographical names in the next WooCommerce release.

    Refs #42013

    * test: Strengthen locale-aware geography cache coverage

    Protect the existing empty-result cache semantics and verify that allowed states, shipping states, and shipping continents follow locale changes.

    Refs #42013

    * fix(i18n): Validate geographical cache locale

    The WordPress locale filter can return invalid values even though
    get_locale() is documented as a string. Casting arrays emitted warnings,
    while non-stringable objects caused geographical lookups to fail.

    Accept only a truthy string as a cache locale and otherwise reuse the
    existing en_US fallback. Cover arrays and non-stringable objects to keep
    malformed filter results from becoming cache keys.

    Refs #42013

    * fix(i18n): Follow request locale in country caches

    WC_Countries keyed geographical data to the site locale and built country
    field settings only once. Request contexts with a different user locale
    could therefore reuse stale translated labels. State restriction helpers
    also re-evaluated the locale for every selected country.

    Use the request locale for cache selection. Rebuild WooCommerce-owned
    country field settings when that locale changes, while preserving
    external prepopulation of the public locale property. Read the existing
    magic states accessor once per helper to retain override compatibility
    and reduce filter calls.

    Refs #42013

diff --git a/plugins/woocommerce/changelog/fix-locale-aware-geographical-cache b/plugins/woocommerce/changelog/fix-locale-aware-geographical-cache
new file mode 100644
index 00000000000..74d68dcc680
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-locale-aware-geographical-cache
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Refresh country, state, and continent names when the active locale changes.
diff --git a/plugins/woocommerce/includes/class-wc-countries.php b/plugins/woocommerce/includes/class-wc-countries.php
index c87df90ec5d..26edf042857 100644
--- a/plugins/woocommerce/includes/class-wc-countries.php
+++ b/plugins/woocommerce/includes/class-wc-countries.php
@@ -12,6 +12,8 @@ use Automattic\WooCommerce\Blocks\Utils\CartCheckoutUtils;

 /**
  * The WooCommerce countries class stores country/state data.
+ *
+ * @property-read array $states Country states.
  */
 class WC_Countries {

@@ -39,6 +41,26 @@ class WC_Countries {
 	 */
 	private $geo_cache = array();

+	/**
+	 * Locale used to build the cached country locale settings.
+	 *
+	 * Null until get_country_locale() builds the public locale property.
+	 *
+	 * @var string|null
+	 */
+	private $country_locale_built_for = null;
+
+	/**
+	 * Get the active request locale for geographical cache entries.
+	 *
+	 * @return string
+	 */
+	private function get_cache_locale() {
+		$locale = determine_locale();
+
+		return is_string( $locale ) && $locale ? $locale : 'en_US';
+	}
+
 	/**
 	 * Auto-load in-accessible properties on demand.
 	 *
@@ -61,7 +83,9 @@ class WC_Countries {
 	 * @return array
 	 */
 	public function get_countries() {
-		if ( empty( $this->geo_cache['countries'] ) ) {
+		$locale = $this->get_cache_locale();
+
+		if ( empty( $this->geo_cache['countries'][ $locale ] ) ) {
 			/**
 			 * Allows filtering of the list of countries in WC.
 			 *
@@ -69,13 +93,13 @@ class WC_Countries {
 			 *
 			 * @param array $countries
 			 */
-			$this->geo_cache['countries'] = apply_filters( 'woocommerce_countries', include WC()->plugin_path() . '/i18n/countries.php' );
+			$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'] );
+				wc_asort_by_locale( $this->geo_cache['countries'][ $locale ], $locale );
 			}
 		}

-		return $this->geo_cache['countries'];
+		return $this->geo_cache['countries'][ $locale ];
 	}

 	/**
@@ -123,7 +147,9 @@ class WC_Countries {
 	 * @return array
 	 */
 	public function get_continents() {
-		if ( empty( $this->geo_cache['continents'] ) ) {
+		$locale = $this->get_cache_locale();
+
+		if ( empty( $this->geo_cache['continents'][ $locale ] ) ) {
 			/**
 			 * Allows filtering of continents in WC.
 			 *
@@ -131,10 +157,10 @@ class WC_Countries {
 			 *
 			 * @param array[array] $continents
 			 */
-			$this->geo_cache['continents'] = apply_filters( 'woocommerce_continents', include WC()->plugin_path() . '/i18n/continents.php' );
+			$this->geo_cache['continents'][ $locale ] = apply_filters( 'woocommerce_continents', include WC()->plugin_path() . '/i18n/continents.php' );
 		}

-		return $this->geo_cache['continents'];
+		return $this->geo_cache['continents'][ $locale ];
 	}

 	/**
@@ -210,6 +236,7 @@ class WC_Countries {
 	public function load_country_states() {
 		global $states;

+		$locale = $this->get_cache_locale();
 		$states = include WC()->plugin_path() . '/i18n/states.php';

 		/**
@@ -219,7 +246,7 @@ class WC_Countries {
 		 *
 		 * @param array $states
 		 */
-		$this->geo_cache['states'] = apply_filters( 'woocommerce_states', $states );
+		$this->geo_cache['states'][ $locale ] = apply_filters( 'woocommerce_states', $states );
 	}

 	/**
@@ -229,7 +256,9 @@ class WC_Countries {
 	 * @return false|array of states
 	 */
 	public function get_states( $cc = null ) {
-		if ( ! isset( $this->geo_cache['states'] ) ) {
+		$locale = $this->get_cache_locale();
+
+		if ( ! isset( $this->geo_cache['states'][ $locale ] ) ) {
 			/**
 			 * Allows filtering of country states in WC.
 			 *
@@ -237,13 +266,13 @@ class WC_Countries {
 			 *
 			 * @param array $states
 			 */
-			$this->geo_cache['states'] = apply_filters( 'woocommerce_states', include WC()->plugin_path() . '/i18n/states.php' );
+			$this->geo_cache['states'][ $locale ] = apply_filters( 'woocommerce_states', include WC()->plugin_path() . '/i18n/states.php' );
 		}

 		if ( ! is_null( $cc ) ) {
-			return isset( $this->geo_cache['states'][ $cc ] ) ? $this->geo_cache['states'][ $cc ] : false;
+			return isset( $this->geo_cache['states'][ $locale ][ $cc ] ) ? $this->geo_cache['states'][ $locale ][ $cc ] : false;
 		} else {
-			return $this->geo_cache['states'];
+			return $this->geo_cache['states'][ $locale ];
 		}
 	}

@@ -400,9 +429,11 @@ class WC_Countries {
 		$raw_countries = get_option( 'woocommerce_specific_allowed_countries' );

 		if ( $raw_countries ) {
+			$all_states = $this->states;
+
 			foreach ( $raw_countries as $country ) {
-				if ( isset( $this->states[ $country ] ) ) {
-					$states[ $country ] = $this->states[ $country ];
+				if ( isset( $all_states[ $country ] ) ) {
+					$states[ $country ] = $all_states[ $country ];
 				}
 			}
 		}
@@ -429,9 +460,11 @@ class WC_Countries {
 		$raw_countries = get_option( 'woocommerce_specific_ship_to_countries' );

 		if ( $raw_countries ) {
+			$all_states = $this->states;
+
 			foreach ( $raw_countries as $country ) {
-				if ( ! empty( $this->states[ $country ] ) ) {
-					$states[ $country ] = $this->states[ $country ];
+				if ( ! empty( $all_states[ $country ] ) ) {
+					$states[ $country ] = $all_states[ $country ];
 				}
 			}
 		}
@@ -894,7 +927,13 @@ class WC_Countries {
 	 * @return array
 	 */
 	public function get_country_locale() {
-		if ( empty( $this->locale ) ) {
+		if ( ! empty( $this->locale ) && null === $this->country_locale_built_for ) {
+			return $this->locale;
+		}
+
+		$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(
@@ -1731,6 +1770,8 @@ class WC_Countries {
 				}
 			}
 			unset( $locale_entry );
+
+			$this->country_locale_built_for = $cache_locale;
 		}

 		return $this->locale;
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 71f8c1074a2..594918b0b8a 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -10686,12 +10686,6 @@ parameters:
 			count: 5
 			path: includes/class-wc-countries.php

-		-
-			message: '#^Access to an undefined property WC_Countries\:\:\$states\.$#'
-			identifier: property.notFound
-			count: 2
-			path: includes/class-wc-countries.php
-
 		-
 			message: '#^Method WC_Countries\:\:country_dropdown_options\(\) has no return type specified\.$#'
 			identifier: missingType.return
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 37a8a984c25..f7676112a37 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-countries-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-countries-test.php
@@ -5,6 +5,452 @@ declare( strict_types=1 );
  * Tests for the WC_Countries class.
  */
 class WC_Countries_Test extends \WC_Unit_Test_Case {
+	/**
+	 * Locale exposed through the WordPress request-locale filter.
+	 *
+	 * @var mixed
+	 */
+	private $active_locale = 'en_US';
+
+	/**
+	 * Geographical filter calls keyed by data type and locale.
+	 *
+	 * @var array<string, array<string, int>>
+	 */
+	private $geographical_filter_calls = array();
+
+	/**
+	 * Number of times the stateful locale filter was called.
+	 *
+	 * @var int
+	 */
+	private $locale_filter_calls = 0;
+
+	/**
+	 * Number of times the country source filter was called.
+	 *
+	 * @var int
+	 */
+	private $country_filter_calls = 0;
+
+	/**
+	 * Number of times the country locale filter was called.
+	 *
+	 * @var int
+	 */
+	private $country_locale_filter_calls = 0;
+
+	/**
+	 * Number of empty geographical filter calls keyed by filter name.
+	 *
+	 * @var array<string, int>
+	 */
+	private $empty_geographical_filter_calls = array();
+
+	/**
+	 * Whether the states global existed before the test.
+	 *
+	 * @var bool
+	 */
+	private $states_global_existed = false;
+
+	/**
+	 * Value of the states global before the test.
+	 *
+	 * @var mixed
+	 */
+	private $states_global_value;
+
+	/**
+	 * Set up test state.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$this->states_global_existed = array_key_exists( 'states', $GLOBALS );
+		$this->states_global_value   = $this->states_global_existed ? $GLOBALS['states'] : null;
+	}
+
+	/**
+	 * Filter the active request locale.
+	 *
+	 * @internal
+	 *
+	 * @param string $locale Current locale.
+	 * @return mixed
+	 */
+	public function filter_active_locale( $locale ) {
+		return $this->active_locale;
+	}
+
+	/**
+	 * Return an empty locale once, then the WordPress fallback locale.
+	 *
+	 * @internal
+	 *
+	 * @param string $locale Current locale.
+	 * @return string
+	 */
+	public function filter_stateful_locale( $locale ) {
+		++$this->locale_filter_calls;
+		return 1 === $this->locale_filter_calls ? '' : 'en_US';
+	}
+
+	/**
+	 * Record and return the active request locale.
+	 *
+	 * @internal
+	 *
+	 * @param string $locale Current locale.
+	 * @return mixed
+	 */
+	public function filter_counted_active_locale( $locale ) {
+		++$this->locale_filter_calls;
+		return $this->active_locale;
+	}
+
+	/**
+	 * Record a country source-filter call.
+	 *
+	 * @internal
+	 *
+	 * @param array $countries Country names.
+	 * @return array
+	 */
+	public function record_country_filter_call( $countries ) {
+		++$this->country_filter_calls;
+		return $countries;
+	}
+
+	/**
+	 * Return empty geographical data and record the filter call.
+	 *
+	 * @internal
+	 *
+	 * @param array $data Geographical data.
+	 * @return array
+	 */
+	public function filter_empty_geographical_data( $data ) {
+		unset( $data );
+
+		$filter = current_filter();
+
+		if ( ! isset( $this->empty_geographical_filter_calls[ $filter ] ) ) {
+			$this->empty_geographical_filter_calls[ $filter ] = 0;
+		}
+
+		++$this->empty_geographical_filter_calls[ $filter ];
+		return array();
+	}
+
+	/**
+	 * Stamp and record country names for the active locale.
+	 *
+	 * @internal
+	 *
+	 * @param array $countries Country names.
+	 * @return array
+	 */
+	public function filter_country_names( $countries ) {
+		$this->record_geographical_filter_call( 'countries' );
+		$countries['US'] = $this->active_locale;
+		return $countries;
+	}
+
+	/**
+	 * Stamp and record state names for the active locale.
+	 *
+	 * @internal
+	 *
+	 * @param array $states State names.
+	 * @return array
+	 */
+	public function filter_state_names( $states ) {
+		$this->record_geographical_filter_call( 'states' );
+		$states['US']['CA'] = $this->active_locale;
+		return $states;
+	}
+
+	/**
+	 * Stamp and record continent names for the active locale.
+	 *
+	 * @internal
+	 *
+	 * @param array $continents Continent data.
+	 * @return array
+	 */
+	public function filter_continent_names( $continents ) {
+		$this->record_geographical_filter_call( 'continents' );
+		$continents['NA']['name'] = $this->active_locale;
+		return $continents;
+	}
+
+	/**
+	 * Stamp country locale settings for the active request locale.
+	 *
+	 * @internal
+	 *
+	 * @param array $locale Country locale settings.
+	 * @return array
+	 */
+	public function filter_country_locale( $locale ) {
+		++$this->country_locale_filter_calls;
+		$locale['US']['postcode']['label'] = $this->active_locale;
+		return $locale;
+	}
+
+	/**
+	 * Return the option value that enables all countries.
+	 *
+	 * @internal
+	 *
+	 * @return string
+	 */
+	public function return_all_countries() {
+		return 'all';
+	}
+
+	/**
+	 * Remove registered filters and reset test state.
+	 */
+	public function tearDown(): void {
+		if ( $this->states_global_existed ) {
+			$GLOBALS['states'] = $this->states_global_value;
+		} else {
+			unset( $GLOBALS['states'] );
+		}
+
+		$this->states_global_existed = false;
+		$this->states_global_value   = null;
+
+		remove_filter( 'determine_locale', array( $this, 'filter_active_locale' ) );
+		remove_filter( 'determine_locale', array( $this, 'filter_counted_active_locale' ) );
+		remove_filter( 'determine_locale', array( $this, 'filter_stateful_locale' ) );
+		remove_filter( 'woocommerce_countries', array( $this, 'filter_country_names' ) );
+		remove_filter( 'woocommerce_countries', array( $this, 'record_country_filter_call' ) );
+		remove_filter( 'woocommerce_countries', array( $this, 'filter_empty_geographical_data' ) );
+		remove_filter( 'woocommerce_states', array( $this, 'filter_state_names' ) );
+		remove_filter( 'woocommerce_states', array( $this, 'filter_empty_geographical_data' ) );
+		remove_filter( 'woocommerce_continents', array( $this, 'filter_continent_names' ) );
+		remove_filter( 'woocommerce_continents', array( $this, 'filter_empty_geographical_data' ) );
+		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' ) );
+
+		$this->active_locale                   = 'en_US';
+		$this->geographical_filter_calls       = array();
+		$this->locale_filter_calls             = 0;
+		$this->country_filter_calls            = 0;
+		$this->country_locale_filter_calls     = 0;
+		$this->empty_geographical_filter_calls = array();
+
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox Geographical data uses the active request locale and reuses each locale cache.
+	 */
+	public function test_geographical_data_uses_the_active_request_locale_and_reuses_each_locale_cache() {
+		$this->register_geographical_filters();
+		$sut = new WC_Countries();
+
+		$this->assert_geographical_locale( $sut, 'en_US' );
+
+		$this->active_locale = 'fr_FR';
+		$this->assert_geographical_locale( $sut, 'fr_FR' );
+
+		$this->active_locale = 'en_US';
+		$this->assert_geographical_locale( $sut, 'en_US' );
+
+		$expected_calls = array(
+			'countries'  => array(
+				'en_US' => 1,
+				'fr_FR' => 1,
+			),
+			'states'     => array(
+				'en_US' => 1,
+				'fr_FR' => 1,
+			),
+			'continents' => array(
+				'en_US' => 1,
+				'fr_FR' => 1,
+			),
+		);
+		$this->assertSame( $expected_calls, $this->geographical_filter_calls, 'Each geographical source filter should run once per locale.' );
+	}
+
+	/**
+	 * @testdox Loading country states populates only the active request locale.
+	 */
+	public function test_load_country_states_populates_only_the_active_request_locale() {
+		$this->register_geographical_filters();
+		$sut = new WC_Countries();
+
+		$sut->load_country_states();
+		$this->assertSame( 'en_US', $sut->get_states( 'US' )['CA'], 'Loaded states should use the active request locale.' );
+
+		$this->active_locale = 'fr_FR';
+		$this->assertSame( 'fr_FR', $sut->get_states( 'US' )['CA'], 'States should use the changed active request locale.' );
+
+		$this->active_locale = 'en_US';
+		$this->assertSame( 'en_US', $sut->get_states( 'US' )['CA'], 'States should reuse the restored request-locale cache.' );
+		$this->assertSame(
+			array(
+				'en_US' => 1,
+				'fr_FR' => 1,
+			),
+			$this->geographical_filter_calls['states'],
+			'The states source filter should run once per locale.'
+		);
+	}
+
+	/**
+	 * @testdox Country locale settings rebuild for the active request locale.
+	 */
+	public function test_country_locale_settings_rebuild_for_the_active_request_locale(): void {
+		add_filter( 'determine_locale', array( $this, 'filter_active_locale' ) );
+		add_filter( 'woocommerce_get_country_locale', array( $this, 'filter_country_locale' ) );
+		$sut = new WC_Countries();
+
+		$this->assertSame( 'en_US', $sut->get_country_locale()['US']['postcode']['label'], 'Country locale settings should use the initial request locale.' );
+		$this->assertSame( 'en_US', $sut->get_country_locale()['US']['postcode']['label'], 'Country locale settings should reuse the initial request-locale cache.' );
+		$this->assertSame( 1, $this->country_locale_filter_calls, 'Country locale settings should be built once for the initial request locale.' );
+
+		$this->active_locale = 'fr_FR';
+
+		$this->assertSame( 'fr_FR', $sut->get_country_locale()['US']['postcode']['label'], 'Country locale settings should rebuild for the changed request locale.' );
+		$this->assertSame( 'fr_FR', $sut->get_country_locale()['US']['postcode']['label'], 'Country locale settings should reuse the changed request-locale cache.' );
+		$this->assertSame( 2, $this->country_locale_filter_calls, 'Country locale settings should be built once per request locale.' );
+	}
+
+	/**
+	 * @testdox Country locale settings preserve a prepopulated public locale property.
+	 */
+	public function test_country_locale_settings_preserve_a_prepopulated_public_locale_property(): void {
+		$custom_locale = array(
+			'CUSTOM' => array(
+				'city' => array(
+					'label' => 'Extension-provided city',
+				),
+			),
+		);
+		$sut           = new WC_Countries();
+		$sut->locale   = $custom_locale;
+		add_filter( 'determine_locale', array( $this, 'filter_counted_active_locale' ) );
+
+		$this->assertSame( $custom_locale, $sut->get_country_locale(), 'A prepopulated public locale property should remain authoritative.' );
+		$this->assertSame( 0, $this->locale_filter_calls, 'A prepopulated public locale property should bypass request-locale evaluation.' );
+	}
+
+	/**
+	 * @testdox Filtering states for specific countries reads the magic states property once.
+	 *
+	 * @dataProvider provide_specific_country_state_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_filtering_states_for_specific_countries_reads_the_magic_states_property_once( $method, $mode_option, $specific_countries_option ): void {
+		update_option( $mode_option, 'specific' );
+		update_option( $specific_countries_option, array( 'US', 'CA' ) );
+		add_filter( 'determine_locale', array( $this, 'filter_counted_active_locale' ) );
+		$sut = new class() extends WC_Countries {
+			/**
+			 * Number of reads through the magic states property.
+			 *
+			 * @var int
+			 */
+			public $state_property_reads = 0;
+
+			/**
+			 * Track magic states property reads.
+			 *
+			 * @param mixed $key Property key.
+			 * @return mixed
+			 */
+			public function __get( $key ) {
+				if ( 'states' === $key ) {
+					++$this->state_property_reads;
+				}
+
+				return parent::__get( $key );
+			}
+		};
+
+		$states = $sut->$method();
+
+		$this->assertArrayHasKey( 'US', $states, 'The United States should be included in the filtered states.' );
+		$this->assertArrayHasKey( 'CA', $states, 'Canada should be included in the filtered states.' );
+		$this->assertSame( 1, $sut->state_property_reads, 'Filtering states should preserve one magic property read.' );
+		$this->assertSame( 1, $this->locale_filter_calls, 'Filtering states should evaluate the request locale once.' );
+	}
+
+	/**
+	 * @testdox Country loading normalizes a falsey locale with one locale read.
+	 */
+	public function test_country_loading_normalizes_a_falsey_locale_with_one_locale_read() {
+		add_filter( 'determine_locale', array( $this, 'filter_stateful_locale' ) );
+		add_filter( 'woocommerce_countries', array( $this, 'record_country_filter_call' ) );
+		$sut = new WC_Countries();
+
+		$sut->get_countries();
+
+		$this->assertSame( 1, $this->locale_filter_calls, 'A country load should read the active locale once.' );
+
+		$sut->get_countries();
+
+		$this->assertSame( 2, $this->locale_filter_calls, 'Each country cache lookup should read the active locale once.' );
+		$this->assertSame( 1, $this->country_filter_calls, 'The normalized fallback locale should reuse its country cache.' );
+	}
+
+	/**
+	 * @testdox Country loading falls back for an invalid filtered locale.
+	 *
+	 * @dataProvider provide_invalid_locale_values
+	 *
+	 * @param mixed $invalid_locale Invalid filtered locale.
+	 */
+	public function test_country_loading_normalizes_invalid_filtered_locale( $invalid_locale ): void {
+		$this->active_locale = $invalid_locale;
+		add_filter( 'determine_locale', array( $this, 'filter_active_locale' ) );
+		add_filter( 'woocommerce_countries', array( $this, 'record_country_filter_call' ) );
+		$sut = new WC_Countries();
+
+		$sut->get_countries();
+
+		$this->active_locale = 'en_US';
+		$sut->get_countries();
+
+		$this->assertSame( 1, $this->country_filter_calls, 'An invalid locale should share the fallback locale cache.' );
+	}
+
+	/**
+	 * @testdox Empty filtered geographical data preserves existing cache semantics (characterization, not locale coverage).
+	 */
+	public function test_empty_filtered_geographical_data_preserves_existing_cache_semantics(): void {
+		add_filter( 'woocommerce_countries', array( $this, 'filter_empty_geographical_data' ) );
+		add_filter( 'woocommerce_states', array( $this, 'filter_empty_geographical_data' ) );
+		add_filter( 'woocommerce_continents', array( $this, 'filter_empty_geographical_data' ) );
+		$sut = new WC_Countries();
+
+		$this->assertSame( array(), $sut->get_countries(), 'Countries should allow an empty filtered result.' );
+		$this->assertSame( array(), $sut->get_countries(), 'Countries should allow a repeated empty filtered result.' );
+		$this->assertSame( array(), $sut->get_states(), 'States should allow an empty filtered result.' );
+		$this->assertSame( array(), $sut->get_states(), 'States should reuse an empty filtered result.' );
+		$this->assertSame( array(), $sut->get_continents(), 'Continents should allow an empty filtered result.' );
+		$this->assertSame( array(), $sut->get_continents(), 'Continents should allow a repeated empty filtered result.' );
+		$this->assertSame(
+			array(
+				'woocommerce_countries'  => 2,
+				'woocommerce_states'     => 1,
+				'woocommerce_continents' => 2,
+			),
+			$this->empty_geographical_filter_calls,
+			'Countries and continents should reload empty results while states cache them.'
+		);
+	}
+
 	/**
 	 * Tests for `get_country_from_alpha_3_code`.
 	 *
@@ -77,4 +523,69 @@ class WC_Countries_Test extends \WC_Unit_Test_Case {
 			),
 		);
 	}
+
+	/**
+	 * Invalid values returned by the WordPress locale filter.
+	 *
+	 * @return array<string, array<mixed>>
+	 */
+	public function provide_invalid_locale_values() {
+		return array(
+			'array'                 => array( array() ),
+			'non-stringable object' => array( new stdClass() ),
+		);
+	}
+
+	/**
+	 * Specific-country state methods and their options.
+	 *
+	 * @return array<string, array<string>>
+	 */
+	public function provide_specific_country_state_methods() {
+		return array(
+			'allowed states'  => array( 'get_allowed_country_states', 'woocommerce_allowed_countries', 'woocommerce_specific_allowed_countries' ),
+			'shipping states' => array( 'get_shipping_country_states', 'woocommerce_ship_to_countries', 'woocommerce_specific_ship_to_countries' ),
+		);
+	}
+
+	/**
+	 * Register the geographical data filters used by locale tests.
+	 */
+	private function register_geographical_filters() {
+		add_filter( 'determine_locale', array( $this, 'filter_active_locale' ) );
+		add_filter( 'woocommerce_countries', array( $this, 'filter_country_names' ) );
+		add_filter( 'woocommerce_states', array( $this, 'filter_state_names' ) );
+		add_filter( 'woocommerce_continents', array( $this, 'filter_continent_names' ) );
+		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' ) );
+	}
+
+	/**
+	 * Record a geographical source-filter call for the active locale.
+	 *
+	 * @param string $type Geographical data type.
+	 */
+	private function record_geographical_filter_call( $type ) {
+		if ( ! isset( $this->geographical_filter_calls[ $type ][ $this->active_locale ] ) ) {
+			$this->geographical_filter_calls[ $type ][ $this->active_locale ] = 0;
+		}
+
+		++$this->geographical_filter_calls[ $type ][ $this->active_locale ];
+	}
+
+	/**
+	 * Assert that all geographical data uses the requested locale.
+	 *
+	 * @param WC_Countries $sut    The system under test.
+	 * @param string       $locale Expected locale.
+	 */
+	private function assert_geographical_locale( WC_Countries $sut, $locale ) {
+		$this->assertSame( $locale, $sut->get_allowed_countries()['US'], 'Allowed countries should use the active locale.' );
+		$this->assertSame( $locale, $sut->get_shipping_countries()['US'], 'Shipping countries should use the active locale.' );
+		$this->assertSame( $locale, $sut->get_states( 'US' )['CA'], 'States should use the active locale.' );
+		$this->assertSame( $locale, $sut->get_allowed_country_states()['US']['CA'], 'Allowed states should use the active locale.' );
+		$this->assertSame( $locale, $sut->get_shipping_country_states()['US']['CA'], 'Shipping states should use the active locale.' );
+		$this->assertSame( $locale, $sut->get_continents()['NA']['name'], 'Continents should use the active locale.' );
+		$this->assertSame( $locale, $sut->get_shipping_continents()['NA']['name'], 'Shipping continents should use the active locale.' );
+	}
 }