Commit 454f200bc9b for woocommerce
commit 454f200bc9bc985ec85bcb747ef845a74001e2b3
Author: Mayisha <33387139+Mayisha@users.noreply.github.com>
Date: Mon Jul 27 16:07:44 2026 +0600
Avoid a redundant order lookup and document eligible-status scoping in the abandoned cart recovery scheduler (#66921)
* tweak: avoid redundant order lookup in abandoned cart recovery scheduler
`handle_status_changed()` re-fetched the order with `wc_get_order()` even
though `woocommerce_order_status_changed` passes it as the 4th argument
(class-wc-order.php:478). The callback runs on every order status
transition store-wide, so this removes a lookup from a hot path and
guarantees we act on the instance that actually transitioned.
The new `$order` parameter is optional and falls back to a lookup when
absent, matching `handle_new_order()` in the same class.
Also documents why `store-api` is in `ELIGIBLE_CREATED_VIA` when it
matches no order today, so it isn't removed as dead code before
checkout-draft scheduling lands.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: clarify abandoned cart recovery eligible-status scoping
The class docblock and `get_eligible_statuses()` both implied the
scheduler and the send-time gate resolve to the same status set. They
share the `woocommerce_abandoned_cart_recovery_eligible_statuses` filter
but not its default: the scheduler is scoped to `pending`, the send-time
gate also accepts `checkout-draft`.
Document what each path covers and why, and note that append/replace
callbacks behave consistently across both call sites. Mirrored on
`ABANDONED_STATUSES` so the scoping is visible from either file.
Comments only, no behavior change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* tweak: reuse the hook-supplied order in the abandoned cart recovery scheduler
Addresses review feedback on #66921.
`handle_cancellation()` reloaded the order immediately after
`handle_status_changed()` had already resolved it. It now takes an
optional `$order` and falls back to `wc_get_order()` only when absent,
matching `handle_new_order()` in the same class.
`woocommerce_before_delete_order` passes the order as its 2nd argument
(OrdersTableDataStore.php:2632), so that registration moves to arity 2.
`woocommerce_trash_order` passes only the ID (:2693) and stays at arity
1, taking the fallback path.
Also rewrites the `ELIGIBLE_CREATED_VIA` comment: Store API orders are
generally created in `checkout-draft`, which is not scheduled yet, so
the entry is listed for future block checkout support.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* use @see with full classname
Co-authored-by: daledupreez <dale@automattic.com>
* use @see for get_eligible_statuses()
Co-authored-by: daledupreez <dale@automattic.com>
* remove unnecessary comment
Co-authored-by: daledupreez <dale@automattic.com>
* make comment generic
Co-authored-by: daledupreez <dale@automattic.com>
* simplify doc
Co-authored-by: daledupreez <dale@automattic.com>
* add tests
* consolidate eligible-statuses filter docs at a single call site
* fix docblock spacing before @see tag
* Use explicit order status values instead of reference to class-local constant
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: daledupreez <dale@automattic.com>
diff --git a/plugins/woocommerce/changelog/fix-abandoned-cart-recovery-order-lookup b/plugins/woocommerce/changelog/fix-abandoned-cart-recovery-order-lookup
new file mode 100644
index 00000000000..3b64250aacd
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-abandoned-cart-recovery-order-lookup
@@ -0,0 +1,4 @@
+Significance: patch
+Type: tweak
+
+Abandoned cart recovery: remove a redundant order lookup on every order status transition.
diff --git a/plugins/woocommerce/includes/emails/class-wc-email-customer-abandoned-cart-recovery.php b/plugins/woocommerce/includes/emails/class-wc-email-customer-abandoned-cart-recovery.php
index d7b67585388..9114faaea7d 100644
--- a/plugins/woocommerce/includes/emails/class-wc-email-customer-abandoned-cart-recovery.php
+++ b/plugins/woocommerce/includes/emails/class-wc-email-customer-abandoned-cart-recovery.php
@@ -73,6 +73,11 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
* the customer is mid-flow. May have no billing email yet,
* in which case `trigger()` no-ops.
*
+ * Covers block checkout as well as classic. The automated scheduler is
+ * scoped to `pending`.
+ *
+ * @see \Automattic\WooCommerce\Internal\AbandonedCartRecovery\Scheduler::get_eligible_statuses()
+ *
* @var string[]
*/
private const ABANDONED_STATUSES = array(
@@ -261,14 +266,21 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
/**
* Filter the order statuses that are eligible to receive the abandoned cart recovery email.
*
- * Defaults to the abandoned-checkout statuses (`pending`, `checkout-draft`). Partner
- * integrations or merchants who want recovery to fire for other states (e.g. `failed`)
- * can widen the list here.
+ * The filter is applied at two points in the order lifecycle, which pass different
+ * defaults because they cover different statuses:
+ *
+ * - At send time (here), the default is `pending` and `checkout-draft`, covering
+ * abandonment from both the classic and the block checkout, used in manual email trigger path
+ * from the order edit page.
+ * - When the automated send is scheduled, the default is `pending` only.
+ * Store API orders are generally created in `checkout-draft`, which is not supported
+ * yet for automatic scheduled email.
+ * {@see \Automattic\WooCommerce\Internal\AbandonedCartRecovery\Scheduler::get_eligible_statuses()}
*
* @since 11.0.0
*
- * @param string[] $eligible_statuses Default: ABANDONED_STATUSES.
- * @param WC_Order $order Order being inspected.
+ * @param string[] $eligible_statuses Default: `pending` and `checkout-draft` at manual send time, `pending` when scheduling.
+ * @param WC_Order|null $order Order being inspected, or null if it could not be loaded.
*/
$eligible_statuses = (array) apply_filters(
'woocommerce_abandoned_cart_recovery_eligible_statuses',
diff --git a/plugins/woocommerce/src/Internal/AbandonedCartRecovery/Scheduler.php b/plugins/woocommerce/src/Internal/AbandonedCartRecovery/Scheduler.php
index da03b0eb0f1..fb50c71f62e 100644
--- a/plugins/woocommerce/src/Internal/AbandonedCartRecovery/Scheduler.php
+++ b/plugins/woocommerce/src/Internal/AbandonedCartRecovery/Scheduler.php
@@ -20,9 +20,10 @@ use WC_Order;
* after `WC_Email_Customer_Abandoned_Cart_Recovery::AUTO_SEND_DELAY_SECONDS`.
* The pending action is cancelled when the order transitions out of the
* eligible-status set or is trashed/deleted, so a customer who completes
- * checkout before the delay elapses never receives the nudge. Eligibility
- * comes from the same `woocommerce_abandoned_cart_recovery_eligible_statuses`
- * filter the send/manual paths use.
+ * checkout before the delay elapses never receives the nudge. Eligibility runs
+ * through the same `woocommerce_abandoned_cart_recovery_eligible_statuses`
+ * filter the send/manual paths use, with a default scoped to `pending` —
+ * {@see get_eligible_statuses()}.
*
* Per-order idempotency is enforced two ways: a scheduled-at meta key blocks
* re-scheduling for the same order, and the trigger-time send gate refuses to
@@ -62,6 +63,9 @@ class Scheduler {
* Order-creation origins (`created_via`) eligible for an automated send:
* the classic checkout and the block (Store API) checkout.
*
+ * Store API orders are generally created in `checkout-draft`, which is not scheduled
+ * yet. `store-api` is listed here for future block checkout support.
+ *
* @var string[]
*/
private const ELIGIBLE_CREATED_VIA = array( 'checkout', 'store-api' );
@@ -77,9 +81,9 @@ class Scheduler {
add_action( 'woocommerce_new_order', array( $this, 'handle_new_order' ), 10, 2 );
// Catch every transition out of the eligible set so the pending send
// is unscheduled regardless of which status the order moves to.
- add_action( 'woocommerce_order_status_changed', array( $this, 'handle_status_changed' ), 10, 3 );
+ add_action( 'woocommerce_order_status_changed', array( $this, 'handle_status_changed' ), 10, 4 );
add_action( 'woocommerce_trash_order', array( $this, 'handle_cancellation' ), 10, 1 );
- add_action( 'woocommerce_before_delete_order', array( $this, 'handle_cancellation' ), 10, 1 );
+ add_action( 'woocommerce_before_delete_order', array( $this, 'handle_cancellation' ), 10, 2 );
add_action( self::ACTION_HOOK, array( $this, 'handle_scheduled_send' ), 10, 1 );
}
@@ -147,12 +151,15 @@ class Scheduler {
*
* @internal
*
- * @param int $order_id Order ID.
- * @param string $old_status Previous status (sans `wc-` prefix).
- * @param string $new_status New status (sans `wc-` prefix).
+ * @param int $order_id Order ID.
+ * @param string $old_status Previous status (sans `wc-` prefix).
+ * @param string $new_status New status (sans `wc-` prefix).
+ * @param WC_Order|null $order Order passed by the hook; looked up when absent.
*/
- public function handle_status_changed( int $order_id, string $old_status, string $new_status ): void {
- $order = wc_get_order( $order_id );
+ public function handle_status_changed( int $order_id, string $old_status, string $new_status, $order = null ): void {
+ if ( ! $order instanceof WC_Order ) {
+ $order = wc_get_order( $order_id );
+ }
if ( ! $order instanceof WC_Order ) {
return;
}
@@ -166,7 +173,7 @@ class Scheduler {
return;
}
- $this->handle_cancellation( $order_id );
+ $this->handle_cancellation( $order_id, $order );
}
/**
@@ -179,15 +186,19 @@ class Scheduler {
*
* @internal
*
- * @param int $order_id The affected order ID.
+ * @param int $order_id The affected order ID.
+ * @param WC_Order|null $order Order passed by the caller or hook; looked up when absent, as
+ * some hooks don't supply the order.
*/
- public function handle_cancellation( int $order_id ): void {
+ public function handle_cancellation( int $order_id, $order = null ): void {
// Always attempt to unschedule, even when the order or meta is missing,
// so an out-of-sync meta value cannot leave a stray scheduled send.
// `as_unschedule_action()` is a no-op when no matching action exists.
as_unschedule_action( self::ACTION_HOOK, array( $order_id ) );
- $order = wc_get_order( $order_id );
+ if ( ! $order instanceof WC_Order ) {
+ $order = wc_get_order( $order_id );
+ }
if ( $order instanceof WC_Order && '' !== (string) $order->get_meta( self::SCHEDULED_META_KEY ) ) {
$order->delete_meta_data( self::SCHEDULED_META_KEY );
$order->save_meta_data();
@@ -234,20 +245,17 @@ class Scheduler {
}
/**
- * Order statuses eligible for a recovery send, shared with the send-time
- * gate in `WC_Email_Customer_Abandoned_Cart_Recovery::is_order_eligible_for_recovery()`.
+ * Order statuses that can be scheduled for an automated send. Defaults to `pending`, which is
+ * where classic checkout leaves an abandoned order at creation time.
*
* @param WC_Order|null $order Order being inspected, or null if it could not be loaded.
* @return string[]
*/
private function get_eligible_statuses( ?WC_Order $order ): array {
/**
- * Filter the order statuses that are eligible to receive the abandoned cart recovery email.
+ * This filter is documented in includes/emails/class-wc-email-customer-abandoned-cart-recovery.php
*
* @since 11.0.0
- *
- * @param string[] $eligible_statuses Default: `pending`.
- * @param WC_Order|null $order Order being inspected, or null if it could not be loaded.
*/
return (array) apply_filters(
'woocommerce_abandoned_cart_recovery_eligible_statuses',
diff --git a/plugins/woocommerce/tests/php/src/Internal/AbandonedCartRecovery/SchedulerTest.php b/plugins/woocommerce/tests/php/src/Internal/AbandonedCartRecovery/SchedulerTest.php
index 00c490ef307..42052a0ff6c 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AbandonedCartRecovery/SchedulerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AbandonedCartRecovery/SchedulerTest.php
@@ -532,6 +532,32 @@ class SchedulerTest extends WC_Unit_Test_Case {
$this->assertFalse( as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order->get_id() ) ) );
}
+ /**
+ * @testdox handle_status_changed() cancels the pending send when the hook supplies the order object, matching the id-only path that falls back to a lookup.
+ */
+ public function test_handle_status_changed_cancels_scheduled_send_when_order_object_passed(): void {
+ $order = $this->schedule_for_pending_order();
+
+ $this->sut->handle_status_changed( $order->get_id(), OrderStatus::PENDING, OrderStatus::PROCESSING, $order );
+
+ $fresh = wc_get_order( $order->get_id() );
+ $this->assertSame( '', $fresh->get_meta( Scheduler::SCHEDULED_META_KEY ), 'Scheduled-at meta must be cleared when the hook supplies the order object.' );
+ $this->assertFalse( as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order->get_id() ) ), 'Queued send must be unscheduled when the hook supplies the order object.' );
+ }
+
+ /**
+ * @testdox handle_status_changed() falls back to a lookup when the 4th argument is not an order, so a caller passing null behaves like the id-only path.
+ */
+ public function test_handle_status_changed_cancels_scheduled_send_when_order_object_is_null(): void {
+ $order = $this->schedule_for_pending_order();
+
+ $this->sut->handle_status_changed( $order->get_id(), OrderStatus::PENDING, OrderStatus::PROCESSING, null );
+
+ $fresh = wc_get_order( $order->get_id() );
+ $this->assertSame( '', $fresh->get_meta( Scheduler::SCHEDULED_META_KEY ), 'Scheduled-at meta must be cleared when the order object is absent.' );
+ $this->assertFalse( as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order->get_id() ) ), 'Queued send must be unscheduled when the order object is absent.' );
+ }
+
/**
* @testdox handle_status_changed() does nothing when the previous status was already outside `pending` — nothing to cancel.
*/
@@ -549,9 +575,9 @@ class SchedulerTest extends WC_Unit_Test_Case {
}
/**
- * @testdox handle_cancellation() unschedules and clears the meta for a trashed order so a deleted-then-restored order doesn't fire a stale send.
+ * @testdox handle_cancellation() unschedules and clears the meta when no order object is supplied and it falls back to a lookup — the `woocommerce_trash_order` path — so a deleted-then-restored order doesn't fire a stale send.
*/
- public function test_handle_cancellation_clears_state(): void {
+ public function test_handle_cancellation_clears_state_when_order_object_is_null(): void {
$order = $this->schedule_for_pending_order();
$this->sut->handle_cancellation( $order->get_id() );
@@ -561,6 +587,19 @@ class SchedulerTest extends WC_Unit_Test_Case {
$this->assertFalse( as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order->get_id() ) ) );
}
+ /**
+ * @testdox handle_cancellation() clears the same state when the caller supplies the order object, so the status-change and delete entry points match the id-only trash path.
+ */
+ public function test_handle_cancellation_clears_state_when_order_object_passed(): void {
+ $order = $this->schedule_for_pending_order();
+
+ $this->sut->handle_cancellation( $order->get_id(), $order );
+
+ $fresh = wc_get_order( $order->get_id() );
+ $this->assertSame( '', $fresh->get_meta( Scheduler::SCHEDULED_META_KEY ), 'Scheduled-at meta must be cleared when the caller supplies the order object.' );
+ $this->assertFalse( as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order->get_id() ) ), 'Queued send must be unscheduled when the caller supplies the order object.' );
+ }
+
/**
* Create a pending order and run it through handle_new_order() so the
* tests for the cancel/status-change paths start from a known scheduled