Commit b78864c252b for woocommerce

commit b78864c252bddcc225180a978a52789cc95a97a3
Author: Raluca Stan <ralucastn@gmail.com>
Date:   Tue Sep 15 15:39:11 2026 +0200

    Fix RTL currency symbols not respecting the currency position setting in the React Checkout blocks (#67784)

    * Update react-number-format to 5.4.5

    v5 splits the component into NumericFormat and PatternFormat, drops the
    default export, and renames isNumericString to valueIsNumericString. It
    also types the second argument renderText has always been called with,
    so the prop no longer needs a hand-written signature.

    FormattedMonetaryAmount is the only component that renders it; the price
    slider imports a type v5 still exports unchanged.

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

    * Only fire onValueChange for user input

    * Add changelog entry for the react-number-format update

    * Render block prices with the same markup as wc_price()

    Block-rendered prices composed the currency symbol and the amount into a
    single flat string, leaving the bidi algorithm free to reorder them. A
    currency whose symbol is written in an RTL script, such as the Lebanese
    Pound, ignored the currency position setting entirely, and block prices
    could disagree with the rest of the store about which side the symbol
    belongs on.

    Compose the price as markup instead, matching the structure wc_price()
    produces store-wide: a <bdi> around the price, and the symbol in a
    <span dir="auto"> so it forms a self-contained run. A span with dir is
    used rather than a nested <bdi> because wp_kses_post() strips bdi but
    keeps the dir attribute.

    Sale prices went through renderText to wrap the amount in <del>/<ins>,
    which opted them out of that markup and left every discounted cart line
    item unfixed. Nest the price inside those elements instead.

    Input mode keeps the flat string, since an input value cannot hold
    elements, and so does a consumer-supplied renderText, which owns its own
    output.

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

    * Cover the price markup in tests

    Assert the element structure directly: the bdi wrapper, the isolated
    symbol, the spacing staying outside it, the negative sign ahead of the
    symbol, and an RTL-script symbol keeping its position in both prefix and
    suffix. Also cover the two paths that keep the flat string, input mode
    and a consumer-supplied renderText.

    Price assertions that relied on the amount being a single text node now
    read the element's text content directly, so what is being compared is
    visible in the test. The product price snapshots are replaced by explicit
    assertions for the same reason.

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

    * Add a currency symbol class and tighten the price renderer

    * Isolate currency symbols in the itemised tax lines

    * Restore product price structure assertions lost with the snapshot

    * Update changelog significance to minor

    * Simplify the currency symbol and spacing split

    * Add onValueChange edge-case tests and empty separator fallback

    * Simplify the onValueChange source guard comment

    * Keep the v4 onValueChange behaviour for value prop changes

    * Simplify the price renderer and the itemised tax lines

    * Tighten the price markup tests

    * Reword the changelog entry

    * Use a real LRM in the directional mark test

    The fixture held the six characters of the escape sequence rather than
    U+200E, so the test never saw an actual mark.

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

    * Replace DOM queries in the price tests with Testing Library queries

    The helpers that read prices through querySelector go away; the tests
    use the existing textContentMatcher util instead, which finds a price
    across the elements the symbol isolation adds. The product price tests
    reach the del/ins wrappers through the deletion and insertion roles. The
    order summary tests skip the screen reader label, which repeats the
    price.

    * Clarify the comment on the currency symbol isolation rule

    * Remove duplicate price tests

    The del/ins test in product-price repeated the default price test, and
    the nesting checks now live in the structure test. The RTL suffix test
    in FormattedMonetaryAmount was the EUR suffix test with another string;
    jsdom does no bidi layout, so it proved nothing extra.

    * Trim comments flagged in review

    Shorten the jsdom accessible-name note in the shipping rates test and drop the comment above the tax lines list in the totals footer item; the tests cover why each amount is its own component.

    * Rename the itemised tax test for what it now checks

    * Use toHaveTextContent for price text in the FormattedMonetaryAmount tests

    * Merge the two markup tests that render the same price

    * Fold the symbol placement tests into one table

    Five tests rendered one price and checked the symbol text and the bdi
    text for a different currency each: EUR prefix, EUR suffix, an
    entity-encoded non-breaking space, an RTL-script prefix, and a filtered
    symbol carrying a directional mark. One test.each keeps every case and
    drops the repetition.

    * Update the shipping rate accessible names added on trunk

    The tests from #68142 look up the radios by the flat accessible name.
    With the symbol in its own element, jsdom joins the name with a space,
    the same artifact the existing shipping rate tests already account for.

    ---------

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

diff --git a/plugins/woocommerce/changelog/fix-wooplug-7516-rtl-currency-symbol-isolation b/plugins/woocommerce/changelog/fix-wooplug-7516-rtl-currency-symbol-isolation
new file mode 100644
index 00000000000..a5d00398ac3
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-wooplug-7516-rtl-currency-symbol-isolation
@@ -0,0 +1,4 @@
+Significance: minor
+Type: fix
+
+Fix RTL currency symbols not respecting the currency position setting in the Checkout blocks
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/order-summary/test/index.js b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/order-summary/test/index.js
index 193e257d548..c413b9ca03f 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/order-summary/test/index.js
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/order-summary/test/index.js
@@ -8,6 +8,10 @@ import { previewCart } from '@woocommerce/resource-previews';
  * Internal dependencies
  */
 import OrderSummary from '../index';
+import { textContentMatcher } from '../../../../../../../tests/utils/find-by-text';
+
+// The screen reader label repeats the price, so only the visible one is queried.
+const visibleOnly = { ignore: 'script, style, .screen-reader-text' };

 jest.mock( '@woocommerce/base-context', () => ( {
 	...jest.requireActual( '@woocommerce/base-context' ),
@@ -41,7 +45,9 @@ describe( 'Order Summary', () => {
 			/>
 		);

-		expect( screen.getByText( '16€' ) ).toBeTruthy();
+		expect(
+			screen.getByText( textContentMatcher( '16€' ), visibleOnly )
+		).toBeInTheDocument();
 	} );

 	it( 'renders correct cart line subtotal when product price is 0', async () => {
@@ -68,6 +74,8 @@ describe( 'Order Summary', () => {
 			/>
 		);

-		expect( screen.getByText( '$0.00' ) ).toBeTruthy();
+		expect(
+			screen.getByText( textContentMatcher( '$0.00' ), visibleOnly )
+		).toBeInTheDocument();
 	} );
 } );
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control-package/test/index.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control-package/test/index.tsx
index 0bec8d54253..06fecede607 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control-package/test/index.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control-package/test/index.tsx
@@ -60,8 +60,11 @@ test( 'renders available shipping rates', async () => {
 		/>
 	);

+	// The space between the symbol and the amount is a jsdom artifact: it joins
+	// text across elements with a space when computing the accessible name.
+	// Real browsers report "Flat rate $10.00".
 	const firstRate = await screen.findByRole( 'radio', {
-		name: 'Flat rate $10.00',
+		name: 'Flat rate $ 10.00',
 	} );

 	expect( firstRate ).toBeInTheDocument();
@@ -69,7 +72,9 @@ test( 'renders available shipping rates', async () => {
 	expect( firstRate ).toBeChecked();

 	expect(
-		screen.getByRole( 'radio', { name: 'Flat rate (premium) $15.00' } )
+		screen.getByRole( 'radio', {
+			name: 'Flat rate (premium) $ 15.00',
+		} )
 	).toBeInTheDocument();
 	expect( selectShippingRate ).toHaveBeenCalledTimes( 1 );
 	expect( selectShippingRate ).toHaveBeenCalledWith( 'flat_rate:1', 0 );
@@ -110,7 +115,7 @@ test( 'skips mount selection when disabled but still handles user selection', as
 	await act( async () => {
 		await userEvent.click(
 			screen.getByRole( 'radio', {
-				name: 'Flat rate (premium) $15.00',
+				name: 'Flat rate (premium) $ 15.00',
 			} )
 		);
 	} );
@@ -147,10 +152,10 @@ test( 'changes rate selection locally and informs API about it', async () => {
 	);

 	const firstRate = await screen.findByRole( 'radio', {
-		name: 'Flat rate $10.00',
+		name: 'Flat rate $ 10.00',
 	} );
 	const secondRate = screen.getByRole( 'radio', {
-		name: 'Flat rate (premium) $15.00',
+		name: 'Flat rate (premium) $ 15.00',
 	} );

 	expect( firstRate ).toBeInTheDocument();
@@ -210,10 +215,10 @@ test( 'upstream rate selection updates are properly reflected in local state', a
 	);

 	const firstRate = await screen.findByRole( 'radio', {
-		name: 'Flat rate $10.00',
+		name: 'Flat rate $ 10.00',
 	} );
 	const secondRate = screen.getByRole( 'radio', {
-		name: 'Flat rate (premium) $15.00',
+		name: 'Flat rate (premium) $ 15.00',
 	} );

 	expect( firstRate ).toBeInTheDocument();
@@ -289,7 +294,7 @@ test( 'Core clears a rejected selection so the shopper can retry it', async () =
 			noResultsMessage={ <span>No rates</span> }
 		/>
 	);
-	const flatRate = screen.getByRole( 'radio', { name: 'Flat rate $10.00' } );
+	const flatRate = screen.getByRole( 'radio', { name: 'Flat rate $ 10.00' } );
 	expect( flatRate ).toBeChecked();

 	// Pickup is filtered out of Shipping, leaving no visible selected rate after rollback.
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control/test/recovery.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control/test/recovery.tsx
index f19e1e520af..2933ceebd5c 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control/test/recovery.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control/test/recovery.tsx
@@ -102,7 +102,7 @@ test( 'failed initialization stays retryable without automatically repeating rej
 			await jest.runOnlyPendingTimersAsync();
 		} );
 		const flatRate = screen.getByRole( 'radio', {
-			name: 'Flat rate $12.50',
+			name: 'Flat rate $ 12.50',
 		} );
 		expect( flatRate ).not.toBeChecked();
 		expect( apiFetch ).toHaveBeenCalledTimes( 1 );
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/totals/footer-item/index.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/totals/footer-item/index.tsx
index 7835e2793e6..8181824a58f 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/totals/footer-item/index.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/totals/footer-item/index.tsx
@@ -3,7 +3,7 @@
  */
 import { __, sprintf } from '@wordpress/i18n';
 import clsx from 'clsx';
-import { createInterpolateElement } from '@wordpress/element';
+import { createInterpolateElement, Fragment } from '@wordpress/element';
 import {
 	FormattedMonetaryAmount,
 	TotalsItem,
@@ -22,7 +22,6 @@ import {
 	Currency,
 	LooselyMustHave,
 } from '@woocommerce/types';
-import { formatPrice } from '@woocommerce/price-format';
 import { hasSelectedShippingRate } from '@woocommerce/base-utils';
 import { Skeleton } from '@woocommerce/base-components/skeleton';
 import { DelayedContentWithSkeleton } from '@woocommerce/base-components/delayed-content-with-skeleton';
@@ -111,19 +110,28 @@ const TotalsFooterItem = ( {

 	const parsedTaxValue = parseInt( totalTax, 10 );

+	const taxLinesList = (
+		<>
+			{ taxLines?.map( ( { name, price, rate }, index ) => (
+				<Fragment key={ `${ name }-${ rate }` }>
+					{ index > 0 && ', ' }
+					<FormattedMonetaryAmount
+						className="wc-block-components-totals-footer-item-tax-value"
+						currency={ currency }
+						value={ price }
+					/>
+					{ ` ${ name }` }
+				</Fragment>
+			) ) }
+		</>
+	);
+
 	const description =
 		taxLines && taxLines.length > 0
 			? sprintf(
 					/* translators: %s is a list of tax rates */
 					__( 'Including %s', 'woocommerce' ),
-					taxLines
-						.map( ( { name, price } ) => {
-							return `${ formatPrice(
-								price,
-								currency
-							) } ${ name }`;
-						} )
-						.join( ', ' )
+					'<TaxLines/>'
 			  )
 			: __( 'Including <TaxAmount/> in taxes', 'woocommerce' );

@@ -167,6 +175,7 @@ const TotalsFooterItem = ( {
 												value={ parsedTaxValue }
 											/>
 										),
+										TaxLines: taxLinesList,
 									} ) }
 								</>
 							</DelayedContentWithSkeleton>
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/totals/footer-item/test/index.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/totals/footer-item/test/index.tsx
index d449bb2a20e..4393a09ac7c 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/totals/footer-item/test/index.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/totals/footer-item/test/index.tsx
@@ -1,7 +1,7 @@
 /**
  * External dependencies
  */
-import { render, screen } from '@testing-library/react';
+import { render, screen, within } from '@testing-library/react';
 import { allSettings } from '@woocommerce/settings';
 import { CurrencyCode } from '@woocommerce/types';
 import * as baseContextHooks from '@woocommerce/base-context/hooks';
@@ -12,6 +12,7 @@ import { previewCart as mockPreviewCart } from '@woocommerce/resource-previews';
  * Internal dependencies
  */
 import TotalsFooterItem from '../index';
+import { textContentMatcher } from '../../../../../../../../tests/utils/find-by-text';

 jest.mock( '@wordpress/data', () => ( {
 	__esModule: true,
@@ -174,7 +175,9 @@ describe( 'TotalsFooterItem', () => {
 		render( <TotalsFooterItem currency={ currency } values={ values } /> );

 		// Check that the total price is displayed
-		expect( screen.getByText( /£85\.00/ ) ).toBeInTheDocument();
+		expect(
+			screen.getByText( textContentMatcher( '£85.00' ) )
+		).toBeInTheDocument();

 		// Check that no tax information is displayed
 		expect(
@@ -195,7 +198,9 @@ describe( 'TotalsFooterItem', () => {
 		);

 		// Check that the total price is displayed
-		expect( screen.getByText( /£85\.00/ ) ).toBeInTheDocument();
+		expect(
+			screen.getByText( textContentMatcher( '£85.00' ) )
+		).toBeInTheDocument();

 		// Check that no tax information is displayed when taxes are disabled
 		expect(
@@ -214,7 +219,9 @@ describe( 'TotalsFooterItem', () => {
 		);

 		// Check that the total price is displayed
-		expect( screen.getByText( /£85\.00/ ) ).toBeInTheDocument();
+		expect(
+			screen.getByText( textContentMatcher( '£85.00' ) )
+		).toBeInTheDocument();

 		// Check that tax information is displayed
 		const taxInfo = screen.getByText( /including.*tax/i );
@@ -236,7 +243,9 @@ describe( 'TotalsFooterItem', () => {
 		);

 		// Check that the total price is displayed
-		expect( screen.getByText( /£85\.00/ ) ).toBeInTheDocument();
+		expect(
+			screen.getByText( textContentMatcher( '£85.00' ) )
+		).toBeInTheDocument();

 		// Check that tax information with label is displayed
 		const taxInfo = screen.getByText( /including.*10% VAT/i );
@@ -261,7 +270,9 @@ describe( 'TotalsFooterItem', () => {
 		);

 		// Check that the total price is displayed
-		expect( screen.getByText( /£85\.00/ ) ).toBeInTheDocument();
+		expect(
+			screen.getByText( textContentMatcher( '£85.00' ) )
+		).toBeInTheDocument();

 		// Check that tax information with multiple labels is displayed
 		const taxInfo = screen.getByText( /including.*10% VAT.*5% VAT/i );
@@ -270,4 +281,33 @@ describe( 'TotalsFooterItem', () => {
 			'wc-block-components-totals-footer-item-tax'
 		);
 	} );
+
+	it( 'Renders each itemised tax amount as its own price element', async () => {
+		const valuesWithTax = {
+			...values,
+			total_tax: '100',
+			total_items_tax: '100',
+			tax_lines: [
+				{ name: '10% VAT', price: '50', rate: '10.000' },
+				{ name: '5% VAT', price: '50', rate: '5.000' },
+			],
+		};
+		render(
+			<TotalsFooterItem currency={ currency } values={ valuesWithTax } />
+		);
+
+		// The line reads the same as it did when it was one flat string.
+		const taxInfo = screen.getByText(
+			textContentMatcher( 'Including £0.50 10% VAT, £0.50 5% VAT' )
+		);
+		expect( taxInfo ).toHaveClass(
+			'wc-block-components-totals-footer-item-tax'
+		);
+
+		// Each amount is its own price element rather than part of the flat
+		// string, so the currency symbol keeps its isolation.
+		expect(
+			within( taxInfo ).getAllByText( textContentMatcher( '£0.50' ) )
+		).toHaveLength( 2 );
+	} );
 } );
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/product-price/index.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/product-price/index.tsx
index a369bf76b42..a8fa1c104cb 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/product-price/index.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/product-price/index.tsx
@@ -135,42 +135,38 @@ const SalePrice = ( {
 			<span className="screen-reader-text">
 				{ __( 'Previous price:', 'woocommerce' ) }
 			</span>
-			<FormattedMonetaryAmount
-				currency={ currency }
-				renderText={ ( value ) => (
-					<del
-						className={ clsx(
-							'wc-block-components-product-price__regular',
-							regularPriceClassName
-						) }
-						style={ regularPriceStyle }
-						translate="no"
-					>
-						{ value }
-					</del>
+			{ /* The price is nested rather than rendered through renderText so it
+			     keeps the markup that isolates the currency symbol. */ }
+			<del
+				className={ clsx(
+					'wc-block-components-product-price__regular',
+					regularPriceClassName
 				) }
-				value={ regularPrice }
-			/>
+				style={ regularPriceStyle }
+				translate="no"
+			>
+				<FormattedMonetaryAmount
+					currency={ currency }
+					value={ regularPrice }
+				/>
+			</del>
 			<span className="screen-reader-text">
 				{ __( 'Discounted price:', 'woocommerce' ) }
 			</span>
-			<FormattedMonetaryAmount
-				currency={ currency }
-				renderText={ ( value ) => (
-					<ins
-						className={ clsx(
-							'wc-block-components-product-price__value',
-							'is-discounted',
-							priceClassName
-						) }
-						style={ priceStyle }
-						translate="no"
-					>
-						{ value }
-					</ins>
+			<ins
+				className={ clsx(
+					'wc-block-components-product-price__value',
+					'is-discounted',
+					priceClassName
 				) }
-				value={ price }
-			/>
+				style={ priceStyle }
+				translate="no"
+			>
+				<FormattedMonetaryAmount
+					currency={ currency }
+					value={ price }
+				/>
+			</ins>
 		</>
 	);
 };
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/product-price/test/__snapshots__/index.js.snap b/plugins/woocommerce/client/blocks/assets/js/base/components/product-price/test/__snapshots__/index.js.snap
deleted file mode 100644
index 983dcc38272..00000000000
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/product-price/test/__snapshots__/index.js.snap
+++ /dev/null
@@ -1,65 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`ProductPrice should apply the format if one is provided 1`] = `
-<div>
-  <span
-    class="price wc-block-components-product-price"
-  >
-    pre price
-    <span
-      class="screen-reader-text"
-    >
-      Previous price:
-    </span>
-    <del
-      class="wc-block-components-product-price__regular"
-      translate="no"
-    >
-      £1.00
-    </del>
-    <span
-      class="screen-reader-text"
-    >
-      Discounted price:
-    </span>
-    <ins
-      class="wc-block-components-product-price__value is-discounted"
-      translate="no"
-    >
-      £0.50
-    </ins>
-     Test format
-  </span>
-</div>
-`;
-
-exports[`ProductPrice should use default price if no format is provided 1`] = `
-<div>
-  <span
-    class="price wc-block-components-product-price"
-  >
-    <span
-      class="screen-reader-text"
-    >
-      Previous price:
-    </span>
-    <del
-      class="wc-block-components-product-price__regular"
-      translate="no"
-    >
-      £1.00
-    </del>
-    <span
-      class="screen-reader-text"
-    >
-      Discounted price:
-    </span>
-    <ins
-      class="wc-block-components-product-price__value is-discounted"
-      translate="no"
-    >
-      £0.50
-    </ins>
-  </span>
-</div>
-`;
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/product-price/test/index.js b/plugins/woocommerce/client/blocks/assets/js/base/components/product-price/test/index.js
index 316cd29e0dd..c761e2e9622 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/product-price/test/index.js
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/product-price/test/index.js
@@ -1,12 +1,13 @@
 /**
  * External dependencies
  */
-import { render } from '@testing-library/react';
+import { render, screen, within } from '@testing-library/react';

 /**
  * Internal dependencies
  */
 import ProductPrice from '../index';
+import { textContentMatcher } from '../../../../../../tests/utils/find-by-text';

 describe( 'ProductPrice', () => {
 	const currency = {
@@ -37,7 +38,7 @@ describe( 'ProductPrice', () => {
 	};

 	test( 'should use default price if no format is provided', () => {
-		const { container } = render(
+		render(
 			<ProductPrice
 				price={ 50 }
 				regularPrice={ 100 }
@@ -45,7 +46,8 @@ describe( 'ProductPrice', () => {
 			/>
 		);

-		expect( container ).toMatchSnapshot();
+		expect( screen.getByRole( 'deletion' ) ).toHaveTextContent( '£1.00' );
+		expect( screen.getByRole( 'insertion' ) ).toHaveTextContent( '£0.50' );
 	} );

 	test( 'should apply the format if one is provided', () => {
@@ -58,6 +60,65 @@ describe( 'ProductPrice', () => {
 			/>
 		);

-		expect( container ).toMatchSnapshot();
+		expect( screen.getByRole( 'deletion' ) ).toHaveTextContent( '£1.00' );
+		expect( screen.getByRole( 'insertion' ) ).toHaveTextContent( '£0.50' );
+		// The custom format wraps the whole price, screen reader labels
+		// included.
+		expect( container ).toHaveTextContent(
+			'pre price Previous price:£1.00Discounted price:£0.50 Test format'
+		);
+	} );
+
+	test( 'keeps the sale price structure: labels and del/ins wrappers', () => {
+		const { container } = render(
+			<ProductPrice
+				price={ 50 }
+				regularPrice={ 100 }
+				currency={ currency }
+			/>
+		);
+
+		expect( container.firstChild ).toHaveClass(
+			'price',
+			'wc-block-components-product-price'
+		);
+
+		const previousLabel = screen.getByText( 'Previous price:' );
+		const discountedLabel = screen.getByText( 'Discounted price:' );
+		expect( previousLabel ).toHaveClass( 'screen-reader-text' );
+		expect( discountedLabel ).toHaveClass( 'screen-reader-text' );
+
+		// Each price follows its screen reader label.
+		const del = screen.getByRole( 'deletion' );
+		expect( del ).toHaveClass(
+			'wc-block-components-product-price__regular'
+		);
+		expect( del ).toHaveAttribute( 'translate', 'no' );
+		expect( previousLabel.nextElementSibling ).toBe( del );
+
+		const ins = screen.getByRole( 'insertion' );
+		expect( ins ).toHaveClass(
+			'wc-block-components-product-price__value',
+			'is-discounted'
+		);
+		expect( ins ).toHaveAttribute( 'translate', 'no' );
+		expect( discountedLabel.nextElementSibling ).toBe( ins );
+
+		// The wrappers hold a price element rather than a flat string, so the
+		// currency symbol keeps the isolation FormattedMonetaryAmount gives it.
+		expect(
+			within( del ).getByText( textContentMatcher( '£1.00' ) )
+		).toBeInTheDocument();
+		expect(
+			within( ins ).getByText( textContentMatcher( '£0.50' ) )
+		).toBeInTheDocument();
+	} );
+
+	test( 'renders a single price as a price element', () => {
+		render( <ProductPrice price={ 50 } currency={ currency } /> );
+
+		expect(
+			screen.getByText( textContentMatcher( '£0.50' ) )
+		).toBeInTheDocument();
 	} );
 } );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-block/test/block.js b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-block/test/block.js
index 88e143e5a06..c66ec1a7cc6 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-block/test/block.js
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-block/test/block.js
@@ -405,7 +405,10 @@ describe( 'Checkout Order Summary', () => {
 		const { container } = render( <Block showRateAfterTaxName={ true } /> );

 		expect(
-			queryByText( container, 'Tax $10.00' )
+			queryByText(
+				container,
+				textContentMatcherAcrossSiblings( 'Taxes $10.00' )
+			)
 		).not.toBeInTheDocument();
 	} );

diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/single-product/test/block.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/single-product/test/block.ts
index 3e94edec8c2..adce209fbba 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/single-product/test/block.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/single-product/test/block.ts
@@ -10,6 +10,7 @@ import { setupServer } from 'msw/node';
  * Internal dependencies
  */
 import { initializeEditor } from '../../../../../tests/integration/helpers/integration-test-editor';
+import { textContentMatcher } from '../../../../../tests/utils/find-by-text';
 import '../';
 import '../../../atomic/blocks/product-elements/price';
 import '../../../atomic/blocks/product-elements/summary';
@@ -104,8 +105,9 @@ describe( 'Product block', () => {
 		);
 		expect( productDescription ).toBeInTheDocument();

-		const productPrice = await screen.findByText( '20,00 €' );
-		expect( productPrice ).toBeInTheDocument();
+		expect(
+			await screen.findByText( textContentMatcher( '20,00 €' ) )
+		).toBeInTheDocument();

 		// wp-6.8: MSW warns about unhandled OPTIONS preflight requests from
 		// @wordpress/core-data in jsdom where there's no real network layer.
@@ -129,7 +131,8 @@ describe( 'Product block', () => {
 		);
 		expect( productDescription ).toBeInTheDocument();

-		const productPrice = await screen.findByText( '20,00 €' );
-		expect( productPrice ).toBeInTheDocument();
+		expect(
+			await screen.findByText( textContentMatcher( '20,00 €' ) )
+		).toBeInTheDocument();
 	} );
 } );
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/formatted-monetary-amount/index.tsx b/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/formatted-monetary-amount/index.tsx
index f936e574eec..7dc0886ad4a 100644
--- a/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/formatted-monetary-amount/index.tsx
+++ b/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/formatted-monetary-amount/index.tsx
@@ -7,7 +7,7 @@ import type {
 	NumericFormatProps,
 } from 'react-number-format';
 import clsx from 'clsx';
-import type { ReactElement } from 'react';
+import type { ReactElement, ReactNode } from 'react';
 import type { Currency } from '@woocommerce/types';
 import { SITE_CURRENCY } from '@woocommerce/settings';
 import { decodeHtmlEntities } from '@woocommerce/utils';
@@ -27,16 +27,70 @@ export interface FormattedMonetaryAmountProps
 	currency?: Currency | undefined; // Currency configuration object. Defaults to site currency.
 	onValueChange?: ( unit: number ) => void; // Function to call when value changes.
 	style?: React.CSSProperties | undefined;
-	// Render prop receiving the formatted price string; forwarded to
-	// NumericFormat.
+	// Hands rendering to the caller, replacing the element structure this
+	// component composes; a price rendered this way loses the currency symbol
+	// isolation. Prefer wrapping the component over using this.
 	renderText?: NonNullable< NumericFormatProps[ 'renderText' ] >;
 }

 /**
- * Maps the currency configuration onto the props NumericFormat expects.
+ * Splits a currency prefix or suffix into the symbol and the spacing around it,
+ * which the position setting bakes into the same string: "left with space"
+ * gives "€ ", "left" gives "€".
+ *
+ * The spacing is returned separately so it can be placed outside the isolating
+ * element; inside an RTL isolate a trailing space is drawn on the wrong side of
+ * the symbol.
+ */
+const splitCurrencySymbolAndSpacing = ( currencySymbol: string ) => {
+	const symbol = currencySymbol.trim();
+	const symbolStart = currencySymbol.indexOf( symbol );
+
+	return {
+		before: currencySymbol.slice( 0, symbolStart ),
+		symbol,
+		after: currencySymbol.slice( symbolStart + symbol.length ),
+	};
+};
+
+/**
+ * Renders the currency symbol in its own element, mirroring the
+ * `woocommerce-Price-currencySymbol` span `wc_price()` emits.
+ *
+ * `dir="auto"` keeps the symbol a self-contained run, so it cannot pull the
+ * neighbouring digits into a right-to-left run and land on the wrong side of
+ * the amount. A `<span dir="auto">` rather than a nested `<bdi>` to keep the
+ * structure identical to the PHP-rendered price, which uses a span because it
+ * has to survive `wp_kses_post()` (kses strips `bdi` but keeps `dir`).
+ */
+const renderIsolatedSymbol = ( currencySymbol: string ): ReactNode => {
+	const { before, symbol, after } =
+		splitCurrencySymbolAndSpacing( currencySymbol );
+
+	// A symbol that is nothing but spacing has nothing to isolate.
+	if ( ! symbol ) {
+		return currencySymbol;
+	}
+
+	return (
+		<>
+			{ before }
+			<span
+				className="wc-block-components-formatted-money-amount__currency-symbol"
+				dir="auto"
+			>
+				{ symbol }
+			</span>
+			{ after }
+		</>
+	);
+};
+
+/**
+ * Maps the currency separators onto the props NumericFormat expects.
  */
 const currencyToNumericFormatProps = ( currency: Currency ) => {
-	const { prefix, suffix, thousandSeparator, decimalSeparator } = currency;
+	const { thousandSeparator, decimalSeparator } = currency;
 	// Decode HTML entities in separators
 	const decodedThousandSeparator = decodeHtmlEntities( thousandSeparator );
 	// NumericFormat throws when both separators are identical; an empty
@@ -59,12 +113,48 @@ const currencyToNumericFormatProps = ( currency: Currency ) => {
 			: decodedThousandSeparator,
 		decimalSeparator: decodedDecimalSeparator,
 		fixedDecimalScale: true,
-		prefix: decodeHtmlEntities( prefix ),
-		suffix: decodeHtmlEntities( suffix ),
 		valueIsNumericString: true,
 	};
 };

+/**
+ * Builds the NumericFormat `renderText` callback that composes a price out of
+ * elements, matching the structure `wc_price()` produces.
+ *
+ * NumericFormat renders nothing of its own once `renderText` is set, so the
+ * props it would have applied to its span arrive as the second argument and are
+ * spread here instead.
+ */
+const createStructuredPriceRenderer =
+	( {
+		prefix,
+		suffix,
+		getInputRef,
+	}: {
+		prefix: string;
+		suffix: string;
+		getInputRef?: NumericFormatProps[ 'getInputRef' ] | undefined;
+	} ): NonNullable< NumericFormatProps[ 'renderText' ] > =>
+	( formattedValue, spanProps ) => {
+		// `wc_price()` puts the negative sign inside the `<bdi>`, ahead of the
+		// symbol, so split it off the amount.
+		const isNegative = formattedValue.startsWith( '-' );
+
+		return (
+			<span
+				{ ...( spanProps as React.HTMLAttributes< HTMLSpanElement > ) }
+				ref={ getInputRef as React.Ref< HTMLSpanElement > }
+			>
+				<bdi>
+					{ isNegative ? '-' : '' }
+					{ renderIsolatedSymbol( prefix ) }
+					{ isNegative ? formattedValue.slice( 1 ) : formattedValue }
+					{ renderIsolatedSymbol( suffix ) }
+				</bdi>
+			</span>
+		);
+	};
+
 /**
  * FormattedMonetaryAmount component.
  *
@@ -106,15 +196,34 @@ const FormattedMonetaryAmount = ( {
 		className
 	);
 	const decimalScale = props.decimalScale ?? currency?.minorUnit;
+	const decodedPrefix = decodeHtmlEntities( currency.prefix );
+	const decodedSuffix = decodeHtmlEntities( currency.suffix );
+
+	// Compose the price as markup so it matches the structure `wc_price()`
+	// produces everywhere else in the store. An input value cannot hold
+	// elements, and a consumer-supplied renderText owns its output, so both keep
+	// the plain string.
+	const renderAsMarkup = displayType === 'text' && ! props.renderText;
+
 	const numericFormatProps = {
 		...props,
 		...currencyToNumericFormatProps( currency ),
+		prefix: renderAsMarkup ? '' : decodedPrefix,
+		suffix: renderAsMarkup ? '' : decodedSuffix,
 		decimalScale,
 		value: undefined,
 		currency: undefined,
 		onValueChange: undefined,
 	};

+	if ( renderAsMarkup ) {
+		numericFormatProps.renderText = createStructuredPriceRenderer( {
+			prefix: decodedPrefix,
+			suffix: decodedSuffix,
+			getInputRef: props.getInputRef,
+		} );
+	}
+
 	// Wrapper for NumericFormat onValueChange which handles subunit conversion.
 	// Like v4, this also fires for `value` prop changes; kept deliberately so
 	// the major bump does not change the callback's behaviour.
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/formatted-monetary-amount/style.scss b/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/formatted-monetary-amount/style.scss
index c8f02bd1bee..da1a4190c52 100644
--- a/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/formatted-monetary-amount/style.scss
+++ b/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/formatted-monetary-amount/style.scss
@@ -1,3 +1,9 @@
 .wc-block-components-formatted-money-amount {
 	white-space: nowrap;
 }
+
+.wc-block-components-formatted-money-amount__currency-symbol {
+	// Theme resets can override the browser's default isolation for `dir`, which
+	// would draw an RTL-script symbol on the wrong side of the amount.
+	unicode-bidi: isolate;
+}
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/formatted-monetary-amount/test/index.js b/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/formatted-monetary-amount/test/index.js
index a46afdf55e0..9d24826fe7c 100644
--- a/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/formatted-monetary-amount/test/index.js
+++ b/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/formatted-monetary-amount/test/index.js
@@ -8,6 +8,12 @@ import { fireEvent, render, screen } from '@testing-library/react';
  */
 import FormattedMonetaryAmount from '../index';

+// The element isolating the currency symbol from the amount. Its text is
+// compared with toBe, since toHaveTextContent trims the spacing whose
+// placement these tests check.
+const symbolSelector =
+	'.wc-block-components-formatted-money-amount__currency-symbol';
+
 jest.mock( '@woocommerce/settings', () => ( {
 	...jest.requireActual( '@woocommerce/settings' ),
 	SITE_CURRENCY: {
@@ -24,13 +30,15 @@ jest.mock( '@woocommerce/settings', () => ( {
 describe( 'FormattedMonetaryAmount', () => {
 	describe( 'separators', () => {
 		test( 'should default to store currency configuration', () => {
-			render( <FormattedMonetaryAmount value="156345" /> );
+			const { container } = render(
+				<FormattedMonetaryAmount value="156345" />
+			);

-			expect( screen.getByText( '1.563,45 TEST' ) ).toBeInTheDocument();
+			expect( container ).toHaveTextContent( '1.563,45 TEST' );
 		} );

 		test( 'should add the thousand separator', () => {
-			render(
+			const { container } = render(
 				<FormattedMonetaryAmount
 					value="156345"
 					currency={ {
@@ -45,11 +53,11 @@ describe( 'FormattedMonetaryAmount', () => {
 				/>
 			);

-			expect( screen.getByText( '1.563,45 €' ) ).toBeInTheDocument();
+			expect( container ).toHaveTextContent( '1.563,45 €' );
 		} );

 		test( 'should not add thousand separator', () => {
-			render(
+			const { container } = render(
 				<FormattedMonetaryAmount
 					value="156345"
 					currency={ {
@@ -63,11 +71,11 @@ describe( 'FormattedMonetaryAmount', () => {
 					} }
 				/>
 			);
-			expect( screen.getByText( '1563,45 €' ) ).toBeInTheDocument();
+			expect( container ).toHaveTextContent( '1563,45 €' );
 		} );

 		test( 'should remove the thousand separator when identical to the decimal one', () => {
-			render(
+			const { container } = render(
 				<FormattedMonetaryAmount
 					value="156345"
 					currency={ {
@@ -82,11 +90,11 @@ describe( 'FormattedMonetaryAmount', () => {
 				/>
 			);
 			expect( console ).toHaveWarned();
-			expect( screen.getByText( '1563,45 €' ) ).toBeInTheDocument();
+			expect( container ).toHaveTextContent( '1563,45 €' );
 		} );

 		test( 'should fall back to a period for an empty decimal separator', () => {
-			render(
+			const { container } = render(
 				<FormattedMonetaryAmount
 					value="156345"
 					currency={ {
@@ -101,11 +109,11 @@ describe( 'FormattedMonetaryAmount', () => {
 				/>
 			);
 			expect( console ).toHaveWarned();
-			expect( screen.getByText( '1563.45 €' ) ).toBeInTheDocument();
+			expect( container ).toHaveTextContent( '1563.45 €' );
 		} );

 		test( 'should render when both separators are empty', () => {
-			render(
+			const { container } = render(
 				<FormattedMonetaryAmount
 					value="156345"
 					currency={ {
@@ -119,12 +127,12 @@ describe( 'FormattedMonetaryAmount', () => {
 					} }
 				/>
 			);
-			expect( screen.getByText( '1563.45 €' ) ).toBeInTheDocument();
+			expect( container ).toHaveTextContent( '1563.45 €' );
 		} );
 	} );
 	describe( 'suffix/prefix', () => {
 		test( 'should add the currency suffix', () => {
-			render(
+			const { container } = render(
 				<FormattedMonetaryAmount
 					value="15"
 					currency={ {
@@ -138,11 +146,11 @@ describe( 'FormattedMonetaryAmount', () => {
 					} }
 				/>
 			);
-			expect( screen.getByText( '0,15 €' ) ).toBeInTheDocument();
+			expect( container ).toHaveTextContent( '0,15 €' );
 		} );

 		test( 'should add the currency prefix', () => {
-			render(
+			const { container } = render(
 				<FormattedMonetaryAmount
 					value="15"
 					currency={ {
@@ -156,7 +164,7 @@ describe( 'FormattedMonetaryAmount', () => {
 					} }
 				/>
 			);
-			expect( screen.getByText( '€ 0,15' ) ).toBeInTheDocument();
+			expect( container ).toHaveTextContent( '€ 0,15' );
 		} );
 	} );

@@ -221,7 +229,7 @@ describe( 'FormattedMonetaryAmount', () => {

 	describe( 'supports different value types', () => {
 		test( 'should support numbers', () => {
-			render(
+			const { container } = render(
 				<FormattedMonetaryAmount
 					value={ 15.0 }
 					currency={ {
@@ -235,11 +243,11 @@ describe( 'FormattedMonetaryAmount', () => {
 					} }
 				/>
 			);
-			expect( screen.getByText( '15 €' ) ).toBeInTheDocument();
+			expect( container ).toHaveTextContent( '15 €' );
 		} );

 		test( 'should support strings', () => {
-			render(
+			const { container } = render(
 				<FormattedMonetaryAmount
 					value="15.0"
 					currency={ {
@@ -253,7 +261,178 @@ describe( 'FormattedMonetaryAmount', () => {
 					} }
 				/>
 			);
-			expect( screen.getByText( '€ 15' ) ).toBeInTheDocument();
+			expect( container ).toHaveTextContent( '€ 15' );
+		} );
+	} );
+
+	describe( 'markup', () => {
+		/** @type {import('@woocommerce/types').Currency} */
+		const eurCurrency = {
+			code: 'EUR',
+			symbol: '€',
+			thousandSeparator: '.',
+			decimalSeparator: ',',
+			minorUnit: 2,
+			prefix: '€ ',
+			suffix: '',
+		};
+
+		const lbpSymbol = 'ل.ل';
+		/** @type {import('@woocommerce/types').Currency} */
+		const lbpCurrency = {
+			code: 'LBP',
+			symbol: lbpSymbol,
+			thousandSeparator: ',',
+			decimalSeparator: '.',
+			minorUnit: 2,
+			prefix: `${ lbpSymbol } `,
+			suffix: '',
+		};
+
+		const lrm = '‎';
+
+		test( 'renders the price in a bdi with the symbol in its own isolating element, matching wc_price()', () => {
+			const { container } = render(
+				<FormattedMonetaryAmount
+					value="156345"
+					currency={ eurCurrency }
+				/>
+			);
+
+			expect( container.querySelector( 'bdi' ) ).toHaveTextContent(
+				'€ 1.563,45'
+			);
+
+			const symbol = container.querySelector( symbolSelector );
+			// The space stays outside the isolate; inside it, it would be drawn
+			// on the wrong side of the symbol.
+			expect( symbol?.textContent ).toBe( '€' );
+			expect( symbol ).toHaveAttribute( 'dir', 'auto' );
+		} );
+
+		test.each( [
+			[ 'a prefix symbol', eurCurrency, '€', '€ 1.563,45' ],
+			[
+				'a suffix symbol',
+				{ ...eurCurrency, prefix: '', suffix: ' €' },
+				'€',
+				'1.563,45 €',
+			],
+			[
+				'a symbol followed by an entity-encoded non-breaking space',
+				{ ...eurCurrency, prefix: '€&nbsp;' },
+				'€',
+				'€ 1.563,45',
+			],
+			[
+				'an RTL-script prefix symbol',
+				lbpCurrency,
+				lbpSymbol,
+				`${ lbpSymbol } 1,563.45`,
+			],
+			[
+				'a filtered symbol carrying a directional mark',
+				{ ...lbpCurrency, prefix: `${ lrm }${ lbpSymbol } ` },
+				`${ lrm }${ lbpSymbol }`,
+				`${ lrm }${ lbpSymbol } 1,563.45`,
+			],
+		] )(
+			'isolates %s and keeps its spacing outside the isolate',
+			( _case, currency, symbol, text ) => {
+				const { container } = render(
+					<FormattedMonetaryAmount
+						value="156345"
+						currency={ currency }
+					/>
+				);
+
+				expect(
+					container.querySelector( symbolSelector )?.textContent
+				).toBe( symbol );
+				expect( container.querySelector( 'bdi' ) ).toHaveTextContent(
+					text
+				);
+			}
+		);
+
+		test( 'keeps the negative sign inside the bdi, ahead of the symbol', () => {
+			const { container } = render(
+				<FormattedMonetaryAmount
+					value="-156345"
+					currency={ eurCurrency }
+				/>
+			);
+
+			expect( container.querySelector( 'bdi' ) ).toHaveTextContent(
+				'-€ 1.563,45'
+			);
+		} );
+
+		test( 'keeps the symbol in the value when rendering an input', () => {
+			const { container } = render(
+				<FormattedMonetaryAmount
+					value="156345"
+					currency={ eurCurrency }
+					displayType="input"
+				/>
+			);
+
+			expect( container.querySelector( 'bdi' ) ).toBeNull();
+			expect( screen.getByRole( 'textbox' ) ).toHaveValue( '€ 1.563,45' );
+		} );
+
+		test( 'keeps the wrapper span classes, style and translate attribute', () => {
+			const { container } = render(
+				<FormattedMonetaryAmount
+					value="156345"
+					currency={ eurCurrency }
+					className="custom-class"
+					style={ { color: 'red' } }
+				/>
+			);
+
+			// The span props come through the renderText callback now, so pin
+			// down that nothing is lost on the way.
+			const wrapper = container.firstChild;
+			expect( wrapper ).toHaveClass(
+				'wc-block-formatted-money-amount',
+				'wc-block-components-formatted-money-amount',
+				'custom-class'
+			);
+			expect( wrapper ).toHaveStyle( { color: 'rgb(255, 0, 0)' } );
+			expect( wrapper ).toHaveAttribute( 'translate', 'no' );
+		} );
+
+		test( 'forwards getInputRef to the wrapper span in text mode', () => {
+			const getInputRef = jest.fn();
+			const { container } = render(
+				<FormattedMonetaryAmount
+					value="156345"
+					currency={ eurCurrency }
+					getInputRef={ getInputRef }
+				/>
+			);
+
+			// NumericFormat does not apply getInputRef when renderText is set,
+			// so the component wires it to the span itself.
+			expect( getInputRef ).toHaveBeenCalledWith( container.firstChild );
+		} );
+
+		test( 'leaves a consumer-supplied renderText in control of the output', () => {
+			const { container } = render(
+				<FormattedMonetaryAmount
+					value="156345"
+					currency={ eurCurrency }
+					renderText={ ( formattedValue ) => (
+						<strong>{ formattedValue }</strong>
+					) }
+				/>
+			);
+
+			expect( container.querySelector( 'bdi' ) ).toBeNull();
+			expect( container.querySelector( 'strong' ) ).toHaveTextContent(
+				'€ 1.563,45'
+			);
 		} );
 	} );
 } );
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/totals/item/test/index.tsx b/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/totals/item/test/index.tsx
index ac065c00142..1d2a45e12c5 100644
--- a/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/totals/item/test/index.tsx
+++ b/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/totals/item/test/index.tsx
@@ -8,6 +8,7 @@ import type { Currency } from '@woocommerce/types';
  * Internal dependencies
  */
 import TotalsItem from '../index';
+import { textContentMatcher } from '../../../../../../tests/utils/find-by-text';

 const mockCurrency: Currency = {
 	code: 'USD',
@@ -30,7 +31,9 @@ describe( 'TotalsItem', () => {
 		);

 		expect( screen.getByText( 'Subtotal' ) ).toBeInTheDocument();
-		expect( screen.getByText( '$25.99' ) ).toBeInTheDocument();
+		expect(
+			screen.getByText( textContentMatcher( '$25.99' ) )
+		).toBeInTheDocument();
 	} );

 	it( 'renders value of 0 correctly', () => {
@@ -43,7 +46,9 @@ describe( 'TotalsItem', () => {
 		);

 		expect( screen.getByText( 'Discount' ) ).toBeInTheDocument();
-		expect( screen.getByText( '$0.00' ) ).toBeInTheDocument();
+		expect(
+			screen.getByText( textContentMatcher( '$0.00' ) )
+		).toBeInTheDocument();
 	} );

 	it( 'renders ReactNode value correctly', () => {
@@ -68,7 +73,9 @@ describe( 'TotalsItem', () => {
 		);

 		expect( screen.getByText( 'Tax' ) ).toBeInTheDocument();
-		expect( screen.getByText( '$5.99' ) ).toBeInTheDocument();
+		expect(
+			screen.getByText( textContentMatcher( '$5.99' ) )
+		).toBeInTheDocument();
 		expect( screen.getByText( 'Including VAT' ) ).toBeInTheDocument();
 	} );

@@ -83,7 +90,9 @@ describe( 'TotalsItem', () => {
 		);

 		expect( screen.getByLabelText( 'Loading price…' ) ).toBeInTheDocument();
-		expect( screen.queryByText( '$1.00' ) ).not.toBeInTheDocument();
+		expect(
+			screen.queryByText( textContentMatcher( '$1.00' ) )
+		).not.toBeInTheDocument();
 	} );

 	it( 'does not show skeleton when showSkeleton is false', () => {
@@ -97,7 +106,9 @@ describe( 'TotalsItem', () => {
 		);

 		expect( screen.getByText( 'Loaded' ) ).toBeInTheDocument();
-		expect( screen.getByText( '$1.55' ) ).toBeInTheDocument();
+		expect(
+			screen.getByText( textContentMatcher( '$1.55' ) )
+		).toBeInTheDocument();
 		expect(
 			screen.queryByLabelText( 'Loading price…' )
 		).not.toBeInTheDocument();
@@ -118,7 +129,9 @@ describe( 'TotalsItem', () => {
 		);

 		expect( screen.getByText( 'Total' ) ).toBeInTheDocument();
-		expect( screen.getByText( '¥1,000' ) ).toBeInTheDocument();
+		expect(
+			screen.getByText( textContentMatcher( '¥1,000' ) )
+		).toBeInTheDocument();
 	} );

 	it( 'renders without currency when not provided', () => {
@@ -126,6 +139,8 @@ describe( 'TotalsItem', () => {

 		expect( screen.getByText( 'Amount' ) ).toBeInTheDocument();
 		// When no currency is provided, the value should still render
-		expect( screen.getByText( '$0.42' ) ).toBeInTheDocument();
+		expect(
+			screen.getByText( textContentMatcher( '$0.42' ) )
+		).toBeInTheDocument();
 	} );
 } );