Commit 01632275493 for woocommerce

commit 016322754934b5cc2cbf5b8ff2706138c9674b5f
Author: agent-sandbox-woocommerce[bot] <282076412+agent-sandbox-woocommerce[bot]@users.noreply.github.com>
Date:   Thu Nov 5 13:14:20 2026 +0000

    Keep multi-value order meta in HPOS order when syncing to posts (#70108)

diff --git a/plugins/woocommerce/changelog/fxlt-10-hpos-sync-multi-value-meta-order b/plugins/woocommerce/changelog/fxlt-10-hpos-sync-multi-value-meta-order
new file mode 100644
index 00000000000..5cf60d066cc
--- /dev/null
+++ b/plugins/woocommerce/changelog/fxlt-10-hpos-sync-multi-value-meta-order
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep order meta in the posts tables in step with HPOS when sync is on: same order for multi-value meta, and no duplicate rows on sync-on-read.
diff --git a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
index e120cffa81a..b472c4efd49 100644
--- a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
+++ b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
@@ -3673,7 +3673,7 @@ CREATE TABLE $meta_table (
 		$delete_meta     = $this->data_store_meta->delete_meta( $object, $meta );
 		$changes_applied = $this->after_meta_change( $object, $meta );

-		if ( ! $changes_applied && $object instanceof WC_Abstract_Order && $this->should_backfill_post_record() && isset( $meta->key ) ) {
+		if ( ! $changes_applied && $object instanceof WC_Abstract_Order && ! isset( self::$sync_on_read_order_ids[ $object->get_id() ] ) && $this->should_backfill_post_record() && isset( $meta->key ) ) {
 			self::$backfilling_order_ids[] = $object->get_id();
 			if ( is_object( $meta->value ) && '__PHP_Incomplete_Class' === get_class( $meta->value ) ) {
 				$meta_value = maybe_serialize( $meta->value );
@@ -3708,16 +3708,18 @@ CREATE TABLE $meta_table (
 	 * @return int|bool  meta ID or false on failure
 	 */
 	public function add_meta( &$object, $meta ) { // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.objectFound
-		$add_meta        = $this->data_store_meta->add_meta( $object, $meta );
-		$meta->id        = $add_meta;
-		$changes_applied = $this->after_meta_change( $object, $meta );
+		$add_meta = $this->data_store_meta->add_meta( $object, $meta );
+		$meta->id = $add_meta;

-		if ( ! $changes_applied && $object instanceof WC_Abstract_Order && $this->should_backfill_post_record() ) {
+		// Write the post meta before after_meta_change(): a full order save there would add any later meta first.
+		if ( $object instanceof WC_Abstract_Order && ! isset( self::$sync_on_read_order_ids[ $object->get_id() ] ) && $this->should_backfill_post_record() ) {
 			self::$backfilling_order_ids[] = $object->get_id();
 			add_post_meta( $object->get_id(), $meta->key, $meta->value );
 			self::$backfilling_order_ids = array_diff( self::$backfilling_order_ids, array( $object->get_id() ) );
 		}

+		$this->after_meta_change( $object, $meta );
+
 		return $add_meta;
 	}

@@ -3733,7 +3735,7 @@ CREATE TABLE $meta_table (
 		$update_meta     = $this->data_store_meta->update_meta( $object, $meta );
 		$changes_applied = $this->after_meta_change( $object, $meta );

-		if ( ! $changes_applied && $object instanceof WC_Abstract_Order && $this->should_backfill_post_record() ) {
+		if ( ! $changes_applied && $object instanceof WC_Abstract_Order && ! isset( self::$sync_on_read_order_ids[ $object->get_id() ] ) && $this->should_backfill_post_record() ) {
 			self::$backfilling_order_ids[] = $object->get_id();
 			update_post_meta( $object->get_id(), $meta->key, $meta->value );
 			self::$backfilling_order_ids = array_diff( self::$backfilling_order_ids, array( $object->get_id() ) );
diff --git a/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreTests.php b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreTests.php
index a9e5c3888f5..66c91c485db 100644
--- a/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreTests.php
@@ -1693,6 +1693,39 @@ class OrdersTableDataStoreTests extends \HposTestCase {
 		remove_all_filters( 'woocommerce_hpos_enable_sync_on_read' );
 	}

+	/**
+	 * @testDox Sync-on-read does not write duplicate rows back to wp_postmeta when migrating meta.
+	 */
+	public function test_sync_on_read_does_not_duplicate_postmeta() {
+		$this->enable_cot_sync();
+		$this->toggle_cot_feature_and_usage( true );
+		add_filter( 'woocommerce_hpos_enable_sync_on_read', '__return_true' );
+		$order = $this->create_complex_cot_order();
+		$order->add_meta_data( 'multi_meta', 'x' );
+		$order->add_meta_data( 'multi_meta', 'y' );
+		$order->add_meta_data( 'changed_meta', 'before' );
+		$order->save();
+
+		// Simulate direct writes to the posts table.
+		add_post_meta( $order->get_id(), 'new_meta', 'new_value' ); // add_meta path.
+		update_post_meta( $order->get_id(), 'changed_meta', 'after' ); // update_meta path.
+		delete_post_meta( $order->get_id(), 'multi_meta', 'x' ); // delete_meta path.
+		add_post_meta( $order->get_id(), 'multi_meta', 'z' );
+
+		$refreshed_order = new WC_Order();
+		$refreshed_order->set_id( $order->get_id() );
+		$this->switch_data_store( $refreshed_order, $this->sut );
+		$this->sut->read( $refreshed_order );
+
+		$this->assertEquals( 'new_value', $refreshed_order->get_meta( 'new_meta' ) );
+		$this->assertEquals( 'after', $refreshed_order->get_meta( 'changed_meta' ) );
+
+		$this->assertEquals( array( 'new_value' ), get_post_meta( $order->get_id(), 'new_meta', false ) );
+		$this->assertEquals( array( 'after' ), get_post_meta( $order->get_id(), 'changed_meta', false ) );
+		$this->assertEquals( array( 'y', 'z' ), get_post_meta( $order->get_id(), 'multi_meta', false ) );
+		remove_all_filters( 'woocommerce_hpos_enable_sync_on_read' );
+	}
+
 	/**
 	 * @testDox Test that we are able to correctly detect when order and post are out of sync.
 	 */
@@ -3726,6 +3759,25 @@ class OrdersTableDataStoreTests extends \HposTestCase {
 		$this->assertFalse( in_array( 'test_value5', $post_meta, true ) );
 	}

+	/**
+	 * @testdox Multi-value meta keeps its order in the posts table when a meta save triggers a full order save.
+	 */
+	public function test_multi_value_meta_order_is_synced_when_meta_save_triggers_order_save(): void {
+		$this->toggle_cot_authoritative( true );
+		$this->enable_cot_sync();
+
+		$order = WC_Helper_Order::create_order();
+		// An earlier modified date makes the first add_meta() call trigger a full order save.
+		$order->set_date_modified( gmdate( 'Y-m-d H:i:s', strtotime( '-2 day' ) ) );
+		$order->save();
+
+		$order->add_meta_data( 'test_key', 'test_value1' );
+		$order->add_meta_data( 'test_key', 'test_value2' );
+		$order->save_meta_data();
+
+		$this->assertSame( array( 'test_value1', 'test_value2' ), get_post_meta( $order->get_id(), 'test_key', false ) );
+	}
+
 	/**
 	 * @testDox Test that date queries correctly handle timezones.
 	 */