Commit 80c1a71005b for woocommerce
commit 80c1a71005bd89dd1a3af1e03e002261c74a38d6
Author: Thomas Roberts <5656702+opr@users.noreply.github.com>
Date: Fri Aug 28 14:12:32 2026 +0100
Preserve custom order item deletion behavior (#68107)
* Preserve custom order item deletion behavior
* Add changelog entry for order item deletion fix
* Test custom order item deletion override
* Restore synchronous custom order item deletion
* Cover replacement items in custom deletion tests
* Clarify custom store deferred deletion opt-in
* Clarify deferred item deletion opt-in
* Fall back when custom stores lack item deletion
* Cache deferred item deletion support
diff --git a/plugins/woocommerce/changelog/fix-order-item-delete-overrides b/plugins/woocommerce/changelog/fix-order-item-delete-overrides
new file mode 100644
index 00000000000..640780a537b
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-order-item-delete-overrides
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Preserve custom data store cleanup when removing order items.
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
index 6fe5343db48..ad074f6836c 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
@@ -105,6 +105,13 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
*/
protected $temp_item_id_counter = 0;
+ /**
+ * Whether the data store supports deferred item deletion.
+ *
+ * @var bool|null Null until first checked.
+ */
+ private $data_store_supports_deferred_item_deletion = null;
+
/**
* Bulk order item types scheduled for deletion on save().
*
@@ -358,6 +365,54 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
}
}
+ /**
+ * Determine whether the data store supports deferred item deletion.
+ *
+ * @return bool
+ */
+ private function data_store_supports_deferred_item_deletion(): bool {
+ if ( null !== $this->data_store_supports_deferred_item_deletion ) {
+ return $this->data_store_supports_deferred_item_deletion;
+ }
+
+ /**
+ * Data store wrapper.
+ *
+ * @var WC_Data_Store $data_store
+ */
+ $data_store = $this->data_store;
+
+ if ( ! $data_store->has_callable( 'delete_items' ) ) {
+ $this->data_store_supports_deferred_item_deletion = true;
+ return true;
+ }
+
+ $data_store_class = $data_store->get_current_class_name();
+ $is_cpt_store = is_a( $data_store_class, Abstract_WC_Order_Data_Store_CPT::class, true );
+
+ if ( ! $is_cpt_store ) {
+ // Standalone data stores opt in to deferred deletion by providing this optional method.
+ $this->data_store_supports_deferred_item_deletion = $data_store->has_callable( 'delete_items_by_ids' );
+ return $this->data_store_supports_deferred_item_deletion;
+ }
+
+ $delete_items_method = new ReflectionMethod( $data_store_class, 'delete_items' );
+ if ( Abstract_WC_Order_Data_Store_CPT::class === $delete_items_method->getDeclaringClass()->getName() ) {
+ $this->data_store_supports_deferred_item_deletion = true;
+ return true;
+ }
+
+ if ( ! $data_store->has_callable( 'delete_items_by_ids' ) ) {
+ $this->data_store_supports_deferred_item_deletion = false;
+ return false;
+ }
+
+ $delete_items_by_ids_method = new ReflectionMethod( $data_store_class, 'delete_items_by_ids' );
+
+ $this->data_store_supports_deferred_item_deletion = Abstract_WC_Order_Data_Store_CPT::class !== $delete_items_by_ids_method->getDeclaringClass()->getName();
+ return $this->data_store_supports_deferred_item_deletion;
+ }
+
/**
* Get IDs of items currently persisted for this order.
*
@@ -1055,18 +1110,12 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
/**
* Remove all line items (products, coupons, shipping, taxes) from the order.
*
- * The items are cleared from the in-memory order immediately, but the database
- * deletion is deferred until the next call to save(). This keeps the checkout
- * "resume order" flow atomic: if anything between here and save() throws, the
- * previously persisted items remain intact in the database. As a consequence,
- * the `woocommerce_removed_order_items` action now fires from save_items()
- * (after the actual DB delete completes) rather than synchronously from this
- * method — listeners that observe the persisted state continue to see it as
- * before, but listeners pairing pre/post on the same call stack will see
- * the post-hook fire at save() time.
+ * The items are cleared from the in-memory order immediately, but core data stores defer
+ * database deletion until the next call to save(). Custom stores overriding `delete_items()`
+ * without also overriding `delete_items_by_ids()` retain the historical synchronous behavior.
*
* @param string|null $type Order item type. Default null (remove every type).
- * @throws Exception If persisted item IDs cannot be read.
+ * @throws Exception If persisted item IDs cannot be read or synchronous item deletion fails.
* @return void
*/
public function remove_order_items( $type = null ) {
@@ -1095,10 +1144,16 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
do_action( 'woocommerce_remove_order_items', $this, $type );
// Unsaved orders (id 0) have no persisted items — there's nothing to defer for deletion.
- $has_persisted_items = $this->get_id() > 0;
+ $has_persisted_items = $this->get_id() > 0;
+ $delete_synchronously = ! $this->data_store_supports_deferred_item_deletion();
+
+ if ( $delete_synchronously && $has_persisted_items ) {
+ // @phpstan-ignore-next-line -- Required order data store method forwarded by WC_Data_Store::__call().
+ $this->data_store->delete_items( $this, $type );
+ }
if ( ! empty( $type ) ) {
- if ( $has_persisted_items ) {
+ if ( $has_persisted_items && ! $delete_synchronously ) {
$item_ids = $this->get_persisted_item_ids( $type );
if ( $this->bulk_delete_all_items_pending ) {
@@ -1129,7 +1184,7 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
$this->items[ $group ] = array();
}
} else {
- if ( $has_persisted_items ) {
+ if ( $has_persisted_items && ! $delete_synchronously ) {
$item_ids = $this->get_persisted_item_ids();
foreach ( $this->item_ids_to_bulk_delete_by_type as $typed_item_ids ) {
@@ -1157,6 +1212,15 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
$this->items[ $group ] = array();
}
}
+
+ if ( $delete_synchronously ) {
+ /**
+ * This action is documented in save_items().
+ *
+ * @since 7.8.0
+ */
+ do_action( 'woocommerce_removed_order_items', $this, $type );
+ }
}
/**
diff --git a/plugins/woocommerce/includes/data-stores/abstract-wc-order-data-store-cpt.php b/plugins/woocommerce/includes/data-stores/abstract-wc-order-data-store-cpt.php
index f218e05e3e4..d4c9d7e9fb3 100644
--- a/plugins/woocommerce/includes/data-stores/abstract-wc-order-data-store-cpt.php
+++ b/plugins/woocommerce/includes/data-stores/abstract-wc-order-data-store-cpt.php
@@ -832,6 +832,8 @@ abstract class Abstract_WC_Order_Data_Store_CPT extends WC_Data_Store_WP impleme
/**
* Delete selected order items by ID.
*
+ * Custom order data stores that override this method opt in to deferred item deletion. The IDs are captured when items are removed and deleted during order save.
+ *
* @since 11.1.0
*
* @param WC_Order $order Order object.
diff --git a/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-order-test.php b/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-order-test.php
index c13609b26c7..d50e0419526 100644
--- a/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-order-test.php
+++ b/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-order-test.php
@@ -1538,13 +1538,164 @@ class WC_Abstract_Order_Test extends WC_Unit_Test_Case {
);
}
+ /**
+ * @testdox Should synchronously invoke custom data store item deletion behavior.
+ * @testWith [null]
+ * ["line_item"]
+ *
+ * @param string|null $type Item type, or null for every type.
+ */
+ public function test_remove_order_items_invokes_custom_data_store_delete_items( $type ) {
+ $order = WC_Helper_Order::create_order();
+ $original_data_store = $order->get_data_store();
+ $line_item_ids = array_keys( $order->get_items() );
+ $shipping_item_ids = array_keys( $order->get_items( 'shipping' ) );
+ $expected_deleted_ids = null === $type ? array_merge( $line_item_ids, $shipping_item_ids ) : $line_item_ids;
+ $expected_retained_ids = null === $type ? array() : $shipping_item_ids;
+
+ // phpcs:disable Squiz.Commenting -- Anonymous test double methods are self-explanatory.
+ $custom_data_store = new class( $original_data_store ) extends WC_Order_Data_Store_CPT {
+ public $deleted_item_types = array();
+
+ private $delegate;
+
+ public function __construct( $delegate ) {
+ $this->delegate = $delegate;
+ }
+
+ public function update( &$order ) {
+ return $this->delegate->update( $order );
+ }
+
+ public function delete_items( $order, $type = null ) {
+ $this->deleted_item_types[] = $type;
+ return $this->delegate->delete_items( $order, $type );
+ }
+ };
+ // phpcs:enable Squiz.Commenting
+
+ $data_store_filter = static function () use ( $custom_data_store ) {
+ return $custom_data_store;
+ };
+ $removed_item_types = array();
+ $removed_hook = static function ( $hook_order, $hook_type ) use ( &$removed_item_types ) {
+ $removed_item_types[] = $hook_type;
+ };
+ add_filter( 'woocommerce_order_data_store', $data_store_filter, PHP_INT_MAX );
+ add_action( 'woocommerce_removed_order_items', $removed_hook, 10, 2 );
+
+ try {
+ $reflection = new ReflectionProperty( WC_Data::class, 'data_store' );
+ $reflection->setAccessible( true );
+ $reflection->setValue( $order, new WC_Data_Store( 'order' ) );
+
+ $order->remove_order_items( $type );
+
+ $this->assertSame( array( $type ), $custom_data_store->deleted_item_types, 'The custom delete_items() implementation should run synchronously with the requested type.' );
+ $this->assertSame( array( $type ), $removed_item_types, 'The post-removal hook should run synchronously with the requested type.' );
+ foreach ( $expected_deleted_ids as $item_id ) {
+ $this->assertFalse( WC_Order_Factory::get_order_item( $item_id ), 'Items selected for removal should be deleted synchronously.' );
+ }
+ foreach ( $expected_retained_ids as $item_id ) {
+ $this->assertInstanceOf( WC_Order_Item::class, WC_Order_Factory::get_order_item( $item_id ), 'Items of other types should be retained.' );
+ }
+
+ $order->save();
+
+ $this->assertSame( array( $type ), $custom_data_store->deleted_item_types, 'Saving should not invoke the custom delete_items() implementation again.' );
+ $this->assertSame( array( $type ), $removed_item_types, 'Saving should not fire the post-removal hook again.' );
+ } finally {
+ remove_filter( 'woocommerce_order_data_store', $data_store_filter, PHP_INT_MAX );
+ remove_action( 'woocommerce_removed_order_items', $removed_hook, 10 );
+ }
+ }
+
+ /**
+ * @testdox Should defer deletion when a custom data store opts in to ID-based deletion.
+ */
+ public function test_remove_order_items_defers_custom_data_store_id_deletion() {
+ $order = WC_Helper_Order::create_order();
+ $original_items = $order->get_items();
+ $product = current( $original_items )->get_product();
+ $original_data_store = $order->get_data_store();
+ $original_item_ids = array_merge( array_keys( $original_items ), array_keys( $order->get_items( 'shipping' ) ) );
+
+ // phpcs:disable Squiz.Commenting -- Anonymous test double methods are self-explanatory.
+ $custom_data_store = new class( $original_data_store ) extends WC_Order_Data_Store_CPT {
+ public $delete_items_call_count = 0;
+
+ public $deleted_item_id_batches = array();
+
+ private $delegate;
+
+ public function __construct( $delegate ) {
+ $this->delegate = $delegate;
+ }
+
+ public function update( &$order ) {
+ return $this->delegate->update( $order );
+ }
+
+ public function delete_items( $order, $type = null ) {
+ ++$this->delete_items_call_count;
+ return $this->delegate->delete_items( $order, $type );
+ }
+
+ public function delete_items_by_ids( $order, $ids ) {
+ $this->deleted_item_id_batches[] = $ids;
+ return $this->delegate->delete_items_by_ids( $order, $ids );
+ }
+ };
+ // phpcs:enable Squiz.Commenting
+
+ $data_store_filter = static function () use ( $custom_data_store ) {
+ return $custom_data_store;
+ };
+ add_filter( 'woocommerce_order_data_store', $data_store_filter, PHP_INT_MAX );
+
+ try {
+ $reflection = new ReflectionProperty( WC_Data::class, 'data_store' );
+ $reflection->setAccessible( true );
+ $reflection->setValue( $order, new WC_Data_Store( 'order' ) );
+
+ $order->remove_order_items();
+
+ $this->assertSame( 0, $custom_data_store->delete_items_call_count, 'The legacy deletion override should not run when custom ID-based deletion is available.' );
+ $this->assertSame( array(), $custom_data_store->deleted_item_id_batches, 'ID-based deletion should remain deferred until save().' );
+ foreach ( $original_item_ids as $item_id ) {
+ $this->assertInstanceOf( WC_Order_Item::class, WC_Order_Factory::get_order_item( $item_id ), 'Items should remain persisted until save().' );
+ }
+
+ $replacement_item = $this->create_deferred_deletion_test_item( $product, 'Early-saved replacement' );
+ $replacement_item->add_meta_data( '_custom_id_deletion_test', 'preserved', true );
+ $order->add_item( $replacement_item );
+ $replacement_item->set_order_id( $order->get_id() );
+ $replacement_item_id = $replacement_item->save();
+
+ $order->save();
+ } finally {
+ remove_filter( 'woocommerce_order_data_store', $data_store_filter, PHP_INT_MAX );
+ }
+
+ $this->assertSame( 0, $custom_data_store->delete_items_call_count, 'The legacy deletion override should not run during save().' );
+ $this->assertCount( 1, $custom_data_store->deleted_item_id_batches, 'The custom ID-based deletion override should run once during save().' );
+ $this->assertEqualsCanonicalizing( $original_item_ids, $custom_data_store->deleted_item_id_batches[0], 'The custom ID-based deletion override should receive the snapshotted item IDs.' );
+ foreach ( $original_item_ids as $item_id ) {
+ $this->assertFalse( WC_Order_Factory::get_order_item( $item_id ), 'Snapshotted items should be deleted during save().' );
+ }
+ $persisted_replacement = WC_Order_Factory::get_order_item( $replacement_item_id );
+ $this->assertInstanceOf( WC_Order_Item::class, $persisted_replacement, 'An item saved after removal should not be deleted during save().' );
+ $this->assertSame( 'preserved', $persisted_replacement->get_meta( '_custom_id_deletion_test' ), 'The replacement item metadata should be preserved.' );
+ }
+
/**
* @testdox Should preserve replacement items with a legacy custom data store lacking ID snapshot and deletion methods.
*/
public function test_remove_order_items_preserves_replacements_with_custom_data_store_fallback() {
- $order = WC_Helper_Order::create_order();
- $original_items = $order->get_items();
- $product = current( $original_items )->get_product();
+ $order = WC_Helper_Order::create_order();
+ $original_items = $order->get_items();
+ $original_item_ids = array_merge( array_keys( $original_items ), array_keys( $order->get_items( 'shipping' ) ) );
+ $product = current( $original_items )->get_product();
$original_data_store = $order->get_data_store();
// phpcs:disable Squiz.Commenting -- Anonymous test double methods are self-explanatory.
@@ -1557,7 +1708,7 @@ class WC_Abstract_Order_Test extends WC_Unit_Test_Case {
}
public function has_callable( string $method ): bool {
- return in_array( $method, array( 'get_item_ids', 'delete_items_by_ids' ), true ) ? false : $this->delegate->has_callable( $method );
+ return in_array( $method, array( 'delete_items', 'get_item_ids', 'delete_items_by_ids' ), true ) ? false : $this->delegate->has_callable( $method );
}
public function update( &$data ) {
@@ -1565,7 +1716,7 @@ class WC_Abstract_Order_Test extends WC_Unit_Test_Case {
}
public function get_current_class_name() {
- return $this->delegate->get_current_class_name();
+ return get_class( $this );
}
public function __call( $method, $parameters ) {
@@ -1580,6 +1731,10 @@ class WC_Abstract_Order_Test extends WC_Unit_Test_Case {
$order->remove_order_items();
+ foreach ( $original_item_ids as $item_id ) {
+ $this->assertInstanceOf( WC_Order_Item::class, WC_Order_Factory::get_order_item( $item_id ), 'Items should remain persisted until save().' );
+ }
+
$early_saved_item = $this->create_deferred_deletion_test_item( $product, 'Early-saved replacement' );
$early_saved_item->add_meta_data( '_fallback_test', 'preserved', true );
$order->add_item( $early_saved_item );