Commit a5f7b7f9bc2 for woocommerce

commit a5f7b7f9bc2150dd05fada6b7f64ef6849876cfb
Author: Karol Manijak <20098064+kmanijak@users.noreply.github.com>
Date:   Thu Mar 12 10:49:08 2026 +0100

    Add to Cart: improve validation of attributes with multi-word names (#63640)

    * Improve attributes validation for two words cases

    * Add changelog

    * Cover one more case missing normalization

    * Update chaneglog

    * Refactor normalisation so it's more centralised

    * Add tests

    * Fix lint

diff --git a/plugins/woocommerce/changelog/fix-63635 b/plugins/woocommerce/changelog/fix-63635
new file mode 100644
index 00000000000..70e99d72f72
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-63635
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Add to Cart: fix validation of attributes with multi-word names
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/utils/variations/attribute-matching.ts b/plugins/woocommerce/client/blocks/assets/js/base/utils/variations/attribute-matching.ts
index f824ff9b3f7..66c611c35f9 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/utils/variations/attribute-matching.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/base/utils/variations/attribute-matching.ts
@@ -9,13 +9,17 @@ import type {

 /**
  * Normalize attribute name by stripping the 'attribute_' or 'attribute_pa_' prefix
- * that WooCommerce adds for variation attributes.
+ * that WooCommerce adds for variation attributes, and replacing hyphens with spaces
+ * so that slugs (e.g., "some-name") match labels (e.g., "some name").
  *
  * @param name The attribute name (e.g., 'attribute_color' or 'attribute_pa_color').
  * @return The normalized name (e.g., 'color').
  */
 export const normalizeAttributeName = ( name: string ): string => {
-	return name.replace( /^attribute_(pa_)?/, '' );
+	return name
+		.replace( /^attribute_(pa_)?/, '' )
+		.replace( /-/g, ' ' )
+		.toLowerCase();
 };

 /**
@@ -32,10 +36,7 @@ export const attributeNamesMatch = (
 	name1: string,
 	name2: string
 ): boolean => {
-	return (
-		normalizeAttributeName( name1 ).toLowerCase() ===
-		normalizeAttributeName( name2 ).toLowerCase()
-	);
+	return normalizeAttributeName( name1 ) === normalizeAttributeName( name2 );
 };

 /**
@@ -53,10 +54,8 @@ export const getVariationAttributeValue = (
 	variation: ProductResponseVariationsItem,
 	attributeName: string
 ): string | undefined => {
-	const normalizedName =
-		normalizeAttributeName( attributeName ).toLowerCase();
-	const attr = variation.attributes.find(
-		( a ) => a.name.toLowerCase() === normalizedName
+	const attr = variation.attributes.find( ( a ) =>
+		attributeNamesMatch( attributeName, a.name )
 	);
 	return attr?.value;
 };
@@ -82,12 +81,8 @@ export const findMatchingVariation = (
 	const matchedVariation = product.variations.find(
 		( variation: ProductResponseVariationsItem ) => {
 			return variation.attributes.every( ( attr ) => {
-				const attrNameLower = attr.name.toLowerCase();
-				const selectedAttr = selectedAttributes.find(
-					( selected ) =>
-						normalizeAttributeName(
-							selected.attribute
-						).toLowerCase() === attrNameLower
+				const selectedAttr = selectedAttributes.find( ( selected ) =>
+					attributeNamesMatch( attr.name, selected.attribute )
 				);

 				// If variation attribute is null, it accepts "Any" value.
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/utils/variations/test/attribute-matching.ts b/plugins/woocommerce/client/blocks/assets/js/base/utils/variations/test/attribute-matching.ts
index 763800f188e..6a9a83b91bb 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/utils/variations/test/attribute-matching.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/base/utils/variations/test/attribute-matching.ts
@@ -19,8 +19,20 @@ describe( 'normalizeAttributeName', () => {
 		);
 	} );

-	it( 'returns unchanged name without prefix', () => {
-		expect( normalizeAttributeName( 'Color' ) ).toBe( 'Color' );
+	it( 'returns lowercased name without prefix', () => {
+		expect( normalizeAttributeName( 'Color' ) ).toBe( 'color' );
+	} );
+
+	it( 'replaces hyphens with spaces for multi-word slugs', () => {
+		expect( normalizeAttributeName( 'attribute_pa_numeric-size' ) ).toBe(
+			'numeric size'
+		);
+	} );
+
+	it( 'replaces hyphens with spaces without prefix', () => {
+		expect( normalizeAttributeName( 'numeric-size' ) ).toBe(
+			'numeric size'
+		);
 	} );
 } );

@@ -41,6 +53,24 @@ describe( 'attributeNamesMatch', () => {
 		).toBe( true );
 	} );

+	it( 'matches hyphenated slug against spaced label', () => {
+		expect(
+			attributeNamesMatch( 'attribute_pa_numeric-size', 'numeric size' )
+		).toBe( true );
+	} );
+
+	it( 'matches hyphenated slug against capitalized spaced label', () => {
+		expect(
+			attributeNamesMatch( 'attribute_pa_numeric-size', 'Numeric Size' )
+		).toBe( true );
+	} );
+
+	it( 'matches two hyphenated names', () => {
+		expect(
+			attributeNamesMatch( 'attribute_pa_numeric-size', 'numeric-size' )
+		).toBe( true );
+	} );
+
 	it( 'returns false for different names', () => {
 		expect( attributeNamesMatch( 'color', 'size' ) ).toBe( false );
 	} );
@@ -78,6 +108,45 @@ describe( 'getVariationAttributeValue', () => {
 			getVariationAttributeValue( variation, 'material' )
 		).toBeUndefined();
 	} );
+
+	describe( 'multi-word attribute names', () => {
+		const variationWithSpaces = {
+			id: 456,
+			attributes: [ { name: 'numeric size', value: '42' } ],
+		};
+
+		const variationWithHyphens = {
+			id: 789,
+			attributes: [ { name: 'numeric-size', value: '44' } ],
+		};
+
+		it( 'finds value when slug has hyphens and Store API has spaces', () => {
+			expect(
+				getVariationAttributeValue(
+					variationWithSpaces,
+					'attribute_pa_numeric-size'
+				)
+			).toBe( '42' );
+		} );
+
+		it( 'finds value when both use hyphens', () => {
+			expect(
+				getVariationAttributeValue(
+					variationWithHyphens,
+					'attribute_pa_numeric-size'
+				)
+			).toBe( '44' );
+		} );
+
+		it( 'finds value when both use spaces', () => {
+			expect(
+				getVariationAttributeValue(
+					variationWithSpaces,
+					'numeric size'
+				)
+			).toBe( '42' );
+		} );
+	} );
 } );

 describe( 'findMatchingVariation', () => {
@@ -149,6 +218,54 @@ describe( 'findMatchingVariation', () => {
 		).toBeNull();
 	} );

+	describe( 'multi-word attribute names', () => {
+		const productWithMultiWord = {
+			id: 3,
+			type: 'variable',
+			variations: [
+				{
+					id: 301,
+					attributes: [
+						{ name: 'Color', value: 'Blue' },
+						{ name: 'numeric size', value: '42' },
+					],
+				},
+				{
+					id: 302,
+					attributes: [
+						{ name: 'Color', value: 'Red' },
+						{ name: 'numeric size', value: '44' },
+					],
+				},
+			],
+		};
+
+		it( 'matches when selected attributes use hyphenated slugs', () => {
+			const result = findMatchingVariation( productWithMultiWord, [
+				{ attribute: 'attribute_pa_color', value: 'Blue' },
+				{ attribute: 'attribute_pa_numeric-size', value: '42' },
+			] );
+			expect( result?.id ).toBe( 301 );
+		} );
+
+		it( 'matches when Store API uses hyphens instead of spaces', () => {
+			const productWithHyphens = {
+				id: 4,
+				type: 'variable',
+				variations: [
+					{
+						id: 401,
+						attributes: [ { name: 'numeric-size', value: '42' } ],
+					},
+				],
+			};
+			const result = findMatchingVariation( productWithHyphens, [
+				{ attribute: 'attribute_pa_numeric-size', value: '42' },
+			] );
+			expect( result?.id ).toBe( 401 );
+		} );
+	} );
+
 	describe( 'Any attribute handling', () => {
 		const productWithAny = {
 			id: 2,
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/frontend.ts
index ca6b9c77b23..4e73669d844 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/frontend.ts
@@ -349,17 +349,18 @@ const { actions, state } = store< VariableProductAddToCartWithOptionsStore >(
 				// Normalize included/excluded attributes to lowercase for comparison
 				// with Store API labels (e.g., "Color" vs "attribute_pa_color" → "color").
 				const normalizedIncluded = includedAttributes.map( ( attr ) =>
-					normalizeAttributeName( attr ).toLowerCase()
+					normalizeAttributeName( attr )
 				);
 				const normalizedExcluded = excludedAttributes.map( ( attr ) =>
-					normalizeAttributeName( attr ).toLowerCase()
+					normalizeAttributeName( attr )
 				);

 				const productAttributesAndOptions: Record< string, string[] > =
 					getProductAttributesAndOptions( product );
 				Object.entries( productAttributesAndOptions ).forEach(
 					( [ attribute, options ] ) => {
-						const attributeLower = attribute.toLowerCase();
+						const attributeLower =
+							normalizeAttributeName( attribute );
 						if (
 							normalizedIncluded.length !== 0 &&
 							! normalizedIncluded.includes( attributeLower )
@@ -386,9 +387,8 @@ const { actions, state } = store< VariableProductAddToCartWithOptionsStore >(
 							const contextName =
 								includedAttributes.find(
 									( attr ) =>
-										normalizeAttributeName(
-											attr
-										).toLowerCase() === attributeLower
+										normalizeAttributeName( attr ) ===
+										attributeLower
 								) || attribute;
 							actions.setAttribute( contextName, validOption );
 						}