Commit 4067f21b72 for woocommerce

commit 4067f21b72ba14756411784b07e61e99077b7703
Author: Jorge A. Torres <jorge.torres@automattic.com>
Date:   Tue Feb 17 09:40:33 2026 +0000

    HPOS: disable sync-on-read by default (#63175)

diff --git a/plugins/woocommerce/changelog/deprecate-hpos-sync-on-read b/plugins/woocommerce/changelog/deprecate-hpos-sync-on-read
new file mode 100644
index 0000000000..f0f244e69f
--- /dev/null
+++ b/plugins/woocommerce/changelog/deprecate-hpos-sync-on-read
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Disable HPOS sync-on-read by default and add admin notice for affected sites.
diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-notices.php b/plugins/woocommerce/includes/admin/class-wc-admin-notices.php
index 13b3081149..20fc9a2543 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-notices.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-notices.php
@@ -45,6 +45,7 @@ class WC_Admin_Notices {
 		'uploads_directory_is_unprotected'   => 'uploads_directory_is_unprotected_notice',
 		'base_tables_missing'                => 'base_tables_missing_notice',
 		'download_directories_sync_complete' => 'download_directories_sync_complete',
+		'hpos_sync_on_read_disabled'         => 'sync_on_read_disabled_notice',
 	);

 	/**
@@ -715,6 +716,26 @@ class WC_Admin_Notices {
 		include __DIR__ . '/views/html-notice-base-table-missing.php';
 	}

+	/**
+	 * Notice about HPOS sync-on-read being disabled by default.
+	 *
+	 * @since 10.7.0
+	 * @return void
+	 */
+	public static function sync_on_read_disabled_notice() {
+		$dismiss =
+			! \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled()
+			|| ! wc_get_container()->get( \Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer::class )->data_sync_is_enabled()
+			|| get_user_meta( get_current_user_id(), 'dismissed_hpos_sync_on_read_disabled_notice', true );
+
+		if ( $dismiss ) {
+			self::remove_notice( 'hpos_sync_on_read_disabled' );
+			return;
+		}
+
+		include __DIR__ . '/views/html-notice-sync-on-read-disabled.php';
+	}
+
 	/**
 	 * Determine if the store is running SSL.
 	 *
diff --git a/plugins/woocommerce/includes/admin/views/html-notice-sync-on-read-disabled.php b/plugins/woocommerce/includes/admin/views/html-notice-sync-on-read-disabled.php
new file mode 100644
index 0000000000..10e61231a3
--- /dev/null
+++ b/plugins/woocommerce/includes/admin/views/html-notice-sync-on-read-disabled.php
@@ -0,0 +1,26 @@
+<?php
+/**
+ * Admin View: Notice - HPOS sync-on-read disabled.
+ *
+ * @package WooCommerce\Admin\Notices
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+?>
+<div id="message" class="updated woocommerce-message">
+	<a class="woocommerce-message-close notice-dismiss" href="<?php echo esc_url( wp_nonce_url( add_query_arg( 'wc-hide-notice', 'hpos_sync_on_read_disabled' ), 'woocommerce_hide_notices_nonce', '_wc_notice_nonce' ) ); ?>"><?php esc_html_e( 'Dismiss', 'woocommerce' ); ?></a>
+
+	<p>
+		<strong><?php esc_html_e( 'HPOS order "sync on read" has been disabled', 'woocommerce' ); ?></strong><br />
+		<?php
+			echo wp_kses_post(
+				sprintf(
+					/* translators: %s: URL to blog post about this change. */
+					__( 'Compatibility mode for HPOS no longer pulls order changes made to the posts database back into your orders automatically. If your site uses custom code or plugins that modify orders outside of WooCommerce, this may affect how order data is handled. <a href="%s">Learn more about this change and what to do</a>.', 'woocommerce' ),
+					'https://developer.woocommerce.com/2026/02/16/hpos-sync-on-read-to-be-disabled-by-default-in-woocommerce-10-7/'
+				)
+			);
+			?>
+	</p>
+</div>
diff --git a/plugins/woocommerce/includes/class-wc-install.php b/plugins/woocommerce/includes/class-wc-install.php
index 307934ca9a..fbfa90f5dc 100644
--- a/plugins/woocommerce/includes/class-wc-install.php
+++ b/plugins/woocommerce/includes/class-wc-install.php
@@ -322,6 +322,9 @@ class WC_Install {
 		'10.6.0' => array(
 			'wc_update_1060_add_woo_idx_comment_approved_type_index',
 		),
+		'10.7.0' => array(
+			'wc_update_1070_disable_hpos_sync_on_read',
+		),
 	);

 	/**
diff --git a/plugins/woocommerce/includes/wc-update-functions.php b/plugins/woocommerce/includes/wc-update-functions.php
index 1fa76d377c..a307e641b2 100644
--- a/plugins/woocommerce/includes/wc-update-functions.php
+++ b/plugins/woocommerce/includes/wc-update-functions.php
@@ -3398,3 +3398,23 @@ function wc_update_1060_add_woo_idx_comment_approved_type_index(): void {
 		$wpdb->query( "ALTER TABLE {$wpdb->comments} ADD INDEX woo_idx_comment_approved_type (comment_approved, comment_type, comment_post_ID)" );
 	}
 }
+
+/**
+ * Add an admin notice about HPOS sync-on-read being disabled by default for sites
+ * that have both HPOS and data synchronization enabled.
+ *
+ * @since 10.7.0
+ *
+ * @return void
+ */
+function wc_update_1070_disable_hpos_sync_on_read(): void {
+	if ( 'yes' !== get_option( 'woocommerce_custom_orders_table_enabled' ) ) {
+		return;
+	}
+
+	if ( 'yes' !== get_option( 'woocommerce_custom_orders_table_data_sync_enabled' ) ) {
+		return;
+	}
+
+	WC_Admin_Notices::add_notice( 'hpos_sync_on_read_disabled' );
+}
diff --git a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
index cf21de351c..e1065eac62 100644
--- a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
+++ b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
@@ -1362,21 +1362,23 @@ WHERE
 			return;
 		}

