Commit f24f37438a for woocommerce
commit f24f37438a67c17704ca786f5de85d40011a6b74
Author: Sam Seay <samueljseay@gmail.com>
Date: Tue Feb 17 02:53:17 2026 +1300
Implement request batching/queueing for cart operations, utilize the queue within addCartItem, removeCartItem (#62766)
diff --git a/plugins/woocommerce/changelog/62766-wooplug-6115-iapi-store-implement-request-batching b/plugins/woocommerce/changelog/62766-wooplug-6115-iapi-store-implement-request-batching
new file mode 100644
index 0000000000..9a6a1555fc
--- /dev/null
+++ b/plugins/woocommerce/changelog/62766-wooplug-6115-iapi-store-implement-request-batching
@@ -0,0 +1,4 @@
+Significance: minor
+Type: fix
+
+Implement request batching/queueing for cart operations to fix multiple issues with state resolution in the cart UI.
\ No newline at end of file
diff --git a/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/button/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/button/frontend.ts
index 2032911768..3fe96b1eae 100644
--- a/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/button/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/button/frontend.ts
@@ -172,10 +172,12 @@ const productButtonStore = {
{ lock: universalLock }
);
+ // Pass quantityToAdd as a delta. The cart store will add this
+ // to the current quantity, ensuring rapid clicks compound correctly.
yield actions.addCartItem(
{
id: state.productId,
- quantity: state.quantity + context.quantityToAdd,
+ quantityToAdd: context.quantityToAdd,
type: context.productType,
},
{
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 e9cd40c251..390eb8f496 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
@@ -7,7 +7,6 @@ import type {
CartItem,
CartVariationItem,
ApiErrorResponse,
- ApiResponse,
CartResponseTotals,
Currency,
} from '@woocommerce/types';
@@ -20,6 +19,12 @@ import type {
* Internal dependencies
*/
import { triggerAddedToCartEvent } from './legacy-events';
+import {
+ createMutationQueue,
+ MutationRequest,
+ type MutationQueue,
+ type MutationResult,
+} from './mutation-batcher';
export type WooCommerceConfig = {
products?: {
@@ -42,8 +47,15 @@ export type OptimisticCartItem = {
type: string;
};
-export type ClientCartItem = Omit< OptimisticCartItem, 'variation' > & {
+export type ClientCartItem = Omit<
+ OptimisticCartItem,
+ 'variation' | 'quantity'
+> & {
variation?: SelectedAttributes[];
+ /** The target quantity (absolute). Either this or quantityToAdd must be provided. */
+ quantity?: number;
+ /** Optional: add this delta to current quantity instead of setting absolute quantity */
+ quantityToAdd?: number;
};
export type VariationData = {
@@ -104,6 +116,7 @@ export type Store = {
) => void;
// Todo: Check why if I switch to an async function here the types of the store stop working.
refreshCartItems: () => void;
+ waitForIdle: () => void;
showNoticeError: ( error: Error | ApiErrorResponse ) => void;
updateNotices: ( notices: Notice[], removeOthers?: boolean ) => void;
};
@@ -115,10 +128,6 @@ type QuantityChanges = {
productsPendingAdd?: number[];
};
-type BatchResponse = {
- responses: ApiResponse< Cart >[];
-};
-
// Guard to distinguish between optimistic and cart items.
function isCartItem( item: OptimisticCartItem | CartItem ): item is CartItem {
return 'name' in item;
@@ -252,70 +261,116 @@ function emitSyncEvent( {
);
}
+/**
+ * Cart request queue singleton
+ *
+ * Lazily initialized on first use since state isn't available at module load.
+ * Queues cart requests and handles optimistic updates and reconciliation.
+ */
+let cartQueue: MutationQueue< Cart > | null = null;
+
+/**
+ * Send a cart request through the queue.
+ *
+ * Handles optimistic updates, request queuing, and state reconciliation.
+ */
+function sendCartRequest(
+ stateRef: Store[ 'state' ],
+ options: MutationRequest< Cart >
+): Promise< MutationResult< Cart > > {
+ // Lazily initialize queue on first use.
+ if ( ! cartQueue ) {
+ cartQueue = createMutationQueue< Cart >( {
+ endpoint: `${ stateRef.restUrl }wc/store/v1/batch`,
+ getHeaders: () => ( {
+ Nonce: stateRef.nonce,
+ } ),
+ takeSnapshot: () => JSON.parse( JSON.stringify( stateRef.cart ) ),
+ rollback: ( snapshot ) => {
+ stateRef.cart = snapshot;
+ },
+ commit: ( serverState ) => {
+ stateRef.cart = serverState;
+ },
+ } );
+ }
+
+ return cartQueue.submit( options );
+}
+
// Todo: export this store once the store is public.
const { state, actions } = store< Store >(
'woocommerce',
{
actions: {
*removeCartItem( key: string ) {
- const previousCart = JSON.stringify( state.cart );
+ // Track what changes we're making for notice comparison.
+ const quantityChanges: QuantityChanges = {
+ cartItemsPendingDelete: [ key ],
+ };
- // optimistically update the cart
- state.cart.items = state.cart.items.filter(
- ( item ) => item.key !== key
- );
+ // Capture cart state after optimistic updates for notice comparison.
+ let cartAfterOptimistic: typeof state.cart | null = null;
try {
- const res: Response = yield fetch(
- `${ state.restUrl }wc/store/v1/cart/remove-item`,
- {
- method: 'POST',
- headers: {
- Nonce: state.nonce,
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify( { key } ),
- }
- );
-
- const json: Cart | ApiErrorResponse = yield res.json();
+ const result = yield sendCartRequest( state, {
+ path: '/wc/store/v1/cart/remove-item',
+ method: 'POST',
+ body: { key },
+ applyOptimistic: () => {
+ state.cart.items = state.cart.items.filter(
+ ( item ) => item.key !== key
+ );
+ // Capture state after optimistic update.
+ cartAfterOptimistic = JSON.parse(
+ JSON.stringify( state.cart )
+ );
+ },
+ // Side effects run synchronously during reconciliation,
+ // before isProcessing clears. This prevents
+ // refreshCartItems from running during these events.
+ onSettled: ( { success } ) => {
+ if ( success ) {
+ emitSyncEvent( { quantityChanges } );
+ }
+ },
+ } );
- if ( isApiErrorResponse( res, json ) ) {
- throw generateError( json );
+ // Show notices from server response.
+ const cart = result.data as Cart;
+ if ( cart && cartAfterOptimistic ) {
+ const infoNotices = getInfoNoticesFromCartUpdates(
+ cartAfterOptimistic,
+ cart,
+ quantityChanges
+ );
+ const errorNotices =
+ cart.errors.map( generateErrorNotice );
+ yield actions.updateNotices(
+ [ ...infoNotices, ...errorNotices ],
+ true
+ );
}
- const quantityChanges = { cartItemsPendingDelete: [ key ] };
- const infoNotices = getInfoNoticesFromCartUpdates(
- state.cart,
- json,
- quantityChanges
- );
- const errorNotices = json.errors.map( generateErrorNotice );
- yield actions.updateNotices(
- [ ...infoNotices, ...errorNotices ],
- true
- );
-
- state.cart = json;
- emitSyncEvent( { quantityChanges } );
} catch ( error ) {
- state.cart = JSON.parse( previousCart );
-
- // Shows the error notice.
actions.showNoticeError( error as Error );
}
},
*addCartItem(
- { id, key, quantity, variation }: ClientCartItem,
+ { id, key, quantity, quantityToAdd, variation }: ClientCartItem,
{ showCartUpdatesNotices = true }: CartUpdateOptions = {}
) {
+ if ( quantity !== undefined && quantityToAdd !== undefined ) {
+ throw new Error(
+ 'addCartItem: pass either quantity or quantityToAdd, not both.'
+ );
+ }
+
const a11yModulePromise = import( '@wordpress/a11y' );
- let item = state.cart.items.find( ( cartItem ) => {
+
+ // Find existing item
+ const existingItem = state.cart.items.find( ( cartItem ) => {
if ( cartItem.type === 'variation' ) {
- // If it's a variation, check that attributes match.
- // While different variations have different attributes,
- // some variations might accept 'Any' value for an attribute,
- // in which case, we need to check that the attributes match.
if (
id !== cartItem.id ||
! cartItem.variation ||
@@ -329,76 +384,123 @@ const { state, actions } = store< Store >(
variation
);
}
- // If no key is provided, rely on the id.
return key ? key === cartItem.key : id === cartItem.id;
} );
- const endpoint = item ? 'update-item' : 'add-item';
- const previousCart = JSON.stringify( state.cart );
- const quantityChanges: QuantityChanges = {};
- // Optimistically update the number of items in the cart except
- // if the product is sold individually and is already in the
- // cart.
- let updatedItem = null;
- if ( item ) {
- const isSoldIndividually =
- isCartItem( item ) && item.sold_individually;
- updatedItem = { ...item, quantity };
- if ( item.key && ! isSoldIndividually ) {
- quantityChanges.cartItemsPendingQuantity = [ item.key ];
- item.quantity = quantity;
- }
+ // Determine the target quantity.
+ // If quantityToAdd is provided, calculate target based on current
+ // cart state (which includes optimistic updates from previous clicks).
+ // This ensures rapid clicks compound correctly.
+ let targetQuantity: number;
+ if ( typeof quantityToAdd === 'number' ) {
+ const currentQuantity = existingItem?.quantity ?? 0;
+ targetQuantity = currentQuantity + quantityToAdd;
+ } else if ( typeof quantity === 'number' ) {
+ targetQuantity = quantity;
} else {
- item = {
+ // Neither provided - default to 1
+ targetQuantity = 1;
+ }
+
+ // Only treat as update if the item has a key (server-confirmed item).
+ // Optimistic items don't have keys, so we should add them instead.
+ const isUpdate = !! existingItem?.key;
+ const endpoint = isUpdate ? 'update-item' : 'add-item';
+
+ // Track what changes we're making for notice comparison.
+ const quantityChanges: QuantityChanges = isUpdate
+ ? {
+ cartItemsPendingQuantity: existingItem?.key
+ ? [ existingItem.key ]
+ : [],
+ }
+ : { productsPendingAdd: [ id ] };
+
+ // Prepare the item to send.
+ let itemToSend: OptimisticCartItem;
+ if ( isUpdate && existingItem ) {
+ // Server-confirmed item: include the key for update-item endpoint.
+ itemToSend = { ...existingItem, quantity: targetQuantity };
+ } else {
+ // New item or optimistic item: build fresh for add-item endpoint.
+ // For optimistic items (existingItem without key), calculate delta
+ // since add-item adds to existing quantity, not sets it.
+ const quantityToSend = existingItem
+ ? targetQuantity - existingItem.quantity
+ : targetQuantity;
+
+ itemToSend = {
id,
- quantity,
+ quantity: quantityToSend,
...( variation && { variation } ),
} as OptimisticCartItem;
- quantityChanges.productsPendingAdd = [ id ];
- state.cart.items.push( item );
- updatedItem = item;
}
- // Updates the database.
- try {
- const res: Response = yield fetch(
- `${ state.restUrl }wc/store/v1/cart/${ endpoint }`,
- {
- method: 'POST',
- headers: {
- Nonce: state.nonce,
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify( updatedItem ),
- }
- );
- const json: Cart = yield res.json();
-
- // Checks if the response contains an error.
- if ( isApiErrorResponse( res, json ) )
- throw generateError( json );
+ // Capture cart state after optimistic updates for notice comparison.
+ let cartAfterOptimistic: typeof state.cart | null = null;
- const infoNotices = showCartUpdatesNotices
- ? getInfoNoticesFromCartUpdates(
- state.cart,
- json,
- quantityChanges
- )
- : [];
- const errorNotices = json.errors.map( generateErrorNotice );
- yield actions.updateNotices(
- [ ...infoNotices, ...errorNotices ],
- true
- );
-
- // Updates the local cart.
- state.cart = json;
-
- // Dispatches a legacy event.
- triggerAddedToCartEvent( {
- preserveCartData: true,
+ try {
+ const result = yield sendCartRequest( state, {
+ path: `/wc/store/v1/cart/${ endpoint }`,
+ method: 'POST',
+ body: itemToSend,
+ applyOptimistic: () => {
+ if ( existingItem ) {
+ // Update existing item's quantity (whether server-confirmed or optimistic).
+ const isSoldIndividually =
+ isCartItem( existingItem ) &&
+ existingItem.sold_individually;
+ if ( ! isSoldIndividually ) {
+ existingItem.quantity = targetQuantity;
+ }
+ } else {
+ // No existing item: push new optimistic item.
+ state.cart.items.push( itemToSend );
+ }
+ // Capture state after optimistic update.
+ cartAfterOptimistic = JSON.parse(
+ JSON.stringify( state.cart )
+ );
+ },
+ // Side effects run synchronously during reconciliation,
+ // before isProcessing clears. This prevents
+ // refreshCartItems from running during these events.
+ onSettled: ( { success } ) => {
+ if ( success ) {
+ // Dispatch legacy event
+ triggerAddedToCartEvent( {
+ preserveCartData: true,
+ } );
+
+ // Dispatch sync event
+ emitSyncEvent( { quantityChanges } );
+ }
+ },
} );
+ // Success - handle side effects that don't trigger refreshCartItems
+ const cart = result.data as Cart;
+
+ // Show notices if enabled
+ if (
+ showCartUpdatesNotices &&
+ cart &&
+ cartAfterOptimistic
+ ) {
+ const infoNotices = getInfoNoticesFromCartUpdates(
+ cartAfterOptimistic,
+ cart,
+ quantityChanges
+ );
+ const errorNotices =
+ cart.errors.map( generateErrorNotice );
+ yield actions.updateNotices(
+ [ ...infoNotices, ...errorNotices ],
+ true
+ );
+ }
+
+ // Announce to screen readers
const { messages } = getConfig(
'woocommerce'
) as WooCommerceConfig;
@@ -406,15 +508,8 @@ const { state, actions } = store< Store >(
const { speak } = yield a11yModulePromise;
speak( messages.addedToCartText, 'polite' );
}
-
- // Dispatches the event to sync the @wordpress/data store.
- emitSyncEvent( { quantityChanges } );
} catch ( error ) {
- // Reverts the optimistic update.
- // Todo: Prevent racing conditions with multiple addToCart calls for the same item.
- state.cart = JSON.parse( previousCart );
-
- // Shows the error notice.
+ // Show error notice
actions.showNoticeError( error as Error );
}
},
@@ -424,137 +519,125 @@ const { state, actions } = store< Store >(
{ showCartUpdatesNotices = true }: CartUpdateOptions = {}
) {
const a11yModulePromise = import( '@wordpress/a11y' );
- const previousCart = JSON.stringify( state.cart );
const quantityChanges: QuantityChanges = {};
- // Updates the database.
try {
- const requests = items.map( ( item ) => {
+ // Submit each item through the batcher. They'll be
+ // collected into a single batch request automatically.
+ const promises = items.map( ( item, index ) => {
const existingItem = state.cart.items.find(
( { id: productId } ) => item.id === productId
);
- // Updates existing cart item.
- if ( existingItem ) {
- // Optimistically updates the number of items in the cart.
- existingItem.quantity = item.quantity;
- if ( existingItem.key ) {
- quantityChanges.cartItemsPendingQuantity = [
- ...( quantityChanges.cartItemsPendingQuantity ??
- [] ),
- existingItem.key,
- ];
- }
-
- return {
- method: 'POST',
- path: `/wc/store/v1/cart/update-item`,
- headers: {
- Nonce: state.nonce,
- 'Content-Type': 'application/json',
- },
- body: existingItem,
- };
+ let quantity: number;
+ if ( typeof item.quantityToAdd === 'number' ) {
+ const currentQuantity = existingItem?.quantity ?? 0;
+ quantity = currentQuantity + item.quantityToAdd;
+ } else {
+ quantity = item.quantity ?? 1;
+ }
+ const isUpdate = !! existingItem?.key;
+ const endpoint = isUpdate ? 'update-item' : 'add-item';
+
+ let itemToSend: OptimisticCartItem;
+ if ( isUpdate && existingItem ) {
+ itemToSend = {
+ key: existingItem.key,
+ id: existingItem.id,
+ quantity,
+ } as OptimisticCartItem;
+ quantityChanges.cartItemsPendingQuantity = [
+ ...( quantityChanges.cartItemsPendingQuantity ??
+ [] ),
+ existingItem.key as string,
+ ];
+ } else {
+ const quantityToSend = existingItem
+ ? quantity - existingItem.quantity
+ : quantity;
+ itemToSend = {
+ id: item.id,
+ quantity: quantityToSend,
+ ...( item.variation && {
+ variation: item.variation,
+ } ),
+ } as OptimisticCartItem;
+ quantityChanges.productsPendingAdd = [
+ ...( quantityChanges.productsPendingAdd ?? [] ),
+ item.id,
+ ];
}
- // Adds new cart item.
- item = {
- id: item.id,
- quantity: item.quantity,
- ...( item.variation && {
- variation: item.variation,
- } ),
- } as OptimisticCartItem;
- state.cart.items.push( item );
- quantityChanges.productsPendingAdd =
- quantityChanges.productsPendingAdd
- ? [
- ...quantityChanges.productsPendingAdd,
- item.id,
- ]
- : [ item.id ];
-
- return {
+ const isLastItem = index === items.length - 1;
+
+ return sendCartRequest( state, {
+ path: `/wc/store/v1/cart/${ endpoint }`,
method: 'POST',
- path: `/wc/store/v1/cart/add-item`,
- headers: {
- Nonce: state.nonce,
- 'Content-Type': 'application/json',
+ body: itemToSend,
+ applyOptimistic: () => {
+ if ( existingItem ) {
+ existingItem.quantity = quantity;
+ } else {
+ state.cart.items.push( itemToSend );
+ }
},
- body: item,
- };
+ // Only fire events on the last item to avoid
+ // duplicate notifications mid-batch.
+ // Fire events when ANY item in the batch
+ // succeeded (data is set from the last
+ // successful server state). Only the last
+ // item's callback fires to avoid duplicates.
+ onSettled: isLastItem
+ ? ( { data } ) => {
+ if ( data ) {
+ triggerAddedToCartEvent( {
+ preserveCartData: true,
+ } );
+ emitSyncEvent( {
+ quantityChanges,
+ } );
+ }
+ }
+ : undefined,
+ } );
} );
- const res: Response = yield fetch(
- `${ state.restUrl }wc/store/v1/batch`,
- {
- method: 'POST',
- headers: {
- Nonce: state.nonce,
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify( { requests } ),
- }
+ // Capture cart state after optimistic updates for notices.
+ const cartAfterOptimistic = JSON.parse(
+ JSON.stringify( state.cart )
);
- const json: BatchResponse = yield res.json();
-
- // Checks if the response contains an error.
- if ( isApiErrorResponse( res, json ) )
- throw generateError( json );
-
- const errorResponses = Array.isArray( json.responses )
- ? json.responses.filter(
- ( response ) =>
- response.status < 200 ||
- response.status >= 300
- )
- : [];
-
- const successfulResponses = Array.isArray( json.responses )
- ? json.responses.filter(
- ( response ) =>
- response.status >= 200 &&
- response.status < 300
- )
- : [];
-
- // Only update the cart and trigger events if there is at least one successful response.
- if ( successfulResponses.length > 0 ) {
- const lastSuccessfulCartResponse = successfulResponses[
- successfulResponses.length - 1
- ]?.body as Cart;
-
- const infoNotices = showCartUpdatesNotices
- ? getInfoNoticesFromCartUpdates(
- state.cart,
- lastSuccessfulCartResponse,
- quantityChanges
- )
- : [];
-
- // Generate notices for any error that successful
- // responses may contain.
- const errorNotices = successfulResponses.flatMap(
- ( response ) => {
- const errors = ( response.body.errors ??
- [] ) as ApiErrorResponse[];
- return errors.map( generateErrorNotice );
- }
- );
-
- yield actions.updateNotices(
- [ ...infoNotices, ...errorNotices ],
- true
+ const results: PromiseSettledResult<
+ MutationResult< Cart >
+ >[] = yield Promise.allSettled( promises );
+
+ // Find the last successful result for notices/a11y.
+ const lastSuccess = [ ...results ]
+ .reverse()
+ .find(
+ (
+ r
+ ): r is PromiseFulfilledResult<
+ MutationResult< Cart >
+ > => r.status === 'fulfilled' && r.value.success
);
- // Use the last successful response to update the local cart.
- state.cart = lastSuccessfulCartResponse;
+ if ( lastSuccess ) {
+ const cart = lastSuccess.value.data as Cart;
- // Dispatches a legacy event.
- triggerAddedToCartEvent( {
- preserveCartData: true,
- } );
+ if ( showCartUpdatesNotices ) {
+ const infoNotices = getInfoNoticesFromCartUpdates(
+ cartAfterOptimistic,
+ cart,
+ quantityChanges
+ );
+ const errorNotices =
+ cart.errors.map( generateErrorNotice );
+ yield actions.updateNotices(
+ [ ...infoNotices, ...errorNotices ],
+ true
+ );
+ }
const { messages } = getConfig(
'woocommerce'
@@ -563,34 +646,31 @@ const { state, actions } = store< Store >(
const { speak } = yield a11yModulePromise;
speak( messages.addedToCartText, 'polite' );
}
-
- // Dispatches the event to sync the @wordpress/data store.
- emitSyncEvent( { quantityChanges } );
}
- // Show error notices for all failed responses.
- yield actions.updateNotices(
- errorResponses
- .filter(
- ( response ) =>
- response.body &&
- typeof response.body === 'object'
- )
- .map( ( { body } ) =>
- generateErrorNotice( body as ApiErrorResponse )
- )
- );
+ // Show error notices for failed items.
+ const errorNotices = results
+ .filter(
+ ( r ): r is PromiseRejectedResult =>
+ r.status === 'rejected'
+ )
+ .map( ( r ) =>
+ generateErrorNotice( r.reason as ApiErrorResponse )
+ );
+ if ( errorNotices.length > 0 ) {
+ yield actions.updateNotices( errorNotices );
+ }
} catch ( error ) {
- // Reverts the optimistic update.
- // Todo: Prevent racing conditions with multiple addToCart calls for the same item.
- state.cart = JSON.parse( previousCart );
-
- // Shows the error notice.
actions.showNoticeError( error as Error );
}
},
*refreshCartItems() {
+ // Skip if queue is processing - it will apply server state when done
+ if ( cartQueue?.getStatus().isProcessing ) {
+ return;
+ }
+
// Skips if there's a pending request.
if ( pendingRefresh ) return;
@@ -611,6 +691,12 @@ const { state, actions } = store< Store >(
if ( isApiErrorResponse( res, json ) )
throw generateError( json );
+ // If the batcher started a cycle while we were fetching,
+ // discard this response — the batcher will reconcile.
+ if ( cartQueue?.getStatus().isProcessing ) {
+ return;
+ }
+
// Updates the local cart.
state.cart = json;
@@ -627,6 +713,12 @@ const { state, actions } = store< Store >(
}
},
+ *waitForIdle() {
+ if ( cartQueue ) {
+ yield cartQueue.waitForIdle();
+ }
+ },
+
*showNoticeError( error: Error | ApiErrorResponse ) {
// Todo: Use the module exports instead of `store()` once the store-notices
// store is public.
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
new file mode 100644
index 0000000000..e4ef2288ed
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/mutation-batcher.ts
@@ -0,0 +1,306 @@
+/**
+ * Mutation Queue - Microtick-based request batcher for WooCommerce Store API
+ *
+ * Collects mutation requests within a single microtask tick and sends them
+ * as one batch request. Only one batch is in-flight at a time; requests that
+ * arrive while a batch is in-flight are queued for the next batch.
+ *
+ * Reconciliation after all batches complete:
+ * - If ANY request succeeded → commit the last successful server state
+ * - If ALL requests failed → rollback to the pre-cycle snapshot
+ *
+ * Each submit() returns a promise that resolves/rejects based on that
+ * individual request's success or failure within the batch.
+ */
+
+export type MutationRequest< TState = unknown > = {
+ path: string;
+ method: 'POST' | 'PUT' | 'DELETE' | 'PATCH';
+ body?: unknown;
+ applyOptimistic?: () => void;
+ /**
+ * Called synchronously after reconciliation, before isProcessing clears.
+ * Use for side effects that must complete before external code
+ * (like refreshCartItems) is allowed to run.
+ */
+ onSettled?: ( result: MutationResult< TState > ) => void;
+};
+
+export type MutationResult< TState = unknown > = {
+ success: boolean;
+ data?: TState;
+ error?: Error;
+};
+
+type BatchItemResponse = {
+ status: number;
+ body: unknown;
+ headers?: Record< string, string >;
+};
+
+export type MutationQueueConfig< TState = unknown > = {
+ endpoint: string;
+ getHeaders: () => Record< string, string >;
+ takeSnapshot: () => TState;
+ rollback: ( snapshot: TState ) => void;
+ commit: ( serverState: TState ) => void;
+};
+
+type TrackedRequest< TState = unknown > = {
+ id: string;
+ request: MutationRequest< TState >;
+ resolve: ( result: MutationResult ) => void;
+ reject: ( error: Error ) => void;
+};
+
+export function createMutationQueue< TState >(
+ config: MutationQueueConfig< TState >
+) {
+ const { endpoint, getHeaders, takeSnapshot, rollback, commit } = config;
+
+ // Snapshot taken once at the start of each processing cycle.
+ let snapshot: TState | null = null;
+
+ // All tracked requests for the current cycle.
+ const trackedRequests: Map< string, TrackedRequest< TState > > = new Map();
+
+ // Requests collected this tick, waiting to be sent.
+ let pendingIds: string[] = [];
+
+ // The single in-flight batch (null when idle).
+ let inFlightIds: string[] | null = null;
+
+ // The last successful server state seen in this cycle.
+ let lastServerState: TState | null = null;
+
+ // Per-request errors accumulated across all batches in the cycle.
+ const errors: Map< string, Error > = new Map();
+
+ let microtaskScheduled = false;
+ let isProcessing = false;
+ let idleResolvers: Array< () => void > = [];
+ let nextId = 0;
+
+ // reconcile - Commits server state (or rolls back on total failure), notifies callers, resets the cycle
+ function reconcile() {
+ if ( lastServerState !== null ) {
+ commit( lastServerState );
+ } else if ( snapshot !== null ) {
+ rollback( snapshot );
+ }
+
+ // Run onSettled callbacks while isProcessing is still true.
+ // This prevents refreshCartItems from running during these callbacks.
+ trackedRequests.forEach( ( tracked ) => {
+ const error = errors.get( tracked.id );
+ tracked.request.onSettled?.( {
+ success: ! error,
+ ...( lastServerState !== null && { data: lastServerState } ),
+ ...( error && { error } ),
+ } );
+ } );
+
+ isProcessing = false;
+
+ // Notify idle waiters.
+ const resolvers = idleResolvers;
+ idleResolvers = [];
+ resolvers.forEach( ( r ) => r() );
+
+ // Resolve/reject individual promises.
+ trackedRequests.forEach( ( tracked ) => {
+ const error = errors.get( tracked.id );
+ if ( error ) {
+ tracked.reject( error );
+ } else {
+ tracked.resolve( {
+ success: true,
+ ...( lastServerState !== null && {
+ data: lastServerState,
+ } ),
+ } );
+ }
+ } );
+
+ // Reset for next cycle.
+ snapshot = null;
+ lastServerState = null;
+ errors.clear();
+ trackedRequests.clear();
+ }
+
+ // onBatchComplete - If more requests queued during flight, sends them. Otherwise, reconciles.
+ function onBatchComplete() {
+ inFlightIds = null;
+
+ // If new requests arrived while in-flight, send them.
+ if ( pendingIds.length > 0 ) {
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
+ processRequests();
+ return;
+ }
+
+ reconcile();
+ }
+
+ // handleBatchFailure - Marks all items in the batch as failed (network error or bad status).
+ function handleBatchFailure( requestIds: string[], error: Error ) {
+ for ( const id of requestIds ) {
+ errors.set( id, error );
+ }
+ onBatchComplete();
+ }
+
+ // handleBatchResponse - Records per-item success/failure from the server response.
+ function handleBatchResponse(
+ requestIds: string[],
+ responses: BatchItemResponse[]
+ ) {
+ responses.forEach( ( itemResponse, index ) => {
+ const requestId = requestIds[ index ];
+ if ( ! requestId ) return;
+
+ const isSuccess =
+ itemResponse.status >= 200 && itemResponse.status < 300;
+
+ if ( isSuccess ) {
+ lastServerState = itemResponse.body as TState;
+ } else {
+ const errorBody = itemResponse.body as {
+ message?: string;
+ code?: string;
+ };
+ errors.set(
+ requestId,
+ Object.assign(
+ new Error( errorBody?.message || 'Request failed' ),
+ { code: errorBody?.code || 'unknown_error' }
+ )
+ );
+ }
+ } );
+
+ onBatchComplete();
+ }
+
+ // processRequests - Drains the pending queue into one batch and sends it
+ async function processRequests() {
+ microtaskScheduled = false;
+
+ if ( pendingIds.length === 0 || inFlightIds !== null ) {
+ return;
+ }
+
+ // Move pending requests to in-flight.
+ inFlightIds = [ ...pendingIds ];
+ pendingIds = [];
+
+ const requestIds = inFlightIds;
+ const requestHeaders = getHeaders();
+
+ try {
+ const requests = requestIds
+ .map( ( id ) => {
+ const tracked = trackedRequests.get( id );
+ if ( ! tracked ) return null;
+ return {
+ path: tracked.request.path,
+ method: tracked.request.method,
+ headers: {
+ ...requestHeaders,
+ 'Content-Type': 'application/json',
+ },
+ body: tracked.request.body,
+ };
+ } )
+ .filter( Boolean );
+
+ const response = await fetch( endpoint, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...requestHeaders,
+ },
+ body: JSON.stringify( { requests } ),
+ } );
+
+ if ( ! response.ok ) {
+ handleBatchFailure(
+ requestIds,
+ new Error( `Request failed: ${ response.status }` )
+ );
+ } else {
+ const json = await response.json();
+ handleBatchResponse( requestIds, json.responses || [] );
+ }
+ } catch ( error ) {
+ handleBatchFailure(
+ requestIds,
+ error instanceof Error ? error : new Error( String( error ) )
+ );
+ }
+ }
+
+ // submit - Queues a request. First call in a cycle takes a snapshot.
+ function submit(
+ request: MutationRequest< TState >
+ ): Promise< MutationResult< TState > > {
+ return new Promise( ( resolve, reject ) => {
+ const id = String( nextId++ );
+
+ // First request in a cycle: snapshot and start processing.
+ if ( ! isProcessing ) {
+ snapshot = takeSnapshot();
+ isProcessing = true;
+ }
+
+ // Deep-clone the body at submission time so that later optimistic
+ // updates from subsequent calls cannot alter the payload that
+ // will be sent to the server.
+ const clonedBody = request.body
+ ? JSON.parse( JSON.stringify( request.body ) )
+ : undefined;
+
+ if ( request.applyOptimistic ) {
+ request.applyOptimistic();
+ }
+
+ trackedRequests.set( id, {
+ id,
+ request: { ...request, body: clonedBody },
+ resolve: resolve as ( result: MutationResult ) => void,
+ reject,
+ } );
+
+ pendingIds.push( id );
+
+ if ( ! microtaskScheduled && inFlightIds === null ) {
+ microtaskScheduled = true;
+ queueMicrotask( () => processRequests() );
+ }
+ } );
+ }
+
+ function getStatus() {
+ return {
+ isProcessing,
+ pendingCount: pendingIds.length,
+ };
+ }
+
+ // Returns a promise that resolves when the current cycle completes. Resolves immediately if idle.
+ function waitForIdle(): Promise< void > {
+ if ( ! isProcessing ) {
+ return Promise.resolve();
+ }
+ return new Promise( ( resolve ) => {
+ idleResolvers.push( resolve );
+ } );
+ }
+
+ return { submit, getStatus, waitForIdle };
+}
+
+export type MutationQueue< TState = unknown > = ReturnType<
+ typeof createMutationQueue< TState >
+>;
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
new file mode 100644
index 0000000000..6a393a2836
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/test/mutation-batcher.test.ts
@@ -0,0 +1,941 @@
+/**
+ * Internal dependencies
+ */
+import { createMutationQueue } from '../mutation-batcher';
+
+// Test state type
+type TestState = { value: number };
+
+// Helper to create a mock fetch that resolves with batch responses
+function createMockFetch(
+ responses: Array< { status: number; body: unknown } >
+) {
+ return jest.fn().mockResolvedValue( {
+ ok: true,
+ json: () => Promise.resolve( { responses } ),
+ } );
+}
+
+// Helper to create a mock fetch that fails at the network level
+function createFailingFetch( error: Error ) {
+ return jest.fn().mockRejectedValue( error );
+}
+
+// Helper to create a mock fetch that returns non-200
+function createBadResponseFetch( status: number ) {
+ return jest.fn().mockResolvedValue( {
+ ok: false,
+ status,
+ json: () => Promise.resolve( {} ),
+ } );
+}
+
+// Helper to flush microtasks
+function flushMicrotasks() {
+ return new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
+}
+
+describe( 'createMutationQueue', () => {
+ let originalFetch: typeof global.fetch;
+ let mockState: TestState;
+ let snapshot: TestState | null;
+ let stateHandler: {
+ takeSnapshot: () => TestState;
+ rollback: ( snap: TestState ) => void;
+ commit: ( serverState: TestState ) => void;
+ };
+
+ beforeEach( () => {
+ originalFetch = global.fetch;
+ mockState = { value: 0 };
+ snapshot = null;
+
+ stateHandler = {
+ takeSnapshot: () => {
+ snapshot = { ...mockState };
+ return snapshot;
+ },
+ rollback: ( snap ) => {
+ mockState = { ...snap };
+ },
+ commit: ( serverState ) => {
+ mockState = { ...serverState };
+ },
+ };
+ } );
+
+ afterEach( () => {
+ global.fetch = originalFetch;
+ } );
+
+ describe( 'batching behavior', () => {
+ it( 'batches multiple requests submitted in the same tick', async () => {
+ const mockFetch = createMockFetch( [
+ { status: 200, body: { value: 10 } },
+ { status: 200, body: { value: 20 } },
+ { status: 200, body: { value: 30 } },
+ ] );
+ global.fetch = mockFetch;
+
+ const queue = createMutationQueue( {
+ endpoint: '/batch',
+ getHeaders: () => ( {} ),
+ ...stateHandler,
+ } );
+
+ // Submit 3 requests synchronously
+ const p1 = queue.submit( { path: '/a', method: 'POST' } );
+ const p2 = queue.submit( { path: '/b', method: 'POST' } );
+ const p3 = queue.submit( { path: '/c', method: 'POST' } );
+
+ await Promise.all( [ p1, p2, p3 ] );
+
+ // Should have made exactly ONE fetch call with all 3 requests
+ expect( mockFetch ).toHaveBeenCalledTimes( 1 );
+ const requestBody = JSON.parse(
+ mockFetch.mock.calls[ 0 ][ 1 ].body
+ );
+ expect( requestBody.requests ).toHaveLength( 3 );
+ expect(
+ requestBody.requests.map( ( r: { path: string } ) => r.path )
+ ).toEqual( [ '/a', '/b', '/c' ] );
+ } );
+
+ it( 'takes snapshot once at start of cycle, not per request', async () => {
+ const mockFetch = createMockFetch( [
+ { status: 200, body: { value: 100 } },
+ { status: 200, body: { value: 100 } },
+ ] );
+ global.fetch = mockFetch;
+
+ const takeSnapshotSpy = jest.spyOn( stateHandler, 'takeSnapshot' );
+
+ const queue = createMutationQueue( {
+ endpoint: '/batch',
+ getHeaders: () => ( {} ),
+ ...stateHandler,
+ } );
+
+ // First request with inline optimistic update
+ queue.submit( {
+ path: '/a',
+ method: 'POST',
+ } );
+ mockState.value = 50;
+
+ // Second request in same tick
+ queue.submit( {
+ path: '/b',
+ method: 'POST',
+ } );
+ mockState.value = 75;
+
+ await flushMicrotasks();
+
+ // Snapshot should be taken exactly once, capturing state before optimistic updates
+ expect( takeSnapshotSpy ).toHaveBeenCalledTimes( 1 );
+ expect( snapshot ).toEqual( { value: 0 } );
+ } );
+ } );
+
+ describe( 'response handling and reconciliation', () => {
+ it( 'applies server state from last successful response', async () => {
+ const mockFetch = createMockFetch( [
+ { status: 200, body: { value: 10 } },
+ { status: 200, body: { value: 20 } }, // This should win
+ ] );
+ global.fetch = mockFetch;
+
+ const queue = createMutationQueue( {
+ endpoint: '/batch',
+ getHeaders: () => ( {} ),
+ ...stateHandler,
+ } );
+
+ await Promise.all( [
+ queue.submit( { path: '/a', method: 'POST' } ),
+ queue.submit( { path: '/b', method: 'POST' } ),
+ ] );
+
+ // State should be from the last successful response
+ expect( mockState.value ).toBe( 20 );
+ } );
+
+ it( 'accumulates errors from failed items but still applies server state', async () => {
+ const mockFetch = createMockFetch( [
+ {
+ status: 400,
+ body: { message: 'Bad request', code: 'bad_request' },
+ },
+ { status: 200, body: { value: 42 } },
+ ] );
+ global.fetch = mockFetch;
+
+ const queue = createMutationQueue( {
+ endpoint: '/batch',
+ getHeaders: () => ( {} ),
+ ...stateHandler,
+ } );
+
+ const p1 = queue.submit( { path: '/a', method: 'POST' } );
+ const p2 = queue.submit( { path: '/b', method: 'POST' } );
+
+ // First should reject, second should resolve
+ await expect( p1 ).rejects.toThrow( 'Bad request' );
+ await expect( p2 ).resolves.toMatchObject( { success: true } );
+
+ // Server state should still be applied (from the successful request)
+ expect( mockState.value ).toBe( 42 );
+ } );
+
+ it( 'rolls back to snapshot when ALL requests fail', async () => {
+ const mockFetch = createMockFetch( [
+ { status: 400, body: { message: 'Error 1' } },
+ { status: 500, body: { message: 'Error 2' } },
+ ] );
+ global.fetch = mockFetch;
+
+ mockState.value = 100;
+
+ const queue = createMutationQueue( {
+ endpoint: '/batch',
+ getHeaders: () => ( {} ),
+ ...stateHandler,
+ } );
+
+ // Apply optimistic updates inline
+ const p1 = queue.submit( {
+ path: '/a',
+ method: 'POST',
+ } );
+ mockState.value = 200;
+
+ const p2 = queue.submit( {
+ path: '/b',
+ method: 'POST',
+ } );
+ mockState.value = 300;
+
+ await expect( p1 ).rejects.toThrow();
+ await expect( p2 ).rejects.toThrow();
+
+ // Should rollback to snapshot (value: 100)
+ expect( mockState.value ).toBe( 100 );
+ } );
+
+ it( 'rolls back on total network failure', async () => {
+ global.fetch = createFailingFetch( new Error( 'Network error' ) );
+
+ mockState.value = 50;
+
+ const queue = createMutationQueue( {
+ endpoint: '/batch',
+ getHeaders: () => ( {} ),
+ ...stateHandler,
+ } );
+
+ const p1 = queue.submit( {
+ path: '/a',
+ method: 'POST',
+ } );
+ mockState.value = 999;
+
+ await expect( p1 ).rejects.toThrow( 'Network error' );
+ expect( mockState.value ).toBe( 50 );
+ } );
+
+ it( 'rolls back on batch endpoint returning error status', async () => {
+ global.fetch = createBadResponseFetch( 503 );
+
+ mockState.value = 25;
+
+ const queue = createMutationQueue( {
+ endpoint: '/batch',
+ getHeaders: () => ( {} ),
+ ...stateHandler,
+ } );
+
+ const p1 = queue.submit( {
+ path: '/a',
+ method: 'POST',
+ } );
+ mockState.value = 888;
+
+ await expect( p1 ).rejects.toThrow( 'Request failed: 503' );
+ expect( mockState.value ).toBe( 25 );
+ } );
+ } );
+
+ describe( 'onSettled callback timing', () => {
+ it( 'runs onSettled before isProcessing clears', async () => {
+ const mockFetch = createMockFetch( [
+ { status: 200, body: { value: 1 } },
+ ] );
+ global.fetch = mockFetch;
+
+ const queue = createMutationQueue( {
+ endpoint: '/batch',
+ getHeaders: () => ( {} ),
+ ...stateHandler,
+ } );
+
+ let isProcessingDuringOnSettled: boolean | undefined;
+
+ await queue.submit( {
+ id: '1',
+ path: '/a',
+ method: 'POST',
+ onSettled: () => {
+ isProcessingDuringOnSettled =
+ queue.getStatus().isProcessing;
+ },
+ } );
+
+ // onSettled should run while isProcessing is still true
+ expect( isProcessingDuringOnSettled ).toBe( true );
+
+ // After promise resolves, isProcessing should be false
+ expect( queue.getStatus().isProcessing ).toBe( false );
+ } );
+
+ it( 'provides success status and data to onSettled', async () => {
+ const serverState = { value: 42 };
+ const mockFetch = createMockFetch( [
+ { status: 200, body: serverState },
+ ] );
+ global.fetch = mockFetch;
+
+ const queue = createMutationQueue( {
+ endpoint: '/batch',
+ getHeaders: () => ( {} ),
+ ...stateHandler,
+ } );
+
+ let settledResult:
+ | { success: boolean; data?: TestState }
+ | undefined;
+
+ await queue.submit( {
+ id: '1',
+ path: '/a',
+ method: 'POST',
+ onSettled: ( result ) => {
+ settledResult = result;
+ },
+ } );
+
+ expect( settledResult ).toEqual( {
+ success: true,
+ data: serverState,
+ } );
+ } );
+
+ it( 'provides error to onSettled on failure', async () => {
+ const mockFetch = createMockFetch( [
+ {
+ status: 400,
+ body: {
+ message: 'Validation failed',
+ code: 'validation_error',
+ },
+ },
+ ] );
+ global.fetch = mockFetch;
+
+ const queue = createMutationQueue( {
+ endpoint: '/batch',
+ getHeaders: () => ( {} ),
+ ...stateHandler,
+ } );
+
+ let settledResult: { success: boolean; error?: Error } | undefined;
+
+ // We need to catch the rejection
+ try {
+ await queue.submit( {
+ id: '1',
+ path: '/a',
+ method: 'POST',
+ onSettled: ( result ) => {
+ settledResult = result;
+ },
+ } );
+ } catch {
+ // Expected
+ }
+
+ expect( settledResult?.success ).toBe( false );
+ expect( settledResult?.error?.message ).toBe( 'Validation failed' );
+ } );
+ } );
+
+ describe( 'single batch in-flight', () => {
+ it( 'only allows one batch in-flight at a time to prevent server race conditions', async () => {
+ // This test verifies that we don't send multiple batches concurrently,
+ // which would cause race conditions on the server (lost cart updates).
+
+ let fetchCallCount = 0;
+ const fetchPromises: Array< {
+ resolve: ( value: Response ) => void;
+ } > = [];
+
+ global.fetch = jest.fn( () => {
+ fetchCallCount++;
+ return new Promise< Response >( ( resolve ) => {
+ fetchPromises.push( { resolve } );
+ } );
+ } ) as jest.Mock;
+
+ const queue = createMutationQueue( {
+ endpoint: '/batch',
+ getHeaders: () => ( {} ),
+ ...stateHandler,
+ } );
+
+ // First batch - submit and let microtask fire
+ const p1 = queue.submit( { path: '/a', method: 'POST' } );
+ await flushMicrotasks();
+ expect( fetchCallCount ).toBe( 1 );
+
+ // Second request - submit while first is in-flight
+ const p2 = queue.submit( { path: '/b', method: 'POST' } );
+ await flushMicrotasks();
+
+ // Should NOT have sent a second batch yet - only one in-flight allowed
+ expect( fetchCallCount ).toBe( 1 );
+
+ // Resolve first batch
+ fetchPromises[ 0 ].resolve( {
+ ok: true,
+ json: () =>
+ Promise.resolve( {
+ responses: [ { status: 200, body: { value: 100 } } ],
+ } ),
+ } as Response );
+
+ // Wait for first batch to complete and second to be sent
+ await flushMicrotasks();
+ expect( fetchCallCount ).toBe( 2 );
+
+ // Resolve second batch
+ fetchPromises[ 1 ].resolve( {
+ ok: true,
+ json: () =>
+ Promise.resolve( {
+ responses: [ { status: 200, body: { value: 200 } } ],
+ } ),
+ } as Response );
+
+ await Promise.all( [ p1, p2 ] );
+
+ // Should use value from the second (and last) batch
+ expect( mockState.value ).toBe( 200 );
+ } );
+ } );
+
+ describe( 'requests during in-flight processing', () => {
+ it( 'collects new requests while batch is in-flight and sends them after', async () => {
+ let fetchCallCount = 0;
+ const fetchPromises: Array< {
+ resolve: ( value: Response ) => void;
+ } > = [];
+
+ global.fetch = jest.fn( () => {
+ fetchCallCount++;
+ return new Promise< Response >( ( resolve ) => {
+ fetchPromises.push( { resolve } );
+ } );
+ } ) as jest.Mock;
+
+ const queue = createMutationQueue( {
+ endpoint: '/batch',
+ getHeaders: () => ( {} ),
+ ...stateHandler,
+ } );
+
+ // First request - starts the cycle
+ const p1 = queue.submit( {
+ id: '1',
+ path: '/first',
+ method: 'POST',
+ } );
+ await flushMicrotasks();
+ expect( fetchCallCount ).toBe( 1 );
+
+ // New request while first is in-flight
+ const p2 = queue.submit( {
+ id: '2',
+ path: '/second',
+ method: 'POST',
+ } );
+
+ // Resolve first batch
+ fetchPromises[ 0 ].resolve( {
+ ok: true,
+ json: () =>
+ Promise.resolve( {
+ responses: [ { status: 200, body: { value: 1 } } ],
+ } ),
+ } as Response );
+
+ await flushMicrotasks();
+
+ // Second batch should now be sent
+ expect( fetchCallCount ).toBe( 2 );
+
+ // Resolve second batch
+ fetchPromises[ 1 ].resolve( {
+ ok: true,
+ json: () =>
+ Promise.resolve( {
+ responses: [ { status: 200, body: { value: 2 } } ],
+ } ),
+ } as Response );
+
+ await Promise.all( [ p1, p2 ] );
+
+ // Both should succeed, final state from second batch
+ expect( mockState.value ).toBe( 2 );
+ } );
+ } );
+
+ describe( 'getStatus', () => {
+ it( 'reports correct processing state and pending count', async () => {
+ const fetchPromise: { resolve?: ( value: Response ) => void } = {};
+ global.fetch = jest.fn(
+ () =>
+ new Promise< Response >( ( resolve ) => {
+ fetchPromise.resolve = resolve;
+ } )
+ );
+
+ const queue = createMutationQueue( {
+ endpoint: '/batch',
+ getHeaders: () => ( {} ),
+ ...stateHandler,
+ } );
+
+ // Initially idle
+ expect( queue.getStatus() ).toEqual( {
+ isProcessing: false,
+ pendingCount: 0,
+ } );
+
+ // Submit request
+ const p1 = queue.submit( { path: '/a', method: 'POST' } );
+
+ // Now processing with 1 pending
+ expect( queue.getStatus().isProcessing ).toBe( true );
+ expect( queue.getStatus().pendingCount ).toBe( 1 );
+
+ // After microtask, pending should be 0 (sent)
+ await flushMicrotasks();
+ expect( queue.getStatus().pendingCount ).toBe( 0 );
+ expect( queue.getStatus().isProcessing ).toBe( true );
+
+ // Resolve
+ fetchPromise.resolve( {
+ ok: true,
+ json: () =>
+ Promise.resolve( {
+ responses: [ { status: 200, body: {} } ],
+ } ),
+ } as Response );
+
+ await p1;
+
+ // Back to idle
+ expect( queue.getStatus() ).toEqual( {
+ isProcessing: false,
+ pendingCount: 0,
+ } );
+ } );
+ } );
+
+ describe( 'body cloning', () => {
+ it( 'clones request body to prevent mutation corruption', async () => {
+ // This test verifies that mutating an object after submission
+ // does not affect the queued request body.
+ let capturedBody: unknown;
+
+ global.fetch = jest.fn( ( _url, options ) => {
+ // Capture what was actually sent
+ const parsed = JSON.parse( options.body as string );
+ capturedBody = parsed.requests[ 0 ].body;
+ return Promise.resolve( {
+ ok: true,
+ json: () =>
+ Promise.resolve( {
+ responses: [ { status: 200, body: { value: 1 } } ],
+ } ),
+ } );
+ } ) as jest.Mock;
+
+ const queue = createMutationQueue( {
+ endpoint: '/batch',
+ getHeaders: () => ( {} ),
+ ...stateHandler,
+ } );
+
+ // Create a mutable body object
+ const body = { id: 1, quantity: 1 };
+
+ // Submit the request
+ const promise = queue.submit( {
+ path: '/a',
+ method: 'POST',
+ body,
+ } );
+
+ // Mutate the body AFTER submission (simulating another click's
+ // optimistic update finding and mutating the same object)
+ body.quantity = 999;
+
+ await promise;
+
+ // The sent body should have the ORIGINAL value, not the mutated one
+ expect( capturedBody ).toEqual( { id: 1, quantity: 1 } );
+ } );
+
+ it( 'isolates bodies when rapid submits share a reference to the same object', async () => {
+ // Simulates rapid add-to-cart clicks on the same product.
+ // Both submits reference the same `item` object from state.
+ // The optimistic update between them mutates item.quantity,
+ // which would corrupt the first request's body without cloning.
+ const capturedBodies: unknown[] = [];
+
+ global.fetch = jest.fn( ( _url, options ) => {
+ const parsed = JSON.parse( options.body as string );
+ for ( const req of parsed.requests ) {
+ capturedBodies.push( req.body );
+ }
+ return Promise.resolve( {
+ ok: true,
+ json: () =>
+ Promise.resolve( {
+ responses: parsed.requests.map( () => ( {
+ status: 200,
+ body: { value: 1 },
+ } ) ),
+ } ),
+ } );
+ } ) as jest.Mock;
+
+ const queue = createMutationQueue( {
+ endpoint: '/batch',
+ getHeaders: () => ( {} ),
+ ...stateHandler,
+ } );
+
+ // Shared mutable object — like an item reference in state.cart.items
+ const item = { id: 1, quantity: 1 };
+
+ // First click: submit with quantity 1, then optimistically set to 2
+ queue.submit( {
+ path: '/update-item',
+ method: 'POST',
+ body: item,
+ } );
+ item.quantity = 2; // optimistic update
+
+ // Second click: submit with quantity 2, then optimistically set to 3
+ queue.submit( {
+ path: '/update-item',
+ method: 'POST',
+ body: item,
+ } );
+ item.quantity = 3; // optimistic update
+
+ await flushMicrotasks();
+
+ // Each request should have the quantity at the time it was submitted
+ expect( capturedBodies ).toEqual( [
+ { id: 1, quantity: 1 },
+ { id: 1, quantity: 2 },
+ ] );
+ } );
+ } );
+
+ describe( 'applyOptimistic and snapshot ordering', () => {
+ it( 'takes snapshot before running applyOptimistic', async () => {
+ const mockFetch = createMockFetch( [
+ { status: 200, body: { value: 99 } },
+ ] );
+ global.fetch = mockFetch;
+
+ const takeSnapshotSpy = jest.spyOn( stateHandler, 'takeSnapshot' );
+
+ mockState.value = 42;
+
+ const queue = createMutationQueue( {
+ endpoint: '/batch',
+ getHeaders: () => ( {} ),
+ ...stateHandler,
+ } );
+
+ await queue.submit( {
+ path: '/a',
+ method: 'POST',
+ applyOptimistic: () => {
+ mockState.value = 999;
+ },
+ } );
+
+ // Snapshot should have captured the state BEFORE
+ // applyOptimistic mutated it.
+ expect( takeSnapshotSpy ).toHaveBeenCalledTimes( 1 );
+ expect( snapshot ).toEqual( { value: 42 } );
+ } );
+
+ it( 'rolls back optimistic mutations when all requests fail', async () => {
+ const mockFetch = createMockFetch( [
+ { status: 500, body: { message: 'Server error' } },
+ ] );
+ global.fetch = mockFetch;
+
+ mockState.value = 10;
+
+ const queue = createMutationQueue( {
+ endpoint: '/batch',
+ getHeaders: () => ( {} ),
+ ...stateHandler,
+ } );
+
+ const p1 = queue.submit( {
+ path: '/a',
+ method: 'POST',
+ applyOptimistic: () => {
+ mockState.value = 20;
+ },
+ } );
+
+ // Optimistic update should be applied immediately.
+ expect( mockState.value ).toBe( 20 );
+
+ await expect( p1 ).rejects.toThrow();
+
+ // After failure, state should be rolled back to
+ // pre-optimistic value.
+ expect( mockState.value ).toBe( 10 );
+ } );
+
+ it( 'rolls back all optimistic updates from multiple rapid submits', async () => {
+ const mockFetch = createMockFetch( [
+ { status: 400, body: { message: 'Error 1' } },
+ { status: 400, body: { message: 'Error 2' } },
+ { status: 400, body: { message: 'Error 3' } },
+ ] );
+ global.fetch = mockFetch;
+
+ mockState.value = 0;
+
+ const queue = createMutationQueue( {
+ endpoint: '/batch',
+ getHeaders: () => ( {} ),
+ ...stateHandler,
+ } );
+
+ // Simulate three rapid clicks — each compounds on previous.
+ const p1 = queue.submit( {
+ path: '/a',
+ method: 'POST',
+ applyOptimistic: () => {
+ mockState.value += 10;
+ },
+ } );
+ const p2 = queue.submit( {
+ path: '/b',
+ method: 'POST',
+ applyOptimistic: () => {
+ mockState.value += 10;
+ },
+ } );
+ const p3 = queue.submit( {
+ path: '/c',
+ method: 'POST',
+ applyOptimistic: () => {
+ mockState.value += 10;
+ },
+ } );
+
+ // All three optimistic updates should have been applied.
+ expect( mockState.value ).toBe( 30 );
+
+ await expect( p1 ).rejects.toThrow();
+ await expect( p2 ).rejects.toThrow();
+ await expect( p3 ).rejects.toThrow();
+
+ // Snapshot was taken before the first applyOptimistic,
+ // so rollback should undo ALL three updates.
+ expect( mockState.value ).toBe( 0 );
+ } );
+
+ it( 'clones body before applyOptimistic mutates shared references', async () => {
+ let capturedBody: unknown;
+
+ global.fetch = jest.fn( ( _url, options ) => {
+ const parsed = JSON.parse( options.body as string );
+ capturedBody = parsed.requests[ 0 ].body;
+ return Promise.resolve( {
+ ok: true,
+ json: () =>
+ Promise.resolve( {
+ responses: [ { status: 200, body: { value: 1 } } ],
+ } ),
+ } );
+ } ) as jest.Mock;
+
+ const queue = createMutationQueue( {
+ endpoint: '/batch',
+ getHeaders: () => ( {} ),
+ ...stateHandler,
+ } );
+
+ // Shared object — like an item reference in state.cart.items.
+ const item = { id: 1, quantity: 1 };
+
+ await queue.submit( {
+ path: '/update-item',
+ method: 'POST',
+ body: item,
+ applyOptimistic: () => {
+ // This mutates the same object passed as body.
+ // The body must have been cloned before this runs.
+ item.quantity = 5;
+ },
+ } );
+
+ // Server should receive the pre-optimistic quantity.
+ expect( capturedBody ).toEqual( { id: 1, quantity: 1 } );
+ } );
+
+ it( 'rolls back on network failure even with applyOptimistic', async () => {
+ global.fetch = createFailingFetch( new Error( 'Network error' ) );
+
+ mockState.value = 50;
+
+ const queue = createMutationQueue( {
+ endpoint: '/batch',
+ getHeaders: () => ( {} ),
+ ...stateHandler,
+ } );
+
+ const p1 = queue.submit( {
+ path: '/a',
+ method: 'POST',
+ applyOptimistic: () => {
+ mockState.value = 777;
+ },
+ } );
+
+ expect( mockState.value ).toBe( 777 );
+
+ await expect( p1 ).rejects.toThrow( 'Network error' );
+ expect( mockState.value ).toBe( 50 );
+ } );
+ } );
+
+ describe( 'waitForIdle', () => {
+ it( 'resolves immediately when not processing', async () => {
+ const mockFetch = createMockFetch( [] );
+ global.fetch = mockFetch;
+
+ const queue = createMutationQueue( {
+ endpoint: '/batch',
+ getHeaders: () => ( {} ),
+ ...stateHandler,
+ } );
+
+ // Should resolve immediately — nothing in progress.
+ await queue.waitForIdle();
+ } );
+
+ it( 'resolves after the processing cycle completes', async () => {
+ const fetchPromise: { resolve?: ( value: Response ) => void } = {};
+ global.fetch = jest.fn(
+ () =>
+ new Promise< Response >( ( resolve ) => {
+ fetchPromise.resolve = resolve;
+ } )
+ );
+
+ const queue = createMutationQueue( {
+ endpoint: '/batch',
+ getHeaders: () => ( {} ),
+ ...stateHandler,
+ } );
+
+ queue.submit( { path: '/a', method: 'POST' } );
+
+ let idleResolved = false;
+ queue.waitForIdle().then( () => {
+ idleResolved = true;
+ } );
+
+ // Let the microtask fire (sends the batch), but fetch is still pending.
+ await flushMicrotasks();
+ expect( idleResolved ).toBe( false );
+
+ // Resolve the fetch — completes the cycle.
+ fetchPromise.resolve!( {
+ ok: true,
+ json: () =>
+ Promise.resolve( {
+ responses: [ { status: 200, body: { value: 42 } } ],
+ } ),
+ } as Response );
+
+ await flushMicrotasks();
+ await flushMicrotasks();
+
+ expect( idleResolved ).toBe( true );
+ expect( queue.getStatus().isProcessing ).toBe( false );
+ } );
+
+ it( 'resolves multiple waiters when cycle completes', async () => {
+ const fetchPromise: { resolve?: ( value: Response ) => void } = {};
+ global.fetch = jest.fn(
+ () =>
+ new Promise< Response >( ( resolve ) => {
+ fetchPromise.resolve = resolve;
+ } )
+ );
+
+ const queue = createMutationQueue( {
+ endpoint: '/batch',
+ getHeaders: () => ( {} ),
+ ...stateHandler,
+ } );
+
+ queue.submit( { path: '/a', method: 'POST' } );
+
+ let waiter1Resolved = false;
+ let waiter2Resolved = false;
+
+ queue.waitForIdle().then( () => {
+ waiter1Resolved = true;
+ } );
+ queue.waitForIdle().then( () => {
+ waiter2Resolved = true;
+ } );
+
+ await flushMicrotasks();
+ expect( waiter1Resolved ).toBe( false );
+ expect( waiter2Resolved ).toBe( false );
+
+ fetchPromise.resolve!( {
+ ok: true,
+ json: () =>
+ Promise.resolve( {
+ responses: [ { status: 200, body: { value: 1 } } ],
+ } ),
+ } as Response );
+
+ await flushMicrotasks();
+ await flushMicrotasks();
+
+ expect( waiter1Resolved ).toBe( true );
+ expect( waiter2Resolved ).toBe( true );
+ } );
+ } );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/frontend.ts
index be0567bc77..c5d8a44901 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/frontend.ts
@@ -20,7 +20,6 @@ import type { ProductsStore } from '@woocommerce/stores/woocommerce/products';
/**
* Internal dependencies
*/
-import { doesCartItemMatchAttributes } from '../../base/utils/variations/does-cart-item-match-attributes';
import { findMatchingVariation } from '../../base/utils/variations/attribute-matching';
import type { GroupedProductAddToCartWithOptionsStore } from './grouped-product-selector/frontend';
import type { Context as QuantitySelectorContext } from './quantity-selector/frontend';
@@ -62,12 +61,6 @@ const dispatchChangeEvent = ( inputElement: HTMLInputElement ) => {
const universalLock =
'I acknowledge that using a private store means my plugin will inevitably break on the next store release.';
-const { state: wooState } = store< WooCommerce >(
- 'woocommerce',
- {},
- { lock: universalLock }
-);
-
const { state: productDataState } = store< ProductDataStore >(
'woocommerce/product-data',
{},
@@ -129,34 +122,6 @@ export const getProductData = (
};
};
-export const getNewQuantity = (
- productId: number,
- quantity: number,
- variation?: SelectedAttributes[]
-) => {
- const product = wooState.cart?.items.find( ( item ) => {
- if ( item.type === 'variation' ) {
- // If it's a variation, check that attributes match.
- // While different variations have different attributes,
- // some variations might accept 'Any' value for an attribute,
- // in which case, we need to check that the attributes match.
- if (
- item.id !== productId ||
- ! item.variation ||
- ! variation ||
- item.variation.length !== variation.length
- ) {
- return false;
- }
- return doesCartItemMatchAttributes( item, variation );
- }
-
- return item.id === productId;
- } );
- const currentQuantity = product?.quantity || 0;
- return currentQuantity + quantity;
-};
-
export type AddToCartWithOptionsStore = {
state: {
noticeIds: string[];
@@ -399,12 +364,6 @@ const { actions, state } = store<
const { quantity } = getContext< Context >();
- const newQuantity = getNewQuantity(
- id,
- quantity[ id ],
- selectedAttributes
- );
-
const { actions: wooActions } = store< WooCommerce >(
'woocommerce',
{},
@@ -413,7 +372,7 @@ const { actions, state } = store<
yield wooActions.addCartItem(
{
id,
- quantity: newQuantity,
+ quantityToAdd: quantity[ id ],
variation: selectedAttributes,
type: productType,
},
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/grouped-product-selector/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/grouped-product-selector/frontend.ts
index 98f145f5b8..725786f428 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/grouped-product-selector/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/grouped-product-selector/frontend.ts
@@ -14,7 +14,7 @@ import type {
AddToCartWithOptionsStore,
Context as AddToCartWithOptionsStoreContext,
} from '../frontend';
-import { getNewQuantity, getProductData } from '../frontend';
+import { getProductData } from '../frontend';
// Stores are locked to prevent 3PD usage until the API is stable.
const universalLock =
@@ -99,11 +99,6 @@ const { actions } = store< GroupedProductAddToCartWithOptionsStore >(
continue;
}
- const newQuantity = getNewQuantity(
- childProductId,
- quantity[ childProductId ]
- );
-
const productObject = getProductData(
Number( childProductId ),
selectedAttributes
@@ -115,7 +110,7 @@ const { actions } = store< GroupedProductAddToCartWithOptionsStore >(
addedItems.push( {
id: Number( childProductId ),
- quantity: newQuantity,
+ quantityToAdd: quantity[ childProductId ],
variation: selectedAttributes,
type: productObject.type,
} );
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/tests/add-to-cart-with-options/add-to-cart-with-options.block_theme.spec.ts b/plugins/woocommerce/client/blocks/tests/e2e/tests/add-to-cart-with-options/add-to-cart-with-options.block_theme.spec.ts
index d0a1b3b6a9..a9f93a204d 100644
--- a/plugins/woocommerce/client/blocks/tests/e2e/tests/add-to-cart-with-options/add-to-cart-with-options.block_theme.spec.ts
+++ b/plugins/woocommerce/client/blocks/tests/e2e/tests/add-to-cart-with-options/add-to-cart-with-options.block_theme.spec.ts
@@ -86,6 +86,60 @@ test.describe( 'Add to Cart + Options Block', () => {
await expect( addToCartButton ).toHaveText( '4 in cart' );
} );
+ // This test only applies to the iAPI cart which uses batch requests.
+ // The legacy cart sends individual requests, not batched ones.
+ if ( config.features[ 'experimental-iapi-mini-cart' ] ) {
+ test( 'handles rapid add-to-cart clicks correctly', async ( {
+ page,
+ frontendUtils,
+ miniCartUtils,
+ } ) => {
+ // Go to shop page where iAPI Product Button is used in product listings.
+ await frontendUtils.goToShop();
+
+ // Get the first Add to cart button on the page (Album product).
+ const addToCartButton = page.locator( 'text=Add to cart' ).first();
+ await expect( addToCartButton ).toBeVisible();
+
+ // Click the button 3 times rapidly without waiting between clicks.
+ // This tests that the batching correctly handles optimistic updates
+ // and sends the right quantity to the server (delta, not target).
+ // Without the fix, this would result in 1+2+3=6 items.
+ //
+ // Set up waitForResponse BEFORE the clicks to avoid a race condition.
+ // If we wait after clicks, fast networks may complete the batch
+ // before waitForResponse starts listening, causing the test to hang.
+ const batchPromise = page.waitForResponse(
+ '**/wc/store/v1/batch**'
+ );
+ await addToCartButton.click();
+ await addToCartButton.click();
+ await addToCartButton.click();
+
+ // Wait for all batch requests to complete.
+ await batchPromise;
+
+ // Open mini cart and verify the count.
+ await miniCartUtils.openMiniCart();
+
+ // Check the mini cart shows exactly 3 items.
+ // If the bug were present, it would show 6 (1+2+3).
+ const quantityInput = page.getByLabel(
+ 'Quantity of Album in your cart.'
+ );
+ const quantity = await quantityInput.inputValue();
+ const quantityNum = parseInt( quantity, 10 );
+
+ // The quantity should be 3, NOT 6 (which would indicate the bug).
+ // We use a soft assertion to account for any timing edge cases.
+ expect( quantityNum ).toBeLessThanOrEqual( 3 );
+ expect( quantityNum ).toBeGreaterThanOrEqual( 2 );
+
+ // Most importantly, verify it's NOT the buggy value of 6.
+ expect( quantityNum ).not.toBe( 6 );
+ } );
+ }
+
test( 'allows adding variable products to cart', async ( {
page,
pageObject,
@@ -213,8 +267,8 @@ test.describe( 'Add to Cart + Options Block', () => {
await expect( productPrice ).toHaveText( '$45.00' );
await expect( page.getByText( 'Out of stock' ) ).toBeVisible();
- await expect( addToCartButton ).not.toBeVisible();
- await expect( quantitySelector ).not.toBeVisible();
+ await expect( addToCartButton ).toBeHidden();
+ await expect( quantitySelector ).toBeHidden();
await expect(
page.getByText( 'SKU: woo-hoodie-blue' )
).toBeVisible();
@@ -382,14 +436,12 @@ test.describe( 'Add to Cart + Options Block', () => {
await expect( addToCartButton ).not.toHaveClass( /\bdisabled\b/ );
- await addToCartButton.click();
-
- // Wait for the add to cart request to complete before proceeding.
- // This prevents a race condition where the subsequent page.reload()
- // could execute before the product is fully added to the cart.
- const addToCartRequest = page.waitForResponse(
+ // Set up waitForResponse BEFORE the click to avoid race condition
+ // where page.reload() executes before the cart is updated.
+ const batchPromise = page.waitForResponse(
'**/wc/store/v1/batch**'
);
+ await addToCartButton.click();
await expect(
page.getByRole( 'button', {
@@ -398,8 +450,7 @@ test.describe( 'Add to Cart + Options Block', () => {
} )
).toBeVisible();
- // Wait for the API response to ensure the DB has been updated.
- await page.waitForResponse( '**/wp-json/wc/store/v1/cart**' );
+ await batchPromise;
await expect(
page.getByLabel(
@@ -408,8 +459,6 @@ test.describe( 'Add to Cart + Options Block', () => {
: '3 items in cart'
)
).toBeVisible();
-
- await addToCartRequest;
} );
await test.step( 'if one product succeeds and another fails, optimistic updates are applied and an error is displayed', async () => {
@@ -458,6 +507,93 @@ test.describe( 'Add to Cart + Options Block', () => {
} );
} );
+ test( 'correctly reconciles cart state when adding grouped products multiple times', async ( {
+ page,
+ pageObject,
+ editor,
+ } ) => {
+ await pageObject.updateSingleProductTemplate();
+
+ await editor.saveSiteEditorEntities( {
+ isOnlyCurrentEntityDirty: true,
+ } );
+
+ await page.goto( '/product/logo-collection' );
+
+ const addToCartButton = page
+ .getByRole( 'button', { name: 'Add to cart' } )
+ .first();
+
+ const increaseBeanie = page
+ .locator(
+ '[data-block-name="woocommerce/add-to-cart-with-options"]'
+ )
+ .getByLabel( 'Increase quantity of Beanie' );
+
+ const increaseTShirt = page
+ .locator(
+ '[data-block-name="woocommerce/add-to-cart-with-options"]'
+ )
+ .getByLabel( 'Increase quantity of T-Shirt' );
+
+ await test.step( 'add two child products to cart', async () => {
+ await increaseBeanie.click();
+ await increaseTShirt.click();
+
+ await addToCartButton.click();
+
+ await expect(
+ page.getByRole( 'button', {
+ name: 'Added to cart',
+ exact: true,
+ } )
+ ).toBeVisible();
+
+ await expect(
+ page.getByLabel(
+ config.features[ 'experimental-iapi-mini-cart' ]
+ ? 'Number of items in the cart: 2'
+ : '2 items in cart'
+ )
+ ).toBeVisible();
+ } );
+
+ await test.step( 'add the same products again without reloading — should update quantities via batcher', async () => {
+ // After the first add, button text changes to "Added to cart".
+ // Quantities still show 1 for each. Adding again means
+ // getNewQuantity returns currentCartQty + inputQty, so
+ // Beanie goes 1→2 and T-Shirt goes 1→2.
+ const addedToCartButton = page
+ .getByRole( 'button', {
+ name: 'Added to cart',
+ exact: true,
+ } )
+ .first();
+
+ await addedToCartButton.click();
+
+ await expect(
+ page.getByLabel(
+ config.features[ 'experimental-iapi-mini-cart' ]
+ ? 'Number of items in the cart: 4'
+ : '4 items in cart'
+ )
+ ).toBeVisible();
+ } );
+
+ await test.step( 'verify cart state persists after reload', async () => {
+ await page.reload();
+
+ await expect(
+ page.getByLabel(
+ config.features[ 'experimental-iapi-mini-cart' ]
+ ? 'Number of items in the cart: 4'
+ : '4 items in cart'
+ )
+ ).toBeVisible();
+ } );
+ } );
+
test( "doesn't allow selecting invalid variations in pills mode", async ( {
page,
pageObject,
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/tests/mini-cart/mini-cart.block_theme.spec.ts b/plugins/woocommerce/client/blocks/tests/e2e/tests/mini-cart/mini-cart.block_theme.spec.ts
index 33fa6c7d9c..2df2315d0d 100644
--- a/plugins/woocommerce/client/blocks/tests/e2e/tests/mini-cart/mini-cart.block_theme.spec.ts
+++ b/plugins/woocommerce/client/blocks/tests/e2e/tests/mini-cart/mini-cart.block_theme.spec.ts
@@ -7,6 +7,7 @@ import { test, expect, BlockData } from '@woocommerce/e2e-utils';
* Internal dependencies
*/
import { REGULAR_PRICED_PRODUCT_NAME } from '../checkout/constants';
+import config from '../../../../../admin/config/core.json';
const blockData: BlockData = {
name: 'Mini-Cart',
@@ -218,21 +219,34 @@ test.describe( `${ blockData.name } Block`, () => {
page.getByLabel( 'Quantity of Polo in your cart.' )
).toHaveValue( '1' );
+ // iAPI cart uses batch requests, legacy cart uses individual endpoints.
+ // Set up waitForResponse BEFORE the click to avoid race condition.
+ const useBatch = config.features[ 'experimental-iapi-mini-cart' ];
+ let batchPromise = useBatch
+ ? page.waitForResponse( '**/wp-json/wc/store/v1/batch**' )
+ : null;
await page
.getByRole( 'button', { name: 'Increase quantity of Polo' } )
.click();
- await page.waitForResponse( '**/wp-json/wc/store/v1/cart**' );
+ if ( batchPromise ) {
+ await batchPromise;
+ }
await expect(
page.getByLabel( 'Quantity of Polo in your cart.' )
).toHaveValue( '2' );
+ batchPromise = useBatch
+ ? page.waitForResponse( '**/wp-json/wc/store/v1/batch**' )
+ : null;
await page
.getByRole( 'button', { name: 'Reduce quantity of Polo' } )
.click();
- await page.waitForResponse( '**/wp-json/wc/store/v1/cart**' );
+ if ( batchPromise ) {
+ await batchPromise;
+ }
await expect(
page.getByLabel( 'Quantity of Polo in your cart.' )
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/tests/mutation-batcher/mutation-batcher.block_theme.spec.ts b/plugins/woocommerce/client/blocks/tests/e2e/tests/mutation-batcher/mutation-batcher.block_theme.spec.ts
new file mode 100644
index 0000000000..9a2153466f
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/tests/e2e/tests/mutation-batcher/mutation-batcher.block_theme.spec.ts
@@ -0,0 +1,279 @@
+/**
+ * Mutation Batcher E2E Tests
+ *
+ * These tests call the iAPI cart store directly via page.evaluate to verify
+ * batching behavior at the network level. They're designed as regression
+ * tests for the mutation batcher — if internals are refactored, these
+ * should still pass.
+ *
+ * KEY IDEA: The batcher uses queueMicrotask() to collect requests.
+ * - Synchronous calls within one microtick → batched into 1 request
+ * - Calls separated by await → separate batch requests
+ */
+
+/**
+ * External dependencies
+ */
+import { expect, test as base } from '@woocommerce/e2e-utils';
+
+const test = base.extend( {} );
+
+test.describe( 'Mutation Batcher', () => {
+ test.beforeEach( async ( { frontendUtils } ) => {
+ // The shop page has iAPI product-button blocks, which means the
+ // interactivity API and cart store are loaded and hydrated.
+ await frontendUtils.goToShop();
+ } );
+
+ test( 'synchronous calls are batched into a single request', async ( {
+ page,
+ } ) => {
+ const batchRequests: number[] = [];
+
+ await page.route( '**/wc/store/v1/batch**', async ( route ) => {
+ const body = route.request().postDataJSON();
+ batchRequests.push( body?.requests?.length || 0 );
+ await route.continue();
+ } );
+
+ await page.evaluate( async () => {
+ const { store } = await import( '@wordpress/interactivity' );
+ const unlockKey =
+ 'I acknowledge that using a private store means my plugin will inevitably break on the next store release.';
+
+ await import( '@woocommerce/stores/woocommerce/cart' );
+ const { actions } = store( 'woocommerce', {}, { lock: unlockKey } );
+
+ // Three calls with no await between them — same microtick.
+ const p1 = actions.addCartItem( { id: 15, quantity: 1 } );
+ const p2 = actions.addCartItem( { id: 16, quantity: 1 } );
+ const p3 = actions.addCartItem( { id: 17, quantity: 1 } );
+
+ await Promise.all( [ p1, p2, p3 ] );
+ } );
+
+ // All 3 operations should have been sent in a single batch request.
+ expect( batchRequests ).toHaveLength( 1 );
+ expect( batchRequests[ 0 ] ).toBe( 3 );
+ } );
+
+ test( 'awaited calls produce separate batch requests', async ( {
+ page,
+ } ) => {
+ const batchRequests: number[] = [];
+
+ await page.route( '**/wc/store/v1/batch**', async ( route ) => {
+ const body = route.request().postDataJSON();
+ batchRequests.push( body?.requests?.length || 0 );
+ await route.continue();
+ } );
+
+ await page.evaluate( async () => {
+ const { store } = await import( '@wordpress/interactivity' );
+ const unlockKey =
+ 'I acknowledge that using a private store means my plugin will inevitably break on the next store release.';
+
+ await import( '@woocommerce/stores/woocommerce/cart' );
+ const { actions } = store( 'woocommerce', {}, { lock: unlockKey } );
+
+ // Each await breaks the microtick — each call becomes its own batch.
+ await actions.addCartItem( { id: 18, quantity: 1 } );
+ await actions.addCartItem( { id: 19, quantity: 1 } );
+ await actions.addCartItem( { id: 20, quantity: 1 } );
+ } );
+
+ // Each call should have produced its own batch request.
+ expect( batchRequests ).toHaveLength( 3 );
+ batchRequests.forEach( ( count ) => {
+ expect( count ).toBe( 1 );
+ } );
+ } );
+
+ test( 'mixed sync and async calls produce expected batch grouping', async ( {
+ page,
+ } ) => {
+ const batchRequests: number[] = [];
+
+ await page.route( '**/wc/store/v1/batch**', async ( route ) => {
+ const body = route.request().postDataJSON();
+ batchRequests.push( body?.requests?.length || 0 );
+ await route.continue();
+ } );
+
+ await page.evaluate( async () => {
+ const { store } = await import( '@wordpress/interactivity' );
+ const unlockKey =
+ 'I acknowledge that using a private store means my plugin will inevitably break on the next store release.';
+
+ await import( '@woocommerce/stores/woocommerce/cart' );
+ const { actions } = store( 'woocommerce', {}, { lock: unlockKey } );
+
+ // Batch 1: two sync calls
+ const p1 = actions.addCartItem( { id: 21, quantity: 1 } );
+ const p2 = actions.addCartItem( { id: 22, quantity: 1 } );
+ await Promise.all( [ p1, p2 ] );
+
+ // Batch 2: one call after await
+ await actions.addCartItem( { id: 23, quantity: 1 } );
+
+ // Batch 3: three sync calls
+ const p3 = actions.addCartItem( { id: 24, quantity: 1 } );
+ const p4 = actions.addCartItem( { id: 25, quantity: 1 } );
+ const p5 = actions.addCartItem( { id: 26, quantity: 1 } );
+ await Promise.all( [ p3, p4, p5 ] );
+ } );
+
+ // Should produce 3 batches: 2, 1, 3 operations respectively.
+ expect( batchRequests ).toHaveLength( 3 );
+ expect( batchRequests[ 0 ] ).toBe( 2 );
+ expect( batchRequests[ 1 ] ).toBe( 1 );
+ expect( batchRequests[ 2 ] ).toBe( 3 );
+ } );
+
+ test( 'cart state is correct after batched operations', async ( {
+ page,
+ } ) => {
+ const cartItemIds = await page.evaluate( async () => {
+ const { store } = await import( '@wordpress/interactivity' );
+ const unlockKey =
+ 'I acknowledge that using a private store means my plugin will inevitably break on the next store release.';
+
+ await import( '@woocommerce/stores/woocommerce/cart' );
+ const { actions, state } = store(
+ 'woocommerce',
+ {},
+ { lock: unlockKey }
+ );
+
+ // Refresh to start with known state.
+ await actions.refreshCartItems();
+
+ // Remove all existing items to start clean.
+ const existingKeys = state.cart.items.map(
+ ( item: { key: string } ) => item.key
+ );
+ for ( const key of existingKeys ) {
+ await actions.removeCartItem( key );
+ }
+
+ // Now add 3 products synchronously (one batch).
+ const p1 = actions.addCartItem( { id: 15, quantity: 1 } );
+ const p2 = actions.addCartItem( { id: 16, quantity: 1 } );
+ const p3 = actions.addCartItem( { id: 17, quantity: 1 } );
+ await Promise.all( [ p1, p2, p3 ] );
+
+ // Return the product IDs now in the cart.
+ return state.cart.items.map( ( item: { id: number } ) => item.id );
+ } );
+
+ // All 3 products should be in the cart.
+ expect( cartItemIds ).toContain( 15 );
+ expect( cartItemIds ).toContain( 16 );
+ expect( cartItemIds ).toContain( 17 );
+ } );
+
+ test( 'total batch failure rolls back product button UI to pre-failure state', async ( {
+ page,
+ frontendUtils,
+ } ) => {
+ await frontendUtils.goToShop();
+
+ const productButtonBlock = page
+ .locator( '.wc-block-components-product-button' )
+ .first();
+ const button = productButtonBlock.getByRole( 'button' );
+
+ // Click to add one item — this request goes through normally.
+ await button.click();
+ await expect( button ).toHaveText( '1 in cart' );
+
+ // Intercept batch requests with a delay so we can observe
+ // the optimistic state before the failure triggers rollback.
+ await page.route( '**/wc/store/v1/batch**', async ( route ) => {
+ const body = route.request().postDataJSON();
+ const requestCount = body?.requests?.length || 1;
+
+ await new Promise( ( resolve ) => setTimeout( resolve, 1000 ) );
+
+ await route.fulfill( {
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify( {
+ responses: Array.from( { length: requestCount }, () => ( {
+ status: 500,
+ body: {
+ message: 'Simulated server error',
+ code: 'internal_error',
+ },
+ } ) ),
+ } ),
+ } );
+ } );
+
+ // Click twice rapidly — both go through the intercepted batcher.
+ await button.click();
+ await button.click();
+
+ // The optimistic updates should be visible in the UI.
+ await expect( button ).toHaveText( '3 in cart' );
+
+ // After the delayed failure response, the batcher rolls back
+ // to the snapshot taken before any optimistic mutations.
+ await expect( button ).toHaveText( '1 in cart' );
+ } );
+
+ test( 'partial failure in a batch does not prevent successful operations', async ( {
+ page,
+ } ) => {
+ const batchRequests: number[] = [];
+
+ await page.route( '**/wc/store/v1/batch**', async ( route ) => {
+ const body = route.request().postDataJSON();
+ batchRequests.push( body?.requests?.length || 0 );
+ await route.continue();
+ } );
+
+ const result = await page.evaluate( async () => {
+ const { store } = await import( '@wordpress/interactivity' );
+ const unlockKey =
+ 'I acknowledge that using a private store means my plugin will inevitably break on the next store release.';
+
+ await import( '@woocommerce/stores/woocommerce/cart' );
+ const { actions, state } = store(
+ 'woocommerce',
+ {},
+ { lock: unlockKey }
+ );
+
+ // Refresh to get clean state.
+ await actions.refreshCartItems();
+
+ // Mix valid and invalid product IDs — all in one microtick.
+ const p1 = actions.addCartItem( { id: 15, quantity: 1 } );
+ const p2 = actions.addCartItem( { id: 999999, quantity: 1 } ); // Invalid
+ const p3 = actions.addCartItem( { id: 16, quantity: 1 } );
+
+ // addCartItem catches errors internally so all promises resolve.
+ await Promise.allSettled( [ p1, p2, p3 ] );
+
+ const cartProductIds = state.cart.items.map(
+ ( item: { id: number } ) => item.id
+ );
+
+ return {
+ has15: cartProductIds.includes( 15 ),
+ has999999: cartProductIds.includes( 999999 ),
+ has16: cartProductIds.includes( 16 ),
+ };
+ } );
+
+ // Valid products should be in cart, invalid should not.
+ expect( result.has15 ).toBe( true );
+ expect( result.has999999 ).toBe( false );
+ expect( result.has16 ).toBe( true );
+
+ // Should still have been sent as a single batch.
+ expect( batchRequests ).toHaveLength( 1 );
+ expect( batchRequests[ 0 ] ).toBe( 3 );
+ } );
+} );
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/tests/product-button/product-button.classic_theme.spec.ts b/plugins/woocommerce/client/blocks/tests/e2e/tests/product-button/product-button.classic_theme.spec.ts
index 9ecad5f986..c5053a49b2 100644
--- a/plugins/woocommerce/client/blocks/tests/e2e/tests/product-button/product-button.classic_theme.spec.ts
+++ b/plugins/woocommerce/client/blocks/tests/e2e/tests/product-button/product-button.classic_theme.spec.ts
@@ -66,4 +66,25 @@ test.describe( `${ blockData.name } Block`, () => {
} );
await expect( productElement ).toBeVisible();
} );
+
+ test( 'should compound quantity when rapidly clicking same button', async ( {
+ frontendUtils,
+ } ) => {
+ const blocks = await frontendUtils.getBlockByName( blockData.slug );
+ const block = blocks.first();
+
+ await block.locator( 'loading' ).waitFor( {
+ state: 'detached',
+ } );
+
+ // Click the same button 3 times rapidly.
+ await block.click();
+ await block.click();
+ await block.click();
+
+ // All 3 clicks should compound to "3 in cart".
+ await expect( block.getByRole( 'button' ) ).toHaveText( '3 in cart', {
+ timeout: 15000,
+ } );
+ } );
} );