Commit b6aceac13b7 for woocommerce

commit b6aceac13b7e462aa3373803ac3d70fd4998837b
Author: Luis Felipe Zaguini <26530524+zaguiini@users.noreply.github.com>
Date:   Mon Nov 2 10:53:52 2026 -0300

    Give cloned orders and products their own child objects (#70105)

    * Give each cloned order its own meta objects

    The empty WC_Abstract_Order::__clone() copied the meta array but not the
    WC_Meta_Data objects in it. With the HPOS order cache, every instance of
    an order in a request shared those objects, so an unsaved meta change on
    one instance appeared on the others.

    Use CLONE_MODE_CACHE for orders. A clone keeps the meta IDs, so a cached
    copy still does not insert its meta again, and gets its own meta objects.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Give each cloned order its own copies of the loaded items

    An order clone still shared its WC_Order_Item objects with the original.
    An unsaved change to an item or its meta on one copy appeared on the
    other copies.

    WC_Abstract_Order::__clone() now clones each loaded item in cache mode.
    The item copy keeps its ID and its meta IDs, so saving a cached copy does
    not insert the item meta again. The order sets the old clone mode back on
    the item and on its copy, so code that clones an item directly behaves
    as before.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Explain why orders clone in cache mode

    Bring back the note from the old empty __clone() docblock. It says why
    orders keep meta IDs on a clone, and that Core should later make
    duplication an explicit operation instead of a side effect of
    WC_Data::__clone().

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Give each cloned product its own attribute and download objects

    WC_Data::__clone() clones only the meta objects. A product clone shared
    its WC_Product_Attribute and WC_Product_Download objects with the
    original. The product cache clones a product when it stores it and when
    it returns it, so an unsaved attribute change on one copy appeared on the
    cached copy.

    WC_Product::__clone() now clones each attribute and download object in
    both the saved data and the pending changes.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Point cloned order items at the cloned order

    Each order item keeps a weak reference to its order. Cloning an order
    cloned its items, but the copied items still pointed at the source
    order. Now each copied item points at the copy. The order ID does not
    change, so the item records no change.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Cover downloads and pending changes in the product clone tests

    The product cache test now also changes a download on one cached copy.
    It checks that another copy and the original keep the old name. A new
    test repeats the check while the attributes and downloads are still
    pending changes, so the test fails if cloning skips the changes set.

    The two order cache tests now have a void return type and a testdox
    line, like the other tests in this folder.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Give every cloned WC_Data object its own date objects

    A clone shared its date objects with the original. Setters replace a date
    object, but the getters return the shared object, and DateTime::modify()
    changes it before the setter runs. So a change on a clone reached the
    original, in saved data and in pending changes.

    WC_Data::__clone() now clones each date in the data and the changes. This
    covers orders, products, and the other WC_Data types. An object stored as a
    meta value stays shared, and the docblock says so.

    This also restores the @since tag on the order __clone() and adds one to the
    product __clone().

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Restore the item clone mode when an item clone throws

    Cloning an order switches each source item to cache mode and then restores
    the mode. If the __clone() of an extension's item threw, the source item
    stayed in cache mode, and a later direct clone kept its meta IDs. The
    restore now runs in a finally block.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Test saving and reloading a cloned order item

    In-memory checks cannot catch a duplicate insert. The new test changes the
    source item and the cloned item, saves the clone twice, and reads the rows.
    It checks that the item and meta IDs stay, one meta row remains, the clone's
    values persist, and the source keeps its own unsaved values.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Save only the changed fields of fee and coupon order items

    The fee and coupon data stores wrote every field on each save. Saving a
    stale copy of an item therefore overwrote a value that another copy had
    saved. Cloning an order now gives each copy its own items, so a direct
    clone could hit this too.

    Both stores now use get_props_to_update(), like the shipping and product
    item stores. A field is written only if it changed or does not exist yet.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Make the clone date tests independent of daylight saving time

    Adding one calendar day does not always advance a timestamp by
    DAY_IN_SECONDS. The tests now check only that the clone's date moved
    forward and that the original's date stayed the same.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Restore the item clone mode without try/finally in order clone

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/wooai-order-clone-meta b/plugins/woocommerce/changelog/wooai-order-clone-meta
new file mode 100644
index 00000000000..68de6051fa0
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooai-order-clone-meta
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Give each cloned order its own meta objects and its own copies of the loaded items, so an unsaved change on one order instance no longer appears on other instances from the order cache. Give each cloned product its own attribute and download objects, and give every cloned WC_Data object its own date objects, for the same reason. Save only the changed fields of fee and coupon order items, so saving a stale copy no longer overwrites a value that another copy saved.
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-data.php b/plugins/woocommerce/includes/abstracts/abstract-wc-data.php
index 1118eb167bc..912530c80fa 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-data.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-data.php
@@ -180,6 +180,9 @@ abstract class WC_Data {
 	 * - CLONE_MODE_DUPLICATE (default): Forces reading of Meta and clears meta IDs for duplication (backward compatible).
 	 * - CLONE_MODE_CACHE: Preserves meta IDs for caching purposes.
 	 *
+	 * The clone also gets its own date objects, because DateTime::modify() changes the object in place.
+	 * The clone is not fully isolated: an object stored as a meta value stays shared with the original.
+	 *
 	 * @since 3.0.2
 	 */
 	public function __clone() {
@@ -196,6 +199,13 @@ abstract class WC_Data {
 				}
 			}
 		}
+		foreach ( array( 'data', 'changes' ) as $set ) {
+			foreach ( $this->{$set} as $prop => $value ) {
+				if ( $value instanceof DateTime ) {
+					$this->{$set}[ $prop ] = clone $value;
+				}
+			}
+		}
 	}

 	/**
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
index 955759ac60c..af70f34d230 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
@@ -37,6 +37,13 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
 	use WC_Item_Totals;
 	use CogsAwareTrait;

+	/**
+	 * Clone mode. A clone of an order keeps the meta IDs, so a cached copy does not insert its meta again.
+	 *
+	 * @var string
+	 */
+	protected $clone_mode = self::CLONE_MODE_CACHE;
+
 	/**
 	 * Order Data array. This is the core order data exposed in APIs since 3.0.0.
 	 *
@@ -210,15 +217,30 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
 	}

 	/**
-	 * This method overwrites the base class's clone method to make it a no-op. In base class WC_Data, we are unsetting the meta_id to clone.
-	 * It seems like this was done to avoid conflicting the metadata when duplicating products. However, doing that does not seems necessary for orders.
-	 * In-fact, when we do that for orders, we lose the capability to clone orders with custom meta data by caching plugins. This is because, when we clone an order object for caching, it will clone the metadata without the ID. Unfortunately, when this cached object with nulled meta ID is retrieved, WC_Data will consider it as a new meta and will insert it as a new meta-data causing duplicates.
+	 * Clone the order.
 	 *
-	 * Eventually, we should move away from overwriting the __clone method in base class itself, since it's easily possible to still duplicate the product without having to hook into the __clone method.
+	 * Orders use cache mode. A cached copy keeps its meta IDs, so it does not insert its meta again on save.
+	 * Each copy gets its own meta, item, and date objects, so a change to one copy does not reach the others.
+	 *
+	 * Eventually, Core should stop unsetting meta IDs in WC_Data::__clone() for every subclass, and make
+	 * duplication an explicit operation. Duplicating an object does not need to hook into __clone().
 	 *
 	 * @since 7.6.0
 	 */
