Commit bcec005bc24 for woocommerce
commit bcec005bc243c19cb45963ae41834479fcf0e930
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Thu Aug 13 18:44:11 2026 +0300
Restore the cart context after hydrating a Store API route (#67312)
* fix: restore the cart context after hydrating a Store API route
`Hydration` dispatches Store API routes inside a front-end page render to
preload data. Dispatching a cart route runs `CartController::load_cart()`,
which sets `cart_context` to `store-api` on the shared cart and never puts
it back.
That is correct for a real Store API request, where the whole request is
Store API. It is not correct here: the remainder of the front-end render
inherits the flag, and three places branch on it —
`ShippingController::remove_shipping_if_no_address()`,
`WC_Cart::show_shipping()`, and
`wc_get_default_shipping_method_for_package()`. The first is registered
unconditionally on `woocommerce_shipping_packages`, so it runs on classic
pages too, and under `store-api` it strips every non-local-pickup rate
when the customer has no full shipping address. A page rendering the
shortcode cart after hydration would show no shipping rates.
`Hydration` already solves this shape of problem for the notice queue,
snapshotting it before dispatch and restoring it afterwards. This adds
the same pairing for the cart context, so `store-api` stays correct for
genuine Store API requests without leaking into a render that is not one.
The regression test sets `cart_context` explicitly before hydrating
rather than reading whatever the previous test left behind. Reading it
would make the test pass or fail on the order it runs in — the leak this
change fixes means an earlier test can hand it `store-api`, and asserting
against inherited state is how a test ends up green in isolation and red
in the suite. It fails without the restore.
Refs #67311
* fix: always restore hydration session state via try/finally
Restoring the cart context, store notices, and nonce check ran only
after controller dispatch and preload completed. An \Error escaping the
dispatch catch (which only handles \Exception), or any Throwable from
rest_preload_api_request(), skipped the restores: the rest of the
request ran with the nonce check disabled and the store-api cart
context, and the cleared notices were persisted to the session by the
shutdown-time session save.
Wrap setup, controller dispatch, and preload in try/finally so the
three restores always run. Success and caught-Exception paths are
unchanged, and errors still propagate.
The regression test throws an \Error from the after-callbacks filter,
after load_cart() has polluted the cart context, so all three
restore assertions are meaningful against genuinely dirty state.
diff --git a/plugins/woocommerce/changelog/fix-hydration-restore-cart-context b/plugins/woocommerce/changelog/fix-hydration-restore-cart-context
new file mode 100644
index 00000000000..3cda2ce4779
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-hydration-restore-cart-context
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Restore the cart context after hydrating a Store API route, so a front-end page render no longer inherits the store-api cart context and its shipping behaviour.
diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/Hydration.php b/plugins/woocommerce/src/Blocks/Domain/Services/Hydration.php
index 8d0aabbdf05..fdbb31c9e10 100644
--- a/plugins/woocommerce/src/Blocks/Domain/Services/Hydration.php
+++ b/plugins/woocommerce/src/Blocks/Domain/Services/Hydration.php
@@ -28,6 +28,17 @@ class Hydration {
*/
protected $cached_store_notices = null;
+ /**
+ * Snapshot of WC()->cart->cart_context taken by cache_cart_context(), to restore after hydrating the API.
+ *
+ * `null` means no snapshot has been taken this cycle, either because the method has not run yet or because
+ * its guards skipped it (for example, there was no cart). restore_cached_cart_context() only restores from a
+ * non-null string, so a `null` value always leaves the cart untouched.
+ *
+ * @var string|null
+ */
+ protected $cached_cart_context = null;
+
/**
* Constructor.
*
@@ -53,54 +64,63 @@ class Hydration {
$available_routes = StoreApi::container()->get( RoutesController::class )->get_all_routes( 'v1', true );
$route_match = $this->match_route_to_handler( $path, $available_routes );
- /**
- * We disable nonce check to support endpoints such as checkout. The caveat here is that we need to be careful to only support GET requests. No other request type should be processed without nonce check. Additionally, no GET request can modify data as part of hydration request, for example adding items to cart.
- *
- * Long term, we should consider validating nonce here, instead of disabling it temporarily.
- */
- $this->disable_nonce_check();
-
- $this->cache_store_notices();
-
$preloaded_data = array();
- if ( null !== $route_match ) {
- try {
- $response = $this->get_response_from_controller(
- $route_match['controller'],
- $path,
- $route_match['url_params'],
- $route_match['query_params']
- );
- if ( $response ) {
- $preloaded_data = array(
- 'body' => $response->get_data(),
- 'headers' => $response->get_headers(),
+ // The `finally` guarantees the session state mutated during setup (nonce check disabled, notices cleared,
+ // cart context snapshot) is restored even when dispatching throws something the inner catch does not
+ // handle (an `\Error`, or any Throwable from `rest_preload_api_request()`). Without it, the rest of the
+ // request would run with the nonce check disabled and the `store-api` cart context, and the cleared
+ // notices would be persisted to the session on shutdown.
+ try {
+ /**
+ * We disable nonce check to support endpoints such as checkout. The caveat here is that we need to be careful to only support GET requests. No other request type should be processed without nonce check. Additionally, no GET request can modify data as part of hydration request, for example adding items to cart.
+ *
+ * Long term, we should consider validating nonce here, instead of disabling it temporarily.
+ */
+ $this->disable_nonce_check();
+
+ $this->cache_store_notices();
+ $this->cache_cart_context();
+
+ if ( null !== $route_match ) {
+ try {
+ $response = $this->get_response_from_controller(
+ $route_match['controller'],
+ $path,
+ $route_match['url_params'],
+ $route_match['query_params']
+ );
+ if ( $response ) {
+ $preloaded_data = array(
+ 'body' => $response->get_data(),
+ 'headers' => $response->get_headers(),
+ );
+ }
+ } catch ( \Exception $e ) {
+ // This is executing in frontend of the site, a failure in hydration should not stop the site from working.
+ wc_get_logger()->warning(
+ 'Error in hydrating REST API request: ' . $e->getMessage(),
+ array(
+ 'source' => 'blocks-hydration',
+ 'data' => array(
+ 'path' => $path,
+ 'controller' => $route_match['controller'] ?? null,
+ ),
+ 'backtrace' => true,
+ )
);
}
- } catch ( \Exception $e ) {
- // This is executing in frontend of the site, a failure in hydration should not stop the site from working.
- wc_get_logger()->warning(
- 'Error in hydrating REST API request: ' . $e->getMessage(),
- array(
- 'source' => 'blocks-hydration',
- 'data' => array(
- 'path' => $path,
- 'controller' => $route_match['controller'] ?? null,
- ),
- 'backtrace' => true,
- )
- );
+ } else {
+ // Preload the request and add it to the array. It will be $preloaded_requests['path'] and contain 'body' and 'headers'.
+ $preloaded_requests = rest_preload_api_request( array(), $path );
+ $preloaded_data = $preloaded_requests[ $path ] ?? array();
}
- } else {
- // Preload the request and add it to the array. It will be $preloaded_requests['path'] and contain 'body' and 'headers'.
- $preloaded_requests = rest_preload_api_request( array(), $path );
- $preloaded_data = $preloaded_requests[ $path ] ?? array();
+ } finally {
+ $this->restore_cached_cart_context();
+ $this->restore_cached_store_notices();
+ $this->restore_nonce_check();
}
- $this->restore_cached_store_notices();
- $this->restore_nonce_check();
-
// Returns just the single preloaded request, or an empty array if it doesn't exist.
return $preloaded_data;
}
@@ -296,4 +316,39 @@ class Hydration {
wc_set_notices( $this->cached_store_notices );
$this->cached_store_notices = null;
}
+
+ /**
+ * Cache the cart context before hydrating the API.
+ *
+ * Dispatching a Store API cart route runs `CartController::load_cart()`, which sets `cart_context` to
+ * `store-api` on the shared cart and never puts it back. That is correct for a real Store API request, where
+ * the whole request is Store API — but hydration runs inside a front-end render, so without a snapshot the
+ * rest of that render inherits the flag. Shipping code branches on it, so the page would silently take the
+ * block path.
+ *
+ * @since 11.1.0
+ */
+ protected function cache_cart_context(): void {
+ $this->cached_cart_context = null;
+
+ if ( ! did_action( 'woocommerce_init' ) || ! WC()->cart instanceof \WC_Cart ) {
+ return;
+ }
+
+ $this->cached_cart_context = WC()->cart->cart_context;
+ }
+
+ /**
+ * Restore the cart context, only if a snapshot was taken this cycle.
+ *
+ * @since 11.1.0
+ */
+ protected function restore_cached_cart_context(): void {
+ if ( ! is_string( $this->cached_cart_context ) || ! WC()->cart instanceof \WC_Cart ) {
+ return;
+ }
+
+ WC()->cart->cart_context = $this->cached_cart_context;
+ $this->cached_cart_context = null;
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/Hydration.php b/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/Hydration.php
index 55e5ee18a1e..71b54d79ed7 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/Hydration.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/Hydration.php
@@ -108,4 +108,72 @@ class Hydration extends \WC_Unit_Test_Case {
$this->assertEmpty( $response );
}
+
+ /**
+ * @testDox Hydrating a cart route leaves the cart context as it found it.
+ */
+ public function test_cart_context_is_restored_after_hydration() {
+ // Set the context explicitly rather than reading whatever the previous test left behind:
+ // this is the state a front-end render starts in, and the assertion below is only
+ // meaningful against a known starting point.
+ WC()->cart->cart_context = 'shortcode';
+
+ $this->sut->get_rest_api_response_data( '/wc/store/v1/cart' );
+
+ $this->assertSame(
+ 'shortcode',
+ WC()->cart->cart_context,
+ 'Hydration must not leak the store-api cart context into the surrounding request.'
+ );
+ }
+
+ /**
+ * @testDox Hydration restores the cart context, store notices, and nonce check even when dispatching throws a non-Exception error.
+ */
+ public function test_state_is_restored_when_hydration_throws_a_non_exception_error(): void {
+ WC()->cart->cart_context = 'shortcode';
+ wc_clear_notices();
+ wc_add_notice( 'Notice set before hydration.' );
+
+ // Throwing from this filter fails the dispatch after `load_cart()` has already switched the cart
+ // context to `store-api`, so the restore-on-error path is exercised against genuinely polluted state.
+ // An `\Error` (not an `\Exception`) bypasses the catch inside `get_rest_api_response_data()`.
+ $throwing_callback = function () {
+ throw new \Error( 'Simulated non-Exception failure during hydration.' );
+ };
+ // @phpstan-ignore return.missing (The callback never returns by design: it simulates a fatal error during dispatch.)
+ add_filter( 'woocommerce_hydration_request_after_callbacks', $throwing_callback );
+
+ // @phpstan-ignore deadCode.unreachable (PHPStan considers the code after registering an always-throwing callback unreachable; at runtime the callback only fires during dispatch below.)
+ $caught = null;
+ try {
+ $this->sut->get_rest_api_response_data( '/wc/store/v1/cart' );
+ } catch ( \Error $error ) {
+ $caught = $error;
+ } finally {
+ remove_filter( 'woocommerce_hydration_request_after_callbacks', $throwing_callback );
+ }
+
+ $this->assertInstanceOf(
+ \Error::class,
+ $caught,
+ 'Hydration should restore state but not swallow non-Exception errors.'
+ );
+ $this->assertSame(
+ 'shortcode',
+ WC()->cart->cart_context,
+ 'Hydration must restore the cart context even when dispatching throws.'
+ );
+ $this->assertSame(
+ array( 'Notice set before hydration.' ),
+ wp_list_pluck( wc_get_notices( 'success' ), 'notice' ),
+ 'Hydration must restore store notices even when dispatching throws.'
+ );
+ $this->assertFalse(
+ has_filter( 'woocommerce_store_api_disable_nonce_check', array( $this->sut, 'disable_nonce_check_callback' ) ),
+ 'Hydration must re-enable the nonce check even when dispatching throws.'
+ );
+
+ wc_clear_notices();
+ }
}