Commit 3e06c743503 for woocommerce
commit 3e06c7435034fa757268f9f0789e6bdde003a11d
Author: Luis Herranz <luisherranz@gmail.com>
Date: Wed Nov 11 12:52:04 2026 +0100
Document cart cycle DOM event contract and add regression coverage (#68015)
* Add iAPI cart cycle contract coverage
* Document cart cycle DOM event contracts
* Fix native cart event contract isolation
* Remove internal store sync event from public DOM event docs
* Rename changelog fragment to match upstream branch
* Correct added-to-cart producers in DOM events docs
Drop the product-form row: api-fetch rejects non-2xx responses when
called with parse: false, so that handler only dispatches on success,
and AddToCartFormContextProvider has no consumers.
List the useStoreCart() jQuery bridge alongside the Cart and Mini-Cart
ones, since up to three registrations can translate the same event.
* Move dev-only changelog text to a Comment
* Fix Prettier spacing in cart store test import type
diff --git a/docs/block-development/extensible-blocks/cart-and-checkout-blocks/dom-events.md b/docs/block-development/extensible-blocks/cart-and-checkout-blocks/dom-events.md
index babb8f1e82a..575cebd7bab 100644
--- a/docs/block-development/extensible-blocks/cart-and-checkout-blocks/dom-events.md
+++ b/docs/block-development/extensible-blocks/cart-and-checkout-blocks/dom-events.md
@@ -22,15 +22,33 @@ _Example usage in WC Blocks:_ Mini-Cart block listens to this event to append it
### `wc-blocks_added_to_cart`
-This event is the equivalent to the jQuery event `added_to_cart` triggered by WooCommerce core. It indicates that the process of adding a product to the cart has finished with success.
+This native event has several producers. Its cadence and meaning depend on the path that dispatched it; it does not always confirm that a particular add succeeded and is not guaranteed to fire only once for an underlying action.
-_Example usage in WC Blocks:_ Cart and Mini-Cart blocks (via the `useStoreCart()` hook) listen to this event to know if they need to update their contents.
+_Example usage in WC Blocks:_ Cart block (via the `useStoreCart()` hook) listens to this event to know if it needs to update its contents.
+
+#### Added-event paths
+
+| Path | Trigger and cadence | Target and options | `detail` and meaning |
+| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
+| Interactivity API (iAPI) cart cycle | Once after a settled mutation cycle that contains at least one successful add-origin mutation. It does not fire after an all-failed cycle or a successful remove-only cycle. | `document.body`; bubbles; cancelable | `{ preserveCartData: true }`. This is an aggregate cycle signal, not an acknowledgement for a particular caller. |
+| Classic `@wordpress/data` `addItemToCart` thunk | After each successful call. A caught API failure does not dispatch it. | `document.body`; bubbles; cancelable | `{ preserveCartData: true }`. |
+| jQuery `added_to_cart` bridges | Each active bridge registration translates every underlying jQuery event. Up to three registrations can be active at once: the Cart block's, the shared `useStoreCart()` one (a single registration while Cart, Checkout, or any other block using the hook is mounted), and the Mini-Cart's. There is no global exactly-once guarantee. | `document.body`; the Cart and `useStoreCart()` registrations do not bubble, the Mini-Cart registration bubbles, and none is cancelable | `{}`. This path reports translation of the jQuery event rather than sharing the cadence or payload of the other paths. |
+
+The direct iAPI and classic `@wordpress/data` paths have this envelope:
+
+```javascript
+new CustomEvent( 'wc-blocks_added_to_cart', {
+ bubbles: true,
+ cancelable: true,
+ detail: { preserveCartData: true },
+} );
+```
+
+The jQuery bridges use the same event name and target with `detail: {}` and `cancelable: false`; only the Mini-Cart registration sets `bubbles: true`.
-#### `detail` parameters
+For the iAPI path, a mutation cycle can contain work from unrelated callers. One successful add-origin mutation causes the single cycle event even when a sibling mutation from another caller fails. The failed caller still receives a failed `addCartItem` outcome. A successful keyed quantity update made through `addCartItem` also has add origin, so this event does not prove that a new cart line was created.
-| Parameter | Type | Default value | Description |
-| ------------------ | ------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `preserveCartData` | boolean | `false` | Whether Cart data in the store should be preserved. By default, it's `false` so any `wc-blocks_added_to_cart` event will invalidate cart data and blocks listening to it will refetch cart data again. However, if the code triggering the event already updates the store (ie: All Products block), it can set `preserveCartData: true` to avoid the other blocks refetching the data again. |
+`preserveCartData` tells listeners whether to retain current cart data. When it is `false`, listeners can invalidate and refetch cart data. Producers that have already updated the store set it to `true` to avoid an unnecessary refetch.
### `wc-blocks_removed_from_cart`
diff --git a/plugins/woocommerce/changelog/wooplug-7386-ai-regression-reviewtrunk-public-dom-events-wc b/plugins/woocommerce/changelog/wooplug-7386-ai-regression-reviewtrunk-public-dom-events-wc
new file mode 100644
index 00000000000..44999d06f0e
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-7386-ai-regression-reviewtrunk-public-dom-events-wc
@@ -0,0 +1,3 @@
+Significance: patch
+Type: dev
+Comment: Document and test the cart DOM event cycle contract; no runtime change.
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 b42f7f7cfff..c65a2f14765 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
@@ -457,6 +457,73 @@ function captureSyncEvents(): {
};
}
+/**
+ * A native cart event observed at its public DOM dispatch boundary.
+ */
+type NativeCartEventObservation = {
+ /** A short name identifying which cart event was observed. */
+ name: 'added' | 'sync';
+ /** The event target at the point of dispatch. */
+ target: EventTarget | null;
+ /** Whether the event bubbles through the DOM. */
+ bubbles: boolean;
+ /** Whether a listener can cancel the event. */
+ cancelable: boolean;
+ /** The native custom-event detail payload. */
+ detail: unknown;
+};
+
+/**
+ * Captures native added-to-cart and store-sync events at their documented DOM
+ * targets, preserving their dispatch order and event envelopes.
+ *
+ * @param onAdded Optional callback invoked synchronously from the added-event
+ * listener.
+ * @param onSync Optional callback invoked synchronously from the sync-event
+ * listener.
+ * @return The observations and a cleanup callback that removes both listeners.
+ */
+function captureNativeCartEvents(
+ onAdded?: () => void,
+ onSync?: () => void
+): {
+ events: NativeCartEventObservation[];
+ cleanup: () => void;
+} {
+ const events: NativeCartEventObservation[] = [];
+ const capture = ( name: 'added' | 'sync' ) => ( event: Event ) => {
+ events.push( {
+ name,
+ target: event.target,
+ bubbles: event.bubbles,
+ cancelable: event.cancelable,
+ detail: ( event as CustomEvent< unknown > ).detail,
+ } );
+ if ( name === 'added' ) {
+ onAdded?.();
+ } else {
+ onSync?.();
+ }
+ };
+ const addedListener = capture( 'added' );
+ const syncListener = capture( 'sync' );
+ document.body.addEventListener( 'wc-blocks_added_to_cart', addedListener );
+ window.addEventListener( 'wc-blocks_store_sync_required', syncListener );
+ return {
+ events,
+ cleanup: () => {
+ document.body.removeEventListener(
+ 'wc-blocks_added_to_cart',
+ addedListener
+ );
+ window.removeEventListener(
+ 'wc-blocks_store_sync_required',
+ syncListener
+ );
+ },
+ };
+}
+
/**
* Waits for a macrotask boundary, letting every microtask (promise
* `.then`/`.catch` chain) queued so far run to completion.
@@ -1880,10 +1947,15 @@ describe( 'WooCommerce Cart Interactivity API Store', () => {
await flushMicrotasks();
expect( acceptedOutcome ).toEqual( { success: true } );
- expect( ( rejectedOutcome as AddCartItemOutcome ).success ).toBe(
- false
- );
+ expect( rejectedOutcome ).toEqual( {
+ success: false,
+ error: {
+ code: 'woocommerce_rest_cart_product_no_stock',
+ message: 'You cannot add that amount to the cart.',
+ },
+ } );
expect( events ).toHaveLength( 1 );
+ expect( triggerAddedToCartEvent ).toHaveBeenCalledTimes( 1 );
expect( errors ).toHaveLength( 1 );
cleanup();
@@ -1919,9 +1991,9 @@ describe( 'WooCommerce Cart Interactivity API Store', () => {
cleanup();
} );
- it( 'unions quantityChanges across successful add, keyed-update, and remove mutations in the same cycle', async () => {
+ it( 'unions and deduplicates only successful quantityChanges across add, keyed-update, and remove mutations in the same cycle', async () => {
( getConfig as jest.Mock ).mockReturnValue( {} );
- mockBatchFetch();
+ mockBatchFetchFailingProduct( { failForId: 100 } );
const actions = await loadCartStore();
seedCart( [
makeKeyedLine( {
@@ -1936,8 +2008,16 @@ describe( 'WooCommerce Cart Interactivity API Store', () => {
} ),
] );
const { events, cleanup } = captureSyncEvents();
+ spyOnShowNoticeError();
- await Promise.all( [
+ const outcomes = await Promise.all( [
+ runAction(
+ actions.addCartItem( {
+ id: 99,
+ quantityToAdd: 1,
+ type: 'simple',
+ } )
+ ),
runAction(
actions.addCartItem( {
id: 99,
@@ -1945,6 +2025,13 @@ describe( 'WooCommerce Cart Interactivity API Store', () => {
type: 'simple',
} )
),
+ runAction(
+ actions.addCartItem( {
+ id: 100,
+ quantityToAdd: 1,
+ type: 'simple',
+ } )
+ ),
runAction(
actions.addCartItem( {
id: 42,
@@ -1953,10 +2040,25 @@ describe( 'WooCommerce Cart Interactivity API Store', () => {
type: 'simple',
} )
),
+ runAction(
+ actions.addCartItem( {
+ id: 42,
+ key: 'server-key-abc',
+ quantity: 6,
+ type: 'simple',
+ } )
+ ),
+ runAction( actions.removeCartItem( 'server-key-gone' ) ),
runAction( actions.removeCartItem( 'server-key-gone' ) ),
] );
- await flushMicrotasks();
+ expect( outcomes.slice( 0, 2 ) ).toEqual( [
+ { success: true },
+ { success: true },
+ ] );
+ expect( ( outcomes[ 2 ] as AddCartItemOutcome ).success ).toBe(
+ false
+ );
expect( events ).toHaveLength( 1 );
expect( events[ 0 ].quantityChanges ).toEqual( {
productsPendingAdd: [ 99 ],
@@ -1968,6 +2070,161 @@ describe( 'WooCommerce Cart Interactivity API Store', () => {
cleanup();
} );
+ it( 'treats a successful keyed quantity update as add-origin while reporting only its cart-item key', async () => {
+ ( getConfig as jest.Mock ).mockReturnValue( {} );
+ mockBatchFetch();
+ const actions = await loadCartStore();
+ seedCart( [ makeKeyedLine( { key: 'quantity-key', id: 42 } ) ] );
+ const { events, cleanup } = captureSyncEvents();
+
+ await runAction(
+ actions.addCartItem( {
+ id: 42,
+ key: 'quantity-key',
+ quantity: 4,
+ type: 'simple',
+ } )
+ );
+
+ expect( triggerAddedToCartEvent ).toHaveBeenCalledTimes( 1 );
+ expect( events ).toHaveLength( 1 );
+ expect( events[ 0 ].quantityChanges ).toEqual( {
+ cartItemsPendingQuantity: [ 'quantity-key' ],
+ } );
+ expect(
+ events[ 0 ].quantityChanges.productsPendingAdd
+ ).toBeUndefined();
+
+ cleanup();
+ } );
+
+ it( 'preserves submitted simple and selected-variation identities in requests and successful sync metadata', async () => {
+ ( getConfig as jest.Mock ).mockReturnValue( {} );
+ const captured = mockBatchFetch();
+ const actions = await loadCartStore();
+ seedCart( [] );
+ const { events, cleanup } = captureSyncEvents();
+ const selection = [
+ { attribute: 'Color', value: 'Blue' },
+ { attribute: 'Size', value: 'Large' },
+ ];
+
+ await Promise.all( [
+ runAction(
+ actions.addCartItem( {
+ id: 501,
+ quantityToAdd: 1,
+ type: 'variation',
+ variation: selection,
+ } )
+ ),
+ runAction(
+ actions.addCartItem( {
+ id: 77,
+ quantityToAdd: 1,
+ type: 'simple',
+ } )
+ ),
+ ] );
+
+ expect( captured[ 0 ].body ).toEqual( {
+ id: 501,
+ quantity: 1,
+ variation: selection,
+ } );
+ expect( captured[ 1 ].body ).toEqual( { id: 77, quantity: 1 } );
+ expect( events[ 0 ].quantityChanges ).toEqual( {
+ productsPendingAdd: [ 501, 77 ],
+ } );
+
+ cleanup();
+ } );
+
+ it( 'dispatches native added then sync events synchronously with their public envelopes', async () => {
+ ( getConfig as jest.Mock ).mockReturnValue( {} );
+ mockBatchFetch();
+ const actions = await loadCartStore();
+ seedCart( [] );
+ const triggerAddedToCartEventMock =
+ triggerAddedToCartEvent as jest.MockedFunction<
+ typeof triggerAddedToCartEvent
+ >;
+ const priorImplementation =
+ triggerAddedToCartEventMock.getMockImplementation();
+ const realTriggerAddedToCartEvent =
+ jest.requireActual< typeof import( '../legacy-events' ) >(
+ '../legacy-events'
+ ).triggerAddedToCartEvent;
+ triggerAddedToCartEventMock.mockImplementation(
+ realTriggerAddedToCartEvent
+ );
+ let syncCountSeenByAdded = -1;
+ let addedListenerMicrotaskHasRun = false;
+ let microtaskStateSeenBySync: boolean | undefined;
+ const native = captureNativeCartEvents(
+ () => {
+ syncCountSeenByAdded = native.events.filter(
+ ( event ) => event.name === 'sync'
+ ).length;
+ Promise.resolve().then( () => {
+ addedListenerMicrotaskHasRun = true;
+ } );
+ },
+ () => {
+ microtaskStateSeenBySync = addedListenerMicrotaskHasRun;
+ }
+ );
+
+ try {
+ await runAction(
+ actions.addCartItem( {
+ id: 42,
+ quantityToAdd: 1,
+ type: 'simple',
+ } )
+ );
+ const orderImmediatelyAfterSettlement = [
+ ...native.events.map( ( event ) => event.name ),
+ 'after-settlement',
+ ];
+
+ expect( orderImmediatelyAfterSettlement ).toEqual( [
+ 'added',
+ 'sync',
+ 'after-settlement',
+ ] );
+ expect( syncCountSeenByAdded ).toBe( 0 );
+ expect( microtaskStateSeenBySync ).toBe( false );
+ expect( addedListenerMicrotaskHasRun ).toBe( true );
+ expect( native.events[ 0 ] ).toEqual( {
+ name: 'added',
+ target: document.body,
+ bubbles: true,
+ cancelable: true,
+ detail: { preserveCartData: true },
+ } );
+ expect( native.events[ 1 ] ).toEqual( {
+ name: 'sync',
+ target: window,
+ bubbles: false,
+ cancelable: false,
+ detail: {
+ type: 'from_iAPI',
+ quantityChanges: { productsPendingAdd: [ 42 ] },
+ },
+ } );
+ } finally {
+ native.cleanup();
+ if ( priorImplementation ) {
+ triggerAddedToCartEventMock.mockImplementation(
+ priorImplementation
+ );
+ } else {
+ triggerAddedToCartEventMock.mockReset();
+ }
+ }
+ } );
+
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();