Commit 90764a38077 for woocommerce

commit 90764a380773a7f506434b95e7461c5853e9a0bc
Author: Vladimir Reznichenko <kalessil@gmail.com>
Date:   Thu Apr 30 08:39:05 2026 +0200

    [Performance] Reduce the frequency of wc_get_product/wc_get_order calls during checkout (#64418)

    Order composition workflows have been updated to inject order and product information into order items. The 'get_product' method for order items has been refactored to use request-level caching for the product object.

diff --git a/plugins/woocommerce/changelog/performance-checkout-thruput-order-model-composition b/plugins/woocommerce/changelog/performance-checkout-thruput-order-model-composition
new file mode 100644
index 00000000000..5fac0595864
--- /dev/null
+++ b/plugins/woocommerce/changelog/performance-checkout-thruput-order-model-composition
@@ -0,0 +1,4 @@
+Significance: minor
+Type: performance
+
+Reduced the frequency of wc_get_product and wc_get_order calls during checkout to improve performance.
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
index e341b5c1338..fd3bdc3db75 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
@@ -76,7 +76,7 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
 	 * Order items will be stored here, sometimes before they persist in the DB.
 	 *
 	 * @since 3.0.0
-	 * @var array<string, array<int, \WC_Order_Item>>
+	 * @var array<string, array<\WC_Order_Item>>
 	 */
 	protected $items = array();

@@ -954,15 +954,17 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
 					// on warm post meta caches for products. This addresses scenarios where the order object was not populated during a batch population.
 					if ( 'line_item' === $type && ! empty( $read_items ) ) {
 						$product_ids = array_map( static fn( $item ) => $item->get_variation_id() ? $item->get_variation_id() : $item->get_product_id(), $read_items );
-						$product_ids = array_unique( array_filter( $product_ids ) );
-						_prime_post_caches( $product_ids );
+						_prime_post_caches( array_unique( array_filter( $product_ids ) ) );
 					}

+					// Set the back-reference to the parent order on each loaded item.
+					array_walk( $read_items, fn( $item ) => $item instanceof WC_Order_Item && $item->set_order( $this ) );
+
 					$this->items[ $group ] = $read_items;
 				}
 				// Don't use array_merge here because keys are numeric.
 				$items = $items + $this->items[ $group ];
-			}
+			}//end if
 		}

 		return apply_filters( 'woocommerce_order_get_items', $items, $this, $types );
@@ -1165,11 +1167,10 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
 		}

 		// Set parent.
-		$item->set_order_id( $this->get_id() );
+		$item instanceof WC_Order_Item ? $item->set_order( $this ) : $item->set_order_id( $this->get_id() );

 		// Append new row with generated temporary ID.
 		$item_id = $item->get_id();
