Commit 9e086fb230d for woocommerce

commit 9e086fb230d4441de3c6a5e14b3aa862f55ce16f
Author: Tung Du <dinhtungdu@gmail.com>
Date:   Wed Oct 14 19:12:06 2026 +0700

    Update sale badges for the selected variation (#69596)

    * Update sale badges for the selected variation

    * Refactor sale badge discount calculation into a shared helper

    Co-authored-by: Cursor <cursoragent@cursor.com>

    * Move sale badge interactivity into its own store

    Co-authored-by: Cursor <cursoragent@cursor.com>

    * Simplify sale badge interactivity markup and remove weak frontend test

    Co-authored-by: Cursor <cursoragent@cursor.com>

    * Fix sale badge CI lint and PHPUnit attribute-order assertions

    Co-authored-by: Cursor <cursoragent@cursor.com>

    * Fix empty sale badge alignment class

    * Simplify sale badge alignment expression

    ---------

    Co-authored-by: Cursor <cursoragent@cursor.com>

diff --git a/plugins/woocommerce/changelog/selected-variation-sale-badge b/plugins/woocommerce/changelog/selected-variation-sale-badge
new file mode 100644
index 00000000000..238afc63e54
--- /dev/null
+++ b/plugins/woocommerce/changelog/selected-variation-sale-badge
@@ -0,0 +1,4 @@
+Significance: patch
+Type: enhancement
+
+Update sale badges to show the discount for the selected product variation.
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/README.md b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/README.md
index 75f7aaf0732..7c83f646b57 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/README.md
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/README.md
@@ -4,7 +4,7 @@ Product element blocks show parts of a product, such as its image, title, price,

 ## Structure

-Each element has its own directory with block metadata and the code needed for editing, saving, and rendering it. Shared editor code lives in `shared/`. The root `index.js` imports the element registration files, while `component-init.js` registers components that are loaded when needed. `frontend.ts` contains the shared product element callback for interactive updates.
+Each element has its own directory with block metadata and the code needed for editing, saving, and rendering it. Shared editor code lives in `shared/`. The root `index.js` imports the element registration files, while `component-init.js` registers components that are loaded when needed. `frontend.ts` contains the shared product element callback for interactive HTML field updates. Blocks with their own Interactivity logic, such as the button and sale badge, keep a dedicated `frontend.ts` in their directory.

 Shared block registration and rendering helpers live in dedicated files under `assets/js/utils/` because they are also used outside this directory. Import elements through `@woocommerce/product-elements` and helpers from their `@woocommerce/utils/<file>` paths.

diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/block.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/block.tsx
index 9674dde77b4..933c31e5964 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/block.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/block.tsx
@@ -21,6 +21,7 @@ import type { HTMLAttributes, ReactElement } from 'react';
  */
 import './style.scss';
 import type { BlockAttributes } from './types';
+import { getSaleBadgeDiscount } from './utils';

 type Props = BlockAttributes &
 	HTMLAttributes< HTMLDivElement > & {
@@ -51,12 +52,12 @@ export const Block = ( props: Props ): ReactElement | null => {
 	let label = props.saleText || __( 'Sale', 'woocommerce' );
 	if ( isNumeric && product.type !== 'grouped' ) {
 		const prices = 'prices' in product ? product.prices : undefined;
-		const regular = Number( prices?.regular_price );
-		const price = Number( prices?.price );
-		if ( regular > 0 && price < regular ) {
-			const discount = regular - price;
-			const percentage = Math.round( ( discount / regular ) * 100 );
-			if ( props.badgeContent === 'percentage' && percentage === 0 ) {
+		const discount = getSaleBadgeDiscount( prices );
+		if ( discount ) {
+			if (
+				props.badgeContent === 'percentage' &&
+				discount.percentage === 0
+			) {
 				return null;
 			}
 			const value =
@@ -64,10 +65,10 @@ export const Block = ( props: Props ): ReactElement | null => {
 					? sprintf(
 							/* translators: %s: discount percentage. %% is the percent sign. */
 							__( '%s%%', 'woocommerce' ),
-							percentage
+							discount.percentage
 						)
 					: formatPrice(
-							discount,
+							discount.amount,
 							getCurrencyFromPriceResponse( prices )
 						);
 			// Parent prices are independent minima, so the editor preview may differ from the largest variation discount.
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/frontend.ts
new file mode 100644
index 00000000000..ea08f7e3bb0
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/frontend.ts
@@ -0,0 +1,109 @@
+/**
+ * External dependencies
+ */
+import { store, getContext, getConfig } from '@wordpress/interactivity';
+import '@woocommerce/stores/woocommerce/products';
+import type { ProductsStore } from '@woocommerce/stores/woocommerce/products';
+import { formatPriceWithCurrency } from '@woocommerce/price-format/utils/currency';
+
+/**
+ * Internal dependencies
+ */
+import { getSaleBadgeDiscount } from './utils';
+
+// Stores are locked to prevent 3PD usage until the API is stable.
+const universalLock =
+	'I acknowledge that using a private store means my plugin will inevitably break on the next store release.';
+
+const { state: productsState } = store< ProductsStore >(
+	'woocommerce/products',
+	{},
+	{ lock: universalLock }
+);
+
+type SaleBadgeConfig = {
+	saleBadgePercentageTemplate: string;
+	saleBadgeScreenReaderTemplate: string;
+};
+
+type SaleBadgeContext = {
+	saleBadgeText: string;
+	saleBadgeScreenReaderText: string;
+	badgeContent: string;
+	prefix: string;
+	suffix: string;
+};
+
+const { state } = store(
+	'woocommerce/product-sale-badge',
+	{
+		state: {
+			get saleBadgeText(): string | null {
+				const variation = productsState.productVariationInContext;
+				const context = getContext< SaleBadgeContext >();
+				if ( ! variation ) {
+					return context.saleBadgeText;
+				}
+				if ( ! variation.on_sale ) {
+					return null;
+				}
+				if (
+					! [ 'amount', 'percentage' ].includes(
+						context.badgeContent
+					)
+				) {
+					return context.saleBadgeText;
+				}
+				const prices = variation.prices;
+				const discount = getSaleBadgeDiscount( prices );
+				if ( ! discount ) {
+					return context.saleBadgeText;
+				}
+				if (
+					context.badgeContent === 'percentage' &&
+					discount.percentage === 0
+				) {
+					return null;
+				}
+				const config = getConfig(
+					'woocommerce/product-sale-badge'
+				) as SaleBadgeConfig;
+				let value: string;
+				if ( context.badgeContent === 'percentage' ) {
+					value = config.saleBadgePercentageTemplate.replace(
+						'%s',
+						String( discount.percentage )
+					);
+				} else {
+					value = formatPriceWithCurrency( discount.amount, {
+						code: prices.currency_code,
+						symbol: prices.currency_symbol,
+						minorUnit: prices.currency_minor_unit,
+						decimalSeparator: prices.currency_decimal_separator,
+						thousandSeparator: prices.currency_thousand_separator,
+						prefix: prices.currency_prefix,
+						suffix: prices.currency_suffix,
+					} );
+				}
+				return `${ context.prefix }${ value }${ context.suffix }`;
+			},
+			get saleBadgeScreenReaderText(): string {
+				const config = getConfig(
+					'woocommerce/product-sale-badge'
+				) as SaleBadgeConfig;
+				if ( ! productsState.productVariationInContext ) {
+					return getContext< SaleBadgeContext >()
+						.saleBadgeScreenReaderText;
+				}
+				return config.saleBadgeScreenReaderTemplate.replace(
+					'%s',
+					() => state.saleBadgeText ?? ''
+				);
+			},
+			get isSaleBadgeHidden(): boolean {
+				return state.saleBadgeText === null;
+			},
+		},
+	},
+	{ lock: true }
+);
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/style.scss b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/style.scss
index 463c08243ff..d378fd41606 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/style.scss
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/style.scss
@@ -1,6 +1,10 @@
 .wp-block-woocommerce-product-sale-badge {
 	display: flex;
 	flex-direction: column;
+
+	&[hidden] {
+		display: none;
+	}
 }

 .wc-block-components-product-sale-badge {
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/utils.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/utils.ts
new file mode 100644
index 00000000000..f56de0f3bc7
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/utils.ts
@@ -0,0 +1,20 @@
+type SaleBadgePrices = {
+	price?: string;
+	regular_price?: string;
+};
+
+/**
+ * Returns the discount in the currency's minor unit and as a rounded percentage, or null when the price is not discounted.
+ *
+ * Shared by the editor preview and the frontend script module, so it must not import packages that script modules cannot load.
+ */
+export const getSaleBadgeDiscount = (
+	prices: SaleBadgePrices | undefined
+): { amount: number; percentage: number } | null => {
+	const regular = Number( prices?.regular_price );
+	const amount = regular - Number( prices?.price );
+	if ( ! ( regular > 0 && amount > 0 ) ) {
+		return null;
+	}
+	return { amount, percentage: Math.round( ( amount / regular ) * 100 ) };
+};
diff --git a/plugins/woocommerce/client/blocks/bin/webpack-config-interactive-blocks.js b/plugins/woocommerce/client/blocks/bin/webpack-config-interactive-blocks.js
index e7abe4d8a20..2e5b4ce4bc2 100644
--- a/plugins/woocommerce/client/blocks/bin/webpack-config-interactive-blocks.js
+++ b/plugins/woocommerce/client/blocks/bin/webpack-config-interactive-blocks.js
@@ -43,6 +43,10 @@ const entries = {
 	// Product elements frontend module. Share by several blocks.
 	'woocommerce/product-elements':
 		'./assets/js/blocks/product-elements-blocks/frontend.ts',
+	// Sale badge frontend module. Built separately so collection badges can
+	// skip loading variation-only label logic.
+	'woocommerce/product-sale-badge':
+		'./assets/js/blocks/product-elements-blocks/sale-badge/frontend.ts',
 	// Add to cart with options quantity selector frontend module used by the
 	// Product Quantity block and the Grouped Product Selector block.
 	'woocommerce/add-to-cart-with-options-quantity-selector':
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 755c7780768..69bf75dc2f0 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -51513,18 +51513,6 @@ parameters:
 			count: 1
 			path: src/Blocks/BlockTypes/ProductSKU.php

-		-
-			message: '#^@param Automattic\\WooCommerce\\Blocks\\BlockTypes\\WC_Product \$product does not accept actual type of parameter\: WC_Product\.$#'
-			identifier: parameter.phpDocType
-			count: 1
-			path: src/Blocks/BlockTypes/ProductSaleBadge.php
-
-		-
-			message: '#^Access to property \$context on an unknown class Automattic\\WooCommerce\\Blocks\\BlockTypes\\WP_Block\.$#'
-			identifier: class.notFound
-			count: 2
-			path: src/Blocks/BlockTypes/ProductSaleBadge.php
-
 		-
 			message: '#^Method Automattic\\WooCommerce\\Blocks\\BlockTypes\\ProductSaleBadge\:\:get_block_type_uses_context\(\) has no return type specified\.$#'
 			identifier: missingType.return
@@ -51543,12 +51531,6 @@ parameters:
 			count: 2
 			path: src/Blocks/BlockTypes/ProductSaleBadge.php

-		-
-			message: '#^Parameter \$block of method Automattic\\WooCommerce\\Blocks\\BlockTypes\\ProductSaleBadge\:\:render\(\) has invalid type Automattic\\WooCommerce\\Blocks\\BlockTypes\\WP_Block\.$#'
-			identifier: class.notFound
-			count: 1
-			path: src/Blocks/BlockTypes/ProductSaleBadge.php
-
 		-
 			message: '#^Method Automattic\\WooCommerce\\Blocks\\Assets\\Api\:\:add_inline_script\(\) invoked with 3 parameters, 2 required\.$#'
 			identifier: arguments.count
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductSaleBadge.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductSaleBadge.php
index ddc88d90710..efa6bd9e660 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductSaleBadge.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductSaleBadge.php
@@ -36,15 +36,15 @@ class ProductSaleBadge extends AbstractBlock {
 	 * Register the context.
 	 */
 	protected function get_block_type_uses_context() {
-		return [ 'query', 'queryId', 'postId' ];
+		return array( 'query', 'queryId', 'postId' );
 	}

 	/**
 	 * Include and render the block.
 	 *
-	 * @param array    $attributes Block attributes. Default empty array.
-	 * @param string   $content    Block content. Default empty string.
-	 * @param WP_Block $block      Block instance.
+	 * @param array     $attributes Block attributes. Default empty array.
+	 * @param string    $content    Block content. Default empty string.
+	 * @param \WP_Block $block      Block instance.
 	 * @return string Rendered block type output.
 	 */
 	protected function render( $attributes, $content, $block ) {
@@ -65,8 +65,6 @@ class ProductSaleBadge extends AbstractBlock {

 		$classname = StyleAttributesUtils::get_classes_by_attributes( $attributes, array( 'extra_classes' ) );

-		$align = isset( $attributes['align'] ) ? $attributes['align'] : '';
-
 		$sale_text     = $attributes['saleText'] ?? '';
 		$sale_text     = '' === $sale_text ? __( 'Sale', 'woocommerce' ) : $sale_text;
 		$badge_content = $attributes['badgeContent'] ?? 'text';
@@ -128,18 +126,95 @@ class ProductSaleBadge extends AbstractBlock {
 		 * @since 10.0.0
 		 *
 		 * @param string $sale_text The sale badge text.
-		 * @param WC_Product $product The product object.
+		 * @param \WC_Product $product The product object.
 		 * @return string The filtered sale badge text.
 		 */
 		$sale_text = apply_filters( 'woocommerce_sale_badge_text', $sale_text, $product );

-		$output  = '<div class="wp-block-woocommerce-product-sale-badge ' . esc_attr( $classname ) . '">';
-		$output .= sprintf( '<div class="wc-block-components-product-sale-badge %1$s wc-block-components-product-sale-badge--align-%2$s" style="%3$s">', esc_attr( $classes_and_styles['classes'] ), esc_attr( $align ), esc_attr( $classes_and_styles['styles'] ) );
-		$output .= '<span class="wc-block-components-product-sale-badge__text" aria-hidden="true">' . esc_html( $sale_text ) . '</span>';
 		/* translators: %s: sale badge text. */
 		$screen_reader_text = sprintf( __( 'Product on sale: %s', 'woocommerce' ), $sale_text );
+		$is_interactive     = $product instanceof \WC_Product_Variable
+			&& ! isset( $block->context['query']['isProductCollectionBlock'] )
+			&& ! isset( $block->context['isDescendantOfGroupedProductSelector'] );
+
+		$wrapper_attributes = array();
+		if ( '' !== $classname ) {
+			$wrapper_attributes['class'] = $classname;
+		}
+		$context_directive        = '';
+		$label_attributes         = array(
+			'class'       => 'wc-block-components-product-sale-badge__text',
+			'aria-hidden' => 'true',
+		);
+		$screen_reader_attributes = array(
+			'class' => 'screen-reader-text',
+		);
+
+		if ( $is_interactive ) {
+			wp_enqueue_script_module( 'woocommerce/product-sale-badge' );
+			wp_interactivity_config(
+				'woocommerce/product-sale-badge',
+				array(
+					/* translators: %s: discount percentage. %% is the percent sign. */
+					'saleBadgePercentageTemplate'   => sprintf( __( '%s%%', 'woocommerce' ), '%s' ),
+					/* translators: %s: sale badge text. */
+					'saleBadgeScreenReaderTemplate' => sprintf( __( 'Product on sale: %s', 'woocommerce' ), '%s' ),
+				)
+			);
+			wp_interactivity_state(
+				'woocommerce/product-sale-badge',
+				array(
+					'saleBadgeText'             => static function () {
+						return wp_interactivity_get_context()['saleBadgeText'];
+					},
+					'saleBadgeScreenReaderText' => static function () {
+						return wp_interactivity_get_context()['saleBadgeScreenReaderText'];
+					},
+					'isSaleBadgeHidden'         => false,
+				)
+			);
+			$wrapper_attributes['data-wp-interactive']  = 'woocommerce/product-sale-badge';
+			$wrapper_attributes['data-wp-bind--hidden'] = 'state.isSaleBadgeHidden';
+			$wrapper_attributes['aria-live']            = 'polite';
+			$wrapper_attributes['aria-atomic']          = 'true';
+			$context_directive                          = wp_interactivity_data_wp_context(
+				array(
+					'saleBadgeText'             => $sale_text,
+					'saleBadgeScreenReaderText' => $screen_reader_text,
+					'badgeContent'              => $attributes['badgeContent'] ?? 'text',
+					'prefix'                    => $attributes['prefix'] ?? '',
+					'suffix'                    => $attributes['suffix'] ?? '',
+				)
+			);
+			$label_attributes['data-wp-text']           = 'state.saleBadgeText';
+			$screen_reader_attributes['data-wp-text']   = 'state.saleBadgeScreenReaderText';
+		}

-		$output .= '<span class="screen-reader-text">' . esc_html( $screen_reader_text ) . '</span>';
+		$alignment_class = isset( $attributes['align'] ) && '' !== $attributes['align']
+			? ' wc-block-components-product-sale-badge--align-' . esc_attr( $attributes['align'] )
+			: '';
+
+		$output  = sprintf(
+			'<div %1$s %2$s>',
+			get_block_wrapper_attributes( $wrapper_attributes ),
+			$context_directive
+		);
+		$output .= sprintf(
+			'<div class="wc-block-components-product-sale-badge %1$s%2$s" style="%3$s">',
+			esc_attr( $classes_and_styles['classes'] ),
+			$alignment_class,
+			esc_attr( $classes_and_styles['styles'] )
+		);
+		$output .= sprintf(
+			'<span %1$s>%2$s</span>',
+			wc_implode_html_attributes( $label_attributes ),
+			esc_html( $sale_text )
+		);
+		$output .= sprintf(
+			'<span %1$s>%2$s</span>',
+			wc_implode_html_attributes( $screen_reader_attributes ),
+			esc_html( $screen_reader_text )
+		);
 		$output .= '</div></div>';

 		return $output;
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductSaleBadge.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductSaleBadge.php
index 988332dc616..5ae476a4131 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductSaleBadge.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductSaleBadge.php
@@ -24,7 +24,7 @@ class ProductSaleBadge extends \WP_UnitTestCase {
 			$product->set_regular_price( '10' );
 			$product_id = $product->save();

-			$markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product_id . '} --><!-- wp:woocommerce/product-sale-badge /--><!-- /wp:woocommerce/single-product -->' );
+			$markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product_id . '} --><div class="wp-block-woocommerce-single-product woocommerce"><!-- wp:woocommerce/product-sale-badge /--></div><!-- /wp:woocommerce/single-product -->' );

 			$this->assertStringNotContainsString( 'wp-block-woocommerce-product-sale-badge', $markup, 'The outer Sale Badge block should be omitted.' );
 			$this->assertStringNotContainsString( 'wc-block-components-product-sale-badge', $markup, 'The Sale Badge component should be omitted.' );
@@ -61,10 +61,14 @@ class ProductSaleBadge extends \WP_UnitTestCase {
 			$product->set_sale_price( '5' );
 			$product_id = $product->save();

-			$markup         = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product_id . '} --><!-- wp:woocommerce/product-sale-badge {"align":"' . $align . '"} /--><!-- /wp:woocommerce/single-product -->' );
+			$markup         = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product_id . '} --><div class="wp-block-woocommerce-single-product woocommerce"><!-- wp:woocommerce/product-sale-badge {"align":"' . $align . '"} /--></div><!-- /wp:woocommerce/single-product -->' );
 			$expected_class = 'wc-block-components-product-sale-badge--align-' . $align;

-			$this->assertStringContainsString( $expected_class, $markup );
+			if ( '' === $align ) {
+				$this->assertStringNotContainsString( 'wc-block-components-product-sale-badge--align-', $markup );
+			} else {
+				$this->assertStringContainsString( $expected_class, $markup );
+			}
 			foreach ( array_diff( array( 'left', 'center', 'right' ), array( $align ) ) as $other_align ) {
 				$this->assertStringNotContainsString(
 					'wc-block-components-product-sale-badge--align-' . $other_align,
@@ -97,10 +101,9 @@ class ProductSaleBadge extends \WP_UnitTestCase {
 		$product->set_sale_price( '15' );
 		$product->save();

-		$markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product->get_id() . '} --><!-- wp:woocommerce/product-sale-badge ' . wp_json_encode( (object) $attributes ) . ' /--><!-- /wp:woocommerce/single-product -->' );
+		$markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product->get_id() . '} --><div class="wp-block-woocommerce-single-product woocommerce"><!-- wp:woocommerce/product-sale-badge ' . wp_json_encode( (object) $attributes ) . ' /--></div><!-- /wp:woocommerce/single-product -->' );

-		$this->assertStringContainsString( 'wc-block-components-product-sale-badge__text" aria-hidden="true">' . $expected . '</span>', $markup );
-		$this->assertStringContainsString( 'screen-reader-text">Product on sale: ' . $expected . '</span>', $markup );
+		$this->assert_sale_badge_labels( $markup, $expected );
 	}

 	/**
@@ -149,12 +152,12 @@ class ProductSaleBadge extends \WP_UnitTestCase {
 		$priced->set_sale_price( $sale );
 		$priced->save();

-		$markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product->get_id() . '} --><!-- wp:woocommerce/product-sale-badge {"badgeContent":"' . $mode . '"} /--><!-- /wp:woocommerce/single-product -->' );
+		$markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product->get_id() . '} --><div class="wp-block-woocommerce-single-product woocommerce"><!-- wp:woocommerce/product-sale-badge {"badgeContent":"' . $mode . '"} /--></div><!-- /wp:woocommerce/single-product -->' );

 		if ( '' === $expected ) {
 			$this->assertStringNotContainsString( 'wc-block-components-product-sale-badge', $markup, 'Zero-rounded percentage badges should be omitted.' );
 		} else {
-			$this->assertStringContainsString( 'aria-hidden="true">' . $expected . '</span>', $markup );
+			$this->assert_sale_badge_labels( $markup, $expected );
 		}
 	}

@@ -200,9 +203,8 @@ class ProductSaleBadge extends \WP_UnitTestCase {
 				'prefix'       => 'Save ',
 				'suffix'       => ' off',
 			);
-			$markup     = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product->get_id() . '} --><!-- wp:woocommerce/product-sale-badge ' . wp_json_encode( $attributes ) . ' /--><!-- /wp:woocommerce/single-product -->' );
-			$this->assertStringContainsString( 'wc-block-components-product-sale-badge__text" aria-hidden="true">' . $expected . '</span>', $markup );
-			$this->assertStringContainsString( 'screen-reader-text">Product on sale: ' . $expected . '</span>', $markup );
+			$markup     = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product->get_id() . '} --><div class="wp-block-woocommerce-single-product woocommerce"><!-- wp:woocommerce/product-sale-badge ' . wp_json_encode( $attributes ) . ' /--></div><!-- /wp:woocommerce/single-product -->' );
+			$this->assert_sale_badge_labels( $markup, $expected );
 		}
 	}

@@ -226,9 +228,8 @@ class ProductSaleBadge extends \WP_UnitTestCase {
 			'prefix'       => 'Save ',
 			'suffix'       => ' off',
 		);
-		$markup     = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product->get_id() . '} --><!-- wp:woocommerce/product-sale-badge ' . wp_json_encode( $attributes ) . ' /--><!-- /wp:woocommerce/single-product -->' );
-		$this->assertStringContainsString( 'aria-hidden="true">' . $expected . '</span>', $markup );
-		$this->assertStringContainsString( 'screen-reader-text">Product on sale: ' . $expected . '</span>', $markup );
+		$markup     = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product->get_id() . '} --><div class="wp-block-woocommerce-single-product woocommerce"><!-- wp:woocommerce/product-sale-badge ' . wp_json_encode( $attributes ) . ' /--></div><!-- /wp:woocommerce/single-product -->' );
+		$this->assert_sale_badge_labels( $markup, $expected );
 	}

 	/**
@@ -285,8 +286,8 @@ class ProductSaleBadge extends \WP_UnitTestCase {
 					$variable->get_id() => 'Up to $24.00',
 				) as $product_id => $expected
 			) {
-				$markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product_id . '} --><!-- wp:woocommerce/product-sale-badge {"badgeContent":"amount"} /--><!-- /wp:woocommerce/single-product -->' );
-				$this->assertStringContainsString( 'wc-block-components-product-sale-badge__text" aria-hidden="true">' . $expected . '</span>', $markup );
+				$markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product_id . '} --><div class="wp-block-woocommerce-single-product woocommerce"><!-- wp:woocommerce/product-sale-badge {"badgeContent":"amount"} /--></div><!-- /wp:woocommerce/single-product -->' );
+				$this->assert_sale_badge_labels( $markup, $expected );
 			}
 		} finally {
 			\WC_Tax::_delete_tax_rate( $tax_id );
@@ -294,6 +295,41 @@ class ProductSaleBadge extends \WP_UnitTestCase {
 		}
 	}

+	/**
+	 * @testdox Variable badges bind their labels and visibility to Interactivity API state.
+	 */
+	public function test_variable_badge_interactivity(): void {
+		$product   = \WC_Helper_Product::create_variation_product();
+		$variation = wc_get_product( $product->get_children()[0] );
+		$variation->set_sale_price( '5' );
+		$variation->save();
+		$markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product->get_id() . '} --><div class="wp-block-woocommerce-single-product woocommerce"><!-- wp:woocommerce/product-sale-badge {"badgeContent":"percentage","prefix":"Save ","suffix":" off"} /--></div><!-- /wp:woocommerce/single-product -->' );
+		$html   = new \WP_HTML_Tag_Processor( $markup );
+		$html->next_tag( array( 'class_name' => 'wp-block-woocommerce-product-sale-badge' ) );
+		$context = json_decode( $html->get_attribute( 'data-wp-context' ), true );
+		$this->assertSame( 'woocommerce/product-sale-badge', $html->get_attribute( 'data-wp-interactive' ) );
+		$this->assertSame( 'state.isSaleBadgeHidden', $html->get_attribute( 'data-wp-bind--hidden' ) );
+		$this->assertSame( 'Up to 50%', $context['saleBadgeText'] );
+		$this->assertSame( 'percentage', $context['badgeContent'] );
+		$this->assertSame( 'Save ', $context['prefix'] );
+		$this->assertStringContainsString( 'data-wp-text="state.saleBadgeText"', $markup );
+		$this->assertStringContainsString( 'data-wp-text="state.saleBadgeScreenReaderText"', $markup );
+		$this->assertStringContainsString( '>Up to 50%</span>', $markup );
+	}
+
+	/**
+	 * Assert visible and screen-reader sale badge labels without depending on attribute order.
+	 *
+	 * @param string $markup Rendered block markup.
+	 * @param string $expected Expected badge text.
+	 */
+	private function assert_sale_badge_labels( string $markup, string $expected ): void {
+		$this->assertStringContainsString( 'wc-block-components-product-sale-badge__text', $markup );
+		$this->assertStringContainsString( 'aria-hidden="true"', $markup );
+		$this->assertStringContainsString( '>' . $expected . '</span>', $markup );
+		$this->assertStringContainsString( 'Product on sale: ' . $expected . '</span>', $markup );
+	}
+
 	/**
 	 * Alignment values for the Sale Badge.
 	 *
@@ -301,6 +337,7 @@ class ProductSaleBadge extends \WP_UnitTestCase {
 	 */
 	public function provider_product_sale_badge_alignment(): array {
 		return array(
+			'empty'  => array( '' ),
 			'left'   => array( 'left' ),
 			'center' => array( 'center' ),
 			'right'  => array( 'right' ),
@@ -316,7 +353,7 @@ class ProductSaleBadge extends \WP_UnitTestCase {
 		$product->set_regular_price( 10 );
 		$product->set_sale_price( 5 );
 		$product_id = $product->save();
-		$markup     = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product_id . '} --><!-- wp:woocommerce/product-sale-badge /--><!-- /wp:woocommerce/single-product -->' );
+		$markup     = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product_id . '} --><div class="wp-block-woocommerce-single-product woocommerce"><!-- wp:woocommerce/product-sale-badge /--></div><!-- /wp:woocommerce/single-product -->' );

 		$this->assertStringContainsString( 'wp-block-woocommerce-product-sale-badge', $markup, 'The Single Product Block contains the Product Sale Badge block.' );
 		$this->assertStringContainsString( 'Sale', $markup, 'The Product Sale Badge block contains the sale text.' );
@@ -349,7 +386,7 @@ class ProductSaleBadge extends \WP_UnitTestCase {
 			2
 		);

-		$markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product_id . '} --><!-- wp:woocommerce/product-sale-badge /--><!-- /wp:woocommerce/single-product -->' );
+		$markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product_id . '} --><div class="wp-block-woocommerce-single-product woocommerce"><!-- wp:woocommerce/product-sale-badge /--></div><!-- /wp:woocommerce/single-product -->' );

 		$this->assertStringContainsString( 'wp-block-woocommerce-product-sale-badge', $markup, 'The Single Product Block contains the Product Sale Badge block.' );
 		$this->assertStringContainsString( 'Special Offer!', $markup, 'The Product Sale Badge block contains the custom sale text.' );