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();