Commit 99f3b21d9c4 for woocommerce
commit 99f3b21d9c433c37954f17d1c3f8b64de2a1c373
Author: Alefe Souza <contact@alefesouza.com>
Date: Mon Aug 31 13:40:01 2026 -0300
Use consistent markup for single and multiple store notices (#67635)
* Use consistent markup for single and multiple store notices
Store notices rendered bare content for a single notice and a <ul>/<li>
list for several, so a single notice could not be styled or scripted the
same way as a group.
All render branches now emit the same list markup, the error summary
shows for one error too and is pluralized with _n(), and the list
markers are removed so a single notice looks unchanged to shoppers.
role="list" keeps the semantics where list-style: none strips them.
* Cover non-dismissible and non-error notice list markup
The existing test only dispatched dismissible error notices, leaving
the nonDismissibleNotices branch and the non-error statuses (where the
summary is empty) untested, though both share the unified list markup.
Add two tests using role-based queries, and note the new summary string
on single error notices in the changelog entry.
* Expose store notice ids as data-id on their list items
The classic notice templates already expose each notice's id on its li via
wc_get_notice_data_attr(), while block notices had no per-notice hook. Store
API errors use their error code as the notice id, so a rendered notice now
carries data-id="woocommerce_rest_cart_coupon_error" and similar, which is
the second half of the expected behavior in the issue.
Covered by a test asserting the id reaches each list item, and reflected in
the changelog entry.
diff --git a/plugins/woocommerce/changelog/44613-fix-inconsistent-checkout-error-handling b/plugins/woocommerce/changelog/44613-fix-inconsistent-checkout-error-handling
new file mode 100644
index 00000000000..48ea0535459
--- /dev/null
+++ b/plugins/woocommerce/changelog/44613-fix-inconsistent-checkout-error-handling
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Render Cart and Checkout notices with the same list markup whether there is one notice or several, and expose each notice's id as `data-id` on its list item.
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/notice-banner/style.scss b/plugins/woocommerce/client/blocks/assets/js/base/components/notice-banner/style.scss
index e582ca2b794..8ea922adb1f 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/notice-banner/style.scss
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/notice-banner/style.scss
@@ -41,6 +41,19 @@
}
}
+ // Notice contents are always a list, whether there is one notice or
+ // several, so the markers are removed to keep a single notice reading
+ // as plain text. `role="list"` on the element preserves the semantics.
+ ul.wc-block-components-notice-banner__list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+
+ li + li {
+ margin-block-start: $gap-smaller;
+ }
+ }
+
// Legacy notice compatibility.
.wc-forward {
float: right;
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/store-notices-container/store-notices.tsx b/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/store-notices-container/store-notices.tsx
index f9f5b7641da..aff3b28b901 100644
--- a/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/store-notices-container/store-notices.tsx
+++ b/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/store-notices-container/store-notices.tsx
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
-import { __ } from '@wordpress/i18n';
+import { _n } from '@wordpress/i18n';
import clsx from 'clsx';
import { useRef, useEffect, RawHTML } from '@wordpress/element';
import { sanitizeHTML } from '@woocommerce/sanitize';
@@ -16,6 +16,36 @@ import type { NoticeBannerProps } from '@woocommerce/base-components/notice-bann
*/
import StoreNotice from '../store-notice';
+/**
+ * Renders notice contents as a list, so a single notice and multiple notices
+ * share the same markup and can be styled with a single set of rules.
+ *
+ * `role="list"` is needed because the list markers are removed with CSS, which
+ * strips the implicit list semantics in some browsers.
+ *
+ * `data-id` mirrors the classic notice templates, which expose the notice id on
+ * each `li` via `wc_get_notice_data_attr()`. Store API errors surface their
+ * error code as the notice id (e.g. `woocommerce_rest_cart_coupon_error`), so
+ * this gives themes and scripts the same per-notice hook the shortcode
+ * checkout has.
+ */
+const StoreNoticeList = ( {
+ notices,
+}: {
+ notices: ( Pick< NoticeType, 'id' | 'content' > &
+ Partial< Pick< NoticeType, 'context' > > )[];
+} ): JSX.Element => (
+ // The role is not redundant here, see above.
+ // eslint-disable-next-line jsx-a11y/no-redundant-roles
+ <ul className="wc-block-components-notice-banner__list" role="list">
+ { notices.map( ( notice ) => (
+ <li key={ notice.id + '-' + notice.context } data-id={ notice.id }>
+ <RawHTML>{ notice.content }</RawHTML>
+ </li>
+ ) ) }
+ </ul>
+);
+
const StoreNotices = ( {
className,
notices,
@@ -103,9 +133,16 @@ const StoreNotices = ( {
key={ notice.id + '-' + notice.context }
{ ...notice }
>
- <RawHTML>
- { sanitizeHTML( decodeEntities( notice.content ) ) }
- </RawHTML>
+ <StoreNoticeList
+ notices={ [
+ {
+ ...notice,
+ content: sanitizeHTML(
+ decodeEntities( notice.content )
+ ),
+ },
+ ] }
+ />
</StoreNotice>
) ) }
{ Object.entries( dismissibleNoticeGroups ).map(
@@ -139,35 +176,22 @@ const StoreNotices = ( {
} );
},
};
- return uniqueNotices.length === 1 ? (
- <StoreNotice
- key={ 'store-notice-' + status }
- { ...noticeProps }
- >
- <RawHTML>{ uniqueNotices[ 0 ].content }</RawHTML>
- </StoreNotice>
- ) : (
+ return (
<StoreNotice
key={ 'store-notice-' + status }
{ ...noticeProps }
summary={
status === 'error'
- ? __(
+ ? _n(
+ 'Please fix the following error before continuing',
'Please fix the following errors before continuing',
+ uniqueNotices.length,
'woocommerce'
)
: ''
}
>
- <ul>
- { uniqueNotices.map( ( notice ) => (
- <li
- key={ notice.id + '-' + notice.context }
- >
- <RawHTML>{ notice.content }</RawHTML>
- </li>
- ) ) }
- </ul>
+ <StoreNoticeList notices={ uniqueNotices } />
</StoreNotice>
);
}
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/store-notices-container/test/index.tsx b/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/store-notices-container/test/index.tsx
index 44c04b1d01a..6b841969eae 100644
--- a/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/store-notices-container/test/index.tsx
+++ b/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/store-notices-container/test/index.tsx
@@ -3,7 +3,7 @@
*/
import { store as noticesStore } from '@wordpress/notices';
import { dispatch, select } from '@wordpress/data';
-import { act, render, screen, waitFor } from '@testing-library/react';
+import { act, render, screen, waitFor, within } from '@testing-library/react';
/**
* Internal dependencies
@@ -214,6 +214,171 @@ describe( 'StoreNoticesContainer', () => {
} );
} );
+ it( 'Renders a single notice with the same list markup as multiple notices', async () => {
+ dispatch( noticesStore ).createErrorNotice( 'Single list error', {
+ id: 'single-list-error',
+ context: 'test-context',
+ } );
+ const { container, rerender } = render(
+ <StoreNoticesContainer context="test-context" />
+ );
+ const singleList = container.querySelectorAll(
+ '.wc-block-components-notice-banner__list'
+ );
+ expect( singleList ).toHaveLength( 1 );
+ expect( singleList[ 0 ].getAttribute( 'role' ) ).toBe( 'list' );
+ expect( singleList[ 0 ].querySelectorAll( 'li' ) ).toHaveLength( 1 );
+ expect(
+ screen.getAllByText(
+ /Please fix the following error before continuing/i
+ ).length
+ ).toBeGreaterThan( 0 );
+
+ await act( () =>
+ dispatch( noticesStore ).createErrorNotice( 'Second list error', {
+ id: 'second-list-error',
+ context: 'test-context',
+ } )
+ );
+ rerender( <StoreNoticesContainer context="test-context" /> );
+
+ const multipleList = container.querySelectorAll(
+ '.wc-block-components-notice-banner__list'
+ );
+ expect( multipleList ).toHaveLength( 1 );
+ expect( multipleList[ 0 ].querySelectorAll( 'li' ) ).toHaveLength( 2 );
+ expect(
+ screen.getAllByText(
+ /Please fix the following errors before continuing/i
+ ).length
+ ).toBeGreaterThan( 0 );
+
+ // Clean up notices.
+ await act( () =>
+ dispatch( noticesStore ).removeNotice(
+ 'single-list-error',
+ 'test-context'
+ )
+ );
+ await act( () =>
+ dispatch( noticesStore ).removeNotice(
+ 'second-list-error',
+ 'test-context'
+ )
+ );
+ } );
+
+ it( 'Exposes the notice id as data-id on each list item', async () => {
+ // The classic notice templates put the notice id on the li via
+ // wc_get_notice_data_attr(), and Store API errors use their error code as
+ // the notice id. Keep the same hook here so scripts and themes can target
+ // an individual notice.
+ dispatch( noticesStore ).createErrorNotice( 'First data-id error', {
+ id: 'woocommerce_rest_cart_coupon_error',
+ context: 'test-context',
+ } );
+ dispatch( noticesStore ).createErrorNotice( 'Second data-id error', {
+ id: 'woocommerce_rest_invalid_postcode',
+ context: 'test-context',
+ } );
+ const { container } = render(
+ <StoreNoticesContainer context="test-context" />
+ );
+
+ expect(
+ [ ...container.querySelectorAll( 'li' ) ].map( ( item ) =>
+ item.getAttribute( 'data-id' )
+ )
+ ).toEqual( [
+ 'woocommerce_rest_cart_coupon_error',
+ 'woocommerce_rest_invalid_postcode',
+ ] );
+
+ // Clean up notices.
+ await act( () =>
+ dispatch( noticesStore ).removeNotice(
+ 'woocommerce_rest_cart_coupon_error',
+ 'test-context'
+ )
+ );
+ await act( () =>
+ dispatch( noticesStore ).removeNotice(
+ 'woocommerce_rest_invalid_postcode',
+ 'test-context'
+ )
+ );
+ } );
+
+ it( 'Renders a non-dismissible notice with the same list markup, without a summary', async () => {
+ dispatch( noticesStore ).createErrorNotice(
+ 'Non-dismissible list error',
+ {
+ id: 'non-dismissible-list-error',
+ context: 'test-context',
+ isDismissible: false,
+ }
+ );
+ const { container } = render(
+ <StoreNoticesContainer context="test-context" />
+ );
+
+ const lists = within( container ).getAllByRole( 'list' );
+ expect( lists ).toHaveLength( 1 );
+ expect( within( lists[ 0 ] ).getAllByRole( 'listitem' ) ).toHaveLength(
+ 1
+ );
+ expect( lists[ 0 ] ).toHaveClass(
+ 'wc-block-components-notice-banner__list'
+ );
+ // Each non-dismissible notice is its own banner, so it carries no summary.
+ expect(
+ container.querySelector(
+ '.wc-block-components-notice-banner__summary'
+ )
+ ).toBeNull();
+
+ // Clean up notices.
+ await act( () =>
+ dispatch( noticesStore ).removeNotice(
+ 'non-dismissible-list-error',
+ 'test-context'
+ )
+ );
+ } );
+
+ it( 'Renders non-error notices with the same list markup, without a summary', async () => {
+ dispatch( noticesStore ).createSuccessNotice( 'Coupon applied', {
+ id: 'success-list-notice',
+ context: 'test-context',
+ } );
+ const { container } = render(
+ <StoreNoticesContainer context="test-context" />
+ );
+
+ const lists = within( container ).getAllByRole( 'list' );
+ expect( lists ).toHaveLength( 1 );
+ expect( within( lists[ 0 ] ).getAllByRole( 'listitem' ) ).toHaveLength(
+ 1
+ );
+ expect( lists[ 0 ] ).toHaveClass(
+ 'wc-block-components-notice-banner__list'
+ );
+ // The summary is only rendered for the error status.
+ expect(
+ container.querySelector(
+ '.wc-block-components-notice-banner__summary'
+ )
+ ).toBeNull();
+
+ // Clean up notices.
+ await act( () =>
+ dispatch( noticesStore ).removeNotice(
+ 'success-list-notice',
+ 'test-context'
+ )
+ );
+ } );
+
it( 'Combine same notices from several contexts', async () => {
dispatch( noticesStore ).createErrorNotice( 'Custom generic error', {
id: 'custom-subcontext-test-error',