Commit adefb5f9715 for woocommerce

commit adefb5f97154a7f0393c158cac56d27561028b31
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Sat Sep 12 00:12:42 2026 +0300

    Fix checkout validation for non-error update notices (#68360)

    * fix(checkout): Avoid validation for non-error notices

    Classic checkout refreshes classified every rendered notice as a failure, so success and neutral messages caused unrelated empty fields to be validated and highlighted.

    Classify the response from queued error notices before rendering clears them, keep reload semantics intact, and display returned messages independently from failure-only cleanup, validation, and scrolling. Cover normal, mixed, reload, and client replacement behavior with evergreen fixtures.

    Refs #40443

    * chore: Add checkout notice validation changelog

    Record the classic checkout fix so non-error update notices no longer trigger field validation in WooCommerce release notes.

    Refs #40443

    * fix(checkout): Report update errors without redefining the legacy result

    Classic checkout treated every rendered notice as a failure, so a success
    or info notice validated and highlighted unrelated empty fields. Reporting
    the error state through `result` fixed that, but `result` is a field third
    parties consume: forks of the Core handler render `messages` only when it
    reads `failure`, and the whole payload is broadcast to every
    `updated_checkout` listener. Success notices silently vanished for them.

    Keep `result` reporting whether the response carries a rendered notice, and
    add `has_errors` for the actual error state. Core derives notice clearing,
    field validation, and scrolling from the flag, and falls back to `result`
    when it is absent, so a callback that answers this endpoint before Core
    does still gets validation and scrolling on a real error.

    The shipping method radio focus now keys on the flag too. It asked for
    `success` before, which no longer means "no error", and its own comment
    always described it as an error check.

    Refs #40443

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * test(checkout): Assert scroll behavior for checkout update notices

    The checkout update notice tests covered notice rendering and field
    validation but never scrolling, so a change that scrolled to a success
    notice would have passed.

    Assert that scroll_to_notices stays untouched for responses without an
    error and runs once for a failure.

    Refs #40443

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * fix(checkout): Trust the update error flag only when it is a boolean

    `has_errors` is documented as a boolean, but the handler read it with
    truthiness. A callback that answers this endpoint before Core does and
    sends a non-boolean, the string "false" for instance, would then read as
    an error and validate fields that are fine.

    Trust the flag only when it is a boolean and fall back to `result`
    otherwise. This matches how the file already reads `reload`, which is
    compared against `true` rather than coerced, after Core shipped that
    field as a string for years.

    Refs #40443

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * fix(checkout): Guard the shipping focus restore against a missing input

    The focus restore dereferenced `getElementById` without checking it. When
    a checkout update drops the shipping method that triggered it, the element
    is gone from the refreshed fragment and `.focus()` throws. The throw lands
    before `updated_checkout` fires, so every listener on the page stops
    receiving the event.

    Reaching this on trunk needed a response carrying no notice at all. Keying
    the block on the error flag widened it to every non-error response, which
    includes the notice a store would show while withdrawing a method, so
    guard the lookup before focusing.

    Refs #40443

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * fix(checkout): Key notice cleanup and rendering on one condition

    Moving the notice cleanup behind the new error flag left two gaps
    against trunk, whose only gate was `'failure' === data.result`.

    A failed place order leaves `.woocommerce-NoticeGroup-checkout` on the
    page. Trunk cleared it on the next checkout update that carried a
    notice. With the cleanup behind the error flag, a later success notice
    was prepended above the stale failure, which stayed on screen.

    Rendering also stopped consulting `result`, so a third-party callback
    answering this endpoint with `result: 'success'` and a `messages`
    payload got that payload rendered where trunk dropped it. That
    undercuts the reason `result` was preserved in the first place.

    Both now share one condition: render when the error flag says so, or
    when `result` reports a notice the way it always has. A non-error
    notice clears only the stale place-order group, so every unrelated
    notice on the page survives, which is what this fix set out to
    protect. Real errors keep the broad cleanup unchanged.

    Refs #40443

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * docs(ajax): Correct the @since for the order review fragments filter

    The `woocommerce_update_order_review_fragments` docblock added in this
    branch dated the filter to 2.4.0. It was introduced in 2e398a3a79 on
    2014-11-26 and first shipped in 2.3.0.

    Refs #40443

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * fix(ajax): Report checkout update errors only when a notice rendered

    `has_errors` counted the error notices queued in the session, while
    `result` reported what `wc_print_notices()` rendered. The two disagree
    when a filter on `woocommerce_notice_types` keeps errors off the page.
    Funnel Builder does exactly that on AJAX requests other than
    `wc_ajax_checkout`, which includes this one.

    The response then said `has_errors: true` with empty `messages`, and
    the checkout script marked every required field invalid with nothing
    on the page to explain why. Trunk ignored the same response.

    The flag now requires both a queued error and rendered output, which
    is what the legacy `result` already keyed on. The test gains a case
    that mirrors the filter.

    Refs #40443

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * test(ajax): Cover the notice-free checkout update

    Every existing case either queued a notice or reached `success` through
    the reload short-circuit, which empties the messages regardless of the
    queue. The ordinary request with nothing queued, the most common call
    this handler serves, never exercised the real `empty( $messages )`
    branch.

    Refs #40443

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * test(ajax): Match the provider naming used in the file

    The other data providers in this test class end in `_provider`. Rename
    `provide_update_order_review_notice_cases` to follow suit.

    Refs #40443

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * test(ajax): Drop hook cleanup the test framework already performs

    `WP_UnitTestCase` backs up all hooks in `set_up()` and restores them in
    `tear_down()`, so removing the update-order-review callback in
    `finally` was redundant. The block stays for the session, cart,
    customer, and `$_POST` state the framework does not reset.

    Refs #40443

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * fix(checkout): Trust the error flag only when it has a notice to show

    A callback that answers `update_order_review` before Core can report
    `has_errors: true` without carrying the notice for it. Acting on the
    flag alone cleared every notice, marked every required field invalid,
    and scrolled, with nothing on the page to explain why. Trunk ignored
    that response because it only read `result`.

    The flag is now trusted when it is a boolean and, if it reports an
    error, `messages` is non-empty. Otherwise the handler falls back to
    `result`, the same path a missing or non-boolean flag takes, so every
    legacy response shape behaves exactly as on trunk.

    Refs #40443

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * test(checkout): Assert the shipping method regains focus after an update

    The only focus test covered the null path: the input is gone, nothing
    throws, `updated_checkout` still fires. Deleting the `focus()` call
    kept the suite green. The focus restore is the one intentional
    behavior change in this branch, so it needs a test with the input
    present.

    Refs #40443

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/fix-40443-checkout-notice-validation b/plugins/woocommerce/changelog/fix-40443-checkout-notice-validation
new file mode 100644
index 00000000000..4c6030ea3e3
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-40443-checkout-notice-validation
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent non-error checkout update notices from triggering field validation.
diff --git a/plugins/woocommerce/client/legacy/js/frontend/checkout.js b/plugins/woocommerce/client/legacy/js/frontend/checkout.js
index 81911b423e4..d2410a532e3 100644
--- a/plugins/woocommerce/client/legacy/js/frontend/checkout.js
+++ b/plugins/woocommerce/client/legacy/js/frontend/checkout.js
@@ -838,26 +838,55 @@ jQuery( function ( $ ) {
 						} );
 					}

