Commit 7ca98179e8 for woocommerce
commit 7ca98179e8334a25b060f729665a49011410eeb6
Author: Thomas Roberts <5656702+opr@users.noreply.github.com>
Date: Fri Jan 16 16:58:33 2026 +0000
Prevent inconsistent and flaky quantity updates in Cart block (#62788)
* Add abort controllers to quantity change requests
* Prevent batching for quantity update requests
* Prevent quantity sync from Redux when API call is pending
* Add test for thunk changes
* Add changelog
* Allow other quantities that were updated during the API call to persist
* Fix lint and TS issue
* Fix lint issue
diff --git a/plugins/woocommerce/changelog/wooplug-5076-quantity-qty-selector-in-new-block-cart-inconsistentflaky b/plugins/woocommerce/changelog/wooplug-5076-quantity-qty-selector-in-new-block-cart-inconsistentflaky
new file mode 100644
index 0000000000..a48cf5e6ea
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-5076-quantity-qty-selector-in-new-block-cart-inconsistentflaky
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix cart quantity selector briefly reverting to previous value when changing quantity rapidly.
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/cart/use-store-cart-item-quantity.ts b/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/cart/use-store-cart-item-quantity.ts
index 852fac43a5..b9884d4f17 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/cart/use-store-cart-item-quantity.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/cart/use-store-cart-item-quantity.ts
@@ -66,9 +66,6 @@ export const useStoreCartItemQuantity = (
const { removeItemFromCart, changeCartItemQuantity } =
useDispatch( cartStore );
- // Update local state when server updates.
- useEffect( () => setQuantity( cartItemQuantity ), [ cartItemQuantity ] );
-
// Track when things are already pending updates.
const isPending = useSelect(
( select ) => {
@@ -87,6 +84,36 @@ export const useStoreCartItemQuantity = (
[ cartItemKey ]
);
+ // Update local state when server updates, but only if:
+ // 1. User hasn't made a change waiting to be debounced
+ // 2. No API request is currently in flight
+ // 3. No API call about to fire (debounce just caught up but thunk hasn't started)
+ // This prevents stale API responses from overwriting user's pending changes,
+ // while still allowing server-initiated changes (e.g., bundled products) to sync.
+ useEffect( () => {
+ const hasPendingLocalChange = quantity !== debouncedQuantity;
+ const hasInflightRequest = isPending.quantity;
+ // Only block if debounce JUST caught up and differs from server (about to fire API)
+ // If debounce didn't change, server must have changed cartItemQuantity → allow sync
+ const debouncedJustChanged =
+ debouncedQuantity !== previousDebouncedQuantity;
+ const aboutToFireApiCall =
+ debouncedJustChanged && debouncedQuantity !== cartItemQuantity;
+ if (
+ ! hasPendingLocalChange &&
+ ! hasInflightRequest &&
+ ! aboutToFireApiCall
+ ) {
+ setQuantity( cartItemQuantity );
+ }
+ }, [
+ cartItemQuantity,
+ quantity,
+ debouncedQuantity,
+ previousDebouncedQuantity,
+ isPending.quantity,
+ ] );
+
const removeItem = useCallback( () => {
if ( cartItemKey ) {
return removeItemFromCart( cartItemKey ).catch( ( error ) => {
diff --git a/plugins/woocommerce/client/blocks/assets/js/data/cart/test/thunks.ts b/plugins/woocommerce/client/blocks/assets/js/data/cart/test/thunks.ts
new file mode 100644
index 0000000000..f2196b0fb8
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/data/cart/test/thunks.ts
@@ -0,0 +1,249 @@
+/**
+ * Internal dependencies
+ */
+import { changeCartItemQuantity } from '../thunks';
+import { apiFetchWithHeaders } from '../../shared-controls';
+
+jest.mock( '../../shared-controls', () => ( {
+ apiFetchWithHeaders: jest.fn(),
+} ) );
+
+const mockApiFetchWithHeaders = apiFetchWithHeaders as jest.MockedFunction<
+ typeof apiFetchWithHeaders
+>;
+
+describe( 'changeCartItemQuantity', () => {
+ const createMockDispatchAndSelect = (
+ cartItems: Record< string, number >
+ ) => {
+ const mockDispatch = {
+ receiveCart: jest.fn(),
+ receiveError: jest.fn(),
+ itemIsPendingQuantity: jest.fn(),
+ };
+
+ const mockSelect = {
+ getCartItem: jest.fn( ( key: string ) => {
+ if ( key in cartItems ) {
+ return { quantity: cartItems[ key ] };
+ }
+ return null;
+ } ),
+ };
+
+ return { dispatch: mockDispatch, select: mockSelect };
+ };
+
+ beforeEach( () => {
+ jest.clearAllMocks();
+ } );
+
+ it( 'should not make API call if quantity is unchanged', async () => {
+ const { dispatch, select } = createMockDispatchAndSelect( {
+ 'item-1': 5,
+ } );
+
+ await changeCartItemQuantity(
+ 'item-1',
+ 5
+ )( { dispatch, select } as never );
+
+ expect( mockApiFetchWithHeaders ).not.toHaveBeenCalled();
+ expect( dispatch.itemIsPendingQuantity ).not.toHaveBeenCalled();
+ } );
+
+ it( 'should make API call when quantity changes', async () => {
+ const { dispatch, select } = createMockDispatchAndSelect( {
+ 'item-1': 1,
+ } );
+
+ mockApiFetchWithHeaders.mockResolvedValueOnce( {
+ response: { items: [ { key: 'item-1', quantity: 5 } ] },
+ } );
+
+ await changeCartItemQuantity(
+ 'item-1',
+ 5
+ )( { dispatch, select } as never );
+
+ expect( mockApiFetchWithHeaders ).toHaveBeenCalledTimes( 1 );
+ expect( mockApiFetchWithHeaders ).toHaveBeenCalledWith(
+ expect.objectContaining( {
+ path: '/wc/store/v1/cart/update-item',
+ method: 'POST',
+ data: {
+ key: 'item-1',
+ quantity: 5,
+ },
+ signal: expect.any( AbortSignal ),
+ } )
+ );
+ expect( dispatch.itemIsPendingQuantity ).toHaveBeenCalledWith(
+ 'item-1'
+ );
+ expect( dispatch.itemIsPendingQuantity ).toHaveBeenCalledWith(
+ 'item-1',
+ false
+ );
+ } );
+
+ it( 'should abort previous request when same item quantity changes again', async () => {
+ const cartItems: Record< string, number > = { 'item-1': 1 };
+
+ const mockDispatch = {
+ receiveCart: jest.fn(),
+ receiveError: jest.fn(),
+ itemIsPendingQuantity: jest.fn(),
+ };
+
+ const mockSelect = {
+ getCartItem: jest.fn( ( key: string ) => {
+ if ( key in cartItems ) {
+ return { quantity: cartItems[ key ] };
+ }
+ return null;
+ } ),
+ };
+
+ // Track abort signals
+ const abortSignals: AbortSignal[] = [];
+
+ // First request is slow
+ mockApiFetchWithHeaders.mockImplementation(
+ ( options: { signal?: AbortSignal } ) => {
+ if ( options.signal ) {
+ abortSignals.push( options.signal );
+ }
+ return new Promise( ( resolve, reject ) => {
+ // Check if already aborted
+ if ( options.signal?.aborted ) {
+ const error = new DOMException(
+ 'Aborted',
+ 'AbortError'
+ );
+ reject( error );
+ return;
+ }
+ // Listen for abort
+ options.signal?.addEventListener( 'abort', () => {
+ const error = new DOMException(
+ 'Aborted',
+ 'AbortError'
+ );
+ reject( error );
+ } );
+ // Resolve after delay if not aborted
+ setTimeout( () => {
+ resolve( {
+ response: {
+ items: [ { key: 'item-1', quantity: 5 } ],
+ },
+ } );
+ }, 100 );
+ } );
+ }
+ );
+
+ // Start first request (1→5)
+ const promise1 = changeCartItemQuantity(
+ 'item-1',
+ 5
+ )( { dispatch: mockDispatch, select: mockSelect } as never );
+
+ // Start second request before first completes (should abort first)
+ const promise2 = changeCartItemQuantity(
+ 'item-1',
+ 10
+ )( { dispatch: mockDispatch, select: mockSelect } as never );
+
+ await Promise.all( [ promise1, promise2 ] );
+
+ // First signal should be aborted
+ expect( abortSignals[ 0 ].aborted ).toBe( true );
+ // Second signal should not be aborted
+ expect( abortSignals[ 1 ].aborted ).toBe( false );
+
+ // receiveCart should only be called once (for the second request)
+ expect( mockDispatch.receiveCart ).toHaveBeenCalledTimes( 1 );
+ // receiveError should NOT be called for aborted requests
+ expect( mockDispatch.receiveError ).not.toHaveBeenCalled();
+ } );
+
+ it( 'should not abort requests for different items', async () => {
+ const cartItems: Record< string, number > = {
+ 'item-a': 1,
+ 'item-b': 1,
+ };
+
+ const mockDispatch = {
+ receiveCart: jest.fn(),
+ receiveError: jest.fn(),
+ itemIsPendingQuantity: jest.fn(),
+ };
+
+ const mockSelect = {
+ getCartItem: jest.fn( ( key: string ) => {
+ if ( key in cartItems ) {
+ return { quantity: cartItems[ key ] };
+ }
+ return null;
+ } ),
+ };
+
+ const abortSignals: AbortSignal[] = [];
+
+ mockApiFetchWithHeaders.mockImplementation(
+ ( options: { signal?: AbortSignal } ) => {
+ if ( options.signal ) {
+ abortSignals.push( options.signal );
+ }
+ return Promise.resolve( {
+ response: { items: [] },
+ } );
+ }
+ );
+
+ // Change different items
+ const promise1 = changeCartItemQuantity(
+ 'item-a',
+ 5
+ )( { dispatch: mockDispatch, select: mockSelect } as never );
+ const promise2 = changeCartItemQuantity(
+ 'item-b',
+ 3
+ )( { dispatch: mockDispatch, select: mockSelect } as never );
+
+ await Promise.all( [ promise1, promise2 ] );
+
+ // Neither should be aborted - they're different items
+ expect( abortSignals[ 0 ].aborted ).toBe( false );
+ expect( abortSignals[ 1 ].aborted ).toBe( false );
+
+ // Both should complete
+ expect( mockApiFetchWithHeaders ).toHaveBeenCalledTimes( 2 );
+ expect( mockDispatch.receiveCart ).toHaveBeenCalledTimes( 2 );
+ } );
+
+ it( 'should handle API errors', async () => {
+ const { dispatch, select } = createMockDispatchAndSelect( {
+ 'item-1': 1,
+ } );
+
+ mockApiFetchWithHeaders.mockRejectedValueOnce(
+ new Error( 'Network error' )
+ );
+
+ await expect(
+ changeCartItemQuantity(
+ 'item-1',
+ 5
+ )( { dispatch, select } as never )
+ ).rejects.toThrow( 'Network error' );
+
+ expect( dispatch.receiveError ).toHaveBeenCalledTimes( 1 );
+ expect( dispatch.itemIsPendingQuantity ).toHaveBeenCalledWith(
+ 'item-1',
+ false
+ );
+ } );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/data/cart/thunks.ts b/plugins/woocommerce/client/blocks/assets/js/data/cart/thunks.ts
index aa7230fcc5..4ff460d51f 100644
--- a/plugins/woocommerce/client/blocks/assets/js/data/cart/thunks.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/data/cart/thunks.ts
@@ -443,10 +443,16 @@ export const removeItemFromCart =
};
/**
- * Persists a quantity change the for specified cart item:
+ * Tracks AbortControllers per cart item for cancelling in-flight quantity requests.
+ */
+const quantityAbortControllers = new Map< string, AbortController >();
+
+/**
+ * Persists a quantity change for the specified cart item:
+ * - Aborts any in-flight request for the same item.
* - Calls API to set quantity.
* - If successful, yields action to update store.
- * - If error, yields action to store error.
+ * - If error (except AbortError), yields action to store error.
*
* @param {string} cartItemKey Cart item being updated.
* @param {number} quantity Specified (new) quantity.
@@ -462,6 +468,22 @@ export const changeCartItemQuantity =
if ( cartItem?.quantity === quantity ) {
return;
}
+
+ // Abort any existing in-flight request for this item.
+ const existingController = quantityAbortControllers.get( cartItemKey );
+ if ( existingController ) {
+ existingController.abort();
+ }
+
+ // Create new AbortController for this request.
+ const abortController =
+ typeof AbortController === 'undefined'
+ ? null
+ : new AbortController();
+ if ( abortController ) {
+ quantityAbortControllers.set( cartItemKey, abortController );
+ }
+
try {
dispatch.itemIsPendingQuantity( cartItemKey );
const { response } = await apiFetchWithHeaders< {
@@ -474,18 +496,33 @@ export const changeCartItemQuantity =
quantity,
},
cache: 'no-store',
+ signal: abortController?.signal ?? null,
} );
+
dispatch.receiveCart( response );
return response;
} catch ( error ) {
+ // Don't treat aborted requests as errors - they were intentionally cancelled.
+ if (
+ error instanceof DOMException &&
+ error.name === 'AbortError'
+ ) {
+ return;
+ }
dispatch.receiveError( isApiErrorResponse( error ) ? error : null );
return Promise.reject( error );
} finally {
+ // Clean up controller if it's still the current one for this item.
+ if (
+ quantityAbortControllers.get( cartItemKey ) === abortController
+ ) {
+ quantityAbortControllers.delete( cartItemKey );
+ }
dispatch.itemIsPendingQuantity( cartItemKey, false );
}
};
-// Facilitates aborting fetch requests.
+// Facilitates aborting fetch requests for shipping rate selection.
let abortController: AbortController | null = null;
/**
diff --git a/plugins/woocommerce/client/blocks/assets/js/data/shared-controls.ts b/plugins/woocommerce/client/blocks/assets/js/data/shared-controls.ts
index 1a13b2727d..08c36e0742 100644
--- a/plugins/woocommerce/client/blocks/assets/js/data/shared-controls.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/data/shared-controls.ts
@@ -129,6 +129,7 @@ export const apiFetchWithHeadersControl = ( options: APIFetchOptions ) =>
const preventBatching = [
'/wc/store/v1/checkout',
'/wc/store/v1/checkout?__experimental_calc_totals=true',
+ '/wc/store/v1/cart/update-item',
];
/**
@@ -167,7 +168,12 @@ const doApiFetchWithHeaders = ( options: APIFetchOptions ) =>
}
} )
.catch( ( errorResponse ) => {
- if ( errorResponse.name !== 'AbortError' ) {
+ // Propagate AbortError directly so callers can detect cancelled requests.
+ if ( errorResponse.name === 'AbortError' ) {
+ reject( errorResponse );
+ return;
+ }
+ if ( errorResponse.headers ) {
processHeadersOnFetch( errorResponse.headers );
}
if ( typeof errorResponse.json === 'function' ) {