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