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 );
+		}
 	}

 	/**