Commit d4a09924734 for woocommerce

commit d4a0992473476c0ca8f31d99cb268555e948f6f9
Author: Francesco <frosso@users.noreply.github.com>
Date:   Mon Nov 9 18:20:19 2026 +0100

    fix: keep checkout busy until every cart update finishes (#70176)

diff --git a/plugins/woocommerce/changelog/42148-checkout-calculating-count b/plugins/woocommerce/changelog/42148-checkout-calculating-count
new file mode 100644
index 00000000000..465ac1f6090
--- /dev/null
+++ b/plugins/woocommerce/changelog/42148-checkout-calculating-count
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep the Cart and Checkout block buttons disabled until all cart updates finish, including those from extensions.
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/cart/test/use-store-cart-item-quantity.js b/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/cart/test/use-store-cart-item-quantity.js
index f91d77e9ba4..b5af0ecfc6c 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/cart/test/use-store-cart-item-quantity.js
+++ b/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/cart/test/use-store-cart-item-quantity.js
@@ -58,18 +58,27 @@ describe( 'useStoreCartItemQuantity', () => {
 			.mockReturnValue( { type: 'changeCartItemQuantityAction' } );

 		registry.registerStore( CART_STORE_KEY, {
-			reducer: () => ( {} ),
+			reducer: (
+				state = {
+					delete: isPendingDelete,
+					quantity: isPendingQuantity,
+				},
+				action
+			) =>
+				action.type === 'SET_ITEM_PENDING'
+					? { ...state, ...action.pending }
+					: state,
 			actions: {
 				removeItemFromCart: mockRemoveItemFromCart,
 				changeCartItemQuantity: mockChangeCartItemQuantity,
+				setItemPending: ( pending ) => ( {
+					type: 'SET_ITEM_PENDING',
+					pending,
+				} ),
 			},
 			selectors: {
-				isItemPendingDelete: jest
-					.fn()
-					.mockReturnValue( isPendingDelete ),
-				isItemPendingQuantity: jest
-					.fn()
-					.mockReturnValue( isPendingQuantity ),
+				isItemPendingDelete: ( state ) => state.delete,
+				isItemPendingQuantity: ( state ) => state.quantity,
 			},
 		} );

@@ -174,4 +183,56 @@ describe( 'useStoreCartItemQuantity', () => {

 		expect( result.current.isPendingDelete ).toBe( true );
 	} );
+
+	describe( 'checkout calculating count', () => {
+		const isCalculating = () =>
+			registry.select( CHECKOUT_STORE_KEY ).isCalculating();
+
+		beforeEach( () => {
+			mockUseStoreCart.useStoreCart.mockReturnValue( {
+				cartErrors: {},
+			} );
+		} );
+
+		it( 'keeps an existing hold when mounted with nothing pending', () => {
+			setupMocks( { isPendingDelete: false, isPendingQuantity: false } );
+			registry
+				.dispatch( CHECKOUT_STORE_KEY )
+				.__internalStartCalculation();
+
+			renderStoreCartItemQuantityHook( { key: '123', quantity: 1 } );
+
+			expect( isCalculating() ).toBe( true );
+		} );
+
+		it.each( [ 'quantity', 'delete' ] )(
+			'releases only its own hold when the pending %s update settles',
+			( type ) => {
+				setupMocks( {
+					isPendingDelete: type === 'delete',
+					isPendingQuantity: type === 'quantity',
+				} );
+				registry
+					.dispatch( CHECKOUT_STORE_KEY )
+					.__internalStartCalculation();
+				renderStoreCartItemQuantityHook( { key: '123', quantity: 1 } );
+
+				act( () => {
+					registry
+						.dispatch( CART_STORE_KEY )
+						.setItemPending( { [ type ]: false } );
+				} );
+
+				expect( isCalculating() ).toBe( true );
+
+				act( () => {
+					registry
+						.dispatch( CHECKOUT_STORE_KEY )
+						.__internalFinishCalculation();
+				} );
+
+				expect( isCalculating() ).toBe( false );
+			}
+		);
+	} );
 } );
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/cart/use-store-cart-item-quantity.ts b/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/cart/use-store-cart-item-quantity.ts
index b9884d4f17d..47965883296 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/cart/use-store-cart-item-quantity.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/cart/use-store-cart-item-quantity.ts
@@ -144,16 +144,15 @@ export const useStoreCartItemQuantity = (
 		previousDebouncedQuantity,
 	] );