-
 		if ( $item_id ) {
 			$this->items[ $items_key ][ $item_id ] = $item;
 		} else {
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-product.php b/plugins/woocommerce/includes/abstracts/abstract-wc-product.php
index bf4c8284fc6..7fb8497ce06 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-product.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-product.php
@@ -14,6 +14,7 @@ use Automattic\WooCommerce\Enums\ProductStockStatus;
 use Automattic\WooCommerce\Enums\ProductTaxStatus;
 use Automattic\WooCommerce\Enums\ProductType;
 use Automattic\WooCommerce\Enums\CatalogVisibility;
+use Automattic\WooCommerce\Internal\Caches\StaleObjectAttribution;
 use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareTrait;
 use Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore as ProductAttributesLookupDataStore;

@@ -33,6 +34,7 @@ require_once WC_ABSPATH . 'includes/legacy/abstract-wc-legacy-product.php';
  */
 class WC_Product extends WC_Abstract_Legacy_Product {
 	use CogsAwareTrait;
+	use StaleObjectAttribution;

 	/**
 	 * This is the name of this object type.
@@ -146,6 +148,8 @@ class WC_Product extends WC_Abstract_Legacy_Product {
 		if ( $this->get_id() > 0 ) {
 			$this->data_store->read( $this );
 		}
+
+		$this->_woocommerce_object_instantiated();
 	}

 	/**
diff --git a/plugins/woocommerce/includes/class-wc-checkout.php b/plugins/woocommerce/includes/class-wc-checkout.php
index 4397f008237..dbbb004e34c 100644
--- a/plugins/woocommerce/includes/class-wc-checkout.php
+++ b/plugins/woocommerce/includes/class-wc-checkout.php
@@ -547,6 +547,8 @@ class WC_Checkout {
 					'subtotal_tax' => $values['line_subtotal_tax'],
 					'total_tax'    => $values['line_tax'],
 					'taxes'        => $values['line_tax_data'],
+					// Order model compositions: set the order instance (early one, for `set_backorder_meta`).
+					'order'        => $order,
 				)
 			);

@@ -557,6 +559,8 @@ class WC_Checkout {
 						'tax_class'    => $product->get_tax_class(),
 						'product_id'   => $product->is_type( ProductType::VARIATION ) ? $product->get_parent_id() : $product->get_id(),
 						'variation_id' => $product->is_type( ProductType::VARIATION ) ? $product->get_id() : 0,
+						// Order model compositions: set the product instance.
+						'product'      => $product,
 					)
 				);
 			}
diff --git a/plugins/woocommerce/includes/class-wc-order-item-product.php b/plugins/woocommerce/includes/class-wc-order-item-product.php
index 917e0edc4c6..c8abf5d6701 100644
--- a/plugins/woocommerce/includes/class-wc-order-item-product.php
+++ b/plugins/woocommerce/includes/class-wc-order-item-product.php
@@ -56,6 +56,14 @@ class WC_Order_Item_Product extends WC_Order_Item {
 		),
 	);

+	/**
+	 * The product object is set using set_product or is automatically populated by get_product. This property was introduced
+	 * to reduce the number of wc_get_product calls when working with this class in core and extension workflows.
+	 *
+	 * @var null|false|WC_Product
+	 */
+	private $product;
+
 	/*
 	|--------------------------------------------------------------------------
 	| Setters
@@ -92,7 +100,19 @@ class WC_Order_Item_Product extends WC_Order_Item {
 		if ( $value > 0 && 'product' !== get_post_type( absint( $value ) ) ) {
 			$this->error( 'order_item_product_invalid_product_id', __( 'Invalid product ID', 'woocommerce' ) );
 		}
-		$this->set_prop( 'product_id', absint( $value ) );
+		$product_id = absint( $value );
+		$this->set_prop( 'product_id', $product_id );
+
+		if ( null !== $this->product ) {
+			// Cached instance invalidation: match the cached product identity against the incoming ID.
+			$cached_product_id = null;
+			if ( $this->product ) {
+				$cached_product_id = $this->product->is_type( ProductType::VARIATION ) ? $this->product->get_parent_id() : $this->product->get_id();
+			}
+			if ( ! $this->product || $cached_product_id !== $product_id ) {
+				$this->product = null;
+			}
+		}
 	}

 	/**
@@ -109,7 +129,19 @@ class WC_Order_Item_Product extends WC_Order_Item {
 				array( 'variation_id' => $value )
 			);
 		}
-		$this->set_prop( 'variation_id', absint( $value ) );
+		$variation_id = absint( $value );
+		$this->set_prop( 'variation_id', $variation_id );
+
+		if ( null !== $this->product ) {
+			// Cached instance invalidation: match the cached product identity against the incoming ID.
+			$cached_variation_id = null;
+			if ( $this->product ) {
+				$cached_variation_id = $this->product->is_type( ProductType::VARIATION ) ? $this->product->get_id() : 0;
+			}
+			if ( ! $this->product || $cached_variation_id !== $variation_id ) {
+				$this->product = null;
+			}
+		}
 	}

 	/**
@@ -244,12 +276,13 @@ class WC_Order_Item_Product extends WC_Order_Item {
 	}

 	/**
-	 * Set properties based on passed in product object.
+	 * Aggregate and set properties based on passed in product object.
 	 *
 	 * @param WC_Product $product Product instance.
+	 * @return void
 	 */
 	public function set_product( $product ) {
-		if ( ! is_a( $product, 'WC_Product' ) ) {
+		if ( ! ( $product instanceof \WC_Product ) ) {
 			$this->error( 'order_item_product_invalid_product', __( 'Invalid product', 'woocommerce' ) );
 		}
 		if ( $product->is_type( ProductType::VARIATION ) ) {
@@ -261,6 +294,7 @@ class WC_Order_Item_Product extends WC_Order_Item {
 		}
 		$this->set_name( $product->get_name() );
 		$this->set_tax_class( $product->get_tax_class() );
+		$this->product = $product;
 	}

 	/**
@@ -383,21 +417,51 @@ class WC_Order_Item_Product extends WC_Order_Item {
 	/**
 	 * Get the associated product.
 	 *
+	 * @since 10.9.0 returns the same product instance; if the product is updated or deleted, the instance will be re-instantiated.
 	 * @return WC_Product|bool
 	 */
 	public function get_product() {
-		if ( $this->get_variation_id() ) {
-			$product = wc_get_product( $this->get_variation_id() );
-		} else {
-			$product = wc_get_product( $this->get_product_id() );
+		if ( $this->product ) {
+			$reinstantiate = ! ( $this->product instanceof WC_Product ) ||
+				$this->product->_woocommerce_object_is_stale() ||
+				$this->product->_woocommerce_object_is_dirty();
+			if ( $reinstantiate ) {
+				$this->product = null;
+			}
+		}
+		if ( ! $this->product ) {
+			$product_id    = $this->get_variation_id() ? $this->get_variation_id() : $this->get_product_id();
+			$this->product = wc_get_product( $product_id );
 		}

 		// Backwards compatible filter from WC_Order::get_product_from_item().
+		/** @var WC_Product|false $product */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort
+		$product = $this->product;
 		if ( has_filter( 'woocommerce_get_product_from_item' ) ) {
+			/**
+			 * Modifies the product object returned by \WC_Order_Item_Product::get_product.
+			 *
+			 * @since 10.9.0 the method is caching the product object.
+			 * @since 2.7.0 filter introduction.
+			 *
+			 * @param WC_Product|false      $product   Product instance.
+			 * @param WC_Order_Item_Product $line_item Line item instance.
+			 * @param WC_Order              $order     Order Instance.
+			 */
 			$product = apply_filters( 'woocommerce_get_product_from_item', $product, $this, $this->get_order() );
 		}
+		/**
+		 * Modifies the product object returned by \WC_Order_Item_Product::get_product.
+		 *
+		 * @since 10.9.0 the method is caching the product object.
+		 * @since 2.7.0 filter introduction.
+		 *
+		 * @param WC_Product|false      $product   Product instance.
+		 * @param WC_Order_Item_Product $line_item Line item instance.
+		 */
+		$product = apply_filters( 'woocommerce_order_item_product', $product, $this );

-		return apply_filters( 'woocommerce_order_item_product', $product, $this );
+		return $product;
 	}

 	/**
diff --git a/plugins/woocommerce/includes/class-wc-order-item.php b/plugins/woocommerce/includes/class-wc-order-item.php
index 7080cb2466b..22858fcd59a 100644
--- a/plugins/woocommerce/includes/class-wc-order-item.php
+++ b/plugins/woocommerce/includes/class-wc-order-item.php
@@ -81,6 +81,15 @@ class WC_Order_Item extends WC_Data implements ArrayAccess {
 	 */
 	public $legacy_package_key;

+	/**
+	 * The order object is set using set_order. This property was introduced to reduce the number
+	 * of wc_get_order calls when working with this class in core and extension workflows.
+	 * Stored as a WeakReference to avoid circular reference memory retention in batch/CLI workflows.
+	 *
+	 * @var null|\WeakReference<\WC_Order>
+	 */
+	private $order;
+
 	/**
 	 * Constructor.
 	 *
@@ -191,10 +200,12 @@ class WC_Order_Item extends WC_Data implements ArrayAccess {
 	/**
 	 * Get parent order object.
 	 *
+	 * @since 10.9.0 returns the same order instance associated with the item; previously, a new instance was created on each call.
 	 * @return WC_Order
 	 */
 	public function get_order() {
-		return wc_get_order( $this->get_order_id() );
+		$order = $this->order ? $this->order->get() : null;
+		return $order ?? wc_get_order( $this->get_order_id() );
 	}

 	/*
@@ -209,7 +220,31 @@ class WC_Order_Item extends WC_Data implements ArrayAccess {
 	 * @param int $value Order ID.
 	 */
 	public function set_order_id( $value ) {
-		$this->set_prop( 'order_id', absint( $value ) );
+		$order_id = absint( $value );
+		$this->set_prop( 'order_id', $order_id );
+
+		$order = $this->order ? $this->order->get() : null;
+		if ( null !== $order && $order->get_id() !== $order_id ) {
+			$this->order = null;
+		}
+	}
+
+	/**
+	 * Aggregate and set properties based on passed in order object.
+	 *
+	 * @since 10.9.0
+	 * @param WC_Abstract_Order $order Order instance.
+	 * @return void
+	 */
+	public function set_order( $order ) {
+		if ( ! ( $order instanceof \WC_Abstract_Order ) ) {
+			$this->error( 'order_item_invalid_order', __( 'Invalid order', 'woocommerce' ) );
+		}
+		$this->set_order_id( $order->get_id() );
+		// Don't inject the refund object: get_order() declares a WC_Order return type; WC_Order_Refund would violate that contract.
+		if ( $order instanceof \WC_Order ) {
+			$this->order = \WeakReference::create( $order );
+		}
 	}

 	/**
diff --git a/plugins/woocommerce/includes/class-wc-post-data.php b/plugins/woocommerce/includes/class-wc-post-data.php
index 3326045eed1..cf13876717a 100644
--- a/plugins/woocommerce/includes/class-wc-post-data.php
+++ b/plugins/woocommerce/includes/class-wc-post-data.php
@@ -39,6 +39,7 @@ class WC_Post_Data {
 	public static function init() {
 		add_action( 'clean_post_cache', array( __CLASS__, 'invalidate_products_last_modified' ), 10, 2 );
 		add_action( 'clean_post_cache', array( __CLASS__, 'invalidate_db_block_templates_cache' ), 10, 2 );
+		add_action( 'clean_post_cache', array( __CLASS__, 'update_stale_product_objects_tracking_cache' ), 10, 2 );
 		add_filter( 'post_type_link', array( __CLASS__, 'variation_post_link' ), 10, 2 );
 		add_action( 'shutdown', array( __CLASS__, 'do_deferred_product_sync' ), 10 );
 		add_action( 'set_object_terms', array( __CLASS__, 'force_default_term' ), 10, 5 );
@@ -208,6 +209,24 @@ class WC_Post_Data {
 		}
 	}

+	/**
+	 * Updates product save/delete operation timestamp as part of stale objects trackings.
+	 *
+	 * @param int      $post_id Post ID.
+	 * @param \WP_Post $post    Post object.
+	 *
+	 * @internal
+	 * @since 10.9.0
+	 *
+	 * @return void
+	 */
+	public static function update_stale_product_objects_tracking_cache( $post_id, $post ): void {
+		if ( $post instanceof \WP_Post && in_array( $post->post_type, array( 'product', 'product_variation' ), true ) ) {
+			// Not the greatest design, but we need access to non-static 'object_type' property of the product object.
+			( new WC_Product() )->_woocommerce_entity_persisted( $post_id );
+		}
+	}
+
 	/**
 	 * Handle type changes.
 	 *
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 0d8494c76ca..4199cbe80ee 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -12786,12 +12786,6 @@ parameters:
 			count: 1
 			path: includes/class-wc-order-item-product.php

-		-
-			message: '#^Method WC_Order_Item_Product\:\:set_product\(\) has no return type specified\.$#'
-			identifier: missingType.return
-			count: 1
-			path: includes/class-wc-order-item-product.php
-
 		-
 			message: '#^Method WC_Order_Item_Product\:\:set_product_id\(\) has no return type specified\.$#'
 			identifier: missingType.return
diff --git a/plugins/woocommerce/src/Internal/Caches/StaleObjectAttribution.php b/plugins/woocommerce/src/Internal/Caches/StaleObjectAttribution.php
new file mode 100644
index 00000000000..47df90f8aa8
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Caches/StaleObjectAttribution.php
@@ -0,0 +1,78 @@
+<?php declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Caches;
+
+/**
+ * Tracks stale object instances. The decentralized hooks architecture allows multiple modification routes in both
+ * WooCommerce core and its extensions. This feature enables the identification of stale objects under those constraints.
+ */
+trait StaleObjectAttribution {
+	/**
+	 * Object instantiation timestamp.
+	 *
+	 * @var float|null
+	 */
+	private ?float $_woocommerce_instance_created_at = null; // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore
+
+	/**
+	 * Registry of persistence (update/delete) operations on the ID-level.
+	 *
+	 * @var array<string,float>
+	 */
+	private static array $_woocommerce_entity_persisted_at = array(); // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore
+
+	/**
+	 * Tracks object instantiation timestamp.
+	 *
+	 * @return void
+	 */
+	private function _woocommerce_object_instantiated(): void { // phpcs:ignore PSR2.Methods.MethodDeclaration.Underscore
+		$this->_woocommerce_instance_created_at = microtime( true );
+	}
+
+	/**
+	 * Tracks object persistence (update/delete) timestamp.
+	 *
+	 * Do not use this method for customization. Although it is public due to architectural constraints, improper use
+	 * may result in stale instance usage, which is critical for functions such as product inventory management.
+	 *
+	 * @param int $entity_id Object ID.
+	 * @return void
+	 */
+	public function _woocommerce_entity_persisted( int $entity_id ): void { // phpcs:ignore PSR2.Methods.MethodDeclaration.Underscore
+		$object_type = $this instanceof \WC_Product ? 'product' : $this->object_type;
+		self::$_woocommerce_entity_persisted_at[ $object_type . ':' . $entity_id ] = microtime( true );
+	}
+
+	/**
+	 * Enables verification of whether the object instance is stale. If it is stale, the object must be re-created,
+	 * as the current architecture does not support refreshing object attributes without replacing the object.
+	 *
+	 * @return bool
+	 */
+	public function _woocommerce_object_is_stale(): bool { // phpcs:ignore PSR2.Methods.MethodDeclaration.Underscore
+		// Unknown instantiation timestamp means freshness cannot be determined — treat as stale to fail safe.
+		if ( null !== $this->_woocommerce_instance_created_at ) {
+			$instance_timestamp  = (float) ( $this->_woocommerce_instance_created_at );
+			$object_type         = $this instanceof \WC_Product ? 'product' : $this->object_type;
+			$entity_persisted_at = (float) ( self::$_woocommerce_entity_persisted_at[ $object_type . ':' . $this->get_id() ] ?? null );
+
+			return $instance_timestamp <= $entity_persisted_at;
+		}
+
+		return true;
+	}
+
+	/**
+	 * Enables verification of whether the object instance is modified.
+	 *
+	 * @return bool
+	 */
+	public function _woocommerce_object_is_dirty(): bool { // phpcs:ignore PSR2.Methods.MethodDeclaration.Underscore
+		$meta_data = $this->meta_data ?? array();
+		$is_dirty  = ! empty( $this->changes );
+		$is_dirty  = $is_dirty || ! empty( array_filter( $meta_data, static fn( $meta ) => ! $meta->id || ! empty( $meta->get_changes() ) ) );
+
+		return $is_dirty;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderItemSchema.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderItemSchema.php
index 28ca201f5ad..98e3bc7a554 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderItemSchema.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderItemSchema.php
@@ -167,6 +167,7 @@ class OrderItemSchema extends AbstractLineItemSchema {
 	public function get_item_response( $order_item, WP_REST_Request $request, array $include_fields = array() ): array {
 		$dp              = is_null( $request['num_decimals'] ) ? wc_get_price_decimals() : absint( $request['num_decimals'] );
 		$quantity_amount = (float) $order_item->get_quantity();
+		$currency        = $order_item->get_order()->get_currency();
 		$data            = array(
 			'id'              => $order_item->get_id(),
 			'name'            => $order_item->get_name(),
@@ -182,8 +183,8 @@ class OrderItemSchema extends AbstractLineItemSchema {
 			'total_tax'       => wc_format_decimal( $order_item->get_total_tax(), $dp ),
 			'taxes'           => $this->prepare_taxes( $order_item, $request ),
 			'meta_data'       => $this->prepare_meta_data( $order_item ),
-			'currency'        => $order_item->get_order()->get_currency(),
-			'currency_symbol' => html_entity_decode( get_woocommerce_currency_symbol( $order_item->get_order()->get_currency() ), ENT_QUOTES ),
+			'currency'        => $currency,
+			'currency_symbol' => html_entity_decode( get_woocommerce_currency_symbol( $currency ), ENT_QUOTES ),
 		);

 		// Add COGS data.
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/order-items/class-wc-tests-order-item-product.php b/plugins/woocommerce/tests/legacy/unit-tests/order-items/class-wc-tests-order-item-product.php
index 13c354e512b..1e04bccbd5c 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/order-items/class-wc-tests-order-item-product.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/order-items/class-wc-tests-order-item-product.php
@@ -147,6 +147,62 @@ class WC_Tests_Order_Item_Product extends WC_Unit_Test_Case {
 		$this->assertEquals( $variation_product->get_id(), $retrieved->get_id() );
 	}

+	/**
+	 * Validates the product instance caching behaviour.
+	 */
+	public function test_get_get_product_caching(): void {
+		$product_item = new WC_Order_Item_Product();
+		$product      = new WC_Product_Simple();
+		$product->save();
+
+		// Verify: cached instance unchanged after the initial injection.
+		$instance = wc_get_product( $product->get_id() );
+		$product_item->set_product( $instance );
+		$this->assertSame( $product->get_id(), $product_item->get_product_id() );
+		$this->assertSame( $instance, $product_item->get_product() );
+
+		// Verify: cached instance refresh when product is saved.
+		$instance = $product_item->get_product();
+		$instance->set_stock_quantity( 10 );
+		$instance->save();
+		$this->assertNotSame( $instance, $product_item->get_product() );
+
+		// Verify: cached instance refresh after unsaved property mutation.
+		$instance = $product_item->get_product();
+		$instance->set_price( 10.99 );
+		$this->assertNotSame( $instance, $product_item->get_product() );
+
+		// Verify: cached instance refresh after unsaved meta addition.
+		$instance = $product_item->get_product();
+		$instance->add_meta_data( 'key', 'add' );
+		$this->assertNotSame( $instance, $product_item->get_product() );
+		$instance->save();
+
+		// Verify: cached instance refresh after unsaved meta update.
+		$instance = $product_item->get_product();
+		$instance->update_meta_data( 'key', 'update' );
+		$this->assertNotSame( $instance, $product_item->get_product() );
+		$instance->save();
+
+		// Verify: cached instance refresh after unsaved meta delete.
+		$instance = $product_item->get_product();
+		$instance->delete_meta_data( 'key' );
+		$this->assertNotSame( $instance, $product_item->get_product() );
+
+		// Verify: cached instance refresh when product is deleted.
+		$product->delete( true );
+		$this->assertFalse( $product_item->get_product() );
+
+		$replacement = new WC_Product_Simple();
+		$replacement->save();
+
+		// Verify: cached instance invalidated when updating product ID.
+		$product_item->set_product_id( $replacement->get_id() );
+		$this->assertSame( $replacement->get_id(), $product_item->get_product()->get_id() );
+
+		$replacement->delete();
+	}
+
 	/**
 	 * Test get_item_download_url method for WC_Order_Item_Product.
 	 *