Commit a7b729880c for woocommerce

commit a7b729880ca75d98423873b10a7fa2d26adf919c
Author: Luis Herranz <luisherranz@gmail.com>
Date:   Fri Feb 20 13:10:00 2026 +0100

    Support non-optimistic item count property (#63378)

    * Support non-optimistic item count property

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

    * Fix lint

    * Fix e2e tests

    * Update plugins/woocommerce/src/Blocks/Utils/BlocksSharedState.php

    Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

    * Update plugins/woocommerce/tests/php/src/Blocks/Utils/BlocksSharedStateTest.php

    Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

    ---------

    Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
    Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/63378-minicart-support-non-optimistic-items-count b/plugins/woocommerce/changelog/63378-minicart-support-non-optimistic-items-count
new file mode 100644
index 0000000000..3ee4365568
--- /dev/null
+++ b/plugins/woocommerce/changelog/63378-minicart-support-non-optimistic-items-count
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Mini-Cart: use server-computed item count when third-party filters modify `woocommerce_cart_contents_count`.
\ No newline at end of file
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/cart.ts b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/cart.ts
index 390eb8f496..891f888c2a 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/cart.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/cart.ts
@@ -35,6 +35,7 @@ export type WooCommerceConfig = {
 	};
 	placeholderImgSrc?: string;
 	currency?: Currency;
+	nonOptimisticProperties?: string[];
 };

 export type SelectedAttributes = Omit< CartVariationItem, 'raw_attribute' >;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/iapi-frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/iapi-frontend.ts
index a6ac7069e0..2775ee478b 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/iapi-frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/iapi-frontend.ts
@@ -31,9 +31,11 @@ import { translateJQueryEventToNative } from '../../base/stores/woocommerce/lega
 const universalLock =
 	'I acknowledge that using a private store means my plugin will inevitably break on the next store release.';

