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;
+	}
+}