Commit 9de3b28614e for woocommerce
commit 9de3b28614efdbaf7b5530107e4089e89436bc87
Author: Karol Manijak <20098064+kmanijak@users.noreply.github.com>
Date: Wed Jun 3 17:00:20 2026 +0200
Fix random Product Collection pagination (#65462)
* Fix random Product Collection pagination
* Add changelog for random Product Collection pagination
* Update PHPStan baseline for Product Collection
* Normalize random collection query IDs
* Rotate random collection seeds daily
* Simplify random collection seed rotation
* Restore random seed docblock typing
* Remove product count from random collection seed
diff --git a/plugins/woocommerce/changelog/fix-random-product-collection-pagination b/plugins/woocommerce/changelog/fix-random-product-collection-pagination
new file mode 100644
index 00000000000..d3a37295dc5
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-random-product-collection-pagination
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep random Product Collection pagination consistent across pages.
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 3f445a62f9a..37ba3481e29 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -52701,12 +52701,6 @@ parameters:
count: 1
path: src/Blocks/BlockTypes/ProductCategory.php
- -
- message: '#^Access to property \$context on an unknown class Automattic\\WooCommerce\\Blocks\\BlockTypes\\ProductCollection\\WP_Block\.$#'
- identifier: class.notFound
- count: 6
- path: src/Blocks/BlockTypes/ProductCollection/Controller.php
-
-
message: '#^Call to method get_param\(\) on an unknown class Automattic\\WooCommerce\\Blocks\\BlockTypes\\ProductCollection\\WP_REST_Request\.$#'
identifier: class.notFound
@@ -52767,12 +52761,6 @@ parameters:
count: 1
path: src/Blocks/BlockTypes/ProductCollection/Controller.php
- -
- message: '#^Parameter \$block of method Automattic\\WooCommerce\\Blocks\\BlockTypes\\ProductCollection\\Controller\:\:build_frontend_query\(\) has invalid type Automattic\\WooCommerce\\Blocks\\BlockTypes\\ProductCollection\\WP_Block\.$#'
- identifier: class.notFound
- count: 1
- path: src/Blocks/BlockTypes/ProductCollection/Controller.php
-
-
message: '#^Parameter \$request of method Automattic\\WooCommerce\\Blocks\\BlockTypes\\ProductCollection\\Controller\:\:update_rest_query_in_editor\(\) has invalid type Automattic\\WooCommerce\\Blocks\\BlockTypes\\ProductCollection\\WP_REST_Request\.$#'
identifier: class.notFound
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/Controller.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/Controller.php
index 3592cc7f606..3f87a11efe2 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/Controller.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/Controller.php
@@ -364,9 +364,9 @@ class Controller extends AbstractBlock {
/**
* Return a custom query based on attributes, filters and global WP_Query.
*
- * @param WP_Query $query The WordPress Query.
- * @param WP_Block $block The block being rendered.
- * @param int $page The page number.
+ * @param WP_Query $query The WordPress Query.
+ * @param \WP_Block $block The block being rendered.
+ * @param int $page The page number.
*
* @return array
*/
@@ -381,6 +381,7 @@ class Controller extends AbstractBlock {
// phpcs:ignore WordPress.DB.SlowDBQuery
$block_context_query['tax_query'] = ! empty( $query['tax_query'] ) ? $query['tax_query'] : array();
+ $block_context_query['queryId'] = $block->context['queryId'] ?? null;
$inherit = $block->context['query']['inherit'] ?? false;
$filterable = $block->context['query']['filterable'] ?? false;
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php
index ed8e3b91f08..c58bfaf96dc 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php
@@ -143,6 +143,7 @@ class QueryBuilder {
$product_ids = $query['post__in'] ?? array();
$offset_raw = $query['offset'] ?? 0;
$per_page_raw = $query['perPage'] ?? null;
+ $query_id = $query['queryId'] ?? null;
$offset = is_numeric( $offset_raw ) ? max( 0, (int) $offset_raw ) : 0;
$per_page = is_numeric( $per_page_raw ) ? max( 1, (int) $per_page_raw ) : 9;
$order = $query['order'] ?? 'asc';
@@ -192,6 +193,7 @@ class QueryBuilder {
'featured' => $featured,
'timeFrame' => $time_frame,
'priceRange' => $price_range,
+ 'queryId' => $query_id,
),
$is_exclude_applied_filters
);
@@ -256,7 +258,10 @@ class QueryBuilder {
$query,
$is_exclude_applied_filters = false
) {
- $orderby_query = $query['orderby'] ? $this->get_custom_orderby_query( $query['orderby'] ) : array();
+ $query_id = is_int( $query['queryId'] ?? null ) ? $query['queryId'] : null;
+ $orderby_query = $query['orderby']
+ ? $this->get_custom_orderby_query( $query['orderby'], $query_id, $query )
+ : array();
$on_sale_query = $this->get_on_sale_products_query( $query['on_sale'] );
$stock_query = $this->get_stock_status_query( $query['stock_status'] );
$visibility_query = is_array( $query['stock_status'] ) ? $this->get_product_visibility_query( $stock_query, $query['stock_status'] ) : array();
@@ -1102,11 +1107,13 @@ class QueryBuilder {
/**
* Return query params to support custom sort values
*
- * @param string $orderby Sort order option.
+ * @param string $orderby Sort order option.
+ * @param int|null $query_id Product Collection query ID, or null to leave random ordering unseeded.
+ * @param array $query Product Collection query context.
*
* @return array
*/
- private function get_custom_orderby_query( $orderby ) {
+ private function get_custom_orderby_query( $orderby, ?int $query_id = null, $query = array() ) {
if ( ! in_array( $orderby, $this->custom_order_opts, true ) || 'post__in' === $orderby ) {
return array( 'orderby' => $orderby );
}
@@ -1137,8 +1144,16 @@ class QueryBuilder {
}
if ( 'random' === $orderby ) {
+ if ( null === $query_id ) {
+ return array(
+ 'orderby' => 'rand',
+ );
+ }
+
+ $seed = Utils::get_random_order_seed( $query_id, $query );
+
return array(
- 'orderby' => 'rand',
+ 'orderby' => 'RAND(' . $seed . ')',
);
}
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/Utils.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/Utils.php
index 434d5915b79..8c23a68e684 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/Utils.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/Utils.php
@@ -36,6 +36,29 @@ class Utils {
return $query;
}
+ /**
+ * Get the random order seed for a Product Collection.
+ *
+ * @since 11.0.0
+ *
+ * @param int $query_id Product Collection query ID.
+ * @param array $query_context Product Collection query context.
+ * @return int Random order seed.
+ */
+ public static function get_random_order_seed( $query_id, $query_context = array() ) {
+ $seed_context = array(
+ 'blog_id' => get_current_blog_id(),
+ 'queried_object_id' => get_queried_object_id(),
+ 'query_id' => absint( $query_id ),
+ 'rotation_key' => wp_date( 'Y-m-d' ),
+ 'query' => $query_context,
+ );
+ $encoded_seed_context = wp_json_encode( $seed_context );
+ $seed_hash = (int) sprintf( '%u', crc32( is_string( $encoded_seed_context ) ? $encoded_seed_context : '' ) );
+
+ return ( $seed_hash % 2147483646 ) + 1;
+ }
+
/**
* Helper function that constructs a WP_Query args array from
* a Product Collection or global query.
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php
index 1f22823b407..680aea66f10 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Automattic\WooCommerce\Tests\Blocks\BlockTypes\ProductCollection;
+use Automattic\WooCommerce\Blocks\BlockTypes\ProductCollection\Utils as ProductCollectionUtils;
use Automattic\WooCommerce\Tests\Blocks\BlockTypes\ProductCollection\Utils;
use Automattic\WooCommerce\Tests\Blocks\Mocks\ProductCollectionMock;
use Automattic\WooCommerce\Enums\ProductStockStatus;
@@ -642,14 +643,76 @@ class QueryBuilder extends \WP_UnitTestCase {
}
/**
- * Test the random sorting functionality.
+ * @testdox Should use a deterministic random seed for frontend random sorting.
*/
- public function test_random_sorting() {
+ public function test_random_sorting_uses_deterministic_seed(): void {
$parsed_block = Utils::get_base_parsed_block();
+ $parsed_block['attrs']['queryId'] = 53919;
$parsed_block['attrs']['query']['orderBy'] = 'random';
- $merged_query = Utils::initialize_merged_query( $this->block_instance, $parsed_block );
- $this->assertEquals( 'rand', $merged_query['orderby'] );
+ $first_merged_query = Utils::initialize_merged_query( $this->block_instance, $parsed_block );
+ $second_merged_query = Utils::initialize_merged_query( $this->block_instance, $parsed_block );
+
+ $this->assertMatchesRegularExpression(
+ '/^RAND\([1-9][0-9]*\)$/',
+ $first_merged_query['orderby'],
+ 'Random sorting should use a seeded random order.'
+ );
+ $this->assertSame(
+ $first_merged_query['orderby'],
+ $second_merged_query['orderby'],
+ 'Random sorting should use the same seed for the same Product Collection query.'
+ );
+ }
+
+ /**
+ * @testdox Should include the Product Collection query ID in the random seed.
+ */
+ public function test_random_sorting_seed_uses_query_id(): void {
+ $first_parsed_block = Utils::get_base_parsed_block();
+ $first_parsed_block['attrs']['queryId'] = 53919;
+ $first_parsed_block['attrs']['query']['orderBy'] = 'random';
+
+ $second_parsed_block = $first_parsed_block;
+ $second_parsed_block['attrs']['queryId'] = 53920;
+
+ $first_merged_query = Utils::initialize_merged_query( $this->block_instance, $first_parsed_block );
+ $second_merged_query = Utils::initialize_merged_query( $this->block_instance, $second_parsed_block );
+
+ $this->assertNotSame(
+ $first_merged_query['orderby'],
+ $second_merged_query['orderby'],
+ 'Random sorting should use a different seed for different Product Collection query IDs.'
+ );
+ }
+
+ /**
+ * @testdox Should include the daily rotation key in the random seed.
+ */
+ public function test_random_sorting_seed_uses_rotation_key(): void {
+ $query_context = array(
+ 'orderby' => 'random',
+ );
+ $rotation_key = '2026-06-03';
+ $wp_date_mock = static function ( $date, $format ) use ( &$rotation_key ) {
+ return 'Y-m-d' === $format ? $rotation_key : $date;
+ };
+
+ add_filter( 'wp_date', $wp_date_mock, 10, 2 );
+
+ try {
+ $first_seed = ProductCollectionUtils::get_random_order_seed( 53919, $query_context );
+ $rotation_key = '2026-06-04';
+ $second_seed = ProductCollectionUtils::get_random_order_seed( 53919, $query_context );
+
+ $this->assertNotSame(
+ $first_seed,
+ $second_seed,
+ 'Random sorting should use a different seed when the daily rotation key changes.'
+ );
+ } finally {
+ remove_filter( 'wp_date', $wp_date_mock, 10 );
+ }
}
/**