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> 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
+ );
},
},
},