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'] );
+ }
+}