Commit f90f57a3675 for woocommerce

commit f90f57a3675d1aed423092235f62d387e4b3d1a4
Author: Tung Du <dinhtungdu@gmail.com>
Date:   Thu Jul 9 16:24:05 2026 +0700

    Fix: encoded character in filter option label (#66438)

    fix: encoded character in filter option label

diff --git a/plugins/woocommerce/changelog/wooplug-6950-product-filters-category-in-taxonomy-term-labels-is-double b/plugins/woocommerce/changelog/wooplug-6950-product-filters-category-in-taxonomy-term-labels-is-double
new file mode 100644
index 00000000000..ce550403b24
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-6950-product-filters-category-in-taxonomy-term-labels-is-double
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix encoded character in filter option label.
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterAttribute.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterAttribute.php
index 8c1d36e7ee1..24f68a915dc 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterAttribute.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterAttribute.php
@@ -120,10 +120,11 @@ final class ProductFilterAttribute extends AbstractBlock {

 		foreach ( $attribute_terms as $term_object ) {
 			$attribute_name = str_replace( 'pa_', '', $term_object->taxonomy );
+			$term_name      = wp_specialchars_decode( $term_object->name, ENT_QUOTES );
 			$items[]        = array(
 				'type'               => 'attribute/' . $attribute_name,
 				'value'              => $term_object->slug,
-				'activeLabel'        => sprintf( '%s: %s', $product_attributes_map[ $attribute_name ], $term_object->name ),
+				'activeLabel'        => sprintf( '%s: %s', $product_attributes_map[ $attribute_name ], $term_name ),
 				'attributeQueryType' => $query_types[ $attribute_name ] ?? 'or',
 			);
 		}
@@ -208,11 +209,12 @@ final class ProductFilterAttribute extends AbstractBlock {
 					$term          = (array) $term;
 					$term['count'] = $attribute_counts[ $term['term_id'] ] ?? 0;

-					$type = 'attribute/' . str_replace( 'pa_', '', $product_attribute->slug );
-					$item = array(
+					$term_name = wp_specialchars_decode( $term['name'], ENT_QUOTES );
+					$type      = 'attribute/' . str_replace( 'pa_', '', $product_attribute->slug );
+					$item      = array(
 						'id'                 => $type . '-' . $term['slug'],
-						'label'              => $term['name'],
-						'ariaLabel'          => $term['name'],
+						'label'              => $term_name,
+						'ariaLabel'          => $term_name,
 						'value'              => $term['slug'],
 						'selected'           => in_array( $term['slug'], $selected_terms, true ),
 						'type'               => $type,
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterTaxonomy.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterTaxonomy.php
index 6a0ed831c1f..9db3982fdd0 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterTaxonomy.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterTaxonomy.php
@@ -67,10 +67,11 @@ final class ProductFilterTaxonomy extends AbstractBlock {
 		foreach ( $terms as $term ) {
 			$taxonomy_object = get_taxonomy( $term->taxonomy );
 			if ( $taxonomy_object ) {
-				$items[] = array(
+				$term_name = wp_specialchars_decode( $term->name, ENT_QUOTES );
+				$items[]   = array(
 					'type'        => 'taxonomy/' . $term->taxonomy,
 					'value'       => $term->slug,
-					'activeLabel' => $taxonomy_object->labels->singular_name . ': ' . $term->name,
+					'activeLabel' => $taxonomy_object->labels->singular_name . ': ' . $term_name,
 				);
 			}//end if
 		}
@@ -243,12 +244,13 @@ final class ProductFilterTaxonomy extends AbstractBlock {
 				function ( $term ) use ( $taxonomy_counts, $selected_terms, $taxonomy, $show_counts ) {
 					$term          = (array) $term;
 					$term['count'] = $taxonomy_counts[ $term['term_id'] ] ?? 0;
+					$term_name     = wp_specialchars_decode( $term['name'], ENT_QUOTES );

 					$type   = 'taxonomy/' . $taxonomy;
 					$option = array(
 						'id'        => $type . '-' . $term['slug'],
-						'label'     => $term['name'],
-						'ariaLabel' => $term['name'],
+						'label'     => $term_name,
+						'ariaLabel' => $term_name,
 						'value'     => $term['slug'],
 						'selected'  => in_array( $term['slug'], $selected_terms, true ),
 						'type'      => $type,
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductFilterAttributeTest.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductFilterAttributeTest.php
index 385f17e6072..2545149ac90 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductFilterAttributeTest.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductFilterAttributeTest.php
@@ -3,17 +3,59 @@ declare( strict_types = 1 );

 namespace Automattic\WooCommerce\Tests\Blocks\BlockTypes;

+use WC_Unit_Test_Case;
+
 /**
  * Tests for the ProductFilterAttribute block type.
  */
-class ProductFilterAttributeTest extends \WP_UnitTestCase {
+class ProductFilterAttributeTest extends WC_Unit_Test_Case {

 	/**
-	 * Test that rendering returns empty string when the attribute ID references a deleted taxonomy.
+	 * Attribute counts returned by the product filter data hook.
 	 *
-	 * Regression test for https://github.com/woocommerce/woocommerce/issues/63791
+	 * @var array
+	 */
+	private $attribute_counts = array();
+
+	/**
+	 * Attribute IDs created during tests.
+	 *
+	 * @var array
+	 */
+	private $attribute_ids = array();
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		add_filter( 'woocommerce_pre_product_filter_data', array( $this, 'filter_product_filter_data' ), 10, 4 );
+	}
+
+	/**
+	 * Tear down test fixtures.
 	 */
-	public function test_render_returns_empty_for_deleted_attribute() {
+	public function tearDown(): void {
+		remove_filter( 'woocommerce_pre_product_filter_data', array( $this, 'filter_product_filter_data' ), 10 );
+
+		foreach ( $this->attribute_ids as $attribute_id ) {
+			wc_delete_attribute( $attribute_id );
+		}
+
+		if ( taxonomy_exists( 'pa_material' ) ) {
+			unregister_taxonomy( 'pa_material' );
+		}
+
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox Should render empty output when the attribute ID references a deleted taxonomy.
+	 *
+	 * Regression test for https://github.com/woocommerce/woocommerce/issues/63791.
+	 */
+	public function test_render_returns_empty_for_deleted_attribute(): void {
 		$non_existent_attribute_id = 999999;

 		$block_markup = sprintf(
@@ -28,4 +70,100 @@ class ProductFilterAttributeTest extends \WP_UnitTestCase {

 		$this->assertSame( '', $output );
 	}
+
+	/**
+	 * @testdox Should render decoded attribute term labels in checkbox filters.
+	 */
+	public function test_renders_decoded_attribute_term_labels_in_checkbox_filters(): void {
+		$attribute_id = wc_create_attribute(
+			array(
+				'name' => 'Material',
+				'slug' => 'material',
+			)
+		);
+
+		$this->assertIsInt( $attribute_id, 'Attribute should be created.' );
+
+		$this->attribute_ids[] = $attribute_id;
+
+		if ( ! taxonomy_exists( 'pa_material' ) ) {
+			register_taxonomy( 'pa_material', array( 'product' ), array( 'labels' => array( 'name' => 'Material' ) ) );
+		}
+
+		$term = wp_insert_term( 'Cotton & Linen', 'pa_material' );
+
+		$this->assertNotWPError( $term );
+
+		$term_id                       = (int) $term['term_id'];
+		$this->attribute_counts        = array( $term_id => 1 );
+		$stored_term                   = get_term( $term_id, 'pa_material' );
+		$expected_serialized_label     = 'Cotton &amp; Linen';
+		$expected_context_label        = 'Cotton \\u0026 Linen';
+		$double_encoded_serialization  = 'Cotton &amp;amp; Linen';
+		$double_encoded_context_entity = '\\u0026amp;';
+
+		$this->assertSame( 'Cotton &amp; Linen', $stored_term->name, 'Term fixture should use WordPress encoded storage.' );
+
+		$output = $this->render_attribute_filter_with_checkbox_list( $attribute_id );
+
+		$this->assertStringContainsString( $expected_serialized_label, $output, 'Rendered label should be serialized once for HTML output.' );
+		$this->assertStringContainsString( $expected_context_label, $output, 'Interactivity context should contain the decoded label.' );
+		$this->assertStringNotContainsString( $double_encoded_serialization, $output, 'Rendered label should not be double-encoded.' );
+		$this->assertStringNotContainsString( $double_encoded_context_entity, $output, 'Interactivity context should not contain an encoded entity label.' );
+	}
+
+	/**
+	 * Render an attribute filter block with a checkbox list inner block.
+	 *
+	 * @param int $attribute_id Attribute ID.
+	 * @return string
+	 */
+	private function render_attribute_filter_with_checkbox_list( int $attribute_id ): string {
+		$attribute_block = array(
+			'blockName'    => 'woocommerce/product-filter-attribute',
+			'attrs'        => array(
+				'attributeId' => $attribute_id,
+				'hideEmpty'   => false,
+				'queryType'   => 'or',
+				'sortOrder'   => 'name-asc',
+			),
+			'innerBlocks'  => array(
+				array(
+					'blockName'    => 'woocommerce/product-filter-checkbox-list',
+					'attrs'        => array(),
+					'innerBlocks'  => array(),
+					'innerHTML'    => '',
+					'innerContent' => array(),
+				),
+			),
+			'innerHTML'    => '',
+			'innerContent' => array( null ),
+		);
+		$parsed_block    = array(
+			'blockName'    => 'woocommerce/product-filters',
+			'attrs'        => array( 'showFilterDrawer' => false ),
+			'innerBlocks'  => array( $attribute_block ),
+			'innerHTML'    => '',
+			'innerContent' => array( null ),
+		);
+
+		return ( new \WP_Block( $parsed_block ) )->render();
+	}
+
+	/**
+	 * Filter product filter data for tests.
+	 *
+	 * @param mixed  $results     Filter data results.
+	 * @param string $filter_type Filter type.
+	 * @param array  $query_vars  Query variables.
+	 * @param array  $extra       Extra filter arguments.
+	 * @return mixed
+	 */
+	public function filter_product_filter_data( $results, string $filter_type, array $query_vars, array $extra ) {
+		if ( 'attribute' === $filter_type ) {
+			return $this->attribute_counts;
+		}
+
+		return $results;
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductFilterTaxonomyTest.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductFilterTaxonomyTest.php
new file mode 100644
index 00000000000..d989be19369
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductFilterTaxonomyTest.php
@@ -0,0 +1,127 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\BlockTypes;
+
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the ProductFilterTaxonomy block type.
+ */
+class ProductFilterTaxonomyTest extends WC_Unit_Test_Case {
+
+	/**
+	 * Term counts returned by the product filter data hook.
+	 *
+	 * @var array
+	 */
+	private $taxonomy_counts = array();
+
+	/**
+	 * Term IDs created during tests.
+	 *
+	 * @var array
+	 */
+	private $term_ids = array();
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		add_filter( 'woocommerce_pre_product_filter_data', array( $this, 'filter_product_filter_data' ), 10, 4 );
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		remove_filter( 'woocommerce_pre_product_filter_data', array( $this, 'filter_product_filter_data' ), 10 );
+
+		foreach ( $this->term_ids as $term_id ) {
+			wp_delete_term( $term_id, 'product_cat' );
+		}
+
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox Should render decoded taxonomy term labels in checkbox filters.
+	 */
+	public function test_renders_decoded_taxonomy_term_labels_in_checkbox_filters(): void {
+		$term = wp_insert_term( 'Indoor & Tropical Bonsai', 'product_cat' );
+
+		$this->assertNotWPError( $term );
+
+		$term_id                       = (int) $term['term_id'];
+		$this->term_ids[]              = $term_id;
+		$this->taxonomy_counts         = array( $term_id => 1 );
+		$stored_term                   = get_term( $term_id, 'product_cat' );
+		$expected_serialized_label     = 'Indoor &amp; Tropical Bonsai';
+		$expected_context_label        = 'Indoor \\u0026 Tropical Bonsai';
+		$double_encoded_serialization  = 'Indoor &amp;amp; Tropical Bonsai';
+		$double_encoded_context_entity = '\\u0026amp;';
+
+		$this->assertSame( 'Indoor &amp; Tropical Bonsai', $stored_term->name, 'Term fixture should use WordPress encoded storage.' );
+
+		$output = $this->render_taxonomy_filter_with_checkbox_list();
+
+		$this->assertStringContainsString( $expected_serialized_label, $output, 'Rendered label should be serialized once for HTML output.' );
+		$this->assertStringContainsString( $expected_context_label, $output, 'Interactivity context should contain the decoded label.' );
+		$this->assertStringNotContainsString( $double_encoded_serialization, $output, 'Rendered label should not be double-encoded.' );
+		$this->assertStringNotContainsString( $double_encoded_context_entity, $output, 'Interactivity context should not contain an encoded entity label.' );
+	}
+
+	/**
+	 * Render a taxonomy filter block with a checkbox list inner block.
+	 *
+	 * @return string
+	 */
+	private function render_taxonomy_filter_with_checkbox_list(): string {
+		$taxonomy_block = array(
+			'blockName'    => 'woocommerce/product-filter-taxonomy',
+			'attrs'        => array(
+				'taxonomy'  => 'product_cat',
+				'sortOrder' => 'name-asc',
+			),
+			'innerBlocks'  => array(
+				array(
+					'blockName'    => 'woocommerce/product-filter-checkbox-list',
+					'attrs'        => array(),
+					'innerBlocks'  => array(),
+					'innerHTML'    => '',
+					'innerContent' => array(),
+				),
+			),
+			'innerHTML'    => '',
+			'innerContent' => array( null ),
+		);
+		$parsed_block   = array(
+			'blockName'    => 'woocommerce/product-filters',
+			'attrs'        => array( 'showFilterDrawer' => false ),
+			'innerBlocks'  => array( $taxonomy_block ),
+			'innerHTML'    => '',
+			'innerContent' => array( null ),
+		);
+
+		return ( new \WP_Block( $parsed_block ) )->render();
+	}
+
+	/**
+	 * Filter product filter data for tests.
+	 *
+	 * @param mixed  $results     Filter data results.
+	 * @param string $filter_type Filter type.
+	 * @param array  $query_vars  Query variables.
+	 * @param array  $extra       Extra filter arguments.
+	 * @return mixed
+	 */
+	public function filter_product_filter_data( $results, string $filter_type, array $query_vars, array $extra ) {
+		if ( 'taxonomy' === $filter_type && 'product_cat' === ( $extra['taxonomy'] ?? '' ) ) {
+			return $this->taxonomy_counts;
+		}
+
+		return $results;
+	}
+}