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 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 ` ` or `'`, 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', ' ' );
+ update_option( 'woocommerce_price_decimal_sep', ',' );
+
+ $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', ' ' );
+ update_option( 'woocommerce_price_decimal_sep', ',' );
+
+ $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', ' ' );
+ update_option( 'woocommerce_price_decimal_sep', ',' );
+
+ $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', ' ' );
+ update_option( 'woocommerce_price_decimal_sep', ',' );
+
+ $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( ' ', "\u{00A0}" ),
+ 'numeric entity comma' => array( ',', ',' ),
+ 'HTML5-only entity apos' => array( ''', "'" ),
+ );
+ }
+
+ /**
+ * @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()' );
+ }
+}