Commit 6ccad632b35 for woocommerce

commit 6ccad632b3563309e381ccac9a04e3ee3d4fd39a
Author: Adam Grzybkowski <agrzybkowski@outlook.com>
Date:   Thu Oct 22 08:59:59 2026 +0200

    Fix REST variation pages overlapping when sorted by menu order (#69857)

    * Fix duplicate and missing REST variations when paging by menu order

    * Add changelog entry for REST variations ordering fix

diff --git a/plugins/woocommerce/changelog/fix-woomob-4340-rest-variations-menu-order-tie-breaker b/plugins/woocommerce/changelog/fix-woomob-4340-rest-variations-menu-order-tie-breaker
new file mode 100644
index 00000000000..507dc0118e1
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-woomob-4340-rest-variations-menu-order-tie-breaker
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix duplicate and missing variations when apps and integrations page through them by menu order using the REST API.
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
index 4b71d02a3db..5f42650c3f2 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
@@ -984,6 +984,17 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V
 	protected function prepare_objects_query( $request ) {
 		$args = WC_REST_CRUD_Controller::prepare_objects_query( $request );

+		// Break orderby ties by ID so pages don't overlap. menu_order ties always go newest first, like the admin
+		// variations list (WC_AJAX::load_variations). The 'slug' orderby arrives here as 'name', mapped by prepare_items_query().
+		if ( 'menu_order' === $args['orderby'] ) {
+			$args['orderby'] = array(
+				'menu_order' => $args['order'] ?? '',
+				'ID'         => 'DESC',
+			);
+		} elseif ( in_array( $args['orderby'], array( 'title', 'name' ), true ) ) {
+			$args['orderby'] .= ' ID';
+		}
+
 		// Set post_status.
 		$args['post_status'] = $request['status'];

diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/product-variations.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/product-variations.php
index e2423492c87..9723081bf16 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/product-variations.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/product-variations.php
@@ -66,7 +66,8 @@ class Product_Variations_API extends WC_REST_Unit_Test_Case {
 		$variations = $response->get_data();
 		$this->assertEquals( 200, $response->get_status() );
 		$this->assertEquals( 2, count( $variations ) );
-		$this->assertEquals( 'DUMMY SKU VARIABLE SMALL', $variations[0]['sku'] );
+		// Both variations have menu_order 0, so the newer one (LARGE) comes first, as in the admin variations list.
+		$this->assertEquals( 'DUMMY SKU VARIABLE LARGE', $variations[0]['sku'] );
 		$this->assertEquals( 'size', $variations[0]['attributes'][0]['name'] );
 	}

diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller-tests.php
index b382de3d594..af197e24db5 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller-tests.php
@@ -32,6 +32,13 @@ class WC_REST_Product_Variations_Controller_Tests extends WC_Unit_Test_Case {
 	 */
 	protected $user;

+	/**
+	 * ORDER BY clauses of product variation queries, recorded by capture_variation_orderby_clauses().
+	 *
+	 * @var string[]
+	 */
+	private $variation_orderby_clauses = array();
+
 	/**
 	 * Runs before each test.
 	 */
@@ -1295,6 +1302,160 @@ class WC_REST_Product_Variations_Controller_Tests extends WC_Unit_Test_Case {
 		$this->assertSame( array(), wc_get_product( $product->get_id() )->get_children(), 'No variations should be generated.' );
 	}

+	/**
+	 * @testdox Paging variations that share a menu_order returns each variation exactly once, in the admin list order.
+	 *
+	 * @testWith ["offset"]
+	 *           ["page"]
+	 *
+	 * @param string $pagination_param Either 'offset' or 'page'.
+	 */
+	public function test_variations_paginated_by_menu_order_return_each_variation_once( string $pagination_param ): void {
+		$product       = $this->create_product_with_variations( array_fill( 0, 7, 0 ) );
+		$per_page      = 3;
+		$collected_ids = array();
+
+		// Request one page past the last, which should come back empty.
+		$page_count = (int) ceil( count( $product->get_children() ) / $per_page ) + 1;
+		for ( $page = 1; $page <= $page_count; $page++ ) {
+			$request = new WP_REST_Request( 'GET', '/wc/v3/products/' . $product->get_id() . '/variations' );
+			$request->set_query_params(
+				array(
+					'orderby'         => 'menu_order',
+					'order'           => 'asc',
+					'per_page'        => $per_page,
+					$pagination_param => 'offset' === $pagination_param ? ( $page - 1 ) * $per_page : $page,
+				)
+			);
+			$response = $this->server->dispatch( $request );
+			$this->assertSame( 200, $response->get_status(), "Page {$page} should load." );
+			$collected_ids = array_merge( $collected_ids, wp_list_pluck( $response->get_data(), 'id' ) );
+		}
+
+		// The admin variations list (WC_AJAX::load_variations) breaks menu_order ties by ID DESC.
+		$admin_order_ids = $product->get_children();
+		rsort( $admin_order_ids );
+		$this->assertSame( count( $collected_ids ), count( array_unique( $collected_ids ) ), 'No variation should appear on more than one page.' );
+		$this->assertSame( $admin_order_ids, $collected_ids, 'The pages together should contain every variation, in the admin variations list order.' );
+	}
+
+	/**
+	 * @testdox Sorting by menu_order sorts by menu_order first and breaks ties by ID DESC, like the admin variations list.
+	 */
+	public function test_variations_sorted_by_menu_order_break_ties_within_each_menu_order(): void {
+		$product = $this->create_product_with_variations( array( 1, 0, 1, 0, 2 ) );
+		$ids     = $product->get_children();
+		sort( $ids );
+
+		$request = new WP_REST_Request( 'GET', '/wc/v3/products/' . $product->get_id() . '/variations' );
+		$request->set_query_params(
+			array(
+				'orderby' => 'menu_order',
+				'order'   => 'asc',
+			)
+		);
+		$response = $this->server->dispatch( $request );
+
+		// Variations were created with menu_order 1, 0, 1, 0, 2 in ascending ID order.
+		$expected_ids = array( $ids[3], $ids[1], $ids[2], $ids[0], $ids[4] );
+		$this->assertSame( $expected_ids, wp_list_pluck( $response->get_data(), 'id' ), 'Variations should be grouped by menu_order, newest first within each group.' );
+	}
+
+	/**
+	 * @testdox Non-unique orderby values get an ID tie-breaker; menu_order matches the admin variations list.
+	 *
+	 * @testWith ["menu_order", "asc", "{posts}.menu_order ASC, {posts}.ID DESC"]
+	 *           ["menu_order", "desc", "{posts}.menu_order DESC, {posts}.ID DESC"]
+	 *           ["title", "asc", "{posts}.post_title ASC, {posts}.ID ASC"]
+	 *           ["slug", "asc", "{posts}.post_name ASC, {posts}.ID ASC"]
+	 *           ["date", "asc", "{posts}.post_date ASC, {posts}.ID ASC"]
+	 *           ["id", "asc", "{posts}.ID ASC"]
+	 *
+	 * @param string $orderby  The orderby request param.
+	 * @param string $order    The order request param.
+	 * @param string $expected Expected ORDER BY clause, with the posts table name as {posts}.
+	 */
+	public function test_variations_orderby_adds_id_tie_breaker( string $orderby, string $order, string $expected ): void {
+		global $wpdb;
+
+		$product = $this->create_product_with_variations( array( 0, 0 ) );
+		$this->capture_variation_orderby_clauses();
+
+		$request = new WP_REST_Request( 'GET', '/wc/v3/products/' . $product->get_id() . '/variations' );
+		$request->set_query_params(
+			array(
+				'orderby' => $orderby,
+				'order'   => $order,
+			)
+		);
+		$this->server->dispatch( $request );
+
+		$this->assertSame(
+			array( str_replace( '{posts}', $wpdb->posts, $expected ) ),
+			$this->variation_orderby_clauses,
+			"orderby={$orderby}&order={$order} should produce the expected ORDER BY clause."
+		);
+	}
+
+	/**
+	 * @testdox An array orderby set through the object query filter is left unchanged.
+	 */
+	public function test_variations_orderby_array_from_filter_is_left_unchanged(): void {
+		global $wpdb;
+
+		$product = $this->create_product_with_variations( array( 0, 0 ) );
+		$this->capture_variation_orderby_clauses();
+		add_filter(
+			'woocommerce_rest_product_variation_object_query',
+			function ( $args ) {
+				$args['orderby'] = array( 'menu_order' => 'ASC' );
+				return $args;
+			}
+		);
+
+		$request = new WP_REST_Request( 'GET', '/wc/v3/products/' . $product->get_id() . '/variations' );
+		$request->set_query_params( array( 'orderby' => 'menu_order' ) );
+		$this->server->dispatch( $request );
+
+		$this->assertSame( array( "{$wpdb->posts}.menu_order ASC" ), $this->variation_orderby_clauses, 'A filtered array orderby should not get an ID tie-breaker.' );
+	}
+
+	/**
+	 * Record the ORDER BY clause of each product variation WP_Query in $variation_orderby_clauses.
+	 */
+	private function capture_variation_orderby_clauses(): void {
+		$this->variation_orderby_clauses = array();
+		add_filter(
+			'posts_orderby',
+			function ( $clause, $query ) {
+				if ( 'product_variation' === $query->get( 'post_type' ) ) {
+					$this->variation_orderby_clauses[] = $clause;
+				}
+				return $clause;
+			},
+			10,
+			2
+		);
+	}
+
+	/**
+	 * Create a variable product with one variation per given menu_order, in ascending ID order.
+	 *
+	 * @param int[] $menu_orders The menu_order of each variation.
+	 * @return WC_Product_Variable
+	 */
+	private function create_product_with_variations( array $menu_orders ): WC_Product_Variable {
+		$product = new WC_Product_Variable();
+		$product->save();
+		foreach ( $menu_orders as $menu_order ) {
+			$variation = new WC_Product_Variation();
+			$variation->set_parent_id( $product->get_id() );
+			$variation->set_menu_order( $menu_order );
+			$variation->save();
+		}
+		return new WC_Product_Variable( $product->get_id() );
+	}
+
 	/**
 	 * Create a variable product and return its variations collection route.
 	 *
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-variations-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-variations-controller-tests.php
index 24af199e2ef..03af58c369e 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-variations-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-variations-controller-tests.php
@@ -142,6 +142,44 @@ class WC_REST_Variations_Controller_Tests extends WC_REST_Unit_Test_Case {
 		$this->assertSame( 3, $total_pages );
 	}

+	/**
+	 * @testdox Paging variations from several products that share a menu_order returns each variation exactly once.
+	 */
+	public function test_get_variations_paginated_by_menu_order_return_each_variation_once(): void {
+		wp_set_current_user( $this->user );
+		$expected_ids = array();
+		for ( $p = 0; $p < 2; $p++ ) {
+			$product = new WC_Product_Variable();
+			$product->save();
+			for ( $v = 0; $v < 4; $v++ ) {
+				$variation = new WC_Product_Variation();
+				$variation->set_parent_id( $product->get_id() );
+				$variation->set_menu_order( 0 );
+				$expected_ids[] = $variation->save();
+			}
+		}
+
+		$per_page      = 3;
+		$collected_ids = array();
+		// Request one page past the last, which should come back empty.
+		$page_count = (int) ceil( count( $expected_ids ) / $per_page ) + 1;
+		for ( $page = 1; $page <= $page_count; $page++ ) {
+			$request = new WP_REST_Request( 'GET', '/wc/v3/variations' );
+			$request->set_query_params(
+				array(
+					'orderby'  => 'menu_order',
+					'order'    => 'asc',
+					'per_page' => $per_page,
+					'page'     => $page,
+				)
+			);
+			$collected_ids = array_merge( $collected_ids, wp_list_pluck( $this->server->dispatch( $request )->get_data(), 'id' ) );
+		}
+
+		rsort( $expected_ids );
+		$this->assertSame( $expected_ids, $collected_ids, 'Every variation should appear exactly once, newest first among equal menu_order values.' );
+	}
+
 	/**
 	 * Test variations endpoint supports modified_after parameter.
 	 */