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 & Linen';
+ $expected_context_label = 'Cotton \\u0026 Linen';
+ $double_encoded_serialization = 'Cotton &amp; Linen';
+ $double_encoded_context_entity = '\\u0026amp;';
+
+ $this->assertSame( 'Cotton & 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 & Tropical Bonsai';
+ $expected_context_label = 'Indoor \\u0026 Tropical Bonsai';
+ $double_encoded_serialization = 'Indoor &amp; Tropical Bonsai';
+ $double_encoded_context_entity = '\\u0026amp;';
+
+ $this->assertSame( 'Indoor & 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;
+ }
+}