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