Commit 943172b85cf for woocommerce
commit 943172b85cf259142b679798e5197554a53ede0d
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Fri Sep 25 21:36:09 2026 +0300
Fix checkout marking fields invalid when error notices are hidden (#69109)
* fix(checkout): report has_errors only when error notices render
#68360 added a has_errors flag to the update_order_review response so
checkout validates fields only after a real error. It counted queued
error notices and required some output, but wc_print_notices() renders
only the types left by the woocommerce_notice_types filter. A filter
that drops the error type while keeping success or info notices made
has_errors true for a page that showed no error, so checkout cleared
the other notices and marked fields invalid.
Record the notice types the filter settles on while the notices print,
and report an error only when the error type was among them. The
filter still runs once, as before.
Refs WOOAIRR-305
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(checkout): keep has_errors for non-array notice type filters
wc_print_notices() only iterates the woocommerce_notice_types value,
so a callback returning an ArrayIterator that includes 'error' still
prints the error notices. The is_array() guard added in the previous
commit reported has_errors false in that case, where trunk reported
true.
The recorded value can't be inspected after the foreach consumes it
(a generator is already exhausted, and in_array() rejects non-arrays),
so fall back to trusting the rendered output for non-array values, as
trunk did. Add a data provider case that returns the types as an
ArrayIterator; it fails without this change.
Refs WOOAIRR-305
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/fix-wooairr-305-has-errors-rendered-types b/plugins/woocommerce/changelog/fix-wooairr-305-has-errors-rendered-types
new file mode 100644
index 00000000000..2308f73e55b
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-wooairr-305-has-errors-rendered-types
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep checkout from marking fields invalid when an extension hides error notices but still shows other notices.
diff --git a/plugins/woocommerce/includes/class-wc-ajax.php b/plugins/woocommerce/includes/class-wc-ajax.php
index c465c9e0c45..9f7bcb8d135 100644
--- a/plugins/woocommerce/includes/class-wc-ajax.php
+++ b/plugins/woocommerce/includes/class-wc-ajax.php
@@ -483,10 +483,19 @@ class WC_AJAX {
if ( ! $reload_checkout ) {
// 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.
+ // flag only reports errors that were rendered. Record the types that filter settles on
+ // rather than applying it a second time; a non-array iterable can't be inspected after
+ // wc_print_notices() consumes it, so it falls back to trusting the rendered output.
$error_notice_count = wc_notice_count( 'error' );
- $messages = wc_print_notices( true );
- $has_error_notices = 0 < $error_notice_count && '' !== $messages;
+ $rendered_types = array();
+ $record_types = static function ( $types ) use ( &$rendered_types ) {
+ $rendered_types = $types;
+ return $types;
+ };
+ add_filter( 'woocommerce_notice_types', $record_types, PHP_INT_MAX );
+ $messages = wc_print_notices( true );
+ remove_filter( 'woocommerce_notice_types', $record_types, PHP_INT_MAX );
+ $has_error_notices = 0 < $error_notice_count && ( ! is_array( $rendered_types ) || in_array( 'error', $rendered_types, true ) ) && '' !== $messages;
} else {
$has_error_notices = false;
$messages = '';
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 adf9e943429..a731f820d8b 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
@@ -1867,13 +1867,14 @@ 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.
+ * @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 string[]|null $rendered_types Notice types a `woocommerce_notice_types` filter keeps, or null for no filter. An empty list suppresses every notice, the way Funnel Builder does on AJAX requests.
+ * @param bool $as_iterator Whether the filter returns the types as an `ArrayIterator` instead of an array.
*/
- 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 {
+ public function test_update_order_review_classifies_notices( array $notices, string $expected_result, bool $expected_has_errors, bool $reload_checkout, ?array $rendered_types = null, bool $as_iterator = false ): void {
$product = null;
$original_post = $_POST; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Restored after the AJAX fixture.
$original_customer = clone WC()->customer;
@@ -1909,8 +1910,13 @@ class WC_AJAX_Test extends \WP_Ajax_UnitTestCase {
};
add_action( 'woocommerce_checkout_update_order_review', $callback, 10, 1 );
- if ( $suppress_notice_output ) {
- add_filter( 'woocommerce_notice_types', '__return_empty_array' );
+ if ( null !== $rendered_types ) {
+ add_filter(
+ 'woocommerce_notice_types',
+ static function () use ( $rendered_types, $as_iterator ) {
+ return $as_iterator ? new ArrayIterator( $rendered_types ) : $rendered_types;
+ }
+ );
}
$_POST = array(
@@ -1928,10 +1934,14 @@ class WC_AJAX_Test extends \WP_Ajax_UnitTestCase {
$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 ) ) {
+ if ( $reload_checkout || array() === $rendered_types || empty( $notices ) ) {
$this->assertSame( '', $response['messages'], 'The response should carry no rendered notices.' );
} else {
foreach ( $notices as $notice ) {
+ if ( null !== $rendered_types && ! in_array( $notice['type'], $rendered_types, true ) ) {
+ $this->assertStringNotContainsString( $notice['message'], $response['messages'], 'The response should leave out notice types the filter removed.' );
+ continue;
+ }
$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.' );
}
@@ -1959,7 +1969,8 @@ class WC_AJAX_Test extends \WP_Ajax_UnitTestCase {
*
* 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.
+ * rendered, so a queued error that a filter keeps off the page counts for neither, even when
+ * the filter still lets other notice types through.
*
* @return array[]
*/
@@ -2047,6 +2058,38 @@ class WC_AJAX_Test extends \WP_Ajax_UnitTestCase {
'success',
false,
false,
+ array(),
+ ),
+ 'error notice kept off the page beside a success notice' => array(
+ array(
+ array(
+ 'type' => 'success',
+ 'message' => 'Coupon applied.',
+ 'class' => 'woocommerce-message',
+ ),
+ array(
+ 'type' => 'error',
+ 'message' => 'A checkout error occurred.',
+ 'class' => 'woocommerce-error',
+ ),
+ ),
+ 'failure',
+ false,
+ false,
+ array( 'success', 'notice' ),
+ ),
+ 'error notice through a filter returning an iterator' => array(
+ array(
+ array(
+ 'type' => 'error',
+ 'message' => 'A checkout error occurred.',
+ 'class' => 'woocommerce-error',
+ ),
+ ),
+ 'failure',
+ true,
+ false,
+ array( 'error', 'success', 'notice' ),
true,
),
);