Commit c68877e25a9 for woocommerce
commit c68877e25a9d27258036d14876014cf6fde90eef
Author: Alefe Souza <contact@alefesouza.com>
Date: Wed Oct 14 21:55:19 2026 -0300
Add a cart-updated action for the Cart and Checkout blocks (#69788)
diff --git a/plugins/woocommerce/changelog/42312-cart-updated-event b/plugins/woocommerce/changelog/42312-cart-updated-event
new file mode 100644
index 00000000000..d976b8d3d86
--- /dev/null
+++ b/plugins/woocommerce/changelog/42312-cart-updated-event
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add an `experimental__woocommerce_blocks-cart-updated` action so extensions can react after the Cart and Checkout blocks receive an updated cart.
diff --git a/plugins/woocommerce/client/blocks/docs/internal-developers/blocks/feature-flags-and-experimental-interfaces.md b/plugins/woocommerce/client/blocks/docs/internal-developers/blocks/feature-flags-and-experimental-interfaces.md
index 3dbb481e3f3..8838fe68bff 100644
--- a/plugins/woocommerce/client/blocks/docs/internal-developers/blocks/feature-flags-and-experimental-interfaces.md
+++ b/plugins/woocommerce/client/blocks/docs/internal-developers/blocks/feature-flags-and-experimental-interfaces.md
@@ -101,9 +101,12 @@ See the [available SlotFills](https://github.com/woocommerce/woocommerce/blob/tr
| `experimental__woocommerce_blocks-cart-view-link` | `product` |
| `experimental__woocommerce_blocks-cart-remove-item` | `product`, `quantity` |
| `experimental__woocommerce_blocks-cart-set-item-quantity` | `product`, `quantity` |
+| `experimental__woocommerce_blocks-cart-updated` | `storeCart`, `previousStoreCart` |
| `experimental__woocommerce_blocks-product-view-link` | `product` |
| `experimental__woocommerce_blocks-product-list-render` | `products`, `listName` |
+`experimental__woocommerce_blocks-cart-updated` is emitted by the `wc/store/cart` data store instead of `useStoreEvents`. It fires each time a cart from a Store API response is applied to the store, after cart notices are shown and items that left the cart are no longer marked as pending delete.
+
Examples:
```js
@@ -131,6 +134,16 @@ wp.hooks.addAction(
}
);
+wp.hooks.addAction(
+ 'experimental__woocommerce_blocks-cart-updated',
+ 'plugin/namespace',
+ ( { storeCart, previousStoreCart } ) => {
+ console.log(
+ `The cart went from ${ previousStoreCart.itemsCount } to ${ storeCart.itemsCount } items`
+ );
+ }
+);
+
wp.hooks.addAction(
'experimental__woocommerce_blocks-product-view-link',
'plugin/namespace',
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/test/thunks.ts b/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/test/thunks.ts
index 59a92989540..67368d2b7fd 100644
--- a/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/test/thunks.ts
+++ b/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/test/thunks.ts
@@ -2,6 +2,7 @@
* External dependencies
*/
import { ExtensionCartUpdateArgs } from '@woocommerce/types';
+import { addAction, removeAction } from '@wordpress/hooks';
/**
* Internal dependencies
@@ -10,6 +11,7 @@ import {
changeCartItemQuantity,
receiveCart,
applyExtensionCartUpdate,
+ syncCartWithIAPIStore,
} from '../thunks';
import { apiFetchWithHeaders } from '../../shared-controls';
import { getIsCustomerDataDirty } from '../utils';
@@ -382,6 +384,77 @@ describe( 'receiveCart', () => {
expect( dispatch.itemIsPendingDelete ).not.toHaveBeenCalled();
} );
+ it( 'should fire the cart-updated action with the new and previous cart after clearing pending deletes', () => {
+ const { dispatch, select } = createReceiveCartMocks( {
+ cartItems: [ { key: 'bundle-parent' }, { key: 'simple-product' } ],
+ pendingDelete: [ 'bundle-parent' ],
+ } );
+ const previousCart = select.getCartData();
+ let pendingDeleteClearedFirst = false;
+ // Record the state instead of asserting here, since errors thrown by callbacks are caught and logged.
+ const callback = jest.fn( () => {
+ pendingDeleteClearedFirst =
+ dispatch.itemIsPendingDelete.mock.calls.some(
+ ( [ key, isPending ] ) =>
+ key === 'bundle-parent' && isPending === false
+ );
+ } );
+ addAction(
+ 'experimental__woocommerce_blocks-cart-updated',
+ 'test/cart-updated',
+ callback
+ );
+
+ try {
+ receiveCart( {
+ items: [ { key: 'simple-product' } ],
+ errors: [],
+ } as never )( { dispatch, select } as never );
+ } finally {
+ removeAction(
+ 'experimental__woocommerce_blocks-cart-updated',
+ 'test/cart-updated'
+ );
+ }
+
+ expect( callback ).toHaveBeenCalledTimes( 1 );
+ expect( callback ).toHaveBeenCalledWith( {
+ storeCart: select.getCartData(),
+ previousStoreCart: previousCart,
+ } );
+ expect( pendingDeleteClearedFirst ).toBe( true );
+ } );
+
+ it( 'should still update the cart when a cart-updated callback throws', () => {
+ const { dispatch, select } = createReceiveCartMocks( {
+ cartItems: [ { key: 'item-1' } ],
+ pendingDelete: [],
+ } );
+ addAction(
+ 'experimental__woocommerce_blocks-cart-updated',
+ 'test/cart-updated-throws',
+ () => {
+ throw new Error( 'Extension error' );
+ }
+ );
+
+ try {
+ receiveCart( {
+ items: [ { key: 'item-2' } ],
+ errors: [],
+ } as never )( { dispatch, select } as never );
+ } finally {
+ removeAction(
+ 'experimental__woocommerce_blocks-cart-updated',
+ 'test/cart-updated-throws'
+ );
+ }
+
+ expect( dispatch.setCartData ).toHaveBeenCalled();
+ expect( dispatch.setErrorData ).toHaveBeenCalledWith( null );
+ expect( console ).toHaveErroredWith( new Error( 'Extension error' ) );
+ } );
+
it( 'should handle empty pending delete list', () => {
const { dispatch, select } = createReceiveCartMocks( {
cartItems: [ { key: 'item-1' } ],
@@ -397,6 +470,106 @@ describe( 'receiveCart', () => {
} );
} );
+describe( 'syncCartWithIAPIStore', () => {
+ const createSyncMocks = () => {
+ let cartData = {
+ items: [ { key: 'item-1', quantity: 1 } ],
+ errors: [] as never[],
+ };
+
+ const dispatch = {
+ setCartData: jest.fn( ( newCart ) => {
+ cartData = newCart;
+ } ),
+ setProductsPendingAdd: jest.fn(),
+ itemIsPendingQuantity: jest.fn(),
+ itemIsPendingDelete: jest.fn(),
+ setErrorData: jest.fn(),
+ receiveError: jest.fn(),
+ };
+ const select = {
+ getCartData: jest.fn( () => cartData ),
+ getCartErrors: jest.fn( () => [] ),
+ };
+
+ return { dispatch, select };
+ };
+
+ beforeEach( () => {
+ jest.clearAllMocks();
+ } );
+
+ it( 'should fire the cart-updated action with the new and previous cart after clearing pending quantities', async () => {
+ mockApiFetchWithHeaders.mockResolvedValue( {
+ response: {
+ items: [ { key: 'item-1', quantity: 2 } ],
+ errors: [],
+ },
+ } );
+ const { dispatch, select } = createSyncMocks();
+ const previousCart = select.getCartData();
+ let pendingQuantityClearedFirst = false;
+ const callback = jest.fn( () => {
+ pendingQuantityClearedFirst =
+ dispatch.itemIsPendingQuantity.mock.calls.some(
+ ( [ key, isPending ] ) =>
+ key === 'item-1' && isPending === false
+ );
+ } );
+ addAction(
+ 'experimental__woocommerce_blocks-cart-updated',
+ 'test/cart-updated-sync',
+ callback
+ );
+
+ try {
+ await syncCartWithIAPIStore( {
+ cartItemsPendingQuantity: [ 'item-1' ],
+ } )( { dispatch, select } as never );
+ } finally {
+ removeAction(
+ 'experimental__woocommerce_blocks-cart-updated',
+ 'test/cart-updated-sync'
+ );
+ }
+
+ expect( callback ).toHaveBeenCalledTimes( 1 );
+ expect( callback ).toHaveBeenCalledWith( {
+ storeCart: select.getCartData(),
+ previousStoreCart: previousCart,
+ } );
+ expect( pendingQuantityClearedFirst ).toBe( true );
+ } );
+
+ it( 'should not fire the cart-updated action when the cart request fails', async () => {
+ mockApiFetchWithHeaders.mockRejectedValue(
+ new Error( 'Request failed' )
+ );
+ const { dispatch, select } = createSyncMocks();
+ const callback = jest.fn();
+ addAction(
+ 'experimental__woocommerce_blocks-cart-updated',
+ 'test/cart-updated-sync-fails',
+ callback
+ );
+
+ try {
+ await expect(
+ syncCartWithIAPIStore( {
+ cartItemsPendingQuantity: [ 'item-1' ],
+ } )( { dispatch, select } as never )
+ ).rejects.toThrow( 'Request failed' );
+ } finally {
+ removeAction(
+ 'experimental__woocommerce_blocks-cart-updated',
+ 'test/cart-updated-sync-fails'
+ );
+ }
+
+ expect( callback ).not.toHaveBeenCalled();
+ } );
+} );
+
describe( 'applyExtensionCartUpdate', () => {
const mockResponse = {
items: [],
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/thunks.ts b/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/thunks.ts
index c5e10777e82..24e047cd3ad 100644
--- a/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/thunks.ts
+++ b/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/thunks.ts
@@ -24,6 +24,7 @@ import {
type DispatchFunction,
} from '@wordpress/data/build-types/types';
import { __ } from '@wordpress/i18n';
+import { doAction } from '@wordpress/hooks';
import { cartStore } from '@woocommerce/block-data';
/**
@@ -50,6 +51,32 @@ interface CartThunkArgs {
registry?: { dispatch: DispatchFunction };
}
+/**
+ * Fires an action once a cart from the server is in the store, so extensions can react to the updated cart.
+ *
+ * @param storeCart The cart now in the store.
+ * @param previousStoreCart The cart in the store before this update.
+ */
+const triggerCartUpdatedEvent = (
+ storeCart: Cart,
+ previousStoreCart: Cart
+) => {
+ // The editor shows a preview cart, not one from the server.
+ if ( isEditor() ) {
+ return;
+ }
+ try {
+ doAction( 'experimental__woocommerce_blocks-cart-updated', {
+ storeCart,
+ previousStoreCart,
+ } );
+ } catch ( error ) {
+ // A failing callback must not break the cart, so only log it for troubleshooting.
+ // eslint-disable-next-line no-console
+ console.error( error );
+ }
+};
+
/**
* A thunk used in updating the store with the cart items retrieved from a request. This also notifies the shopper
* of any unexpected quantity changes occurred.
@@ -95,6 +122,8 @@ export const receiveCart =
updateCartErrorNotices( newCart.errors, oldCartErrors );
dispatch.setErrorData( null );
+
+ triggerCartUpdatedEvent( newCart, oldCart );
};
/**
@@ -325,6 +354,8 @@ export const syncCartWithIAPIStore =
updateCartErrorNotices( newCart.errors, oldCartErrors );
dispatch.setErrorData( null );
+
+ triggerCartUpdatedEvent( newCart, oldCart );
} catch ( error ) {
// Clear pending states on error as well
if ( productsPendingAdd && productsPendingAdd.length > 0 ) {