-const { currency, placeholderImgSrc } = getConfig(
-	'woocommerce'
-) as WooCommerceConfig;
+const {
+	currency,
+	placeholderImgSrc,
+	nonOptimisticProperties = [],
+} = getConfig( 'woocommerce' ) as WooCommerceConfig;
 const {
 	onCartClickBehaviour,
 	checkoutUrl,
@@ -171,6 +173,9 @@ store< MiniCart >(
 	{
 		state: {
 			get totalItemsInCart() {
+				if ( nonOptimisticProperties.includes( 'cart.items_count' ) ) {
+					return woocommerceState.cart.items_count as number;
+				}
 				return woocommerceState.cart.items.reduce< number >(
 					( total, { quantity } ) => total + quantity,
 					0
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/plugins/cart-contents-count-filter.php b/plugins/woocommerce/client/blocks/tests/e2e/plugins/cart-contents-count-filter.php
new file mode 100644
index 0000000000..36940e5287
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/tests/e2e/plugins/cart-contents-count-filter.php
@@ -0,0 +1,18 @@
+<?php
+/**
+ * Plugin Name: WooCommerce Blocks Test Cart Contents Count Filter
+ * Description: Overrides the cart contents count to always return 999 for e2e testing.
+ * Plugin URI: https://github.com/woocommerce/woocommerce
+ * Author: WooCommerce
+ *
+ * @package woocommerce-blocks-test-cart-contents-count-filter
+ */
+
+declare( strict_types = 1 );
+
+add_filter(
+	'woocommerce_cart_contents_count',
+	function () {
+		return 999;
+	}
+);
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/tests/mini-cart/mini-cart.block_theme.spec.ts b/plugins/woocommerce/client/blocks/tests/e2e/tests/mini-cart/mini-cart.block_theme.spec.ts
index 2df2315d0d..3a5b0f435f 100644
--- a/plugins/woocommerce/client/blocks/tests/e2e/tests/mini-cart/mini-cart.block_theme.spec.ts
+++ b/plugins/woocommerce/client/blocks/tests/e2e/tests/mini-cart/mini-cart.block_theme.spec.ts
@@ -300,4 +300,43 @@ test.describe( `${ blockData.name } Block`, () => {
 		await page.getByRole( 'link', { name: 'Go to checkout' } ).click();
 		await expect( page ).toHaveURL( /\/checkout\/?$/ );
 	} );
+
+	test.describe( 'optimistic updates', () => {
+		// eslint-disable-next-line playwright/no-skipped-test
+		test.skip(
+			! config.features[ 'experimental-iapi-mini-cart' ],
+			'These tests are only relevant for the iAPI mini cart.'
+		);
+
+		test( 'should show the server filtered item count in the mini-cart title', async ( {
+			page,
+			frontendUtils,
+			miniCartUtils,
+			requestUtils,
+		} ) => {
+			await requestUtils.activatePlugin(
+				'woocommerce-blocks-test-cart-contents-count-filter'
+			);
+
+			try {
+				await frontendUtils.goToShop();
+				await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
+				await miniCartUtils.openMiniCart();
+
+				// The filter overrides the count to 999. The mini-cart title should
+				// display this filtered value rather than the actual number of items.
+				const miniCartTitleItemsCounterBlock = page.locator(
+					'[data-block-name="woocommerce/mini-cart-title-items-counter-block"]'
+				);
+				await expect( miniCartTitleItemsCounterBlock ).toBeVisible();
+				await expect( miniCartTitleItemsCounterBlock ).toContainText(
+					'999'
+				);
+			} finally {
+				await requestUtils.deactivatePlugin(
+					'woocommerce-blocks-test-cart-contents-count-filter'
+				);
+			}
+		} );
+	} );
 } );
diff --git a/plugins/woocommerce/src/Blocks/Utils/BlocksSharedState.php b/plugins/woocommerce/src/Blocks/Utils/BlocksSharedState.php
index 8904a863d7..19d831cf6e 100644
--- a/plugins/woocommerce/src/Blocks/Utils/BlocksSharedState.php
+++ b/plugins/woocommerce/src/Blocks/Utils/BlocksSharedState.php
@@ -24,7 +24,7 @@ class BlocksSharedState {
 	private static string $consent_statement = 'I acknowledge that using private APIs means my theme or plugin will inevitably break in the next version of WooCommerce';

 	/**
-	 * The namespace for the config.
+	 * The namespace for interactivity config and state.
 	 *
 	 * @var string
 	 */
@@ -113,8 +113,13 @@ class BlocksSharedState {
 				self::prevent_cache();
 			}

+			wp_interactivity_config(
+				self::$settings_namespace,
+				array( 'nonOptimisticProperties' => self::get_non_optimistic_properties() )
+			);
+
 			wp_interactivity_state(
-				'woocommerce',
+				self::$settings_namespace,
 				array(
 					'cart'     => self::$blocks_shared_cart_state,
 					'nonce'    => wp_create_nonce( 'wc_store_api' ),
@@ -163,6 +168,28 @@ class BlocksSharedState {
 		);
 	}

+	/**
+	 * Get cart properties that cannot use optimistic UI on the frontend.
+	 *
+	 * Detects whether third-party code has registered callbacks on filters that
+	 * modify cart property values. When callbacks are present, the corresponding
+	 * property must use the server-computed value instead of a client-side
+	 * optimistic computation.
+	 *
+	 * `@return` string[] List of cart property paths (dot-delimited) that cannot be optimistic.
+	 *
+	 * @return string[] List of cart property paths (dot-delimited) that cannot be optimistic.
+	 */
+	private static function get_non_optimistic_properties(): array {
+		$properties = array();
+
+		if ( has_filter( 'woocommerce_cart_contents_count' ) ) {
+			$properties[] = 'cart.items_count';
+		}
+
+		return $properties;
+	}
+
 	/**
 	 * Load placeholder image into interactivity config.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Utils/BlocksSharedStateTest.php b/plugins/woocommerce/tests/php/src/Blocks/Utils/BlocksSharedStateTest.php
new file mode 100644
index 0000000000..0e9bef12b0
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/Utils/BlocksSharedStateTest.php
@@ -0,0 +1,104 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\Utils;
+
+use Automattic\WooCommerce\Blocks\Utils\BlocksSharedState;
+
+/**
+ * Tests for the BlocksSharedState class.
+ */
+class BlocksSharedStateTest extends \WC_Unit_Test_Case {
+
+	/**
+	 * The consent statement required by the private API.
+	 *
+	 * @var string
+	 */
+	private string $consent = 'I acknowledge that using private APIs means my theme or plugin will inevitably break in the next version of WooCommerce';
+
+	/**
+	 * Set up each test.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->reset_shared_state();
+	}
+
+	/**
+	 * Tear down each test.
+	 */
+	public function tearDown(): void {
+		remove_all_filters( 'woocommerce_cart_contents_count' );
+		$this->reset_shared_state();
+		parent::tearDown();
+	}
+
+	/**
+	 * Reset the static flags and interactivity config so load_store_config()
+	 * and load_cart_state() can run again with a clean slate.
+	 */
+	private function reset_shared_state(): void {
+		$reflection = new \ReflectionClass( BlocksSharedState::class );
+
+		$prop = $reflection->getProperty( 'core_config_registered' );
+		$prop->setAccessible( true );
+		$prop->setValue( null, false );
+
+		$cart_state = $reflection->getProperty( 'blocks_shared_cart_state' );
+		$cart_state->setAccessible( true );
+		$cart_state->setValue( null, null );
+
+		$interactivity     = wp_interactivity();
+		$interactivity_ref = new \ReflectionClass( $interactivity );
+		$config_data       = $interactivity_ref->getProperty( 'config_data' );
+
+		$config_data->setAccessible( true );
+		$data = $config_data->getValue( $interactivity );
+		unset( $data['woocommerce'] );
+		$config_data->setValue( $interactivity, $data );
+	}
+
+	/**
+	 * @testdox nonOptimisticProperties is empty when no filter is registered.
+	 */
+	public function test_no_filter_returns_empty_non_optimistic_properties(): void {
+		BlocksSharedState::load_cart_state( $this->consent );
+
+		$config = wp_interactivity_config( 'woocommerce' );
+
+		$this->assertArrayHasKey( 'nonOptimisticProperties', $config );
+		$this->assertSame( array(), $config['nonOptimisticProperties'] );
+	}
+
+	/**
+	 * @testdox nonOptimisticProperties contains items_count when a third-party filter is registered.
+	 */
+	public function test_third_party_filter_detected(): void {
+		add_filter( 'woocommerce_cart_contents_count', fn( $count ) => $count + 1 );
+
+		BlocksSharedState::load_cart_state( $this->consent );
+
+		$config = wp_interactivity_config( 'woocommerce' );
+
+		$this->assertArrayHasKey( 'nonOptimisticProperties', $config );
+		$this->assertContains( 'cart.items_count', $config['nonOptimisticProperties'] );
+	}
+
+	/**
+	 * @testdox nonOptimisticProperties is empty when a filter is added and then removed.
+	 */
+	public function test_filter_added_then_removed_returns_empty(): void {
+		$callback = fn( $count ) => $count + 1;
+
+		add_filter( 'woocommerce_cart_contents_count', $callback );
+		remove_filter( 'woocommerce_cart_contents_count', $callback );
+
+		BlocksSharedState::load_cart_state( $this->consent );
+
+		$config = wp_interactivity_config( 'woocommerce' );
+
+		$this->assertArrayHasKey( 'nonOptimisticProperties', $config );
+		$this->assertSame( array(), $config['nonOptimisticProperties'] );
+	}
+}