Commit 707ee6c3a20 for woocommerce

commit 707ee6c3a204ece4859ab1b8ff8f59dfc8fb4778
Author: Vladimir Reznichenko <kalessil@gmail.com>
Date:   Thu Aug 20 16:57:45 2026 +0200

    Product ordering: start legacy hooks deprecation cycle (#67812)

    Start the deprecation cycle for the legacy algorithm:
    - provide a new extensibility surface (bulk-processing oriented) for caching/sync/backup extensions
    - deprecate the legacy actions

diff --git a/plugins/woocommerce/changelog/dev-66603-folloup-deprecate-cycle-start b/plugins/woocommerce/changelog/dev-66603-folloup-deprecate-cycle-start
new file mode 100644
index 00000000000..40ff1c2ca07
--- /dev/null
+++ b/plugins/woocommerce/changelog/dev-66603-folloup-deprecate-cycle-start
@@ -0,0 +1,4 @@
+Significance: minor
+Type: dev
+
+Product ordering: start legacy algorithm deprecation cycle.
diff --git a/plugins/woocommerce/includes/class-wc-ajax.php b/plugins/woocommerce/includes/class-wc-ajax.php
index ca96f1f5514..f6a8663fe1b 100644
--- a/plugins/woocommerce/includes/class-wc-ajax.php
+++ b/plugins/woocommerce/includes/class-wc-ajax.php
@@ -2353,8 +2353,18 @@ class WC_AJAX {
 		$product_id  = absint( $_POST['id'] );
 		$next_id     = absint( $_POST['nextid'] ?? 0 );

-		$use_legacy_algorithm = has_action( 'woocommerce_after_single_product_ordering' ) || has_action( 'woocommerce_after_product_ordering' );
-		if ( $use_legacy_algorithm ) {
+		$has_per_product_hook   = has_action( 'woocommerce_after_single_product_ordering' );
+		$has_post_ordering_hook = has_action( 'woocommerce_after_product_ordering' );
+		if ( $has_per_product_hook || $has_post_ordering_hook ) {
+			// See `clean_post_cache`, `wp_ajax_woocommerce_product_ordering`, `woocommerce_product_ordering_process_reindexed_products`
+			// and `woocommerce_product_ordering_process_moved_products` for available migration primitives.
+			if ( $has_per_product_hook ) {
+				wc_deprecated_hook( 'woocommerce_after_single_product_ordering', '11.2', null, 'Using this hook forces a non-optimized reordering path which causes performance issues on larger catalogs.' );
+			}
+			if ( $has_post_ordering_hook ) {
+				wc_deprecated_hook( 'woocommerce_after_product_ordering', '11.2', null, 'Using this hook forces a non-optimized reordering path which causes performance issues on larger catalogs.' );
+			}
+
 			// 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;
@@ -2415,9 +2425,35 @@ class WC_AJAX {

 		} else {
 			$modifications = wc_get_container()->get( ProductsOrderingMoveService::class )->move( $previous_id, $product_id, $next_id );
-			if ( ! empty( $modifications->moved ) || ! empty( $modifications->reindexed ) ) {
+			$moved         = ! empty( $modifications->moved );
+			$reindexed     = ! empty( $modifications->reindexed );
+			if ( $moved || $reindexed ) {
 				WC_Post_Data::delete_product_query_transients();
-				unset( $modifications->reindexed );
+
+				if ( $reindexed ) {
+					/**
+					 * Fires after a full catalog reindex was triggered during product ordering.
+					 *
+					 * @param int            $product_id The product ID that was repositioned.
+					 * @param array<int,int> $reindexed  Reindexed product positions (product ID → menu_order), excludes moved products.
+					 *
+					 * @since 11.2.0
+					 */
+					do_action( 'woocommerce_product_ordering_process_reindexed_products', $product_id, $modifications->reindexed );
+					unset( $modifications->reindexed );
+				}
+
+				if ( $moved ) {
+					/**
+					 * Fires after products have been repositioned during product ordering.
+					 *
+					 * @param int            $product_id The product ID that was repositioned.
+					 * @param array<int,int> $moved      Moved product positions (product ID → menu_order).
+					 *
+					 * @since 11.2.0
+					 */
+					do_action( 'woocommerce_product_ordering_process_moved_products', $product_id, $modifications->moved );
+				}
 			}
 			wp_send_json( $modifications->moved );
 		}
diff --git a/plugins/woocommerce/src/Internal/Products/ProductsOrderingMoveService.php b/plugins/woocommerce/src/Internal/Products/ProductsOrderingMoveService.php
index 20831d97a2b..9fe902aae5a 100644
--- a/plugins/woocommerce/src/Internal/Products/ProductsOrderingMoveService.php
+++ b/plugins/woocommerce/src/Internal/Products/ProductsOrderingMoveService.php
@@ -99,24 +99,9 @@ final class ProductsOrderingMoveService {
 		);
 		$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();
-			}
+			// Performance note: fires `clean_post_cache` action per product for extensions compatibility (WooCommerce v11.2).
+			// Targeted wp_cache_delete_multiple + wp_cache_set_posts_last_changed is fast, but insufficient for extensibility surface.
+			array_walk( $range_ids, 'clean_post_cache' );
 		}

 		// Fetch updated positions for cache invalidation, hooks, and response; fetch by PK is nearly instant.
diff --git a/plugins/woocommerce/src/Internal/Products/ProductsOrderingReindexService.php b/plugins/woocommerce/src/Internal/Products/ProductsOrderingReindexService.php
index cdbbedf2e24..f193a7d965c 100644
--- a/plugins/woocommerce/src/Internal/Products/ProductsOrderingReindexService.php
+++ b/plugins/woocommerce/src/Internal/Products/ProductsOrderingReindexService.php
@@ -23,16 +23,6 @@ final class ProductsOrderingReindexService {
 		// 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;
@@ -50,14 +40,9 @@ final class ProductsOrderingReindexService {
 			// 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();
-				}
+				// Performance note: fires `clean_post_cache` action per product for extensions compatibility (WooCommerce v11.2).
+				// Targeted wp_cache_delete_multiple + wp_cache_set_posts_last_changed is fast, but insufficient for extensibility surface.
+				array_walk( $batch_ids, 'clean_post_cache' );

 				// Update the result entries only if update is confirmed.
 				foreach ( $batch_positions as $id => $position ) {
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 104978276a8..98741c22d3e 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
@@ -722,6 +722,7 @@ class WC_AJAX_Test extends \WP_Ajax_UnitTestCase {
 		global $wpdb;

 		$this->_setRole( 'administrator' );
+		$this->setExpectedDeprecated( 'woocommerce_after_single_product_ordering' );

 		// Attach a listener to force the legacy branching path.
 		$legacy_hook = function () {};
@@ -806,6 +807,7 @@ class WC_AJAX_Test extends \WP_Ajax_UnitTestCase {
 	 */
 	public function test_product_ordering_fires_after_product_ordering_action(): void {
 		$this->_setRole( 'administrator' );
+		$this->setExpectedDeprecated( 'woocommerce_after_product_ordering' );

 		$products = array();
 		for ( $i = 1; $i <= 2; ++$i ) {
@@ -858,6 +860,81 @@ class WC_AJAX_Test extends \WP_Ajax_UnitTestCase {
 		}
 	}

+	/**
+	 * @testdox 'product_ordering' (fast path) fires the process_moved and process_reindexed hooks with correct payloads.
+	 */
+	public function test_product_ordering_fires_fast_path_hooks(): void {
+		$this->_setRole( 'administrator' );
+
+		$setup    = array(
+			'Alpha' => 1,
+			'Beta'  => 2,
+			'Gamma' => 3,
+			'Delta' => 0,
+			'Echo'  => 0,
+		);
+		$ids      = array();
+		$products = array();
+		foreach ( $setup as $name => $menu_order ) {
+			$product = new \WC_Product_Simple();
+			$product->set_name( $name );
+			$product->set_menu_order( $menu_order );
+			$product->save();
+			$ids[ $name ]                   = $product->get_id();
+			$products[ $product->get_id() ] = $product;
+		}
+
+		$moved_captured     = array();
+		$reindexed_captured = array();
+		$moved_hook         = function ( $sorting_id, $moved ) use ( &$moved_captured ) {
+			$moved_captured = array(
+				'sorting_id' => $sorting_id,
+				'moved'      => $moved,
+			);
+		};
+		$reindexed_hook     = function ( $sorting_id, $reindexed ) use ( &$reindexed_captured ) {
+			$reindexed_captured = array(
+				'sorting_id' => $sorting_id,
+				'reindexed'  => $reindexed,
+			);
+		};
+		add_action( 'woocommerce_product_ordering_process_moved_products', $moved_hook, 10, 2 );
+		add_action( 'woocommerce_product_ordering_process_reindexed_products', $reindexed_hook, 10, 2 );
+
+		$_POST['security'] = wp_create_nonce( 'product-ordering' );
+		$_POST['id']       = $ids['Gamma'];
+		$_POST['previd']   = $ids['Delta'];
+		$_POST['nextid']   = $ids['Echo'];
+
+		$this->do_ajax( 'woocommerce_product_ordering' );
+
+		unset( $_POST['security'], $_POST['id'], $_POST['previd'], $_POST['nextid'] );
+		remove_action( 'woocommerce_product_ordering_process_moved_products', $moved_hook, 10 );
+		remove_action( 'woocommerce_product_ordering_process_reindexed_products', $reindexed_hook, 10 );
+
+		$this->assertSame(
+			array(
+				'sorting_id' => $ids['Gamma'],
+				'reindexed'  => array( $ids['Delta'] => 1 ),
+			),
+			$reindexed_captured
+		);
+		$this->assertSame(
+			array(
+				'sorting_id' => $ids['Gamma'],
+				'moved'      => array(
+					$ids['Gamma'] => 2,
+					$ids['Echo']  => 3,
+					$ids['Alpha'] => 4,
+					$ids['Beta']  => 5,
+				),
+			),
+			$moved_captured
+		);
+
+		array_walk( $products, static fn( $p ) => $p->delete( true ) );
+	}
+
 	/**
 	 * @testdox Refunding a 0% taxed line item via the AJAX handler preserves the 0-rate tax line on the refund order.
 	 */