Commit eb4524bde91 for woocommerce

commit eb4524bde91aa1f37782fffe0a34cd5015bb6712
Author: Peter Petrov <peter.petrov89@gmail.com>
Date:   Tue Aug 18 11:28:24 2026 +0300

    Fix raw HTML entity shown when price separator is stored as an entity (#67732)

    * Decode HTML entities in price separators exposed to the client

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * Use ENT_HTML5 and extract separator decoding into a shared helper

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/fix-41577-nbsp-separator-entities b/plugins/woocommerce/changelog/fix-41577-nbsp-separator-entities
new file mode 100644
index 00000000000..1197c3257c8
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-41577-nbsp-separator-entities
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Display the real character instead of the raw HTML entity when the price thousand or decimal separator is stored as an entity such as &nbsp; in Analytics, admin screens, and blocks.
diff --git a/plugins/woocommerce/src/Blocks/Assets/AssetDataRegistry.php b/plugins/woocommerce/src/Blocks/Assets/AssetDataRegistry.php
index b0e523f4efa..15a8e832319 100644
--- a/plugins/woocommerce/src/Blocks/Assets/AssetDataRegistry.php
+++ b/plugins/woocommerce/src/Blocks/Assets/AssetDataRegistry.php
@@ -6,6 +6,7 @@ use Automattic\WooCommerce\Blocks\Package;
 use Automattic\WooCommerce\Utilities\FeaturesUtil;
 use Automattic\WooCommerce\Blocks\Domain\Services\Hydration;
 use Automattic\WooCommerce\Internal\Logging\RemoteLogger;
+use Automattic\WooCommerce\Internal\Utilities\PriceSeparators;
 use Exception;
 use InvalidArgumentException;

@@ -119,8 +120,8 @@ class AssetDataRegistry {
 			'precision'         => wc_get_price_decimals(),
 			'symbol'            => html_entity_decode( get_woocommerce_currency_symbol( $currency ) ),
 			'symbolPosition'    => get_option( 'woocommerce_currency_pos' ),
-			'decimalSeparator'  => wc_get_price_decimal_separator(),
-			'thousandSeparator' => wc_get_price_thousand_separator(),
+			'decimalSeparator'  => PriceSeparators::get_decimal(),
+			'thousandSeparator' => PriceSeparators::get_thousand(),
 			'priceFormat'       => html_entity_decode( get_woocommerce_price_format() ),
 		];
 	}
diff --git a/plugins/woocommerce/src/Blocks/Utils/BlocksSharedState.php b/plugins/woocommerce/src/Blocks/Utils/BlocksSharedState.php
index e01a0f9ab14..def8db66c38 100644
--- a/plugins/woocommerce/src/Blocks/Utils/BlocksSharedState.php
+++ b/plugins/woocommerce/src/Blocks/Utils/BlocksSharedState.php
@@ -7,6 +7,7 @@ namespace Automattic\WooCommerce\Blocks\Utils;
 use InvalidArgumentException;
 use Automattic\WooCommerce\Blocks\Package;
 use Automattic\WooCommerce\Blocks\Domain\Services\Hydration;
