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 ); ?>