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 ) {