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;
+ }
}