Commit 8a37ea3cca0 for woocommerce

commit 8a37ea3cca0ec638173af66d27a46c882bf805a0
Author: Peter Petrov <peter.petrov89@gmail.com>
Date:   Thu Jul 16 09:41:14 2026 +0300

    Make admin notices dismissable without reloading the page (#66625)

    * Add AJAX dismissal for admin notices

    * Add changelog entry for async admin notice dismissal

diff --git a/plugins/woocommerce/changelog/38629-async-admin-notice-dismiss b/plugins/woocommerce/changelog/38629-async-admin-notice-dismiss
new file mode 100644
index 00000000000..e13ba9dff8c
--- /dev/null
+++ b/plugins/woocommerce/changelog/38629-async-admin-notice-dismiss
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Dismiss WooCommerce admin notices asynchronously instead of reloading the page. The query-string based dismissal is kept as a no-JS fallback.
diff --git a/plugins/woocommerce/client/legacy/js/admin/wc-admin-notices.js b/plugins/woocommerce/client/legacy/js/admin/wc-admin-notices.js
new file mode 100644
index 00000000000..42520f6b02e
--- /dev/null
+++ b/plugins/woocommerce/client/legacy/js/admin/wc-admin-notices.js
@@ -0,0 +1,69 @@
+( function () {
+	'use strict';
+
+	/**
+	 * Dismiss WooCommerce admin notices asynchronously.
+	 *
+	 * Notice dismiss links point at the current page with a `wc-hide-notice`
+	 * query arg, which persists the dismissal via a full page reload. Here we
+	 * intercept the click, persist the dismissal over admin-ajax instead, and
+	 * remove the notice in place. The link href is kept intact as a no-JS
+	 * fallback, and on any request failure we fall back to following it.
+	 */
+	document.addEventListener( 'click', function ( event ) {
+		var link = event.target.closest(
+			'a.woocommerce-message-close[href*="wc-hide-notice"]'
+		);
+
+		if ( ! link || typeof window.ajaxurl === 'undefined' ) {
+			return;
+		}
+
+		var url;
+		try {
+			url = new URL( link.href );
+		} catch ( e ) {
+			return;
+		}
+
+		var noticeName = url.searchParams.get( 'wc-hide-notice' );
+		var nonce = url.searchParams.get( '_wc_notice_nonce' );
+
+		if ( ! noticeName || ! nonce ) {
+			return;
+		}
+
+		event.preventDefault();
+
+		var notice = link.closest( '.notice, .woocommerce-message, #message' );
+
+		window
+			.fetch( window.ajaxurl, {
+				method: 'POST',
+				credentials: 'same-origin',
+				body: new URLSearchParams( {
+					action: 'woocommerce_hide_notice',
+					'wc-hide-notice': noticeName,
+					_wc_notice_nonce: nonce,
+				} ),
+			} )
+			.then( function ( response ) {
+				if ( ! response.ok ) {
+					throw new Error( 'Failed to dismiss notice' );
+				}
+
+				if ( notice ) {
+					notice.remove();
+				}
+
+				document.dispatchEvent(
+					new CustomEvent( 'wc-admin-notice-dismissed', {
+						detail: { notice: noticeName },
+					} )
+				);
+			} )
+			.catch( function () {
+				window.location.href = link.href;
+			} );
+	} );
+} )();
diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-notices.php b/plugins/woocommerce/includes/admin/class-wc-admin-notices.php
index 5639414df4a..a0dfec38561 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-notices.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-notices.php
@@ -68,6 +68,7 @@ class WC_Admin_Notices {
 		add_action( 'switch_theme', array( __CLASS__, 'reset_admin_notices' ) );
 		add_action( 'woocommerce_installed', array( __CLASS__, 'reset_admin_notices' ) );
 		add_action( 'admin_init', array( __CLASS__, 'hide_notices' ), 20 );
+		add_action( 'wp_ajax_woocommerce_hide_notice', array( __CLASS__, 'ajax_hide_notice' ) );

 		// @TODO: This prevents Action Scheduler async jobs from storing empty list of notices during WC installation.
 		// That could lead to OBW not starting and 'Run setup wizard' notice not appearing in WP admin, which we want
@@ -265,6 +266,43 @@ class WC_Admin_Notices {
 		}
 	}

+	/**
+	 * AJAX handler to hide a notice without a page reload.
+	 *
+	 * Accepts the same nonce as the query-string based dismissal in hide_notices(),
+	 * which is kept as a no-JS fallback.
+	 *
+	 * @since 11.1.0
+	 * @return void
+	 */
+	public static function ajax_hide_notice() {
+		check_ajax_referer( 'woocommerce_hide_notices_nonce', '_wc_notice_nonce' );
+
+		$notice_name = isset( $_POST['wc-hide-notice'] ) ? sanitize_text_field( wp_unslash( $_POST['wc-hide-notice'] ) ) : '';
+
+		if ( '' === $notice_name ) {
+			wp_send_json_error( null, 400 );
+		}
+
+		/**
+		 * This filter is documented above, in hide_notices().
+		 *
+		 * @since 6.7.0
+		 */
+		$required_capability = apply_filters( 'woocommerce_dismiss_admin_notice_capability', 'manage_woocommerce', $notice_name );
+
+		if ( ! current_user_can( $required_capability ) ) {
+			wp_send_json_error( null, 403 );
+		}
+
+		self::hide_notice( $notice_name );
+
+		// Notices are normally stored to the DB on 'shutdown'; save explicitly so the dismissal is persisted even if that hook was not registered for this request.
+		self::store_notices();
+
+		wp_send_json_success();
+	}
+
 	/**
 	 * Hide a single notice.
 	 *
@@ -323,6 +361,9 @@ class WC_Admin_Notices {
 		// Add RTL support.
 		wp_style_add_data( 'woocommerce-activation', 'rtl', 'replace' );

+		$suffix = Constants::is_true( 'SCRIPT_DEBUG' ) ? '' : '.min';
+		wp_enqueue_script( 'wc-admin-notices', plugins_url( '/assets/js/admin/wc-admin-notices' . $suffix . '.js', WC_PLUGIN_FILE ), array(), Constants::get_constant( 'WC_VERSION' ), true );
+
 		foreach ( $notices as $notice ) {
 			if ( ! empty( self::$core_notices[ $notice ] ) && apply_filters( 'woocommerce_show_admin_notice', true, $notice ) ) {
 				add_action( 'admin_notices', array( __CLASS__, self::$core_notices[ $notice ] ) );
diff --git a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-notices-test.php b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-notices-test.php
new file mode 100644
index 00000000000..47a33e0384a
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-notices-test.php
@@ -0,0 +1,136 @@
+<?php
+declare( strict_types = 1 );
+
+/**
+ * Tests for the WC_Admin_Notices class.
+ */
+class WC_Admin_Notices_Test extends WP_Ajax_UnitTestCase {
+
+	/**
+	 * Sets up the test fixture.
+	 */
+	public function set_up() {
+		parent::set_up();
+
+		include_once WC_ABSPATH . 'includes/admin/class-wc-admin-notices.php';
+
+		if ( ! has_action( 'wp_ajax_woocommerce_hide_notice', array( 'WC_Admin_Notices', 'ajax_hide_notice' ) ) ) {
+			WC_Admin_Notices::init();
+		}
+
+		WC_Admin_Notices::remove_all_notices();
+	}
+
+	/**
+	 * Tears down the test fixture.
+	 */
+	public function tear_down() {
+		WC_Admin_Notices::remove_all_notices();
+		unset( $_POST['wc-hide-notice'], $_POST['_wc_notice_nonce'] );
+		parent::tear_down();
+	}
+
+	/**
+	 * @testdox Should hide the notice and persist the dismissal when dismissed over AJAX.
+	 */
+	public function test_ajax_hide_notice_dismisses_notice(): void {
+		$this->_setRole( 'administrator' );
+		WC_Admin_Notices::add_notice( 'test_notice' );
+
+		$hide_action_fired = false;
+		$callback          = function () use ( &$hide_action_fired ) {
+			$hide_action_fired = true;
+		};
+		add_action( 'woocommerce_hide_test_notice_notice', $callback );
+
+		$_POST['wc-hide-notice']   = 'test_notice';
+		$_POST['_wc_notice_nonce'] = wp_create_nonce( 'woocommerce_hide_notices_nonce' );
+
+		$response = $this->do_ajax( 'woocommerce_hide_notice' );
+
+		remove_action( 'woocommerce_hide_test_notice_notice', $callback );
+
+		$this->assertTrue( $response['success'], 'The AJAX response should indicate success' );
+		$this->assertFalse( WC_Admin_Notices::has_notice( 'test_notice' ), 'The notice should no longer be present' );
+		$this->assertTrue( WC_Admin_Notices::user_has_dismissed_notice( 'test_notice' ), 'The dismissal should be recorded in user meta' );
+		$this->assertTrue( $hide_action_fired, 'The woocommerce_hide_{name}_notice action should have fired' );
+	}
+
+	/**
+	 * @testdox Should reject the AJAX dismissal when the nonce is invalid.
+	 */
+	public function test_ajax_hide_notice_rejects_invalid_nonce(): void {
+		$this->_setRole( 'administrator' );
+		WC_Admin_Notices::add_notice( 'test_notice' );
+
+		$_POST['wc-hide-notice']   = 'test_notice';
+		$_POST['_wc_notice_nonce'] = 'invalid-nonce';
+
+		$this->expectException( 'WPAjaxDieStopException' );
+
+		try {
+			$this->_handleAjax( 'woocommerce_hide_notice' );
+		} finally {
+			$this->assertTrue( WC_Admin_Notices::has_notice( 'test_notice' ), 'The notice should still be present' );
+		}
+	}
+
+	/**
+	 * @testdox Should reject the AJAX dismissal when the user lacks the required capability.
+	 */
+	public function test_ajax_hide_notice_rejects_unauthorized_user(): void {
+		$this->_setRole( 'subscriber' );
+		WC_Admin_Notices::add_notice( 'test_notice' );
+
+		$_POST['wc-hide-notice']   = 'test_notice';
+		$_POST['_wc_notice_nonce'] = wp_create_nonce( 'woocommerce_hide_notices_nonce' );
+
+		$response = $this->do_ajax( 'woocommerce_hide_notice' );
+
+		$this->assertFalse( $response['success'], 'The AJAX response should indicate failure' );
+		$this->assertTrue( WC_Admin_Notices::has_notice( 'test_notice' ), 'The notice should still be present' );
+		$this->assertFalse( WC_Admin_Notices::user_has_dismissed_notice( 'test_notice' ), 'No dismissal should be recorded in user meta' );
+	}
+
+	/**
+	 * @testdox Should return an error when no notice name is provided.
+	 */
+	public function test_ajax_hide_notice_requires_notice_name(): void {
+		$this->_setRole( 'administrator' );
+
+		$_POST['_wc_notice_nonce'] = wp_create_nonce( 'woocommerce_hide_notices_nonce' );
+
+		$response = $this->do_ajax( 'woocommerce_hide_notice' );
+
+		$this->assertFalse( $response['success'], 'The AJAX response should indicate failure' );
+	}
+
+	/**
+	 * Triggers an ajax endpoint and captures the JSON response.
+	 *
+	 * @param string $ajax_action The action to be triggered.
+	 *
+	 * @return array|null
+	 */
+	private function do_ajax( string $ajax_action ) {
+		$output_buffering_level = ob_get_level();
+
+		try {
+			// Note that _handleAjax makes use of output buffering, which the die
+			// handler usually cleans up; the finally block below closes only any
+			// buffer it leaves dangling so the buffer level stays balanced.
+			$this->_handleAjax( $ajax_action );
+		} catch ( Exception $e ) {
+			unset( $e );
+		} finally {
+			while ( ob_get_level() > $output_buffering_level ) {
+				ob_end_clean();
+			}
+		}
+
+		$result               = json_decode( $this->_last_response, true );
+		$this->_last_response = false;
+
+		return $result;
+	}
+}