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',