Commit cb4f8842e75 for woocommerce

commit cb4f8842e75f1325ffe7ade827489508d1b168bb
Author: Tung Du <dinhtungdu@gmail.com>
Date:   Wed Apr 8 11:08:16 2026 +0700

    ProductFilters: normalise query vars before hashing cache keys (#64041)

diff --git a/plugins/woocommerce/changelog/fix-filter-cache-key-normalisation b/plugins/woocommerce/changelog/fix-filter-cache-key-normalisation
new file mode 100644
index 00000000000..d61fd9f0381
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-filter-cache-key-normalisation
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Normalise filter query vars before hashing cache keys so equivalent filter combinations (e.g. filter_color=red,blue vs blue,red) share the same cache entry.
diff --git a/plugins/woocommerce/src/Internal/ProductFilters/FilterData.php b/plugins/woocommerce/src/Internal/ProductFilters/FilterData.php
index 470017f5046..828d696193e 100644
--- a/plugins/woocommerce/src/Internal/ProductFilters/FilterData.php
+++ b/plugins/woocommerce/src/Internal/ProductFilters/FilterData.php
@@ -480,7 +480,7 @@ class FilterData {
 			md5(
 				wp_json_encode(
 					array(
-						'query_vars'  => $query_vars,
+						'query_vars'  => $this->normalize_query_vars( $query_vars ),
 						'extra'       => $extra,
 						'filter_type' => $filter_type,
 					)
@@ -489,6 +489,51 @@ class FilterData {
 		);
 	}

+	/**
+	 * Normalise query vars for cache key generation so that logically equivalent
+	 * filter combinations produce the same hash.
+	 *
+	 * Rules applied (cache key only – the original $query_vars are never modified):
+	 * - All keys are sorted alphabetically (ksort).
+	 * - Values for keys that start with "filter_", equal "rating_filter", or are
+	 *   built-in taxonomy short-names ("categories", "tags", "brands"):
+	 *   comma-separated items are trimmed, lower-cased, sorted, then re-joined.
+	 * - Values for keys that start with "query_type_": trimmed and lower-cased.
+	 * - Values for "min_price" / "max_price": trimmed.
+	 *
+	 * @since 10.8.0
+	 *
+	 * @param array $query_vars Raw query vars.
+	 * @return array Normalised copy of $query_vars.
+	 */
+	private function normalize_query_vars( array $query_vars ): array {
+		// Built-in taxonomy filter params that are treated as unordered sets.
+		// See Params::get_taxonomy_params() for the source of these short names.
+		$taxonomy_set_params = array( 'categories', 'tags', 'brands' );
+
+		ksort( $query_vars );
+
+		foreach ( $query_vars as $key => $value ) {
+			if ( ! is_string( $key ) || ! is_string( $value ) ) {
+				continue;
+			}
+
+			if ( str_starts_with( $key, 'filter_' ) || 'rating_filter' === $key || in_array( $key, $taxonomy_set_params, true ) ) {
+				$pieces = array_map( 'trim', explode( ',', $value ) );
+				$pieces = array_map( 'strtolower', $pieces );
+				$pieces = array_values( array_unique( array_filter( $pieces, static fn( string $p ): bool => '' !== $p ) ) );
+				sort( $pieces );
+				$query_vars[ $key ] = implode( ',', $pieces );
+			} elseif ( str_starts_with( $key, 'query_type_' ) ) {
+				$query_vars[ $key ] = strtolower( trim( $value ) );
+			} elseif ( 'min_price' === $key || 'max_price' === $key ) {
+				$query_vars[ $key ] = trim( $value );
+			}
+		}
+
+		return $query_vars;
+	}
+
 	/**
 	 * Get cached filter data.
 	 *
@@ -547,7 +592,7 @@ class FilterData {
 	 * @return string Comma-separated list of product IDs.
 	 */
 	private function get_cached_product_ids( array $query_vars ) {
-		$cache_key = WC_Cache_Helper::get_cache_prefix( CacheController::CACHE_GROUP ) . md5( wp_json_encode( $query_vars ) );
+		$cache_key = WC_Cache_Helper::get_cache_prefix( CacheController::CACHE_GROUP ) . md5( wp_json_encode( $this->normalize_query_vars( $query_vars ) ) );
 		$cache     = wp_cache_get( $cache_key );

 		if ( $cache ) {
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductFilters/FilterDataNormalisationTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductFilters/FilterDataNormalisationTest.php
new file mode 100644
index 00000000000..7c4e28927e5
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductFilters/FilterDataNormalisationTest.php
@@ -0,0 +1,221 @@
+<?php
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\ProductFilters;
+
+use Automattic\WooCommerce\Internal\ProductFilters\FilterData;
+use Automattic\WooCommerce\Internal\ProductFilters\Interfaces\QueryClausesGenerator;
+use Automattic\WooCommerce\Internal\ProductFilters\TaxonomyHierarchyData;
+
+/**
+ * Unit tests for FilterData::normalize_query_vars().
+ *
+ * @covers \Automattic\WooCommerce\Internal\ProductFilters\FilterData
+ */
+class FilterDataNormalisationTest extends \WC_Unit_Test_Case {
+
+	/**
+	 * The private method under test, exposed via reflection.
+	 *
+	 * @var \ReflectionMethod
+	 */
+	private $normalize;
+
+	/**
+	 * A FilterData instance to invoke the method on.
+	 *
+	 * @var FilterData
+	 */
+	private $sut;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$query_clauses           = $this->createMock( QueryClausesGenerator::class );
+		$taxonomy_hierarchy_data = $this->createMock( TaxonomyHierarchyData::class );
+
+		$this->sut = new FilterData( $query_clauses, $taxonomy_hierarchy_data );
+
+		$reflection      = new \ReflectionClass( FilterData::class );
+		$this->normalize = $reflection->getMethod( 'normalize_query_vars' );
+		$this->normalize->setAccessible( true );
+	}
+
+	/**
+	 * Invoke normalize_query_vars() with the given array.
+	 *
+	 * @param array $query_vars Input query vars.
+	 * @return array Normalised copy.
+	 */
+	private function normalize( array $query_vars ): array {
+		return $this->normalize->invoke( $this->sut, $query_vars );
+	}
+
+	/**
+	 * @testdox Keys are sorted alphabetically regardless of insertion order.
+	 */
+	public function test_ksort_normalises_key_order(): void {
+		$a = $this->normalize(
+			array(
+				'z_key' => 'val',
+				'a_key' => 'val',
+			)
+		);
+		$b = $this->normalize(
+			array(
+				'a_key' => 'val',
+				'z_key' => 'val',
+			)
+		);
+
+		$this->assertSame( $a, $b );
+		$this->assertSame( array_keys( $a ), array( 'a_key', 'z_key' ) );
+	}
+
+	/**
+	 * @testdox filter_ values: comma items are sorted, trimmed and lowercased.
+	 */
+	public function test_filter_values_are_normalised(): void {
+		$result = $this->normalize( array( 'filter_color' => ' Blue , Red , green ' ) );
+
+		$this->assertSame( 'blue,green,red', $result['filter_color'] );
+	}
+
+	/**
+	 * @testdox Equivalent filter_ values in different orders produce the same output.
+	 */
+	public function test_filter_values_different_order_produces_same_result(): void {
+		$a = $this->normalize( array( 'filter_color' => 'red,blue' ) );
+		$b = $this->normalize( array( 'filter_color' => 'blue,red' ) );
+
+		$this->assertSame( $a['filter_color'], $b['filter_color'] );
+	}
+
+	/**
+	 * @testdox Built-in taxonomy short-name params (categories, tags, brands) are normalised as sets.
+	 */
+	public function test_taxonomy_set_params_are_normalised(): void {
+		$a = $this->normalize( array( 'categories' => 'shirts,hats' ) );
+		$b = $this->normalize( array( 'categories' => 'hats,shirts' ) );
+
+		$this->assertSame( $a['categories'], $b['categories'] );
+		$this->assertSame( 'hats,shirts', $a['categories'] );
+
+		$a = $this->normalize( array( 'tags' => 'sale,new' ) );
+		$b = $this->normalize( array( 'tags' => 'new,sale' ) );
+		$this->assertSame( $a['tags'], $b['tags'] );
+
+		$a = $this->normalize( array( 'brands' => 'nike,adidas' ) );
+		$b = $this->normalize( array( 'brands' => 'adidas,nike' ) );
+		$this->assertSame( $a['brands'], $b['brands'] );
+	}
+
+	/**
+	 * @testdox rating_filter is treated the same as filter_ keys.
+	 */
+	public function test_rating_filter_is_normalised(): void {
+		$a = $this->normalize( array( 'rating_filter' => '5,3' ) );
+		$b = $this->normalize( array( 'rating_filter' => '3,5' ) );
+
+		$this->assertSame( $a['rating_filter'], $b['rating_filter'] );
+		$this->assertSame( '3,5', $a['rating_filter'] );
+	}
+
+	/**
+	 * @testdox query_type_ values are trimmed and lowercased.
+	 */
+	public function test_query_type_values_are_normalised(): void {
+		$result = $this->normalize( array( 'query_type_color' => '  OR  ' ) );
+
+		$this->assertSame( 'or', $result['query_type_color'] );
+	}
+
+	/**
+	 * @testdox min_price is trimmed.
+	 */
+	public function test_min_price_is_trimmed(): void {
+		$result = $this->normalize( array( 'min_price' => ' 10 ' ) );
+
+		$this->assertSame( '10', $result['min_price'] );
+	}
+
+	/**
+	 * @testdox max_price is trimmed.
+	 */
+	public function test_max_price_is_trimmed(): void {
+		$result = $this->normalize( array( 'max_price' => ' 99 ' ) );
+
+		$this->assertSame( '99', $result['max_price'] );
+	}
+
+	/**
+	 * @testdox Non-filter keys are left unchanged.
+	 */
+	public function test_unrelated_keys_are_unchanged(): void {
+		$result = $this->normalize(
+			array(
+				'post_type'      => 'product',
+				'posts_per_page' => -1,
+			)
+		);
+
+		$this->assertSame( 'product', $result['post_type'] );
+		$this->assertSame( -1, $result['posts_per_page'] );
+	}
+
+	/**
+	 * @testdox Combined normalisation: key order + filter value order produce the same hash.
+	 */
+	public function test_combined_normalisation_produces_same_hash(): void {
+		$a = $this->normalize(
+			array(
+				'filter_color'     => 'red,blue',
+				'min_price'        => ' 10 ',
+				'query_type_color' => ' OR ',
+				'post_type'        => 'product',
+			)
+		);
+
+		$b = $this->normalize(
+			array(
+				'post_type'        => 'product',
+				'query_type_color' => 'or',
+				'min_price'        => '10',
+				'filter_color'     => 'blue,red',
+			)
+		);
+
+		$this->assertSame( $a, $b );
+	}
+
+	/**
+	 * @testdox Non-string values are not modified.
+	 */
+	public function test_non_string_values_are_not_modified(): void {
+		$result = $this->normalize( array( 'filter_color' => array( 'red', 'blue' ) ) );
+
+		$this->assertSame( array( 'red', 'blue' ), $result['filter_color'] );
+	}
+
+	/**
+	 * @testdox Empty tokens from malformed comma lists are removed.
+	 */
+	public function test_empty_tokens_are_removed(): void {
+		$result = $this->normalize( array( 'filter_color' => 'red,,blue,' ) );
+
+		$this->assertSame( 'blue,red', $result['filter_color'] );
+	}
+
+	/**
+	 * @testdox Duplicate tokens are deduplicated.
+	 */
+	public function test_duplicate_tokens_are_deduplicated(): void {
+		$a = $this->normalize( array( 'filter_color' => 'red,blue,red' ) );
+		$b = $this->normalize( array( 'filter_color' => 'blue,red' ) );
+
+		$this->assertSame( $a['filter_color'], $b['filter_color'] );
+	}
+}