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.
*/