Commit 993300b0c54 for woocommerce

commit 993300b0c54846c0b65c9e2524b8469c904ef4e9
Author: Vladimir Reznichenko <kalessil@gmail.com>
Date:   Tue Aug 18 11:21:38 2026 +0200

    [Performance] Fix ordering products performance (N-query pattern) (take 2) (#66603)

    Re-implements product position re-indexing and changing product positions:
    - Batched re-indexing: compatible with HyperDB and supported DB versions
    - Move implementation operates on the targeted range of positions (a constant 5 SQLs to move a product across a catalog of any size; 4 SQLs are PK-driven and nearly instant)

diff --git a/plugins/woocommerce/changelog/performance-products-reordering-ajax b/plugins/woocommerce/changelog/performance-products-reordering-ajax
new file mode 100644
index 00000000000..3ae1fecaec7
--- /dev/null
+++ b/plugins/woocommerce/changelog/performance-products-reordering-ajax
@@ -0,0 +1,4 @@
+Significance: minor
+Type: performance
+
+Fixed ordering products performance (N-query pattern).
diff --git a/plugins/woocommerce/includes/class-wc-ajax.php b/plugins/woocommerce/includes/class-wc-ajax.php
index 6c1aba7e10c..ca96f1f5514 100644
--- a/plugins/woocommerce/includes/class-wc-ajax.php
+++ b/plugins/woocommerce/includes/class-wc-ajax.php
@@ -16,6 +16,7 @@ use Automattic\WooCommerce\Internal\Orders\CouponsController;
 use Automattic\WooCommerce\Internal\Orders\TaxesController;
 use Automattic\WooCommerce\Internal\Orders\OrderNoteGroup;
 use Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes\CustomMetaBox;
+use Automattic\WooCommerce\Internal\Products\ProductsOrderingMoveService;
 use Automattic\WooCommerce\Internal\Utilities\Users;
 use Automattic\WooCommerce\Proxies\LegacyProxy;
 use Automattic\WooCommerce\Utilities\ArrayUtil;
@@ -2337,8 +2338,6 @@ class WC_AJAX {
 	/**
 	 * Ajax request handling for product ordering.
 	 *
-	 * Based on Simple Page Ordering by 10up (https://wordpress.org/plugins/simple-page-ordering/).
-	 *
 	 * @return void
 	 */
 	public static function product_ordering() {
@@ -2350,54 +2349,78 @@ class WC_AJAX {
 			wp_die( -1 );
 		}

-		$sorting_id  = absint( $_POST['id'] );
-		$previd      = absint( isset( $_POST['previd'] ) ? $_POST['previd'] : 0 );
-		$nextid      = absint( isset( $_POST['nextid'] ) ? $_POST['nextid'] : 0 );
-		$menu_orders = wp_list_pluck( $wpdb->get_results( "SELECT ID, menu_order FROM {$wpdb->posts} WHERE post_type = 'product' ORDER BY menu_order ASC, post_title ASC" ), 'menu_order', 'ID' );
-		$index       = 0;
+		$previous_id = absint( $_POST['previd'] ?? 0 );
+		$product_id  = absint( $_POST['id'] );
+		$next_id     = absint( $_POST['nextid'] ?? 0 );

-		foreach ( $menu_orders as $id => $menu_order ) {
-			$id = absint( $id );
+		$use_legacy_algorithm = has_action( 'woocommerce_after_single_product_ordering' ) || has_action( 'woocommerce_after_product_ordering' );
+		if ( $use_legacy_algorithm ) {
+			// Based on Simple Page Ordering by 10up (https://wordpress.org/plugins/simple-page-ordering/).
+			$menu_orders = wp_list_pluck( $wpdb->get_results( "SELECT ID, menu_order FROM {$wpdb->posts} WHERE post_type = 'product' ORDER BY menu_order ASC, post_title ASC" ), 'menu_order', 'ID' );
+			$index       = 0;

-			if ( $sorting_id === $id ) {
-				continue;
-			}
-			if ( $nextid === $id ) {
+			foreach ( $menu_orders as $id => $menu_order ) {
+				$id = absint( $id );
+
+				if ( $product_id === $id ) {
+					continue;
+				}
+				if ( $next_id === $id ) {
+					++$index;
+				}
 				++$index;
+				$menu_orders[ $id ] = $index;
+
+				if ( $wpdb->update( $wpdb->posts, array( 'menu_order' => $index ), array( 'ID' => $id ) ) ) {
+					// We only need to clean the cache if the menu order was actually modified.
+					clean_post_cache( $id );
+				}
+
+				/**
+				 * When a single product has gotten its ordering updated.
+				 *
+				 * @param int $id    The product ID.
+				 * @param int $index The new sort position.
+				 *
+				 * @since 3.1.0
+				 */
+				do_action( 'woocommerce_after_single_product_ordering', $id, $index );
 			}
-			++$index;
-			$menu_orders[ $id ] = $index;

-			if ( $wpdb->update( $wpdb->posts, array( 'menu_order' => $index ), array( 'ID' => $id ) ) ) {
+			if ( isset( $menu_orders[ $previous_id ] ) ) {
+				$menu_orders[ $product_id ] = $menu_orders[ $previous_id ] + 1;
+			} elseif ( isset( $menu_orders[ $next_id ] ) ) {
+				$menu_orders[ $product_id ] = $menu_orders[ $next_id ] - 1;
+			} else {
+				$menu_orders[ $product_id ] = 0;
+			}
+
+			if ( $wpdb->update( $wpdb->posts, array( 'menu_order' => $menu_orders[ $product_id ] ), array( 'ID' => $product_id ) ) ) {
 				// We only need to clean the cache if the menu order was actually modified.
-				clean_post_cache( $id );
+				clean_post_cache( $product_id );
 			}

+			WC_Post_Data::delete_product_query_transients();
+
 			/**
-			 * When a single product has gotten it's ordering updated.
-			 * $id The product ID
-			 * $index The new menu order
-			*/
-			do_action( 'woocommerce_after_single_product_ordering', $id, $index );
-		}
+			 * When products ordering update completed.
+			 *
+			 * @param int            $product_id    The product ID that was repositioned.
+			 * @param array<int,int> $all_positions All product sort positions (product ID → actual menu_order value).
+			 *
+			 * @since 3.1.0
+			 */
+			do_action( 'woocommerce_after_product_ordering', $product_id, $menu_orders );
+			wp_send_json( $menu_orders );

-		if ( isset( $menu_orders[ $previd ] ) ) {
-			$menu_orders[ $sorting_id ] = $menu_orders[ $previd ] + 1;
-		} elseif ( isset( $menu_orders[ $nextid ] ) ) {
-			$menu_orders[ $sorting_id ] = $menu_orders[ $nextid ] - 1;
 		} else {
-			$menu_orders[ $sorting_id ] = 0;
-		}
-
-		if ( $wpdb->update( $wpdb->posts, array( 'menu_order' => $menu_orders[ $sorting_id ] ), array( 'ID' => $sorting_id ) ) ) {
-			// We only need to clean the cache if the menu order was actually modified.
-			clean_post_cache( $sorting_id );
+			$modifications = wc_get_container()->get( ProductsOrderingMoveService::class )->move( $previous_id, $product_id, $next_id );
+			if ( ! empty( $modifications->moved ) || ! empty( $modifications->reindexed ) ) {
+				WC_Post_Data::delete_product_query_transients();
+				unset( $modifications->reindexed );
+			}
+			wp_send_json( $modifications->moved );
 		}
-
-		WC_Post_Data::delete_product_query_transients();
-
-		do_action( 'woocommerce_after_product_ordering', $sorting_id, $menu_orders );
-		wp_send_json( $menu_orders );
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/Products/ProductsOrderingMoveService.php b/plugins/woocommerce/src/Internal/Products/ProductsOrderingMoveService.php
new file mode 100644
index 00000000000..20831d97a2b
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Products/ProductsOrderingMoveService.php
@@ -0,0 +1,254 @@
+<?php declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Products;
+
+/**
+ * Repositions a single product within the catalog's menu_order sequence.
+ */
+final class ProductsOrderingMoveService {
+
+	/**
+	 * Reindex service.
+	 *
+	 * @var ProductsOrderingReindexService
+	 */
+	private ProductsOrderingReindexService $reindex_service;
+
+	/**
+	 * Initialize the service with dependencies.
+	 *
+	 * @internal
+	 * @param ProductsOrderingReindexService $reindex_service Reindex service.
+	 */
+	final public function init( ProductsOrderingReindexService $reindex_service ): void { // phpcs:ignore Generic.CodeAnalysis.UnnecessaryFinalModifier.Found
+		$this->reindex_service = $reindex_service;
+	}
+
+	/**
+	 * Moves a product to the position between $previous_id and $next_id, triggering a full reindex first if positions are
+	 * uninitialized or colliding. Indexed positions start at 1; menu_order = 0 is the sentinel for an unindexed product.
+	 *
+	 * Designed for an HVM with 500K+ products catalog operating in a clustered environment. To satisfy this setup:
+	 * - if on-the-spot re-indexing is triggered: see design notes in \Automattic\WooCommerce\Internal\Products\ProductsOrderingReindexService::reindex_products
+	 * - otherwise, the move takes 5 SQLs for any catalog size (4 of them PK-driven, hence nearly instant; operating on the targeted range of positions)
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param int $previous_id ID of the product immediately before the target position, or 0 when moving to the start.
+	 * @param int $product_id  ID of the product being repositioned.
+	 * @param int $next_id     ID of the product immediately after the target position, or 0 when moving to the end.
+	 * @return object{ moved:array<int,int>, reindexed:array<int,int> }
+	 */
+	public function move( int $previous_id, int $product_id, int $next_id ): object {
+		$result = array(
+			'moved'     => array(),
+			'reindexed' => array(),
+		);
+
+		$anchor_positions = $this->compose_anchor_positions( $previous_id, $product_id, $next_id );
+		if ( ! $this->has_moved( $anchor_positions, $previous_id, $product_id, $next_id ) ) {
+			return (object) $result;
+		}
+
+		// Re-indexing is required when: a position collision is detected or moving between groups one of which is unindexed.
+		$map              = $this->compose_move_map( $previous_id, $product_id, $next_id, $anchor_positions );
+		$needs_reindexing = $map->old_position === $map->new_position || 0 === $map->new_position || 0 === $map->old_position;
+		$needs_reindexing = $needs_reindexing || count( array_unique( $anchor_positions ) ) !== count( $anchor_positions );
+		if ( $needs_reindexing ) {
+			$result['reindexed'] = $this->reindex_service->reindex_products();
+
+			$anchor_positions = $this->compose_anchor_positions( $previous_id, $product_id, $next_id );
+			$map              = $this->compose_move_map( $previous_id, $product_id, $next_id, $anchor_positions );
+			if ( ! $this->has_moved( $anchor_positions, $previous_id, $product_id, $next_id ) ) {
+				return (object) $result;
+			}
+		}
+
+		$result['moved'] = $this->apply( $map, $result['reindexed'] );
+		// Deduct the move related modification from re-indexing to de-duplicate change events in outside workflow.
+		foreach ( $result['moved'] as $id => $position ) {
+			unset( $result['reindexed'][ $id ] );
+		}
+
+		return (object) $result;
+	}
+
+	/**
+	 * Applies the move to the database and returns updated positions of all affected products.
+	 *
+	 * @phpstan-param object{ product_id:int, old_position:int, new_position:int, range_from:int, range_to:int, range_delta:int } $map Map with the move route.
+	 * @param object         $map       Map with the move route.
+	 * @param array<int,int> $reindexed Reindexed positions map.
+	 * @return array<int,int>
+	 */
+	private function apply( object $map, array $reindexed ): array {
+		global $wpdb;
+
+		$range_ids          = array_merge( array( $map->product_id ), $this->compose_range_ids( $map, $reindexed ) );
+		$range_placeholders = implode( ', ', array_fill( 0, count( $range_ids ), '%d' ) );
+
+		// Shift the affected range (including the moved product) then pin it to the exact target position; update by PK is nearly instant.
+		$updated_count  = 0;
+		$updated_count += (int) $wpdb->query(
+			// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
+			$wpdb->prepare(
+				"UPDATE {$wpdb->posts} SET menu_order = menu_order + %d WHERE ID IN ( {$range_placeholders} )", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+				$map->range_delta,
+				...$range_ids
+			)
+		);
+		$updated_count += (int) $wpdb->update( $wpdb->posts, array( 'menu_order' => $map->new_position ), array( 'ID' => $map->product_id ) );
+		if ( $updated_count > 0 ) {
+			/**
+			 * Whether to fire the clean_post_cache action per product after reordering or apply targeted cache invalidation.
+			 * Default strategy is clean_post_cache is suboptimal, but applied for backward compatibility reasons.
+			 *
+			 * @since 11.2.0
+			 *
+			 * @param bool $clean_post_cache Whether to fire clean_post_cache per product.
+			 * @returns bool
+			 */
+			$clean_post_cache = (bool) apply_filters( 'woocommerce_single_product_ordering_clean_post_cache', true );
+			if ( $clean_post_cache ) {
+				// Performance note: fires clean_post_cache action per product for cache plugins compatibility (WooCommerce v11.2).
+				array_walk( $range_ids, 'clean_post_cache' );
+			} else {
+				// Performance note: clear only the posts cache — menu_order lives in wp_posts, not in meta or term caches.
+				wp_cache_delete_multiple( $range_ids, 'posts' );
+				wp_cache_set_posts_last_changed();
+			}
+		}
+
+		// Fetch updated positions for cache invalidation, hooks, and response; fetch by PK is nearly instant.
+		$updated_positions = array_column(
+			$wpdb->get_results(
+				$wpdb->prepare(
+					"SELECT ID, menu_order FROM {$wpdb->posts} WHERE ID IN ( {$range_placeholders} ) ORDER BY menu_order ASC", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
+					$range_ids
+				)
+			),
+			'menu_order',
+			'ID'
+		);
+
+		return array_map( 'intval', $updated_positions );
+	}
+
+	/**
+	 * Determines whether the product's position needs updating.
+	 *
+	 * @param array<int,int> $anchor_positions Map of product ID → current menu_order, ordered by menu_order ASC.
+	 * @param int            $previous_id      ID of the product before the target position, or 0 for start.
+	 * @param int            $product_id       ID of the product being repositioned.
+	 * @param int            $next_id          ID of the product after the target position, or 0 for end.
+	 *
+	 * @return bool
+	 */
+	private function has_moved( array $anchor_positions, int $previous_id, int $product_id, int $next_id ): bool {
+		// Compare DB ordering (array keys sorted by menu_order) against the requested ordering (filtering out 0 pseudo-IDs).
+		$has_moved = array_keys( $anchor_positions ) !== array_values( array_filter( array( $previous_id, $product_id, $next_id ) ) );
+		if ( $has_moved ) {
+			return true;
+		}
+
+		// Unindexed (position 0) or colliding (duplicate positions) anchors always need work.
+		$needs_reindexing = in_array( 0, $anchor_positions, true ) || count( array_unique( $anchor_positions ) ) !== count( $anchor_positions );
+		if ( $needs_reindexing ) {
+			return true;
+		}
+
+		return false;
+	}
+
+	/**
+	 * Computes the target position and the range of products that must shift to accommodate the move.
+	 *
+	 * @param int            $previous_id      ID of the product immediately before the target position, or 0 when moving to the start.
+	 * @param int            $product_id       ID of the product being repositioned.
+	 * @param int            $next_id          ID of the product immediately after the target position, or 0 when moving to the end.
+	 * @param array<int,int> $anchor_positions Map of product ID → current menu_order for the three anchor products.
+	 *
+	 * @return object{ product_id:int, old_position:int, new_position:int, range_from:int, range_to:int, range_delta:int }
+	 */
+	private function compose_move_map( int $previous_id, int $product_id, int $next_id, array $anchor_positions ): object {
+		$previous_position = (int) ( $anchor_positions[ $previous_id ] ?? 0 );
+		$product_position  = (int) ( $anchor_positions[ $product_id ] ?? 0 );
+		$next_position     = (int) ( $anchor_positions[ $next_id ] ?? 0 );
+
+		if ( $previous_position > $product_position ) {
+			// Moving forward: products between current and new position shift down.
+			$range_from   = $product_position + 1;
+			$range_to     = $previous_position;
+			$range_delta  = -1;
+			$new_position = $previous_position;
+		} else {
+			// Moving backward: products between new and current position shift up.
+			$range_from   = $next_position;
+			$range_to     = $product_position - 1;
+			$range_delta  = +1;
+			$new_position = $next_position;
+		}
+
+		return (object) array(
+			'product_id'   => $product_id,
+			'old_position' => $product_position,
+			'new_position' => $new_position,
+			'range_from'   => $range_from,
+			'range_to'     => $range_to,
+			'range_delta'  => $range_delta,
+		);
+	}
+
+	/**
+	 * Fetches the current menu_order for the anchor products.
+	 *
+	 * @param int $previous_id ID of the product immediately before the target position, or 0 when moving to the start.
+	 * @param int $product_id  ID of the product being repositioned.
+	 * @param int $next_id     ID of the product immediately after the target position, or 0 when moving to the end.
+	 * @return array<int,int>
+	 */
+	private function compose_anchor_positions( int $previous_id, int $product_id, int $next_id ): array {
+		global $wpdb;
+
+		return array_column(
+			$wpdb->get_results(
+				$wpdb->prepare(
+					"SELECT ID, menu_order FROM {$wpdb->posts} WHERE ID IN (%d, %d, %d) ORDER BY menu_order ASC",
+					$previous_id,
+					$product_id,
+					$next_id
+				)
+			),
+			'menu_order',
+			'ID'
+		);
+	}
+
+	/**
+	 * Returns IDs of products whose position falls within the move range.
+	 *
+	 * @phpstan-param object{ range_from:int, range_to:int } $map Map with the move route.
+	 * @param object         $map       Map with the move route.
+	 * @param array<int,int> $reindexed Reindexed positions map, keyed by product ID.
+	 * @return int[]
+	 */
+	private function compose_range_ids( object $map, array $reindexed ): array {
+		global $wpdb;
+
+		// Performance note: when a prior reindex is available, derive range IDs from it — avoids a DB round-trip.
+		$expected_count = $map->range_to - $map->range_from + 1;
+		$range_ids      = array_keys( array_filter( $reindexed, static fn( $position ) => $position >= $map->range_from && $position <= $map->range_to ) );
+		if ( count( $range_ids ) !== $expected_count ) {
+			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+			$range_ids = $wpdb->get_col(
+				$wpdb->prepare(
+					"SELECT ID FROM {$wpdb->posts} WHERE post_type = 'product' AND menu_order BETWEEN %d AND %d",
+					$map->range_from,
+					$map->range_to
+				)
+			);
+		}
+
+		return array_map( 'intval', $range_ids );
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Products/ProductsOrderingReindexService.php b/plugins/woocommerce/src/Internal/Products/ProductsOrderingReindexService.php
new file mode 100644
index 00000000000..cdbbedf2e24
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Products/ProductsOrderingReindexService.php
@@ -0,0 +1,71 @@
+<?php declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Products;
+
+/**
+ * Assigns sequential menu_order values to all products, enabling deterministic drag-and-drop ordering.
+ */
+final class ProductsOrderingReindexService {
+	/**
+	 * Designed for an HVM with 500K+ products catalog operating in a clustered environment. To satisfy this setup:
+	 * - The batch size is set to 250. Increasing this value may negatively impact catalog browsing performance.
+	 * - Cache invalidation targets the posts cache only, since menu_order lives in wp_posts and is not stored in meta or term caches.
+	 * - Resources allocation for 500K products catalog: product-position map - 20 MB RAM, 2000 SQLs for full reindexing ± 2 seconds.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param int $batch_size Number of products included in each batch for re-indexing.
+	 * @return array<int,int>
+	 */
+	public function reindex_products( int $batch_size = 250 ): array {
+		global $wpdb;
+
+		// Performance note: prefetch product ids; enables deterministic behaviour and faster queries below.
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+		$product_ids = array_map( 'intval', $wpdb->get_col( "SELECT ID FROM {$wpdb->posts} WHERE post_type = 'product' ORDER BY menu_order ASC, post_title ASC, ID ASC" ) );
+		/**
+		 * Whether to fire the clean_post_cache action per product after reordering or apply targeted cache invalidation.
+		 * Default strategy is clean_post_cache is suboptimal, but applied for backward compatibility reasons.
+		 *
+		 * @since 11.2.0
+		 *
+		 * @param bool $clean_post_cache Whether to fire clean_post_cache per product.
+		 * @returns bool
+		 */
+		$clean_post_cache = (bool) apply_filters( 'woocommerce_single_product_ordering_clean_post_cache', true );
+
+		$result           = array();
+		$current_position = 1;
+		for ( $offset = 0, $total = count( $product_ids ), $batch_size = max( 1, $batch_size ); $offset < $total; $offset += $batch_size ) {
+			$batch_ids       = array_slice( $product_ids, $offset, $batch_size );
+			$batch_positions = array();
+			$batch_branches  = array();
+			foreach ( $batch_ids as $id ) {
+				$batch_positions[ $id ] = $current_position;
+				$batch_branches[]       = sprintf( 'WHEN %d THEN %d', $id, $current_position++ );
+			}
+
+			$batch_branches = implode( ' ', $batch_branches );
+			$in_values      = implode( ', ', $batch_ids );
+			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+			$updated = (int) $wpdb->query( "UPDATE {$wpdb->posts} SET menu_order = CASE ID {$batch_branches} END WHERE ID IN ( {$in_values} )" );
+			if ( $updated > 0 ) {
+				if ( $clean_post_cache ) {
+					// Performance note: fires clean_post_cache action per product for cache plugins compatibility (WooCommerce v11.2).
+					array_walk( $batch_ids, 'clean_post_cache' );
+				} else {
+					// Performance note: clear only the posts cache — menu_order lives in wp_posts, not in meta or term caches.
+					wp_cache_delete_multiple( $batch_ids, 'posts' );
+					wp_cache_set_posts_last_changed();
+				}
+
+				// Update the result entries only if update is confirmed.
+				foreach ( $batch_positions as $id => $position ) {
+					$result[ $id ] = $position;
+				}
+			}
+		}
+
+		return $result;
+	}
+}
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php b/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
index 11ef63149e4..104978276a8 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
@@ -692,6 +692,172 @@ class WC_AJAX_Test extends \WP_Ajax_UnitTestCase {
 		);
 	}

+	/**
+	 * Data provider for test_product_ordering.
+	 *
+	 * Columns: sorting_idx, previd_idx (-1 = none), nextid_idx (-1 = none), expected menu_orders [P1..P5].
+	 */
+	public function product_ordering_provider(): array {
+		return array(
+			'last to first'             => array( 4, -1, 0, array( 2, 3, 4, 5, 1 ) ),
+			'first to last'             => array( 0, 4, -1, array( 5, 1, 2, 3, 4 ) ),
+			'middle one position left'  => array( 2, 0, 1, array( 1, 3, 2, 4, 5 ) ),
+			'middle one position right' => array( 2, 3, 4, array( 1, 2, 4, 3, 5 ) ),
+			'middle to first'           => array( 2, -1, 0, array( 2, 3, 1, 4, 5 ) ),
+			'middle to last'            => array( 2, 4, -1, array( 1, 2, 5, 3, 4 ) ),
+			'drop in place'             => array( 2, 1, 3, array( 1, 2, 3, 4, 5 ) ),
+		);
+	}
+
+	/**
+	 * @testdox 'product_ordering' (legacy algorithm) moves a product to the correct position and shifts the affected range.
+	 * @dataProvider product_ordering_provider
+	 *
+	 * @param int   $sorting_idx     Index (0-based) of the product being dragged.
+	 * @param int   $previd_idx      Index of the product immediately before the drop target, or -1 if dropped at the top.
+	 * @param int   $nextid_idx      Index of the product immediately after the drop target, or -1 if dropped at the bottom.
+	 * @param int[] $expected_orders Expected menu_order values indexed by original product position [P1..P5].
+	 */
+	public function test_product_ordering_using_legacy_algorithm( int $sorting_idx, int $previd_idx, int $nextid_idx, array $expected_orders ): void {
+		global $wpdb;
+
+		$this->_setRole( 'administrator' );
+
+		// Attach a listener to force the legacy branching path.
+		$legacy_hook = function () {};
+		add_action( 'woocommerce_after_single_product_ordering', $legacy_hook );
+
+		$products = array();
+		for ( $i = 1; $i <= 5; ++$i ) {
+			$product                 = WC_Helper_Product::create_simple_product();
+			$product_id              = $product->get_id();
+			$products[ $product_id ] = $product;
+			wp_update_post(
+				array(
+					'ID'         => $product_id,
+					'menu_order' => $i,
+				)
+			);
+		}
+		$product_ids = array_keys( $products );
+
+		$_POST['security'] = wp_create_nonce( 'product-ordering' );
+		$_POST['id']       = $product_ids[ $sorting_idx ];
+		$_POST['previd']   = $previd_idx >= 0 ? $product_ids[ $previd_idx ] : 0;
+		$_POST['nextid']   = $nextid_idx >= 0 ? $product_ids[ $nextid_idx ] : 0;
+
+		$this->do_ajax( 'woocommerce_product_ordering' );
+
+		unset( $_POST['security'], $_POST['id'], $_POST['previd'], $_POST['nextid'] );
+		remove_action( 'woocommerce_after_single_product_ordering', $legacy_hook );
+
+		foreach ( $product_ids as $idx => $product_id ) {
+			$actual = (int) $wpdb->get_var( $wpdb->prepare( "SELECT menu_order FROM {$wpdb->posts} WHERE ID = %d", $product_id ) );
+			$this->assertSame( $expected_orders[ $idx ], $actual, "Product at index {$idx} has wrong menu_order." );
+			$products[ $product_id ]->delete( true );
+		}
+	}
+
+	/**
+	 * @testdox 'product_ordering' (range algorithm) moves a product to the correct position and shifts the affected range.
+	 * @dataProvider product_ordering_provider
+	 *
+	 * @param int   $sorting_idx     Index (0-based) of the product being dragged.
+	 * @param int   $previd_idx      Index of the product immediately before the drop target, or -1 if dropped at the top.
+	 * @param int   $nextid_idx      Index of the product immediately after the drop target, or -1 if dropped at the bottom.
+	 * @param int[] $expected_orders Expected menu_order values indexed by original product position [P1..P5].
+	 */
+	public function test_product_ordering_using_range_algorithm( int $sorting_idx, int $previd_idx, int $nextid_idx, array $expected_orders ): void {
+		global $wpdb;
+
+		$this->_setRole( 'administrator' );
+
+		$products = array();
+		for ( $i = 1; $i <= 5; ++$i ) {
+			$product                 = WC_Helper_Product::create_simple_product();
+			$product_id              = $product->get_id();
+			$products[ $product_id ] = $product;
+			wp_update_post(
+				array(
+					'ID'         => $product_id,
+					'menu_order' => $i,
+				)
+			);
+		}
+		$product_ids = array_keys( $products );
+
+		$_POST['security'] = wp_create_nonce( 'product-ordering' );
+		$_POST['id']       = $product_ids[ $sorting_idx ];
+		$_POST['previd']   = $previd_idx >= 0 ? $product_ids[ $previd_idx ] : 0;
+		$_POST['nextid']   = $nextid_idx >= 0 ? $product_ids[ $nextid_idx ] : 0;
+
+		$this->do_ajax( 'woocommerce_product_ordering' );
+
+		unset( $_POST['security'], $_POST['id'], $_POST['previd'], $_POST['nextid'] );
+		foreach ( $product_ids as $idx => $product_id ) {
+			$actual = (int) $wpdb->get_var( $wpdb->prepare( "SELECT menu_order FROM {$wpdb->posts} WHERE ID = %d", $product_id ) );
+			$this->assertSame( $expected_orders[ $idx ], $actual, "Product at index {$idx} has wrong menu_order." );
+			$products[ $product_id ]->delete( true );
+		}
+	}
+
+	/**
+	 * @testdox 'product_ordering' fires 'woocommerce_after_product_ordering' with the moved product ID and full positions map.
+	 */
+	public function test_product_ordering_fires_after_product_ordering_action(): void {
+		$this->_setRole( 'administrator' );
+
+		$products = array();
+		for ( $i = 1; $i <= 2; ++$i ) {
+			$product                 = WC_Helper_Product::create_simple_product();
+			$product_id              = $product->get_id();
+			$products[ $product_id ] = $product;
+			wp_update_post(
+				array(
+					'ID'         => $product_id,
+					'menu_order' => $i,
+				)
+			);
+		}
+		$product_ids = array_keys( $products );
+
+		$hook_fired = false;
+		$captured   = array();
+		$hook       = function ( $sorting_id, $all_positions ) use ( &$hook_fired, &$captured ) {
+			$hook_fired = true;
+			$captured   = array(
+				'sorting_id'    => $sorting_id,
+				'all_positions' => $all_positions,
+			);
+		};
+		add_action( 'woocommerce_after_product_ordering', $hook, 10, 2 );
+
+		// Move the last one to the front.
+		$_POST['security'] = wp_create_nonce( 'product-ordering' );
+		$_POST['id']       = $product_ids[1];
+		$_POST['previd']   = 0;
+		$_POST['nextid']   = $product_ids[0];
+
+		$this->do_ajax( 'woocommerce_product_ordering' );
+
+		unset( $_POST['security'], $_POST['id'], $_POST['previd'], $_POST['nextid'] );
+		remove_action( 'woocommerce_after_product_ordering', $hook, 10 );
+
+		$this->assertTrue( $hook_fired, 'woocommerce_after_product_ordering was not fired.' );
+		$this->assertSame( $product_ids[1], $captured['sorting_id'] );
+		$this->assertSame(
+			array(
+				$product_ids[0] => 2,
+				$product_ids[1] => 1,
+			),
+			$captured['all_positions']
+		);
+
+		foreach ( $product_ids as $product_id ) {
+			$products[ $product_id ]->delete( true );
+		}
+	}
+
 	/**
 	 * @testdox Refunding a 0% taxed line item via the AJAX handler preserves the 0-rate tax line on the refund order.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/Products/ProductsOrderingMoveServiceTest.php b/plugins/woocommerce/tests/php/src/Internal/Products/ProductsOrderingMoveServiceTest.php
new file mode 100644
index 00000000000..0cfe68c985c
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Products/ProductsOrderingMoveServiceTest.php
@@ -0,0 +1,386 @@
+<?php declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Products;
+
+use Automattic\WooCommerce\Internal\Products\ProductsOrderingMoveService;
+use Automattic\WooCommerce\Internal\Products\ProductsOrderingReindexService;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the ProductsOrderingMoveService class.
+ */
+final class ProductsOrderingMoveServiceTest extends WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var ProductsOrderingMoveService
+	 */
+	private ProductsOrderingMoveService $sut;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->sut = new ProductsOrderingMoveService();
+		$this->sut->init( new ProductsOrderingReindexService() );
+	}
+
+	/**
+	 * @testdox move() repositions a product within a fully indexed catalog and shifts the affected range.
+	 * @dataProvider move_provider
+	 *
+	 * @param int   $sorting_idx     Index (0-based) of the product being moved.
+	 * @param int   $previd_idx      Index of the product immediately before the drop target, or -1 if dropped at the top.
+	 * @param int   $nextid_idx      Index of the product immediately after the drop target, or -1 if dropped at the bottom.
+	 * @param int[] $expected_orders Expected menu_order values indexed by original product position [P1..P5].
+	 */
+	public function test_move( int $sorting_idx, int $previd_idx, int $nextid_idx, array $expected_orders ): void {
+		global $wpdb;
+
+		$products    = array();
+		$product_ids = array();
+		for ( $i = 1; $i <= 5; ++$i ) {
+			$product = new \WC_Product_Simple();
+			$product->set_menu_order( $i );
+			$product->save();
+
+			$product_id              = $product->get_id();
+			$product_ids[]           = $product_id;
+			$products[ $product_id ] = $product;
+		}
+
+		$result = $this->sut->move(
+			$previd_idx >= 0 ? $product_ids[ $previd_idx ] : 0,
+			$product_ids[ $sorting_idx ],
+			$nextid_idx >= 0 ? $product_ids[ $nextid_idx ] : 0
+		);
+
+		$this->assertEmpty( $result->reindexed, 'No reindex should occur when all products are already sequentially indexed.' );
+		foreach ( $product_ids as $index => $product_id ) {
+			$actual = (int) $wpdb->get_var( $wpdb->prepare( "SELECT menu_order FROM {$wpdb->posts} WHERE ID = %d", $product_id ) );
+			$this->assertSame( $expected_orders[ $index ], $actual, "Product at index {$index} has wrong menu_order." );
+			$products[ $product_id ]->delete( true );
+		}
+	}
+
+	/**
+	 * Data provider for test_move.
+	 *
+	 * Columns: sorting_idx, previd_idx (-1 = none), nextid_idx (-1 = none), expected menu_orders [P1..P5].
+	 *
+	 * @return array
+	 */
+	public function move_provider(): array {
+		return array(
+			'last to first'             => array( 4, -1, 0, array( 2, 3, 4, 5, 1 ) ),
+			'first to last'             => array( 0, 4, -1, array( 5, 1, 2, 3, 4 ) ),
+			'middle one position left'  => array( 2, 0, 1, array( 1, 3, 2, 4, 5 ) ),
+			'middle one position right' => array( 2, 3, 4, array( 1, 2, 4, 3, 5 ) ),
+			'middle to first'           => array( 2, -1, 0, array( 2, 3, 1, 4, 5 ) ),
+			'middle to last'            => array( 2, 4, -1, array( 1, 2, 5, 3, 4 ) ),
+			'drop in place'             => array( 2, 1, 3, array( 1, 2, 3, 4, 5 ) ),
+		);
+	}
+
+	/**
+	 * @testdox move() triggers a full reindex and then applies the move when no products have been indexed yet.
+	 */
+	public function test_move_with_no_prior_indexing(): void {
+		global $wpdb;
+
+		$alpha_id = $this->create_product( 'Alpha', 0 );
+		$beta_id  = $this->create_product( 'Beta', 0 );
+		$gamma_id = $this->create_product( 'Gamma', 0 );
+		$products = array( $alpha_id, $beta_id, $gamma_id );
+
+		$result = $this->sut->move( $alpha_id, $gamma_id, $beta_id );
+
+		$this->assertSame( array( $alpha_id => 1 ), $result->reindexed );
+		$this->assertSame(
+			array(
+				$gamma_id => 2,
+				$beta_id  => 3,
+			),
+			$result->moved
+		);
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
+		$db_positions = $wpdb->get_results( $wpdb->prepare( "SELECT ID, menu_order FROM {$wpdb->posts} WHERE ID IN (%d, %d, %d) ORDER BY menu_order ASC", $alpha_id, $gamma_id, $beta_id ) );
+		$this->assertEquals(
+			array(
+				$alpha_id => 1,
+				$gamma_id => 2,
+				$beta_id  => 3,
+			),
+			array_column( $db_positions, 'menu_order', 'ID' )
+		);
+
+		array_walk( $products, static fn( $id ) => wc_get_product( $id )->delete( true ) );
+	}
+
+	/**
+	 * @testdox move() triggers a full reindex when the target position is adjacent to an unindexed product.
+	 */
+	public function test_move_from_indexed_into_unindexed_group(): void {
+		$delta_id = $this->create_product( 'Delta', 0 );
+		$gamma_id = $this->create_product( 'Gamma', 0 );
+		$alpha_id = $this->create_product( 'Alpha', 1 );
+		$beta_id  = $this->create_product( 'Beta', 2 );
+		$products = array( $delta_id, $gamma_id, $alpha_id, $beta_id );
+
+		$result = $this->sut->move( $delta_id, $beta_id, $gamma_id );
+
+		$this->assertSame( array( $delta_id => 1 ), $result->reindexed );
+		$this->assertSame(
+			array(
+				$beta_id  => 2,
+				$gamma_id => 3,
+				$alpha_id => 4,
+			),
+			$result->moved
+		);
+
+		array_walk( $products, static fn( $id ) => wc_get_product( $id )->delete( true ) );
+	}
+
+	/**
+	 * @testdox move() triggers a reindex when two products share the same position (collision) and then applies the move.
+	 */
+	public function test_move_triggers_reindex_on_position_collision(): void {
+		$delta_id = $this->create_product( 'Delta', 1 );
+		$beta_id  = $this->create_product( 'Beta', 2 );
+		$gamma_id = $this->create_product( 'Gamma', 2 );
+		$products = array( $delta_id, $beta_id, $gamma_id );
+
+		$result = $this->sut->move( $delta_id, $gamma_id, $beta_id );
+
+		$this->assertSame( array( $delta_id => 1 ), $result->reindexed );
+		$this->assertSame(
+			array(
+				$gamma_id => 2,
+				$beta_id  => 3,
+			),
+			$result->moved
+		);
+
+		array_walk( $products, static fn( $id ) => wc_get_product( $id )->delete( true ) );
+	}
+
+	/**
+	 * @testdox move() resolves a collision even when colliding anchors already match the requested order.
+	 */
+	public function test_move_resolves_collision_matching_requested_order(): void {
+		$alpha_id = $this->create_product( 'Alpha', 1 );
+		$beta_id  = $this->create_product( 'Beta', 2 );
+		$gamma_id = $this->create_product( 'Gamma', 2 );
+		$products = array( $alpha_id, $beta_id, $gamma_id );
+
+		$result = $this->sut->move( $alpha_id, $beta_id, $gamma_id );
+
+		// Reindex resolves the collision; product is already in place post-reindex, so no move needed.
+		$this->assertSame(
+			array(
+				$alpha_id => 1,
+				$beta_id  => 2,
+				$gamma_id => 3,
+			),
+			$result->reindexed
+		);
+		$this->assertSame( array(), $result->moved );
+
+		array_walk( $products, static fn( $id ) => wc_get_product( $id )->delete( true ) );
+	}
+
+	/**
+	 * @testdox move() triggers a reindex but skips the move when the product is already in the correct position after reindexing.
+	 */
+	public function test_move_skips_apply_when_in_place_after_reindex(): void {
+		$gamma_id = $this->create_product( 'Gamma', 0 );
+		$beta_id  = $this->create_product( 'Beta', 0 );
+		$alpha_id = $this->create_product( 'Alpha', 0 );
+		$products = array( $gamma_id, $beta_id, $alpha_id );
+
+		$result = $this->sut->move( $alpha_id, $beta_id, $gamma_id );
+
+		$this->assertSame(
+			array(
+				$alpha_id => 1,
+				$beta_id  => 2,
+				$gamma_id => 3,
+			),
+			$result->reindexed
+		);
+		$this->assertSame( array(), $result->moved );
+
+		array_walk( $products, static fn( $id ) => wc_get_product( $id )->delete( true ) );
+	}
+
+	/**
+	 * @testdox move() does nothing when moving an unindexed product to the first position and it already sorts first.
+	 */
+	public function test_move_unindexed_to_first_position(): void {
+		$alpha_id = $this->create_product( 'Alpha', 1 );
+		$beta_id  = $this->create_product( 'Beta', 2 );
+		$gamma_id = $this->create_product( 'Gamma', 0 );
+		$products = array( $alpha_id, $beta_id, $gamma_id );
+
+		$result = $this->sut->move( 0, $gamma_id, $alpha_id );
+
+		$this->assertSame( array(), $result->reindexed );
+		$this->assertSame( array(), $result->moved );
+
+		array_walk( $products, static fn( $id ) => wc_get_product( $id )->delete( true ) );
+	}
+
+	/**
+	 * @testdox move() is a no-op when both anchors are zero.
+	 */
+	public function test_move_with_both_anchors_zero(): void {
+		$alpha_id = $this->create_product( 'Alpha', 1 );
+
+		$result = $this->sut->move( 0, $alpha_id, 0 );
+
+		$this->assertSame( array(), $result->reindexed );
+		$this->assertSame( array(), $result->moved );
+
+		wc_get_product( $alpha_id )->delete( true );
+	}
+
+	/**
+	 * @testdox move() triggers a full reindex when moving an unindexed product to the last position.
+	 */
+	public function test_move_unindexed_to_last_position(): void {
+		$alpha_id = $this->create_product( 'Alpha', 1 );
+		$beta_id  = $this->create_product( 'Beta', 2 );
+		$gamma_id = $this->create_product( 'Gamma', 0 );
+		$products = array( $alpha_id, $beta_id, $gamma_id );
+
+		$result = $this->sut->move( $beta_id, $gamma_id, 0 );
+
+		$this->assertSame( array(), $result->reindexed );
+		$this->assertSame(
+			array(
+				$alpha_id => 1,
+				$beta_id  => 2,
+				$gamma_id => 3,
+			),
+			$result->moved
+		);
+
+		array_walk( $products, static fn( $id ) => wc_get_product( $id )->delete( true ) );
+	}
+
+	/**
+	 * @testdox move() triggers a full reindex when moving an unindexed product into an already indexed group.
+	 */
+	public function test_move_from_unindexed_into_indexed_group(): void {
+		$alpha_id = $this->create_product( 'Alpha', 1 );
+		$beta_id  = $this->create_product( 'Beta', 2 );
+		$gamma_id = $this->create_product( 'Gamma', 0 );
+		$products = array( $alpha_id, $beta_id, $gamma_id );
+
+		$result = $this->sut->move( $alpha_id, $gamma_id, $beta_id );
+
+		$this->assertSame( array( $beta_id => 3 ), $result->reindexed );
+		$this->assertSame(
+			array(
+				$alpha_id => 1,
+				$gamma_id => 2,
+			),
+			$result->moved
+		);
+
+		array_walk( $products, static fn( $id ) => wc_get_product( $id )->delete( true ) );
+	}
+
+	/**
+	 * @testdox move() does not reindex and leaves unindexed products untouched when the move stays within the indexed group.
+	 */
+	public function test_move_within_indexed_group_with_unindexed_present(): void {
+		$alpha_id   = $this->create_product( 'Alpha', 1 );
+		$beta_id    = $this->create_product( 'Beta', 2 );
+		$gamma_id   = $this->create_product( 'Gamma', 3 );
+		$delta_id   = $this->create_product( 'Delta', 0 );
+		$epsilon_id = $this->create_product( 'Epsilon', 0 );
+		$products   = array( $alpha_id, $beta_id, $gamma_id, $delta_id, $epsilon_id );
+
+		$result = $this->sut->move( $alpha_id, $gamma_id, $beta_id );
+
+		$this->assertSame( array(), $result->reindexed );
+		$this->assertSame(
+			array(
+				$gamma_id => 2,
+				$beta_id  => 3,
+			),
+			$result->moved
+		);
+
+		array_walk( $products, static fn( $id ) => wc_get_product( $id )->delete( true ) );
+	}
+
+	/**
+	 * @testdox move() correctly shifts all products in the range when non-anchor duplicates exist (no reindex).
+	 */
+	public function test_move_with_non_anchor_duplicate_in_range(): void {
+		$alpha_id   = $this->create_product( 'Alpha', 1 );
+		$beta_id    = $this->create_product( 'Beta', 2 );
+		$charlie_id = $this->create_product( 'Charlie', 2 );
+		$delta_id   = $this->create_product( 'Delta', 3 );
+		$echo_id    = $this->create_product( 'Echo', 4 );
+		$products   = array( $alpha_id, $beta_id, $charlie_id, $delta_id, $echo_id );
+
+		$result = $this->sut->move( $alpha_id, $echo_id, $beta_id );
+
+		$this->assertSame( array(), $result->reindexed );
+		$this->assertSame(
+			array(
+				$echo_id    => 2,
+				$beta_id    => 3,
+				$charlie_id => 3,
+				$delta_id   => 4,
+			),
+			$result->moved
+		);
+
+		array_walk( $products, static fn( $id ) => wc_get_product( $id )->delete( true ) );
+	}
+
+	/**
+	 * @testdox move() triggers a reindex when previous and next anchors share the same non-zero position.
+	 */
+	public function test_move_triggers_reindex_on_anchor_collision(): void {
+		$alpha_id = $this->create_product( 'Alpha', 1 );
+		$beta_id  = $this->create_product( 'Beta', 5 );
+		$gamma_id = $this->create_product( 'Gamma', 5 );
+		$products = array( $alpha_id, $beta_id, $gamma_id );
+
+		$result = $this->sut->move( $beta_id, $alpha_id, $gamma_id );
+
+		$this->assertSame( array( $gamma_id => 3 ), $result->reindexed );
+		$this->assertSame(
+			array(
+				$beta_id  => 1,
+				$alpha_id => 2,
+			),
+			$result->moved
+		);
+
+		array_walk( $products, static fn( $id ) => wc_get_product( $id )->delete( true ) );
+	}
+
+	/**
+	 * @param string $name       Product name (controls reindex sort order via post_title ASC).
+	 * @param int    $menu_order Initial menu_order value.
+	 * @return int
+	 */
+	private function create_product( string $name, int $menu_order ): int {
+		$product = new \WC_Product_Simple();
+		$product->set_name( $name );
+		$product->set_menu_order( $menu_order );
+		$product->save();
+
+		return $product->get_id();
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Products/ProductsOrderingReindexServiceTest.php b/plugins/woocommerce/tests/php/src/Internal/Products/ProductsOrderingReindexServiceTest.php
new file mode 100644
index 00000000000..e0e753f366e
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Products/ProductsOrderingReindexServiceTest.php
@@ -0,0 +1,138 @@
+<?php declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Products;
+
+use Automattic\WooCommerce\Internal\Products\ProductsOrderingReindexService;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the ProductsOrderingReindexService class.
+ */
+final class ProductsOrderingReindexServiceTest extends WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var ProductsOrderingReindexService
+	 */
+	private ProductsOrderingReindexService $sut;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->sut = new ProductsOrderingReindexService();
+	}
+
+	/**
+	 * @testdox reindex_products assigns sequential positions starting at 1, ordered by menu_order then post_title.
+	 * @dataProvider reindex_products_data_provider
+	 *
+	 * @param array<array{name:string,menu_order:int}> $products       Products to create before reindexing.
+	 * @param string[]                                 $expected_order Product names in expected ascending position order.
+	 */
+	public function test_reindex_products( array $products, array $expected_order ): void {
+		$name_to_id = array();
+		foreach ( $products as $product_data ) {
+			$product = new \WC_Product_Simple();
+			$product->set_name( $product_data['name'] );
+			$product->set_menu_order( $product_data['menu_order'] );
+			$product->save();
+			$name_to_id[ $product_data['name'] ] = $product->get_id();
+		}
+
+		$result = $this->sut->reindex_products( 2 );
+
+		$this->assertCount( count( $expected_order ), $result );
+		foreach ( $expected_order as $index => $name ) {
+			$this->assertSame(
+				$index + 1,
+				$result[ $name_to_id[ $name ] ],
+				"Product '{$name}' should have position " . ( $index + 1 ) . '.'
+			);
+		}
+	}
+
+	/**
+	 * Data provider for test_reindex_products.
+	 *
+	 * @return array
+	 */
+	public function reindex_products_data_provider(): array {
+		return array(
+			'empty catalog'                      => array(
+				'products'       => array(),
+				'expected_order' => array(),
+			),
+			'unindexed products sorted by title' => array(
+				'products'       => array(
+					array(
+						'name'       => 'Gamma',
+						'menu_order' => 0,
+					),
+					array(
+						'name'       => 'Alpha',
+						'menu_order' => 0,
+					),
+					array(
+						'name'       => 'Beta',
+						'menu_order' => 0,
+					),
+				),
+				'expected_order' => array( 'Alpha', 'Beta', 'Gamma' ),
+			),
+			'already sequentially indexed'       => array(
+				'products'       => array(
+					array(
+						'name'       => 'First',
+						'menu_order' => 1,
+					),
+					array(
+						'name'       => 'Second',
+						'menu_order' => 2,
+					),
+					array(
+						'name'       => 'Third',
+						'menu_order' => 3,
+					),
+				),
+				'expected_order' => array(),
+			),
+			'sparse positions compacted'         => array(
+				'products'       => array(
+					array(
+						'name'       => 'First',
+						'menu_order' => 1,
+					),
+					array(
+						'name'       => 'Second',
+						'menu_order' => 5,
+					),
+					array(
+						'name'       => 'Third',
+						'menu_order' => 10,
+					),
+				),
+				'expected_order' => array( 'First', 'Second', 'Third' ),
+			),
+			'collisions resolved by title'       => array(
+				'products'       => array(
+					array(
+						'name'       => 'Beta',
+						'menu_order' => 1,
+					),
+					array(
+						'name'       => 'Alpha',
+						'menu_order' => 1,
+					),
+					array(
+						'name'       => 'Gamma',
+						'menu_order' => 2,
+					),
+				),
+				'expected_order' => array( 'Alpha', 'Beta', 'Gamma' ),
+			),
+		);
+	}
+}