Commit c4ca65bc5c0 for woocommerce
commit c4ca65bc5c0afe7222dce7bc2eb5f8b0d46a1b73
Author: Luigi Teschio <gigitux@gmail.com>
Date: Thu Aug 20 15:06:02 2026 +0200
Fix term counts after removing out-of-stock visibility (#67669)
* Fix out-of-stock visibility term recounts
* Add changelog entry for visibility term recount fix
* Simplify TermCount tests
* Replace TermCount mocks with regression test
* Expand TermCount class documentation
* fix unit test
* Clarify TermCount service responsibility
* improve logic
* add comment
* add types
* add final
* don't use legacy proxy
* deprecate function
* test add_object_terms_path
diff --git a/plugins/woocommerce/changelog/fix-external-product-category-count b/plugins/woocommerce/changelog/fix-external-product-category-count
new file mode 100644
index 00000000000..a25735be1e3
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-external-product-category-count
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Refresh category, tag, and brand counts after an out-of-stock product visibility term is removed.
diff --git a/plugins/woocommerce/includes/class-wc-post-data.php b/plugins/woocommerce/includes/class-wc-post-data.php
index ac5806c8414..ccff10aecc8 100644
--- a/plugins/woocommerce/includes/class-wc-post-data.php
+++ b/plugins/woocommerce/includes/class-wc-post-data.php
@@ -43,7 +43,6 @@ class WC_Post_Data {
add_action( 'shutdown', array( __CLASS__, 'do_deferred_product_sync' ), 10 );
add_action( 'set_object_terms', array( __CLASS__, 'force_default_term' ), 10, 5 );
add_action( 'set_object_terms', array( __CLASS__, 'delete_product_query_transients' ) );
- add_action( 'set_object_terms', array( __CLASS__, 'recount_terms_for_product_visibility_change' ), 10, 6 );
add_action( 'deleted_term_relationships', array( __CLASS__, 'delete_product_query_transients' ) );
add_action( 'woocommerce_product_set_stock_status', array( __CLASS__, 'delete_product_query_transients' ) );
add_action( 'woocommerce_product_set_visibility', array( __CLASS__, 'delete_product_query_transients' ) );
@@ -687,10 +686,13 @@ class WC_Post_Data {
* @param array $old_tt_ids The old array of term taxonomy IDs.
*
* @since 10.4.0
+ * @deprecated 11.1.0 Product term-count consistency is now handled by the TermCount service.
*
* @return void
*/
public static function recount_terms_for_product_visibility_change( $object_id, $terms, $tt_ids, $taxonomy, $append, $old_tt_ids ) {
+ wc_deprecated_function( __FUNCTION__, '11.1.0' );
+
if ( 'product_visibility' !== $taxonomy ) {
return;
}
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index 7d59f7b405d..a6d71af5628 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -10,6 +10,7 @@ defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Internal\AddressProvider\AddressProviderController;
use Automattic\WooCommerce\Internal\AssignDefaultCategory;
+use Automattic\WooCommerce\Internal\TermCount;
use Automattic\WooCommerce\Internal\BatchProcessing\BatchProcessingController;
use Automattic\WooCommerce\Internal\Caches\ProductCacheController;
use Automattic\WooCommerce\Internal\ComingSoon\ComingSoonAdminBarBadge;
@@ -381,6 +382,7 @@ final class WooCommerce {
$container->get( ProductDownloadDirectories::class );
$container->get( DownloadPermissionsAdjuster::class );
$container->get( AssignDefaultCategory::class );
+ $container->get( TermCount::class );
$container->get( DataRegenerator::class );
$container->get( LookupDataStore::class );
$container->get( MatchImageBySKU::class );
diff --git a/plugins/woocommerce/src/Internal/TermCount.php b/plugins/woocommerce/src/Internal/TermCount.php
new file mode 100644
index 00000000000..0df04c57c1f
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/TermCount.php
@@ -0,0 +1,143 @@
+<?php
+/**
+ * TermCount class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal;
+
+use Automattic\WooCommerce\Enums\ProductStockStatus;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Coordinates WooCommerce-specific product term-count consistency.
+ *
+ * Product term counts can become stale when product visibility, stock state,
+ * inventory settings, product type, or taxonomy hierarchy changes. This service is
+ * the central integration point for incrementally consolidating the immediate and
+ * deferred recount triggers for those mutations. Existing WooCommerce term-count
+ * functions remain responsible for calculating and persisting category, tag, brand,
+ * and ancestor counts.
+ *
+ * @since 11.1.0
+ *
+ * @internal
+ */
+class TermCount {
+ /**
+ * Class initialization, executed when the class is resolved by the container.
+ *
+ * @since 11.1.0
+ *
+ * @internal
+ */
+ final public function init(): void {
+ add_action( 'set_object_terms', array( $this, 'handle_set_object_terms' ), 10, 6 );
+ add_action( 'deleted_term_relationships', array( $this, 'handle_deleted_term_relationships' ), 10, 3 );
+ }
+
+ /**
+ * Recounts product terms after count-affecting visibility relationships are added.
+ *
+ * Removals are recounted by handle_deleted_term_relationships(), which runs before
+ * the set_object_terms hook during wp_set_object_terms().
+ *
+ * @since 11.1.0
+ *
+ * @internal
+ *
+ * @param int $object_id Object ID.
+ * @param array<int|string> $terms An array of object term IDs or slugs.
+ * @param array<int|string> $tt_ids An array of term taxonomy IDs.
+ * @param string $taxonomy Taxonomy slug.
+ * @param bool $append Whether to append new terms to the old terms.
+ * @param array<int|string> $old_tt_ids The old array of term taxonomy IDs.
+ */
+ public function handle_set_object_terms( $object_id, $terms, $tt_ids, $taxonomy, $append, $old_tt_ids ): void {
+ $object_id = absint( $object_id );
+
+ if ( 'product_visibility' !== $taxonomy || 0 === $object_id || ! is_array( $tt_ids ) || ! is_array( $old_tt_ids ) ) {
+ return;
+ }
+
+ $new_tt_ids = $this->normalize_ids( $tt_ids );
+ $old_tt_ids = $this->normalize_ids( $old_tt_ids );
+ $counting_tt_ids = $this->get_count_affecting_visibility_term_taxonomy_ids();
+
+ $removed_tt_ids = $append ? array() : array_diff( $old_tt_ids, $new_tt_ids );
+
+ // When the removed term taxonomy IDs include any count-affecting visibility terms, the recount will be handled by handle_deleted_term_relationships().
+ if ( ! empty( array_intersect( $removed_tt_ids, $counting_tt_ids ) ) ) {
+ return;
+ }
+
+ $added_tt_ids = $append ? $new_tt_ids : array_diff( $new_tt_ids, $old_tt_ids );
+
+ if ( ! empty( array_intersect( $added_tt_ids, $counting_tt_ids ) ) ) {
+ _wc_recount_terms_by_product( $object_id );
+ }
+ }
+
+ /**
+ * Recounts product terms after count-affecting visibility relationships are deleted.
+ *
+ * @since 11.1.0
+ *
+ * @internal
+ *
+ * @param int $object_id Object ID.
+ * @param array<int|string> $tt_ids Deleted term taxonomy IDs.
+ * @param string $taxonomy Taxonomy slug.
+ */
+ public function handle_deleted_term_relationships( $object_id, $tt_ids, $taxonomy ): void {
+ $object_id = absint( $object_id );
+
+ if ( 'product_visibility' !== $taxonomy || 0 === $object_id || ! is_array( $tt_ids ) ) {
+ return;
+ }
+
+ if (
+ ! empty(
+ array_intersect(
+ $this->normalize_ids( $tt_ids ),
+ $this->get_count_affecting_visibility_term_taxonomy_ids()
+ )
+ )
+ ) {
+ _wc_recount_terms_by_product( $object_id );
+ }
+ }
+
+ /**
+ * Gets visibility term taxonomy IDs that affect WooCommerce product counts.
+ *
+ * @return list<int>
+ */
+ private function get_count_affecting_visibility_term_taxonomy_ids(): array {
+ /**
+ * Product visibility term taxonomy IDs.
+ *
+ * @var array $visibility_term_ids
+ */
+ $visibility_term_ids = wc_get_product_visibility_term_ids();
+ $counting_tt_ids = array( $visibility_term_ids['exclude-from-catalog'] ?? 0 );
+
+ if ( 'yes' === get_option( 'woocommerce_hide_out_of_stock_items' ) ) {
+ $counting_tt_ids[] = $visibility_term_ids[ ProductStockStatus::OUT_OF_STOCK ] ?? 0;
+ }
+
+ return $this->normalize_ids( $counting_tt_ids );
+ }
+
+ /**
+ * Normalizes arbitrary values to a list of positive integer IDs.
+ *
+ * @param array<int|string> $ids Values to normalize.
+ * @return list<int>
+ */
+ private function normalize_ids( array $ids ): array {
+ return array_values( array_filter( array_map( 'absint', $ids ) ) );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/TermCountTest.php b/plugins/woocommerce/tests/php/src/Internal/TermCountTest.php
new file mode 100644
index 00000000000..61bed62252c
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/TermCountTest.php
@@ -0,0 +1,148 @@
+<?php
+/**
+ * TermCount tests.
+ *
+ * @package WooCommerce\Tests\Internal
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal;
+
+use Automattic\WooCommerce\Enums\ProductStockStatus;
+use Automattic\WooCommerce\Enums\ProductType;
+use WC_Helper_Product;
+use WC_Product_External;
+use WC_Product_Factory;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for TermCount.
+ */
+final class TermCountTest extends WC_Unit_Test_Case {
+ /**
+ * @testdox Converting an out-of-stock simple product to an external product refreshes its category count.
+ */
+ public function test_converting_out_of_stock_simple_product_to_external_recounts_product_category(): void {
+ $original_setting = get_option( 'woocommerce_hide_out_of_stock_items', false );
+ $category = wp_insert_term( 'TermCount category', 'product_cat' );
+ $product = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'category_ids' => array( $category['term_id'] ),
+ 'stock_status' => ProductStockStatus::OUT_OF_STOCK,
+ )
+ );
+
+ try {
+ update_option( 'woocommerce_hide_out_of_stock_items', 'yes' );
+ wc_recount_all_terms( false );
+
+ $this->assertSame( '0', get_term_meta( $category['term_id'], 'product_count_product_cat', true ) );
+
+ $external_product = new WC_Product_External( $product->get_id() );
+ $external_product->save();
+
+ $this->assertSame( ProductType::EXTERNAL, WC_Product_Factory::get_product_type( $product->get_id() ) );
+ $this->assertTrue( has_term( ProductStockStatus::OUT_OF_STOCK, 'product_visibility', $product->get_id() ) );
+
+ $recount_attempts = $this->count_recount_attempts(
+ static function () use ( $product ): void {
+ wp_remove_object_terms( $product->get_id(), ProductStockStatus::OUT_OF_STOCK, 'product_visibility' );
+ }
+ );
+
+ $this->assertSame( 1, $recount_attempts, 'Direct relationship deletion should recount once.' );
+ $this->assertFalse( has_term( ProductStockStatus::OUT_OF_STOCK, 'product_visibility', $product->get_id() ) );
+ $this->assertSame( '1', get_term_meta( $category['term_id'], 'product_count_product_cat', true ) );
+ } finally {
+ WC_Helper_Product::delete_product( $product->get_id() );
+ wp_delete_term( $category['term_id'], 'product_cat' );
+
+ if ( false === $original_setting ) {
+ delete_option( 'woocommerce_hide_out_of_stock_items' );
+ } else {
+ update_option( 'woocommerce_hide_out_of_stock_items', $original_setting );
+ }
+ }
+ }
+
+ /**
+ * @testdox Product visibility changes made through wp_set_object_terms recount product terms once per operation.
+ */
+ public function test_set_object_terms_recounts_product_terms_once_per_operation(): void {
+ $original_setting = get_option( 'woocommerce_hide_out_of_stock_items', false );
+ $category = wp_insert_term( 'TermCount set terms category', 'product_cat' );
+ $product = WC_Helper_Product::create_simple_product(
+ true,
+ array( 'category_ids' => array( $category['term_id'] ) )
+ );
+
+ try {
+ update_option( 'woocommerce_hide_out_of_stock_items', 'yes' );
+ wc_recount_all_terms( false );
+
+ $recount_attempts = $this->count_recount_attempts(
+ static function () use ( $product ): void {
+ wp_set_object_terms( $product->get_id(), ProductStockStatus::OUT_OF_STOCK, 'product_visibility' );
+ }
+ );
+ $this->assertSame( 1, $recount_attempts, 'Adding a count-affecting relationship should recount once.' );
+
+ $recount_attempts = $this->count_recount_attempts(
+ static function () use ( $product ): void {
+ wp_set_object_terms( $product->get_id(), array(), 'product_visibility' );
+ }
+ );
+ $this->assertSame( 1, $recount_attempts, 'Removing a count-affecting relationship should recount once.' );
+
+ $recount_attempts = $this->count_recount_attempts(
+ static function () use ( $product ): void {
+ wp_add_object_terms( $product->get_id(), ProductStockStatus::OUT_OF_STOCK, 'product_visibility' );
+ }
+ );
+ $this->assertSame( 1, $recount_attempts, 'Appending a count-affecting relationship should recount once.' );
+
+ wp_set_object_terms( $product->get_id(), 'exclude-from-catalog', 'product_visibility' );
+ $recount_attempts = $this->count_recount_attempts(
+ static function () use ( $product ): void {
+ wp_set_object_terms( $product->get_id(), ProductStockStatus::OUT_OF_STOCK, 'product_visibility' );
+ }
+ );
+ $this->assertSame( 1, $recount_attempts, 'Replacing count-affecting relationships should recount once.' );
+ } finally {
+ WC_Helper_Product::delete_product( $product->get_id() );
+ wp_delete_term( $category['term_id'], 'product_cat' );
+
+ if ( false === $original_setting ) {
+ delete_option( 'woocommerce_hide_out_of_stock_items' );
+ } else {
+ update_option( 'woocommerce_hide_out_of_stock_items', $original_setting );
+ }
+ }
+ }
+
+ /**
+ * Counts WooCommerce product term recounts performed by an operation.
+ *
+ * @param callable(): void $operation Operation to run.
+ * @return int
+ */
+ private function count_recount_attempts( callable $operation ): int {
+ $recount_attempts = 0;
+ $track_recounts = static function ( $should_recount ) use ( &$recount_attempts ) {
+ ++$recount_attempts;
+
+ return $should_recount;
+ };
+ add_filter( 'woocommerce_product_recount_terms', $track_recounts );
+
+ try {
+ $operation();
+ } finally {
+ remove_filter( 'woocommerce_product_recount_terms', $track_recounts );
+ }
+
+ return $recount_attempts;
+ }
+}