Commit db2c48d3cdc for woocommerce

commit db2c48d3cdc04b1c98303090ee9d5d5d6dbb837c
Author: Mario Santos <34552881+SantosGuillamot@users.noreply.github.com>
Date:   Wed Jul 22 12:21:48 2026 +0200

    Fix fatal error on the widgets admin screen when a product block is in a widget area (#66641)

    * Fix admin notice-caching fatal in Hydration

    * Make test lifecycle methods public in HydrationTest

diff --git a/plugins/woocommerce/changelog/66272-fix-widgets-admin-notice-cache-fatal b/plugins/woocommerce/changelog/66272-fix-widgets-admin-notice-cache-fatal
new file mode 100644
index 00000000000..7eeb92e9786
--- /dev/null
+++ b/plugins/woocommerce/changelog/66272-fix-widgets-admin-notice-cache-fatal
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix a fatal error on `/wp-admin/widgets.php` and other admin surfaces when a WooCommerce product block is registered to render in a widget area and a WooCommerce session becomes active during the request.
diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/Hydration.php b/plugins/woocommerce/src/Blocks/Domain/Services/Hydration.php
index e4b391e2631..962d60580ac 100644
--- a/plugins/woocommerce/src/Blocks/Domain/Services/Hydration.php
+++ b/plugins/woocommerce/src/Blocks/Domain/Services/Hydration.php
@@ -18,11 +18,15 @@ class Hydration {
 	protected $asset_data_registry;

 	/**
-	 * Cached notices to restore after hydrating the API.
+	 * Snapshot of store notices taken by cache_store_notices(), to restore after hydrating the API.
 	 *
-	 * @var array
+	 * `null` means no snapshot has been taken this cycle, either because the method has not run yet or because
+	 * its guards skipped the snapshot (for example, the notice functions were unavailable). restore_cached_store_notices()
+	 * only restores from a non-null array, so a `null` value always leaves the session untouched.
+	 *
+	 * @var array|null
 	 */
-	protected $cached_store_notices = array();
+	protected $cached_store_notices = null;

 	/**
 	 * Constructor.
@@ -251,25 +255,45 @@ class Hydration {
 	}

 	/**
-	 * Cache notices before hydrating the API if the customer has a session.
+	 * Whether the store notice functions (`wc_get_notices()`, `wc_clear_notices()`, `wc_set_notices()`) are
+	 * available to call.
+	 *
+	 * These three functions share a single definition site in `includes/wc-notice-functions.php`, which
+	 * WooCommerce loads only for frontend/REST requests (or via `wc_load_cart()`). On a plain wp-admin load the
+	 * functions may therefore be absent even when a WooCommerce session exists, so this seam must be checked
+	 * independently of the session guard. It is `protected` so tests can override it to force the unavailable
+	 * branch deterministically.
+	 *
+	 * @since 11.1.0
+	 * @return bool True if the notice functions are defined and safe to call.
+	 */
+	protected function store_notice_functions_available(): bool {
+		return function_exists( 'wc_set_notices' );
+	}
+
+	/**
+	 * Cache notices before hydrating the API if the customer has a session and the notice functions are available.
 	 */
 	protected function cache_store_notices() {
-		if ( ! did_action( 'woocommerce_init' ) || null === WC()->session ) {
+		$this->cached_store_notices = null;
+
+		if ( ! did_action( 'woocommerce_init' ) || null === WC()->session || ! $this->store_notice_functions_available() ) {
 			return;
 		}
+
 		$this->cached_store_notices = wc_get_notices();
 		wc_clear_notices();
 	}

 	/**
-	 * Restore notices into current session from cache.
+	 * Restore notices into current session from cache, only if a snapshot was taken this cycle.
 	 */
 	protected function restore_cached_store_notices() {
-		if ( ! did_action( 'woocommerce_init' ) || null === WC()->session ) {
+		if ( ! did_action( 'woocommerce_init' ) || null === WC()->session || ! is_array( $this->cached_store_notices ) ) {
 			return;
 		}

 		wc_set_notices( $this->cached_store_notices );
-		$this->cached_store_notices = array();
+		$this->cached_store_notices = null;
 	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/HydrationTest.php b/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/HydrationTest.php
index 21fdefb7516..07f43d5e6d6 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/HydrationTest.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/HydrationTest.php
@@ -21,7 +21,7 @@ class HydrationTest extends \WP_UnitTestCase {
 	/**
 	 * Set up test fixtures.
 	 */
-	protected function setUp(): void {
+	public function setUp(): void {
 		parent::setUp();

 		// Initialize Store API.
@@ -30,6 +30,17 @@ class HydrationTest extends \WP_UnitTestCase {
 		$this->hydration = new Hydration( $this->createMock( AssetDataRegistry::class ) );
 	}

+	/**
+	 * Reset the store session's notice queue after each test so notices don't leak between tests.
+	 */
+	public function tearDown(): void {
+		if ( WC()->session ) {
+			WC()->session->set( 'wc_notices', null );
+		}
+
+		parent::tearDown();
+	}
+
 	/**
 	 * Test that get_rest_api_response_data handles cart endpoint and returns valid cart structure.
 	 */
@@ -198,4 +209,122 @@ class HydrationTest extends \WP_UnitTestCase {
 		$matching_product->delete( true );
 		$non_matching_product->delete( true );
 	}
+
+	/**
+	 * @testdox Should restore caller notices queued before hydration and discard notices added during hydration.
+	 */
+	public function test_get_rest_api_response_data_preserves_caller_notices_and_discards_render_notices() {
+		wc_clear_notices();
+		wc_add_notice( 'Pre-existing notice', 'success' );
+
+		$notices_during_render = null;
+		add_filter(
+			'woocommerce_hydration_request_after_callbacks',
+			function ( $response ) use ( &$notices_during_render ) {
+				$notices_during_render = wc_get_notices();
+				wc_add_notice( 'Notice injected during render', 'success' );
+				return $response;
+			}
+		);
+
+		$this->hydration->get_rest_api_response_data( '/wc/store/v1/products' );
+
+		$this->assertEmpty( $notices_during_render, 'Notices should be cleared while hydration is running, proving the cache step snapshotted and cleared them' );
+		$this->assertTrue( wc_has_notice( 'Pre-existing notice' ), 'Caller notice queued before hydration should be restored afterward' );
+		$this->assertFalse( wc_has_notice( 'Notice injected during render' ), 'Notice added during hydration should be discarded, not leaked into the caller session' );
+	}
+
+	/**
+	 * @testdox Should no-op without touching the session when the notice functions are unavailable.
+	 */
+	public function test_get_rest_api_response_data_noops_when_notice_functions_unavailable() {
+		wc_clear_notices();
+		wc_add_notice( 'Notice queued before hydration', 'success' );
+
+		$hydration = $this->create_hydration_with_forced_availability( false );
+
+		$session_notices_before = WC()->session->get( 'wc_notices' );
+		$this->assertNotEmpty( $session_notices_before, 'Precondition: a notice should be queued before hydration runs' );
+
+		$hydration->get_rest_api_response_data( '/wc/store/v1/products' );
+
+		$this->assertEquals( $session_notices_before, WC()->session->get( 'wc_notices' ), 'Session notices should be untouched when the notice functions are unavailable' );
+		$this->assertTrue( wc_has_notice( 'Notice queued before hydration' ), 'Queued notice should not be cleared when the notice functions are unavailable' );
+		$this->assertNull( $this->get_cached_store_notices( $hydration ), 'No snapshot should be taken when the notice functions are unavailable' );
+	}
+
+	/**
+	 * @testdox Should not wipe notices on restore when the notice functions become available only after caching skipped the snapshot.
+	 */
+	public function test_get_rest_api_response_data_does_not_wipe_notices_when_functions_become_available_mid_render() {
+		wc_clear_notices();
+		wc_add_notice( 'Notice queued before hydration', 'success' );
+
+		$hydration = $this->create_hydration_with_forced_availability( false );
+
+		add_filter(
+			'woocommerce_hydration_request_after_callbacks',
+			function ( $response ) use ( $hydration ) {
+				// Simulate the notice functions becoming defined mid-render, e.g. via wc_load_cart().
+				$hydration->notice_functions_available = true;
+				return $response;
+			}
+		);
+
+		$hydration->get_rest_api_response_data( '/wc/store/v1/products' );
+
+		$this->assertTrue( wc_has_notice( 'Notice queued before hydration' ), 'Notices should survive when no snapshot was taken to restore from' );
+		$this->assertNull( $this->get_cached_store_notices( $hydration ), 'Sentinel should remain null since no snapshot was taken this cycle' );
+	}
+
+	/**
+	 * Creates a Hydration subclass whose notice-functions-availability seam is forced to a given value, so tests
+	 * can deterministically simulate wc_get_notices()/wc_clear_notices()/wc_set_notices() being undefined.
+	 *
+	 * @param bool $available Initial value the seam should report.
+	 * @return Hydration
+	 */
+	private function create_hydration_with_forced_availability( bool $available ) {
+		return new class( $this->createMock( AssetDataRegistry::class ), $available ) extends Hydration {
+			/**
+			 * Whether store_notice_functions_available() should report the notice functions as available.
+			 *
+			 * @var bool
+			 */
+			public $notice_functions_available;
+
+			/**
+			 * Constructor.
+			 *
+			 * @param AssetDataRegistry $asset_data_registry        Instance of the asset data registry.
+			 * @param bool              $notice_functions_available Initial seam value.
+			 */
+			public function __construct( AssetDataRegistry $asset_data_registry, bool $notice_functions_available ) {
+				parent::__construct( $asset_data_registry );
+				$this->notice_functions_available = $notice_functions_available;
+			}
+
+			/**
+			 * Overrides the availability seam so tests can force the "functions unavailable" path deterministically.
+			 *
+			 * @return bool
+			 */
+			protected function store_notice_functions_available(): bool {
+				return $this->notice_functions_available;
+			}
+		};
+	}
+
+	/**
+	 * Reads the protected $cached_store_notices property via reflection.
+	 *
+	 * @param Hydration $hydration Instance to inspect.
+	 * @return array|null
+	 */
+	private function get_cached_store_notices( Hydration $hydration ) {
+		$property = new \ReflectionProperty( Hydration::class, 'cached_store_notices' );
+		$property->setAccessible( true );
+
+		return $property->getValue( $hydration );
+	}
 }