-	public function __clone() {}
+	public function __clone() {
+		parent::__clone();
+		foreach ( $this->items as $group => $items ) {
+			foreach ( $items as $key => $item ) {
+				$mode = $item->get_clone_mode();
+				$item->set_clone_mode( self::CLONE_MODE_CACHE );
+				$copy = clone $item;
+				$item->set_clone_mode( $mode );
+				$copy->set_order( $this );
+				$copy->set_clone_mode( $mode );
+				$this->items[ $group ][ $key ] = $copy;
+			}
+		}
+	}

 	/**
 	 * Get internal type.
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-product.php b/plugins/woocommerce/includes/abstracts/abstract-wc-product.php
index 875b4d586db..c2e66495bf7 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-product.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-product.php
@@ -153,6 +153,24 @@ class WC_Product extends WC_Abstract_Legacy_Product {
 		}
 	}

+	/**
+	 * Give the clone its own attribute and download objects.
+	 *
+	 * @since 11.4.0
+	 */
+	public function __clone() {
+		parent::__clone();
+		foreach ( array( 'data', 'changes' ) as $set ) {
+			foreach ( array( 'attributes', 'downloads' ) as $prop ) {
+				foreach ( $this->{$set}[ $prop ] ?? array() as $key => $value ) {
+					if ( is_object( $value ) ) {
+						$this->{$set}[ $prop ][ $key ] = clone $value;
+					}
+				}
+			}
+		}
+	}
+
 	/**
 	 * Get internal type. Should return string and *should be overridden* by child classes.
 	 *
diff --git a/plugins/woocommerce/includes/data-stores/class-wc-order-item-coupon-data-store.php b/plugins/woocommerce/includes/data-stores/class-wc-order-item-coupon-data-store.php
index 01eeb9dc83c..1810d03bfc8 100644
--- a/plugins/woocommerce/includes/data-stores/class-wc-order-item-coupon-data-store.php
+++ b/plugins/woocommerce/includes/data-stores/class-wc-order-item-coupon-data-store.php
@@ -50,13 +50,15 @@ class WC_Order_Item_Coupon_Data_Store extends Abstract_WC_Order_Item_Type_Data_S
 	 * @param WC_Order_Item_Coupon $item Coupon order item.
 	 */
 	public function save_item_data( &$item ) {
-		$id          = $item->get_id();
-		$save_values = array(
-			'discount_amount'     => $item->get_discount( 'edit' ),
-			'discount_amount_tax' => $item->get_discount_tax( 'edit' ),
+		$id                = $item->get_id();
+		$meta_key_to_props = array(
+			'discount_amount'     => 'discount',
+			'discount_amount_tax' => 'discount_tax',
 		);
-		foreach ( $save_values as $key => $value ) {
-			update_metadata( 'order_item', $id, $key, $value );
+		$props_to_update   = $this->get_props_to_update( $item, $meta_key_to_props, 'order_item' );
+
+		foreach ( $props_to_update as $meta_key => $prop ) {
+			update_metadata( 'order_item', $id, $meta_key, $item->{"get_$prop"}( 'edit' ) );
 		}
 	}
 }
diff --git a/plugins/woocommerce/includes/data-stores/class-wc-order-item-fee-data-store.php b/plugins/woocommerce/includes/data-stores/class-wc-order-item-fee-data-store.php
index 2222d064f31..7ca3a48eb7f 100644
--- a/plugins/woocommerce/includes/data-stores/class-wc-order-item-fee-data-store.php
+++ b/plugins/woocommerce/includes/data-stores/class-wc-order-item-fee-data-store.php
@@ -53,17 +53,19 @@ class WC_Order_Item_Fee_Data_Store extends Abstract_WC_Order_Item_Type_Data_Stor
 	 * @param WC_Order_Item_Fee $item Fee order item object.
 	 */
 	public function save_item_data( &$item ) {
-		$id          = $item->get_id();
-		$save_values = array(
-			'_fee_amount'    => $item->get_amount( 'edit' ),
-			'_tax_class'     => $item->get_tax_class( 'edit' ),
-			'_tax_status'    => $item->get_tax_status( 'edit' ),
-			'_line_total'    => $item->get_total( 'edit' ),
-			'_line_tax'      => $item->get_total_tax( 'edit' ),
-			'_line_tax_data' => $item->get_taxes( 'edit' ),
+		$id                = $item->get_id();
+		$meta_key_to_props = array(
+			'_fee_amount'    => 'amount',
+			'_tax_class'     => 'tax_class',
+			'_tax_status'    => 'tax_status',
+			'_line_total'    => 'total',
+			'_line_tax'      => 'total_tax',
+			'_line_tax_data' => 'taxes',
 		);
-		foreach ( $save_values as $key => $value ) {
-			update_metadata( 'order_item', $id, $key, $value );
+		$props_to_update   = $this->get_props_to_update( $item, $meta_key_to_props, 'order_item' );
+
+		foreach ( $props_to_update as $meta_key => $prop ) {
+			update_metadata( 'order_item', $id, $meta_key, $item->{"get_$prop"}( 'edit' ) );
 		}
 	}
 }
diff --git a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-order-item-data-store-test.php b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-order-item-data-store-test.php
index 0bba6369441..48b5bffe23f 100644
--- a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-order-item-data-store-test.php
+++ b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-order-item-data-store-test.php
@@ -270,4 +270,90 @@ class WC_Order_Item_Data_Store_Test extends WC_Unit_Test_Case {
 		$this->assertInstanceOf( WC_Order_Item_Product::class, $item );
 		$this->assertSame( (int) $item_id, $item->get_id() );
 	}
+
+	/**
+	 * @testdox Should keep a fee total saved by another copy when a stale copy saves without changing it.
+	 */
+	public function test_stale_fee_item_save_keeps_value_saved_by_another_copy(): void {
+		$order = new WC_Order();
+		$fee   = new WC_Order_Item_Fee();
+		$fee->set_name( 'Fee' );
+		$fee->set_total( 1 );
+		$order->add_item( $fee );
+		$order->save();
+
+		$source = new WC_Order( $order->get_id() );
+		$item   = current( $source->get_items( 'fee' ) );
+		$clone  = clone $source;
+		$item->set_total( 9 );
+		$item->save();
+		current( $clone->get_items( 'fee' ) )->set_tax_class( 'reduced-rate' );
+		$clone->save();
+
+		$reloaded = new WC_Order_Item_Fee( $item->get_id() );
+		$this->assertSame( '9', $reloaded->get_total() );
+		$this->assertSame( 'reduced-rate', $reloaded->get_tax_class() );
+	}
+
+	/**
+	 * @testdox Should keep a coupon discount saved by another copy when a stale copy saves without changing it.
+	 */
+	public function test_stale_coupon_item_save_keeps_value_saved_by_another_copy(): void {
+		$order  = new WC_Order();
+		$coupon = new WC_Order_Item_Coupon();
+		$coupon->set_code( 'code' );
+		$coupon->set_discount( 1 );
+		$coupon->set_discount_tax( 2 );
+		$order->add_item( $coupon );
+		$order->save();
+
+		$source = new WC_Order( $order->get_id() );
+		$item   = current( $source->get_items( 'coupon' ) );
+		$clone  = clone $source;
+		$item->set_discount( 9 );
+		$item->save();
+		current( $clone->get_items( 'coupon' ) )->set_discount_tax( 3 );
+		$clone->save();
+
+		$reloaded = new WC_Order_Item_Coupon( $item->get_id() );
+		$this->assertSame( '9', $reloaded->get_discount() );
+		$this->assertSame( '3', $reloaded->get_discount_tax() );
+	}
+
+	/**
+	 * @testdox Should write every fee and coupon field when a copied item is saved as a new row.
+	 */
+	public function test_copied_fee_and_coupon_items_write_all_fields_to_new_rows(): void {
+		$fee = new WC_Order_Item_Fee();
+		$fee->set_name( 'Fee' );
+		$fee->set_amount( 4 );
+		$fee->set_total( 5 );
+		$fee->set_taxes( array( 'total' => array( 1 => '1' ) ) );
+		$fee->set_tax_class( 'reduced-rate' );
+		$fee->save();
+		$fee_copy = clone $fee;
+		$fee_copy->set_id( 0 );
+		$fee_copy->save();
+
+		$coupon = new WC_Order_Item_Coupon();
+		$coupon->set_code( 'code' );
+		$coupon->set_discount( 7 );
+		$coupon->set_discount_tax( 2 );
+		$coupon->save();
+		$coupon_copy = clone $coupon;
+		$coupon_copy->set_id( 0 );
+		$coupon_copy->save();
+
+		$this->assertNotSame( $fee->get_id(), $fee_copy->get_id() );
+		$reloaded_fee = new WC_Order_Item_Fee( $fee_copy->get_id() );
+		$this->assertSame( '4', $reloaded_fee->get_amount() );
+		$this->assertSame( '5', $reloaded_fee->get_total() );
+		$this->assertSame( '1', $reloaded_fee->get_total_tax() );
+		$this->assertSame( 'reduced-rate', $reloaded_fee->get_tax_class() );
+
+		$this->assertNotSame( $coupon->get_id(), $coupon_copy->get_id() );
+		$reloaded_coupon = new WC_Order_Item_Coupon( $coupon_copy->get_id() );
+		$this->assertSame( '7', $reloaded_coupon->get_discount() );
+		$this->assertSame( '2', $reloaded_coupon->get_discount_tax() );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Caching/OrderCacheTest.php b/plugins/woocommerce/tests/php/src/Caching/OrderCacheTest.php
index a3872fc7153..a1e69937341 100644
--- a/plugins/woocommerce/tests/php/src/Caching/OrderCacheTest.php
+++ b/plugins/woocommerce/tests/php/src/Caching/OrderCacheTest.php
@@ -52,4 +52,113 @@ class OrderCacheTest extends \WC_Unit_Test_Case {
 		$this->assertEquals( 1, count( $wpdb->get_col( $query ) ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Already prepared query.
 	}

+	/**
+	 * @testdox Should keep an unsaved meta change on one order instance away from another instance from the cache.
+	 */
+	public function test_unsaved_meta_change_does_not_reach_other_instances(): void {
+		if ( ! OrderUtil::orders_cache_usage_is_enabled() ) {
+			$this->markTestSkipped( 'HPOS based caching is not enabled.' );
+		}
+		$order = WC_Helper_Order::create_order();
+		$order->add_meta_data( 'test', 'saved' );
+		$order->save_meta_data();
+
+		$first = wc_get_order( $order->get_id() );
+		$first->update_meta_data( 'test', 'unsaved' );
+		$second = wc_get_order( $order->get_id() );
+
+		$this->assertSame( 'saved', $second->get_meta( 'test' ) );
+		$this->assertSame( 'unsaved', $first->get_meta( 'test' ) );
+	}
+
+	/**
+	 * @testdox Should keep an unsaved change to an item or its meta on one order copy away from another copy.
+	 */
+	public function test_unsaved_item_change_does_not_reach_cloned_order(): void {
+		$order = WC_Helper_Order::create_order();
+		$item  = current( $order->get_items() );
+		$item->add_meta_data( 'test', 'saved' );
+		$item->save();
+
+		$first = wc_get_order( $order->get_id() );
+		$first->get_items();
+		$second      = clone $first;
+		$first_item  = current( $first->get_items() );
+		$second_item = current( $second->get_items() );
+		$first_item->set_quantity( 99 );
+		$first_item->update_meta_data( 'test', 'unsaved' );
+
+		$this->assertNotSame( $first_item, $second_item );
+		$this->assertSame( $first_item->get_id(), $second_item->get_id() );
+		$this->assertSame( 'saved', $second_item->get_meta( 'test' ) );
+		$this->assertNotEmpty( current( $second_item->get_meta_data() )->id );
+		$this->assertNotSame( 99, $second_item->get_quantity() );
+		$this->assertSame( \WC_Data::CLONE_MODE_DUPLICATE, $second_item->get_clone_mode() );
+		$this->assertSame( \WC_Data::CLONE_MODE_DUPLICATE, $first_item->get_clone_mode() );
+		$this->assertSame( $second, $second_item->get_order() );
+		$this->assertSame( $first, $first_item->get_order() );
+		$this->assertEmpty( $second_item->get_changes() );
+	}
+
+	/**
+	 * @testdox Should keep a change to a saved or pending date on an order clone away from the original.
+	 */
+	public function test_date_change_does_not_reach_cloned_order(): void {
+		$order = WC_Helper_Order::create_order();
+		$order->set_date_paid( '2026-01-15 10:00:00' );
+		$this->assertArrayHasKey( 'date_paid', $order->get_changes() );
+		$created = $order->get_date_created()->getTimestamp();
+		$paid    = $order->get_date_paid()->getTimestamp();
+
+		$copy = clone $order;
+		$copy->get_date_created()->modify( '+1 day' );
+		$copy->get_date_paid()->modify( '+1 day' );
+
+		$this->assertSame( $created, $order->get_date_created()->getTimestamp() );
+		$this->assertSame( $paid, $order->get_date_paid()->getTimestamp() );
+		$this->assertGreaterThan( $created, $copy->get_date_created()->getTimestamp() );
+		$this->assertGreaterThan( $paid, $copy->get_date_paid()->getTimestamp() );
+	}
+
+	/**
+	 * @testdox Should save only the clone's item changes, once, on the same item and meta rows.
+	 */
+	public function test_saved_cloned_item_keeps_its_rows_and_changes(): void {
+		global $wpdb;
+		if ( ! OrderUtil::orders_cache_usage_is_enabled() ) {
+			$this->markTestSkipped( 'HPOS based caching is not enabled.' );
+		}
+		$order = WC_Helper_Order::create_order();
+		$item  = current( $order->get_items() );
+		$item->add_meta_data( 'test', 'saved' );
+		$item->save();
+		$meta_id = current( $item->get_meta_data() )->id;
+
+		$source = wc_get_order( $order->get_id() );
+		$source->get_items();
+		$clone       = clone $source;
+		$source_item = current( $source->get_items() );
+		$clone_item  = current( $clone->get_items() );
+		$source_item->set_quantity( 99 );
+		$source_item->update_meta_data( 'test', 'source' );
+		$clone_item->set_quantity( 7 );
+		$clone_item->update_meta_data( 'test', 'clone' );
+
+		$clone->save();
+		$clone->save();
+
+		$rows = $wpdb->get_results( $wpdb->prepare( "SELECT meta_id, meta_value FROM {$wpdb->prefix}woocommerce_order_itemmeta WHERE order_item_id = %d AND meta_key = 'test'", $item->get_id() ) );
+		$this->assertCount( 1, $rows );
+		$this->assertSame( (string) $meta_id, $rows[0]->meta_id );
+		$this->assertSame( 'clone', $rows[0]->meta_value );
+
+		$reloaded = new \WC_Order_Item_Product( $item->get_id() );
+		$this->assertSame( 7, $reloaded->get_quantity() );
+		$this->assertSame( 'clone', $reloaded->get_meta( 'test' ) );
+		$this->assertSame( $meta_id, current( $reloaded->get_meta_data() )->id );
+
+		$this->assertSame( $item->get_id(), $clone_item->get_id() );
+		$this->assertSame( 99, $source_item->get_quantity() );
+		$this->assertSame( 'source', $source_item->get_meta( 'test' ) );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/Caches/ProductCacheTest.php b/plugins/woocommerce/tests/php/src/Internal/Caches/ProductCacheTest.php
index 3cb8118b60b..cb965681f42 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Caches/ProductCacheTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Caches/ProductCacheTest.php
@@ -5,6 +5,7 @@ namespace Automattic\WooCommerce\Tests\Internal\Caches;

 use Automattic\WooCommerce\Internal\Caches\ProductCache;
 use Automattic\WooCommerce\Internal\Caches\ProductCacheController;
+use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register as Download_Directories;
 use Automattic\WooCommerce\Utilities\FeaturesUtil;
 use WC_Data;
 use WC_Helper_Product;
@@ -130,6 +131,95 @@ class ProductCacheTest extends \WC_Unit_Test_Case {
 		$this->assertEquals( WC_Data::CLONE_MODE_DUPLICATE, $retrieved_product->get_clone_mode() );
 	}

+	/**
+	 * @testdox Should keep an unsaved attribute or download change on one cached copy away from other copies.
+	 */
+	public function test_unsaved_attribute_change_does_not_reach_other_copies(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_attributes( array( $this->create_attribute() ) );
+		$product->set_downloads( array( $this->create_download() ) );
+		$product->save();
+
+		$this->assert_cached_copies_are_isolated( $product );
+	}
+
+	/**
+	 * @testdox Should keep an unsaved change on one cached copy away from other copies when the attributes and downloads are pending changes.
+	 */
+	public function test_unsaved_change_does_not_reach_other_copies_when_props_are_pending(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_attributes( array( $this->create_attribute() ) );
+		$product->set_downloads( array( $this->create_download() ) );
+
+		$this->assertArrayHasKey( 'attributes', $product->get_changes() );
+		$this->assertArrayHasKey( 'downloads', $product->get_changes() );
+		$this->assert_cached_copies_are_isolated( $product );
+	}
+
+	/**
+	 * @testdox Should keep a change to a saved or pending date on a product clone away from the original.
+	 */
+	public function test_date_change_does_not_reach_cloned_product(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_date_on_sale_from( '2026-01-15 10:00:00' );
+		$this->assertArrayHasKey( 'date_on_sale_from', $product->get_changes() );
+		$created = $product->get_date_created()->getTimestamp();
+		$on_sale = $product->get_date_on_sale_from()->getTimestamp();
+
+		$copy = clone $product;
+		$copy->get_date_created()->modify( '+1 day' );
+		$copy->get_date_on_sale_from()->modify( '+1 day' );
+
+		$this->assertSame( $created, $product->get_date_created()->getTimestamp() );
+		$this->assertSame( $on_sale, $product->get_date_on_sale_from()->getTimestamp() );
+		$this->assertGreaterThan( $created, $copy->get_date_created()->getTimestamp() );
+		$this->assertGreaterThan( $on_sale, $copy->get_date_on_sale_from()->getTimestamp() );
+	}
+
+	/**
+	 * Cache the product, change the attribute and download objects on one copy, and check that another copy and the original keep their values.
+	 *
+	 * @param \WC_Product $product The product to cache.
+	 */
+	private function assert_cached_copies_are_isolated( \WC_Product $product ): void {
+		$this->sut->set( $product );
+
+		$first = $this->sut->get( $product->get_id() );
+		$first->get_attributes()['color']->set_options( array( 'green' ) );
+		current( $first->get_downloads() )->set_name( 'Changed' );
+		$second = $this->sut->get( $product->get_id() );
+
+		$this->assertSame( array( 'red', 'blue' ), $second->get_attributes()['color']->get_options() );
+		$this->assertSame( array( 'red', 'blue' ), $product->get_attributes()['color']->get_options() );
+		$this->assertSame( 'Manual', current( $second->get_downloads() )->get_name() );
+		$this->assertSame( 'Manual', current( $product->get_downloads() )->get_name() );
+	}
+
+	/**
+	 * Create a color attribute with two options.
+	 *
+	 * @return \WC_Product_Attribute
+	 */
+	private function create_attribute(): \WC_Product_Attribute {
+		$attribute = new \WC_Product_Attribute();
+		$attribute->set_name( 'color' );
+		$attribute->set_options( array( 'red', 'blue' ) );
+		return $attribute;
+	}
+
+	/**
+	 * Create a download named Manual in an approved directory.
+	 *
+	 * @return \WC_Product_Download
+	 */
+	private function create_download(): \WC_Product_Download {
+		wc_get_container()->get( Download_Directories::class )->add_approved_directory( 'https://example.com/' );
+		$download = new \WC_Product_Download();
+		$download->set_name( 'Manual' );
+		$download->set_file( 'https://example.com/manual.pdf' );
+		return $download;
+	}
+
 	/**
 	 * Test that cloning a retrieved product uses DUPLICATE mode.
 	 *