Commit b6772ff65dd for woocommerce

commit b6772ff65dd40b3494dfb760dca1e671d25da7e8
Author: Raluca Stan <ralucastn@gmail.com>
Date:   Wed Aug 26 18:25:10 2026 +0200

    Keep dir and translate attributes when iAPI blocks re-render prices (#68006)

    * Allow dir and translate attributes in price_html sanitizers

    * Add changelog entry for price_html sanitizer attributes fix

    * Remove PR references from code comments

diff --git a/plugins/woocommerce/changelog/fix-price-html-sanitizer-dir-attr b/plugins/woocommerce/changelog/fix-price-html-sanitizer-dir-attr
new file mode 100644
index 00000000000..7504170d195
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-price-html-sanitizer-dir-attr
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep the dir and translate attributes wc_price() puts on the currency symbol when Product Elements, Wishlist, and Save for later swap price_html client-side, so RTL-script currency symbols keep honoring the currency position setting after hydration.
diff --git a/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/frontend.ts
index 0c150b00ae1..ea981e45ce9 100644
--- a/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/frontend.ts
@@ -5,7 +5,14 @@ import { getElement, store, getContext } from '@wordpress/interactivity';
 import '@woocommerce/stores/woocommerce/products';
 import type { ProductsStore } from '@woocommerce/stores/woocommerce/products';
 import type { ProductResponseItem } from '@woocommerce/types';
-import { sanitizeHTML } from '@woocommerce/sanitize';
+
+/**
+ * Internal dependencies
+ */
+import {
+	swapPreformattedHtml,
+	PRODUCT_ELEMENT_HTML_CONFIG,
+} from '../../../base/utils/preformatted-html';

 // Stores are locked to prevent 3PD usage until the API is stable.
 const universalLock =
@@ -17,30 +24,6 @@ const { state: productsState } = store< ProductsStore >(
 	{ lock: universalLock }
 );

-const ALLOWED_TAGS = [
-	'a',
-	'b',
-	'em',
-	'i',
-	'strong',
-	'p',
-	'br',
-	'span',
-	'bdi',
-	'del',
-	'ins',
-	'small',
-];
-const ALLOWED_ATTR = [
-	'class',
-	'target',
-	'href',
-	'rel',
-	'name',
-	'download',
-	'aria-hidden',
-];
-
 type Context = {
 	productElementKey: keyof ProductResponseItem;
 };
@@ -50,23 +33,19 @@ store(
 	{
 		callbacks: {
 			updateValue: () => {
-				const element = getElement();
 				const product = productsState.productInContext;

-				if ( ! element.ref || ! product ) {
+				if ( ! product ) {
 					return;
 				}

 				const { productElementKey } = getContext< Context >();

-				const productElementHtml = product[ productElementKey ];
-
-				if ( typeof productElementHtml === 'string' ) {
-					element.ref.innerHTML = sanitizeHTML( productElementHtml, {
-						tags: ALLOWED_TAGS,
-						attr: ALLOWED_ATTR,
-					} );
-				}
+				swapPreformattedHtml(
+					getElement().ref,
+					product[ productElementKey ],
+					PRODUCT_ELEMENT_HTML_CONFIG
+				);
 			},
 		},
 	},
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/utils/preformatted-html.ts b/plugins/woocommerce/client/blocks/assets/js/base/utils/preformatted-html.ts
new file mode 100644
index 00000000000..a86785a0333
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/base/utils/preformatted-html.ts
@@ -0,0 +1,106 @@
+/**
+ * External dependencies
+ */
+import { sanitizeHTML } from '@woocommerce/sanitize';
+
+/**
+ * Shared swap for server-preformatted HTML fields (`price_html`,
+ * `image_html`, …) that iAPI callbacks paste into the DOM. The allow-lists
+ * live here, next to the swap, so tests can pin what survives sanitization
+ * without registering the stores.
+ */
+
+export type PreformattedHtmlConfig = {
+	tags: readonly string[];
+	attr: readonly string[];
+};
+
+// Bidi isolation (dir) and no-translate (translate) attributes wc_price()
+// puts on the currency symbol. Stripping them lets the bidi algorithm move
+// an RTL-script symbol to the wrong side of the amount.
+const CURRENCY_SYMBOL_ATTR = [ 'dir', 'translate' ] as const;
+
+// Covers what wc_price() (sale/discount markup, currency symbol) and product
+// element fields rendered by the product-elements `updateValue` callback emit.
+export const PRODUCT_ELEMENT_HTML_CONFIG: PreformattedHtmlConfig = {
+	tags: [
+		'a',
+		'b',
+		'em',
+		'i',
+		'strong',
+		'p',
+		'br',
+		'span',
+		'bdi',
+		'del',
+		'ins',
+		'small',
+	],
+	attr: [
+		'class',
+		'target',
+		'href',
+		'rel',
+		'name',
+		'download',
+		'aria-hidden',
+		...CURRENCY_SYMBOL_ATTR,
+	],
+};
+
+// Covers the shopper-list schema's preformatted fields: what wc_price()
+// emits for `price_html`, and what `wp_get_attachment_image` /
+// `wc_placeholder_img` emit for `image_html` (responsive image + dimensions
+// + lazy loading).
+export const LIST_ITEM_HTML_CONFIG: PreformattedHtmlConfig = {
+	tags: [
+		'a',
+		'b',
+		'em',
+		'i',
+		'strong',
+		'p',
+		'br',
+		'span',
+		'bdi',
+		'del',
+		'ins',
+		'img',
+		'picture',
+		'source',
+	],
+	attr: [
+		'class',
+		'target',
+		'href',
+		'rel',
+		'name',
+		'download',
+		'aria-hidden',
+		'src',
+		'srcset',
+		'sizes',
+		'alt',
+		'width',
+		'height',
+		'loading',
+		'decoding',
+		...CURRENCY_SYMBOL_ATTR,
+	],
+};
+
+/**
+ * Sanitizes a preformatted HTML field and swaps it into the element.
+ * No-op when the element is missing or the field is not a string.
+ */
+export const swapPreformattedHtml = (
+	ref: HTMLElement | null | undefined,
+	html: unknown,
+	config: PreformattedHtmlConfig
+): void => {
+	if ( ! ref || typeof html !== 'string' ) {
+		return;
+	}
+	ref.innerHTML = sanitizeHTML( html, config );
+};
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/utils/test/preformatted-html.ts b/plugins/woocommerce/client/blocks/assets/js/base/utils/test/preformatted-html.ts
new file mode 100644
index 00000000000..73a9e22c9c6
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/base/utils/test/preformatted-html.ts
@@ -0,0 +1,78 @@
+/**
+ * Internal dependencies
+ */
+import {
+	swapPreformattedHtml,
+	PRODUCT_ELEMENT_HTML_CONFIG,
+	LIST_ITEM_HTML_CONFIG,
+} from '../preformatted-html';
+
+// Markup wc_price() produces for an RTL-script currency symbol (Lebanese
+// pound). The dir="auto" bidi isolation and translate="no" must survive the
+// sanitizer, or the first client-side price swap silently undoes them.
+const RTL_PRICE_HTML =
+	'<span class="woocommerce-Price-amount amount"><bdi><span class="woocommerce-Price-currencySymbol" translate="no" dir="auto">ل.ل</span>&nbsp;2.00</bdi></span>';
+
+describe( 'swapPreformattedHtml', () => {
+	let ref: HTMLElement;
+
+	beforeEach( () => {
+		ref = document.createElement( 'div' );
+	} );
+
+	describe.each( [
+		[ 'PRODUCT_ELEMENT_HTML_CONFIG', PRODUCT_ELEMENT_HTML_CONFIG ],
+		[ 'LIST_ITEM_HTML_CONFIG', LIST_ITEM_HTML_CONFIG ],
+	] )( 'price markup through %s', ( _label, config ) => {
+		it( 'keeps the dir and translate attributes on the currency symbol', () => {
+			swapPreformattedHtml( ref, RTL_PRICE_HTML, config );
+
+			const symbol = ref.querySelector(
+				'.woocommerce-Price-currencySymbol'
+			);
+			expect( symbol ).not.toBeNull();
+			expect( symbol?.getAttribute( 'dir' ) ).toBe( 'auto' );
+			expect( symbol?.getAttribute( 'translate' ) ).toBe( 'no' );
+		} );
+
+		it( 'still strips disallowed tags and attributes', () => {
+			swapPreformattedHtml(
+				ref,
+				'<span class="amount" onclick="alert(1)" style="color:red">1</span><script>alert(1)</script>',
+				config
+			);
+
+			const span = ref.querySelector( 'span' );
+			expect( span?.getAttribute( 'onclick' ) ).toBeNull();
+			expect( span?.getAttribute( 'style' ) ).toBeNull();
+			expect( ref.querySelector( 'script' ) ).toBeNull();
+		} );
+	} );
+
+	it( 'keeps image markup in list-item fields', () => {
+		swapPreformattedHtml(
+			ref,
+			'<img src="a.jpg" srcset="a.jpg 1x" sizes="64px" alt="p" width="64" height="64" loading="lazy" decoding="async" />',
+			LIST_ITEM_HTML_CONFIG
+		);
+
+		const img = ref.querySelector( 'img' );
+		expect( img ).not.toBeNull();
+		expect( img?.getAttribute( 'srcset' ) ).toBe( 'a.jpg 1x' );
+		expect( img?.getAttribute( 'loading' ) ).toBe( 'lazy' );
+	} );
+
+	it( 'does nothing when the element is missing', () => {
+		expect( () =>
+			swapPreformattedHtml( null, RTL_PRICE_HTML, LIST_ITEM_HTML_CONFIG )
+		).not.toThrow();
+	} );
+
+	it( 'does nothing when the field is not a string', () => {
+		ref.innerHTML = '<em>previous</em>';
+
+		swapPreformattedHtml( ref, undefined, PRODUCT_ELEMENT_HTML_CONFIG );
+
+		expect( ref.innerHTML ).toBe( '<em>previous</em>' );
+	} );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/frontend.ts
index 37f5ba8f1ec..557789d0d09 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/frontend.ts
@@ -18,7 +18,13 @@ import type {
 	AddCartItemOutcome,
 	Store as WooCommerce,
 } from '@woocommerce/stores/woocommerce/cart';
-import { sanitizeHTML } from '@woocommerce/sanitize';
+/**
+ * Internal dependencies
+ */
+import {
+	swapPreformattedHtml,
+	LIST_ITEM_HTML_CONFIG,
+} from '../../base/utils/preformatted-html';

 const universalLock =
 	'I acknowledge that using a private store means my plugin will inevitably break on the next store release.';
@@ -64,44 +70,6 @@ type BlockStore = {
 	};
 };

-// Allow-list for sanitizing the schema's preformatted strings on innerHTML
-// swap. Covers what `wc_price` (sale/discount markup, currency symbol) and
-// `wp_get_attachment_image` / `wc_placeholder_img` emit (responsive image
-// + dimensions + lazy loading).
-const ALLOWED_TAGS = [
-	'a',
-	'b',
-	'em',
-	'i',
-	'strong',
-	'p',
-	'br',
-	'span',
-	'bdi',
-	'del',
-	'ins',
-	'img',
-	'picture',
-	'source',
-];
-const ALLOWED_ATTR = [
-	'class',
-	'target',
-	'href',
-	'rel',
-	'name',
-	'download',
-	'aria-hidden',
-	'src',
-	'srcset',
-	'sizes',
-	'alt',
-	'width',
-	'height',
-	'loading',
-	'decoding',
-];
-
 const { state: shopperListsState, actions: shopperListsActions } =
 	store< ShopperListsStore >(
 		'woocommerce/shopper-lists',
@@ -321,18 +289,15 @@ store< BlockStore >(
 			// and a clean swap when it has (e.g. after Remove shifts the
 			// next item into this slot).
 			updateInnerHtml: () => {
-				const { ref } = getElement();
 				const { listItem, htmlField } = getContext< BlockContext >();
-				if ( ! ref || ! listItem || ! htmlField ) {
+				if ( ! listItem || ! htmlField ) {
 					return;
 				}
-				const html = listItem[ htmlField ];
-				if ( typeof html === 'string' ) {
-					ref.innerHTML = sanitizeHTML( html, {
-						tags: ALLOWED_TAGS,
-						attr: ALLOWED_ATTR,
-					} );
-				}
+				swapPreformattedHtml(
+					getElement().ref,
+					listItem[ htmlField ],
+					LIST_ITEM_HTML_CONFIG
+				);
 			},
 		},
 	},
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/frontend.ts
index 26412c093f9..bd291d14267 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/frontend.ts
@@ -18,7 +18,13 @@ import type {
 	AddCartItemOutcome,
 	Store as WooCommerce,
 } from '@woocommerce/stores/woocommerce/cart';
-import { sanitizeHTML } from '@woocommerce/sanitize';
+/**
+ * Internal dependencies
+ */
+import {
+	swapPreformattedHtml,
+	LIST_ITEM_HTML_CONFIG,
+} from '../../base/utils/preformatted-html';

 const universalLock =
 	'I acknowledge that using a private store means my plugin will inevitably break on the next store release.';
@@ -56,44 +62,6 @@ type BlockStore = {
 	};
 };

-// Allow-list for sanitizing the schema's preformatted strings on innerHTML
-// swap. Covers what `wc_price` (sale/discount markup, currency symbol) and
-// `wp_get_attachment_image` / `wc_placeholder_img` emit (responsive image
-// + dimensions + lazy loading).
-const ALLOWED_TAGS = [
-	'a',
-	'b',
-	'em',
-	'i',
-	'strong',
-	'p',
-	'br',
-	'span',
-	'bdi',
-	'del',
-	'ins',
-	'img',
-	'picture',
-	'source',
-];
-const ALLOWED_ATTR = [
-	'class',
-	'target',
-	'href',
-	'rel',
-	'name',
-	'download',
-	'aria-hidden',
-	'src',
-	'srcset',
-	'sizes',
-	'alt',
-	'width',
-	'height',
-	'loading',
-	'decoding',
-];
-
 const { state: shopperListsState, actions: shopperListsActions } =
 	store< ShopperListsStore >(
 		'woocommerce/shopper-lists',
@@ -281,18 +249,15 @@ store< BlockStore >(
 			// and a clean swap when it has (e.g. after Remove shifts the
 			// next item into this slot).
 			updateInnerHtml: () => {
-				const { ref } = getElement();
 				const { listItem, htmlField } = getContext< BlockContext >();
-				if ( ! ref || ! listItem || ! htmlField ) {
+				if ( ! listItem || ! htmlField ) {
 					return;
 				}
-				const html = listItem[ htmlField ];
-				if ( typeof html === 'string' ) {
-					ref.innerHTML = sanitizeHTML( html, {
-						tags: ALLOWED_TAGS,
-						attr: ALLOWED_ATTR,
-					} );
-				}
+				swapPreformattedHtml(
+					getElement().ref,
+					listItem[ htmlField ],
+					LIST_ITEM_HTML_CONFIG
+				);
 			},
 		},
 	},