Commit 651c1777206 for woocommerce

commit 651c1777206067c0c65f6f6cddd085645dc0afe7
Author: Alefe Souza <contact@alefesouza.com>
Date:   Thu Oct 15 12:55:12 2026 -0300

    Show an updating state in the Mini-Cart while the cart changes (#69792)

diff --git a/plugins/woocommerce/changelog/42480-mini-cart-updating-state b/plugins/woocommerce/changelog/42480-mini-cart-updating-state
new file mode 100644
index 00000000000..1082b1208a8
--- /dev/null
+++ b/plugins/woocommerce/changelog/42480-mini-cart-updating-state
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Disable the Mini-Cart checkout button and show a loading subtotal while a quantity change or other cart update is in progress.
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 ef22c04b48e..98e9bd13b17 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
@@ -91,6 +91,8 @@ export type Store = {
 		};
 		restUrl: string;
 		nonce: string;
+		// Whether a cart update request is in progress.
+		isCartUpdating?: boolean;
 		findItemInCart: ( args: {
 			id: ClientCartItem[ 'id' ];
 			key?: ClientCartItem[ 'key' ];
@@ -513,6 +515,9 @@ async function sendCartRequest(
 			commit: ( serverState ) => {
 				stateRef.cart = serverState;
 			},
+			onProcessingChange: ( isProcessing ) => {
+				stateRef.isCartUpdating = isProcessing;
+			},
 			fetchHandler: async ( ...args ) => {
 				const response = await fetch( ...args );
 				stateRef.nonce =
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/mutation-batcher.ts b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/mutation-batcher.ts
index 239312dd279..aead67154ec 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/mutation-batcher.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/mutation-batcher.ts
@@ -70,6 +70,11 @@ export type MutationQueueConfig< TState = unknown, TMeta = unknown > = {
 	 * individual requests' own settlement.
 	 */
 	onCycleSettled?: ( settled: Array< CycleSettledEntry< TMeta > > ) => void;
+	/**
+	 * Called with `true` when the first request of a cycle is submitted, and with `false` once the cycle has settled,
+	 * so callers can show that an update is in progress. Exceptions are caught and logged.
+	 */
+	onProcessingChange?: ( isProcessing: boolean ) => void;
 };

 type TrackedRequest< TMeta = unknown > = {
@@ -113,6 +118,15 @@ export function createMutationQueue< TState, TMeta = unknown >(

 	let microtaskScheduled = false;
 	let isProcessing = false;
+
+	function notifyProcessingChange() {
+		try {
+			config.onProcessingChange?.( isProcessing );
+		} catch ( error ) {
+			// eslint-disable-next-line no-console
+			console.error( error );
+		}
+	}
 	let idleResolvers: Array< () => void > = [];
 	let nextId = 0;

@@ -145,6 +159,7 @@ export function createMutationQueue< TState, TMeta = unknown >(
 		}

 		isProcessing = false;
+		notifyProcessingChange();

 		// Notify idle waiters.
 		const resolvers = idleResolvers;
@@ -304,6 +319,7 @@ export function createMutationQueue< TState, TMeta = unknown >(
 			if ( ! isProcessing ) {
 				snapshot = takeSnapshot();
 				isProcessing = true;
+				notifyProcessingChange();
 			}

 			// Deep-clone the body at submission time so that later optimistic
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/test/cart.ts b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/test/cart.ts
index 82f036389a3..b42f7f7cfff 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/test/cart.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/test/cart.ts
@@ -768,6 +768,38 @@ describe( 'WooCommerce Cart Interactivity API Store', () => {
 		} );
 	} );

+	describe( 'isCartUpdating', () => {
+		it( 'is true while a cart update request is in progress and false once it settles', async () => {
+			mockBatchFetch();
+			const batchFetch = global.fetch;
+			const duringRequest: Array< boolean | undefined > = [];
+			global.fetch = jest.fn(
+				async ( url: RequestInfo | URL, init?: RequestInit ) => {
+					if ( init?.body ) {
+						duringRequest.push( mockState.isCartUpdating );
+					}
+					return batchFetch( url, init );
+				}
+			) as typeof fetch;
+			const actions = await loadCartStore();
+			seedCart( [
+				makeKeyedLine( { id: 42, quantity: 3, key: 'server-key-abc' } ),
+			] );
+
+			await runAction(
+				actions.addCartItem( {
+					id: 42,
+					key: 'server-key-abc',
+					quantity: 5,
+					type: 'simple',
+				} )
+			);
+
+			expect( duringRequest ).toEqual( [ true ] );
+			expect( mockState.isCartUpdating ).toBe( false );
+		} );
+	} );
+
 	describe( 'addCartItem keyless-requires-delta invariant guard', () => {
 		it( 'throws when called keyless with an absolute quantity (no quantityToAdd)', async () => {
 			mockBatchFetch();
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/test/mutation-batcher.test.ts b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/test/mutation-batcher.test.ts
index 01b78f713a1..cf20df2c4fc 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/test/mutation-batcher.test.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/test/mutation-batcher.test.ts
@@ -300,6 +300,80 @@ describe( 'createMutationQueue', () => {
 		} );
 	} );

+	describe( 'onProcessingChange callback', () => {
+		it( 'is called with true once when a cycle starts and with false once its server state is committed', async () => {
+			global.fetch = createMockFetch( [
+				{ status: 200, body: { value: 1 } },
+				{ status: 200, body: { value: 2 } },
+			] );
+			const calls: Array< { isProcessing: boolean; value: number } > = [];
+
+			const queue = createMutationQueue( {
+				endpoint: '/batch',
+				getHeaders: () => ( {} ),
+				...stateHandler,
+				onProcessingChange: ( isProcessing ) => {
+					calls.push( { isProcessing, value: mockState.value } );
+				},
+			} );
+
+			const requests = Promise.all( [
+				queue.submit( { path: '/a', method: 'POST' } ),
+				queue.submit( { path: '/b', method: 'POST' } ),
+			] );
+			expect( calls ).toEqual( [ { isProcessing: true, value: 0 } ] );
+
+			await requests;
+
+			expect( calls ).toEqual( [
+				{ isProcessing: true, value: 0 },
+				{ isProcessing: false, value: 2 },
+			] );
+		} );
+
+		it( 'is called with false when every request in the cycle failed', async () => {
+			global.fetch = createFailingFetch( new Error( 'Network error' ) );
+			const onProcessingChange = jest.fn();
+
+			const queue = createMutationQueue( {
+				endpoint: '/batch',
+				getHeaders: () => ( {} ),
+				...stateHandler,
+				onProcessingChange,
+			} );
+
+			await queue
+				.submit( { path: '/a', method: 'POST' } )
+				.catch( () => undefined );
+
+			expect( onProcessingChange.mock.calls ).toEqual( [
+				[ true ],
+				[ false ],
+			] );
+		} );
+
+		it( 'logs a callback error without interrupting the cycle', async () => {
+			global.fetch = createMockFetch( [
+				{ status: 200, body: { value: 1 } },
+			] );
+
+			const queue = createMutationQueue( {
+				endpoint: '/batch',
+				getHeaders: () => ( {} ),
+				...stateHandler,
+				onProcessingChange: () => {
+					throw new Error( 'Callback error' );
+				},
+			} );
+
+			await queue.submit( { path: '/a', method: 'POST' } );
+
+			expect( mockState.value ).toBe( 1 );
+			expect( queue.getStatus().isProcessing ).toBe( false );
+			expect( console ).toHaveErrored();
+		} );
+	} );
+
 	describe( 'onCycleSettled callback', () => {
 		it( 'is invoked exactly once per cycle, synchronously while isProcessing is still true', async () => {
 			const mockFetch = createMockFetch( [
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/frontend.ts
index 036ef5c0398..2be45222e57 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/frontend.ts
@@ -106,6 +106,7 @@ export type MiniCart = {
 		isOpen: boolean;
 		totalItemsInCart: number;
 		formattedSubtotal: string;
+		isCartUpdating: boolean;
 		badgeIsVisible: boolean;
 		cartIsEmpty: boolean;
 		drawerRole: string | null;
@@ -123,6 +124,7 @@ export type MiniCart = {
 		closeDrawer: () => void;
 		overlayCloseDrawer: ( e: MouseEvent ) => void;
 		handleOverlayKeydown: ( e: KeyboardEvent ) => void;
+		preventCheckoutWhileUpdating: ( e: MouseEvent ) => void;
 	};
 	callbacks: {
 		markAsHydrated: () => void;
@@ -237,6 +239,11 @@ store< MiniCart >(
 				return formatPriceWithCurrency( subtotal, normalizedCurrency );
 			},

+			// The subtotal comes from the server, so it's out of date until a cart update finishes.
+			get isCartUpdating(): boolean {
+				return !! woocommerceState.isCartUpdating;
+			},
+
 			get drawerRole() {
 				return state.isOpen ? 'dialog' : null;
 			},
@@ -317,6 +324,13 @@ store< MiniCart >(
 				state.miniCartButtonRef?.focus();
 			},

+			// The checkout button is a link, so it can't be disabled natively.
+			preventCheckoutWhileUpdating: withSyncEvent( ( e: MouseEvent ) => {
+				if ( state.isCartUpdating ) {
+					e.preventDefault();
+				}
+			} ),
+
 			overlayCloseDrawer( e: MouseEvent ) {
 				// Only close the drawer if the overlay itself was clicked.
 				if ( e.target === e.currentTarget ) {
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/mini-cart-contents/style.scss b/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/mini-cart-contents/style.scss
index ae5623d21c7..df22722b20f 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/mini-cart-contents/style.scss
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/mini-cart-contents/style.scss
@@ -127,6 +127,13 @@ h2.wc-block-mini-cart__title {
 		font-weight: 600;
 		margin-bottom: $gap;

+		// While the cart updates, show a placeholder the width of the outdated subtotal instead of the amount.
+		// The text fill is hidden instead of the color, since the placeholder is drawn with currentColor.
+		.wc-block-components-totals-item__value.is-loading {
+			@include skeleton-animation();
+			-webkit-text-fill-color: transparent;
+		}
+
 		.wc-block-components-totals-item__description {
 			@include font-x-small-locked;
 			display: none;
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/MiniCartCheckoutButtonBlock.php b/plugins/woocommerce/src/Blocks/BlockTypes/MiniCartCheckoutButtonBlock.php
index b07457b2a2c..f03147214f5 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/MiniCartCheckoutButtonBlock.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/MiniCartCheckoutButtonBlock.php
@@ -27,8 +27,10 @@ class MiniCartCheckoutButtonBlock extends AbstractInnerBlock {
 		$checkout_page_url     = get_permalink( $checkout_page_id );
 		$wrapper_attributes    = get_block_wrapper_attributes(
 			array(
-				'href'  => esc_url( $checkout_page_url ),
-				'class' => 'wc-block-components-button wp-element-button wc-block-mini-cart__footer-checkout',
+				'href'                        => esc_url( $checkout_page_url ),
+				'class'                       => 'wc-block-components-button wp-element-button wc-block-mini-cart__footer-checkout',
+				'data-wp-bind--aria-disabled' => 'woocommerce/mini-cart::state.isCartUpdating',
+				'data-wp-on--click'           => 'woocommerce/mini-cart::actions.preventCheckoutWhileUpdating',
 			)
 		);

diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/MiniCartFooterBlock.php b/plugins/woocommerce/src/Blocks/BlockTypes/MiniCartFooterBlock.php
index 95d1b68586a..5825dde3e56 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/MiniCartFooterBlock.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/MiniCartFooterBlock.php
@@ -72,11 +72,14 @@ class MiniCartFooterBlock extends AbstractInnerBlock {

 		?>
 		<div <?php echo $wrapper_attributes; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>>
-			<div class="wc-block-components-totals-item wc-block-mini-cart__footer-subtotal">
+			<div class="wc-block-components-totals-item wc-block-mini-cart__footer-subtotal" data-wp-bind--aria-busy="woocommerce/mini-cart::state.isCartUpdating">
 				<span class="wc-block-components-totals-item__label">
 					<?php echo esc_html( $subtotal_label ); ?>
 				</span>
-				<span data-wp-text="woocommerce/mini-cart::state.formattedSubtotal" class="wc-block-formatted-money-amount wc-block-components-formatted-money-amount wc-block-components-totals-item__value notranslate">
+				<span data-wp-text="woocommerce/mini-cart::state.formattedSubtotal" data-wp-class--is-loading="woocommerce/mini-cart::state.isCartUpdating" data-wp-bind--aria-hidden="woocommerce/mini-cart::state.isCartUpdating" class="wc-block-formatted-money-amount wc-block-components-formatted-money-amount wc-block-components-totals-item__value notranslate">
+				</span>
+				<span class="screen-reader-text" hidden data-wp-bind--hidden="woocommerce/mini-cart::!state.isCartUpdating">
+					<?php echo esc_html_x( 'Updating', 'Mini-Cart subtotal while the cart is being updated', 'woocommerce' ); ?>
 				</span>
 				<div class="wc-block-components-totals-item__description">
 					<?php echo esc_html( $other_costs_label ); ?>