Commit cf443b2c834 for woocommerce

commit cf443b2c83448294656cd261a4b13571ad5e1581
Author: Lucio Giannotta <lucio.giannotta@a8c.com>
Date:   Tue Mar 3 14:44:02 2026 +0800

    Fix mini-cart badge disappearing after iAPI navigation (#63014)

    The badge became invisible after client-side navigation because the
    injected <style> element's rules stopped being applied by the browser.

    This fix uses the adoptedStyleSheets API instead, which is not part of
    the DOM tree and remains stable through iAPI navigation. Also moves
    productCountVisibility from context to config since it's static.

    ---------

    Co-authored-by: luisherranz <luisherranz@gmail.com>

diff --git a/plugins/woocommerce/changelog/63014-fix-mini-cart-badge-iapi-navigation b/plugins/woocommerce/changelog/63014-fix-mini-cart-badge-iapi-navigation
new file mode 100644
index 00000000000..4675c139a98
--- /dev/null
+++ b/plugins/woocommerce/changelog/63014-fix-mini-cart-badge-iapi-navigation
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix Mini-Cart badge becoming invisible after client-side navigation.
\ No newline at end of file
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 2775ee478bb..23a68f5d94a 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
@@ -20,7 +20,6 @@ import type {
 /**
  * Internal dependencies
  */
-import setStyles from './utils/set-styles';
 import {
 	formatPriceWithCurrency,
 	normalizeCurrencyResponse,
@@ -68,15 +67,35 @@ const scalePrice = ( {
 	return Math.round( scaledPrice );
 };

-// Inject style tags for badge styles based on background colors of the document.
-setStyles();
-
-type MiniCartContext = {
-	productCountVisibility: 'never' | 'always' | 'greater_than_zero';
-};
+/**
+ * Recursively traverses the DOM hierarchy to find the closest non-transparent color.
+ *
+ * @param element   The starting element to check.
+ * @param colorType Either 'color' (text) or 'backgroundColor'.
+ * @return The computed color as an RGB string, or null if not found.
+ */
+function getClosestColor(
+	element: Element | null,
+	colorType: 'color' | 'backgroundColor'
+): string | null {
+	if ( ! element ) {
+		return null;
+	}
+	const color = window.getComputedStyle( element )[ colorType ];
+	if ( color !== 'rgba(0, 0, 0, 0)' && color !== 'transparent' ) {
+		const matches = color.match( /\d+/g );
+		if ( ! matches || matches.length < 3 ) {
+			return null;
+		}
+		const [ r, g, b ] = matches.slice( 0, 3 );
+		return `rgb(${ r }, ${ g }, ${ b })`;
+	}
+	return getClosestColor( element.parentElement, colorType );
+}

 type MiniCart = {
 	state: {
+		isHydrated: boolean;
 		isOpen: boolean;
 		totalItemsInCart: number;
 		formattedSubtotal: string;
@@ -88,6 +107,9 @@ type MiniCart = {
 		buttonAriaLabel: string;
 		shouldShowTaxLabel: boolean;
 		miniCartButtonRef: HTMLElement | null;
+		contentsBackgroundColor: string;
+		badgeBackgroundColor: string | undefined;
+		badgeTextColor: string | undefined;
 	};
 	actions: {
 		openDrawer: () => void;
@@ -96,12 +118,17 @@ type MiniCart = {
 		handleOverlayKeydown: ( e: KeyboardEvent ) => void;
 	};
 	callbacks: {
+		markAsHydrated: () => void;
 		setupJQueryEventBridge: () => void;
 		disableScrollingOnBody: () => void;
 		focusFirstElement: () => void;
 	};
 };

+type MiniCartContext = {
+	productCountVisibility: 'never' | 'always' | 'greater_than_zero';
+};
+
 type CartItemContext = {
 	cartItem: CartItem;
 };
@@ -172,6 +199,7 @@ store< MiniCart >(
 	'woocommerce/mini-cart',
 	{
 		state: {
+			isHydrated: false,
 			get totalItemsInCart() {
 				if ( nonOptimisticProperties.includes( 'cart.items_count' ) ) {
 					return woocommerceState.cart.items_count as number;
@@ -250,6 +278,26 @@ store< MiniCart >(
 					) > 0
 				);
 			},
+
+			get contentsBackgroundColor(): string {
+				return (
+					getComputedStyle( document.body ).backgroundColor || '#fff'
+				);
+			},
+
+			get badgeBackgroundColor(): string | undefined {
+				if ( state.isHydrated ) {
+					const { ref } = getElement();
+					return getClosestColor( ref!, 'color' ) || '#000';
+				}
+			},
+
+			get badgeTextColor(): string | undefined {
+				if ( state.isHydrated ) {
+					const { ref } = getElement();
+					return getClosestColor( ref, 'backgroundColor' ) || '#fff';
+				}
+			},
 		},

 		actions: {
@@ -358,6 +406,9 @@ store< MiniCart >(
 					getFocusableElements( ref )[ 0 ]?.focus();
 				}
 			},
+			markAsHydrated() {
+				state.isHydrated = true;
+			},
 		},
 	},
 	{ lock: universalLock }
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/utils/set-styles.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/utils/set-styles.ts
index 32eb90b77fe..27323a4132e 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/utils/set-styles.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/utils/set-styles.ts
@@ -25,7 +25,6 @@ function setStyles() {
 	 * We only set the background color, instead of the whole background. As
 	 * we only provide the option to customize the background color.
 	 */
-	const style = document.createElement( 'style' );
 	const backgroundColor = getComputedStyle( document.body ).backgroundColor;
 	// For simplicity, we only consider the background color of the first Mini-Cart button.
 	const firstMiniCartButton = document.querySelector(
@@ -39,19 +38,26 @@ function setStyles() {
 	// We use :where here to reduce specificity so customized colors and theme
 	// CSS take priority.
 	// We need to set `div` and `span` in the selector so it has more specificity than the CSS.
-	style.appendChild(
-		document.createTextNode(
-			`div:where(.wp-block-woocommerce-mini-cart-contents) {
-				background-color: ${ backgroundColor };
-			}
-			span:where(.wc-block-mini-cart__badge) {
-				background-color: ${ badgeBackgroundColor };
-				color: ${ badgeTextColor };
-			}`
-		)
-	);
+	const css = `div:where(.wp-block-woocommerce-mini-cart-contents) {
+			background-color: ${ backgroundColor };
+		}
+		span:where(.wc-block-mini-cart__badge) {
+			background-color: ${ badgeBackgroundColor };
+			color: ${ badgeTextColor };
+		}`;

-	document.head.appendChild( style );
+	// Use adoptedStyleSheets so styles survive iAPI client-side navigations.
+	// Fall back to a <style> element for environments that don't support it,
+	// (specifically it would fail in tests).
+	try {
+		const sheet = new CSSStyleSheet();
+		sheet.replaceSync( css );
+		document.adoptedStyleSheets = [ ...document.adoptedStyleSheets, sheet ];
+	} catch {
+		const style = document.createElement( 'style' );
+		style.appendChild( document.createTextNode( css ) );
+		document.head.appendChild( style );
+	}
 }

 export default setStyles;
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 3a5b0f435f3..81876efd02b 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
@@ -301,42 +301,154 @@ test.describe( `${ blockData.name } Block`, () => {
 		await expect( page ).toHaveURL( /\/checkout\/?$/ );
 	} );

-	test.describe( 'optimistic updates', () => {
-		// eslint-disable-next-line playwright/no-skipped-test
-		test.skip(
-			! config.features[ 'experimental-iapi-mini-cart' ],
-			'These tests are only relevant for the iAPI mini cart.'
+	test( 'should process badge colors on load', async ( {
+		page,
+		frontendUtils,
+	} ) => {
+		await frontendUtils.goToShop();
+		await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
+
+		// Get the badge element and verify colors are computed.
+		const badge = page.locator( '.wc-block-mini-cart__badge' );
+		await expect( badge ).toBeVisible();
+
+		// Wait for colors to be computed (they start as transparent).
+		await expect( badge ).toHaveCSS(
+			'background-color',
+			/.+(?<!transparent)/
 		);

-		test( 'should show the server filtered item count in the mini-cart title', async ( {
-			page,
-			frontendUtils,
-			miniCartUtils,
-			requestUtils,
-		} ) => {
-			await requestUtils.activatePlugin(
-				'woocommerce-blocks-test-cart-contents-count-filter'
-			);
+		// Get the initial computed colors.
+		const initialBgColor = await badge.evaluate(
+			( el ) => window.getComputedStyle( el ).backgroundColor
+		);
+		const initialTextColor = await badge.evaluate(
+			( el ) => window.getComputedStyle( el ).color
+		);

-			try {
-				await frontendUtils.goToShop();
-				await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
-				await miniCartUtils.openMiniCart();
+		// Verify colors are not transparent (they should be computed).
+		expect( initialBgColor ).not.toBe( 'transparent' );
+		expect( initialBgColor ).not.toBe( 'rgba(0, 0, 0, 0)' );
+		expect( initialTextColor ).not.toBe( 'transparent' );
+		expect( initialTextColor ).not.toBe( 'rgba(0, 0, 0, 0)' );
+	} );
+} );

-				// The filter overrides the count to 999. The mini-cart title should
-				// display this filtered value rather than the actual number of items.
-				const miniCartTitleItemsCounterBlock = page.locator(
-					'[data-block-name="woocommerce/mini-cart-title-items-counter-block"]'
-				);
-				await expect( miniCartTitleItemsCounterBlock ).toBeVisible();
-				await expect( miniCartTitleItemsCounterBlock ).toContainText(
-					'999'
-				);
-			} finally {
-				await requestUtils.deactivatePlugin(
-					'woocommerce-blocks-test-cart-contents-count-filter'
-				);
-			}
+test.describe( `${ blockData.name } Block (admin)`, () => {
+	test( 'should update badge colors when header background changes', async ( {
+		page,
+		admin,
+		editor,
+		frontendUtils,
+	} ) => {
+		// First, change the header background color in the site editor.
+		await admin.visitSiteEditor( {
+			postId: 'twentytwentyfour//header',
+			postType: 'wp_template_part',
+			canvas: 'edit',
 		} );
+
+		// Select the mini-cart block to get access to its parent (the header row).
+		const miniCartBlock = editor.canvas.locator(
+			'[data-type="woocommerce/mini-cart"]'
+		);
+		await miniCartBlock.click();
+
+		// Select the parent Row block that contains the mini-cart.
+		// Use the block toolbar to select parent.
+		await editor.clickBlockToolbarButton( 'Select parent block: Row' );
+
+		// Now open the Styles panel and set background color.
+		await editor.openDocumentSettingsSidebar();
+
+		// Click on the Styles tab.
+		const stylesTab = page.getByRole( 'tab', { name: 'Styles' } );
+		if ( await stylesTab.isVisible() ) {
+			await stylesTab.click();
+		}
+
+		// Find and click the background color control.
+		const bgColorButton = page
+			.getByRole( 'button', { name: 'Background' } )
+			.first();
+		await bgColorButton.click();
+
+		// Select "Contrast" preset color (black).
+		await page
+			.getByRole( 'option', { name: 'Contrast', exact: true } )
+			.click();
+
+		// Extract the background color hex value from the editor UI.
+		const parentBgColorHex = await page
+			.locator( '.components-color-palette__custom-color-value' )
+			.textContent();
+
+		// Close the background color popover by clicking outside.
+		await stylesTab.click();
+
+		// Find and click the text color control.
+		const textColorButton = page
+			.getByRole( 'button', { name: 'Text' } )
+			.first();
+		await textColorButton.click();
+
+		// Select "Base" preset color (white).
+		await page.getByRole( 'option', { name: 'Base', exact: true } ).click();
+
+		// Extract the text color hex value from the editor UI.
+		const parentTextColorHex = await page
+			.locator( '.components-color-palette__custom-color-value' )
+			.textContent();
+
+		// Save the changes.
+		await editor.saveSiteEditorEntities( {
+			isOnlyCurrentEntityDirty: true,
+		} );
+
+		// Add an item to cart (use a product that's on the first page).
+		await frontendUtils.goToShop();
+		await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
+
+		// Verify color values were extracted from the editor.
+		expect( parentBgColorHex ).toBeTruthy();
+		expect( parentTextColorHex ).toBeTruthy();
+
+		// Helper to convert hex color to rgb format.
+		const hexToRgb = ( hex: string ) => {
+			const cleanHex = hex.replace( '#', '' );
+			const r = parseInt( cleanHex.slice( 0, 2 ), 16 );
+			const g = parseInt( cleanHex.slice( 2, 4 ), 16 );
+			const b = parseInt( cleanHex.slice( 4, 6 ), 16 );
+			return `rgb(${ r }, ${ g }, ${ b })`;
+		};
+
+		// Verify the badge has the correct colors:
+		// - Badge background = parent's text color (inverted)
+		// - Badge text = parent's background color (inverted)
+		const badge = page.locator( '.wc-block-mini-cart__badge' );
+		await expect( badge ).toHaveCSS(
+			'background-color',
+			hexToRgb( parentTextColorHex as string )
+		);
+		await expect( badge ).toHaveCSS(
+			'color',
+			hexToRgb( parentBgColorHex as string )
+		);
+
+		// Navigate to the next page using client-side navigation.
+		await page.getByRole( 'link', { name: 'Next Page' } ).click();
+
+		// Await for the navigation to happen.
+		await expect( page ).toHaveURL( /page\/2\/?$/ );
+
+		// Verify the badge colors persist after navigation.
+		await expect( badge ).toHaveCSS(
+			'background-color',
+			hexToRgb( parentTextColorHex as string )
+		);
+		await expect( badge ).toHaveCSS(
+			'color',
+			hexToRgb( parentBgColorHex as string )
+		);
 	} );
 } );
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/MiniCart.php b/plugins/woocommerce/src/Blocks/BlockTypes/MiniCart.php
index 4736035981d..2abec4bdb5c 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/MiniCart.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/MiniCart.php
@@ -607,12 +607,13 @@ class MiniCart extends AbstractBlock {
 				data-wp-on-document--wc-blocks_added_to_cart---open-drawer="actions.openDrawer"
 				<?php endif; ?>
 				data-wp-watch="callbacks.disableScrollingOnBody"
+				data-wp-init--mark-as-hydrated="callbacks.markAsHydrated"
 				<?php // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
 				<?php echo wp_interactivity_data_wp_context( $context ); ?>
 				class="<?php echo esc_attr( $wrapper_classes ); ?>"
 				style="<?php echo esc_attr( $wrapper_styles ); ?>"
 			>
-				<button
+				<button
 					data-wp-on--click="actions.openDrawer"
 					data-wp-bind--aria-label="state.buttonAriaLabel"
 					class="wc-block-mini-cart__button"
@@ -623,8 +624,15 @@ class MiniCart extends AbstractBlock {
 							// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
 							echo $icon;
 						?>
-						<?php if ( 'never' !== $product_count_visibility ) : ?>
-							<span data-wp-bind--hidden="!state.badgeIsVisible" data-wp-text="state.totalItemsInCart" class="wc-block-mini-cart__badge" style="<?php echo esc_attr( $styles ); ?>">
+							<?php if ( 'never' !== $product_count_visibility ) : ?>
+								<span
+									data-wp-style--background-color="state.badgeBackgroundColor"
+									data-wp-style--color="state.badgeTextColor"
+									data-wp-bind--hidden="!state.badgeIsVisible"
+									data-wp-text="state.totalItemsInCart"
+									class="wc-block-mini-cart__badge"
+									style="<?php echo esc_attr( $styles ); ?>"
+								>
 							</span>
 						<?php endif; ?>
 					</span>
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/MiniCartContents.php b/plugins/woocommerce/src/Blocks/BlockTypes/MiniCartContents.php
index 28d086c7cdc..ca63fe1bdb9 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/MiniCartContents.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/MiniCartContents.php
@@ -66,7 +66,8 @@ class MiniCartContents extends AbstractBlock {
 	protected function render_experimental_iapi_mini_cart_contents( $attributes, $content, $block ) {
 		$wrapper_attributes = get_block_wrapper_attributes(
 			array(
-				'data-wp-interactive' => 'woocommerce/mini-cart-contents',
+				'data-wp-interactive'             => 'woocommerce/mini-cart-contents',
+				'data-wp-style--background-color' => 'state.contentsBackgroundColor',
 			)
 		);