Commit 447db9bafed for woocommerce
commit 447db9bafedc3cb12710855549df740b1305f052
Author: Ján Mikláš <neosinner@gmail.com>
Date: Mon Apr 13 12:36:46 2026 +0200
Fix entity-encoded HTML in `item_data` being incorrectly decoded during mini-cart rendering (#64120)
* Fix entity-encoded HTML in `item_data` being incorrectly decoded during mini-cart rendering
* Fix no use before define eslint
---------
Co-authored-by: Luis Herranz <luisherranz@gmail.com>
diff --git a/plugins/woocommerce/changelog/441-fix-item-data b/plugins/woocommerce/changelog/441-fix-item-data
new file mode 100644
index 00000000000..7ee23bad6a9
--- /dev/null
+++ b/plugins/woocommerce/changelog/441-fix-item-data
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix entity-encoded HTML in `item_data` being incorrectly decoded during mini-cart rendering.
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/iapi-frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/iapi-frontend.ts
index c09d3a202d8..8412d2c8ec9 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/iapi-frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/iapi-frontend.ts
@@ -414,26 +414,34 @@ store< MiniCart >(
{ lock: universalLock }
);
-function itemDataInnerHTML( field: 'name' | 'value' ) {
- const { ref } = getElement();
-
- if ( ! ref ) {
- return;
+/**
+ * Returns the raw API value for an item_data field. Used by both innerHTML
+ * callbacks and the cartItemDataAttr getter.
+ */
+function getItemDataRaw( field: 'name' | 'value' ): string {
+ const { itemData, dataProperty } = getContext< {
+ itemData: ItemData;
+ dataProperty: DataProperty;
+ } >();
+
+ const dataItemAttr =
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
+ itemData || cartItemState.cartItem[ dataProperty ]?.[ 0 ];
+
+ if ( ! dataItemAttr ) {
+ return '';
}
- // eslint-disable-next-line @typescript-eslint/no-use-before-define
- const dataAttr = cartItemState.cartItemDataAttr;
-
- if ( ! dataAttr ) {
- return;
+ if ( field === 'name' ) {
+ return (
+ dataItemAttr.key ||
+ dataItemAttr.attribute ||
+ dataItemAttr.name ||
+ ''
+ );
}
- if ( field in dataAttr ) {
- const value = dataAttr[ field as keyof typeof dataAttr ];
- if ( typeof value === 'string' && value ) {
- ref.innerHTML = trimWords( value );
- }
- }
+ return dataItemAttr.display || dataItemAttr.value || '';
}
const { state: cartItemState } = store(
@@ -776,41 +784,28 @@ const { state: cartItemState } = store(
},
get cartItemDataAttr(): CartItemDataAttr | null {
- const { itemData, dataProperty } = getContext< {
- itemData: ItemData;
- dataProperty: DataProperty;
- } >();
+ const rawName = getItemDataRaw( 'name' );
+ const rawValue = getItemDataRaw( 'value' );
- // Use the context if it is in a loop, otherwise use the unique item if it exists.
- const dataItemAttr =
- itemData || cartItemState.cartItem[ dataProperty ]?.[ 0 ];
-
- if ( ! dataItemAttr ) {
+ if ( ! rawName && ! rawValue ) {
return null;
}
- // Extract name based on data type (variation uses 'attribute', item_data uses 'key' or 'name')
- const rawName =
- dataItemAttr.key ||
- dataItemAttr.attribute ||
- dataItemAttr.name ||
- '';
-
- // Extract value - prefer 'display' over 'value' for item_data if available
- const rawValue =
- dataItemAttr.display || dataItemAttr.value || '';
-
- // Decode entities.
const nameTxt = document.createElement( 'textarea' );
nameTxt.innerHTML = rawName;
const valueTxt = document.createElement( 'textarea' );
valueTxt.innerHTML = rawValue;
- const processedName = nameTxt.value ? nameTxt.value + ':' : '';
- const hiddenValue = dataItemAttr.hidden;
+ const { itemData, dataProperty } = getContext< {
+ itemData: ItemData;
+ dataProperty: DataProperty;
+ } >();
+ const dataItemAttr =
+ itemData || cartItemState.cartItem[ dataProperty ]?.[ 0 ];
+ const hiddenValue = dataItemAttr?.hidden;
return {
- name: processedName,
+ name: nameTxt.value ? nameTxt.value + ':' : '',
value: valueTxt.value,
className: `wc-block-components-product-details__${ nameTxt.value
.replace( /([a-z])([A-Z])/g, '$1-$2' )
@@ -1000,8 +995,6 @@ const { state: cartItemState } = store(
const { short_description: shortDescription, description } =
cartItemState.cartItem;
- // A workaround for the lack of dangerous set HTML directive
- // in interactivity API.
if ( innerEl && ( shortDescription || description ) ) {
innerEl.innerHTML = trimWords(
shortDescription || description
@@ -1011,10 +1004,19 @@ const { state: cartItemState } = store(
},
itemDataNameInnerHTML() {
- itemDataInnerHTML( 'name' );
+ const { ref } = getElement();
+ const raw = getItemDataRaw( 'name' );
+ if ( ref && raw ) {
+ ref.innerHTML = trimWords( raw + ':' );
+ }
},
+
itemDataValueInnerHTML() {
- itemDataInnerHTML( 'value' );
+ const { ref } = getElement();
+ const raw = getItemDataRaw( 'value' );
+ if ( ref && raw ) {
+ ref.innerHTML = trimWords( raw );
+ }
},
filterCartItemClass() {
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/plugins/item-data-display.php b/plugins/woocommerce/client/blocks/tests/e2e/plugins/item-data-display.php
new file mode 100644
index 00000000000..7675257cd50
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/tests/e2e/plugins/item-data-display.php
@@ -0,0 +1,51 @@
+<?php
+/**
+ * Plugin Name: WooCommerce Blocks Test Item Data Display
+ * Description: Adds custom item_data to cart items for testing HTML rendering and entity decoding.
+ * Plugin URI: https://github.com/woocommerce/woocommerce
+ * Author: WooCommerce
+ *
+ * @package woocommerce-blocks-test-item-data-display
+ */
+
+declare(strict_types=1);
+
+add_action(
+ 'woocommerce_init',
+ function () {
+ add_filter(
+ 'woocommerce_get_item_data',
+ function ( $item_data ) {
+ // Plain text item data.
+ $item_data[] = array(
+ 'key' => 'Gift Message',
+ 'value' => 'Happy Birthday!',
+ );
+
+ // HTML in display field (should render as formatted HTML).
+ $item_data[] = array(
+ 'key' => 'Engraving',
+ 'value' => 'Best Wishes',
+ 'display' => '<em>Best Wishes</em>',
+ );
+
+ // Entity-encoded less-than sign (should decode properly).
+ $item_data[] = array(
+ 'key' => 'Size',
+ 'value' => '1 < 2',
+ );
+
+ // Entity-encoded HTML tag. wp_kses_post sees no actual tags
+ // (just text with entities), so it passes through.
+ $item_data[] = array(
+ 'key' => 'Note',
+ 'value' => '<b>important</b>',
+ );
+
+ return $item_data;
+ },
+ 10,
+ 1
+ );
+ }
+);
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/tests/mini-cart/mini-cart.block_theme.spec.ts b/plugins/woocommerce/client/blocks/tests/e2e/tests/mini-cart/mini-cart.block_theme.spec.ts
index abc2d5573c3..5af2b1f45b4 100644
--- a/plugins/woocommerce/client/blocks/tests/e2e/tests/mini-cart/mini-cart.block_theme.spec.ts
+++ b/plugins/woocommerce/client/blocks/tests/e2e/tests/mini-cart/mini-cart.block_theme.spec.ts
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
-import { test, expect, BlockData } from '@woocommerce/e2e-utils';
+import { test, expect, BlockData, wpCLI } from '@woocommerce/e2e-utils';
/**
* Internal dependencies
@@ -454,3 +454,169 @@ test.describe( `${ blockData.name } Block (admin)`, () => {
);
} );
} );
+
+test.describe( `${ blockData.name } Block (item data)`, () => {
+ test.use( {
+ storageState: {
+ origins: [],
+ cookies: [],
+ },
+ } );
+
+ // Activate in beforeEach because the DB is reset after every test.
+ test.beforeEach( async ( { requestUtils } ) => {
+ await requestUtils.activatePlugin(
+ 'woocommerce-blocks-test-item-data-display'
+ );
+ } );
+
+ test( 'should render plain text item data', async ( {
+ page,
+ frontendUtils,
+ miniCartUtils,
+ } ) => {
+ await frontendUtils.goToShop();
+ await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
+ await miniCartUtils.openMiniCart();
+
+ const dialog = page.getByRole( 'dialog' );
+ await expect( dialog ).toBeVisible();
+
+ // Verify the plain text name and value are rendered.
+ await expect(
+ dialog.locator( '.wc-block-components-product-details__name' )
+ ).toContainText( [ 'Gift Message' ] );
+ await expect(
+ dialog.locator( '.wc-block-components-product-details__value' )
+ ).toContainText( [ 'Happy Birthday!' ] );
+ } );
+
+ test( 'should use display field value when present', async ( {
+ page,
+ frontendUtils,
+ miniCartUtils,
+ } ) => {
+ await frontendUtils.goToShop();
+ await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
+ await miniCartUtils.openMiniCart();
+
+ const dialog = page.getByRole( 'dialog' );
+ await expect( dialog ).toBeVisible();
+
+ // The display field contains "<em>Best Wishes</em>".
+ // Verify the <em> tag is rendered as an actual HTML element.
+ const engravingValue = dialog
+ .locator( '.wc-block-components-product-details__value' )
+ .filter( { hasText: 'Best Wishes' } );
+ await expect( engravingValue ).toBeVisible();
+ await expect( engravingValue.locator( 'em' ) ).toBeVisible();
+ await expect( engravingValue.locator( 'em' ) ).toHaveText(
+ 'Best Wishes'
+ );
+ } );
+
+ test( 'should decode HTML entities in item data values', async ( {
+ page,
+ frontendUtils,
+ miniCartUtils,
+ } ) => {
+ await frontendUtils.goToShop();
+ await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
+ await miniCartUtils.openMiniCart();
+
+ const dialog = page.getByRole( 'dialog' );
+ await expect( dialog ).toBeVisible();
+
+ // The value "1 < 2" should display as "1 < 2"
+ // (entity decoded), not as literal "1 < 2".
+ const sizeValue = dialog
+ .locator( '.wc-block-components-product-details__value' )
+ .filter( { hasText: '1' } );
+ await expect( sizeValue ).toBeVisible();
+ await expect( sizeValue ).toContainText( '1 < 2' );
+ } );
+
+ test( 'should not render entity-encoded HTML tags as DOM elements', async ( {
+ page,
+ frontendUtils,
+ miniCartUtils,
+ } ) => {
+ await frontendUtils.goToShop();
+ await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
+ await miniCartUtils.openMiniCart();
+
+ const dialog = page.getByRole( 'dialog' );
+ await expect( dialog ).toBeVisible();
+
+ const noteValue = dialog
+ .locator( '.wc-block-components-product-details__value' )
+ .filter( { hasText: 'important' } );
+ await expect( noteValue ).toBeVisible();
+ await expect( noteValue.locator( 'b' ) ).toHaveCount( 0 );
+ } );
+} );
+
+test.describe( `${ blockData.name } Block (variation attributes)`, () => {
+ test.use( {
+ storageState: {
+ origins: [],
+ cookies: [],
+ },
+ } );
+
+ test( 'should decode entities in variation attributes rendered via data-wp-text', async ( {
+ page,
+ frontendUtils,
+ miniCartUtils,
+ } ) => {
+ // Create a variable product with an attribute value containing an
+ // ampersand, which the API returns as "&". The data-wp-text
+ // path (textContent) relies on the textarea entity-decode step to
+ // display "Red & Blue" instead of literal "Red & Blue".
+ const cliOutput = await wpCLI(
+ `wc product create --user=1 --name="Test Variable" --type="variable" --attributes='${ JSON.stringify(
+ [
+ {
+ name: 'Shade',
+ options: [ 'Red & Blue' ],
+ variation: true,
+ visible: true,
+ },
+ ]
+ ) }'`
+ );
+ const productId = cliOutput.stdout.match(
+ /Created product (\d+)/
+ )?.[ 1 ];
+ await wpCLI(
+ `wc product_variation create --user=1 "${ productId }" --regular_price="10" --attributes='${ JSON.stringify(
+ [ { name: 'Shade', option: 'Red & Blue' } ]
+ ) }'`
+ );
+
+ // Navigate to the product page and add the variation to cart.
+ await page.goto( `/product/test-variable/` );
+ await page
+ .getByLabel( 'Shade', { exact: true } )
+ .selectOption( 'Red & Blue' );
+ await page
+ .getByRole( 'button', { name: 'Add to cart', exact: true } )
+ .click();
+
+ // Open the mini-cart.
+ await frontendUtils.goToShop();
+ await miniCartUtils.openMiniCart();
+
+ const dialog = page.getByRole( 'dialog' );
+ await expect( dialog ).toBeVisible();
+
+ // Variation attributes are rendered via data-wp-text (textContent).
+ // The entity "&" should be decoded to "&".
+ await expect(
+ dialog.locator( '.wc-block-components-product-details__name' )
+ ).toContainText( [ 'Shade' ] );
+ await expect(
+ dialog.locator( '.wc-block-components-product-details__value' )
+ ).toContainText( [ 'Red & Blue' ] );
+ } );
+} );