Commit 301072e60c7 for woocommerce
commit 301072e60c70e51e0b661d0c16ef398a91fccdbd
Author: Ahmed <ahmed.el.azzabi@automattic.com>
Date: Fri Sep 4 10:55:02 2026 +0100
Keep WC_Form_Handler::cancel_order() returning to direct callers during wp_loaded (#68275)
* fix(orders): Keep cancel_order() returning to direct callers during wp_loaded
WooCommerce redirects to a clean URL after it handles an order
cancellation request without an explicit redirect, so a refresh does not
process the cancellation again. That redirect lived inside
WC_Form_Handler::cancel_order() behind a doing_action( 'wp_loaded' )
guard that was meant to spare code calling the method directly.
The guard cannot tell the registered wp_loaded dispatch apart from a
direct call made inside another wp_loaded callback. Such a caller now
ends the request in the redirect and its follow-up code never runs,
which changes the historical behavior of a public method.
Move the redirect into a separate wp_loaded callback registered right
after cancel_order(). The handler only records that it handled the
request and returns as it did before, so every direct caller regains
control, while browser requests still leave the state-changing URL. The
cancel_order callback identity stays the same, so extensions that remove
it for specific orders keep working, and the new callback can be removed
to opt out of the redirect.
Refs WOOAIRR-205
Refs #26743
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AHY73fmkaz2uPDuNm9gALh
* fix(orders): Skip repeated cancellation handling in the same request
cancel_order() now returns to direct callers and lets a separate
wp_loaded callback perform the redirect. When an extension calls the
method from an earlier wp_loaded callback without removing the
registered action, the priority 20 dispatch ran the handler a second
time. That second run saw the already cancelled order and added a false
"can no longer be cancelled" error before the redirect.
Remember the ID of the order whose request was handled and return early
when the same request is handled again. Keying on the order ID keeps
unrelated later calls working, for example in test suites that cancel
several orders in one process.
Refs WOOAIRR-205
Refs #26743
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AHY73fmkaz2uPDuNm9gALh
* fix(orders): Fire the deferred cancellation redirect only for wp_loaded dispatches, at the end of wp_loaded
The deferred clean redirect after an order cancellation ran at wp_loaded
priority 20 and was armed by the handled order ID. Two edge cases broke
compatibility. An extension that removes and re-adds cancel_order() at
priority 20 placed the handler after the redirect callback, so the
clean redirect was missed. An extension that removes the action and
calls cancel_order() before wp_loaded used to keep control of the
request, but the recorded ID made the later callback exit it.
Track the redirect arming separately from the handled order ID and set
it only when cancel_order() runs during wp_loaded, so detached callers
outside wp_loaded keep control. Register the redirect callback at
PHP_INT_MAX so it runs after any re-registered handler.
Tests fire a real wp_loaded dispatch with a re-added handler, confirm a
direct call outside wp_loaded does not arm the redirect, and cancel two
orders in one request to pin the per-order scope of the repeat guard.
Refs WOOAIRR-205
Refs #26743
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AHY73fmkaz2uPDuNm9gALh
* fix(orders): Remember every order handled by cancel_order() in a request
The repeat guard in cancel_order() stored only the last handled order
ID. A call sequence for order A, order B, then order A again bypassed
the guard for the final call and added a false "can no longer be
cancelled" error. One request carries one order, so this needs code that
rewrites the request between calls, but the guard should mean "already
handled in this request" for any order, not only the last one.
Track the handled order IDs as a request-local set keyed by order ID.
The redirect callback still clears it, and the per-order scope stays the
same.
Refs WOOAIRR-205
Refs #26743
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AHY73fmkaz2uPDuNm9gALh
* docs(orders): Clarify handled cancellation ID scope
The property description implied that every cancellation path populated the request-local set. Explicit redirects exit before the assignment. State the narrower no-explicit-redirect scope so the documentation matches the implementation.
* docs(orders): Qualify direct cancel-order return behavior
The comments described every direct call as returning to its caller. Calls with an explicit redirect still redirect and exit. Qualify the documented compatibility behavior so it covers only the no-explicit-redirect path.
* docs(orders): Describe the tested redirect callback order
The previous wording implied that no callback could run after the PHP_INT_MAX redirect callback. A later callback at the same priority can still follow it. Describe the priority-20 re-registration case that the regression test verifies.
* test(orders): Clarify the unarmed redirect assertion
The assertion message attributed the retained notice to the redirect callback not running. Reaching the assertion already proves that no redirect occurred. Describe only the notice invariant that the assertion checks.
* docs(orders): Describe direct cancellation call return
The comment said that an outside-wp_loaded caller kept control of the whole request. The registered callback can still redirect later. Describe the narrower return-to-caller behavior without changing the deferred redirect flow.
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/fix-cancel-order-redirect-callback b/plugins/woocommerce/changelog/fix-cancel-order-redirect-callback
new file mode 100644
index 00000000000..335893b567c
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-cancel-order-redirect-callback
@@ -0,0 +1,3 @@
+Significance: patch
+Type: dev
+Comment: Follow-up to #68141 (unreleased). Move the order cancellation redirect into a separate wp_loaded callback so direct calls to WC_Form_Handler::cancel_order() keep their historical return behavior. No user-facing change.
diff --git a/plugins/woocommerce/includes/class-wc-form-handler.php b/plugins/woocommerce/includes/class-wc-form-handler.php
index fb942e5200f..f58765ccc27 100644
--- a/plugins/woocommerce/includes/class-wc-form-handler.php
+++ b/plugins/woocommerce/includes/class-wc-form-handler.php
@@ -26,6 +26,20 @@ class WC_Form_Handler {
*/
const SET_PASSWORD_RESEND_RATE_LIMIT_SECONDS = 60;
+ /**
+ * IDs of orders whose cancellation requests cancel_order() handled without an explicit redirect in this request, as keys.
+ *
+ * @var array<int, true>
+ */
+ private static $handled_cancel_order_ids = array();
+
+ /**
+ * Whether redirect_after_cancel_order() should redirect once wp_loaded finishes.
+ *
+ * @var bool
+ */
+ private static $cancel_order_redirect_pending = false;
+
/**
* Hook in methods.
*/
@@ -40,6 +54,7 @@ class WC_Form_Handler {
add_action( 'wp_loaded', array( __CLASS__, 'process_lost_password' ), 20 );
add_action( 'wp_loaded', array( __CLASS__, 'process_reset_password' ), 20 );
add_action( 'wp_loaded', array( __CLASS__, 'cancel_order' ), 20 );
+ add_action( 'wp_loaded', array( __CLASS__, 'redirect_after_cancel_order' ), PHP_INT_MAX );
add_action( 'wp_loaded', array( __CLASS__, 'update_cart_action' ), 20 );
add_action( 'wp_loaded', array( __CLASS__, 'add_to_cart_action' ), 20 );
@@ -868,7 +883,18 @@ class WC_Form_Handler {
$order_key = is_string( $_GET['order'] ) ? wp_unslash( $_GET['order'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$order_id = absint( $_GET['order_id'] );
- $order = wc_get_order( $order_id );
+
+ if ( doing_action( 'wp_loaded' ) ) {
+ // Only a wp_loaded dispatch arms the deferred clean redirect. Without an explicit redirect, calls outside wp_loaded return control to the caller.
+ self::$cancel_order_redirect_pending = true;
+ }
+
+ if ( isset( self::$handled_cancel_order_ids[ $order_id ] ) ) {
+ // Already handled earlier in this request, for example by a direct call from another callback.
+ return;
+ }
+
+ $order = wc_get_order( $order_id );
/**
* Filter valid order statuses for cancel.
*
@@ -902,21 +928,40 @@ class WC_Form_Handler {
wc_add_notice( __( 'Invalid order.', 'woocommerce' ), 'error' );
}
- if ( ! $redirect && ! doing_action( 'wp_loaded' ) ) {
- // Preserve the historical return behavior for extensions that call this public method directly.
- return;
+ if ( $redirect ) {
+ wp_safe_redirect( $redirect );
+ exit;
}
- if ( ! $redirect ) {
- $request_uri = wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
- $redirect = remove_query_arg( array( 'cancel_order', 'order', 'order_id', 'redirect', '_wpnonce' ), $request_uri );
- }
+ self::$handled_cancel_order_ids[ $order_id ] = true;
+ }
+ }
- $redirect = $redirect ? $redirect : wc_get_cart_url();
- $redirect = $redirect ? $redirect : home_url();
- wp_safe_redirect( $redirect );
- exit;
+ /**
+ * Redirect to a clean URL after cancel_order() handled a request during wp_loaded without an explicit redirect.
+ *
+ * This runs at the end of wp_loaded, after a re-registered priority-20 cancel_order() callback.
+ * Keeping the redirect out of cancel_order() preserves its return behavior for code
+ * that calls it directly without an explicit redirect, while browser requests still
+ * leave the state-changing URL.
+ *
+ * @since 11.2.0
+ */
+ public static function redirect_after_cancel_order(): void {
+ if ( ! self::$cancel_order_redirect_pending ) {
+ return;
}
+
+ self::$cancel_order_redirect_pending = false;
+ self::$handled_cancel_order_ids = array();
+
+ $request_uri = wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+ $redirect = remove_query_arg( array( 'cancel_order', 'order', 'order_id', 'redirect', '_wpnonce' ), $request_uri );
+ $redirect = $redirect ? $redirect : wc_get_cart_url();
+ $redirect = $redirect ? $redirect : home_url();
+
+ wp_safe_redirect( $redirect );
+ exit;
}
/**
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-form-handler-test.php b/plugins/woocommerce/tests/php/includes/class-wc-form-handler-test.php
index 3cd2fd69526..bf23ec3fd68 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-form-handler-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-form-handler-test.php
@@ -75,6 +75,7 @@ class WC_Form_Handler_Test extends WC_Unit_Test_Case {
*/
public function tearDown(): void {
remove_filter( 'wp_redirect', array( $this, 'intercept_redirect' ) );
+ $this->reset_cancel_order_handled_flag();
$_GET = $this->original_get;
if ( null === $this->original_request_uri ) {
@@ -161,9 +162,10 @@ class WC_Form_Handler_Test extends WC_Unit_Test_Case {
}
/**
- * @testdox cancel_order() returns to direct callers when no custom redirect is provided.
+ * @testdox cancel_order() returns to direct callers outside wp_loaded and does not arm the deferred redirect.
*
* @covers WC_Form_Handler::cancel_order()
+ * @covers WC_Form_Handler::redirect_after_cancel_order()
*/
public function test_cancel_order_returns_to_direct_callers_without_custom_redirect(): void {
$user_id = self::factory()->user->create( array( 'role' => 'customer' ) );
@@ -174,6 +176,153 @@ class WC_Form_Handler_Test extends WC_Unit_Test_Case {
WC_Form_Handler::cancel_order();
$this->assertTrue( wc_get_order( $order->get_id() )->has_status( OrderStatus::CANCELLED ), 'The direct call should cancel the order and return control to the caller.' );
+
+ WC_Form_Handler::redirect_after_cancel_order();
+
+ $this->assertSame( 1, wc_notice_count( 'notice' ), 'The cancellation notice should remain after an unarmed redirect callback.' );
+ }
+
+ /**
+ * @testdox cancel_order() returns to direct callers inside a wp_loaded callback, and the redirect completes afterwards.
+ *
+ * @covers WC_Form_Handler::cancel_order()
+ * @covers WC_Form_Handler::redirect_after_cancel_order()
+ */
+ public function test_cancel_order_returns_to_direct_callers_inside_wp_loaded(): void {
+ global $wp_current_filter;
+
+ $user_id = self::factory()->user->create( array( 'role' => 'customer' ) );
+ wp_set_current_user( $user_id );
+ $order = WC_Helper_Order::create_order( $user_id );
+
+ $this->prepare_cancel_order_request( $order );
+
+ $current_filter_backup = $wp_current_filter;
+ $wp_current_filter[] = 'wp_loaded'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Simulate an extension calling the handler from its own wp_loaded callback.
+
+ try {
+ WC_Form_Handler::cancel_order();
+ $this->assertTrue( wc_get_order( $order->get_id() )->has_status( OrderStatus::CANCELLED ), 'The nested direct call should cancel the order and return control to the caller.' );
+
+ $this->dispatch_redirect_after_cancel_order_expecting_redirect( wp_make_link_relative( $order->get_cancel_endpoint() ) );
+ } finally {
+ $wp_current_filter = $current_filter_backup; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the action stack after the simulated dispatch.
+ }
+ }
+
+ /**
+ * @testdox cancel_order() does not handle the same request twice when an earlier wp_loaded callback already called it.
+ *
+ * @covers WC_Form_Handler::cancel_order()
+ * @covers WC_Form_Handler::redirect_after_cancel_order()
+ */
+ public function test_cancel_order_ignores_repeated_call_for_the_same_request(): void {
+ global $wp_current_filter;
+
+ $user_id = self::factory()->user->create( array( 'role' => 'customer' ) );
+ wp_set_current_user( $user_id );
+ $order = WC_Helper_Order::create_order( $user_id );
+
+ $this->prepare_cancel_order_request( $order );
+
+ $current_filter_backup = $wp_current_filter;
+ $wp_current_filter[] = 'wp_loaded'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Simulate an extension calling the handler before the registered priority 20 dispatch.
+
+ try {
+ WC_Form_Handler::cancel_order();
+ $this->dispatch_cancel_order_expecting_redirect( wp_make_link_relative( $order->get_cancel_endpoint() ) );
+ } finally {
+ $wp_current_filter = $current_filter_backup; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the action stack after the simulated dispatch.
+ }
+
+ $this->assertSame( 1, wc_notice_count( 'notice' ), 'The cancellation notice should be added once.' );
+ $this->assertSame( 0, wc_notice_count( 'error' ), 'The repeated dispatch must not add a "can no longer be cancelled" error.' );
+ }
+
+ /**
+ * @testdox cancel_order() handles each order once per request, in any call order.
+ *
+ * @covers WC_Form_Handler::cancel_order()
+ */
+ public function test_cancel_order_handles_each_order_once_per_request(): void {
+ global $wp_current_filter;
+
+ $user_id = self::factory()->user->create( array( 'role' => 'customer' ) );
+ wp_set_current_user( $user_id );
+ $first_order = WC_Helper_Order::create_order( $user_id );
+ $second_order = WC_Helper_Order::create_order( $user_id );
+
+ $current_filter_backup = $wp_current_filter;
+ $wp_current_filter[] = 'wp_loaded'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Simulate the handler running inside wp_loaded.
+
+ try {
+ $this->prepare_cancel_order_request( $first_order );
+ WC_Form_Handler::cancel_order();
+ WC_Form_Handler::cancel_order();
+
+ $this->prepare_cancel_order_request( $second_order );
+ WC_Form_Handler::cancel_order();
+
+ $this->prepare_cancel_order_request( $first_order );
+ WC_Form_Handler::cancel_order();
+ } finally {
+ $wp_current_filter = $current_filter_backup; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the action stack after the simulated dispatch.
+ }
+
+ $this->assertTrue( wc_get_order( $first_order->get_id() )->has_status( OrderStatus::CANCELLED ), 'The first order should be cancelled.' );
+ $this->assertTrue( wc_get_order( $second_order->get_id() )->has_status( OrderStatus::CANCELLED ), 'The second order should be cancelled even though the previous request was deduplicated.' );
+ $this->assertSame( 2, wc_notice_count( 'notice' ), 'Each order should add exactly one cancellation notice.' );
+ $this->assertSame( 0, wc_notice_count( 'error' ), 'Neither the immediate repeat nor the later repeat for the first order should add an error notice.' );
+ }
+
+ /**
+ * @testdox redirect_after_cancel_order() still redirects when an extension re-adds cancel_order() at priority 20 during a real wp_loaded dispatch.
+ *
+ * @covers WC_Form_Handler::cancel_order()
+ * @covers WC_Form_Handler::redirect_after_cancel_order()
+ */
+ public function test_redirect_after_cancel_order_runs_after_a_re_added_handler(): void {
+ global $wp_current_filter;
+
+ $user_id = self::factory()->user->create( array( 'role' => 'customer' ) );
+ wp_set_current_user( $user_id );
+ $order = WC_Helper_Order::create_order( $user_id );
+
+ remove_action( 'wp_loaded', 'WC_Form_Handler::cancel_order', 20 );
+ add_action( 'wp_loaded', 'WC_Form_Handler::cancel_order', 20 );
+
+ $this->prepare_cancel_order_request( $order );
+
+ $current_filter_backup = $wp_current_filter;
+
+ try {
+ do_action( 'wp_loaded' );
+ } catch ( RuntimeException $e ) {
+ $this->assertSame( wp_make_link_relative( $order->get_cancel_endpoint() ), $e->getMessage(), 'The deferred redirect should still run after the re-added handler.' );
+ $this->assertTrue( wc_get_order( $order->get_id() )->has_status( OrderStatus::CANCELLED ), 'The re-added handler should cancel the order before the redirect.' );
+ return;
+ } finally {
+ $wp_current_filter = $current_filter_backup; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- The intercepted redirect unwinds do_action() before it pops the action stack.
+ }
+
+ $this->fail( 'Expected the wp_loaded dispatch to redirect after handling the cancellation request.' );
+ }
+
+ /**
+ * @testdox redirect_after_cancel_order() does nothing when cancel_order() did not handle the request.
+ *
+ * @covers WC_Form_Handler::redirect_after_cancel_order()
+ */
+ public function test_redirect_after_cancel_order_does_nothing_when_handler_did_not_run(): void {
+ $user_id = self::factory()->user->create( array( 'role' => 'customer' ) );
+ wp_set_current_user( $user_id );
+ $order = WC_Helper_Order::create_order( $user_id );
+
+ // An extension can remove cancel_order() from wp_loaded, so only the redirect callback runs.
+ $this->prepare_cancel_order_request( $order );
+ WC_Form_Handler::redirect_after_cancel_order();
+
+ $this->assertTrue( wc_get_order( $order->get_id() )->has_status( OrderStatus::PENDING ), 'The order should stay untouched when only the redirect callback runs.' );
}
/**
@@ -450,7 +599,7 @@ class WC_Form_Handler_Test extends WC_Unit_Test_Case {
}
/**
- * Dispatches the cancel-order handler and expects a redirect.
+ * Dispatches the cancel-order handler and its redirect callback in registration order and expects a redirect.
*
* @param string $expected_redirect Expected redirect URL.
*/
@@ -462,6 +611,7 @@ class WC_Form_Handler_Test extends WC_Unit_Test_Case {
try {
WC_Form_Handler::cancel_order();
+ WC_Form_Handler::redirect_after_cancel_order();
} catch ( RuntimeException $e ) {
$this->assertSame( $expected_redirect, $e->getMessage(), 'The cancellation request should redirect to a clean URL.' );
return;
@@ -469,7 +619,36 @@ class WC_Form_Handler_Test extends WC_Unit_Test_Case {
$wp_current_filter = $current_filter_backup; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the action stack after the simulated dispatch.
}
- $this->fail( 'Expected cancel_order() to redirect after handling the request.' );
+ $this->fail( 'Expected the cancellation request to redirect after handling.' );
+ }
+
+ /**
+ * Dispatches only the redirect callback and expects a redirect.
+ *
+ * @param string $expected_redirect Expected redirect URL.
+ */
+ private function dispatch_redirect_after_cancel_order_expecting_redirect( string $expected_redirect ): void {
+ try {
+ WC_Form_Handler::redirect_after_cancel_order();
+ } catch ( RuntimeException $e ) {
+ $this->assertSame( $expected_redirect, $e->getMessage(), 'The redirect callback should redirect to a clean URL.' );
+ return;
+ }
+
+ $this->fail( 'Expected redirect_after_cancel_order() to redirect after cancel_order() handled the request.' );
+ }
+
+ /**
+ * Resets the handled order ID cancel_order() leaves behind so it cannot leak into the next test.
+ */
+ private function reset_cancel_order_handled_flag(): void {
+ $handled_ids = new ReflectionProperty( WC_Form_Handler::class, 'handled_cancel_order_ids' );
+ $handled_ids->setAccessible( true );
+ $handled_ids->setValue( null, array() );
+
+ $pending = new ReflectionProperty( WC_Form_Handler::class, 'cancel_order_redirect_pending' );
+ $pending->setAccessible( true );
+ $pending->setValue( null, false );
}
/**