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