Commit 36d8b2a7269 for woocommerce

commit 36d8b2a72696fdad3130686e4d6e7e5356e5a358
Author: Mario Santos <34552881+SantosGuillamot@users.noreply.github.com>
Date:   Tue Mar 10 16:10:42 2026 +0100

    Fix: Add multiple variable products with "Any" in attributes (#63535)

    * Create `findExistingCartItem` helper

    * Read `attribute` in `doesCartItemMatchAttributes`

    * Use `findExistingCartItem` in blocks

    * Use helper in the product button quantity

    * Create `itemInCart` derived state

    * Add cart import

    * Add a second variation in tests

    * Add changefile(s) from automation for the following project(s): woocommerce

    * Move eslint rule

    * Change arguments pass to the function

    * Prioritize always the key check

    * Improve naming

    ---------

    Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/63535-wooplug-6375-add-to-cart-options-cant-add-two-different-variations-if b/plugins/woocommerce/changelog/63535-wooplug-6375-add-to-cart-options-cant-add-two-different-variations-if
new file mode 100644
index 00000000000..fce18db5d36
--- /dev/null
+++ b/plugins/woocommerce/changelog/63535-wooplug-6375-add-to-cart-options-cant-add-two-different-variations-if
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+Comment: Fix adding multiple variable products with "any" attributes
+
diff --git a/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/button/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/button/frontend.ts
index 3fe96b1eae4..922dab937fd 100644
--- a/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/button/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/button/frontend.ts
@@ -8,8 +8,10 @@ import type { ProductDataStore } from '@woocommerce/stores/woocommerce/product-d
 /**
  * Internal dependencies
  */
-import { doesCartItemMatchAttributes } from '../../../../base/utils/variations/does-cart-item-match-attributes';
-import type { AddToCartWithOptionsStore } from '../../../../blocks/add-to-cart-with-options/frontend';
+import type {
+	Context as AddToCartWithOptionsContext,
+	AddToCartWithOptionsStore,
+} from '../../../../blocks/add-to-cart-with-options/frontend';

 // Stores are locked to prevent 3PD usage until the API is stable.
 const universalLock =
@@ -63,32 +65,16 @@ const { state: productDataState } = store< ProductDataStore >(
 const productButtonStore = {
 	state: {
 		get quantity(): number {
-			const products = wooState.cart?.items.filter(
-				( item ) => item.id === state.productId
+			const formContext = getContext< AddToCartWithOptionsContext >(
+				'woocommerce/add-to-cart-with-options'
 			);

-			if ( products.length === 0 ) {
-				return 0;
-			}
-
-			// Return the product quantity when the item is a non-variable product.
-			if ( products[ 0 ]?.type !== 'variation' ) {
-				return products.reduce(
-					( acc, item ) => acc + item.quantity,
-					0
-				);
-			}
+			const item = wooState.findItemInCart( {
+				id: state.productId,
+				variation: formContext?.selectedAttributes,
+			} );

-			const selectedAttributes =
-				addToCartWithOptionsState?.selectedAttributes;
-			const selectedVariableProducts = products.filter( ( item ) =>
-				doesCartItemMatchAttributes( item, selectedAttributes )
-			);
-
-			return selectedVariableProducts.reduce(
-				( acc, item ) => acc + item.quantity,
-				0
-			);
+			return item?.quantity ?? 0;
 		},
 		get slideInAnimation() {
 			const { animationStatus } = getContext< Context >();
@@ -121,9 +107,9 @@ const productButtonStore = {
 			if ( productType === 'grouped' ) {
 				const groupedProductIdsInCart = groupedProductIds?.map(
 					( productId ) => {
-						const product = wooState.cart?.items.find(
-							( item ) => item.id === productId
-						);
+						const product = wooState.findItemInCart( {
+							id: productId,
+						} );
 						return product?.quantity || 0;
 					}
 				);
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 fcd160be9fd..ecca02fe331 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
@@ -26,6 +26,7 @@ import {
 	type MutationQueue,
 	type MutationResult,
 } from './mutation-batcher';
+import { doesCartItemMatchAttributes } from '../../utils/variations/does-cart-item-match-attributes';

 export type WooCommerceConfig = {
 	products?: {
@@ -101,6 +102,11 @@ export type Store = {
 		};
 		restUrl: string;
 		nonce: string;
+		findItemInCart: ( args: {
+			id: ClientCartItem[ 'id' ];
+			key?: ClientCartItem[ 'key' ];
+			variation?: ClientCartItem[ 'variation' ];
+		} ) => CartItem | OptimisticCartItem | undefined;
 		cart: Omit< Cart, 'items' > & {
 			items: ( OptimisticCartItem | CartItem )[];
 			totals: CartResponseTotals;
@@ -207,40 +213,6 @@ const getInfoNoticesFromCartUpdates = (
 	];
 };

-// Same as the one in /assets/js/base/utils/variations/does-cart-item-match-attributes.ts.
-const doesCartItemMatchAttributes = (
-	cartItem: OptimisticCartItem,
-	selectedAttributes: SelectedAttributes[]
-) => {
-	if (
-		! Array.isArray( cartItem.variation ) ||
-		! Array.isArray( selectedAttributes )
-	) {
-		return false;
-	}
-
-	if ( cartItem.variation.length !== selectedAttributes.length ) {
-		return false;
-	}
-
-	return cartItem.variation.every(
-		( {
-			// eslint-disable-next-line
-			raw_attribute,
-			value,
-		}: {
-			raw_attribute: string;
-			value: string;
-		} ) =>
-			selectedAttributes.some( ( item: SelectedAttributes ) => {
-				return (
-					item.attribute === raw_attribute &&
-					item.value.toLowerCase() === value?.toLowerCase()
-				);
-			} )
-	);
-};
-
 let pendingRefresh = false;
 let refreshTimeout = 3000;
 let resolveNonceReady: ( () => void ) | null = null;
@@ -311,6 +283,38 @@ async function sendCartRequest(
 const { state, actions } = store< Store >(
 	'woocommerce',
 	{
+		state: {
+			findItemInCart( {
+				id,
+				key,
+				variation,
+			}: {
+				id: ClientCartItem[ 'id' ];
+				key?: ClientCartItem[ 'key' ];
+				variation?: ClientCartItem[ 'variation' ];
+			} ) {
+				return state.cart.items.find( ( cartItem ) => {
+					if ( key ) {
+						return key === cartItem.key;
+					}
+					if ( cartItem.type === 'variation' ) {
+						if (
+							id !== cartItem.id ||
+							! cartItem.variation ||
+							! variation ||
+							cartItem.variation.length !== variation.length
+						) {
+							return false;
+						}
+						return doesCartItemMatchAttributes(
+							cartItem,
+							variation
+						);
+					}
+					return id === cartItem.id;
+				} );
+			},
+		},
 		actions: {
 			*removeCartItem( key: string ): AsyncAction< void > {
 				// Track what changes we're making for the sync event.
@@ -377,22 +381,10 @@ const { state, actions } = store< Store >(
 				const a11yModulePromise = import( '@wordpress/a11y' );

 				// Find existing item
-				const existingItem = state.cart.items.find( ( cartItem ) => {
-					if ( cartItem.type === 'variation' ) {
-						if (
-							id !== cartItem.id ||
-							! cartItem.variation ||
-							! variation ||
-							cartItem.variation.length !== variation.length
-						) {
-							return false;
-						}
-						return doesCartItemMatchAttributes(
-							cartItem,
-							variation
-						);
-					}
-					return key ? key === cartItem.key : id === cartItem.id;
+				const existingItem = state.findItemInCart( {
+					id,
+					key,
+					variation,
 				} );

 				// Determine the target quantity.
@@ -535,9 +527,11 @@ const { state, actions } = store< Store >(
 					// Submit each item through the batcher. They'll be
 					// collected into a single batch request automatically.
 					const promises = items.map( ( item, index ) => {
-						const existingItem = state.cart.items.find(
-							( { id: productId } ) => item.id === productId
-						);
+						const existingItem = state.findItemInCart( {
+							id: item.id,
+							key: item.key,
+							variation: item.variation,
+						} );

 						let quantity: number;
 						if ( typeof item.quantityToAdd === 'number' ) {
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/utils/variations/does-cart-item-match-attributes.ts b/plugins/woocommerce/client/blocks/assets/js/base/utils/variations/does-cart-item-match-attributes.ts
index d6878faf126..c7e6b69a67f 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/utils/variations/does-cart-item-match-attributes.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/base/utils/variations/does-cart-item-match-attributes.ts
@@ -28,17 +28,22 @@ export const doesCartItemMatchAttributes = (

 	return cartItem.variation.every(
 		( {
+			attribute,
 			// eslint-disable-next-line
 			raw_attribute,
 			value,
 		}: {
+			attribute: string;
 			raw_attribute: string;
 			value: string;
 		} ) =>
 			selectedAttributes.some( ( item: SelectedAttributes ) => {
 				return (
-					attributeNamesMatch( item.attribute, raw_attribute ) &&
-					item.value.toLowerCase() === value?.toLowerCase()
+					attributeNamesMatch(
+						item.attribute,
+						// It needs to check both because it uses different keys from the same value depending on the context.
+						raw_attribute ?? attribute
+					) && item.value.toLowerCase() === value?.toLowerCase()
 				);
 			} )
 	);
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/iapi-frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/iapi-frontend.ts
index 23a68f5d94a..dbf28c9a39e 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/iapi-frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/iapi-frontend.ts
@@ -445,12 +445,14 @@ const { state: cartItemState } = store(
 			// state.cartItem to get the cart item.
 			get cartItem() {
 				const {
-					cartItem: { id, key },
+					cartItem: { id, key, variation },
 				} = getContext< CartItemContext >( 'woocommerce' );

-				const cartItem = ( woocommerceState.cart.items.find( ( item ) =>
-					key ? item.key === key : item.id === id
-				) || {} ) as CartItem;
+				const cartItem = ( woocommerceState.findItemInCart( {
+					id,
+					key,
+					variation,
+				} ) || {} ) as CartItem;

 				cartItem.variation = cartItem.variation || [];
 				cartItem.item_data = cartItem.item_data || [];
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/tests/add-to-cart-with-options/add-to-cart-with-options.block_theme.spec.ts b/plugins/woocommerce/client/blocks/tests/e2e/tests/add-to-cart-with-options/add-to-cart-with-options.block_theme.spec.ts
index 372deef19ec..d8854e73079 100644
--- a/plugins/woocommerce/client/blocks/tests/e2e/tests/add-to-cart-with-options/add-to-cart-with-options.block_theme.spec.ts
+++ b/plugins/woocommerce/client/blocks/tests/e2e/tests/add-to-cart-with-options/add-to-cart-with-options.block_theme.spec.ts
@@ -369,6 +369,11 @@ test.describe( 'Add to Cart + Options Block', () => {
 		await colorRedOption.click();

 		await expect( page.getByText( '1 in cart' ) ).toBeHidden();
+
+		// Add a second variation (Red + Large).
+		await addToCartButton.click();
+
+		await expect( page.getByText( '1 in cart' ) ).toBeVisible();
 	} );

 	test( 'allows adding grouped products to cart', async ( {