Commit bc6ccee820 for woocommerce

commit bc6ccee8202ce78459603bae45f35a4eecce6b12
Author: Sam Seay <samueljseay@gmail.com>
Date:   Wed Jan 21 17:59:48 2026 +1300

    Mini Cart Bugfix - Error notices dont show up in the mini cart on initial load. (#62315)


    ---------
    Co-authored-by: David <david.arenas@automattic.com>
    Co-authored-by: luisherranz <luisherranz@gmail.com>

diff --git a/plugins/woocommerce/changelog/62315-wooplug-4934-error-notices-dont-show-up-in-the-mini-cart-on-initial-load b/plugins/woocommerce/changelog/62315-wooplug-4934-error-notices-dont-show-up-in-the-mini-cart-on-initial-load
new file mode 100644
index 0000000000..ddff358039
--- /dev/null
+++ b/plugins/woocommerce/changelog/62315-wooplug-4934-error-notices-dont-show-up-in-the-mini-cart-on-initial-load
@@ -0,0 +1,4 @@
+Significance: minor
+Type: fix
+
+Fix a bug where error notices don't show up in the Mini Cart on first load.
\ No newline at end of file
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/stores/store-notices.ts b/plugins/woocommerce/client/blocks/assets/js/base/stores/store-notices.ts
index ece65110f9..7b40754e31 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/stores/store-notices.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/base/stores/store-notices.ts
@@ -31,7 +31,6 @@ const getProductCollectionContext = () =>

 type StoreNoticesState = {
 	get role(): string;
-	get iconPath(): string;
 	get isError(): boolean;
 	get isSuccess(): boolean;
 	get isInfo(): boolean;
@@ -47,18 +46,10 @@ export type Store = {
 	callbacks: {
 		renderNoticeContent: () => void;
 		scrollIntoView: () => void;
+		injectIcon: () => void;
 	};
 };

-const ALERT_ICON_PATH =
-	'M12 3.2c-4.8 0-8.8 3.9-8.8 8.8 0 4.8 3.9 8.8 8.8 8.8 4.8 0 8.8-3.9 8.8-8.8 0-4.8-4-8.8-8.8-8.8zm0 16c-4 0-7.2-3.3-7.2-7.2C4.8 8 8 4.8 12 4.8s7.2 3.3 7.2 7.2c0 4-3.2 7.2-7.2 7.2zM11 17h2v-6h-2v6zm0-8h2V7h-2v2z';
-
-const ICON_PATHS = {
-	error: ALERT_ICON_PATH,
-	success: 'M16.7 7.1l-6.3 8.5-3.3-2.5-.9 1.2 4.5 3.4L17.9 8z',
-	notice: ALERT_ICON_PATH,
-};
-
 const generateNoticeId = () => {
 	// semi-random with low collision probability.
 	return `${ Date.now() }-${ Math.random()
@@ -66,6 +57,12 @@ const generateNoticeId = () => {
 		.substring( 2, 15 ) }`;
 };

+const ICON_PATHS = {
+	errorOrInfo:
+		'M12 3.2c-4.8 0-8.8 3.9-8.8 8.8 0 4.8 3.9 8.8 8.8 8.8 4.8 0 8.8-3.9 8.8-8.8 0-4.8-4-8.8-8.8-8.8zm0 16c-4 0-7.2-3.3-7.2-7.2C4.8 8 8 4.8 12 4.8s7.2 3.3 7.2 7.2c0 4-3.2 7.2-7.2 7.2zM11 17h2v-6h-2v6zm0-8h2V7h-2v2z',
+	success: 'M16.7 7.1l-6.3 8.5-3.3-2.5-.9 1.2 4.5 3.4L17.9 8z',
+};
+
 // Todo: export this store once the store is public.
 const { state } = store< Store >(
 	'woocommerce/store-notices',
@@ -82,11 +79,6 @@ const { state } = store< Store >(

 				return 'status';
 			},
-			get iconPath() {
-				const context = getStoreNoticeContext();
-				const noticeType = context.notice.type;
-				return ICON_PATHS[ noticeType ];
-			},
 			get isError() {
 				const { notice } = getStoreNoticeContext();
 				return notice.type === 'error';
@@ -168,6 +160,45 @@ const { state } = store< Store >(
 					ref.scrollIntoView( { behavior: 'smooth' } );
 				}
 			},
+
+			injectIcon: () => {
+				const { ref } = getElement();
+				if ( ! ref ) {
+					return;
+				}
+
+				// Remove existing icon SVG if present (watch may run multiple times).
+				const existingSvg = ref.querySelector( ':scope > svg' );
+				if ( existingSvg ) {
+					existingSvg.remove();
+				}
+
+				const svg = document.createElementNS(
+					'http://www.w3.org/2000/svg',
+					'svg'
+				);
+				svg.setAttribute( 'xmlns', 'http://www.w3.org/2000/svg' );
+				svg.setAttribute( 'viewBox', '0 0 24 24' );
+				svg.setAttribute( 'width', '24' );
+				svg.setAttribute( 'height', '24' );
+				svg.setAttribute( 'aria-hidden', 'true' );
+				svg.setAttribute( 'focusable', 'false' );
+
+				const path = document.createElementNS(
+					'http://www.w3.org/2000/svg',
+					'path'
+				);
+				path.setAttribute(
+					'd',
+					state.isError || state.isInfo
+						? ICON_PATHS.errorOrInfo
+						: ICON_PATHS.success
+				);
+				svg.appendChild( path );
+
+				// Insert as first child.
+				ref.prepend( svg );
+			},
 		},
 	},
 	{ lock: true }
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 18838dcf5d..b5b89952da 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
@@ -11,6 +11,7 @@ import {
 	withSyncEvent,
 } from '@wordpress/interactivity';
 import '@woocommerce/stores/woocommerce/cart';
+import '@woocommerce/stores/store-notices';
 import type {
 	Store as WooCommerce,
 	WooCommerceConfig,
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/tests/mini-cart/mini-cart-block.shopper.block_theme.spec.ts b/plugins/woocommerce/client/blocks/tests/e2e/tests/mini-cart/mini-cart-block.shopper.block_theme.spec.ts
index dd218f4d11..a12405419b 100644
--- a/plugins/woocommerce/client/blocks/tests/e2e/tests/mini-cart/mini-cart-block.shopper.block_theme.spec.ts
+++ b/plugins/woocommerce/client/blocks/tests/e2e/tests/mini-cart/mini-cart-block.shopper.block_theme.spec.ts
@@ -27,6 +27,67 @@ const test = base.extend< { productCollectionPage: ProductCollectionPage } >( {
 } );

 test.describe( 'Shopper → Notices', () => {
+	// This test only applies to the iAPI mini cart which supports SSR.
+	// The legacy React mini cart is entirely client-side rendered.
+	if ( config.features[ 'experimental-iapi-mini-cart' ] ) {
+		test( 'Shopper sees SSR error notice in mini cart when product goes out of stock', async ( {
+			page,
+			browser,
+			frontendUtils,
+		} ) => {
+			const productName = 'Limited Stock Product';
+
+			// Create a product with only 1 in stock.
+			const result = await wpCLI(
+				`wc product create --name="${ productName }" --regular_price=10 --manage_stock=true --stock_quantity=1 --user=admin --porcelain`
+			);
+			// Extract just the numeric ID from output (npm adds prefix lines to stdout).
+			const productId = result.stdout.match( /^\d+$/m )?.[ 0 ];
+			if ( ! productId ) {
+				throw new Error(
+					`Failed to extract product ID from wpCLI output: ${ result.stdout }`
+				);
+			}
+
+			await frontendUtils.emptyCart();
+			await frontendUtils.goToShop();
+			await frontendUtils.addToCart( productName );
+
+			// Set product to out of stock while it's in cart.
+			await wpCLI(
+				`wc product update ${ productId } --stock_quantity=0 --in_stock=false --user=admin`
+			);
+
+			// Get the current URL to revisit with JS disabled.
+			const currentUrl = page.url();
+
+			// Create a new context with JavaScript disabled to verify SSR output.
+			const noJsContext = await browser.newContext( {
+				javaScriptEnabled: false,
+			} );
+
+			try {
+				const noJsPage = await noJsContext.newPage();
+
+				// Copy cookies to maintain cart session.
+				const cookies = await page.context().cookies();
+				await noJsContext.addCookies( cookies );
+
+				await noJsPage.goto( currentUrl );
+
+				// Verify error notice banner is rendered in SSR output (not client-side JS).
+				// Note: The notice text content contains HTML and is rendered client-side via
+				// data-wp-init callback, so we only verify the banner structure exists in SSR.
+				const miniCartNotice = noJsPage.locator(
+					'.wp-block-woocommerce-filled-mini-cart-contents-block .wc-block-components-notice-banner'
+				);
+				await expect( miniCartNotice ).toBeVisible();
+			} finally {
+				await noJsContext.close();
+			}
+		} );
+	}
+
 	test( 'Shopper can add item to cart, and will not see a notice in the mini cart', async ( {
 		page,
 		editor,
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/AddToCartWithOptions.php b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/AddToCartWithOptions.php
index ec3b3bc0eb..51114d64c1 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/AddToCartWithOptions.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/AddToCartWithOptions.php
@@ -6,7 +6,6 @@ namespace Automattic\WooCommerce\Blocks\BlockTypes\AddToCartWithOptions;
 use Automattic\WooCommerce\Blocks\BlockTypes\AbstractBlock;
 use Automattic\WooCommerce\Blocks\BlockTypes\EnableBlockJsonAssetsTrait;
 use Automattic\WooCommerce\Blocks\Package;
-use Automattic\WooCommerce\Admin\Features\Features;
 use Automattic\WooCommerce\Blocks\Utils\StyleAttributesUtils;
 use Automattic\WooCommerce\Enums\ProductType;
 use Automattic\WooCommerce\Blocks\Utils\BlockTemplateUtils;
@@ -728,10 +727,8 @@ class AddToCartWithOptions extends AbstractBlock {
 					data-wp-class--is-info="state.isInfo"
 					data-wp-class--is-dismissible="context.notice.dismissible"
 					data-wp-bind--role="state.role"
+					data-wp-watch="callbacks.injectIcon"
 				>
-					<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" focusable="false">
-						<path data-wp-bind--d="state.iconPath"></path>
-					</svg>
 					<div class="wc-block-components-notice-banner__content">
 						<span data-wp-init="callbacks.renderNoticeContent" aria-live="assertive" aria-atomic="true"></span>
 					</div>
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/FilledMiniCartContentsBlock.php b/plugins/woocommerce/src/Blocks/BlockTypes/FilledMiniCartContentsBlock.php
index f5ab3650c2..dbc122d47d 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/FilledMiniCartContentsBlock.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/FilledMiniCartContentsBlock.php
@@ -2,6 +2,7 @@
 namespace Automattic\WooCommerce\Blocks\BlockTypes;

 use Automattic\WooCommerce\Admin\Features\Features;
+use Automattic\WooCommerce\Blocks\Utils\BlocksSharedState;

 /**
  * FilledMiniCartContentsBlock class.
@@ -39,9 +40,12 @@ class FilledMiniCartContentsBlock extends AbstractInnerBlock {
 	 * @return string Rendered block type output.
 	 */
 	protected function render_experimental_filled_mini_cart_contents( $attributes, $content, $block ) {
+		$consent = 'I acknowledge that using private APIs means my theme or plugin will inevitably break in the next version of WooCommerce';
+		$notices = BlocksSharedState::get_cart_error_notices( $consent );
+
 		$context = wp_json_encode(
 			array(
-				'notices' => array(),
+				'notices' => $notices,
 			),
 			JSON_NUMERIC_CHECK
 				| JSON_HEX_TAG
@@ -58,6 +62,8 @@ class FilledMiniCartContentsBlock extends AbstractInnerBlock {
 			)
 		);

+		$dismiss_aria_label = __( 'Dismiss this notice', 'woocommerce' );
+
 		ob_start();
 		?>
 		<div <?php echo $wrapper_attributes; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>>
@@ -65,35 +71,34 @@ class FilledMiniCartContentsBlock extends AbstractInnerBlock {
 				class="wc-block-components-notices"
 				data-wp-interactive="woocommerce/store-notices"
 			><template
-				data-wp-each--notice="context.notices"
-				data-wp-each-key="context.notice.id"
-			>
-				<div
-					class="wc-block-components-notice-banner"
-					data-wp-class--is-error="state.isError"
-					data-wp-class--is-success ="state.isSuccess"
-					data-wp-class--is-info="state.isInfo"
-					data-wp-class--is-dismissible="context.notice.dismissible"
-					data-wp-bind--role="state.role"
+					data-wp-each--notice="context.notices"
+					data-wp-each-key="context.notice.id"
 				>
-					<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" focusable="false">
-						<path data-wp-bind--d="state.iconPath"></path>
-					</svg>
-					<div class="wc-block-components-notice-banner__content">
-						<span data-wp-init="callbacks.renderNoticeContent"></span>
-					</div>
-					<button
-						data-wp-bind--hidden="!context.notice.dismissible"
-						class="wc-block-components-button wp-element-button wc-block-components-notice-banner__dismiss contained"
-						aria-label="<?php esc_attr_e( 'Dismiss this notice', 'woocommerce' ); ?>"
-						data-wp-on--click="actions.removeNotice"
+					<div
+						class="wc-block-components-notice-banner"
+						data-wp-class--is-error="state.isError"
+						data-wp-class--is-success="state.isSuccess"
+						data-wp-class--is-info="state.isInfo"
+						data-wp-class--is-dismissible="context.notice.dismissible"
+						data-wp-bind--role="state.role"
+						data-wp-watch="callbacks.injectIcon"
 					>
-						<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
-							<path d="M13 11.8l6.1-6.3-1-1-6.1 6.2-6.1-6.2-1 1 6.1 6.3-6.5 6.7 1 1 6.5-6.6 6.5 6.6 1-1z" />
-						</svg>
-					</button>
-				</div>
-			</template></div>
+						<div class="wc-block-components-notice-banner__content">
+							<span data-wp-init="callbacks.renderNoticeContent"></span>
+						</div>
+						<button
+							data-wp-bind--hidden="!context.notice.dismissible"
+							class="wc-block-components-button wp-element-button wc-block-components-notice-banner__dismiss contained"
+							aria-label="<?php echo esc_attr( $dismiss_aria_label ); ?>"
+							data-wp-on--click="actions.removeNotice"
+						>
+							<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
+								<path d="M13 11.8l6.1-6.3-1-1-6.1 6.2-6.1-6.2-1 1 6.1 6.3-6.5 6.7 1 1 6.5-6.6 6.5 6.6 1-1z" />
+							</svg>
+						</button>
+					</div>
+				</template>
+			</div>
 			<?php
 				// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
 				echo $content;
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/Renderer.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/Renderer.php
index 6f6fcd60b8..536618087c 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/Renderer.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/Renderer.php
@@ -197,14 +197,12 @@ class Renderer {
 					class="wc-block-components-notice-banner"
 					data-wp-init="callbacks.scrollIntoView"
 					data-wp-class--is-error="state.isError"
-					data-wp-class--is-success ="state.isSuccess"
+					data-wp-class--is-success="state.isSuccess"
 					data-wp-class--is-info="state.isInfo"
 					data-wp-class--is-dismissible="context.notice.dismissible"
 					data-wp-bind--role="state.role"
+					data-wp-watch="callbacks.injectIcon"
 				>
-					<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" focusable="false">
-						<path data-wp-bind--d="state.iconPath"></path>
-					</svg>
 					<div class="wc-block-components-notice-banner__content">
 						<span data-wp-init="callbacks.renderNoticeContent" aria-live="assertive" aria-atomic="true"></span>
 					</div>
diff --git a/plugins/woocommerce/src/Blocks/Utils/BlocksSharedState.php b/plugins/woocommerce/src/Blocks/Utils/BlocksSharedState.php
index fd8d96b5a9..5518e42e98 100644
--- a/plugins/woocommerce/src/Blocks/Utils/BlocksSharedState.php
+++ b/plugins/woocommerce/src/Blocks/Utils/BlocksSharedState.php
@@ -190,4 +190,37 @@ class BlocksSharedState {
 			array( 'placeholderImgSrc' => wc_placeholder_img_src() )
 		);
 	}
+
+	/**
+	 * Get cart errors formatted as notices for the store-notices interactivity store.
+	 *
+	 * Returns errors from the hydrated cart state in the format expected by
+	 * the store-notices store context.
+	 *
+	 * @param string $consent_statement The consent statement string.
+	 * @return array Array of notices with id, notice, type, and dismissible keys.
+	 * @throws InvalidArgumentException If consent statement doesn't match.
+	 */
+	public static function get_cart_error_notices( string $consent_statement ): array {
+		self::check_consent( $consent_statement );
+
+		// Ensure cart state is loaded so this method works independently.
+		if ( null === self::$blocks_shared_cart_state ) {
+			self::load_cart_state( $consent_statement );
+		}
+
+		$errors  = self::$blocks_shared_cart_state['errors'] ?? array();
+		$notices = array();
+
+		foreach ( $errors as $error ) {
+			$notices[] = array(
+				'id'          => wp_unique_id( 'store-notice-' ),
+				'notice'      => $error['message'] ?? '',
+				'type'        => 'error',
+				'dismissible' => true,
+			);
+		}
+
+		return $notices;
+	}
 }