Commit ec40b0bc174 for woocommerce

commit ec40b0bc1740c09625766bc409a8955559a0be32
Author: Thomas Roberts <5656702+opr@users.noreply.github.com>
Date:   Thu Aug 27 15:39:57 2026 +0100

    Fix deferred deletion removing replacement order items (#67975)

    * Add targeted order item deletion by ID

    * Preserve replacement items during deferred order deletion

    * Add regression coverage for deferred item deletion

    * Add changelog entry for deferred item deletion fix

    * Refactor deferred deletion regression tests

    * Remove resolved order deletion PHPStan baseline entry

    * Preserve deletion of custom order item types

    * Test legacy custom order data store fallback

    * Exercise legacy data store snapshot fallback

    * Remove version tags from private order helpers

    * Rename order data store item ID getter

    * Reject invalid persisted order item IDs

    * Preserve re-added items during deferred deletion

    * Test re-adding items queued for deferred deletion

    * Restore specific order item ID getter name

    * Surface deferred order item database failures

    * Use consistent order item ID getter name

    * Log order item database failures safely

    * Test order item deletion safety guards

    * Update order item helper since tags

diff --git a/plugins/woocommerce/changelog/fix-deferred-order-item-deletion b/plugins/woocommerce/changelog/fix-deferred-order-item-deletion
new file mode 100644
index 00000000000..2b80af41d6c
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-deferred-order-item-deletion
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Preserve replacement order items saved by extensions during deferred checkout item deletion.
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
index 4b258335a8d..6fe5343db48 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
@@ -118,6 +118,14 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
 	 */
 	protected $item_types_to_bulk_delete = array();

+	/**
+	 * Item IDs captured for bulk delete by type.
+	 *
+	 * @since 11.1.0
+	 * @var array<string, array<int>>
+	 */
+	protected $item_ids_to_bulk_delete_by_type = array();
+
 	/**
 	 * Whether every order item type should be deleted on the next save().
 	 *
@@ -129,6 +137,14 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
 	 */
 	protected $bulk_delete_all_items_pending = false;

+	/**
+	 * IDs of items captured when a full deferred removal is requested.
+	 *
+	 * @since 11.1.0
+	 * @var array<int>
+	 */
+	protected $item_ids_to_bulk_delete = array();
+
 	/**
 	 * Stores meta in cache for future reads.
 	 *
@@ -312,6 +328,91 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
 		);
 	}

+	/**
+	 * Deletes items in bulk if the data store supports it, otherwise deletes them one by one.
+	 *
+	 * @param array<int> $item_ids IDs of the items to delete.
+	 * @return void
+	 */
+	private function delete_items_by_ids( array $item_ids ): void {
+		/**
+		 * Data store wrapper.
+		 *
+		 * @var WC_Data_Store $data_store
+		 */
+		$data_store = $this->data_store;
+
+		if ( $data_store->has_callable( 'delete_items_by_ids' ) ) {
+			// @phpstan-ignore-next-line -- Optional data store method checked above.
+			$data_store->delete_items_by_ids( $this, $item_ids );
+		} else {
+			// Custom data stores may not support bulk deletion by IDs, so delete the snapshotted items individually
+			// so that items added *after* remove_order_items() are preserved.
+			foreach ( array_unique( $item_ids ) as $item_id ) {
+				$item = WC_Order_Factory::get_order_item( $item_id );
+
+				if ( $item && $this->get_id() === $item->get_order_id() ) {
+					$item->delete();
+				}
+			}
+		}
+	}
+
+	/**
+	 * Get IDs of items currently persisted for this order.
+	 *
+	 * @param string|null $type Item type, or null for every registered item type.
+	 * @return array<int>
+	 */
+	private function get_persisted_item_ids( $type = null ): array {
+		/**
+		 * Data store wrapper.
+		 *
+		 * @var WC_Data_Store $data_store
+		 */
+		$data_store = $this->data_store;
+
+		if ( $data_store->has_callable( 'get_item_ids' ) ) {
+			// @phpstan-ignore-next-line -- Optional data store method checked above.
+			$item_ids = $data_store->get_item_ids( $this, $type );
+
+			return array_values(
+				array_unique(
+					array_filter(
+						array_map( 'intval', (array) $item_ids ),
+						static fn( $item_id ) => $item_id > 0
+					)
+				)
+			);
+		}
+
+		$types = null === $type
+			? array_keys( $this->get_item_types_to_group() )
+			: array( $type );
+
+		$item_ids = array();
+		foreach ( $types as $item_type ) {
+			if ( ! is_string( $item_type ) || '' === $item_type ) {
+				continue;
+			}
+
+			// @phpstan-ignore-next-line -- Required order data store method forwarded by WC_Data_Store::__call().
+			$items = $this->data_store->read_items( $this, $item_type );
+			foreach ( (array) $items as $item ) {
+				if ( ! $item instanceof WC_Order_Item ) {
+					continue;
+				}
+				$item_id = absint( $item->get_id() );
+
+				if ( $item_id && $this->get_id() === (int) $item->get_order_id() ) {
+					$item_ids[] = $item_id;
+				}
+			}
+		}
+
+		return array_values( array_unique( $item_ids ) );
+	}
+
 	/**
 	 * Save all order items which are part of this order.
 	 *
@@ -321,10 +422,12 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
 		$items_changed = false;

 		if ( $this->bulk_delete_all_items_pending ) {
-			$this->data_store->delete_items( $this );
-			$this->bulk_delete_all_items_pending = false;
-			$this->item_types_to_bulk_delete     = array();
-			$items_changed                       = true;
+			$this->delete_items_by_ids( $this->item_ids_to_bulk_delete );
+			$this->bulk_delete_all_items_pending   = false;
+			$this->item_ids_to_bulk_delete_by_type = array();
+			$this->item_ids_to_bulk_delete         = array();
+			$this->item_types_to_bulk_delete       = array();
+			$items_changed                         = true;

 			/**
 			 * Trigger action after removing all order line items from the database.
@@ -336,11 +439,13 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
 			 */
 			do_action( 'woocommerce_removed_order_items', $this, null );
 		} elseif ( ! empty( $this->item_types_to_bulk_delete ) ) {
-			// Drain the queue one type at a time, dropping each entry only after delete_items() succeeds.
+			// Drain the queue one type at a time, dropping each entry only after item deletion succeeds.
 			// If a delete or hook callback throws, save()'s catch handles it and any types that have not
 			// yet been processed remain queued for the next save() rather than being silently lost.
 			foreach ( array_values( array_unique( $this->item_types_to_bulk_delete ) ) as $type ) {
-				$this->data_store->delete_items( $this, $type );
+				$item_ids = $this->item_ids_to_bulk_delete_by_type[ $type ] ?? array();
+				$this->delete_items_by_ids( $item_ids );
+				unset( $this->item_ids_to_bulk_delete_by_type[ $type ] );
 				$items_changed                   = true;
 				$this->item_types_to_bulk_delete = array_values(
 					array_filter(
@@ -961,6 +1066,7 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
 	 * the post-hook fire at save() time.
 	 *
 	 * @param string|null $type Order item type. Default null (remove every type).
+	 * @throws Exception If persisted item IDs cannot be read.
 	 * @return void
 	 */
 	public function remove_order_items( $type = null ) {
@@ -993,7 +1099,25 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {

 		if ( ! empty( $type ) ) {
 			if ( $has_persisted_items ) {
-				$this->item_types_to_bulk_delete[] = $type;
+				$item_ids = $this->get_persisted_item_ids( $type );
+
+				if ( $this->bulk_delete_all_items_pending ) {
+					$this->item_ids_to_bulk_delete = array_values(
+						array_unique(
+							array_merge( $this->item_ids_to_bulk_delete, $item_ids )
+						)
+					);
+				} else {
+					$this->item_types_to_bulk_delete[]              = $type;
+					$this->item_ids_to_bulk_delete_by_type[ $type ] = array_values(
+						array_unique(
+							array_merge(
+								$this->item_ids_to_bulk_delete_by_type[ $type ] ?? array(),
+								$item_ids
+							)
+						)
+					);
+				}
 			}

 			$group = $this->type_to_group( $type );
@@ -1006,8 +1130,20 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
 			}
 		} else {
 			if ( $has_persisted_items ) {
-				$this->bulk_delete_all_items_pending = true;
-				$this->item_types_to_bulk_delete     = array();
+				$item_ids = $this->get_persisted_item_ids();
+
+				foreach ( $this->item_ids_to_bulk_delete_by_type as $typed_item_ids ) {
+					$item_ids = array_merge( $item_ids, $typed_item_ids );
+				}
+
+				$this->bulk_delete_all_items_pending   = true;
+				$this->item_ids_to_bulk_delete         = array_values(
+					array_unique(
+						array_merge( $this->item_ids_to_bulk_delete, $item_ids )
+					)
+				);
+				$this->item_types_to_bulk_delete       = array();
+				$this->item_ids_to_bulk_delete_by_type = array();
 			}
 			$type_to_group = $this->get_item_types_to_group();
 			// Union with currently populated keys so any group already loaded into
@@ -1299,7 +1435,6 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
 		if ( ! $items_key ) {
 			return false;
 		}
-
 		// Make sure existing items are loaded so we can append this new one.
 		if ( ! isset( $this->items[ $items_key ] ) ) {
 			$this->items[ $items_key ] = $this->get_items( $item->get_type() );
@@ -1310,8 +1445,26 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {

 		// Append new row with generated temporary ID.
 		$item_id = $item->get_id();
+
 		if ( $item_id ) {
 			$this->items[ $items_key ][ $item_id ] = $item;
+
+			// Scrub this item from deferred bulk deletion snapshots, in case it was removed and then re-added before save().
+			$this->item_ids_to_bulk_delete = array_values(
+				array_filter(
+					$this->item_ids_to_bulk_delete,
+					static fn( $deleted_item_id ) => $deleted_item_id !== $item_id
+				)
+			);
+
+			foreach ( $this->item_ids_to_bulk_delete_by_type as $type => $item_ids ) {
+				$this->item_ids_to_bulk_delete_by_type[ $type ] = array_values(
+					array_filter(
+						$item_ids,
+						static fn( $deleted_item_id ) => $deleted_item_id !== $item_id
+					)
+				);
+			}
 		} else {
 			$this->items[ $items_key ][ 'new:' . $items_key . $this->temp_item_id_counter++ ] = $item;
 		}
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 10e30fda862..f218e05e3e4 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
@@ -780,6 +780,130 @@ abstract class Abstract_WC_Order_Data_Store_CPT extends WC_Data_Store_WP impleme
 	protected function prime_needs_processing_transients( $order_ids, $query_vars ) {
 	}

+	/**
+	 * Get persisted order item IDs, optionally limited to an item type.
+	 *
+	 * @since 11.1.0
+	 *
+	 * @param WC_Order    $order Order object.
+	 * @param string|null $type Order item type, or null for every type.
+	 * @throws Exception If the database query fails.
+	 * @return int[]
+	 */
+	public function get_item_ids( $order, $type = null ) {
+		global $wpdb;
+
+		if ( ! $order->get_id() ) {
+			return array();
+		}
+
+		if ( null === $type ) {
+			$item_ids = $wpdb->get_col(
+				$wpdb->prepare(
+					"SELECT order_item_id FROM {$wpdb->prefix}woocommerce_order_items WHERE order_id = %d",
+					$order->get_id()
+				)
+			);
+		} else {
+			$item_ids = $wpdb->get_col(
+				$wpdb->prepare(
+					"SELECT order_item_id FROM {$wpdb->prefix}woocommerce_order_items WHERE order_id = %d AND order_item_type = %s",
+					$order->get_id(),
+					$type
+				)
+			);
+		}
+
+		if ( '' !== $wpdb->last_error ) {
+			wc_get_logger()->error(
+				'Failed to retrieve persisted order item IDs.',
+				array(
+					'source'   => 'order-data-store',
+					'order_id' => $order->get_id(),
+					'error'    => $wpdb->last_error,
+				)
+			);
+			throw new Exception( esc_html__( 'Unable to retrieve persisted order item IDs.', 'woocommerce' ) );
+		}
+
+		return array_map( 'intval', $item_ids );
+	}
+
+	/**
+	 * Delete selected order items by ID.
+	 *
+	 * @since 11.1.0
+	 *
+	 * @param WC_Order $order Order object.
+	 * @param int[]    $ids   Order item IDs to delete.
+	 * @throws Exception If the database query fails.
+	 * @return void
+	 */
+	public function delete_items_by_ids( $order, $ids ) {
+		global $wpdb;
+
+		if ( ! $order->get_id() || empty( $ids ) ) {
+			return;
+		}
+
+		$sanitized_ids = array_values(
+			array_unique(
+				array_filter(
+					array_map( 'intval', (array) $ids ),
+					static fn( $id ) => $id > 0
+				)
+			)
+		);
+		if ( empty( $sanitized_ids ) ) {
+			return;
+		}
+
+		$ids_placeholders = implode( ', ', array_fill( 0, count( $sanitized_ids ), '%d' ) );
+
+		$result = $wpdb->query(
+			$wpdb->prepare(
+				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $ids_placeholders is generated above.
+				"DELETE itemmeta FROM {$wpdb->prefix}woocommerce_order_itemmeta as itemmeta INNER JOIN {$wpdb->prefix}woocommerce_order_items as items WHERE itemmeta.order_item_id = items.order_item_id AND items.order_id = %d AND items.order_item_id IN ($ids_placeholders)",
+				array_merge( array( $order->get_id() ), $sanitized_ids )
+			)
+		);
+		if ( false === $result ) {
+			wc_get_logger()->error(
+				'Failed to delete order item metadata.',
+				array(
+					'source'   => 'order-data-store',
+					'order_id' => $order->get_id(),
+					'error'    => $wpdb->last_error,
+				)
+			);
+			throw new Exception( esc_html__( 'Unable to delete order item metadata.', 'woocommerce' ) );
+		}
+		$result = $wpdb->query(
+			$wpdb->prepare(
+				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $ids_placeholders is generated above.
+				"DELETE FROM {$wpdb->prefix}woocommerce_order_items WHERE order_id = %d AND order_item_id IN ($ids_placeholders)",
+				array_merge( array( $order->get_id() ), $sanitized_ids )
+			)
+		);
+		if ( false === $result ) {
+			wc_get_logger()->error(
+				'Failed to delete order items.',
+				array(
+					'source'   => 'order-data-store',
+					'order_id' => $order->get_id(),
+					'error'    => $wpdb->last_error,
+				)
+			);
+			throw new Exception( esc_html__( 'Unable to delete order items.', 'woocommerce' ) );
+		}
+
+		foreach ( $sanitized_ids as $item_id ) {
+			wp_cache_delete( 'item-' . $item_id, 'order-items' );
+		}
+
+		$this->clear_caches( $order );
+	}
+
 	/**
 	 * Remove all line items (products, coupons, shipping, taxes) from the order.
 	 *
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 6964211caa3..1fddb9acec8 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -528,12 +528,6 @@ parameters:
 			count: 1
 			path: includes/abstracts/abstract-wc-order.php

-		-
-			message: '#^Call to an undefined method object\:\:delete_items\(\)\.$#'
-			identifier: method.notFound
-			count: 2
-			path: includes/abstracts/abstract-wc-order.php
-
 		-
 			message: '#^Call to an undefined method object\:\:get_order_item_type\(\)\.$#'
 			identifier: method.notFound
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 5cd2451f3ac..c13609b26c7 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
@@ -1102,6 +1102,112 @@ class WC_Abstract_Order_Test extends WC_Unit_Test_Case {
 		$this->assertCount( 0, $reloaded_after_save->get_items( 'shipping' ), 'Shipping items should be removed from the DB after save().' );
 	}

+	/**
+	 * @testdox remove_order_items() should surface a failed item ID snapshot query.
+	 */
+	public function test_remove_order_items_throws_when_snapshot_query_fails() {
+		global $wpdb;
+
+		$order          = WC_Helper_Order::create_order();
+		$query_fragment = "SELECT order_item_id FROM {$wpdb->prefix}woocommerce_order_items";
+		$query_filter   = static function ( $query ) use ( $query_fragment ) {
+			return false !== strpos( $query, $query_fragment ) ? 'INVALID SQL' : $query;
+		};
+
+		$logger = $this->createMock( WC_Logger_Interface::class );
+		$logger->expects( $this->once() )
+			->method( 'error' )
+			->with(
+				'Failed to retrieve persisted order item IDs.',
+				$this->callback(
+					static function ( $context ) use ( $order ) {
+						return 'order-data-store' === ( $context['source'] ?? null )
+							&& $order->get_id() === ( $context['order_id'] ?? null )
+							&& ! empty( $context['error'] );
+					}
+				)
+			);
+		$logger_filter = static function () use ( $logger ) {
+			return $logger;
+		};
+
+		$previous_suppress_errors = $wpdb->suppress_errors( true );
+		$caught_exception         = null;
+
+		add_filter( 'query', $query_filter );
+		add_filter( 'woocommerce_logging_class', $logger_filter );
+		try {
+			$order->remove_order_items();
+		} catch ( Exception $exception ) {
+			$caught_exception = $exception;
+		} finally {
+			remove_filter( 'query', $query_filter );
+			remove_filter( 'woocommerce_logging_class', $logger_filter );
+			$wpdb->suppress_errors( $previous_suppress_errors );
+		}
+
+		$this->assertInstanceOf( Exception::class, $caught_exception );
+		$this->assertSame( 'Unable to retrieve persisted order item IDs.', $caught_exception->getMessage() );
+		$this->assertNotEmpty( wc_get_order( $order->get_id() )->get_items(), 'A failed snapshot must leave persisted items unchanged.' );
+	}
+
+	/**
+	 * @testdox save() should retain the deferred deletion queue after a database delete fails.
+	 * @dataProvider provide_failed_order_item_delete_queries
+	 *
+	 * @param string $query_fragment SQL fragment identifying the query to fail.
+	 */
+	public function test_remove_order_items_retries_after_delete_query_fails( $query_fragment ) {
+		global $wpdb;
+
+		$order          = WC_Helper_Order::create_order();
+		$query_fragment = str_replace( '{prefix}', $wpdb->prefix, $query_fragment );
+		$query_filter   = static function ( $query ) use ( $query_fragment ) {
+			return false !== strpos( $query, $query_fragment ) ? 'INVALID SQL' : $query;
+		};
+
+		$order->remove_order_items( 'line_item' );
+
+		$previous_suppress_errors = $wpdb->suppress_errors( true );
+		add_filter( 'query', $query_filter );
+		try {
+			$order->save();
+		} finally {
+			remove_filter( 'query', $query_filter );
+			$wpdb->suppress_errors( $previous_suppress_errors );
+		}
+
+		$persisted_item_count = (int) $wpdb->get_var(
+			$wpdb->prepare(
+				"SELECT COUNT(*) FROM {$wpdb->prefix}woocommerce_order_items WHERE order_id = %d AND order_item_type = 'line_item'",
+				$order->get_id()
+			)
+		);
+		$this->assertGreaterThan( 0, $persisted_item_count, 'A failed delete must leave the item row available for retry.' );
+
+		$order->save();
+
+		$persisted_item_count = (int) $wpdb->get_var(
+			$wpdb->prepare(
+				"SELECT COUNT(*) FROM {$wpdb->prefix}woocommerce_order_items WHERE order_id = %d AND order_item_type = 'line_item'",
+				$order->get_id()
+			)
+		);
+		$this->assertSame( 0, $persisted_item_count, 'A subsequent save should retry and complete the queued deletion.' );
+	}
+
+	/**
+	 * Provides metadata and item-row deletion queries to fail.
+	 *
+	 * @return array<string, array{string}>
+	 */
+	public static function provide_failed_order_item_delete_queries(): array {
+		return array(
+			'item metadata delete' => array( 'DELETE itemmeta FROM' ),
+			'item row delete'      => array( 'DELETE FROM {prefix}woocommerce_order_items' ),
+		);
+	}
+
 	/**
 	 * @testdox add_item() must not overwrite an earlier unsaved item when items are removed and re-added before save().
 	 */
@@ -1144,6 +1250,356 @@ class WC_Abstract_Order_Test extends WC_Unit_Test_Case {
 		$this->assertCount( 2, $names );
 	}

+	/**
+	 * @testdox add_item() should preserve a persisted item re-added after deferred removal.
+	 * @dataProvider provide_deferred_removal_types
+	 *
+	 * @param string|null $type Item type to remove, or null to remove every type.
+	 */
+	public function test_add_item_keeps_persisted_item_after_remove_and_readd( $type ) {
+		$order            = WC_Helper_Order::create_order();
+		$item             = current( $order->get_items() );
+		$item_id          = $item->get_id();
+		$updated_quantity = 5;
+
+		$order->add_product( $item->get_product(), 1 );
+		$order->save();
+
+		$order->remove_order_items( $type );
+		$item->set_quantity( $updated_quantity );
+		$item->add_meta_data( '_readded_item', 'preserved', true );
+		$order->add_item( $item );
+		$order->save();
+
+		$persisted_items = wc_get_order( $order->get_id() )->get_items();
+		$this->assertCount( 1, $persisted_items, 'Only the explicitly re-added line item should remain.' );
+		$this->assertArrayHasKey( $item_id, $persisted_items, 'The re-added item should retain its ID.' );
+		$this->assertSame( $updated_quantity, $persisted_items[ $item_id ]->get_quantity(), 'The re-added item should retain its updated quantity.' );
+		$this->assertSame( 'preserved', $persisted_items[ $item_id ]->get_meta( '_readded_item' ), 'The re-added item should retain its metadata.' );
+	}
+
+	/**
+	 * Provides full and typed deferred removal cases.
+	 *
+	 * @return array<string, array{string|null}>
+	 */
+	public static function provide_deferred_removal_types(): array {
+		return array(
+			'all item types' => array( null ),
+			'line items'     => array( 'line_item' ),
+		);
+	}
+
+	/**
+	 * Create a product item for deferred deletion tests.
+	 *
+	 * @param WC_Product $product Product object.
+	 * @param string     $name    Item name.
+	 * @return WC_Order_Item_Product
+	 */
+	private function create_deferred_deletion_test_item( WC_Product $product, string $name ): WC_Order_Item_Product {
+		$item = new WC_Order_Item_Product();
+		$item->set_props(
+			array(
+				'product'  => $product,
+				'quantity' => 1,
+				'subtotal' => 10,
+				'total'    => 10,
+			)
+		);
+		$item->set_name( $name );
+		return $item;
+	}
+
+	/**
+	 * @testdox Should preserve replacement items saved while bulk deletion is pending.
+	 */
+	public function test_remove_order_items_preserves_replacement_items_saved_before_order_save() {
+		$order          = WC_Helper_Order::create_order();
+		$original_items = $order->get_items();
+		$product        = current( $original_items )->get_product();
+
+		$order->remove_order_items();
+
+		$early_saved_item_ids = array();
+		$expected_item_names  = array();
+		for ( $index = 1; $index <= 5; $index++ ) {
+			$item_name             = 'Replacement ' . $index;
+			$expected_item_names[] = $item_name;
+			$item                  = $this->create_deferred_deletion_test_item( $product, $item_name );
+			$item->add_meta_data( '_replacement_index', (string) $index, true );
+			$order->add_item( $item );
+
+			if ( $index <= 4 ) {
+				$item->set_order_id( $order->get_id() );
+				$early_saved_item_ids[] = $item->save();
+			}
+		}
+
+		$order->save();
+
+		$persisted_items = wc_get_order( $order->get_id() )->get_items();
+		$this->assertEqualsCanonicalizing(
+			$expected_item_names,
+			array_map( static fn( $item ) => $item->get_name(), $persisted_items ),
+			'Every replacement, and no original line item, should survive the deferred deletion.'
+		);
+		foreach ( $early_saved_item_ids as $item_id ) {
+			$this->assertArrayHasKey( $item_id, $persisted_items, 'An early-saved replacement should retain its original item ID.' );
+			$this->assertNotEmpty( $persisted_items[ $item_id ]->get_meta( '_replacement_index' ), 'An early-saved replacement should retain its metadata.' );
+		}
+	}
+
+	/**
+	 * @testdox Item-read filters should not control deletion snapshots, and deleted item caches should be cleared.
+	 */
+	public function test_remove_order_items_uses_unfiltered_snapshot_and_clears_item_cache() {
+		$order                      = WC_Helper_Order::create_order();
+		$original_line_item_ids     = array_keys( $order->get_items() );
+		$original_shipping_item_ids = array_keys( $order->get_items( 'shipping' ) );
+		$hide_items                 = static function () {
+			return array();
+		};
+
+		add_filter( 'woocommerce_order_get_items', $hide_items );
+		try {
+			$order->remove_order_items();
+		} finally {
+			remove_filter( 'woocommerce_order_get_items', $hide_items );
+		}
+
+		$order->save();
+
+		$persisted_order = wc_get_order( $order->get_id() );
+		$this->assertNotEmpty( $original_line_item_ids, 'The test order should initially contain line items.' );
+		$this->assertNotEmpty( $original_shipping_item_ids, 'The test order should initially contain shipping items.' );
+		$this->assertCount( 0, $persisted_order->get_items(), 'The read filter should not prevent persisted line items from being deleted.' );
+		$this->assertCount( 0, $persisted_order->get_items( 'shipping' ), 'The read filter should not prevent persisted shipping items from being deleted.' );
+
+		foreach ( array_merge( $original_line_item_ids, $original_shipping_item_ids ) as $item_id ) {
+			$this->assertFalse(
+				WC_Order_Factory::get_order_item( $item_id ),
+				'Deleted items should not remain available from the item cache.'
+			);
+		}
+	}
+
+	/**
+	 * @testdox Should delete persisted items whose types are not registered with the order object.
+	 * @dataProvider provide_unregistered_item_removal_types
+	 *
+	 * @param string|null $type Item type to remove, or null for every type.
+	 */
+	public function test_remove_order_items_deletes_unregistered_persisted_item_types( $type ) {
+		global $wpdb;
+
+		$order = WC_Helper_Order::create_order();
+		$wpdb->insert(
+			$wpdb->prefix . 'woocommerce_order_items',
+			array(
+				'order_item_name' => 'Extension item',
+				'order_item_type' => 'extension_item',
+				'order_id'        => $order->get_id(),
+			),
+			array( '%s', '%s', '%d' )
+		);
+		$extension_item_id = (int) $wpdb->insert_id;
+		wc_add_order_item_meta( $extension_item_id, '_extension_item_test', 'present' );
+
+		$this->assertGreaterThan( 0, $extension_item_id, 'Precondition: an extension item should be persisted.' );
+		$this->assertSame( 'present', wc_get_order_item_meta( $extension_item_id, '_extension_item_test', true ), 'Precondition: extension item metadata should be persisted.' );
+
+		$order->remove_order_items( $type );
+		$order->save();
+
+		$this->assertNull(
+			$wpdb->get_var(
+				$wpdb->prepare(
+					"SELECT order_item_id FROM {$wpdb->prefix}woocommerce_order_items WHERE order_item_id = %d",
+					$extension_item_id
+				)
+			),
+			'An unregistered persisted item should be deleted.'
+		);
+		$this->assertNull(
+			$wpdb->get_var(
+				$wpdb->prepare(
+					"SELECT meta_id FROM {$wpdb->prefix}woocommerce_order_itemmeta WHERE order_item_id = %d",
+					$extension_item_id
+				)
+			),
+			'Metadata for an unregistered persisted item should be deleted.'
+		);
+	}
+
+	/**
+	 * Provide full and typed removal requests for unregistered persisted items.
+	 *
+	 * @return array<string, array{string|null}>
+	 */
+	public static function provide_unregistered_item_removal_types(): array {
+		return array(
+			'full removal'  => array( null ),
+			'typed removal' => array( 'extension_item' ),
+		);
+	}
+
+	/**
+	 * @testdox Should preserve replacement items saved while deletion by type is pending.
+	 */
+	public function test_remove_order_items_by_type_preserves_replacement_items_saved_before_order_save() {
+		$order                 = WC_Helper_Order::create_order();
+		$original_items        = $order->get_items();
+		$product               = current( $original_items )->get_product();
+		$original_shipping_ids = array_keys( $order->get_items( 'shipping' ) );
+
+		$order->remove_order_items( 'line_item' );
+
+		$early_saved_item = $this->create_deferred_deletion_test_item( $product, 'Early-saved replacement' );
+		$early_saved_item->add_meta_data( '_typed_removal_test', 'preserved', true );
+		$order->add_item( $early_saved_item );
+		$early_saved_item->set_order_id( $order->get_id() );
+		$early_saved_item_id = $early_saved_item->save();
+
+		$unsaved_item = $this->create_deferred_deletion_test_item( $product, 'Unsaved replacement' );
+		$order->add_item( $unsaved_item );
+
+		$order->save();
+
+		$persisted_order = wc_get_order( $order->get_id() );
+		$persisted_items = $persisted_order->get_items();
+		$this->assertEqualsCanonicalizing(
+			array( 'Early-saved replacement', 'Unsaved replacement' ),
+			array_map( static fn( $item ) => $item->get_name(), $persisted_items ),
+			'Both replacements, and no original line item, should survive the typed deferred deletion.'
+		);
+		$this->assertArrayHasKey( $early_saved_item_id, $persisted_items, 'The early-saved replacement should retain its item ID.' );
+		$this->assertSame( 'preserved', $persisted_items[ $early_saved_item_id ]->get_meta( '_typed_removal_test' ), 'The early-saved replacement should retain its metadata.' );
+		$this->assertSame( $original_shipping_ids, array_keys( $persisted_order->get_items( 'shipping' ) ), 'Items of other types should remain unchanged.' );
+	}
+
+	/**
+	 * @testdox Repeated removal requests should combine their persisted item snapshots.
+	 * @dataProvider provide_repeated_remove_order_items_sequences
+	 *
+	 * @param string|null $first_type              First removal type.
+	 * @param string|null $second_type             Second removal type.
+	 * @param bool        $should_preserve_shipping Whether shipping should survive.
+	 */
+	public function test_repeated_remove_order_items_combines_snapshots( $first_type, $second_type, $should_preserve_shipping ) {
+		$order                 = WC_Helper_Order::create_order();
+		$original_items        = $order->get_items();
+		$original_item_ids     = array_keys( $original_items );
+		$product               = current( $original_items )->get_product();
+		$original_shipping_ids = array_keys( $order->get_items( 'shipping' ) );
+
+		$order->remove_order_items( $first_type );
+
+		$intermediate_item = $this->create_deferred_deletion_test_item( $product, 'Intermediate replacement' );
+		$order->add_item( $intermediate_item );
+		$intermediate_item->set_order_id( $order->get_id() );
+		$intermediate_item_id = $intermediate_item->save();
+
+		$order->remove_order_items( $second_type );
+
+		$final_item = $this->create_deferred_deletion_test_item( $product, 'Final replacement' );
+		$order->add_item( $final_item );
+		$order->save();
+
+		$persisted_order = wc_get_order( $order->get_id() );
+		$persisted_items = $persisted_order->get_items();
+		$this->assertSame(
+			array( 'Final replacement' ),
+			array_values( array_map( static fn( $item ) => $item->get_name(), $persisted_items ) ),
+			'Only the item added after the final removal should survive.'
+		);
+		$this->assertArrayNotHasKey( $intermediate_item_id, $persisted_items, 'The intermediate item should be deleted by the second removal.' );
+		foreach ( $original_item_ids as $original_item_id ) {
+			$this->assertArrayNotHasKey( $original_item_id, $persisted_items, 'Items captured by the first removal should still be deleted.' );
+		}
+
+		if ( $should_preserve_shipping ) {
+			$this->assertSame( $original_shipping_ids, array_keys( $persisted_order->get_items( 'shipping' ) ), 'Typed removals should preserve other item types.' );
+		} else {
+			$this->assertCount( 0, $persisted_order->get_items( 'shipping' ), 'A full removal should remove other item types.' );
+		}
+	}
+
+	/**
+	 * Provide repeated removal sequences.
+	 *
+	 * @return array<string, array{string|null, string|null, bool}>
+	 */
+	public static function provide_repeated_remove_order_items_sequences(): array {
+		return array(
+			'typed then typed' => array( 'line_item', 'line_item', true ),
+			'full then typed'  => array( null, 'line_item', false ),
+			'typed then full'  => array( 'line_item', null, false ),
+		);
+	}
+
+	/**
+	 * @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();
+
+		$original_data_store = $order->get_data_store();
+		// phpcs:disable Squiz.Commenting -- Anonymous test double methods are self-explanatory.
+		$custom_data_store = new class( $original_data_store ) extends WC_Data_Store {
+			private $delegate;
+
+			public function __construct( $delegate ) {
+				parent::__construct( 'order' );
+				$this->delegate = $delegate;
+			}
+
+			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 );
+			}
+
+			public function update( &$data ) {
+				return $this->delegate->update( $data );
+			}
+
+			public function get_current_class_name() {
+				return $this->delegate->get_current_class_name();
+			}
+
+			public function __call( $method, $parameters ) {
+				return $this->delegate->{$method}( ...$parameters );
+			}
+		};
+		// phpcs:enable Squiz.Commenting
+
+		$reflection = new ReflectionProperty( WC_Data::class, 'data_store' );
+		$reflection->setAccessible( true );
+		$reflection->setValue( $order, $custom_data_store );
+
+		$order->remove_order_items();
+
+		$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 );
+		$early_saved_item->set_order_id( $order->get_id() );
+		$early_saved_item_id = $early_saved_item->save();
+
+		$unsaved_item = $this->create_deferred_deletion_test_item( $product, 'Unsaved replacement' );
+		$order->add_item( $unsaved_item );
+		$order->save();
+
+		$persisted_items = wc_get_order( $order->get_id() )->get_items();
+		$this->assertEqualsCanonicalizing(
+			array( 'Early-saved replacement', 'Unsaved replacement' ),
+			array_map( static fn( $item ) => $item->get_name(), $persisted_items ),
+			'Both replacements, and no original item, should survive the custom data store fallback.'
+		);
+		$this->assertArrayHasKey( $early_saved_item_id, $persisted_items, 'The early-saved replacement should retain its item ID.' );
+		$this->assertSame( 'preserved', $persisted_items[ $early_saved_item_id ]->get_meta( '_fallback_test' ), 'The early-saved replacement should retain its metadata.' );
+	}
+
 	/**
 	 * @testdox Should keep original items in the DB if save() never runs after remove_order_items().
 	 */
diff --git a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-order-data-store-cpt-test.php b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-order-data-store-cpt-test.php
index 63e6902cfd2..2ccc0bbff5d 100644
--- a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-order-data-store-cpt-test.php
+++ b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-order-data-store-cpt-test.php
@@ -582,6 +582,26 @@ class WC_Order_Data_Store_CPT_Test extends WC_Unit_Test_Case {
 		$this->assertFalse( $r_order->get_item( $shipping_item->get_id() ) );
 	}

+	/**
+	 * @testdox delete_items_by_ids() should reject invalid IDs and preserve items belonging to other orders.
+	 */
+	public function test_delete_items_by_ids_sanitizes_and_scopes_ids_to_order() {
+		$order_1         = WC_Helper_Order::create_order();
+		$order_2         = WC_Helper_Order::create_order();
+		$order_1_item_id = array_key_first( $order_1->get_items() );
+		$order_2_item_id = array_key_first( $order_2->get_items() );
+		$data_store      = $order_1->get_data_store();
+
+		$data_store->delete_items_by_ids( $order_1, array( -$order_1_item_id, 0 ) );
+
+		$this->assertInstanceOf( WC_Order_Item::class, WC_Order_Factory::get_order_item( $order_1_item_id ), 'Negative and zero IDs should be rejected.' );
+
+		$data_store->delete_items_by_ids( $order_1, array( $order_1_item_id, $order_1_item_id, $order_2_item_id ) );
+
+		$this->assertFalse( WC_Order_Factory::get_order_item( $order_1_item_id ), 'A valid item belonging to the target order should be deleted.' );
+		$this->assertInstanceOf( WC_Order_Item::class, WC_Order_Factory::get_order_item( $order_2_item_id ), 'An item belonging to another order should remain.' );
+	}
+
 	/**
 	 * @testDox Deleting order item should delete items from only that order.
 	 */