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 &lt; 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' => '&lt;b&gt;important&lt;/b&gt;',
+				);
+
+				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 &lt; 2" should display as "1 < 2"
+		// (entity decoded), not as literal "1 &lt; 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 "&amp;". The data-wp-text
+		// path (textContent) relies on the textarea entity-decode step to
+		// display "Red & Blue" instead of literal "Red &amp; 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 "&amp;" 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' ] );
+	} );
+} );