Commit 0e5a279a775 for woocommerce
commit 0e5a279a7750e782baff8f4d5e62563dd48551f3
Author: Luis Herranz <luisherranz@gmail.com>
Date: Fri Jul 31 14:58:43 2026 +0200
Fire cart store cross-cutting side effects once per batch cycle (#66748)
* Widen addCartItem to return AddCartItemOutcome captured at the request boundary
addCartItem now resolves a structured per-call outcome (`{ success: true }` or
`{ success: false, error: { code?, message } }`) captured immediately after its
own Store API request settles, so a later throw in post-success processing
(notices, a11y announcement) can never downgrade an already-captured success.
The Store type's addCartItem signature and the test harness are widened in
lockstep; removeCartItem and batchAddCartItems are unchanged.
* Rewire saved-for-later Move to cart onto the addCartItem outcome
onClickMoveToCart now gates the source-entry removal on the resolved
AddCartItemOutcome from addCartItem instead of summing cart-line quantities
before and after the request. This removes sumMatchingCartQuantity and its
supporting CartLine type, the doesCartItemMatchAttributes dependency, and the
now-unused cartState binding, since the outcome tells the block directly
whether the add was accepted.
* Rewire wishlist Add to cart onto the addCartItem outcome
Gate the wishlist entry's removal on addCartItem's resolved
AddCartItemOutcome instead of comparing cart quantity totals before
and after the add, removing the sumMatchingCartQuantity helper and
its variation-matching dependency now that success/failure comes
straight from the request boundary.
* Document the addCartItem outcome contract in the cart store README
Add a tightly-scoped `woocommerce/cart` store section covering the
AddCartItemOutcome/AddCartItemError shape, the fire-and-forget guarantee,
per-call attribution under batching/looping, keyed/keyless coverage, and
the removeCartItem/batchAddCartItems non-parity guardrail, with pointers
to the saved-for-later and wishlist read sites.
* Update stale addCartItem comment in mutation-batcher e2e suite
The inline comment said addCartItem's promises merely "resolve," which
no longer tells the whole story now that each call resolves with a
structured per-product outcome. Spell out the { success: true } /
{ success: false, error } shape so a reader of this mixed accept/reject
scenario (products 15, 16 accepted, 999999 rejected) understands the
outcome is distinguishable programmatically, not just observable via
cart state.
* Add changelog entry
* Add per-mutation meta and per-cycle onCycleSettled hook to the batcher
Threads an opaque TMeta type parameter through MutationRequest,
MutationQueueConfig, and MutationQueue so callers can attach caller-defined
meta to a submitted request. Adds an onCycleSettled hook that fires once per
reconciliation cycle, synchronously while isProcessing is still true, with
one { success, meta } entry per tracked request in submission order -
including cycles where every request failed. Hook exceptions are caught and
logged so a misbehaving consumer can never break reconciliation. The
existing per-request onSettled channel is untouched.
* Migrate cart store onto onCycleSettled for cross-cutting side effects
Consolidate the sync event, legacy added-to-cart event, and screen-reader
announcement into a single per-cycle onCycleSettled callback that aggregates
successful mutations' metadata, instead of firing them from each request's
own onSettled wire-up. removeCartItem, addCartItem, and batchAddCartItems now
attach per-mutation meta (quantityChanges + origin) and let the queue merge
and dispatch once per settled cycle. The a11y module is preloaded once per
action call and its speak binding is reused across cycles.
* Remove per-request onSettled channel from mutation batcher
The per-cycle onCycleSettled hook is now the only settlement channel;
the per-request onSettled callback and its reconcile() loop are gone,
and MutationRequest drops its now-unused TState generic.
* Add e2e regression coverage for once-per-cycle cart side effects
Extends the cart-store batcher e2e spec with three flows that lock in
the once-per-cycle contract for the cross-cutting sync/legacy events:
N concurrent successful adds, a concurrent remove-only cycle, and a
mixed successful/failed add cycle each fire the sync event and (where
applicable) the legacy added-to-cart event exactly once per batch
cycle, regardless of how many mutations were coalesced into it.
* Document once-per-cycle cart side effects in stores README
Extends the woocommerce/cart store section with the sync/legacy
event and screen-reader announcement once-per-cycle mechanism
(onCycleSettled, per-request meta/origin gates), and updates the
store-index bullet and scope-disclaimer sentence so neither
undersells nor contradicts the new subsection.
* Add changelog fragment for once-per-cycle cart side effects
---------
Co-authored-by: luisherranz <luis.herranz@automattic.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/fix-cart-store-batch-cycle-side-effects b/plugins/woocommerce/changelog/fix-cart-store-batch-cycle-side-effects
new file mode 100644
index 00000000000..8256aedcedb
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-cart-store-batch-cycle-side-effects
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fire the cart store's cross-store sync event, legacy added-to-cart event, and screen-reader announcement once per mutation batch cycle instead of once per request, removing the redundant resync requests and duplicate events/announcements produced when several cart mutations are coalesced into one cycle.
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/README.md b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/README.md
index 3fed9dd18c3..0d37e4974c0 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/README.md
+++ b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/README.md
@@ -5,7 +5,7 @@ This folder contains the Interactivity API (iAPI) stores that WooCommerce blocks
Stores in this folder:
- [`woocommerce/products`](#woocommerceproducts-store) — server-populated cache of product and variation data in Store API format.
-- [`woocommerce/cart`](#woocommercecart-store) — cart state and actions (with mutation batching for performance). `addCartItem` resolves with a structured per-call outcome (`AddCartItemOutcome`) instead of `void` — see below.
+- [`woocommerce/cart`](#woocommercecart-store) — cart state and actions (with mutation batching for performance). `addCartItem` resolves with a structured per-call outcome (`AddCartItemOutcome`) instead of `void`, and the store's cross-cutting side effects (sync event, legacy event, screen-reader announcement) fire exactly once per batch cycle, not once per request — see below.
---
@@ -237,7 +237,7 @@ For variable products, `findProduct` returns `null` when no variation matches th
## `woocommerce/cart` store
-The `woocommerce` Interactivity API store (`base/stores/woocommerce/cart.ts`) holds cart state and actions, with mutation batching for performance. This section only covers the `addCartItem` outcome contract below — it is not a write-up of the store's full state shape, its other actions, or the mutation batcher's internals.
+The `woocommerce` Interactivity API store (`base/stores/woocommerce/cart.ts`) holds cart state and actions, with mutation batching for performance. This section covers the `addCartItem` outcome contract and the once-per-cycle cross-cutting side effects below — it is not a write-up of the store's full state shape, its other actions, or the rest of the mutation batcher's internals.
### The `addCartItem` outcome contract
@@ -289,3 +289,27 @@ if ( ! outcome.success ) {
**`removeCartItem` and `batchAddCartItems` do not share this contract.** Both remain typed `Promise<void>` and swallow their own errors the way `addCartItem` used to. Don't assume parity when touching either of them — extending this contract to other cart actions is a separate decision, not something the store does today.
For a worked example, see `onClickMoveToCart` in `saved-for-later/frontend.ts` and `onClickAddToCart` in `wishlist/frontend.ts` — both read the outcome and gate a destructive list-removal on `outcome.success`.
+
+### Once-per-cycle cross-cutting side effects
+
+The store emits three cart-wide side effects whenever a mutation succeeds:
+
+- the cross-store sync event (`wc-blocks_store_sync_required`, `detail.type: 'from_iAPI'`), which drives the legacy `@wordpress/data` cart store's `GET /wc/store/v1/cart` resync (notice suppression and per-line pending flags included);
+- the legacy `wc-blocks_added_to_cart` event (payload `{ preserveCartData: true }`), consumed outside this store;
+- the screen-reader "added to cart" announcement, made only when the store is configured with an `addedToCartText` message.
+
+These fire **exactly once per mutation batch cycle, not once per request**. When the mutation batcher (`mutation-batcher.ts`) coalesces several concurrent mutations — e.g. N concurrent `addCartItem` calls issued in the same tick — into one processing cycle and one `/wc/store/v1/batch` request, each of the three effects fires only once for that cycle, not once per coalesced request. Before this mechanism existed, each of the three effects was wired per request rather than per cycle, so N coalesced mutations produced N sync events (N redundant resync GETs), N legacy events, and N announcements; a single successful mutation still produces exactly one of each, unchanged.
+
+**Mechanism.** Every action that calls `sendCartRequest` attaches a per-request `meta: { quantityChanges, origin }` (type `CartMutationMeta`) describing that one mutation: `quantityChanges` is its contribution to the sync event's payload (`productsPendingAdd` / `cartItemsPendingQuantity` / `cartItemsPendingDelete`), and `origin` is `'add'` (set by `addCartItem` and `batchAddCartItems`, regardless of whether the mutation hit `add-item` or `update-item`) or `'remove'` (set by `removeCartItem`). The batcher carries `meta` through unchanged and, once a cycle settles — synchronously, after the cycle's server state is committed (or rolled back) and before the queue's `isProcessing` state clears — invokes the mutation queue's `onCycleSettled` config callback exactly once, with one `{ success, meta }` entry per request in the cycle. The `onCycleSettled` callback built into the queue passed to `createMutationQueue` inside `sendCartRequest` is the single place in `cart.ts` that emits the three effects; no action emits them itself.
+
+**Gates.** The callback applies three rules to the cycle's successful entries (`entry.success && entry.meta`):
+
+- **No successful entry** (every mutation in the cycle failed) → nothing fires: no sync event, no legacy event, no announcement.
+- **At least one successful entry, of any origin** → exactly one sync event, whose `quantityChanges` is the union of every successful entry's `quantityChanges` (via the module-level `mergeQuantityChanges` helper — duplicates collapsed, a key absent from every entry stays absent). A mixed cycle of successful adds and removes still fires only one sync event, carrying both the added product ids and the removed line keys.
+- **At least one successful entry with `origin: 'add'`** → exactly one legacy event and one announcement, in addition to the sync event above (the legacy event dispatches before the sync event, preserving the existing relative order). A remove-only cycle — however many concurrent removals — still fires the sync event, but never the legacy event or the announcement.
+
+Per-item error notices and each action's own info-notice update (including its `removeOthers` behavior) are untouched by this mechanism — they remain per-action and per-item, raised from each action's own request handling, not collapsed into the once-per-cycle effects.
+
+**`batchAddCartItems`.** A single `batchAddCartItems` call is just a cycle whose requests all originate from one call, so it goes through the exact same `onCycleSettled` mechanism as `addCartItem`/`removeCartItem`: each item submits its own `meta` (all `origin: 'add'`), and the call still produces one sync event, one legacy event, and one announcement when at least one item succeeds. Its merged sync `quantityChanges` is the union of only the *successful* items' entries — a failed item contributes nothing, since it made no server change.
+
+**Stays internal.** This is purely store-side wiring: no new option, export, or event was added for consumers to orchestrate or suppress these effects. `addCartItem` and `batchAddCartItems` still expose only their existing `showCartUpdatesNotices` option, which does not touch these three effects, and the `woocommerce` store remains private — see the note at the top of this file.
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 bedacbfb783..e4940925b08 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
@@ -23,6 +23,7 @@ import { triggerAddedToCartEvent } from './legacy-events';
import {
createMutationQueue,
MutationRequest,
+ type CycleSettledEntry,
type MutationQueue,
type MutationResult,
} from './mutation-batcher';
@@ -77,8 +78,8 @@ export type AddCartItemError = {
/**
* The per-call outcome `addCartItem` resolves with, captured at the moment
* its own request settles (accepted or rejected). A later throw in
- * post-success processing (notices, a11y announcement) never downgrades an
- * already-captured success into a failure.
+ * post-success processing (notices) never downgrades an already-captured
+ * success into a failure.
*/
export type AddCartItemOutcome =
| { success: true }
@@ -128,6 +129,24 @@ type QuantityChanges = {
productsPendingAdd?: number[];
};
+/**
+ * The per-mutation metadata carried through the cart's mutation queue,
+ * submitted with every request and aggregated by the queue's
+ * `onCycleSettled` callback once a cycle finishes.
+ */
+type CartMutationMeta = {
+ /** The quantity changes this single mutation contributes, if it succeeds. */
+ quantityChanges: QuantityChanges;
+ /**
+ * Whether this mutation was issued by an add-style action (`addCartItem`
+ * or `batchAddCartItems`, regardless of whether it hit the `add-item` or
+ * `update-item` endpoint) or by `removeCartItem`. Only `'add'`-origin
+ * successes trigger the legacy added-to-cart event and the screen-reader
+ * announcement.
+ */
+ origin: 'add' | 'remove';
+};
+
// Guard to distinguish between optimistic and cart items.
function isCartItem( item: OptimisticCartItem | CartItem ): item is CartItem {
return 'name' in item;
@@ -272,6 +291,51 @@ function computeKeylessAddSuppressKeys(
return suppressKeys;
}
+/**
+ * Merges the per-mutation `quantityChanges` records of every successful
+ * mutation in a settled cycle into the single record dispatched with the
+ * sync event.
+ *
+ * Each of the three keys (`productsPendingAdd`, `cartItemsPendingQuantity`,
+ * `cartItemsPendingDelete`) is unioned independently across `list`, with
+ * duplicates collapsed via `Set`. A key that no entry in `list` contributes
+ * is left absent from the result rather than emitted as an empty array, so
+ * the sync event's shape matches today's single-mutation payloads.
+ *
+ * @param list The `quantityChanges` records of every successful mutation in
+ * the cycle, one per mutation.
+ * @return The unioned `quantityChanges` record.
+ */
+function mergeQuantityChanges( list: QuantityChanges[] ): QuantityChanges {
+ const productsPendingAdd = new Set< number >();
+ const cartItemsPendingQuantity = new Set< string >();
+ const cartItemsPendingDelete = new Set< string >();
+
+ for ( const entry of list ) {
+ entry.productsPendingAdd?.forEach( ( id ) =>
+ productsPendingAdd.add( id )
+ );
+ entry.cartItemsPendingQuantity?.forEach( ( key ) =>
+ cartItemsPendingQuantity.add( key )
+ );
+ entry.cartItemsPendingDelete?.forEach( ( key ) =>
+ cartItemsPendingDelete.add( key )
+ );
+ }
+
+ const merged: QuantityChanges = {};
+ if ( productsPendingAdd.size > 0 ) {
+ merged.productsPendingAdd = [ ...productsPendingAdd ];
+ }
+ if ( cartItemsPendingQuantity.size > 0 ) {
+ merged.cartItemsPendingQuantity = [ ...cartItemsPendingQuantity ];
+ }
+ if ( cartItemsPendingDelete.size > 0 ) {
+ merged.cartItemsPendingDelete = [ ...cartItemsPendingDelete ];
+ }
+ return merged;
+}
+
/**
* Derives the auto-update and auto-removal info notices from the diff between
* the post-optimistic cart and the committed server cart.
@@ -379,13 +443,52 @@ function emitSyncEvent( {
);
}
+/**
+ * The `speak` binding from `@wordpress/a11y`, populated once the module
+ * import kicked off by {@link preloadA11y} resolves. `null` until then, or if
+ * the import never resolves (e.g. a chunk-load failure).
+ */
+let speakFn: ( typeof import('@wordpress/a11y') )[ 'speak' ] | null = null;
+
+/**
+ * The in-flight (or already-settled) `@wordpress/a11y` import kicked off by
+ * {@link preloadA11y}, reused across calls so the module is only imported
+ * once per page load.
+ */
+let a11yPromise: Promise< typeof import('@wordpress/a11y') > | null = null;
+
+/**
+ * Kicks off (once) the dynamic import of `@wordpress/a11y` and stashes its
+ * `speak` export into {@link speakFn} on resolution.
+ *
+ * Called at the start of `addCartItem` and `batchAddCartItems` so the module
+ * is already loading — often already resolved — by the time the mutation
+ * cycle settles and needs to announce. A rejected import (e.g. a chunk-load
+ * failure) is swallowed: it degrades the eventual announcement to a no-op
+ * without affecting the mutation's own success/failure outcome.
+ */
+function preloadA11y(): void {
+ if ( a11yPromise ) {
+ return;
+ }
+ a11yPromise = import( '@wordpress/a11y' );
+ a11yPromise
+ .then( ( { speak } ) => {
+ speakFn = speak;
+ } )
+ .catch( () => {
+ // Swallowed: a failed a11y chunk load must not affect mutation
+ // settlement. The eventual announcement attempt becomes a no-op.
+ } );
+}
+
/**
* 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;
+let cartQueue: MutationQueue< Cart, CartMutationMeta > | null = null;
/**
* Send a cart request through the queue.
@@ -394,12 +497,12 @@ let cartQueue: MutationQueue< Cart > | null = null;
*/
async function sendCartRequest(
stateRef: Store[ 'state' ],
- options: MutationRequest< Cart >
+ options: MutationRequest< CartMutationMeta >
): Promise< MutationResult< Cart > > {
await isNonceReady;
// Lazily initialize queue on first use.
if ( ! cartQueue ) {
- cartQueue = createMutationQueue< Cart >( {
+ cartQueue = createMutationQueue< Cart, CartMutationMeta >( {
endpoint: `${ stateRef.restUrl }wc/store/v1/batch`,
getHeaders: () => ( {
Nonce: stateRef.nonce,
@@ -417,6 +520,63 @@ async function sendCartRequest(
response.headers.get( 'Nonce' ) || stateRef.nonce;
return response;
},
+ // The single place that emits the cart's cross-cutting side
+ // effects (the sync event, the legacy added-to-cart event, and
+ // the screen-reader announcement) once per settled cycle, no
+ // matter how many mutations it batched together.
+ onCycleSettled: ( settled ) => {
+ const successful = settled.filter(
+ (
+ entry
+ ): entry is Required<
+ CycleSettledEntry< CartMutationMeta >
+ > => entry.success && entry.meta !== undefined
+ );
+ if ( successful.length === 0 ) {
+ // Every mutation in the cycle failed: nothing succeeded,
+ // so no sync event, legacy event, or announcement fires.
+ return;
+ }
+
+ const anyAdd = successful.some(
+ ( entry ) => entry.meta.origin === 'add'
+ );
+
+ // Preserve today's relative order: the legacy event fires
+ // before the sync event.
+ if ( anyAdd ) {
+ triggerAddedToCartEvent( { preserveCartData: true } );
+ }
+
+ emitSyncEvent( {
+ quantityChanges: mergeQuantityChanges(
+ successful.map(
+ ( entry ) => entry.meta.quantityChanges
+ )
+ ),
+ } );
+
+ if ( anyAdd ) {
+ const { messages } = getConfig(
+ 'woocommerce'
+ ) as WooCommerceConfig;
+ const text = messages?.addedToCartText;
+ if ( text ) {
+ if ( speakFn ) {
+ speakFn( text, 'polite' );
+ } else if ( a11yPromise ) {
+ a11yPromise
+ .then( () => {
+ speakFn?.( text, 'polite' );
+ } )
+ .catch( () => {
+ // Swallowed: a failed a11y chunk load
+ // degrades the announcement to a no-op.
+ } );
+ }
+ }
+ }
+ },
} );
}
@@ -465,11 +625,6 @@ const { actions } = store< Store >(
},
actions: {
*removeCartItem( key: string ): AsyncAction< void > {
- // Track what changes we're making for the sync event.
- const quantityChanges: QuantityChanges = {
- cartItemsPendingDelete: [ key ],
- };
-
// Capture cart state after optimistic updates for notice comparison.
let cartAfterOptimistic: typeof state.cart | null = null;
@@ -487,13 +642,11 @@ const { actions } = store< Store >(
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 } );
- }
+ meta: {
+ quantityChanges: {
+ cartItemsPendingDelete: [ key ],
+ },
+ origin: 'remove',
},
} ) ) as TypeYield< typeof sendCartRequest >;
@@ -547,7 +700,7 @@ const { actions } = store< Store >(
);
}
- const a11yModulePromise = import( '@wordpress/a11y' );
+ preloadA11y();
// Find existing item
const existingItem = state.findItemInCart( {
@@ -673,8 +826,8 @@ const { actions } = store< Store >(
// Captured at the request-settlement boundary (the line right
// after the request-sending yield resolves/throws) and never
// overwritten afterwards. A throw from later post-success
- // processing (notices, a11y announcement) must not downgrade an
- // already-captured success.
+ // processing (notices) must not downgrade an already-captured
+ // success.
let outcome: AddCartItemOutcome | undefined;
try {
@@ -712,26 +865,13 @@ const { actions } = store< Store >(
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 } );
- }
- },
+ meta: { quantityChanges, origin: 'add' },
} ) ) as TypeYield< typeof sendCartRequest >;
// The request settled successfully. Capture the outcome
- // immediately so any later throw in this block (a11y
- // chunk-load failure, notices import rejection, response-shape
- // assertion) cannot downgrade it to a failure.
+ // immediately so any later throw in this block (notices
+ // import rejection, response-shape assertion) cannot
+ // downgrade it to a failure.
outcome = { success: true };
// Success - handle side effects that don't trigger refreshCartItems
@@ -763,18 +903,6 @@ const { actions } = store< Store >(
true
);
}
-
- // Announce to screen readers
- const { messages } = getConfig(
- 'woocommerce'
- ) as WooCommerceConfig;
- if ( messages?.addedToCartText ) {
- const { speak } =
- ( yield a11yModulePromise ) as Awaited<
- typeof a11yModulePromise
- >;
- speak( messages.addedToCartText, 'polite' );
- }
} catch ( error ) {
// Show error notice
void actions.showNoticeError( error as Error );
@@ -803,8 +931,7 @@ const { actions } = store< Store >(
items: ClientCartItem[],
{ showCartUpdatesNotices = true }: CartUpdateOptions = {}
): AsyncAction< void > {
- const a11yModulePromise = import( '@wordpress/a11y' );
- const quantityChanges: QuantityChanges = {};
+ preloadA11y();
try {
// Per-product capture for the keyless-add exactness test,
@@ -831,7 +958,7 @@ const { actions } = store< Store >(
string,
BatchProductCapture
>();
- const promises = items.map( ( item, index ) => {
+ const promises = items.map( ( item ) => {
const existingItem = state.findItemInCart( {
id: item.id,
key: item.key,
@@ -858,6 +985,7 @@ const { actions } = store< Store >(
const endpoint = isUpdate ? 'update-item' : 'add-item';
let itemToSend: OptimisticCartItem;
+ let itemMeta: CartMutationMeta;
if ( isUpdate && existingItem ) {
// Caller-keyed update: target the exact line by key
// and send the absolute target quantity to the
@@ -867,11 +995,14 @@ const { actions } = store< Store >(
id: existingItem.id,
quantity,
} as OptimisticCartItem;
- quantityChanges.cartItemsPendingQuantity = [
- ...( quantityChanges.cartItemsPendingQuantity ??
- [] ),
- existingItem.key as string,
- ];
+ itemMeta = {
+ quantityChanges: {
+ cartItemsPendingQuantity: [
+ existingItem.key as string,
+ ],
+ },
+ origin: 'add',
+ };
} else {
// Keyless add: build a fresh payload for the add-item
// endpoint and never copy the matched line's key. As in
@@ -892,10 +1023,12 @@ const { actions } = store< Store >(
variation: item.variation,
} ),
} as OptimisticCartItem;
- quantityChanges.productsPendingAdd = [
- ...( quantityChanges.productsPendingAdd ?? [] ),
- item.id,
- ];
+ itemMeta = {
+ quantityChanges: {
+ productsPendingAdd: [ item.id ],
+ },
+ origin: 'add',
+ };
// Accumulate the per-product capture for the exactness
// test. On the first encounter of each product token,
@@ -944,8 +1077,6 @@ const { actions } = store< Store >(
}
}
- const isLastItem = index === items.length - 1;
-
return sendCartRequest( state, {
path: `/wc/store/v1/cart/${ endpoint }`,
method: 'POST',
@@ -967,24 +1098,7 @@ const { actions } = store< Store >(
state.cart.items.push( itemToSend );
}
},
- // 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.
- ...( isLastItem && {
- onSettled: ( { data } ) => {
- if ( data ) {
- triggerAddedToCartEvent( {
- preserveCartData: true,
- } );
- emitSyncEvent( {
- quantityChanges,
- } );
- }
- },
- } ),
+ meta: itemMeta,
} );
} );
@@ -997,7 +1111,7 @@ const { actions } = store< Store >(
promises
) ) as PromiseSettledResult< MutationResult< Cart > >[];
- // Find the last successful result for notices/a11y.
+ // Find the last successful result for notices.
const lastSuccess = [ ...results ]
.reverse()
.find(
@@ -1032,17 +1146,6 @@ const { actions } = store< Store >(
true
);
}
-
- const { messages } = getConfig(
- 'woocommerce'
- ) as WooCommerceConfig;
- if ( messages?.addedToCartText ) {
- const { speak } =
- ( yield a11yModulePromise ) as Awaited<
- typeof a11yModulePromise
- >;
- speak( messages.addedToCartText, 'polite' );
- }
}
// Show error notices for failed items.
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/mutation-batcher.ts b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/mutation-batcher.ts
index a4facb5abb6..5195ee0a8c3 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/mutation-batcher.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/mutation-batcher.ts
@@ -13,17 +13,17 @@
* individual request's success or failure within the batch.
*/
-export type MutationRequest< TState = unknown > = {
+export type MutationRequest< TMeta = 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.
+ * Opaque, caller-defined data associated with this request. The batcher
+ * never reads or mutates it; it is carried through unchanged to this
+ * request's entry in the queue's onCycleSettled callback.
*/
- onSettled?: ( result: MutationResult< TState > ) => void;
+ meta?: TMeta;
};
export type MutationResult< TState = unknown > = {
@@ -32,30 +32,55 @@ export type MutationResult< TState = unknown > = {
error?: Error;
};
+/**
+ * A single request's outcome as reported to a queue's onCycleSettled
+ * callback.
+ */
+export type CycleSettledEntry< TMeta = unknown > = {
+ /**
+ * Whether this request succeeded within the cycle (the same definition
+ * used for its own settled promise: no per-item error was recorded).
+ */
+ success: boolean;
+ /**
+ * The opaque meta value passed to submit() for this request, if any.
+ */
+ meta?: TMeta;
+};
+
type BatchItemResponse = {
status: number;
body: unknown;
headers?: Record< string, string >;
};
-export type MutationQueueConfig< TState = unknown > = {
+export type MutationQueueConfig< TState = unknown, TMeta = unknown > = {
endpoint: string;
getHeaders: () => Record< string, string >;
fetchHandler?: typeof fetch;
takeSnapshot: () => TState;
rollback: ( snapshot: TState ) => void;
commit: ( serverState: TState ) => void;
+ /**
+ * Called synchronously once per reconciliation cycle, after the cycle's
+ * commit/rollback and before isProcessing clears. Receives one entry per
+ * request submitted during the cycle, in submission order — including
+ * cycles where every request failed. Exceptions thrown by this callback
+ * are caught and logged; they never interrupt reconciliation or affect
+ * individual requests' own settlement.
+ */
+ onCycleSettled?: ( settled: Array< CycleSettledEntry< TMeta > > ) => void;
};
-type TrackedRequest< TState = unknown > = {
+type TrackedRequest< TMeta = unknown > = {
id: string;
- request: MutationRequest< TState >;
+ request: MutationRequest< TMeta >;
resolve: ( result: MutationResult ) => void;
reject: ( error: Error ) => void;
};
-export function createMutationQueue< TState >(
- config: MutationQueueConfig< TState >
+export function createMutationQueue< TState, TMeta = unknown >(
+ config: MutationQueueConfig< TState, TMeta >
) {
const {
endpoint,
@@ -70,7 +95,7 @@ export function createMutationQueue< TState >(
let snapshot: TState | null = null;
// All tracked requests for the current cycle.
- const trackedRequests: Map< string, TrackedRequest< TState > > = new Map();
+ const trackedRequests: Map< string, TrackedRequest< TMeta > > = new Map();
// Requests collected this tick, waiting to be sent.
let pendingIds: string[] = [];
@@ -97,16 +122,25 @@ export function createMutationQueue< TState >(
rollback( snapshot );
}
- // Run onSettled callbacks while isProcessing is still true.
- // This prevents refreshCartItems from running during these callbacks.
+ // Build the per-cycle settled list, one entry per tracked request in
+ // submission order, and notify the single per-cycle hook — also
+ // while isProcessing is still true. Exceptions are contained here so
+ // a misbehaving hook can never break reconciliation.
+ const settled: Array< CycleSettledEntry< TMeta > > = [];
trackedRequests.forEach( ( tracked ) => {
- const error = errors.get( tracked.id );
- tracked.request.onSettled?.( {
- success: ! error,
- ...( lastServerState !== null && { data: lastServerState } ),
- ...( error && { error } ),
+ settled.push( {
+ success: ! errors.get( tracked.id ),
+ ...( tracked.request.meta !== undefined && {
+ meta: tracked.request.meta,
+ } ),
} );
} );
+ try {
+ config.onCycleSettled?.( settled );
+ } catch ( error ) {
+ // eslint-disable-next-line no-console
+ console.error( error );
+ }
isProcessing = false;
@@ -251,7 +285,7 @@ export function createMutationQueue< TState >(
// submit - Queues a request. First call in a cycle takes a snapshot.
function submit(
- request: MutationRequest< TState >
+ request: MutationRequest< TMeta >
): Promise< MutationResult< TState > > {
return new Promise( ( resolve, reject ) => {
const id = String( nextId++ );
@@ -309,6 +343,6 @@ export function createMutationQueue< TState >(
return { submit, getStatus, waitForIdle };
}
-export type MutationQueue< TState = unknown > = ReturnType<
- typeof createMutationQueue< TState >
+export type MutationQueue< TState = unknown, TMeta = unknown > = ReturnType<
+ typeof createMutationQueue< TState, TMeta >
>;
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/test/cart.ts b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/test/cart.ts
index c1769771e67..b12b68a78b8 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/test/cart.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/test/cart.ts
@@ -3,11 +3,14 @@
*/
import type { Cart, CartItem } from '@woocommerce/types';
import type { Notice } from '@woocommerce/stores/store-notices';
+import { getConfig } from '@wordpress/interactivity';
+import { speak } from '@wordpress/a11y';
/**
* Internal dependencies
*/
import type { Store, OptimisticCartItem, AddCartItemOutcome } from '../cart';
+import { triggerAddedToCartEvent } from '../legacy-events';
type MockStore = { state: Store[ 'state' ]; actions: Store[ 'actions' ] };
@@ -47,6 +50,10 @@ jest.mock( '../legacy-events', () => ( {
triggerAddedToCartEvent: jest.fn(),
} ) );
+jest.mock( '@wordpress/a11y', () => ( {
+ speak: jest.fn(),
+} ) );
+
/**
* Captured representation of a single mutation sent through the batch endpoint.
*/
@@ -404,6 +411,68 @@ function mockBatchFetchWholeBatchFailure(): void {
) as unknown as typeof fetch;
}
+/**
+ * The `quantityChanges` shape carried by the sync event's detail. Mirrors the
+ * cart module's internal (unexported) `QuantityChanges` type.
+ */
+type QuantityChangesLike = {
+ cartItemsPendingQuantity?: string[];
+ cartItemsPendingDelete?: string[];
+ productsPendingAdd?: number[];
+};
+
+/**
+ * Installs a listener that captures every `wc-blocks_store_sync_required`
+ * event dispatched on `window`, in dispatch order.
+ *
+ * @return An object with the accumulating `events` list and a `cleanup`
+ * function the caller must invoke once the test is done asserting,
+ * so the listener does not leak into later tests.
+ */
+function captureSyncEvents(): {
+ events: Array< { type: string; quantityChanges: QuantityChangesLike } >;
+ cleanup: () => void;
+} {
+ const events: Array< {
+ type: string;
+ quantityChanges: QuantityChangesLike;
+ } > = [];
+ const listener = ( event: Event ) => {
+ events.push(
+ (
+ event as CustomEvent< {
+ type: string;
+ quantityChanges: QuantityChangesLike;
+ } >
+ ).detail
+ );
+ };
+ window.addEventListener( 'wc-blocks_store_sync_required', listener );
+ return {
+ events,
+ cleanup: () =>
+ window.removeEventListener(
+ 'wc-blocks_store_sync_required',
+ listener
+ ),
+ };
+}
+
+/**
+ * Waits for a macrotask boundary, letting every microtask (promise
+ * `.then`/`.catch` chain) queued so far run to completion.
+ *
+ * Used after driving an action to its generator's `done` return value, since
+ * the screen-reader announcement may still be chained on the a11y module's
+ * import promise and settle in a microtask queued during — but not
+ * necessarily flushed by the end of — the action's own settlement.
+ *
+ * @return A promise that resolves after the next macrotask tick.
+ */
+function flushMicrotasks(): Promise< void > {
+ return new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
+}
+
/**
* Builds a minimal server-confirmed cart line carrying a key.
*
@@ -427,6 +496,11 @@ function makeKeyedLine( overrides: Partial< CartItem > = {} ): CartItem {
describe( 'WooCommerce Cart Interactivity API Store', () => {
afterEach( () => {
jest.clearAllMocks();
+ // `getConfig` may have been given a per-test `mockReturnValue` (e.g. to
+ // configure `addedToCartText`); `clearAllMocks` only clears call
+ // history, so reset it explicitly to avoid leaking config into later
+ // tests.
+ ( getConfig as jest.Mock ).mockReset();
delete ( mockState as Partial< Store[ 'state' ] > ).cart;
} );
@@ -1624,4 +1698,414 @@ describe( 'WooCommerce Cart Interactivity API Store', () => {
expect( mockState.cart.items[ 0 ].quantity ).toBe( 3 );
} );
} );
+
+ describe( 'onCycleSettled cross-cutting effects', () => {
+ it( 'dispatches exactly one sync event, one legacy event, and one announcement for a single successful addCartItem call', async () => {
+ ( getConfig as jest.Mock ).mockReturnValue( {
+ messages: { addedToCartText: 'Added to your cart.' },
+ } );
+ mockBatchFetch();
+ const actions = await loadCartStore();
+ seedCart( [] );
+ const { events, cleanup } = captureSyncEvents();
+
+ await runAction(
+ actions.addCartItem( {
+ id: 1,
+ quantityToAdd: 1,
+ type: 'simple',
+ } )
+ );
+ await flushMicrotasks();
+
+ expect( events ).toHaveLength( 1 );
+ expect( events[ 0 ].type ).toBe( 'from_iAPI' );
+ expect( triggerAddedToCartEvent ).toHaveBeenCalledTimes( 1 );
+ expect( triggerAddedToCartEvent ).toHaveBeenCalledWith( {
+ preserveCartData: true,
+ } );
+ expect( speak ).toHaveBeenCalledTimes( 1 );
+ expect( speak ).toHaveBeenCalledWith(
+ 'Added to your cart.',
+ 'polite'
+ );
+
+ cleanup();
+ } );
+
+ it( 'dispatches exactly one sync event, one legacy event, and one announcement for a cycle of concurrent successful addCartItem calls', async () => {
+ ( getConfig as jest.Mock ).mockReturnValue( {
+ messages: { addedToCartText: 'Added to your cart.' },
+ } );
+ const captured = mockBatchFetch();
+ const actions = await loadCartStore();
+ seedCart( [] );
+ const { events, cleanup } = captureSyncEvents();
+
+ await Promise.all( [
+ runAction(
+ actions.addCartItem( {
+ id: 1,
+ quantityToAdd: 1,
+ type: 'simple',
+ } )
+ ),
+ runAction(
+ actions.addCartItem( {
+ id: 2,
+ quantityToAdd: 1,
+ type: 'simple',
+ } )
+ ),
+ runAction(
+ actions.addCartItem( {
+ id: 3,
+ quantityToAdd: 1,
+ type: 'simple',
+ } )
+ ),
+ ] );
+ await flushMicrotasks();
+
+ // Sanity check: all three mutations landed in the same cycle/batch.
+ expect( captured ).toHaveLength( 3 );
+ expect( events ).toHaveLength( 1 );
+ expect( triggerAddedToCartEvent ).toHaveBeenCalledTimes( 1 );
+ expect( speak ).toHaveBeenCalledTimes( 1 );
+
+ cleanup();
+ } );
+
+ it( 'still dispatches exactly one sync event for a cycle mixing a successful and a failed mutation, and surfaces the failure as its own error notice', async () => {
+ ( getConfig as jest.Mock ).mockReturnValue( {} );
+ mockBatchFetchFailingProduct( { failForId: 99 } );
+ const actions = await loadCartStore();
+ seedCart( [] );
+ const { events, cleanup } = captureSyncEvents();
+ const errors = spyOnShowNoticeError();
+
+ const [ acceptedOutcome, rejectedOutcome ] = await Promise.all( [
+ runAction(
+ actions.addCartItem( {
+ id: 42,
+ quantityToAdd: 1,
+ type: 'simple',
+ } )
+ ),
+ runAction(
+ actions.addCartItem( {
+ id: 99,
+ quantityToAdd: 1,
+ type: 'simple',
+ } )
+ ),
+ ] );
+ await flushMicrotasks();
+
+ expect( acceptedOutcome ).toEqual( { success: true } );
+ expect( ( rejectedOutcome as AddCartItemOutcome ).success ).toBe(
+ false
+ );
+ expect( events ).toHaveLength( 1 );
+ expect( errors ).toHaveLength( 1 );
+
+ cleanup();
+ } );
+
+ it( 'dispatches no sync event and no announcement when every mutation in the cycle fails, while the failed mutation still surfaces its own error notice', async () => {
+ ( getConfig as jest.Mock ).mockReturnValue( {
+ messages: { addedToCartText: 'Added to your cart.' },
+ } );
+ mockBatchFetchFailing( {
+ failForPath: '/wc/store/v1/cart/add-item',
+ status: 400,
+ } );
+ const actions = await loadCartStore();
+ seedCart( [ makeKeyedLine( { id: 42, quantity: 3 } ) ] );
+ const { events, cleanup } = captureSyncEvents();
+ const errors = spyOnShowNoticeError();
+
+ await runAction(
+ actions.addCartItem( {
+ id: 42,
+ quantityToAdd: 1,
+ type: 'simple',
+ } )
+ );
+ await flushMicrotasks();
+
+ expect( events ).toHaveLength( 0 );
+ expect( triggerAddedToCartEvent ).not.toHaveBeenCalled();
+ expect( speak ).not.toHaveBeenCalled();
+ expect( errors ).toHaveLength( 1 );
+
+ cleanup();
+ } );
+
+ it( 'unions quantityChanges across successful add, keyed-update, and remove mutations in the same cycle', async () => {
+ ( getConfig as jest.Mock ).mockReturnValue( {} );
+ mockBatchFetch();
+ const actions = await loadCartStore();
+ seedCart( [
+ makeKeyedLine( {
+ key: 'server-key-abc',
+ id: 42,
+ quantity: 3,
+ } ),
+ makeKeyedLine( {
+ key: 'server-key-gone',
+ id: 7,
+ quantity: 1,
+ } ),
+ ] );
+ const { events, cleanup } = captureSyncEvents();
+
+ await Promise.all( [
+ runAction(
+ actions.addCartItem( {
+ id: 99,
+ quantityToAdd: 1,
+ type: 'simple',
+ } )
+ ),
+ runAction(
+ actions.addCartItem( {
+ id: 42,
+ key: 'server-key-abc',
+ quantity: 5,
+ type: 'simple',
+ } )
+ ),
+ runAction( actions.removeCartItem( 'server-key-gone' ) ),
+ ] );
+ await flushMicrotasks();
+
+ expect( events ).toHaveLength( 1 );
+ expect( events[ 0 ].quantityChanges ).toEqual( {
+ productsPendingAdd: [ 99 ],
+ cartItemsPendingQuantity: [ 'server-key-abc' ],
+ cartItemsPendingDelete: [ 'server-key-gone' ],
+ } );
+ expect( triggerAddedToCartEvent ).toHaveBeenCalledTimes( 1 );
+
+ cleanup();
+ } );
+
+ it( 'dedupes a repeated product id in the sync event quantityChanges when the same product succeeds twice in one cycle', async () => {
+ ( getConfig as jest.Mock ).mockReturnValue( {} );
+ mockBatchFetch();
+ const actions = await loadCartStore();
+ seedCart( [] );
+ const { events, cleanup } = captureSyncEvents();
+
+ await Promise.all( [
+ runAction(
+ actions.addCartItem( {
+ id: 42,
+ quantityToAdd: 1,
+ type: 'simple',
+ } )
+ ),
+ runAction(
+ actions.addCartItem( {
+ id: 42,
+ quantityToAdd: 1,
+ type: 'simple',
+ } )
+ ),
+ ] );
+ await flushMicrotasks();
+
+ expect( events ).toHaveLength( 1 );
+ expect( events[ 0 ].quantityChanges ).toEqual( {
+ productsPendingAdd: [ 42 ],
+ } );
+
+ cleanup();
+ } );
+
+ it( 'dispatches the sync event but not the legacy event or announcement for a successful remove-only cycle', async () => {
+ ( getConfig as jest.Mock ).mockReturnValue( {
+ messages: { addedToCartText: 'Added to your cart.' },
+ } );
+ mockBatchFetch();
+ const actions = await loadCartStore();
+ seedCart( [
+ makeKeyedLine( {
+ key: 'server-key-abc',
+ id: 42,
+ quantity: 3,
+ } ),
+ ] );
+ const { events, cleanup } = captureSyncEvents();
+
+ await runAction( actions.removeCartItem( 'server-key-abc' ) );
+ await flushMicrotasks();
+
+ expect( events ).toHaveLength( 1 );
+ expect( events[ 0 ].quantityChanges ).toEqual( {
+ cartItemsPendingDelete: [ 'server-key-abc' ],
+ } );
+ expect( triggerAddedToCartEvent ).not.toHaveBeenCalled();
+ expect( speak ).not.toHaveBeenCalled();
+
+ cleanup();
+ } );
+
+ it( 'dispatches one merged sync event, one legacy event, and one announcement for a cycle mixing a successful add and a successful remove', async () => {
+ ( getConfig as jest.Mock ).mockReturnValue( {
+ messages: { addedToCartText: 'Added to your cart.' },
+ } );
+ mockBatchFetch();
+ const actions = await loadCartStore();
+ seedCart( [
+ makeKeyedLine( {
+ key: 'server-key-gone',
+ id: 7,
+ quantity: 1,
+ } ),
+ ] );
+ const { events, cleanup } = captureSyncEvents();
+
+ await Promise.all( [
+ runAction(
+ actions.addCartItem( {
+ id: 99,
+ quantityToAdd: 1,
+ type: 'simple',
+ } )
+ ),
+ runAction( actions.removeCartItem( 'server-key-gone' ) ),
+ ] );
+ await flushMicrotasks();
+
+ expect( events ).toHaveLength( 1 );
+ expect( events[ 0 ].quantityChanges ).toEqual( {
+ productsPendingAdd: [ 99 ],
+ cartItemsPendingDelete: [ 'server-key-gone' ],
+ } );
+ expect( triggerAddedToCartEvent ).toHaveBeenCalledTimes( 1 );
+ expect( speak ).toHaveBeenCalledTimes( 1 );
+
+ cleanup();
+ } );
+
+ it( "dispatches exactly one sync/legacy/announcement for a batchAddCartItems call where the last item fails but an earlier item succeeds, unioning only the successful item's changes", async () => {
+ ( getConfig as jest.Mock ).mockReturnValue( {
+ messages: { addedToCartText: 'Added to your cart.' },
+ } );
+ mockBatchFetchFailingProduct( { failForId: 99 } );
+ const actions = await loadCartStore();
+ seedCart( [] );
+ const { events, cleanup } = captureSyncEvents();
+ const notices = spyOnUpdateNotices();
+
+ await runAction(
+ actions.batchAddCartItems( [
+ { id: 42, quantityToAdd: 1, type: 'simple' },
+ { id: 99, quantityToAdd: 1, type: 'simple' },
+ ] )
+ );
+ await flushMicrotasks();
+
+ expect( events ).toHaveLength( 1 );
+ expect( events[ 0 ].quantityChanges ).toEqual( {
+ productsPendingAdd: [ 42 ],
+ } );
+ expect( triggerAddedToCartEvent ).toHaveBeenCalledTimes( 1 );
+ expect( speak ).toHaveBeenCalledTimes( 1 );
+ // The failed item (id 99) still surfaces its own error notice.
+ expect(
+ notices.some(
+ ( n ) => n.type === 'error' && n.notice.includes( '99' )
+ ) ||
+ notices.some(
+ ( n ) =>
+ n.type === 'error' &&
+ n.notice.includes( 'cannot add' )
+ )
+ ).toBe( true );
+
+ cleanup();
+ } );
+
+ it( 'announces once via the already-resolved a11y binding on a later call in the same store instance', async () => {
+ ( getConfig as jest.Mock ).mockReturnValue( {
+ messages: { addedToCartText: 'Added to your cart.' },
+ } );
+ mockBatchFetch();
+ const actions = await loadCartStore();
+ seedCart( [] );
+
+ // First call kicks off — and, given the many awaits already
+ // driven through by `runAction`, very likely resolves — the
+ // preloaded a11y module import.
+ await runAction(
+ actions.addCartItem( {
+ id: 1,
+ quantityToAdd: 1,
+ type: 'simple',
+ } )
+ );
+ await flushMicrotasks();
+
+ // A second call's announcement — whether it takes the
+ // already-resolved synchronous branch or still has to chain on
+ // the import promise — must still fire exactly once more.
+ await runAction(
+ actions.addCartItem( {
+ id: 2,
+ quantityToAdd: 1,
+ type: 'simple',
+ } )
+ );
+ await flushMicrotasks();
+
+ expect( speak ).toHaveBeenCalledTimes( 2 );
+ expect( speak ).toHaveBeenNthCalledWith(
+ 2,
+ 'Added to your cart.',
+ 'polite'
+ );
+ } );
+
+ it( 'does not let an a11y announcement failure affect the mutation outcome or the already-dispatched sync/legacy events', async () => {
+ ( getConfig as jest.Mock ).mockReturnValue( {
+ messages: { addedToCartText: 'Added to your cart.' },
+ } );
+ mockBatchFetch();
+ const actions = await loadCartStore();
+ seedCart( [] );
+
+ // Warm up: resolve the a11y binding via a first successful call.
+ await runAction(
+ actions.addCartItem( {
+ id: 1,
+ quantityToAdd: 1,
+ type: 'simple',
+ } )
+ );
+ await flushMicrotasks();
+
+ ( speak as jest.Mock ).mockImplementation( () => {
+ throw new Error( 'a11y unavailable' );
+ } );
+
+ const { events, cleanup } = captureSyncEvents();
+ const outcome = await runAction(
+ actions.addCartItem( {
+ id: 2,
+ quantityToAdd: 1,
+ type: 'simple',
+ } )
+ );
+ await flushMicrotasks();
+
+ expect( outcome ).toEqual( { success: true } );
+ expect( events ).toHaveLength( 1 );
+ expect( triggerAddedToCartEvent ).toHaveBeenCalledTimes( 2 );
+
+ cleanup();
+ } );
+ } );
} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/test/mutation-batcher.test.ts b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/test/mutation-batcher.test.ts
index ab48d9aae12..a7c3efbc10e 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/test/mutation-batcher.test.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/test/mutation-batcher.test.ts
@@ -277,106 +277,170 @@ describe( 'createMutationQueue', () => {
} );
} );
- describe( 'onSettled callback timing', () => {
- it( 'runs onSettled before isProcessing clears', async () => {
+ describe( 'onCycleSettled callback', () => {
+ it( 'is invoked exactly once per cycle, synchronously while isProcessing is still true', async () => {
const mockFetch = createMockFetch( [
{ status: 200, body: { value: 1 } },
+ { status: 200, body: { value: 2 } },
] );
global.fetch = mockFetch;
+ let isProcessingDuringCycleSettled: boolean | undefined;
+ const onCycleSettled = jest.fn();
+
const queue = createMutationQueue( {
endpoint: '/batch',
getHeaders: () => ( {} ),
...stateHandler,
- } );
-
- let isProcessingDuringOnSettled: boolean | undefined;
-
- await queue.submit( {
- id: '1',
- path: '/a',
- method: 'POST',
- onSettled: () => {
- isProcessingDuringOnSettled =
+ onCycleSettled: ( settled ) => {
+ isProcessingDuringCycleSettled =
queue.getStatus().isProcessing;
+ onCycleSettled( settled );
},
} );
- // onSettled should run while isProcessing is still true
- expect( isProcessingDuringOnSettled ).toBe( true );
+ await Promise.all( [
+ queue.submit( { path: '/a', method: 'POST' } ),
+ queue.submit( { path: '/b', method: 'POST' } ),
+ ] );
- // After promise resolves, isProcessing should be false
+ expect( onCycleSettled ).toHaveBeenCalledTimes( 1 );
+ expect( isProcessingDuringCycleSettled ).toBe( true );
expect( queue.getStatus().isProcessing ).toBe( false );
} );
- it( 'provides success status and data to onSettled', async () => {
- const serverState = { value: 42 };
+ it( 'is invoked for a cycle in which every request failed, with all entries success: false', async () => {
const mockFetch = createMockFetch( [
- { status: 200, body: serverState },
+ { status: 400, body: { message: 'Error 1' } },
+ { status: 500, body: { message: 'Error 2' } },
] );
global.fetch = mockFetch;
+ const onCycleSettled = jest.fn();
+
const queue = createMutationQueue( {
endpoint: '/batch',
getHeaders: () => ( {} ),
...stateHandler,
+ onCycleSettled,
} );
- let settledResult:
- | { success: boolean; data?: TestState }
- | undefined;
+ const p1 = queue.submit( { path: '/a', method: 'POST' } );
+ const p2 = queue.submit( { path: '/b', method: 'POST' } );
- await queue.submit( {
- id: '1',
+ await expect( p1 ).rejects.toThrow();
+ await expect( p2 ).rejects.toThrow();
+
+ expect( onCycleSettled ).toHaveBeenCalledTimes( 1 );
+ expect( onCycleSettled ).toHaveBeenCalledWith( [
+ { success: false, meta: undefined },
+ { success: false, meta: undefined },
+ ] );
+ } );
+
+ it( 'provides one entry per submitted request, in submission order, with per-item success and meta', async () => {
+ const mockFetch = createMockFetch( [
+ { status: 200, body: { value: 1 } },
+ { status: 400, body: { message: 'Bad request' } },
+ { status: 200, body: { value: 2 } },
+ ] );
+ global.fetch = mockFetch;
+
+ const onCycleSettled = jest.fn();
+
+ const queue = createMutationQueue( {
+ endpoint: '/batch',
+ getHeaders: () => ( {} ),
+ ...stateHandler,
+ onCycleSettled,
+ } );
+
+ const p1 = queue.submit( {
path: '/a',
method: 'POST',
- onSettled: ( result ) => {
- settledResult = result;
- },
+ meta: { origin: 'a' },
} );
+ const p2 = queue.submit( {
+ path: '/b',
+ method: 'POST',
+ meta: { origin: 'b' },
+ } );
+ const p3 = queue.submit( {
+ path: '/c',
+ method: 'POST',
+ meta: { origin: 'c' },
+ } );
+
+ await Promise.allSettled( [ p1, p2, p3 ] );
+
+ expect( onCycleSettled ).toHaveBeenCalledWith( [
+ { success: true, meta: { origin: 'a' } },
+ { success: false, meta: { origin: 'b' } },
+ { success: true, meta: { origin: 'c' } },
+ ] );
+ } );
+
+ it( 'carries the exact meta value through to onCycleSettled without cloning', async () => {
+ const mockFetch = createMockFetch( [
+ { status: 200, body: { value: 1 } },
+ ] );
+ global.fetch = mockFetch;
- expect( settledResult ).toEqual( {
- success: true,
- data: serverState,
+ let receivedMeta: unknown;
+ const onCycleSettled = jest.fn(
+ ( settled: Array< { success: boolean; meta?: unknown } > ) => {
+ receivedMeta = settled[ 0 ].meta;
+ }
+ );
+
+ const queue = createMutationQueue( {
+ endpoint: '/batch',
+ getHeaders: () => ( {} ),
+ ...stateHandler,
+ onCycleSettled,
} );
+
+ const meta = { origin: 'cart-item-add', nested: { id: 1 } };
+
+ await queue.submit( { path: '/a', method: 'POST', meta } );
+
+ expect( receivedMeta ).toBe( meta );
} );
- it( 'provides error to onSettled on failure', async () => {
+ it( 'catches and logs a throwing onCycleSettled without affecting reconciliation or request outcomes', async () => {
const mockFetch = createMockFetch( [
- {
- status: 400,
- body: {
- message: 'Validation failed',
- code: 'validation_error',
- },
- },
+ { status: 200, body: { value: 1 } },
+ { status: 400, body: { message: 'Bad request' } },
] );
global.fetch = mockFetch;
+ const consoleErrorSpy = jest
+ .spyOn( console, 'error' )
+ .mockImplementation( () => {} );
+
+ const onCycleSettled = jest.fn( () => {
+ throw new Error( 'boom' );
+ } );
+
const queue = createMutationQueue( {
endpoint: '/batch',
getHeaders: () => ( {} ),
...stateHandler,
+ onCycleSettled,
} );
- let settledResult: { success: boolean; error?: Error } | undefined;
+ const p1 = queue.submit( { path: '/a', method: 'POST' } );
+ const p2 = queue.submit( { path: '/b', method: 'POST' } );
- // We need to catch the rejection
- try {
- await queue.submit( {
- id: '1',
- path: '/a',
- method: 'POST',
- onSettled: ( result ) => {
- settledResult = result;
- },
- } );
- } catch {
- // Expected
- }
+ await expect( p1 ).resolves.toMatchObject( { success: true } );
+ await expect( p2 ).rejects.toThrow( 'Bad request' );
+
+ expect( onCycleSettled ).toHaveBeenCalledTimes( 1 );
+ expect( consoleErrorSpy ).toHaveBeenCalledTimes( 1 );
+ expect( mockFetch ).toHaveBeenCalledTimes( 1 );
+ expect( queue.getStatus().isProcessing ).toBe( false );
- expect( settledResult?.success ).toBe( false );
- expect( settledResult?.error?.message ).toBe( 'Validation failed' );
+ consoleErrorSpy.mockRestore();
} );
} );
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/cart-store/mutation-batcher.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/cart-store/mutation-batcher.block_theme.spec.ts
index 1ce093d0a88..c4be682b4e4 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/cart-store/mutation-batcher.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/cart-store/mutation-batcher.block_theme.spec.ts
@@ -189,4 +189,332 @@ test.describe( 'Mutation Batcher', () => {
// to the snapshot taken before any optimistic mutations.
await expect( button ).toHaveText( '1 in cart' );
} );
+
+ test.describe( 'cross-cutting side effects fire once per cycle', () => {
+ // Cross-cutting effects (the `wc-blocks_store_sync_required` sync
+ // event and the legacy `wc-blocks_added_to_cart` event) are wired to
+ // the batcher's per-cycle settlement hook, not to each individual
+ // request. These tests lock that contract in: no matter how many
+ // mutations land in one microtick/cycle, each effect fires at most
+ // once for that cycle.
+
+ test( 'N concurrent successful adds fire each effect exactly once', async ( {
+ page,
+ } ) => {
+ // This project reuses one authenticated user's persistent cart
+ // across every test in the file (not a fresh guest cart per
+ // test), so start from a known-clean cart in a preliminary
+ // evaluate call, *before* the batch-request route is installed —
+ // otherwise the cleanup's own request(s) would pollute the count
+ // below.
+ 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 }
+ );
+
+ await actions.refreshCartItems();
+ const existingKeys = state.cart.items.map(
+ ( item: { key: string } ) => item.key
+ );
+ for ( const key of existingKeys ) {
+ await actions.removeCartItem( key );
+ }
+ } );
+
+ 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 }
+ );
+
+ // Register listeners before the gesture.
+ let syncEvents = 0;
+ window.addEventListener(
+ 'wc-blocks_store_sync_required',
+ ( event ) => {
+ if (
+ ( event as CustomEvent ).detail?.type ===
+ 'from_iAPI'
+ ) {
+ syncEvents++;
+ }
+ }
+ );
+ let addedToCartEvents = 0;
+ document.body.addEventListener(
+ 'wc-blocks_added_to_cart',
+ () => {
+ addedToCartEvents++;
+ }
+ );
+
+ // Three concurrent adds for three distinct products, no
+ // await between them — one microtick, one cycle.
+ const p1 = actions.addCartItem( { id: 15, quantityToAdd: 1 } );
+ const p2 = actions.addCartItem( { id: 16, quantityToAdd: 1 } );
+ const p3 = actions.addCartItem( { id: 17, quantityToAdd: 1 } );
+ await Promise.all( [ p1, p2, p3 ] );
+
+ return {
+ syncEvents,
+ addedToCartEvents,
+ cartProductIds: state.cart.items.map(
+ ( item: { id: number } ) => item.id
+ ),
+ };
+ } );
+
+ // One batch request carrying all three mutations.
+ expect( batchRequests ).toHaveLength( 1 );
+ expect( batchRequests[ 0 ] ).toBe( 3 );
+
+ // Exactly one of each cross-cutting effect for the whole cycle.
+ expect( result.syncEvents ).toBe( 1 );
+ expect( result.addedToCartEvents ).toBe( 1 );
+
+ // All three products landed in the cart.
+ expect( result.cartProductIds ).toContain( 15 );
+ expect( result.cartProductIds ).toContain( 16 );
+ expect( result.cartProductIds ).toContain( 17 );
+ } );
+
+ test( 'a concurrent remove-only cycle fires one sync event and no legacy event', 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();
+ } );
+
+ // Seed a clean cart with several server-keyed lines before the
+ // gesture under test, outside the batch-request count below.
+ const seededKeys = 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 }
+ );
+
+ await actions.refreshCartItems();
+ const existingKeys = state.cart.items.map(
+ ( item: { key: string } ) => item.key
+ );
+ for ( const key of existingKeys ) {
+ await actions.removeCartItem( key );
+ }
+
+ await actions.addCartItem( { id: 15, quantityToAdd: 1 } );
+ await actions.addCartItem( { id: 16, quantityToAdd: 1 } );
+ await actions.addCartItem( { id: 17, quantityToAdd: 1 } );
+
+ return state.cart.items.map(
+ ( item: { key: string } ) => item.key
+ );
+ } );
+
+ // Only count the batch request produced by the gesture below.
+ batchRequests.length = 0;
+
+ const result = await page.evaluate( async ( keys ) => {
+ 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 }
+ );
+
+ // Register listeners before the gesture.
+ let syncEvents = 0;
+ window.addEventListener(
+ 'wc-blocks_store_sync_required',
+ ( event ) => {
+ if (
+ ( event as CustomEvent ).detail?.type ===
+ 'from_iAPI'
+ ) {
+ syncEvents++;
+ }
+ }
+ );
+ let addedToCartEvents = 0;
+ document.body.addEventListener(
+ 'wc-blocks_added_to_cart',
+ () => {
+ addedToCartEvents++;
+ }
+ );
+
+ // Remove every seeded line concurrently — one microtick,
+ // one cycle, no adds at all.
+ const removals = keys.map( ( key: string ) =>
+ actions.removeCartItem( key )
+ );
+ await Promise.all( removals );
+
+ return {
+ syncEvents,
+ addedToCartEvents,
+ cartProductIds: state.cart.items.map(
+ ( item: { id: number } ) => item.id
+ ),
+ };
+ }, seededKeys );
+
+ // One batch request carrying every removal.
+ expect( batchRequests ).toHaveLength( 1 );
+ expect( batchRequests[ 0 ] ).toBe( seededKeys.length );
+
+ // One sync event, but the remove-only cycle never triggers the
+ // legacy added-to-cart event.
+ expect( result.syncEvents ).toBe( 1 );
+ expect( result.addedToCartEvents ).toBe( 0 );
+
+ // The targeted lines are gone from the cart.
+ expect( result.cartProductIds ).not.toContain( 15 );
+ expect( result.cartProductIds ).not.toContain( 16 );
+ expect( result.cartProductIds ).not.toContain( 17 );
+ } );
+
+ test( 'a mixed successful/failed add cycle fires each effect once and preserves the failed item', async ( {
+ page,
+ } ) => {
+ // This project reuses one authenticated user's persistent cart
+ // across every test in the file (not a fresh guest cart per
+ // test), so start from a known-clean cart in a preliminary
+ // evaluate call, *before* the batch-request route is installed —
+ // otherwise the cleanup's own request(s) would pollute the count
+ // below.
+ 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 }
+ );
+
+ await actions.refreshCartItems();
+ const existingKeys = state.cart.items.map(
+ ( item: { key: string } ) => item.key
+ );
+ for ( const key of existingKeys ) {
+ await actions.removeCartItem( key );
+ }
+ } );
+
+ 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 }
+ );
+
+ // Register listeners before the gesture.
+ let syncEvents = 0;
+ window.addEventListener(
+ 'wc-blocks_store_sync_required',
+ ( event ) => {
+ if (
+ ( event as CustomEvent ).detail?.type ===
+ 'from_iAPI'
+ ) {
+ syncEvents++;
+ }
+ }
+ );
+ let addedToCartEvents = 0;
+ document.body.addEventListener(
+ 'wc-blocks_added_to_cart',
+ () => {
+ addedToCartEvents++;
+ }
+ );
+
+ // Two valid adds and one invalid product id, no await
+ // between them — one microtick, one cycle.
+ const p1 = actions.addCartItem( { id: 15, quantityToAdd: 1 } );
+ const p2 = actions.addCartItem( {
+ id: 999999,
+ quantityToAdd: 1,
+ } ); // Invalid.
+ const p3 = actions.addCartItem( { id: 16, quantityToAdd: 1 } );
+
+ // addCartItem catches errors internally, so all three
+ // promises resolve rather than reject; Promise.allSettled
+ // just waits for all three to settle.
+ await Promise.allSettled( [ p1, p2, p3 ] );
+
+ return {
+ syncEvents,
+ addedToCartEvents,
+ cartProductIds: state.cart.items.map(
+ ( item: { id: number } ) => item.id
+ ),
+ };
+ } );
+
+ // One batch request carrying all three mutations.
+ expect( batchRequests ).toHaveLength( 1 );
+ expect( batchRequests[ 0 ] ).toBe( 3 );
+
+ // Exactly one of each cross-cutting effect for the whole cycle,
+ // since at least one add succeeded.
+ expect( result.syncEvents ).toBe( 1 );
+ expect( result.addedToCartEvents ).toBe( 1 );
+
+ // The valid products landed in the cart, the invalid one did not.
+ expect( result.cartProductIds ).toContain( 15 );
+ expect( result.cartProductIds ).toContain( 16 );
+ expect( result.cartProductIds ).not.toContain( 999999 );
+ } );
+ } );
} );