+use Automattic\WooCommerce\Internal\Utilities\PriceSeparators;

 /**
  * Manages the registration of interactivity config and state that is commonly shared by WooCommerce blocks.
@@ -143,8 +144,8 @@ class BlocksSharedState {
 				'precision'         => wc_get_price_decimals(),
 				'symbol'            => html_entity_decode( get_woocommerce_currency_symbol( $currency ) ),
 				'symbolPosition'    => get_option( 'woocommerce_currency_pos' ),
-				'decimalSeparator'  => wc_get_price_decimal_separator(),
-				'thousandSeparator' => wc_get_price_thousand_separator(),
+				'decimalSeparator'  => PriceSeparators::get_decimal(),
+				'thousandSeparator' => PriceSeparators::get_thousand(),
 				'priceFormat'       => html_entity_decode( get_woocommerce_price_format() ),
 			),
 		);
diff --git a/plugins/woocommerce/src/Internal/Admin/Settings.php b/plugins/woocommerce/src/Internal/Admin/Settings.php
index c70fdf98c06..d6033f1ca8b 100644
--- a/plugins/woocommerce/src/Internal/Admin/Settings.php
+++ b/plugins/woocommerce/src/Internal/Admin/Settings.php
@@ -11,6 +11,7 @@ use Automattic\WooCommerce\Admin\Features\Features;
 use Automattic\WooCommerce\Admin\PageController;
 use Automattic\WooCommerce\Admin\PluginsHelper;
 use Automattic\WooCommerce\Internal\Admin\Settings\SettingsUIRequestContext;
+use Automattic\WooCommerce\Internal\Utilities\PriceSeparators;
 use Automattic\WooCommerce\Utilities\FeaturesUtil;
 use Automattic\WooCommerce\Utilities\OrderUtil;
 use WC_Marketplace_Suggestions;
@@ -105,8 +106,8 @@ class Settings {
 				'precision'         => wc_get_price_decimals(),
 				'symbol'            => html_entity_decode( get_woocommerce_currency_symbol( $code ) ),
 				'symbolPosition'    => get_option( 'woocommerce_currency_pos' ),
-				'decimalSeparator'  => wc_get_price_decimal_separator(),
-				'thousandSeparator' => wc_get_price_thousand_separator(),
+				'decimalSeparator'  => PriceSeparators::get_decimal(),
+				'thousandSeparator' => PriceSeparators::get_thousand(),
 				'priceFormat'       => html_entity_decode( get_woocommerce_price_format() ),
 			)
 		);
diff --git a/plugins/woocommerce/src/Internal/Utilities/PriceSeparators.php b/plugins/woocommerce/src/Internal/Utilities/PriceSeparators.php
new file mode 100644
index 00000000000..a0a3f5ff8c2
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Utilities/PriceSeparators.php
@@ -0,0 +1,34 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\Utilities;
+
+/**
+ * Provides the price separators decoded for non-HTML consumers (client settings payloads, APIs).
+ *
+ * Merchants may store a separator as an HTML entity such as `&nbsp;` or `&apos;`, which renders
+ * correctly in HTML output but shows up as literal text when the raw value reaches JavaScript or
+ * API responses. ENT_HTML5 matches how browsers parse the entity in HTML output.
+ *
+ * @since 11.2.0
+ */
+class PriceSeparators {
+
+	/**
+	 * Get the decimal separator with HTML entities decoded.
+	 *
+	 * @return string
+	 */
+	public static function get_decimal(): string {
+		return html_entity_decode( wc_get_price_decimal_separator(), ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5 );
+	}
+
+	/**
+	 * Get the thousand separator with HTML entities decoded.
+	 *
+	 * @return string
+	 */
+	public static function get_thousand(): string {
+		return html_entity_decode( wc_get_price_thousand_separator(), ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5 );
+	}
+}
diff --git a/plugins/woocommerce/src/StoreApi/Formatters/CurrencyFormatter.php b/plugins/woocommerce/src/StoreApi/Formatters/CurrencyFormatter.php
index 7a48794e54e..0d4f5a3961b 100644
--- a/plugins/woocommerce/src/StoreApi/Formatters/CurrencyFormatter.php
+++ b/plugins/woocommerce/src/StoreApi/Formatters/CurrencyFormatter.php
@@ -2,6 +2,7 @@
 namespace Automattic\WooCommerce\StoreApi\Formatters;

 use Automattic\WooCommerce\Enums\CurrencyPosition;
