Commit 23b89ba1eb9 for woocommerce

commit 23b89ba1eb9846b8671e4d850b2d3aff3e663cbe
Author: Mike Jolley <mike.jolley@me.com>
Date:   Wed Nov 11 11:38:39 2026 +0000

    Use the filtered price format for currency position in blocks (#69710)

    * Fix blocks ignoring the filtered price format for currency position

    * Add changelog entry for blocks currency position fix

    * Ignore markup when detecting currency symbol spacing

    * Use strip_tags when detecting currency symbol spacing

    * Update CurrencySymbol @since for the next release

diff --git a/plugins/woocommerce/changelog/42250-blocks-filtered-currency-position b/plugins/woocommerce/changelog/42250-blocks-filtered-currency-position
new file mode 100644
index 00000000000..468e1792c22
--- /dev/null
+++ b/plugins/woocommerce/changelog/42250-blocks-filtered-currency-position
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Show the currency symbol in the Mini-Cart, Cart and Checkout blocks where extensions place it with the woocommerce_price_format filter.
diff --git a/plugins/woocommerce/src/Blocks/Assets/AssetDataRegistry.php b/plugins/woocommerce/src/Blocks/Assets/AssetDataRegistry.php
index 6c764549488..5f31c9a268f 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\CurrencySymbol;
 use Automattic\WooCommerce\Internal\Utilities\PriceSeparators;
 use Exception;
 use InvalidArgumentException;
@@ -120,8 +121,8 @@ class AssetDataRegistry {
 		return [
 			'code'              => $currency,
 			'precision'         => wc_get_price_decimals(),
-			'symbol'            => html_entity_decode( get_woocommerce_currency_symbol( $currency ) ),
-			'symbolPosition'    => get_option( 'woocommerce_currency_pos' ),
+			'symbol'            => CurrencySymbol::get_symbol(),
+			'symbolPosition'    => CurrencySymbol::get_position(),
 			'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 def8db66c38..798b89630eb 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\CurrencySymbol;
 use Automattic\WooCommerce\Internal\Utilities\PriceSeparators;

 /**
@@ -142,8 +143,8 @@ class BlocksSharedState {
 			'currency' => array(
 				'code'              => $currency,
 				'precision'         => wc_get_price_decimals(),
-				'symbol'            => html_entity_decode( get_woocommerce_currency_symbol( $currency ) ),
-				'symbolPosition'    => get_option( 'woocommerce_currency_pos' ),
+				'symbol'            => CurrencySymbol::get_symbol(),
+				'symbolPosition'    => CurrencySymbol::get_position(),
 				'decimalSeparator'  => PriceSeparators::get_decimal(),
 				'thousandSeparator' => PriceSeparators::get_thousand(),
 				'priceFormat'       => html_entity_decode( get_woocommerce_price_format() ),
diff --git a/plugins/woocommerce/src/Internal/Utilities/CurrencySymbol.php b/plugins/woocommerce/src/Internal/Utilities/CurrencySymbol.php
new file mode 100644
index 00000000000..e9094bda835
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Utilities/CurrencySymbol.php
@@ -0,0 +1,59 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\Utilities;
+
+use Automattic\WooCommerce\Enums\CurrencyPosition;
+
+/**
+ * Provides the currency symbol and its position for non-HTML consumers (client settings payloads, APIs).
+ *
+ * The position is read from the filtered price format rather than the `woocommerce_currency_pos`
+ * option, so extensions that filter `woocommerce_price_format` (e.g. currency switchers) apply here too.
+ *
+ * @since 11.4.0
+ */
+class CurrencySymbol {
+
+	/**
+	 * Get the store currency symbol with HTML entities decoded.
+	 *
+	 * ENT_HTML5 matches how browsers parse the entity in HTML output, as in PriceSeparators.
+	 *
+	 * @return string
+	 */
+	public static function get_symbol(): string {
+		return html_entity_decode( get_woocommerce_currency_symbol(), ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5 );
+	}
+
+	/**
+	 * Get the currency symbol position, as one of the CurrencyPosition values.
+	 *
+	 * Only visible whitespace between the symbol and the amount in the price format counts as a space,
+	 * so markup is ignored and entities such as `&nbsp;` are decoded first.
+	 * Falls back to the option when the format is missing either placeholder.
+	 *
+	 * @return string
+	 */
+	public static function get_position(): string {
+		$format     = (string) get_woocommerce_price_format();
+		$symbol_pos = strpos( $format, '%1$s' );
+		$amount_pos = strpos( $format, '%2$s' );
+
+		if ( false === $symbol_pos || false === $amount_pos ) {
+			return (string) get_option( 'woocommerce_currency_pos' );
+		}
+
+		$placeholder_length = strlen( '%1$s' );
+		$between            = (string) substr( $format, min( $symbol_pos, $amount_pos ) + $placeholder_length, abs( $amount_pos - $symbol_pos ) - $placeholder_length );
+		// phpcs:ignore WordPress.WP.AlternativeFunctions.strip_tags_strip_tags -- wp_strip_all_tags() trims, which would drop a plain-space separator.
+		$visible   = html_entity_decode( strip_tags( $between ), ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5 );
+		$has_space = 1 === preg_match( '/[\s\p{Zs}]/u', $visible );
+
+		if ( $symbol_pos < $amount_pos ) {
+			return $has_space ? CurrencyPosition::LEFT_SPACE : CurrencyPosition::LEFT;
+		}
+
+		return $has_space ? CurrencyPosition::RIGHT_SPACE : CurrencyPosition::RIGHT;
+	}
+}
diff --git a/plugins/woocommerce/src/StoreApi/Formatters/CurrencyFormatter.php b/plugins/woocommerce/src/StoreApi/Formatters/CurrencyFormatter.php
index 0d4f5a3961b..44393fe920f 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\CurrencySymbol;
 use Automattic\WooCommerce\Internal\Utilities\PriceSeparators;

 /**
@@ -18,8 +19,8 @@ class CurrencyFormatter implements FormatterInterface {
 	 * @return array
 	 */
 	public function format( $value, array $options = [] ) {
-		$position = get_option( 'woocommerce_currency_pos' );
-		$symbol   = html_entity_decode( get_woocommerce_currency_symbol() );
+		$position = CurrencySymbol::get_position();
+		$symbol   = CurrencySymbol::get_symbol();
 		$prefix   = '';
 		$suffix   = '';

diff --git a/plugins/woocommerce/tests/php/src/Blocks/Assets/AssetDataRegistry.php b/plugins/woocommerce/tests/php/src/Blocks/Assets/AssetDataRegistry.php
index d7c23efea32..4398fa42fdd 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/Assets/AssetDataRegistry.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/Assets/AssetDataRegistry.php
@@ -43,6 +43,25 @@ class AssetDataRegistry extends \WP_UnitTestCase {
 		$this->assertSame( ',', $currency_data['decimalSeparator'] );
 	}

+	/**
+	 * @testdox Currency symbol position follows the filtered price format.
+	 */
+	public function test_currency_data_symbol_position_uses_filtered_price_format() {
+		update_option( 'woocommerce_currency_pos', 'left' );
+		add_filter(
+			'woocommerce_price_format',
+			function () {
+				return '%2$s&nbsp;%1$s';
+			}
+		);
+
+		$method = new \ReflectionMethod( $this->registry, 'get_currency_data' );
+		$method->setAccessible( true );
+		$currency_data = $method->invoke( $this->registry );
+
+		$this->assertSame( 'right_space', $currency_data['symbolPosition'] );
+	}
+
 	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 065c59c8478..67dd9359830 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Formatters/TestCurrencyFormatter.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Formatters/TestCurrencyFormatter.php
@@ -49,4 +49,23 @@ class TestCurrencyFormatter extends \WP_UnitTestCase {
 		$this->assertSame( "\u{00A0}", $value['currency_thousand_separator'] );
 		$this->assertSame( ',', $value['currency_decimal_separator'] );
 	}
+
+	/**
+	 * @testdox Currency prefix and suffix follow the filtered price format.
+	 */
+	public function test_format_uses_filtered_price_format() {
+		update_option( 'woocommerce_currency_pos', 'left' );
+		add_filter(
+			'woocommerce_price_format',
+			function () {
+				return '%2$s&nbsp;%1$s';
+			}
+		);
+
+		$value  = $this->mock_formatter->format( [] );
+		$symbol = $value['currency_symbol'];
+
+		$this->assertSame( '', $value['currency_prefix'], 'A right-positioned price format should not add a prefix' );
+		$this->assertSame( ' ' . $symbol, $value['currency_suffix'], 'A right-positioned price format should add a spaced suffix' );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Utils/BlocksSharedStateTest.php b/plugins/woocommerce/tests/php/src/Blocks/Utils/BlocksSharedStateTest.php
index c99b1a66081..2c83029c7de 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/Utils/BlocksSharedStateTest.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/Utils/BlocksSharedStateTest.php
@@ -74,6 +74,25 @@ class BlocksSharedStateTest extends \WC_Unit_Test_Case {
 		$this->assertSame( ',', $currency_data['currency']['decimalSeparator'] );
 	}

+	/**
+	 * @testdox Currency symbol position follows the filtered price format.
+	 */
+	public function test_currency_data_symbol_position_uses_filtered_price_format(): void {
+		update_option( 'woocommerce_currency_pos', 'left' );
+		add_filter(
+			'woocommerce_price_format',
+			function () {
+				return '%2$s&nbsp;%1$s';
+			}
+		);
+
+		$method = new \ReflectionMethod( BlocksSharedState::class, 'get_currency_data' );
+		$method->setAccessible( true );
+		$currency_data = $method->invoke( null );
+
+		$this->assertSame( 'right_space', $currency_data['currency']['symbolPosition'] );
+	}
+
 	/**
 	 * @testdox nonOptimisticProperties is empty when no filter is registered.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/Utilities/CurrencySymbolTest.php b/plugins/woocommerce/tests/php/src/Internal/Utilities/CurrencySymbolTest.php
new file mode 100644
index 00000000000..ec472b7d959
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Utilities/CurrencySymbolTest.php
@@ -0,0 +1,118 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Utilities;
+
+use Automattic\WooCommerce\Enums\CurrencyPosition;
+use Automattic\WooCommerce\Internal\Utilities\CurrencySymbol;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the CurrencySymbol class.
+ */
+class CurrencySymbolTest extends WC_Unit_Test_Case {
+
+	/**
+	 * @testdox Should decode HTML entities in the currency symbol.
+	 * @testWith ["EUR", "€"]
+	 *           ["USD", "$"]
+	 *           ["AED", "د.إ"]
+	 *
+	 * @param string $currency The store currency.
+	 * @param string $expected The expected decoded symbol.
+	 */
+	public function test_symbol_is_decoded( string $currency, string $expected ): void {
+		update_option( 'woocommerce_currency', $currency );
+
+		$this->assertSame( $expected, CurrencySymbol::get_symbol(), "The {$currency} symbol should be decoded to plain text" );
+	}
+
+	/**
+	 * @testdox Should decode a filtered currency symbol the way browsers parse it.
+	 * @testWith ["z&#322;", "zł"]
+	 *           ["&dollar;", "$"]
+	 *           ["&apos;", "'"]
+	 *
+	 * @param string $filtered The filtered symbol.
+	 * @param string $expected The expected decoded symbol.
+	 */
+	public function test_filtered_symbol_is_decoded( string $filtered, string $expected ): void {
+		add_filter(
+			'woocommerce_currency_symbol',
+			function () use ( $filtered ) {
+				return $filtered;
+			}
+		);
+
+		$this->assertSame( $expected, CurrencySymbol::get_symbol(), "A symbol filtered to '{$filtered}' should decode to '{$expected}'" );
+	}
+
+	/**
+	 * @testdox Should match the currency position option when the price format is not filtered.
+	 * @testWith ["left"]
+	 *           ["right"]
+	 *           ["left_space"]
+	 *           ["right_space"]
+	 *
+	 * @param string $position The stored currency position.
+	 */
+	public function test_matches_option_when_price_format_is_not_filtered( string $position ): void {
+		update_option( 'woocommerce_currency_pos', $position );
+
+		$this->assertSame( $position, CurrencySymbol::get_position(), "The '{$position}' option should be returned unchanged" );
+	}
+
+	/**
+	 * @testdox Should derive the position from a filtered price format.
+	 * @dataProvider filtered_price_format_data
+	 *
+	 * @param string $format   The filtered price format.
+	 * @param string $expected The expected symbol position.
+	 */
+	public function test_derives_position_from_filtered_price_format( string $format, string $expected ): void {
+		update_option( 'woocommerce_currency_pos', CurrencyPosition::LEFT );
+		add_filter(
+			'woocommerce_price_format',
+			function () use ( $format ) {
+				return $format;
+			}
+		);
+
+		$this->assertSame( $expected, CurrencySymbol::get_position(), "The '{$format}' price format should map to '{$expected}'" );
+	}
+
+	/**
+	 * Data provider of filtered price formats and their expected symbol positions.
+	 *
+	 * @return array
+	 */
+	public function filtered_price_format_data(): array {
+		return array(
+			'right'                    => array( '%2$s%1$s', CurrencyPosition::RIGHT ),
+			'right with nbsp'          => array( '%2$s&nbsp;%1$s', CurrencyPosition::RIGHT_SPACE ),
+			'right with space'         => array( '%2$s %1$s', CurrencyPosition::RIGHT_SPACE ),
+			'left with nbsp'           => array( '%1$s&nbsp;%2$s', CurrencyPosition::LEFT_SPACE ),
+			'right with code appended' => array( '%2$s&nbsp;%1$sPLN', CurrencyPosition::RIGHT_SPACE ),
+			'left with code prepended' => array( 'USD%1$s%2$s', CurrencyPosition::LEFT ),
+			'left with empty markup'   => array( '%1$s<span></span>%2$s', CurrencyPosition::LEFT ),
+			'right with spaced markup' => array( '%2$s<span>&nbsp;</span>%1$s', CurrencyPosition::RIGHT_SPACE ),
+			'left with dash'           => array( '%1$s-%2$s', CurrencyPosition::LEFT ),
+			'left with quoted bracket' => array( '%1$s<span title="x > y"></span>%2$s', CurrencyPosition::LEFT ),
+		);
+	}
+
+	/**
+	 * @testdox Should fall back to the currency position option when the price format has no symbol placeholder.
+	 */
+	public function test_falls_back_to_option_without_symbol_placeholder(): void {
+		update_option( 'woocommerce_currency_pos', CurrencyPosition::RIGHT );
+		add_filter(
+			'woocommerce_price_format',
+			function () {
+				return '%2$s';
+			}
+		);
+
+		$this->assertSame( CurrencyPosition::RIGHT, CurrencySymbol::get_position(), 'A format without the symbol should not change the position' );
+	}
+}