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;
+ }
+}