Commit 6952c567120 for woocommerce

commit 6952c567120e6f6bee89e7147a0aaf5ae64e179a
Author: Jorge A. Torres <jorge.torres@automattic.com>
Date:   Fri Oct 23 05:34:30 2026 -0500

    Disambiguate orders using ID in admin order list when sorting by date (#69460)

    * Keep the admin orders list in a stable order when creation dates tie

    * Add tests for the stable ordering in the orders list tables

    * Add changelog entry

diff --git a/plugins/woocommerce/changelog/wooplug-810-stable-order-list b/plugins/woocommerce/changelog/wooplug-810-stable-order-list
new file mode 100644
index 00000000000..8f07e5f40c3
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-810-stable-order-list
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep the admin orders list in a stable order when several orders share the same creation date.
diff --git a/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-orders.php b/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-orders.php
index ef7cf424a1d..83c30d1455a 100644
--- a/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-orders.php
+++ b/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-orders.php
@@ -642,6 +642,11 @@ class WC_Admin_List_Table_Orders extends WC_Admin_List_Table {
 			}
 		}

+		// Order by ID as well to keep the ordering stable.
+		if ( empty( $query_vars['orderby'] ) || 'date' === $query_vars['orderby'] ) {
+			$query_vars['orderby'] = 'date ID';
+		}
+
 		// Status.
 		if ( empty( $query_vars['post_status'] ) ) {
 			$post_statuses = wc_get_order_statuses();
diff --git a/plugins/woocommerce/src/Internal/Admin/Orders/ListTable.php b/plugins/woocommerce/src/Internal/Admin/Orders/ListTable.php
index 547190b3dc4..65716f679a2 100644
--- a/plugins/woocommerce/src/Internal/Admin/Orders/ListTable.php
+++ b/plugins/woocommerce/src/Internal/Admin/Orders/ListTable.php
@@ -496,11 +496,15 @@ class ListTable extends WP_List_Table {
 		if ( ! in_array( $field, $sortable, true ) ) {
 			$this->order_query_args['orderby'] = 'date';
 			$this->order_query_args['order']   = 'DESC';
-			return;
+		} else {
+			$this->order_query_args['orderby'] = $field;
+			$this->order_query_args['order']   = in_array( $direction, array( 'ASC', 'DESC' ), true ) ? $direction : 'ASC';
 		}

-		$this->order_query_args['orderby'] = $field;
-		$this->order_query_args['order']   = in_array( $direction, array( 'ASC', 'DESC' ), true ) ? $direction : 'ASC';
+		// Order by ID as well to keep the ordering stable.
+		if ( 'date' === $this->order_query_args['orderby'] ) {
+			$this->order_query_args['orderby'] = 'date ID';
+		}
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableStatusUnionQuery.php b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableStatusUnionQuery.php
index 03e01a77aaf..ed2cbe081a1 100644
--- a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableStatusUnionQuery.php
+++ b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableStatusUnionQuery.php
@@ -132,14 +132,17 @@ class OrdersTableStatusUnionQuery {

 	/**
 	 * Extracts the sort direction from the ORDER BY clause, or NULL when it isn't an ORDER BY on date_created_gmt
-	 * alone (the only ordering the type_status_date index can satisfy within each branch).
+	 * alone, optionally followed by id in the same direction (the only ordering the type_status_date index can
+	 * satisfy within each branch).
 	 *
 	 * @param string $orderby The ORDER BY clause, including the keyword.
 	 * @return string|null 'ASC', 'DESC', or NULL when ineligible.
 	 */
 	private function extract_order_direction( string $orderby ): ?string {
 		foreach ( array( 'ASC', 'DESC' ) as $direction ) {
-			if ( "ORDER BY {$this->orders_table}.date_created_gmt {$direction}" === $orderby ) {
+			$date_only = "ORDER BY {$this->orders_table}.date_created_gmt {$direction}";
+
+			if ( $date_only === $orderby || "{$date_only}, {$this->orders_table}.id {$direction}" === $orderby ) {
 				return $direction;
 			}
 		}
@@ -236,7 +239,7 @@ class OrdersTableStatusUnionQuery {
 		foreach ( $types as $type ) {
 			foreach ( $statuses as $status ) {
 				$branch = $wpdb->prepare(
-					"SELECT id, date_created_gmt FROM {$this->orders_table} WHERE type = %s AND status = %s ORDER BY date_created_gmt {$direction} LIMIT {$branch_rows}", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+					"SELECT id, date_created_gmt FROM {$this->orders_table} WHERE type = %s AND status = %s ORDER BY date_created_gmt {$direction}, id {$direction} LIMIT {$branch_rows}", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
 					$type,
 					$status
 				);
@@ -245,7 +248,7 @@ class OrdersTableStatusUnionQuery {
 			}
 		}

-		return 'SELECT id FROM ( ' . implode( ' UNION ALL ', $branches ) . " ) candidates ORDER BY date_created_gmt {$direction} {$limits}";
+		return 'SELECT id FROM ( ' . implode( ' UNION ALL ', $branches ) . " ) candidates ORDER BY date_created_gmt {$direction}, id {$direction} {$limits}";
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/includes/admin/list-tables/class-wc-admin-list-table-orders-test.php b/plugins/woocommerce/tests/php/includes/admin/list-tables/class-wc-admin-list-table-orders-test.php
index b8f39a97c17..09b143ee6b7 100644
--- a/plugins/woocommerce/tests/php/includes/admin/list-tables/class-wc-admin-list-table-orders-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/list-tables/class-wc-admin-list-table-orders-test.php
@@ -584,4 +584,21 @@ class WC_Admin_List_Table_Orders_Test extends WC_Unit_Test_Case {
 		unset( $_GET['order_date_type'], $_GET['m'], $GLOBALS['pagenow'] );
 		wp_delete_post( $order->get_id(), true );
 	}
+
+	/**
+	 * Test that ordering by date also orders by ID, so orders created at the same time keep a stable order.
+	 *
+	 * @testWith [{"orderby": ""}]
+	 *           [{"orderby": "date"}]
+	 * @param array $query_vars Query vars as they arrive via the 'request' filter.
+	 */
+	public function test_ordering_by_date_also_orders_by_id( array $query_vars ) {
+		$GLOBALS['typenow'] = 'shop_order'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+
+		$query_vars = ( new WC_Admin_List_Table_Orders() )->request_query( $query_vars );
+
+		unset( $GLOBALS['typenow'] );
+
+		$this->assertSame( 'date ID', $query_vars['orderby'] );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/Orders/ListTableTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/Orders/ListTableTest.php
index 7daddbd35c7..e962a51cfb6 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/Orders/ListTableTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/Orders/ListTableTest.php
@@ -492,4 +492,20 @@ class ListTableTest extends \WC_Unit_Test_Case {
 		$this->assertArrayNotHasKey( 'date_created', $query_args, 'An invalid date should not produce a date filter' );
 		$this->assertCount( 2, $items, 'An invalid date filter should be ignored and all orders shown' );
 	}
+
+	/**
+	 * @testdox Ordering by date also orders by ID, so orders created at the same time keep a stable order.
+	 * @testWith [{}]
+	 *           [{"orderby": "date"}]
+	 * @param array $get_params $_GET values to simulate.
+	 */
+	public function test_ordering_by_date_also_orders_by_id( array $get_params ): void {
+		foreach ( $get_params as $key => $value ) {
+			$_GET[ $key ] = $value;
+		}
+
+		$this->sut->prepare_items();
+
+		$this->assertSame( 'date ID', $this->get_order_query_args()['orderby'] );
+	}
 }