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