Commit 8dd6615403c for woocommerce

commit 8dd6615403c8196351a3f354f45fafe5e42a084d
Author: Vladimir Reznichenko <kalessil@gmail.com>
Date:   Wed Nov 11 08:29:34 2026 +0100

    [Performance] Product reviews, optimize actualizing product average rating to avoid unnecessary products cache invalidation cascades (#70322)

    Introduces \WC_Comments::maybe_update_product_average_rating to skip updates if possible, and skips one unnecessary SQL query when calculating the rating average.

diff --git a/plugins/woocommerce/changelog/performance-70269-product-cache-invalidation-on-clear-comments-transients b/plugins/woocommerce/changelog/performance-70269-product-cache-invalidation-on-clear-comments-transients
new file mode 100644
index 00000000000..e64d383c4ff
--- /dev/null
+++ b/plugins/woocommerce/changelog/performance-70269-product-cache-invalidation-on-clear-comments-transients
@@ -0,0 +1,4 @@
+Significance: minor
+Type: performance
+
+Optimized actualizing product average rating to avoid unnecessary products cache invalidation cascades.
diff --git a/plugins/woocommerce/includes/class-wc-comments.php b/plugins/woocommerce/includes/class-wc-comments.php
index f2f2a6eac0b..ea7b3d44620 100644
--- a/plugins/woocommerce/includes/class-wc-comments.php
+++ b/plugins/woocommerce/includes/class-wc-comments.php
@@ -41,8 +41,7 @@ class WC_Comments {
 		add_action( 'comment_post', array( __CLASS__, 'add_comment_rating' ), 1 );
 		add_action( 'comment_moderation_recipients', array( __CLASS__, 'comment_moderation_recipients' ), 10, 2 );

-		// Clear transients.
-		add_action( 'wp_update_comment_count', array( __CLASS__, 'clear_transients' ) );
+		add_action( 'wp_update_comment_count', array( __CLASS__, 'maybe_update_product_average_rating' ) );

 		// Secure order notes.
 		add_filter( 'comments_clauses', array( __CLASS__, 'exclude_order_comments' ) );
@@ -258,7 +257,7 @@ class WC_Comments {

 			$post_id = isset( $_POST['comment_post_ID'] ) ? absint( $_POST['comment_post_ID'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Anonymous product reviews are submitted through the core comment form, which carries no WooCommerce nonce; the post ID is read through absint().
 			if ( $post_id ) {
-				self::clear_transients( $post_id );
+				self::maybe_update_product_average_rating( $post_id );
 			}
 		}
 	}
@@ -283,9 +282,13 @@ class WC_Comments {
 	/**
 	 * Ensure product average rating and review count is kept up to date.
 	 *
+	 * @deprecated 11.4.0
+	 *
 	 * @param int $post_id Post ID.
+	 * @return void
 	 */
 	public static function clear_transients( $post_id ) {
+		wc_deprecated_function( __METHOD__, '11.4.0' );
 		$post_id = absint( $post_id );
 		if ( 0 === $post_id || 'product' !== get_post_type( $post_id ) ) {
 			return;
@@ -300,6 +303,36 @@ class WC_Comments {
 		}
 	}

+	/**
+	 * Ensure product average rating and review count is kept up to date (the logic is subject of change; no BC guarantees).
+	 *
+	 * @internal
+	 * @since 11.4.0
+	 *
+	 * @param int $post_id Post ID.
+	 * @return void
+	 */
+	public static function maybe_update_product_average_rating( $post_id ) {
+		$product_id = absint( $post_id );
+		$product    = ( $product_id && 'product' === get_post_type( $product_id ) ) ? wc_get_product( $product_id ) : false;
+		// Performance note: save triggers products cache invalidation cascade, hence perform it only if necessary.
+		if ( $product instanceof WC_Product ) {
+			$reviews_count = (int) self::get_review_count_for_product( $product );
+			$ratings_stats = self::get_rating_counts_for_product( $product );
+			if ( $reviews_count !== $product->get_review_count( 'edit' ) || $ratings_stats != $product->get_rating_counts( 'edit' ) ) { // phpcs:ignore Universal.Operators.StrictComparisons -- performance optimization
+				$ratings_count   = array_sum( $ratings_stats );
+				$weighted_sum    = array_sum( array_map( static fn( $rating, $count ) => $rating * $count, array_keys( $ratings_stats ), $ratings_stats ) );
+				$ratings_average = $ratings_count ? number_format( $weighted_sum / $ratings_count, 2, '.', '' ) : '0.00';
+
+				$product->set_review_count( $reviews_count );
+				$product->set_rating_counts( $ratings_stats );
+				$product->set_average_rating( $ratings_average ); // @phpstan-ignore argument.type (false-positive)
+
+				$product->save();
+			}
+		}
+	}
+
 	/**
 	 * Callback for 'wp_insert_comment' to delete the comment count cache if the comment is included in the count.
 	 *
@@ -609,9 +642,10 @@ class WC_Comments {
 	 * @return int
 	 */
 	public static function get_review_count_for_product( &$product ) {
-		$counts = self::get_review_counts_for_product_ids( array( $product->get_id() ) );
+		$product_id = $product->get_id();
+		$counts     = self::get_review_counts_for_product_ids( array( $product_id ) );

-		return $counts[ $product->get_id() ];
+		return $counts[ $product_id ];
 	}

 	/**
@@ -624,24 +658,23 @@ class WC_Comments {
 	public static function get_rating_counts_for_product( &$product ) {
 		global $wpdb;

-		$counts     = array();
-		$raw_counts = $wpdb->get_results(
+		$rows = $wpdb->get_results(
 			$wpdb->prepare(
-				"
-			SELECT meta_value, COUNT( * ) as meta_value_count FROM $wpdb->commentmeta
-			LEFT JOIN $wpdb->comments ON $wpdb->commentmeta.comment_id = $wpdb->comments.comment_ID
-			WHERE meta_key = 'rating'
-			AND comment_post_ID = %d
-			AND comment_approved = '1'
-			AND meta_value > 0
-			GROUP BY meta_value
-				",
+				"SELECT metas.meta_value AS rating, COUNT(*) AS count
+				FROM {$wpdb->commentmeta} metas
+				INNER JOIN {$wpdb->comments} comments ON metas.comment_id = comments.comment_ID
+				WHERE metas.meta_key = 'rating'
+					AND comments.comment_post_ID = %d
+					AND comments.comment_approved = '1'
+					AND metas.meta_value > 0
+				GROUP BY rating",
 				$product->get_id()
 			)
 		);

-		foreach ( $raw_counts as $count ) {
-			$counts[ $count->meta_value ] = absint( $count->meta_value_count );
+		$counts = array();
+		foreach ( $rows as $row ) {
+			$counts[ (int) $row->rating ] = (int) $row->count;
 		}

 		return $counts;
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-product-reviews-v1-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-product-reviews-v1-controller.php
index ff07b391db8..7060e2169ae 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-product-reviews-v1-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-product-reviews-v1-controller.php
@@ -333,7 +333,7 @@ class WC_REST_Product_Reviews_V1_Controller extends WC_REST_Controller {
 		 * a rest_pre_insert_product_review callback can send the review to a different one.
 		 */
 		if ( ! empty( $request['rating'] ) && $product_review instanceof WP_Comment && '1' === $product_review->comment_approved ) {
-			WC_Comments::clear_transients( (int) $product_review->comment_post_ID );
+			WC_Comments::maybe_update_product_average_rating( (int) $product_review->comment_post_ID );
 		}

 		$this->update_additional_fields_for_object( $product_review, $request );
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-reviews-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-reviews-controller.php
index 27a8a2fa426..c3f491cc2eb 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-reviews-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-reviews-controller.php
@@ -487,7 +487,7 @@ class WC_REST_Product_Reviews_Controller extends WC_REST_Controller {
 		 * review can change the aggregates.
 		 */
 		if ( ! empty( $request['rating'] ) && $review instanceof WP_Comment && '1' === $review->comment_approved ) {
-			WC_Comments::clear_transients( (int) $review->comment_post_ID );
+			WC_Comments::maybe_update_product_average_rating( (int) $review->comment_post_ID );
 		}

 		/**
diff --git a/plugins/woocommerce/includes/wc-update-functions.php b/plugins/woocommerce/includes/wc-update-functions.php
index 45c0d553b53..22df81362fb 100644
--- a/plugins/woocommerce/includes/wc-update-functions.php
+++ b/plugins/woocommerce/includes/wc-update-functions.php
@@ -2374,7 +2374,7 @@ function wc_update_500_fix_product_review_count() {
 		$current_review_count = intval( $product_data['meta_value'] );

 		if ( intval( $actual_review_counts[ $product_id ] ) !== $current_review_count ) {
-			WC_Comments::clear_transients( $product_id );
+			WC_Comments::maybe_update_product_average_rating( $product_id );
 		}
 	}

diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 9a1a12d6f0f..97652e2e1e3 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -10446,12 +10446,6 @@ parameters:
 			count: 1
 			path: includes/class-wc-comments.php

-		-
-			message: '#^Method WC_Comments\:\:clear_transients\(\) has no return type specified\.$#'
-			identifier: missingType.return
-			count: 1
-			path: includes/class-wc-comments.php
-
 		-
 			message: '#^Method WC_Comments\:\:delete_comments_count_cache\(\) has no return type specified\.$#'
 			identifier: missingType.return
diff --git a/plugins/woocommerce/src/Internal/OrderReviews/SubmissionHandler.php b/plugins/woocommerce/src/Internal/OrderReviews/SubmissionHandler.php
index 66f478ef45d..2d45ae99904 100644
--- a/plugins/woocommerce/src/Internal/OrderReviews/SubmissionHandler.php
+++ b/plugins/woocommerce/src/Internal/OrderReviews/SubmissionHandler.php
@@ -361,7 +361,7 @@ class SubmissionHandler {

 			// Core recounts an approved comment before writing its meta, so recount once the rating is stored.
 			if ( 'approved' === $comment_status ) {
-				WC_Comments::clear_transients( $review_post_id );
+				\WC_Comments::maybe_update_product_average_rating( $review_post_id );
 			}

 			$result['comment_id'] = (int) $comment_id;
diff --git a/plugins/woocommerce/tests/e2e/tests/product/product-reviews.spec.ts b/plugins/woocommerce/tests/e2e/tests/product/product-reviews.spec.ts
index 0438e62ed2b..018947b16c7 100644
--- a/plugins/woocommerce/tests/e2e/tests/product/product-reviews.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/product/product-reviews.spec.ts
@@ -361,7 +361,7 @@ test.describe( 'Product Reviews', () => {
 				page.locator( '.comment_container' ).first()
 			).toContainText( updatedReview );
 			await expect(
-				page.getByLabel( `${ updatedRating } out of 5` )
+				page.getByLabel( `${ updatedRating } out of 5` ).first()
 			).toBeVisible();
 		} );
 	} );
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-comments-test.php b/plugins/woocommerce/tests/php/includes/class-wc-comments-test.php
index 235dd7092e6..1dd54ccfc84 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-comments-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-comments-test.php
@@ -93,6 +93,8 @@ class WC_Comments_Tests extends \WC_Unit_Test_Case {
 	 * Test clear_transients with valid product ID.
 	 */
 	public function test_clear_transients_with_valid_product() {
+		$this->setExpectedDeprecated( 'WC_Comments::clear_transients' );
+
 		$product    = WC_Helper_Product::create_simple_product();
 		$product_id = $product->get_id();

@@ -111,14 +113,48 @@ class WC_Comments_Tests extends \WC_Unit_Test_Case {
 		$this->assertEquals( 1, $updated_product->get_review_count() );
 	}

+	/**
+	 * @testdox maybe_update_product_average_rating updates review count and rating data when a new review is added.
+	 */
+	public function test_maybe_update_product_average_rating_with_valid_product(): void {
+		$product    = WC_Helper_Product::create_simple_product();
+		$product_id = $product->get_id();
+		$review_id  = ProductHelper::create_product_review( $product_id, 'Great product!' );
+
+		update_comment_meta( $review_id, 'rating', 5 );
+		WC_Comments::maybe_update_product_average_rating( $product_id );
+
+		$this->assertSame( 1, wc_get_product( $product_id )->get_review_count() );
+
+		$product->delete( true );
+	}
+
+	/**
+	 * @testdox maybe_update_product_average_rating skips product save when review count has not changed.
+	 */
+	public function test_maybe_update_product_average_rating_skips_save_when_review_count_unchanged(): void {
+		$product    = WC_Helper_Product::create_simple_product();
+		$product_id = $product->get_id();
+		$review_id  = ProductHelper::create_product_review( $product_id, 'Great product!' );
+
+		update_comment_meta( $review_id, 'rating', 5 );
+		WC_Comments::maybe_update_product_average_rating( $product_id );
+
+		$save_count = did_action( 'woocommerce_update_product' );
+		WC_Comments::maybe_update_product_average_rating( $product_id );
+		$this->assertSame( $save_count, did_action( 'woocommerce_update_product' ), 'Product save should not fire when review count is unchanged.' );
+
+		$product->delete( true );
+	}
+
 	/**
 	 * Test clear_transients with invalid product ID.
 	 */
 	public function test_clear_transients_with_invalid_product_id() {
+		$this->setExpectedDeprecated( 'WC_Comments::clear_transients' );
+
 		$invalid_product_id = 99999;

-		// This should not cause any errors or exceptions.
-		$this->expectNotToPerformAssertions();
 		WC_Comments::clear_transients( $invalid_product_id );
 	}

@@ -126,6 +162,8 @@ class WC_Comments_Tests extends \WC_Unit_Test_Case {
 	 * Test clear_transients with non-product post ID.
 	 */
 	public function test_clear_transients_with_non_product_post() {
+		$this->setExpectedDeprecated( 'WC_Comments::clear_transients' );
+
 		// Create a regular post (not a product).
 		$post_id = wp_insert_post(
 			array(
@@ -136,8 +174,6 @@ class WC_Comments_Tests extends \WC_Unit_Test_Case {
 			)
 		);

-		// This should not cause any errors or exceptions.
-		$this->expectNotToPerformAssertions();
 		WC_Comments::clear_transients( $post_id );

 		// Clean up.
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductRatingTest.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductRatingTest.php
index c60e1e9f1c0..cdd08a8bd71 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductRatingTest.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductRatingTest.php
@@ -43,7 +43,7 @@ class ProductRatingTest extends WC_Unit_Test_Case {

 			$product->set_reviews_allowed( $product_reviews_allowed );
 			$product->save();
-			\WC_Comments::clear_transients( $product->get_id() );
+			\WC_Comments::maybe_update_product_average_rating( $product->get_id() );
 			clean_post_cache( $product->get_id() );

 			$product = wc_get_product( $product->get_id() );
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Helpers/FixtureData.php b/plugins/woocommerce/tests/php/src/Blocks/Helpers/FixtureData.php
index 662f63fb745..6fdd833b41d 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/Helpers/FixtureData.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/Helpers/FixtureData.php
@@ -417,7 +417,7 @@ class FixtureData {
 				)
 			)
 		);
-		\WC_Comments::clear_transients( $product_id );
+		\WC_Comments::maybe_update_product_average_rating( $product_id );
 	}

 	/**