Commit ffb46bfa543 for woocommerce

commit ffb46bfa5430ab6591242f922d62a306a8e70653
Author: Alefe Souza <contact@alefesouza.com>
Date:   Tue Sep 29 22:31:30 2026 -0300

    Fix variation attributes hidden when their value is in the parent product name (#68943)

diff --git a/plugins/woocommerce/changelog/31422-show-attribute-matching-parent-name b/plugins/woocommerce/changelog/31422-show-attribute-matching-parent-name
new file mode 100644
index 00000000000..2fb7e34409a
--- /dev/null
+++ b/plugins/woocommerce/changelog/31422-show-attribute-matching-parent-name
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Show a variation attribute in the cart, checkout, and order item details when its value also appears in the parent product name.
diff --git a/plugins/woocommerce/includes/class-wc-order-item.php b/plugins/woocommerce/includes/class-wc-order-item.php
index 62d3450793c..af8b91ad6c4 100644
--- a/plugins/woocommerce/includes/class-wc-order-item.php
+++ b/plugins/woocommerce/includes/class-wc-order-item.php
@@ -362,7 +362,7 @@ class WC_Order_Item extends WC_Data implements ArrayAccess {
 			}

 			// Skip items with values already in the product details area of the product name.
-			if ( null !== $decoded_item_name && wc_is_attribute_in_product_name( wp_specialchars_decode( $display_value, ENT_QUOTES ), $decoded_item_name ) ) {
+			if ( null !== $decoded_item_name && wc_is_attribute_in_product_name( wp_specialchars_decode( $display_value, ENT_QUOTES ), $decoded_item_name, $product ) ) {
 				continue;
 			}

diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller.php
index 0220719c95b..1fecb63bb49 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller.php
@@ -285,7 +285,7 @@ class WC_REST_Orders_V2_Controller extends WC_REST_CRUD_Controller {
 					$display_value = wp_kses_post( rawurldecode( (string) $meta->value ) );

 					// Skip items with values already in the product details area of the product name.
-					if ( $product && $product->is_type( ProductType::VARIATION ) && wc_is_attribute_in_product_name( $display_value, $order_item_name ) ) {
+					if ( $product && $product->is_type( ProductType::VARIATION ) && wc_is_attribute_in_product_name( $display_value, $order_item_name, $product ) ) {
 						return false;
 					}

diff --git a/plugins/woocommerce/includes/wc-attribute-functions.php b/plugins/woocommerce/includes/wc-attribute-functions.php
index 678a6032bd4..34a1d6af370 100644
--- a/plugins/woocommerce/includes/wc-attribute-functions.php
+++ b/plugins/woocommerce/includes/wc-attribute-functions.php
@@ -440,14 +440,46 @@ function wc_attributes_array_filter_variation( $attribute ) {
 /**
  * Check if an attribute is included in the attributes area of a variation name.
  *
+ * A variation name is the parent product name followed by an optional attribute
+ * list ("Parent - Red, Large"). When the variation is given, only that attribute
+ * list is searched, so a parent name that ends with an attribute value
+ * ("Vienna Black") does not hide the "Black" attribute.
+ *
  * @since  3.0.2
- * @param  string $attribute Attribute value to check for.
- * @param  string $name      Product name to check in.
+ * @param  string          $attribute Attribute value to check for.
+ * @param  string          $name      Product name to check in.
+ * @param  WC_Product|null $product   Variation the name belongs to, when known. Since 11.3.0.
  * @return bool
  */
-function wc_is_attribute_in_product_name( $attribute, $name ) {
-	$is_in_name = stristr( $name, ' ' . $attribute . ',' ) || 0 === stripos( strrev( $name ), strrev( ' ' . $attribute ) );
-	return apply_filters( 'woocommerce_is_attribute_in_product_name', $is_in_name, $attribute, $name );
+function wc_is_attribute_in_product_name( $attribute, $name, $product = null ) {
+	$attributes_area = $name;
+
+	if ( $product instanceof WC_Product_Variation && $product->is_type( ProductType::VARIATION ) ) {
+		// The raw parent title, as the name is built from it without the `woocommerce_product_title` filter.
+		$parent_name = $product->get_parent_data()['title'] ?? '';
+
+		// Callers may pass the name raw or entity-decoded, so try both forms of the parent name.
+		foreach ( array_unique( array( $parent_name, wp_specialchars_decode( $parent_name, ENT_QUOTES ) ) ) as $prefix ) {
+			if ( '' !== $prefix && 0 === stripos( $name, $prefix ) ) {
+				$attributes_area = substr( $name, strlen( $prefix ) );
+				break;
+			}
+		}
+	}
+
+	$is_in_name = stristr( $attributes_area, ' ' . $attribute . ',' ) || 0 === stripos( strrev( $attributes_area ), strrev( ' ' . $attribute ) );
+
+	/**
+	 * Filters whether an attribute value is already shown in the attributes area of a variation name.
+	 *
+	 * @since 3.0.2
+	 * @since 11.3.0 Added the `$product` parameter.
+	 * @param bool            $is_in_name Whether the attribute value was found in the name.
+	 * @param string          $attribute  Attribute value checked for.
+	 * @param string          $name       Product name checked in.
+	 * @param WC_Product|null $product    Variation the name belongs to, when known.
+	 */
+	return apply_filters( 'woocommerce_is_attribute_in_product_name', $is_in_name, $attribute, $name, $product );
 }

 /**
diff --git a/plugins/woocommerce/includes/wc-product-functions.php b/plugins/woocommerce/includes/wc-product-functions.php
index c0829188dfd..cf28ab211ff 100644
--- a/plugins/woocommerce/includes/wc-product-functions.php
+++ b/plugins/woocommerce/includes/wc-product-functions.php
@@ -560,7 +560,7 @@ function wc_get_formatted_variation( $variation, $flat = false, $include_names =
 			}

 			// Do not list attributes already part of the variation name.
-			if ( '' === $value || ( $skip_attributes_in_name && wc_is_attribute_in_product_name( $value, $variation_name ) ) ) {
+			if ( '' === $value || ( $skip_attributes_in_name && wc_is_attribute_in_product_name( $value, $variation_name, $product ? $product : null ) ) ) {
 				continue;
 			}

diff --git a/plugins/woocommerce/includes/wc-template-functions.php b/plugins/woocommerce/includes/wc-template-functions.php
index 45ae9bfe353..677d99a5b36 100644
--- a/plugins/woocommerce/includes/wc-template-functions.php
+++ b/plugins/woocommerce/includes/wc-template-functions.php
@@ -4576,7 +4576,7 @@ function wc_get_formatted_cart_item_data( $cart_item, $flat = false, $product_na
 			$value = rawurldecode( (string) $value );

 			// Check the display value against the title.
-			if ( wc_is_attribute_in_product_name( wp_specialchars_decode( $value, ENT_QUOTES ), $product_name ) ) {
+			if ( wc_is_attribute_in_product_name( wp_specialchars_decode( $value, ENT_QUOTES ), $product_name, $cart_item['data'] ) ) {
 				continue;
 			}

diff --git a/plugins/woocommerce/src/Internal/ProductVariations/SelectedVariationName.php b/plugins/woocommerce/src/Internal/ProductVariations/SelectedVariationName.php
index 51bffe55e65..3b813091135 100644
--- a/plugins/woocommerce/src/Internal/ProductVariations/SelectedVariationName.php
+++ b/plugins/woocommerce/src/Internal/ProductVariations/SelectedVariationName.php
@@ -80,7 +80,7 @@ class SelectedVariationName {
 				continue;
 			}

-			if ( wc_is_attribute_in_product_name( $display_value, $product_name ) ) {
+			if ( wc_is_attribute_in_product_name( $display_value, $product_name, $product ) ) {
 				$has_attributes_in_name = true;
 				continue;
 			}
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php b/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php
index 82d2e772dc1..f2f7a221004 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php
@@ -626,6 +626,48 @@ class WC_Cart_Test extends \WC_Unit_Test_Case {
 		}
 	}

+	/**
+	 * @testdox Cart item metadata keeps an attribute whose value only appears in the parent product name.
+	 */
+	public function test_formatted_cart_item_data_keeps_attribute_that_matches_the_parent_name(): void {
+		// Three attributes keep the attribute list out of the variation title, so it is just "Vienna Huge"
+		// and the "huge" size must not be treated as already shown.
+		list( $product, $variation ) = WC_Helper_Product::create_variation_product_with_global_attributes(
+			'Vienna Huge',
+			array(
+				'pa_size'   => 'huge',
+				'pa_number' => '1',
+				'pa_colour' => 'black',
+			),
+			array(
+				'size'   => array( 'small', 'huge' ),
+				'number' => array( '0', '1' ),
+				'colour' => array( 'black', 'white' ),
+			)
+		);
+
+		try {
+			list( , $cart_item ) = $this->add_variation_to_cart(
+				$product,
+				$variation,
+				array(
+					'attribute_pa_size'   => 'huge',
+					'attribute_pa_number' => '1',
+					'attribute_pa_colour' => 'black',
+				)
+			);
+
+			$this->assertSame( 'Vienna Huge', WC()->cart->get_item_product_name( $cart_item ) );
+			$this->assertSame(
+				"size: huge\nnumber: 1\ncolour: black",
+				trim( wc_get_formatted_cart_item_data( $cart_item, true ) )
+			);
+		} finally {
+			$variation->delete( true );
+			$product->delete( true );
+		}
+	}
+
 	/**
 	 * @testdox Cart item metadata dedup keys on the template-provided name regardless of name filters.
 	 */
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-order-item-product-test.php b/plugins/woocommerce/tests/php/includes/class-wc-order-item-product-test.php
index 602aecbbcae..42c6eb5f989 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-order-item-product-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-order-item-product-test.php
@@ -114,6 +114,41 @@ class WC_Order_Item_Product_Test extends WC_Unit_Test_Case {
 		WC_Helper_Order::delete_order( $this->order->get_id() );
 	}

+	/**
+	 * @testdox get_formatted_meta_data keeps a variation attribute whose value only appears in the parent product name.
+	 */
+	public function test_get_formatted_meta_data_keeps_attribute_that_matches_the_parent_name(): void {
+		// Three attributes keep the attribute list out of the variation title, so it is just "Vienna Huge".
+		list( $product, $variation ) = WC_Helper_Product::create_variation_product_with_global_attributes(
+			'Vienna Huge',
+			array(
+				'pa_size'   => 'huge',
+				'pa_number' => '1',
+				'pa_colour' => 'black',
+			),
+			array(
+				'size'   => array( 'small', 'huge' ),
+				'number' => array( '0', '1' ),
+				'colour' => array( 'black', 'white' ),
+			)
+		);
+
+		try {
+			$item_id = $this->order->add_product( $variation, 1 );
+			$item    = $this->order->get_item( $item_id );
+
+			$this->assertSame( 'Vienna Huge', $item->get_name() );
+			$this->assertSame(
+				array( 'huge', '1', 'black' ),
+				array_values( wp_list_pluck( $item->get_formatted_meta_data(), 'value' ) ),
+				'Every selected attribute is listed when the item name shows none of them.'
+			);
+		} finally {
+			$variation->delete( true );
+			$product->delete( true );
+		}
+	}
+
 	/**
 	 * @testdox 'doing it wrong' is thrown, if the Cost of Goods Sold feature is disabled.
 	 */
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller-test.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller-test.php
index f7baed2ce3e..5698d282998 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller-test.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller-test.php
@@ -124,6 +124,53 @@ class WC_REST_Order_V2_Controller_Test extends WC_REST_Unit_Test_case {
 		$this->assertEquals( $order->get_id(), $response->data['id'] );
 	}

+	/**
+	 * Test that `order_item_display_meta` keeps a variation attribute whose value only appears in the
+	 * parent product name.
+	 */
+	public function test_order_item_display_meta_keeps_attribute_matching_the_parent_name() {
+		// Three attributes keep the attribute list out of the variation title, so it is just "Vienna Black"
+		// and the "black" colour must not be treated as already shown.
+		list( $product, $variation ) = WC_Helper_Product::create_variation_product_with_global_attributes(
+			'Vienna Black',
+			array(
+				'pa_size'   => 'huge',
+				'pa_number' => '1',
+				'pa_colour' => 'black',
+			),
+			array(
+				'size'   => array( 'small', 'huge' ),
+				'number' => array( '0', '1' ),
+				'colour' => array( 'black', 'white' ),
+			)
+		);
+
+		try {
+			$order = new WC_Order();
+			$order->add_product( $variation, 1 );
+			$order->save();
+
+			$request = new WP_REST_Request( 'GET', '/wc/v2/orders/' . $order->get_id() );
+			$request->set_param( 'order_item_display_meta', 'true' );
+			$response = $this->server->dispatch( $request );
+
+			$this->assertEquals( 200, $response->get_status() );
+
+			$response_data = $response->get_data();
+			$line_item     = current( $response_data['line_items'] );
+
+			$this->assertSame( 'Vienna Black', $line_item['name'] );
+			$this->assertSame(
+				array( 'pa_size', 'pa_number', 'pa_colour' ),
+				wp_list_pluck( $line_item['meta_data'], 'key' ),
+				'Every selected attribute is returned when the item name shows none of them.'
+			);
+		} finally {
+			$variation->delete( true );
+			$product->delete( true );
+		}
+	}
+
 	/**
 	 * Test that the `include_meta` param filters the `meta_data` prop correctly.
 	 */
diff --git a/plugins/woocommerce/tests/php/includes/wc-attribute-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-attribute-functions-test.php
index 5d41654d0b2..fb2eec7f13e 100644
--- a/plugins/woocommerce/tests/php/includes/wc-attribute-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-attribute-functions-test.php
@@ -52,6 +52,106 @@ class WC_Attribute_Functions_Test extends \WC_Unit_Test_Case {
 		parent::tearDown();
 	}

+	/**
+	 * @testdox wc_is_attribute_in_product_name() should only search the attributes area of a variation name when the variation is given.
+	 */
+	public function test_wc_is_attribute_in_product_name_ignores_the_parent_name_when_the_variation_is_given(): void {
+		// Three attributes keep the attribute list out of the variation title, so it is just "Vienna Black".
+		list( $product, $variation ) = WC_Helper_Product::create_variation_product_with_global_attributes(
+			'Vienna Black',
+			array(
+				'pa_size'   => 'huge',
+				'pa_number' => '1',
+				'pa_colour' => 'black',
+			),
+			array(
+				'size'   => array( 'small', 'huge' ),
+				'number' => array( '0', '1' ),
+				'colour' => array( 'black', 'white' ),
+			)
+		);
+
+		try {
+			$this->assertSame( 'Vienna Black', $variation->get_name() );
+
+			$this->assertFalse(
+				wc_is_attribute_in_product_name( 'Black', 'Vienna Black', $variation ),
+				'A value that only appears in the parent name is not shown by the variation name.'
+			);
+			$this->assertTrue(
+				wc_is_attribute_in_product_name( 'Black', 'Vienna Black - Huge, Black', $variation ),
+				'A value in the attribute list after the parent name is shown by the variation name.'
+			);
+			$this->assertTrue(
+				wc_is_attribute_in_product_name( 'Black', 'Vienna Black' ),
+				'Without the variation the whole name is searched, as before.'
+			);
+		} finally {
+			$variation->delete( true );
+			$product->delete( true );
+		}
+	}
+
+	/**
+	 * @testdox wc_is_attribute_in_product_name() should match the parent name whether the variation name is raw or entity-decoded.
+	 */
+	public function test_wc_is_attribute_in_product_name_matches_raw_and_decoded_parent_names(): void {
+		list( $product, $variation ) = WC_Helper_Product::create_variation_product_with_global_attributes(
+			'Black &amp; White',
+			array(
+				'pa_size'   => 'huge',
+				'pa_number' => '1',
+				'pa_colour' => 'white',
+			),
+			array(
+				'size'   => array( 'small', 'huge' ),
+				'number' => array( '0', '1' ),
+				'colour' => array( 'black', 'white' ),
+			)
+		);
+
+		try {
+			$this->assertFalse( wc_is_attribute_in_product_name( 'White', 'Black &amp; White', $variation ), 'Raw name.' );
+			$this->assertFalse( wc_is_attribute_in_product_name( 'White', 'Black & White', $variation ), 'Decoded name.' );
+			$this->assertTrue( wc_is_attribute_in_product_name( 'White', 'Black & White - Huge, White', $variation ), 'Decoded name with the attribute list.' );
+		} finally {
+			$variation->delete( true );
+			$product->delete( true );
+		}
+	}
+
+	/**
+	 * @testdox wc_is_attribute_in_product_name() should match the parent name when a filter changes the product title.
+	 */
+	public function test_wc_is_attribute_in_product_name_ignores_the_product_title_filter(): void {
+		list( $product, $variation ) = WC_Helper_Product::create_variation_product_with_global_attributes(
+			'Vienna Black',
+			array(
+				'pa_size'   => 'huge',
+				'pa_number' => '1',
+				'pa_colour' => 'black',
+			),
+			array(
+				'size'   => array( 'small', 'huge' ),
+				'number' => array( '0', '1' ),
+				'colour' => array( 'black', 'white' ),
+			)
+		);
+
+		$append_badge = function ( $title ) {
+			return $title . ' (New)';
+		};
+		add_filter( 'woocommerce_product_title', $append_badge );
+
+		try {
+			$this->assertFalse( wc_is_attribute_in_product_name( 'Black', 'Vienna Black', $variation ) );
+		} finally {
+			remove_filter( 'woocommerce_product_title', $append_badge );
+			$variation->delete( true );
+			$product->delete( true );
+		}
+	}
+
 	/**
 	 * Test wc_get_attribute_taxonomy_ids() function.
 	 * Even empty arrays should be cached.
diff --git a/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
index 86d4420734d..d04e2235d81 100644
--- a/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
@@ -47,6 +47,39 @@ class WC_Product_Functions_Tests extends \WC_Unit_Test_Case {
 		parent::tearDown();
 	}

+	/**
+	 * @testdox wc_get_formatted_variation() lists an attribute whose value only appears in the parent product name.
+	 */
+	public function test_wc_get_formatted_variation_keeps_attribute_that_matches_the_parent_name(): void {
+		// Three attributes keep the attribute list out of the variation title, so it is just "Vienna Black"
+		// and the "black" colour must not be treated as already shown.
+		list( $product, $variation ) = WC_Helper_Product::create_variation_product_with_global_attributes(
+			'Vienna Black',
+			array(
+				'pa_size'   => 'huge',
+				'pa_number' => '1',
+				'pa_colour' => 'black',
+			),
+			array(
+				'size'   => array( 'small', 'huge' ),
+				'number' => array( '0', '1' ),
+				'colour' => array( 'black', 'white' ),
+			)
+		);
+
+		try {
+			$this->assertSame( 'Vienna Black', $variation->get_name() );
+			$this->assertSame(
+				'size: huge, number: 1, colour: black',
+				wc_get_formatted_variation( $variation, true, true, true ),
+				'Every attribute is listed when the variation name shows none of them.'
+			);
+		} finally {
+			$variation->delete( true );
+			$product->delete( true );
+		}
+	}
+
 	/**
 	 * @testdox If 'wc_get_price_excluding_tax' gets an order as argument, it passes the order customer to 'WC_Tax::get_rates'.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductVariations/SelectedVariationNameTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductVariations/SelectedVariationNameTest.php
index ea921e08f9d..56f0f14c597 100644
--- a/plugins/woocommerce/tests/php/src/Internal/ProductVariations/SelectedVariationNameTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductVariations/SelectedVariationNameTest.php
@@ -74,6 +74,69 @@ class SelectedVariationNameTest extends WC_Unit_Test_Case {
 		}
 	}

+	/**
+	 * @testdox Selected Any attributes are kept when their value also appears in the parent product name.
+	 *
+	 * @dataProvider parent_name_collision_provider
+	 *
+	 * @param array<string, string>             $variation_attributes Stored variation attributes.
+	 * @param array<string, array<int, string>> $attribute_terms Terms to create for each attribute.
+	 * @param array<string, string>             $selected_attributes Selected cart attributes.
+	 * @param string                            $expected_name Expected contextual name.
+	 */
+	public function test_get_product_name_keeps_attributes_matching_the_parent_name( array $variation_attributes, array $attribute_terms, array $selected_attributes, string $expected_name ): void {
+		// Every attribute is "Any", so the variation title is just the parent name and shows none of the
+		// selected values -- including the colour the parent name happens to end with.
+		list( $product, $variation ) = WC_Helper_Product::create_variation_product_with_global_attributes(
+			'Vienna Black',
+			$variation_attributes,
+			$attribute_terms
+		);
+
+		try {
+			$this->assertSame( 'Vienna Black', $variation->get_name() );
+			$this->assertSame(
+				$expected_name,
+				$this->sut->get_product_name( $variation, $selected_attributes ),
+				'The selected value is listed even though the parent name ends with it.'
+			);
+		} finally {
+			$variation->delete( true );
+			$product->delete( true );
+		}
+	}
+
+	/**
+	 * Provides variations whose parent name ends with a selected attribute value.
+	 *
+	 * @return array<string, array{0: array<string, string>, 1: array<string, array<int, string>>, 2: array<string, string>, 3: string}>
+	 */
+	public static function parent_name_collision_provider(): array {
+		return array(
+			'only attribute is Any'  => array(
+				array( 'pa_colour' => '' ),
+				array( 'colour' => array( 'black', 'white' ) ),
+				array( 'attribute_pa_colour' => 'black' ),
+				'Vienna Black - black',
+			),
+			'every attribute is Any' => array(
+				array(
+					'pa_colour' => '',
+					'pa_number' => '',
+				),
+				array(
+					'colour' => array( 'black', 'white' ),
+					'number' => array( '0', '1' ),
+				),
+				array(
+					'attribute_pa_colour' => 'black',
+					'attribute_pa_number' => '1',
+				),
+				'Vienna Black - black, 1',
+			),
+		);
+	}
+
 	/**
 	 * @testdox Data stores without the shared title policy keep the stored variation name.
 	 */