-					// Check for error
-					if ( data && 'failure' === data.result ) {
-						var $form = $( 'form.checkout' );
-
-						// Remove notices from all sources
+					var $form = $( 'form.checkout' );
+
+					// `result` only reports whether the response carries a notice, so a success
+					// or info notice reads as `failure` there. Prefer the explicit error flag
+					// when it is the boolean this endpoint documents and, if it reports an
+					// error, the response carries the notice to show for it. Otherwise fall
+					// back to `result`, such as for a third-party callback that answers before
+					// Core does.
+					var trustsErrorFlag =
+						!! data &&
+						'boolean' === typeof data.has_errors &&
+						( ! data.has_errors || !! data.messages );
+					var hasErrors = trustsErrorFlag
+						? data.has_errors
+						: !! data && 'failure' === data.result;
+
+					// Whether this response has a notice to render. `result` is the legacy
+					// signal for that and third-party callbacks still set it, so keep honoring
+					// it alongside the error flag.
+					var rendersNotices =
+						!! data &&
+						!! data.messages &&
+						( hasErrors || 'failure' === data.result );
+
+					if ( hasErrors ) {
+						// Remove notices from all sources before rendering an error.
 						$(
 							'.woocommerce-error, .woocommerce-message, .is-error, .is-success'
 						).remove();
+					} else if ( rendersNotices ) {
+						// A non-error notice supersedes a failed place order, but leaves every
+						// other notice on the page alone.
+						$( '.woocommerce-NoticeGroup-checkout' ).remove();
+					}

-						// Add new errors returned by this event
-						if ( data.messages ) {
-							$form.prepend(
-								'<div class="woocommerce-NoticeGroup woocommerce-NoticeGroup-updateOrderReview">' +
-									data.messages +
-									'</div>'
-							); // eslint-disable-line max-len
-						} else {
-							$form.prepend( data );
-						}
+					// Add notices returned by this event.
+					if ( rendersNotices ) {
+						$form.prepend(
+							'<div class="woocommerce-NoticeGroup woocommerce-NoticeGroup-updateOrderReview">' +
+								data.messages +
+								'</div>'
+						); // eslint-disable-line max-len
+					} else if ( data && 'failure' === data.result ) {
+						// A response that reports a notice without carrying one: render it as-is.
+						$form.prepend( data );
+					}

+					// Check for error.
+					if ( hasErrors ) {
 						// Lose focus for all fields
 						$form
 							.find( '.input-text, select, input:checkbox' )
@@ -873,14 +902,19 @@ jQuery( function ( $ ) {
 					// If there is no errors and the checkout update was triggered by changing the shipping method, focus its radio input.
 					if (
 						data &&
-						'success' === data.result &&
+						! hasErrors &&
 						args.current_target &&
 						args.current_target.id.indexOf( 'shipping_method' ) !==
 							-1
 					) {
-						document
-							.getElementById( args.current_target.id )
-							.focus();
+						// The refreshed fragment may no longer offer the method that was selected.
+						var shippingInput = document.getElementById(
+							args.current_target.id
+						);
+
+						if ( shippingInput ) {
+							shippingInput.focus();
+						}
 					}

 					// Fire updated_checkout event.
diff --git a/plugins/woocommerce/client/legacy/js/frontend/test/checkout-place-order-api.js b/plugins/woocommerce/client/legacy/js/frontend/test/checkout-place-order-api.js
index 399948a463a..16b15195746 100644
--- a/plugins/woocommerce/client/legacy/js/frontend/test/checkout-place-order-api.js
+++ b/plugins/woocommerce/client/legacy/js/frontend/test/checkout-place-order-api.js
@@ -8,11 +8,15 @@ const COUPON_CODE = "SAVE'10";
 const BILLING_EMAIL = "o'brien@example.com";

 describe( 'createCheckoutPlaceOrderApi', () => {
-	let $form;
+	let $allNotices;
+	let $checkoutFields;
 	let $couponForm;
+	let $form;
 	let $removeCouponLink;
 	let $termsCheckbox;
 	let $termsRow;
+	let $updateOrderReviewNotices;
+	let $checkoutNotices;
 	let capturedApi;
 	let capturedAjaxRequests;
 	let jQueryMock;
@@ -81,6 +85,19 @@ describe( 'createCheckoutPlaceOrderApi', () => {
 			termsChecked = checked;
 		};

+		$checkoutFields = {
+			trigger: jest.fn( () => $checkoutFields ),
+		};
+		$allNotices = {
+			remove: jest.fn(),
+		};
+		$updateOrderReviewNotices = {
+			remove: jest.fn(),
+		};
+		$checkoutNotices = {
+			remove: jest.fn(),
+		};
+
 		$form = {
 			addClass: jest.fn( () => $form ),
 			block: jest.fn( () => $form ),
@@ -92,7 +109,7 @@ describe( 'createCheckoutPlaceOrderApi', () => {
 					return $termsCheckbox;
 				}
 				if ( selector === '.input-text, select, input:checkbox' ) {
-					return { trigger: jest.fn() };
+					return $checkoutFields;
 				}
 				if ( selector === '.woocommerce-invalid:visible' ) {
 					// Visible invalid fields only (e.g. the terms row). Hidden
@@ -130,6 +147,7 @@ describe( 'createCheckoutPlaceOrderApi', () => {
 				}
 				return { length: 0, trigger: jest.fn() };
 			} ),
+			prepend: jest.fn(),
 			serialize: jest.fn( () => serializedCheckoutData ),
 			trigger: jest.fn(),
 			triggerHandler: jest.fn( () => true ),
@@ -289,6 +307,23 @@ describe( 'createCheckoutPlaceOrderApi', () => {
 			if ( selectorOrCallback === $form ) {
 				return $form;
 			}
+			if (
+				selectorOrCallback ===
+				'.woocommerce-error, .woocommerce-message, .is-error, .is-success'
+			) {
+				return $allNotices;
+			}
+			if (
+				selectorOrCallback ===
+				'.woocommerce-NoticeGroup-updateOrderReview'
+			) {
+				return $updateOrderReviewNotices;
+			}
+			if (
+				selectorOrCallback === '.woocommerce-NoticeGroup-checkout'
+			) {
+				return $checkoutNotices;
+			}
 			if (
 				selectorOrCallback === 'form.checkout_coupon' ||
 				selectorOrCallback === $couponForm
@@ -348,6 +383,10 @@ describe( 'createCheckoutPlaceOrderApi', () => {
 			return parts.join( '&' ).split( '%20' ).join( '+' );
 		} );
 		jQueryMock.ajaxSetup = jest.fn();
+		jQueryMock.isEmptyObject = jest.fn( ( value ) => {
+			return Object.keys( value ).length === 0;
+		} );
+		jQueryMock.scroll_to_notices = jest.fn();

 		global.window.jQuery = jQueryMock;
 		global.window.$ = jQueryMock;
@@ -574,4 +613,219 @@ describe( 'createCheckoutPlaceOrderApi', () => {
 			);
 		} );
 	} );
+	// update_order_review keeps `result` as the legacy "a notice was rendered" signal, so
+	// notices are rendered for every result and only `has_errors` clears the existing ones,
+	// revalidates the form fields, and scrolls. Responses without the flag fall back to `result`.
+	describe( 'Checkout update notices', () => {
+		beforeEach( () => {
+			jest.useFakeTimers();
+		} );
+
+		afterEach( () => {
+			jest.clearAllTimers();
+			jest.useRealTimers();
+		} );
+
+		const sendCheckoutUpdateResponse = ( response, args ) => {
+			mockBody.trigger( 'update_checkout', [
+				args || { update_shipping_method: false },
+			] );
+			jest.runOnlyPendingTimers();
+
+			const request = capturedAjaxRequests.find( ( options ) =>
+				options.url.includes( 'update_order_review' )
+			);
+			expect( request ).toBeDefined();
+
+			request.success( response );
+		};
+
+		test( 'should render successful notices without validating checkout fields', () => {
+			sendCheckoutUpdateResponse( {
+				result: 'failure',
+				has_errors: false,
+				messages:
+					'<div class="woocommerce-message">Coupon applied.</div>',
+			} );
+
+			expect( $form.prepend ).toHaveBeenCalledWith(
+				expect.stringContaining(
+					'woocommerce-NoticeGroup-updateOrderReview'
+				)
+			);
+			expect( $form.prepend ).toHaveBeenCalledWith(
+				expect.stringContaining( 'Coupon applied.' )
+			);
+			expect( $updateOrderReviewNotices.remove ).toHaveBeenCalledTimes( 1 );
+			expect( $allNotices.remove ).not.toHaveBeenCalled();
+			expect( $checkoutFields.trigger ).not.toHaveBeenCalled();
+			expect( jQueryMock.scroll_to_notices ).not.toHaveBeenCalled();
+		} );
+
+		test( 'should preserve failure notice replacement and field validation', () => {
+			sendCheckoutUpdateResponse( {
+				result: 'failure',
+				has_errors: true,
+				messages:
+					'<ul class="woocommerce-error"><li>Invalid address.</li></ul>',
+			} );
+
+			expect( $form.prepend ).toHaveBeenCalledWith(
+				expect.stringContaining( 'Invalid address.' )
+			);
+			expect( $allNotices.remove ).toHaveBeenCalledTimes( 1 );
+			expect( $checkoutFields.trigger ).toHaveBeenNthCalledWith(
+				1,
+				'validate'
+			);
+			expect( $checkoutFields.trigger ).toHaveBeenNthCalledWith(
+				2,
+				'blur'
+			);
+			expect( jQueryMock.scroll_to_notices ).toHaveBeenCalledTimes( 1 );
+		} );
+
+		test( 'should leave notices and fields unchanged for message-free success', () => {
+			sendCheckoutUpdateResponse( {
+				result: 'success',
+				has_errors: false,
+				messages: '',
+			} );
+
+			expect( $form.prepend ).not.toHaveBeenCalled();
+			expect( $allNotices.remove ).not.toHaveBeenCalled();
+			expect( $checkoutFields.trigger ).not.toHaveBeenCalled();
+			expect( jQueryMock.scroll_to_notices ).not.toHaveBeenCalled();
+		} );
+
+		test( 'should ignore a non-boolean error flag and use the result', () => {
+			sendCheckoutUpdateResponse( {
+				result: 'success',
+				has_errors: 'false',
+				messages: '',
+			} );
+
+			expect( $allNotices.remove ).not.toHaveBeenCalled();
+			expect( $checkoutFields.trigger ).not.toHaveBeenCalled();
+			expect( jQueryMock.scroll_to_notices ).not.toHaveBeenCalled();
+		} );
+
+		test( 'should keep firing updated_checkout when the shipping method is gone', () => {
+			// The refreshed fragment can drop the method that triggered the update, so
+			// the element the focus restore points at is no longer in the document.
+			expect(
+				document.getElementById( 'shipping_method_0_flat_rate1' )
+			).toBeNull();
+
+			expect( () =>
+				sendCheckoutUpdateResponse(
+					{
+						result: 'success',
+						has_errors: false,
+						messages: '',
+					},
+					{
+						update_shipping_method: false,
+						current_target: {
+							id: 'shipping_method_0_flat_rate1',
+						},
+					}
+				)
+			).not.toThrow();
+
+			expect( mockBody.trigger ).toHaveBeenCalledWith(
+				'updated_checkout',
+				expect.anything()
+			);
+		} );
+
+		test( 'should clear a stale place-order notice when rendering a non-error one', () => {
+			// A failed place order leaves `.woocommerce-NoticeGroup-checkout` on the page.
+			// The next non-error notice supersedes it, but must leave every other notice.
+			sendCheckoutUpdateResponse( {
+				result: 'failure',
+				has_errors: false,
+				messages:
+					'<div class="woocommerce-message">Coupon applied.</div>',
+			} );
+
+			expect( $checkoutNotices.remove ).toHaveBeenCalledTimes( 1 );
+			expect( $allNotices.remove ).not.toHaveBeenCalled();
+			expect( $form.prepend ).toHaveBeenCalledWith(
+				expect.stringContaining( 'Coupon applied.' )
+			);
+		} );
+
+		test( 'should ignore messages on a response that reports no notice', () => {
+			// A third-party callback can answer this endpoint before Core does. Trunk
+			// rendered nothing for a `success` result, so neither do we.
+			sendCheckoutUpdateResponse( {
+				result: 'success',
+				messages:
+					'<div class="woocommerce-message">Third-party notice.</div>',
+			} );
+
+			expect( $form.prepend ).not.toHaveBeenCalled();
+			expect( $checkoutNotices.remove ).not.toHaveBeenCalled();
+			expect( $allNotices.remove ).not.toHaveBeenCalled();
+			expect( $checkoutFields.trigger ).not.toHaveBeenCalled();
+			expect( jQueryMock.scroll_to_notices ).not.toHaveBeenCalled();
+		} );
+
+		test( 'should fall back to the result when the error flag has nothing to show', () => {
+			// A callback that answers before Core can report an error without carrying the
+			// notice for it. Trunk ignored that response, so acting on the flag alone would
+			// mark every required field invalid with nothing on the page to explain why.
+			sendCheckoutUpdateResponse( {
+				result: 'success',
+				has_errors: true,
+				messages: '',
+			} );
+
+			expect( $form.prepend ).not.toHaveBeenCalled();
+			expect( $allNotices.remove ).not.toHaveBeenCalled();
+			expect( $checkoutFields.trigger ).not.toHaveBeenCalled();
+			expect( jQueryMock.scroll_to_notices ).not.toHaveBeenCalled();
+		} );
+
+		test( 'should focus the shipping method that triggered a notice-free update', () => {
+			const shippingInput = document.createElement( 'input' );
+			shippingInput.id = 'shipping_method_0_flat_rate1';
+			document.body.appendChild( shippingInput );
+			const focus = jest.spyOn( shippingInput, 'focus' );
+
+			try {
+				sendCheckoutUpdateResponse(
+					{
+						result: 'success',
+						has_errors: false,
+						messages: '',
+					},
+					{
+						update_shipping_method: false,
+						current_target: { id: shippingInput.id },
+					}
+				);
+
+				expect( focus ).toHaveBeenCalledTimes( 1 );
+			} finally {
+				shippingInput.remove();
+			}
+		} );
+
+		test( 'should treat a response without the error flag as a failure', () => {
+			sendCheckoutUpdateResponse( {
+				result: 'failure',
+				messages:
+					'<ul class="woocommerce-error"><li>Invalid address.</li></ul>',
+			} );
+
+			expect( $allNotices.remove ).toHaveBeenCalledTimes( 1 );
+			expect( $checkoutFields.trigger ).toHaveBeenNthCalledWith(
+				1,
+				'validate'
+			);
+			expect( jQueryMock.scroll_to_notices ).toHaveBeenCalledTimes( 1 );
+		} );
+	} );
 } );
diff --git a/plugins/woocommerce/includes/class-wc-ajax.php b/plugins/woocommerce/includes/class-wc-ajax.php
index b8dd43a7091..e655f812cc9 100644
--- a/plugins/woocommerce/includes/class-wc-ajax.php
+++ b/plugins/woocommerce/includes/class-wc-ajax.php
@@ -481,25 +481,48 @@ class WC_AJAX {
 		// Get messages if reload checkout is not true.
 		$reload_checkout = isset( WC()->session->reload_checkout );
 		if ( ! $reload_checkout ) {
-			$messages = wc_print_notices( true );
+			// Capture the error count before printing, because wc_print_notices() clears the queue.
+			// A filter on `woocommerce_notice_types` can keep queued errors off the page, so the
+			// flag only reports errors that were rendered.
+			$error_notice_count = wc_notice_count( 'error' );
+			$messages           = wc_print_notices( true );
+			$has_error_notices  = 0 < $error_notice_count && '' !== $messages;
 		} else {
-			$messages = '';
+			$has_error_notices = false;
+			$messages          = '';
 		}

 		unset( WC()->session->refresh_totals, WC()->session->reload_checkout );

+		/**
+		 * Filter the HTML fragments returned with a checkout update, keyed by the selector each one replaces.
+		 *
+		 * @since 2.3.0
+		 *
+		 * @param array $fragments Checkout fragments keyed by selector.
+		 */
+		$fragments = apply_filters(
+			'woocommerce_update_order_review_fragments',
+			array(
+				'.woocommerce-checkout-review-order-table' => $woocommerce_order_review,
+				'.woocommerce-checkout-payment'            => $woocommerce_checkout_payment,
+			)
+		);
+
+		/*
+		 * `result` is the legacy signal and only reports whether the response carries any rendered
+		 * notice, so a success or info notice still reads as `failure`. Third-party checkout scripts
+		 * and `updated_checkout` listeners have consumed it that way since 2014, so it keeps that
+		 * meaning. Use `has_errors` to tell a real failure apart from a notice that merely has
+		 * something to show.
+		 */
 		wp_send_json(
 			array(
-				'result'    => empty( $messages ) ? 'success' : 'failure',
-				'messages'  => $messages,
-				'reload'    => $reload_checkout,
-				'fragments' => apply_filters(
-					'woocommerce_update_order_review_fragments',
-					array(
-						'.woocommerce-checkout-review-order-table' => $woocommerce_order_review,
-						'.woocommerce-checkout-payment' => $woocommerce_checkout_payment,
-					)
-				),
+				'result'     => empty( $messages ) ? 'success' : 'failure',
+				'has_errors' => $has_error_notices,
+				'messages'   => $messages,
+				'reload'     => $reload_checkout,
+				'fragments'  => $fragments,
 			)
 		);
 	}
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php b/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
index c1e463f7b9b..c808e2bac11 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
@@ -1797,6 +1797,195 @@ class WC_AJAX_Test extends \WP_Ajax_UnitTestCase {
 		);
 	}

+	/**
+	 * @testdox Update order review reports errors separately from the legacy result and preserves reload behavior.
+	 * @dataProvider update_order_review_notice_cases_provider
+	 *
+	 * @param array[] $notices                Notices to add during the checkout update.
+	 * @param string  $expected_result        Expected legacy AJAX result, which only reports whether a notice was rendered.
+	 * @param bool    $expected_has_errors    Expected error flag.
+	 * @param bool    $reload_checkout        Whether the callback requests a checkout reload.
+	 * @param bool    $suppress_notice_output Whether a filter empties `woocommerce_notice_types`, the way Funnel Builder does on AJAX requests.
+	 */
+	public function test_update_order_review_classifies_notices( array $notices, string $expected_result, bool $expected_has_errors, bool $reload_checkout, bool $suppress_notice_output = false ): void {
+		$product            = null;
+		$original_post      = $_POST; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Restored after the AJAX fixture.
+		$original_customer  = clone WC()->customer;
+		$session_keys       = array( 'chosen_shipping_methods', 'chosen_payment_method', 'reload_checkout', 'refresh_totals', 'customer' );
+		$original_session   = array();
+		$captured_post_data = null;
+		$post_data          = 'payment_method=test-gateway';
+
+		foreach ( $session_keys as $session_key ) {
+			$original_session[ $session_key ] = array(
+				'exists' => isset( WC()->session->{$session_key} ),
+				'value'  => WC()->session->get( $session_key ),
+			);
+		}
+
+		try {
+			wc_clear_notices();
+			unset( WC()->session->reload_checkout, WC()->session->refresh_totals );
+			WC()->cart->empty_cart();
+
+			$product       = WC_Helper_Product::create_simple_product();
+			$cart_item_key = WC()->cart->add_to_cart( $product->get_id(), 1 );
+			$this->assertNotFalse( $cart_item_key, 'The checkout update fixture product should be added to the cart.' );
+
+			$callback = static function ( $received_post_data ) use ( $notices, $reload_checkout, &$captured_post_data ) {
+				$captured_post_data = $received_post_data;
+				foreach ( $notices as $notice ) {
+					wc_add_notice( $notice['message'], $notice['type'] );
+				}
+				if ( $reload_checkout ) {
+					WC()->session->set( 'reload_checkout', true );
+				}
+			};
+			add_action( 'woocommerce_checkout_update_order_review', $callback, 10, 1 );
+
+			if ( $suppress_notice_output ) {
+				add_filter( 'woocommerce_notice_types', '__return_empty_array' );
+			}
+
+			$_POST = array(
+				'security'  => wp_create_nonce( 'update-order-review' ),
+				'post_data' => $post_data,
+			);
+
+			$response = $this->do_ajax( 'woocommerce_update_order_review' );
+
+			$this->assertIsArray( $response, 'The checkout update should return a JSON array.' );
+			$this->assertSame( $post_data, $captured_post_data, 'The public update hook should receive the exact posted checkout data.' );
+			$this->assertSame( $expected_result, $response['result'], 'The legacy result should keep reporting whether any notice was rendered.' );
+			$this->assertSame( $expected_has_errors, $response['has_errors'], 'Only a response containing an error notice should report errors.' );
+			$this->assertSame( $reload_checkout, $response['reload'], 'The response should preserve the requested reload state.' );
+			$this->assertArrayHasKey( '.woocommerce-checkout-review-order-table', $response['fragments'], 'The order review fragment should remain present.' );
+			$this->assertArrayHasKey( '.woocommerce-checkout-payment', $response['fragments'], 'The checkout payment fragment should remain present.' );
+
+			if ( $reload_checkout || $suppress_notice_output || empty( $notices ) ) {
+				$this->assertSame( '', $response['messages'], 'The response should carry no rendered notices.' );
+			} else {
+				foreach ( $notices as $notice ) {
+					$this->assertStringContainsString( $notice['message'], $response['messages'], 'The response should retain each rendered notice message.' );
+					$this->assertStringContainsString( $notice['class'], $response['messages'], 'The response should retain each rendered notice type.' );
+				}
+			}
+		} finally {
+			wc_clear_notices();
+			WC()->cart->empty_cart();
+			if ( $product instanceof WC_Product ) {
+				$product->delete( true );
+			}
+			WC()->customer = $original_customer;
+			foreach ( $original_session as $session_key => $session_state ) {
+				if ( $session_state['exists'] ) {
+					WC()->session->set( $session_key, $session_state['value'] );
+				} else {
+					unset( WC()->session->{$session_key} );
+				}
+			}
+			$_POST = $original_post;
+		}
+	}
+
+	/**
+	 * Data provider for update order review notice classification.
+	 *
+	 * The legacy result stays `failure` whenever a notice was rendered, whatever its type, so only
+	 * the error flag tells a real failure apart from a success or info notice. Both report what
+	 * rendered, so a queued error that a filter keeps off the page counts for neither.
+	 *
+	 * @return array[]
+	 */
+	public static function update_order_review_notice_cases_provider(): array {
+		return array(
+			'no notices'                     => array(
+				array(),
+				'success',
+				false,
+				false,
+			),
+			'success notice'                 => array(
+				array(
+					array(
+						'type'    => 'success',
+						'message' => 'Coupon applied.',
+						'class'   => 'woocommerce-message',
+					),
+				),
+				'failure',
+				false,
+				false,
+			),
+			'neutral notice'                 => array(
+				array(
+					array(
+						'type'    => 'notice',
+						'message' => 'Address details updated.',
+						'class'   => 'woocommerce-info',
+					),
+				),
+				'failure',
+				false,
+				false,
+			),
+			'error notice'                   => array(
+				array(
+					array(
+						'type'    => 'error',
+						'message' => 'A checkout error occurred.',
+						'class'   => 'woocommerce-error',
+					),
+				),
+				'failure',
+				true,
+				false,
+			),
+			'mixed notices with an error'    => array(
+				array(
+					array(
+						'type'    => 'success',
+						'message' => 'Coupon applied.',
+						'class'   => 'woocommerce-message',
+					),
+					array(
+						'type'    => 'error',
+						'message' => 'A checkout error occurred.',
+						'class'   => 'woocommerce-error',
+					),
+				),
+				'failure',
+				true,
+				false,
+			),
+			'error notice with reload'       => array(
+				array(
+					array(
+						'type'    => 'error',
+						'message' => 'Payment method configuration changed.',
+						'class'   => 'woocommerce-error',
+					),
+				),
+				'success',
+				false,
+				true,
+			),
+			'error notice kept off the page' => array(
+				array(
+					array(
+						'type'    => 'error',
+						'message' => 'A checkout error occurred.',
+						'class'   => 'woocommerce-error',
+					),
+				),
+				'success',
+				false,
+				false,
+				true,
+			),
+		);
+	}
+
 	/**
 	 * Data provider for test_product_ordering.
 	 *