-		$data_sync_enabled = $data_synchronizer->data_sync_is_enabled();
-		if ( $data_sync_enabled ) {
-			// We prefer not syncing-on-read if we are inside a webhook delivery or importing orders, as those events are likely triggered after the order is written
-			// and we don't want to possibly create loops of sync-on-read.
-			$should_sync_on_read = ! doing_action( 'woocommerce_deliver_webhook_async' ) && ! doing_action( 'wc-admin_import_orders' );
+		$data_sync_enabled = $data_synchronizer->data_sync_is_enabled()
+			&& ! doing_action( 'woocommerce_deliver_webhook_async' )
+			&& ! doing_action( 'wc-admin_import_orders' );

+		if ( $data_sync_enabled ) {
 			/**
-			 * Allow opportunity to disable sync on read, while keeping sync on write enabled. This adds another step as a large shop progresses from full sync to no sync with HPOS authoritative.
-			 * This filter is only executed if data sync is enabled from settings in the first place as it's meant to be a step between full sync -> no sync, rather than be a control for enabling just the sync on read. Sync on read without sync on write is problematic as any update will reset on the next read, but sync on write without sync on read is fine.
+			 * Filters whether to sync order data from posts on read.
+			 *
+			 * Defaults to false because sync-on-read can be dangerous when HPOS is
+			 * authoritative and running correctly, as it allows the posts data store
+			 * to override HPOS data.
 			 *
-			 * @param bool $read_on_sync_enabled Whether to sync on read.
+			 * @param bool $sync_on_read_enabled Whether to sync on read.
 			 *
 			 * @since 8.1.0
 			 */
-			$data_sync_enabled = apply_filters( 'woocommerce_hpos_enable_sync_on_read', $should_sync_on_read );
+			$data_sync_enabled = apply_filters( 'woocommerce_hpos_enable_sync_on_read', false );
 		}

 		$load_posts_for = array_diff( $order_ids, array_merge( self::$reading_order_ids, self::$backfilling_order_ids ) );
diff --git a/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/DataSynchronizerTests.php b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/DataSynchronizerTests.php
index fc509aa1d5..797ff7e107 100644
--- a/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/DataSynchronizerTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/DataSynchronizerTests.php
@@ -480,6 +480,7 @@ class DataSynchronizerTests extends \HposTestCase {
 		// Sync enabled and CPT authoritative.
 		update_option( $this->sut::ORDERS_DATA_SYNC_ENABLED_OPTION, 'yes' );
 		update_option( CustomOrdersTableController::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION, 'no' );
+		add_filter( 'woocommerce_hpos_enable_sync_on_read', '__return_true' );

 		$order = OrderHelper::create_order();
 		$order->add_meta_data( 'foo', 'bar' );
@@ -503,6 +504,7 @@ class DataSynchronizerTests extends \HposTestCase {
 			'',
 			'Meta data deleted from the CPT datastore should also be deleted from the HPOS datastore.'
 		);
+		remove_all_filters( 'woocommerce_hpos_enable_sync_on_read' );
 	}

 	/**
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 4d0d3e4ef4..2e36b8b905 100644
--- a/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreTests.php
@@ -1373,6 +1373,7 @@ class OrdersTableDataStoreTests extends \HposTestCase {
 	public function test_read_with_direct_meta_write() {
 		$this->toggle_cot_feature_and_usage( true );
 		$this->enable_cot_sync();
+		add_filter( 'woocommerce_hpos_enable_sync_on_read', '__return_true' );
 		$order = $this->create_complex_cot_order();

 		$post_object = get_post( $order->get_id() );
@@ -1388,6 +1389,7 @@ class OrdersTableDataStoreTests extends \HposTestCase {
 		$this->sut->read( $refreshed_order );

 		$this->assertEquals( array( 'key' => 'value' ), $refreshed_order->get_meta( 'my_custom_meta' ) );
+		remove_all_filters( 'woocommerce_hpos_enable_sync_on_read' );
 	}

 	/**
@@ -1396,6 +1398,7 @@ class OrdersTableDataStoreTests extends \HposTestCase {
 	public function test_read_multiple_with_direct_write() {
 		$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_total = $order->get_total();
 		$order->add_meta_data( 'custom_meta_1', 'custom_value_1' );
@@ -1425,6 +1428,7 @@ class OrdersTableDataStoreTests extends \HposTestCase {
 		$this->assertEquals( 'custom_value_4', $refreshed_order->get_meta( 'custom_meta_4' ) );
 		$this->assertEquals( 'custom_value_1_updated', $refreshed_order->get_meta( 'custom_meta_1' ) );
 		$this->assertEquals( '', $refreshed_order->get_meta( 'custom_meta_2' ) );
+		remove_all_filters( 'woocommerce_hpos_enable_sync_on_read' );
 	}

 	/**
@@ -1433,6 +1437,7 @@ class OrdersTableDataStoreTests extends \HposTestCase {
 	public function test_is_post_different_from_order() {
 		$this->toggle_cot_feature_and_usage( true );
 		$this->enable_cot_sync();
+		add_filter( 'woocommerce_hpos_enable_sync_on_read', '__return_true' );
 		$order                         = $this->create_complex_cot_order();
 		$post_order_comparison_closure = function ( $order ) {
 			$post_order = $this->get_post_orders_for_ids( array( $order->get_id() => $order ) )[ $order->get_id() ];
@@ -1455,6 +1460,7 @@ class OrdersTableDataStoreTests extends \HposTestCase {
 		$this->sut->read( $r_order );
 		$this->assertFalse( $post_order_comparison_closure->call( $this->sut, $r_order ) );
 		$this->assertEquals( array( 'key' => 'value' ), $r_order->get_meta( 'my_custom_meta' ) );
+		remove_all_filters( 'woocommerce_hpos_enable_sync_on_read' );
 	}

 	/**
@@ -1496,6 +1502,7 @@ class OrdersTableDataStoreTests extends \HposTestCase {

 		$this->toggle_cot_authoritative( true );
 		$this->enable_cot_sync();
+		add_filter( 'woocommerce_hpos_enable_sync_on_read', '__return_true' );

 		$now    = time() - ( 10 * MINUTE_IN_SECONDS );
 		$before = $now - ( 10 * MINUTE_IN_SECONDS );
@@ -1547,12 +1554,83 @@ class OrdersTableDataStoreTests extends \HposTestCase {
 		$order = wc_get_order( $order->get_id() );
 		$this->assertTrue( $sync_on_read_triggered );
 		remove_all_actions( 'woocommerce_hpos_post_record_migrated_on_read' );
+		remove_all_filters( 'woocommerce_hpos_enable_sync_on_read' );

 		// Compare dates again.
 		$this->assertEquals( $order->get_date_modified( 'edit' )->getTimestamp(), $now );
 		$this->assertEquals( get_post_modified_time( 'U', true, $order->get_id() ), $now );
 	}

+	/**
+	 * @testdox Confirm that sync on read doesn't run by default and can be enabled via filter.
+	 */
+	public function test_sync_on_read_on_and_off(): void {
+		global $wpdb;
+
+		$this->toggle_cot_authoritative( true );
+		$this->enable_cot_sync();
+
+		$now    = time() - ( 10 * MINUTE_IN_SECONDS );
+		$before = $now - ( 10 * MINUTE_IN_SECONDS );
+
+		$order = new \WC_Order();
+		$order->set_status( OrderStatus::PROCESSING );
+		$order->save();
+
+		// Set the HPOS modified date to the past.
+		$order->set_date_modified( $before );
+		$order->save();
+
+		// Make the post version newer (would normally trigger sync on read).
+		$wpdb->update(
+			$wpdb->posts,
+			array( 'post_modified_gmt' => gmdate( 'Y-m-d H:i:s', $now ) ),
+			array( 'ID' => $order->get_id() )
+		);
+		clean_post_cache( $order->get_id() );
+
+		// Track whether sync on read fires.
+		$sync_on_read_triggered = false;
+		add_action(
+			'woocommerce_hpos_post_record_migrated_on_read',
+			function () use ( &$sync_on_read_triggered ) {
+				$sync_on_read_triggered = true;
+			}
+		);
+
+		// Read order without filter — sync on read should NOT trigger.
+		$this->reset_order_data_store_state( $this->sut );
+		wc_get_order( $order->get_id() );
+		$this->assertFalse( $sync_on_read_triggered, 'Sync on read should not trigger by default.' );
+
+		// Enable sync on read via filter.
+		add_filter( 'woocommerce_hpos_enable_sync_on_read', '__return_true' );
+
+		// Read order with filter — sync on read SHOULD trigger (post is newer).
+		$this->reset_order_data_store_state( $this->sut );
+		wc_get_order( $order->get_id() );
+		$this->assertTrue( $sync_on_read_triggered, 'Sync on read should trigger when enabled and post is newer.' );
+
+		// Reset and make the post version older — sync on read should NOT trigger even with filter.
+		$sync_on_read_triggered = false;
+		$order->set_date_modified( $now );
+		$order->save();
+
+		$wpdb->update(
+			$wpdb->posts,
+			array( 'post_modified_gmt' => gmdate( 'Y-m-d H:i:s', $before ) ),
+			array( 'ID' => $order->get_id() )
+		);
+		clean_post_cache( $order->get_id() );
+
+		$this->reset_order_data_store_state( $this->sut );
+		wc_get_order( $order->get_id() );
+		$this->assertFalse( $sync_on_read_triggered, 'Sync on read should not trigger when post is older.' );
+
+		remove_all_actions( 'woocommerce_hpos_post_record_migrated_on_read' );
+		remove_all_filters( 'woocommerce_hpos_enable_sync_on_read' );
+	}
+
 	/**
 	 * @testDox Meta data should be migrated from post order to cot order.
 	 *
@@ -2314,6 +2392,7 @@ class OrdersTableDataStoreTests extends \HposTestCase {
 	public function test_read_multiple_dont_sync_again_for_same_order() {
 		$this->toggle_cot_feature_and_usage( true );
 		$this->disable_cot_sync();
+		add_filter( 'woocommerce_hpos_enable_sync_on_read', '__return_true' );
 		$order = $this->create_complex_cot_order();
 		$this->sut->backfill_post_record( $order );
 		$this->enable_cot_sync();
@@ -2330,6 +2409,7 @@ class OrdersTableDataStoreTests extends \HposTestCase {
 		$this->assertTrue( $should_sync_callable->call( $this->sut, $order ) );
 		$this->sut->read_multiple( $orders );
 		$this->assertFalse( $should_sync_callable->call( $this->sut, $order ) );
+		remove_all_filters( 'woocommerce_hpos_enable_sync_on_read' );
 		$this->toggle_cot_feature_and_usage( false );
 	}