+use Automattic\WooCommerce\Internal\Utilities\PriceSeparators;

 /**
  * Currency Formatter.
@@ -43,8 +44,8 @@ class CurrencyFormatter implements FormatterInterface {
 				'currency_code'               => get_woocommerce_currency(),
 				'currency_symbol'             => $symbol,
 				'currency_minor_unit'         => wc_get_price_decimals(),
-				'currency_decimal_separator'  => wc_get_price_decimal_separator(),
-				'currency_thousand_separator' => wc_get_price_thousand_separator(),
+				'currency_decimal_separator'  => PriceSeparators::get_decimal(),
+				'currency_thousand_separator' => PriceSeparators::get_thousand(),
 				'currency_prefix'             => $prefix,
 				'currency_suffix'             => $suffix,
 			]
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Assets/AssetDataRegistry.php b/plugins/woocommerce/tests/php/src/Blocks/Assets/AssetDataRegistry.php
index 4958717dbcf..5e02cff825c 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/Assets/AssetDataRegistry.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/Assets/AssetDataRegistry.php
@@ -28,6 +28,21 @@ class AssetDataRegistry extends \WP_UnitTestCase {
 		$this->assertEmpty( $this->registry->get() );
 	}

+	/**
+	 * @testdox Currency data decodes HTML entities in the price separators.
+	 */
+	public function test_currency_data_decodes_separator_entities() {
+		update_option( 'woocommerce_price_thousand_sep', '&nbsp;' );
+		update_option( 'woocommerce_price_decimal_sep', '&#44;' );
+
+		$method = new \ReflectionMethod( $this->registry, 'get_currency_data' );
+		$method->setAccessible( true );
+		$currency_data = $method->invoke( $this->registry );
+
+		$this->assertSame( "\u{00A0}", $currency_data['thousandSeparator'] );
+		$this->assertSame( ',', $currency_data['decimalSeparator'] );
+	}
+
 	public function test_add_data() {
 		$this->registry->add( 'test', 'foo' );
 		$this->assertEquals( [ 'test' => 'foo' ], $this->registry->get() );
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Formatters/TestCurrencyFormatter.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Formatters/TestCurrencyFormatter.php
index 17f5d072598..065c59c8478 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Formatters/TestCurrencyFormatter.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Formatters/TestCurrencyFormatter.php
@@ -36,4 +36,17 @@ class TestCurrencyFormatter extends \WP_UnitTestCase {
 		$this->assertArrayHasKey( 'currency_prefix', $value );
 		$this->assertArrayHasKey( 'currency_suffix', $value );
 	}
+
+	/**
+	 * @testdox Formatted currency data decodes HTML entities in the price separators.
+	 */
+	public function test_format_decodes_separator_entities() {
+		update_option( 'woocommerce_price_thousand_sep', '&nbsp;' );
+		update_option( 'woocommerce_price_decimal_sep', '&#44;' );
+
+		$value = $this->mock_formatter->format( [] );
+
+		$this->assertSame( "\u{00A0}", $value['currency_thousand_separator'] );
+		$this->assertSame( ',', $value['currency_decimal_separator'] );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Utils/BlocksSharedStateTest.php b/plugins/woocommerce/tests/php/src/Blocks/Utils/BlocksSharedStateTest.php
index 0e9bef12b06..c99b1a66081 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/Utils/BlocksSharedStateTest.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/Utils/BlocksSharedStateTest.php
@@ -59,6 +59,21 @@ class BlocksSharedStateTest extends \WC_Unit_Test_Case {
 		$config_data->setValue( $interactivity, $data );
 	}

+	/**
+	 * @testdox Currency data decodes HTML entities in the price separators.
+	 */
+	public function test_currency_data_decodes_separator_entities(): void {
+		update_option( 'woocommerce_price_thousand_sep', '&nbsp;' );
+		update_option( 'woocommerce_price_decimal_sep', '&#44;' );
+
+		$method = new \ReflectionMethod( BlocksSharedState::class, 'get_currency_data' );
+		$method->setAccessible( true );
+		$currency_data = $method->invoke( null );
+
+		$this->assertSame( "\u{00A0}", $currency_data['currency']['thousandSeparator'] );
+		$this->assertSame( ',', $currency_data['currency']['decimalSeparator'] );
+	}
+
 	/**
 	 * @testdox nonOptimisticProperties is empty when no filter is registered.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/SettingsTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/SettingsTest.php
index 2dae4cf06d0..4bfe08eb600 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/SettingsTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/SettingsTest.php
@@ -48,6 +48,19 @@ class SettingsTest extends WC_Unit_Test_Case {
 		$this->assertSame( 'date_completed', $date_type['value'], 'A saved date type option should be reflected as the setting value' );
 	}

+	/**
+	 * @testdox Should decode HTML entities in the currency separators exposed to the client.
+	 */
+	public function test_currency_settings_decode_separator_entities(): void {
+		update_option( 'woocommerce_price_thousand_sep', '&nbsp;' );
+		update_option( 'woocommerce_price_decimal_sep', '&#44;' );
+
+		$currency_settings = Settings::get_currency_settings();
+
+		$this->assertSame( "\u{00A0}", $currency_settings['thousandSeparator'], 'A thousand separator stored as an HTML entity should be decoded to the real character' );
+		$this->assertSame( ',', $currency_settings['decimalSeparator'], 'A decimal separator stored as an HTML entity should be decoded to the real character' );
+	}
+
 	/**
 	 * Get the resolved wc_admin group settings via the REST settings controller.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Internal/Utilities/PriceSeparatorsTest.php b/plugins/woocommerce/tests/php/src/Internal/Utilities/PriceSeparatorsTest.php
new file mode 100644
index 00000000000..d62e85f884d
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Utilities/PriceSeparatorsTest.php
@@ -0,0 +1,55 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Utilities;
+
+use Automattic\WooCommerce\Internal\Utilities\PriceSeparators;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the PriceSeparators class.
+ */
+class PriceSeparatorsTest extends WC_Unit_Test_Case {
+
+	/**
+	 * @testdox Should decode HTML entities stored as separators, including HTML5-only entities.
+	 * @dataProvider separator_entity_data
+	 *
+	 * @param string $stored   The raw option value.
+	 * @param string $expected The expected decoded separator.
+	 */
+	public function test_separators_are_decoded( string $stored, string $expected ): void {
+		update_option( 'woocommerce_price_thousand_sep', $stored );
+		update_option( 'woocommerce_price_decimal_sep', $stored );
+
+		$this->assertSame( $expected, PriceSeparators::get_thousand(), "Thousand separator stored as '{$stored}' should decode to '{$expected}'" );
+		$this->assertSame( $expected, PriceSeparators::get_decimal(), "Decimal separator stored as '{$stored}' should decode to '{$expected}'" );
+	}
+
+	/**
+	 * Data provider of stored separator values and their expected decoded forms.
+	 *
+	 * @return array
+	 */
+	public function separator_entity_data(): array {
+		return array(
+			'plain comma'             => array( ',', ',' ),
+			'plain space'             => array( ' ', ' ' ),
+			'non-breaking space char' => array( "\u{00A0}", "\u{00A0}" ),
+			'named entity nbsp'       => array( '&nbsp;', "\u{00A0}" ),
+			'numeric entity comma'    => array( '&#44;', ',' ),
+			'HTML5-only entity apos'  => array( '&apos;', "'" ),
+		);
+	}
+
+	/**
+	 * @testdox Should preserve the core getters' fallbacks when the options are empty.
+	 */
+	public function test_empty_options_preserve_getter_fallbacks(): void {
+		update_option( 'woocommerce_price_thousand_sep', '' );
+		update_option( 'woocommerce_price_decimal_sep', '' );
+
+		$this->assertSame( '', PriceSeparators::get_thousand(), 'An empty thousand separator should stay empty' );
+		$this->assertSame( '.', PriceSeparators::get_decimal(), 'An empty decimal separator should fall back to a period, matching wc_get_price_decimal_separator()' );
+	}
+}