Commit acb2050c862 for woocommerce

commit acb2050c862c301371cf46216f264f9c49f6ffcf
Author: Chris Huber <chubes@extrachill.com>
Date:   Mon Jun 8 08:47:53 2026 -0400

    Limit layered nav count cache growth (#65552)

    * Limit layered nav count cache growth

    * Address layered nav cache review cleanup

    * Add changefile(s) from automation for the following project(s): woocommerce

    ---------

    Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/65552-fix-layered-nav-count-cache-bound b/plugins/woocommerce/changelog/65552-fix-layered-nav-count-cache-bound
new file mode 100644
index 00000000000..af32d32eccf
--- /dev/null
+++ b/plugins/woocommerce/changelog/65552-fix-layered-nav-count-cache-bound
@@ -0,0 +1,4 @@
+Significance: patch
+Type: performance
+
+Limit layered navigation count cache growth for product attributes and brands.
\ No newline at end of file
diff --git a/plugins/woocommerce/includes/widgets/class-wc-widget-brand-nav.php b/plugins/woocommerce/includes/widgets/class-wc-widget-brand-nav.php
index 5ba848f1877..a42a3696ffd 100644
--- a/plugins/woocommerce/includes/widgets/class-wc-widget-brand-nav.php
+++ b/plugins/woocommerce/includes/widgets/class-wc-widget-brand-nav.php
@@ -1,6 +1,8 @@
 <?php

-declare( strict_types = 1);
+declare( strict_types = 1 );
+
+use Automattic\WooCommerce\Internal\ProductAttributesLookup\Filterer;

 /**
  * Layered Navigation Widget for brands WC 2.6 version
@@ -542,6 +544,7 @@ class WC_Widget_Brand_Nav extends WC_Widget {
 			$counts                       = array_map( 'absint', wp_list_pluck( $results, 'term_count', 'term_count_id' ) );
 			$cached_counts[ $query_hash ] = $counts;
 			if ( true === $cache ) {
+				$cached_counts = Filterer::limit_layered_nav_count_cache_entries( $cached_counts, $query_hash );
 				set_transient( 'wc_layered_nav_counts_' . sanitize_title( $taxonomy ), $cached_counts, HOUR_IN_SECONDS );
 			}
 		}
diff --git a/plugins/woocommerce/src/Internal/ProductAttributesLookup/Filterer.php b/plugins/woocommerce/src/Internal/ProductAttributesLookup/Filterer.php
index c5c404a2015..09e80bd6cab 100644
--- a/plugins/woocommerce/src/Internal/ProductAttributesLookup/Filterer.php
+++ b/plugins/woocommerce/src/Internal/ProductAttributesLookup/Filterer.php
@@ -211,12 +211,52 @@ class Filterer {
 			$counts                       = array_map( 'absint', wp_list_pluck( $results, 'term_count', 'term_count_id' ) );
 			$cached_counts[ $query_hash ] = $counts;
 			if ( true === $cache ) {
+				$cached_counts = self::limit_layered_nav_count_cache_entries( $cached_counts, $query_hash );
 				set_transient( 'wc_layered_nav_counts_' . sanitize_title( $taxonomy ), $cached_counts, DAY_IN_SECONDS );
 			}
 		}
 		return array_map( 'absint', (array) $cached_counts[ $query_hash ] );
 	}

+	/**
+	 * Limit the number of query result entries stored in a layered nav count transient.
+	 *
+	 * The layered nav count cache stores many query hashes inside a single transient per taxonomy.
+	 * Without a cap, bot or faceted-search enumeration can grow that single option without bound.
+	 *
+	 * @since 10.9.0
+	 *
+	 * @param array  $cached_counts Cached count entries keyed by query hash.
+	 * @param string $current_hash  Hash for the query that was just added or read.
+	 * @return array Bounded cached count entries.
+	 */
+	public static function limit_layered_nav_count_cache_entries( array $cached_counts, string $current_hash ): array {
+		/**
+		 * Maximum number of query result entries to store in each layered nav count transient.
+		 *
+		 * Set to 0 to disable the cap.
+		 *
+		 * @hook woocommerce_layered_nav_count_cache_max_entries
+		 * @since 10.9.0
+		 *
+		 * @param int $max_entries Maximum number of cached query result entries. Default 1000.
+		 * @return int
+		 */
+		$max_entries = (int) apply_filters( 'woocommerce_layered_nav_count_cache_max_entries', 1000 );
+
+		if ( $max_entries < 1 || count( $cached_counts ) <= $max_entries ) {
+			return $cached_counts;
+		}
+
+		if ( isset( $cached_counts[ $current_hash ] ) ) {
+			$current_counts = $cached_counts[ $current_hash ];
+			unset( $cached_counts[ $current_hash ] );
+			$cached_counts[ $current_hash ] = $current_counts;
+		}
+
+		return array_slice( $cached_counts, -$max_entries, null, true );
+	}
+
 	/**
 	 * Get the query for counting products by terms using the product attributes lookup table.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductAttributesLookup/FiltererTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductAttributesLookup/FiltererTest.php
index 8516a910082..e113eb87835 100644
--- a/plugins/woocommerce/tests/php/src/Internal/ProductAttributesLookup/FiltererTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductAttributesLookup/FiltererTest.php
@@ -1,10 +1,13 @@
 <?php

+declare( strict_types = 1 );
+
 namespace Automattic\WooCommerce\Tests\Internal\ProductAttributesLookup;

 use Automattic\WooCommerce\Enums\ProductTaxStatus;
 use Automattic\WooCommerce\Enums\ProductType;
 use Automattic\WooCommerce\Internal\AttributesHelper;
+use Automattic\WooCommerce\Internal\ProductAttributesLookup\Filterer;
 use Automattic\WooCommerce\RestApi\UnitTests\Helpers\ProductHelper;
 use Automattic\WooCommerce\Utilities\ArrayUtil;
 use Automattic\WooCommerce\Enums\ProductStockStatus;
@@ -55,6 +58,8 @@ class FiltererTest extends \WC_Unit_Test_Case {
 	public function tearDown(): void {
 		global $wpdb;

+		remove_all_filters( 'woocommerce_layered_nav_count_cache_max_entries' );
+
 		parent::tearDown();

 		// Unregister all product attributes.
@@ -97,6 +102,47 @@ class FiltererTest extends \WC_Unit_Test_Case {
 		\WC_Query::reset_chosen_attributes();
 	}

+	/**
+	 * @testdox Layered nav count cache entries are capped per taxonomy transient.
+	 */
+	public function test_layered_nav_count_cache_entries_are_capped() {
+		add_filter( 'woocommerce_layered_nav_count_cache_max_entries', fn() => 2 );
+
+		$cached_counts = array(
+			'first'  => array( 1 => 1 ),
+			'second' => array( 2 => 2 ),
+			'third'  => array( 3 => 3 ),
+		);
+
+		$limited_counts = Filterer::limit_layered_nav_count_cache_entries( $cached_counts, 'third' );
+
+		$this->assertSame(
+			array(
+				'second' => array( 2 => 2 ),
+				'third'  => array( 3 => 3 ),
+			),
+			$limited_counts
+		);
+	}
+
+	/**
+	 * @testdox The layered nav count cache cap can be disabled.
+	 */
+	public function test_layered_nav_count_cache_cap_can_be_disabled() {
+		add_filter( 'woocommerce_layered_nav_count_cache_max_entries', '__return_zero' );
+
+		$cached_counts = array(
+			'first'  => array( 1 => 1 ),
+			'second' => array( 2 => 2 ),
+			'third'  => array( 3 => 3 ),
+		);
+
+		$this->assertSame(
+			$cached_counts,
+			Filterer::limit_layered_nav_count_cache_entries( $cached_counts, 'third' )
+		);
+	}
+
 	/**
 	 * Save a product and delete any lookup table data that may have been automatically inserted
 	 * (for the purposes of unit testing we want to insert this data manually)