+	// The cleanups release only the count their effect added, so other holds on
+	// the checkout calculating counter survive.
 	useEffect( () => {
-		if ( isPending.delete ) {
-			__internalStartCalculation();
-		} else {
-			__internalFinishCalculation();
+		if ( ! isPending.delete ) {
+			return;
 		}
+		__internalStartCalculation();
 		return () => {
-			if ( isPending.delete ) {
-				__internalFinishCalculation();
-			}
+			__internalFinishCalculation();
 		};
 	}, [
 		__internalFinishCalculation,
@@ -162,15 +161,12 @@ export const useStoreCartItemQuantity = (
 	] );

 	useEffect( () => {
-		if ( isPending.quantity || debouncedQuantity !== quantity ) {
-			__internalStartCalculation();
-		} else {
-			__internalFinishCalculation();
+		if ( ! isPending.quantity && debouncedQuantity === quantity ) {
+			return;
 		}
+		__internalStartCalculation();
 		return () => {
-			if ( isPending.quantity || debouncedQuantity !== quantity ) {
-				__internalFinishCalculation();
-			}
+			__internalFinishCalculation();
 		};
 	}, [
 		__internalStartCalculation,
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/context/providers/cart-checkout/shipping/index.tsx b/plugins/woocommerce/client/blocks/assets/js/base/context/providers/cart-checkout/shipping/index.tsx
index 89833efa07d..fc49a3a42ea 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/context/providers/cart-checkout/shipping/index.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/base/context/providers/cart-checkout/shipping/index.tsx
@@ -73,12 +73,16 @@ export const ShippingDataProvider = ( {
 	}, [ observers ] );

 	// increment/decrement checkout calculating counts when shipping is loading.
+	// The cleanup releases only the count this effect added, so other holds on
+	// the counter survive.
 	useEffect( () => {
-		if ( isLoadingRates ) {
-			void __internalStartCalculation();
-		} else {
-			void __internalFinishCalculation();
+		if ( ! isLoadingRates ) {
+			return;
 		}
+		void __internalStartCalculation();
+		return () => {
+			void __internalFinishCalculation();
+		};
 	}, [
 		isLoadingRates,
 		__internalStartCalculation,
@@ -87,11 +91,13 @@ export const ShippingDataProvider = ( {

 	// increment/decrement checkout calculating counts when shipping rates are being selected.
 	useEffect( () => {
-		if ( isSelectingRate ) {
-			void __internalStartCalculation();
-		} else {
-			void __internalFinishCalculation();
+		if ( ! isSelectingRate ) {
+			return;
 		}
+		void __internalStartCalculation();
+		return () => {
+			void __internalFinishCalculation();
+		};
 	}, [
 		__internalStartCalculation,
 		__internalFinishCalculation,
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/context/providers/cart-checkout/shipping/test/index.tsx b/plugins/woocommerce/client/blocks/assets/js/base/context/providers/cart-checkout/shipping/test/index.tsx
new file mode 100644
index 00000000000..21a94ae1269
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/base/context/providers/cart-checkout/shipping/test/index.tsx
@@ -0,0 +1,118 @@
+/**
+ * External dependencies
+ */
+import { act, render } from '@testing-library/react';
+import { createRegistry, RegistryProvider } from '@wordpress/data';
+import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data';
+import { config as checkoutStoreConfig } from '@woocommerce/block-data/checkout';
+
+/**
+ * Internal dependencies
+ */
+import { ShippingDataProvider } from '../index';
+import { useStoreCart } from '../../../../hooks/cart/use-store-cart';
+import { useShippingData } from '../../../../hooks/shipping/use-shipping-data';
+
+jest.mock( '@woocommerce/block-data', () => {
+	const checkout = jest.requireActual( '@woocommerce/block-data/checkout' );
+
+	return {
+		__esModule: true,
+		CHECKOUT_STORE_KEY: checkout.CHECKOUT_STORE_KEY,
+		checkoutStore: checkout.store,
+	};
+} );
+
+jest.mock( '../../../../hooks/cart/use-store-cart', () => ( {
+	useStoreCart: jest.fn(),
+} ) );
+
+jest.mock( '../../../../hooks/shipping/use-shipping-data', () => ( {
+	useShippingData: jest.fn(),
+} ) );
+
+describe( 'ShippingDataProvider', () => {
+	let registry: ReturnType< typeof createRegistry >;
+
+	const mockShippingState = ( {
+		isLoadingRates = false,
+		isSelectingRate = false,
+	} ) => {
+		( useStoreCart as jest.Mock ).mockReturnValue( {
+			shippingRates: [],
+			isLoadingRates,
+			cartErrors: [],
+		} );
+		( useShippingData as jest.Mock ).mockReturnValue( {
+			selectedRates: { current: {} },
+			isSelectingRate,
+		} );
+	};
+
+	const tree = () => (
+		<RegistryProvider value={ registry }>
+			<ShippingDataProvider>
+				<div />
+			</ShippingDataProvider>
+		</RegistryProvider>
+	);
+
+	const isCalculating = () =>
+		registry.select( CHECKOUT_STORE_KEY ).isCalculating();
+
+	beforeEach( () => {
+		registry = createRegistry();
+		registry.registerStore( CHECKOUT_STORE_KEY, checkoutStoreConfig );
+	} );
+
+	describe( 'checkout calculating count', () => {
+		it( 'keeps an existing hold when mounted with nothing in flight', () => {
+			registry
+				.dispatch( CHECKOUT_STORE_KEY )
+				.__internalStartCalculation();
+			mockShippingState( {} );
+
+			render( tree() );
+
+			expect( isCalculating() ).toBe( true );
+		} );
+
+		it.each( [ 'isLoadingRates', 'isSelectingRate' ] )(
+			'releases only its own hold once %s turns false',
+			( flag ) => {
+				registry
+					.dispatch( CHECKOUT_STORE_KEY )
+					.__internalStartCalculation();
+				mockShippingState( { [ flag ]: true } );
+				const { rerender } = render( tree() );
+
+				mockShippingState( {} );
+				rerender( tree() );
+
+				expect( isCalculating() ).toBe( true );
+
+				act( () => {
+					registry
+						.dispatch( CHECKOUT_STORE_KEY )
+						.__internalFinishCalculation();
+				} );
+
+				expect( isCalculating() ).toBe( false );
+			}
+		);
+
+		it.each( [ 'isLoadingRates', 'isSelectingRate' ] )(
+			'releases its hold when unmounted while %s',
+			( flag ) => {
+				mockShippingState( { [ flag ]: true } );
+				const { unmount } = render( tree() );
+
+				expect( isCalculating() ).toBe( true );
+
+				unmount();
+
+				expect( isCalculating() ).toBe( false );
+			}
+		);
+	} );
+} );