Commit 7d6dd4d1aed for woocommerce
commit 7d6dd4d1aed298bcd023eaf9edde118662d63c40
Author: Bartosz Drewniak <bartech@bartech.dev>
Date: Wed Nov 11 12:38:12 2026 +0100
Fix lost Cart and Checkout block changes when Store API requests overlap (#70084)
* Send Store API session writes one request at a time
The server saves the whole session at the end of each Store API request.
When two cart or checkout writes run at the same time, the one that ends
last overwrites the changes of the other: a quantity change on another
item, a shipping rate chosen before an address change, or a shipping rate
chosen before a payment method change is lost.
apiFetchWithHeaders now queues non-GET requests to cart and checkout
routes. Only one is sent at a time; writes made while one is running are
sent together in one batch. Checkout routes cannot be batched and are sent
on their own, in order. update-item and checkout writes can still be
aborted with a signal; other writes ignore it, as they did when batched.
* Keep the session write queue running when a request fails without an error
A request that rejected with null or undefined made the error handler
throw before it settled the write, and a request that threw before it was
sent did not settle it either. The queue then stayed busy and held back
every later cart and checkout write.
The error handlers now accept empty rejections, and a request that throws
before it is sent rejects its write. Route checks use string comparisons
instead of regular expressions.
* Simplify the write queue and remove DataLoader
Use preventBatching as the only list that decides how a write is sent.
Queue every write, not only cart and checkout writes. The queue now does
all batching, so DataLoader is no longer needed.
Return promises from the send helpers, so an error while handling a
response fails that write and the queue continues.
* Shorten changelog entry for concurrent cart updates fix
---------
Co-authored-by: Mike Jolley <mike.jolley@me.com>
diff --git a/plugins/woocommerce/changelog/fix-cart-block-concurrent-cart-updates b/plugins/woocommerce/changelog/fix-cart-block-concurrent-cart-updates
new file mode 100644
index 00000000000..c90585c363d
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-cart-block-concurrent-cart-updates
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep quick changes to quantities, shipping, address, and payment method in the Cart and Checkout blocks from being lost.
diff --git a/plugins/woocommerce/client/blocks/package.json b/plugins/woocommerce/client/blocks/package.json
index dab0ee91d71..2b220268303 100644
--- a/plugins/woocommerce/client/blocks/package.json
+++ b/plugins/woocommerce/client/blocks/package.json
@@ -248,7 +248,6 @@
"clsx": "2.1.x",
"compare-versions": "4.1.3",
"config": "3.3.7",
- "dataloader": "2.2.2",
"deepsignal": "1.3.6",
"dinero.js": "^2.0.0",
"downshift": "6.1.7",
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/block-data/shared-controls.ts b/plugins/woocommerce/client/blocks/packages/public-api/block-data/shared-controls.ts
index 5093fbf4a2d..65907c9859b 100644
--- a/plugins/woocommerce/client/blocks/packages/public-api/block-data/shared-controls.ts
+++ b/plugins/woocommerce/client/blocks/packages/public-api/block-data/shared-controls.ts
@@ -3,9 +3,7 @@
*/
import { __ } from '@wordpress/i18n';
import triggerFetch, { APIFetchOptions } from '@wordpress/api-fetch';
-import DataLoader from 'dataloader';
import {
- ApiResponse,
assertBatchResponseIsValid,
assertResponseIsValid,
} from '@woocommerce/types';
@@ -90,28 +88,14 @@ const triggerBatchFetch = ( keys: readonly APIFetchOptions[] ) => {
/**
* In ms, how long we should wait for requests to batch.
*
- * DataLoader collects all requests over this window of time (and as a consequence, adds this amount of latency).
+ * The queue collects all requests over this window of time (and as a consequence, adds this amount of latency).
*/
const triggerBatchFetchDelay = 300;
/**
- * DataLoader instance for triggerBatchFetch.
+ * The batch endpoint accepts at most 25 requests.
*/
-const triggerBatchFetchLoader = new DataLoader( triggerBatchFetch, {
- batchScheduleFn: ( callback: () => void ) =>
- setTimeout( callback, triggerBatchFetchDelay ),
- cache: false,
- maxBatchSize: 25,
-} );
-
-/**
- * Trigger a fetch from the API using the batch endpoint.
- *
- * @param {APIFetchOptions} request Request object containing API request.
- */
-const batchFetch = async ( request: APIFetchOptions ) => {
- return await triggerBatchFetchLoader.load( request );
-};
+const maxBatchSize = 25;
/**
* Dispatched a control action for triggering an api fetch call with no parsing.
@@ -125,7 +109,7 @@ export const apiFetchWithHeadersControl = ( options: APIFetchOptions ) =>
options,
} ) as const;
-// List of paths which should not be batched.
+// Writes to these paths are sent on their own, not batched. They skip the batching window and can be aborted.
const preventBatching = [
'/wc/store/v1/checkout',
'/wc/store/v1/checkout?__experimental_calc_totals=true',
@@ -135,92 +119,170 @@ const preventBatching = [
'/wc/store/v1/shopper-lists/saved-for-later/items',
];
+type Result = { response: unknown; headers: unknown };
+
/**
- * The underlying function that actually does the fetch. This is used by both the generator (control) version of
- * apiFetchWithHeadersControl and the async function apiFetchWithHeaders.
+ * Sends a request on its own. Parse is disabled to get the headers as well as the body.
*/
-const doApiFetchWithHeaders = ( options: APIFetchOptions ) =>
- new Promise( ( resolve, reject ) => {
- // GET Requests cannot be batched.
- if (
- ! options.method ||
- options.method === 'GET' ||
- preventBatching.includes( options.path || '' )
- ) {
- // Parse is disabled here to avoid returning just the body--we also need headers.
- triggerFetch( {
- ...options,
- parse: false,
- } )
- .then( ( fetchResponse: unknown ) => {
- if ( fetchResponse instanceof Response ) {
- fetchResponse
- .json()
- .then( ( response: unknown ) => {
- resolve( {
- response,
- headers: fetchResponse.headers,
- } );
- processHeadersOnFetch( fetchResponse.headers );
- } )
- .catch( () => {
- reject( invalidJsonError );
- } );
- } else {
- reject( invalidJsonError );
- }
- } )
- .catch( ( errorResponse ) => {
- // 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' ) {
- // Parse error response before rejecting it.
- errorResponse
- .json()
- .then( ( error: unknown ) => {
- reject( error );
- } )
- .catch( () => {
- reject( invalidJsonError );
- } );
- } else {
- reject( errorResponse.message );
- }
- } );
- } else {
- batchFetch( options )
- .then( ( response: ApiResponse< unknown > ) => {
- assertResponseIsValid( response );
-
- if ( response.status >= 200 && response.status < 300 ) {
- resolve( {
- response: response.body,
- headers: response.headers,
- } );
- processHeadersOnFetch( response.headers );
- }
+const fetchUnbatched = async (
+ options: APIFetchOptions
+): Promise< Result > => {
+ // A synchronous throw (for example, in a middleware) happens before .catch() and rejects with the error itself.
+ const fetchResponse: unknown = await triggerFetch( {
+ ...options,
+ parse: false,
+ } ).catch( async ( errorResponse ) => {
+ // Propagate AbortError directly so callers can detect cancelled requests.
+ if ( errorResponse?.name === 'AbortError' ) {
+ throw errorResponse;
+ }
+ if ( errorResponse?.headers ) {
+ processHeadersOnFetch( errorResponse.headers );
+ }
+ if ( typeof errorResponse?.json === 'function' ) {
+ throw await errorResponse.json().catch( () => invalidJsonError );
+ }
+ throw errorResponse?.message;
+ } );
+ if ( ! ( fetchResponse instanceof Response ) ) {
+ throw invalidJsonError;
+ }
+ const response = await fetchResponse.json().catch( () => {
+ throw invalidJsonError;
+ } );
+ processHeadersOnFetch( fetchResponse.headers );
+ return { response, headers: fetchResponse.headers };
+};
- // Status code indicates error.
- throw response;
- } )
- .catch( ( errorResponse: ApiResponse< unknown > ) => {
- if ( errorResponse.headers ) {
- processHeadersOnFetch( errorResponse.headers );
- }
- if ( errorResponse.body ) {
- reject( errorResponse.body );
- } else {
- reject( errorResponse );
+/**
+ * Returns the result of one request from a batch response, or throws its error.
+ */
+const parseBatchedResponse = ( response: unknown ): Result => {
+ assertResponseIsValid( response );
+ if ( response.headers ) {
+ processHeadersOnFetch( response.headers );
+ }
+ if ( response.status >= 200 && response.status < 300 ) {
+ return { response: response.body, headers: response.headers };
+ }
+ throw response.body || response;
+};
+
+type Write = {
+ options: APIFetchOptions;
+ resolve: ( value: Result ) => void;
+ reject: ( reason?: unknown ) => void;
+};
+
+// Writes waiting to be sent, in the order they were made.
+let queue: Write[] = [];
+let isSending = false;
+let timer: ReturnType< typeof setTimeout > | null = null;
+
+const isUnbatched = ( write: Write ) =>
+ preventBatching.includes( write.options.path || '' );
+
+/**
+ * Sends one write on its own, or several writes in one batch. Resolves when the server has finished.
+ */
+const send = ( writes: Write[] ) => {
+ if ( isUnbatched( writes[ 0 ] ) ) {
+ return fetchUnbatched( writes[ 0 ].options ).then(
+ writes[ 0 ].resolve,
+ writes[ 0 ].reject
+ );
+ }
+ // Promise.resolve().then() turns a synchronous throw (for example, in a middleware) into a rejection.
+ return Promise.resolve()
+ .then( () =>
+ triggerBatchFetch( writes.map( ( write ) => write.options ) )
+ )
+ .then(
+ ( responses ) => {
+ // Settle successful writes first, so a later cart response does not clear the error of a failed write.
+ const failures: Array< () => void > = [];
+ writes.forEach( ( write, index ) => {
+ try {
+ write.resolve(
+ parseBatchedResponse( responses[ index ] )
+ );
+ } catch ( error ) {
+ failures.push( () => write.reject( error ) );
}
} );
+ failures.forEach( ( fail ) => fail() );
+ },
+ ( error ) => writes.forEach( ( write ) => write.reject( error ) )
+ );
+};
+
+/**
+ * Sends the next writes in the queue once the server has finished the previous ones.
+ */
+const sendNext = () => {
+ timer = null;
+ if ( isSending || queue.length === 0 ) {
+ return;
+ }
+ let count = 1;
+ if ( ! isUnbatched( queue[ 0 ] ) ) {
+ while (
+ count < queue.length &&
+ count < maxBatchSize &&
+ ! isUnbatched( queue[ count ] )
+ ) {
+ count++;
}
+ }
+ isSending = true;
+ void send( queue.splice( 0, count ) ).finally( () => {
+ isSending = false;
+ sendNext();
} );
+};
+
+/**
+ * Queues a write. Writes are sent one request at a time, because the server saves the whole session at the end
+ * of each request and the last request to finish overwrites the others.
+ *
+ * Aborting a write in preventBatching removes it from the queue, or rejects it straight away if it was already
+ * sent. Other writes ignore the signal, as they did when they were batched.
+ */
+const queueWrite = ( options: APIFetchOptions ) =>
+ new Promise< Result >( ( resolve, reject ) => {
+ const { signal, ...requestOptions } = options;
+ const write: Write = { options: requestOptions, resolve, reject };
+ const unbatched = isUnbatched( write );
+ if ( unbatched && signal ) {
+ const abort = () => {
+ queue = queue.filter( ( queued ) => queued !== write );
+ reject(
+ new DOMException( 'The request was aborted.', 'AbortError' )
+ );
+ };
+ if ( signal.aborted ) {
+ abort();
+ return;
+ }
+ signal.addEventListener( 'abort', abort, { once: true } );
+ }
+ queue.push( write );
+ if ( ! isSending && ! timer ) {
+ timer = setTimeout(
+ sendNext,
+ unbatched ? 0 : triggerBatchFetchDelay
+ );
+ }
+ } );
+
+/**
+ * The underlying function that actually does the fetch. This is used by both the generator (control) version of
+ * apiFetchWithHeadersControl and the async function apiFetchWithHeaders.
+ */
+const doApiFetchWithHeaders = ( options: APIFetchOptions ) =>
+ ! options.method || options.method === 'GET'
+ ? fetchUnbatched( options )
+ : queueWrite( options );
/**
* Triggers an api fetch call with no parsing.
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/block-data/test/shared-controls.ts b/plugins/woocommerce/client/blocks/packages/public-api/block-data/test/shared-controls.ts
new file mode 100644
index 00000000000..2c16710e957
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/packages/public-api/block-data/test/shared-controls.ts
@@ -0,0 +1,592 @@
+/**
+ * External dependencies
+ */
+import type { APIFetchOptions } from '@wordpress/api-fetch';
+
+jest.mock( '@wordpress/api-fetch', () => {
+ const triggerFetch = jest.fn();
+ Object.assign( triggerFetch, {
+ setNonce: jest.fn(),
+ setCartHash: jest.fn(),
+ } );
+ return { __esModule: true, default: triggerFetch };
+} );
+
+type PendingFetch = {
+ options: APIFetchOptions;
+ resolve: ( value: unknown ) => void;
+ reject: ( reason: unknown ) => void;
+};
+
+type BatchRequest = { path: string; method: string; body: unknown };
+
+let fetches: PendingFetch[];
+let apiFetchWithHeaders: ( options: APIFetchOptions ) => Promise< unknown >;
+
+const settle = async () => {
+ for ( let i = 0; i < 20; i++ ) {
+ await Promise.resolve();
+ }
+};
+
+/**
+ * Runs the batching timers, then lets the promises settle.
+ */
+const tick = async ( ms = 0 ) => {
+ jest.advanceTimersByTime( ms );
+ await settle();
+};
+
+const batchRequests = ( index: number ) =>
+ (
+ fetches[ index ].options.data as {
+ requests: BatchRequest[];
+ }
+ ).requests.map( ( request ) => ( {
+ path: request.path,
+ body: request.body,
+ } ) );
+
+const batchItem = ( body: unknown, status = 200 ) => ( {
+ status,
+ body,
+ headers: {},
+} );
+
+const respondToBatch = async ( index: number, items: unknown[] ) => {
+ fetches[ index ].resolve( { responses: items } );
+ await settle();
+};
+
+const respondToSingle = async ( index: number, body: unknown ) => {
+ // A Response without a body stream, so the test doesn't depend on how the environment reads streams.
+ const response = Object.setPrototypeOf(
+ {
+ json: () => Promise.resolve( body ),
+ headers: new Headers(),
+ },
+ Response.prototype
+ );
+ fetches[ index ].resolve( response );
+ await settle();
+};
+
+const updateItem = ( key: string, quantity: number, signal?: AbortSignal ) =>
+ apiFetchWithHeaders( {
+ path: '/wc/store/v1/cart/update-item',
+ method: 'POST',
+ data: { key, quantity },
+ ...( signal && { signal } ),
+ } );
+
+const selectShippingRate = ( rateId: string ) =>
+ apiFetchWithHeaders( {
+ path: '/wc/store/v1/cart/select-shipping-rate',
+ method: 'POST',
+ data: { package_id: 0, rate_id: rateId },
+ } );
+
+const addItem = ( id: number ) =>
+ apiFetchWithHeaders( {
+ path: '/wc/store/v1/cart/add-item',
+ method: 'POST',
+ data: { id, quantity: 1 },
+ } );
+
+const updateDraftOrder = ( paymentMethod: string ) =>
+ apiFetchWithHeaders( {
+ path: '/wc/store/v1/checkout?__experimental_calc_totals=true',
+ method: 'PUT',
+ data: { payment_method: paymentMethod },
+ } );
+
+const UPDATE_ITEM_PATH = '/wc/store/v1/cart/update-item';
+const BATCH_PATH = '/wc/store/v1/batch';
+
+describe( 'apiFetchWithHeaders writes', () => {
+ beforeEach( async () => {
+ jest.useFakeTimers();
+ // The queue lives in the module, so each test starts from a fresh copy.
+ jest.resetModules();
+ fetches = [];
+ const { default: triggerFetch } =
+ await import( '@wordpress/api-fetch' );
+ jest.mocked( triggerFetch ).mockImplementation(
+ ( options: APIFetchOptions ) =>
+ new Promise( ( resolve, reject ) => {
+ fetches.push( { options, resolve, reject } );
+ } )
+ );
+ ( { apiFetchWithHeaders } = await import( '../shared-controls' ) );
+ } );
+
+ afterEach( () => {
+ jest.useRealTimers();
+ } );
+
+ it( 'sends a write only after the server has finished the one before it', async () => {
+ const first = updateItem( 'item-1', 2 );
+ await tick();
+ const second = updateItem( 'item-2', 3 );
+ await tick( 300 );
+
+ expect( fetches ).toHaveLength( 1 );
+ expect( fetches[ 0 ].options ).toMatchObject( {
+ path: UPDATE_ITEM_PATH,
+ data: { key: 'item-1', quantity: 2 },
+ parse: false,
+ } );
+
+ await respondToSingle( 0, { items: 'first' } );
+ await expect( first ).resolves.toMatchObject( {
+ response: { items: 'first' },
+ } );
+
+ expect( fetches ).toHaveLength( 2 );
+ expect( fetches[ 1 ].options ).toMatchObject( {
+ path: UPDATE_ITEM_PATH,
+ data: { key: 'item-2', quantity: 3 },
+ } );
+
+ await respondToSingle( 1, { items: 'second' } );
+ await expect( second ).resolves.toMatchObject( {
+ response: { items: 'second' },
+ } );
+ } );
+
+ it( 'sends update-item on its own and without waiting for the batching window when nothing is running', async () => {
+ void updateItem( 'item-1', 2 );
+ await tick();
+
+ expect( fetches ).toHaveLength( 1 );
+ expect( fetches[ 0 ].options.path ).toBe( UPDATE_ITEM_PATH );
+ } );
+
+ it( 'never puts update-item writes in a batch', async () => {
+ void selectShippingRate( 'flat_rate:1' );
+ await tick( 300 );
+ void updateItem( 'item-1', 2 );
+ void updateItem( 'item-2', 3 );
+ void updateItem( 'item-3', 4 );
+ await respondToBatch( 0, [ batchItem( {} ) ] );
+
+ expect( fetches ).toHaveLength( 2 );
+ expect( fetches[ 1 ].options.path ).toBe( UPDATE_ITEM_PATH );
+
+ await respondToSingle( 1, {} );
+ expect( fetches ).toHaveLength( 3 );
+ expect( fetches[ 2 ].options.path ).toBe( UPDATE_ITEM_PATH );
+
+ await respondToSingle( 2, {} );
+ expect( fetches ).toHaveLength( 4 );
+ expect( fetches[ 3 ].options.path ).toBe( UPDATE_ITEM_PATH );
+ } );
+
+ it( 'sends the writes made while one is running together in one batch', async () => {
+ void selectShippingRate( 'flat_rate:1' );
+ await tick( 300 );
+ const second = addItem( 1 );
+ const third = selectShippingRate( 'free_shipping:2' );
+ await tick( 300 );
+
+ expect( fetches ).toHaveLength( 1 );
+
+ await respondToBatch( 0, [ batchItem( {} ) ] );
+
+ expect( fetches ).toHaveLength( 2 );
+ expect( fetches[ 1 ].options.path ).toBe( BATCH_PATH );
+ expect( batchRequests( 1 ) ).toEqual( [
+ {
+ path: '/wc/store/v1/cart/add-item',
+ body: { id: 1, quantity: 1 },
+ },
+ {
+ path: '/wc/store/v1/cart/select-shipping-rate',
+ body: { package_id: 0, rate_id: 'free_shipping:2' },
+ },
+ ] );
+
+ await respondToBatch( 1, [
+ batchItem( { cart: 'after-item' } ),
+ batchItem( { cart: 'after-rate' } ),
+ ] );
+ await expect( second ).resolves.toMatchObject( {
+ response: { cart: 'after-item' },
+ } );
+ await expect( third ).resolves.toMatchObject( {
+ response: { cart: 'after-rate' },
+ } );
+ } );
+
+ it( 'sends at most 25 writes in one batch', async () => {
+ void selectShippingRate( 'flat_rate:1' );
+ await tick( 300 );
+ for ( let id = 1; id <= 30; id++ ) {
+ void addItem( id );
+ }
+ await respondToBatch( 0, [ batchItem( {} ) ] );
+
+ expect( batchRequests( 1 ) ).toHaveLength( 25 );
+
+ await respondToBatch(
+ 1,
+ Array.from( { length: 25 }, () => batchItem( {} ) )
+ );
+
+ expect( batchRequests( 2 ) ).toHaveLength( 5 );
+ expect( batchRequests( 2 )[ 0 ].body ).toEqual( {
+ id: 26,
+ quantity: 1,
+ } );
+ } );
+
+ it( 'waits for the batching window before sending writes that can be batched', async () => {
+ void selectShippingRate( 'flat_rate:1' );
+ await tick( 299 );
+ expect( fetches ).toHaveLength( 0 );
+ await tick( 1 );
+ expect( fetches ).toHaveLength( 1 );
+ } );
+
+ it( 'sends a checkout update on its own, in the order it was made', async () => {
+ void selectShippingRate( 'flat_rate:1' );
+ const checkoutUpdate = updateDraftOrder( 'cod' );
+ void selectShippingRate( 'free_shipping:2' );
+ await tick( 300 );
+
+ expect( fetches ).toHaveLength( 1 );
+ expect( batchRequests( 0 ) ).toHaveLength( 1 );
+
+ await respondToBatch( 0, [ batchItem( {} ) ] );
+
+ expect( fetches ).toHaveLength( 2 );
+ expect( fetches[ 1 ].options ).toMatchObject( {
+ path: '/wc/store/v1/checkout?__experimental_calc_totals=true',
+ method: 'PUT',
+ data: { payment_method: 'cod' },
+ parse: false,
+ } );
+
+ await respondToSingle( 1, { payment_method: 'cod' } );
+ await expect( checkoutUpdate ).resolves.toMatchObject( {
+ response: { payment_method: 'cod' },
+ } );
+
+ expect( fetches ).toHaveLength( 3 );
+ expect( batchRequests( 2 ) ).toEqual( [
+ {
+ path: '/wc/store/v1/cart/select-shipping-rate',
+ body: { package_id: 0, rate_id: 'free_shipping:2' },
+ },
+ ] );
+ } );
+
+ it( 'queues writes to any route, not only cart and checkout routes', async () => {
+ void updateItem( 'item-1', 2 );
+ await tick();
+ const otherRoute = apiFetchWithHeaders( {
+ path: '/wc/store/v1/some-extension/route',
+ method: 'POST',
+ data: {},
+ } );
+ await tick( 300 );
+
+ expect( fetches ).toHaveLength( 1 );
+
+ await respondToSingle( 0, {} );
+
+ expect( fetches ).toHaveLength( 2 );
+ expect( batchRequests( 1 ) ).toEqual( [
+ { path: '/wc/store/v1/some-extension/route', body: {} },
+ ] );
+
+ await respondToBatch( 1, [ batchItem( { ok: true } ) ] );
+ await expect( otherRoute ).resolves.toMatchObject( {
+ response: { ok: true },
+ } );
+ } );
+
+ it( 'queues shopper-lists writes and sends them on their own', async () => {
+ void updateItem( 'item-1', 2 );
+ await tick();
+ void apiFetchWithHeaders( {
+ path: '/wc/store/v1/shopper-lists/saved-for-later/items',
+ method: 'POST',
+ data: { key: 'item-2' },
+ } );
+ await tick( 300 );
+
+ expect( fetches ).toHaveLength( 1 );
+
+ await respondToSingle( 0, {} );
+
+ expect( fetches ).toHaveLength( 2 );
+ expect( fetches[ 1 ].options ).toMatchObject( {
+ path: '/wc/store/v1/shopper-lists/saved-for-later/items',
+ parse: false,
+ } );
+ } );
+
+ it( 'resolves or rejects each write in a batch with its own response', async () => {
+ void selectShippingRate( 'flat_rate:1' );
+ await tick( 300 );
+ const succeeds = addItem( 1 );
+ const fails = addItem( 99 );
+ await respondToBatch( 0, [ batchItem( {} ) ] );
+
+ const error = { code: 'woocommerce_rest_cart_invalid_quantity' };
+ await respondToBatch( 1, [
+ batchItem( { cart: 'ok' } ),
+ batchItem( error, 400 ),
+ ] );
+
+ await expect( succeeds ).resolves.toMatchObject( {
+ response: { cart: 'ok' },
+ } );
+ await expect( fails ).rejects.toEqual( error );
+ } );
+
+ it( 'settles the successful writes in a batch before the failed ones', async () => {
+ void selectShippingRate( 'flat_rate:1' );
+ await tick( 300 );
+ const order: string[] = [];
+ addItem( 1 ).catch( () => order.push( 'failed' ) );
+ void addItem( 2 ).then( () => order.push( 'succeeded' ) );
+ await respondToBatch( 0, [ batchItem( {} ) ] );
+
+ await respondToBatch( 1, [
+ batchItem( { code: 'error' }, 400 ),
+ batchItem( { cart: 'ok' } ),
+ ] );
+
+ expect( order ).toEqual( [ 'succeeded', 'failed' ] );
+ } );
+
+ it( 'removes a write from the queue when it is aborted before it is sent', async () => {
+ void updateItem( 'item-1', 2 );
+ await tick();
+ const controller = new AbortController();
+ const aborted = updateItem( 'item-2', 3, controller.signal );
+ controller.abort();
+
+ await expect( aborted ).rejects.toMatchObject( {
+ name: 'AbortError',
+ } );
+
+ await respondToSingle( 0, {} );
+ await tick( 300 );
+ expect( fetches ).toHaveLength( 1 );
+ } );
+
+ it( 'never sends a write whose signal was already aborted', async () => {
+ const controller = new AbortController();
+ controller.abort();
+
+ await expect(
+ updateItem( 'item-1', 2, controller.signal )
+ ).rejects.toMatchObject( { name: 'AbortError' } );
+
+ await tick( 300 );
+ expect( fetches ).toHaveLength( 0 );
+ } );
+
+ it( 'rejects an aborted write that was already sent, but waits for the server before sending the next one', async () => {
+ const controller = new AbortController();
+ const aborted = updateItem( 'item-1', 2, controller.signal );
+ await tick();
+ expect( fetches ).toHaveLength( 1 );
+
+ controller.abort();
+ await expect( aborted ).rejects.toMatchObject( {
+ name: 'AbortError',
+ } );
+
+ const next = updateItem( 'item-1', 3 );
+ await tick( 300 );
+ expect( fetches ).toHaveLength( 1 );
+
+ await respondToSingle( 0, { quantity: 2 } );
+ expect( fetches ).toHaveLength( 2 );
+
+ await respondToSingle( 1, { quantity: 3 } );
+ await expect( next ).resolves.toMatchObject( {
+ response: { quantity: 3 },
+ } );
+ } );
+
+ it( 'ignores the signal of writes that can be batched', async () => {
+ const controller = new AbortController();
+ const selection = apiFetchWithHeaders( {
+ path: '/wc/store/v1/cart/select-shipping-rate',
+ method: 'POST',
+ data: { package_id: 0, rate_id: 'flat_rate:1' },
+ signal: controller.signal,
+ } );
+ controller.abort();
+ await tick( 300 );
+
+ expect( fetches ).toHaveLength( 1 );
+ await respondToBatch( 0, [ batchItem( { cart: 'ok' } ) ] );
+ await expect( selection ).resolves.toMatchObject( {
+ response: { cart: 'ok' },
+ } );
+ } );
+
+ it( 'does not send the signal to the server request', async () => {
+ const controller = new AbortController();
+ void updateDraftOrder( 'cod' );
+ void apiFetchWithHeaders( {
+ path: '/wc/store/v1/checkout?__experimental_calc_totals=true',
+ method: 'PUT',
+ data: { order_notes: 'Leave at the door' },
+ signal: controller.signal,
+ } );
+ await tick();
+ await respondToSingle( 0, {} );
+
+ expect( fetches[ 1 ].options ).not.toHaveProperty( 'signal' );
+ } );
+
+ it( 'keeps sending writes after a batch request fails', async () => {
+ const first = selectShippingRate( 'flat_rate:1' );
+ await tick( 300 );
+ const second = selectShippingRate( 'free_shipping:2' );
+
+ fetches[ 0 ].reject( new Error( 'Network error' ) );
+ await settle();
+ await expect( first ).rejects.toThrow( 'Network error' );
+
+ expect( fetches ).toHaveLength( 2 );
+ await respondToBatch( 1, [ batchItem( { cart: 'ok' } ) ] );
+ await expect( second ).resolves.toMatchObject( {
+ response: { cart: 'ok' },
+ } );
+ } );
+
+ it( 'does not hold back reads while a write is running', async () => {
+ void updateItem( 'item-1', 2 );
+ await tick();
+ void apiFetchWithHeaders( {
+ path: '/wc/store/v1/cart',
+ method: 'GET',
+ } );
+
+ expect( fetches ).toHaveLength( 2 );
+ expect( fetches[ 1 ].options ).toMatchObject( {
+ path: '/wc/store/v1/cart',
+ method: 'GET',
+ } );
+ } );
+
+ it( 'keeps sending writes after a request rejects without an error', async () => {
+ const first = updateDraftOrder( 'cod' );
+ await tick();
+ const second = selectShippingRate( 'flat_rate:1' );
+
+ fetches[ 0 ].reject( undefined );
+ await settle();
+ await expect( first ).rejects.toBeUndefined();
+
+ expect( fetches ).toHaveLength( 2 );
+ await respondToBatch( 1, [ batchItem( { cart: 'ok' } ) ] );
+ await expect( second ).resolves.toMatchObject( {
+ response: { cart: 'ok' },
+ } );
+ } );
+
+ it( 'keeps sending writes after a batch request rejects without an error', async () => {
+ const first = selectShippingRate( 'flat_rate:1' );
+ await tick( 300 );
+ const second = selectShippingRate( 'free_shipping:2' );
+
+ fetches[ 0 ].reject( null );
+ await settle();
+ await expect( first ).rejects.toBeNull();
+
+ expect( fetches ).toHaveLength( 2 );
+ await respondToBatch( 1, [ batchItem( { cart: 'ok' } ) ] );
+ await expect( second ).resolves.toMatchObject( {
+ response: { cart: 'ok' },
+ } );
+ } );
+
+ it( 'rejects a write whose request fails before it is sent, and keeps sending writes', async () => {
+ const { default: triggerFetch } =
+ await import( '@wordpress/api-fetch' );
+ const middlewareError = new Error( 'Middleware failed' );
+ jest.mocked( triggerFetch )
+ .mockImplementationOnce( () => {
+ throw middlewareError;
+ } )
+ .mockImplementationOnce( () => {
+ throw middlewareError;
+ } );
+
+ const checkoutUpdate = updateDraftOrder( 'cod' );
+ const cartUpdate = selectShippingRate( 'flat_rate:1' );
+ await tick();
+ await expect( checkoutUpdate ).rejects.toBe( middlewareError );
+ await tick();
+ await expect( cartUpdate ).rejects.toBe( middlewareError );
+
+ const next = selectShippingRate( 'free_shipping:2' );
+ await tick( 300 );
+ expect( fetches ).toHaveLength( 1 );
+ await respondToBatch( 0, [ batchItem( { cart: 'ok' } ) ] );
+ await expect( next ).resolves.toMatchObject( {
+ response: { cart: 'ok' },
+ } );
+ } );
+
+ it( 'keeps sending writes when handling the headers of an error response throws', async () => {
+ const { default: triggerFetch } =
+ await import( '@wordpress/api-fetch' );
+ const setNonce = ( triggerFetch as unknown as { setNonce: jest.Mock } )
+ .setNonce;
+ const headerError = new Error( 'setNonce failed' );
+ setNonce.mockImplementationOnce( () => {
+ throw headerError;
+ } );
+
+ const first = updateDraftOrder( 'cod' );
+ await tick();
+ const second = updateDraftOrder( 'bacs' );
+
+ fetches[ 0 ].reject( {
+ headers: new Headers(),
+ json: () => Promise.resolve( { code: 'error' } ),
+ } );
+ await settle();
+ await expect( first ).rejects.toBe( headerError );
+
+ expect( fetches ).toHaveLength( 2 );
+ await respondToSingle( 1, { payment_method: 'bacs' } );
+ await expect( second ).resolves.toMatchObject( {
+ response: { payment_method: 'bacs' },
+ } );
+ } );
+
+ it( 'keeps sending writes when handling the headers of a failed batch entry throws', async () => {
+ const { default: triggerFetch } =
+ await import( '@wordpress/api-fetch' );
+ const setNonce = ( triggerFetch as unknown as { setNonce: jest.Mock } )
+ .setNonce;
+ const headerError = new Error( 'setNonce failed' );
+ setNonce.mockImplementationOnce( () => {
+ throw headerError;
+ } );
+
+ const first = selectShippingRate( 'flat_rate:1' );
+ await tick( 300 );
+ const second = selectShippingRate( 'free_shipping:2' );
+
+ await respondToBatch( 0, [ batchItem( { code: 'error' }, 400 ) ] );
+ await expect( first ).rejects.toBe( headerError );
+
+ expect( fetches ).toHaveLength( 2 );
+ await respondToBatch( 1, [ batchItem( { cart: 'ok' } ) ] );
+ await expect( second ).resolves.toMatchObject( {
+ response: { cart: 'ok' },
+ } );
+ } );
+} );
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 747be220e92..cb74f04ab9b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -3502,9 +3502,6 @@ importers:
config:
specifier: 3.3.7
version: 3.3.7
- dataloader:
- specifier: 2.2.2
- version: 2.2.2
deepsignal:
specifier: 1.3.6
version: 1.3.6(@preact/signals-core@1.14.1)(@preact/signals@1.3.4(preact@10.29.1))(preact@10.29.1)
@@ -12936,9 +12933,6 @@ packages:
resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
engines: {node: '>= 0.4'}
- dataloader@2.2.2:
- resolution: {integrity: sha512-8YnDaaf7N3k/q5HnTJVuzSyLETjoZjVmHc4AeKAzOvKHEFQKcn64OKBfzHYtE9zGjctNM7V9I0MfnUVLpi7M5g==}
-
date-fns-jalali@4.1.0-0:
resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==}
@@ -36198,8 +36192,6 @@ snapshots:
es-errors: 1.3.0
is-data-view: 1.0.2
- dataloader@2.2.2: {}
-
date-fns-jalali@4.1.0-0: {}
date-fns-tz@3.2.0(date-fns@4.4.0):