Commit 5a1f0df54bd for woocommerce

commit 5a1f0df54bd83523aef4e022a3bf1f64465cf246
Author: Vladimir Reznichenko <kalessil@gmail.com>
Date:   Fri Jul 17 08:29:47 2026 +0200

    [Performance] Prime caches for variations images (#66498)

    Implements cache priming for variation gallery images to reduce SQL queries on variation pages.

diff --git a/.ai/skills/woocommerce-performance/cache-priming.md b/.ai/skills/woocommerce-performance/cache-priming.md
index 2f9a58ea1fb..0b59d3597b5 100644
--- a/.ai/skills/woocommerce-performance/cache-priming.md
+++ b/.ai/skills/woocommerce-performance/cache-priming.md
@@ -20,7 +20,7 @@ if ( ! empty( $ids ) ) {

 The comment `// Prime caches to reduce future queries.` must always sit **inside** the `if` block, directly above the call. Do not place it before the `if`. Place the prime immediately before the loop or `array_map` that consumes the IDs. Exception: if a `do_action` call between the guard and the loop passes the IDs as arguments (e.g. `do_action( 'wc_before_products_starting_sales', $product_ids )`), move the prime before that action so hooked callbacks loading the same objects also benefit from the warmed cache. If the action does not receive the IDs, keep the prime directly above the loop.

-`_prime_post_caches()` is a WordPress internal (underscore-prefixed) that has existed since WP 4.1. The minimum supported WordPress version for WooCommerce guarantees its presence — `is_callable( '_prime_post_caches' )` guards are unnecessary and must be removed when encountered. Always wrap in `! empty()` to avoid a no-op SQL on empty arrays.
+`_prime_post_caches()` is a WordPress internal (underscore-prefixed) that has existed since WP 4.1. The minimum supported WordPress version for WooCommerce guarantees its presence — `is_callable( '_prime_post_caches' )` guards are unnecessary and must be removed when encountered. Always wrap in `! empty()` as a readability convention; WordPress short-circuits internally before firing SQL on an empty array, so this is not a correctness requirement. The function issues a single `SELECT wp_posts.* WHERE ID IN (...)` for all non-cached IDs and a single `SELECT` for postmeta — two queries regardless of collection size, not one per ID.

 ---

@@ -28,7 +28,7 @@ The comment `// Prime caches to reduce future queries.` must always sit **inside

 **Apply when:** Code that fetches products and then renders them (templates, blocks), especially with thumbnails.

-**Correct pattern:**
+**Correct pattern — simple product collections:**

 ```php
 if ( ! empty( $product_ids ) ) {
@@ -36,13 +36,85 @@ if ( ! empty( $product_ids ) ) {
     _prime_post_caches( $product_ids );
     $products = array_filter( array_map( 'wc_get_product', $product_ids ), 'wc_products_array_filter_visible' );

-    // Prime caches to reduce future queries.
-    _prime_post_caches( array_filter( array_map( fn( $p ) => (int) $p->get_image_id(), $products ) ) );
+    $thumbnail_ids = array_filter( array_map( fn( $p ) => (int) $p->get_image_id(), $products ) );
+    if ( ! empty( $thumbnail_ids ) ) {
+        // Prime caches to reduce future queries.
+        _prime_post_caches( $thumbnail_ids );
+    }
 }
 ```

 Applies to: `woocommerce_related_products()`, `woocommerce_upsell_display()`, block type `RelatedProducts`, and any similar rendering functions.

+**Correct pattern — variation collections:**
+
+Variation collections require a two-phase approach because attachment IDs are not available until variation postmeta is warm. After phase 1, both `_thumbnail_id` and `_product_image_gallery` are postmeta cache hits, so the collection loop in phase 2 costs nothing extra.
+
+The normalization chain is the same regardless of which API you use to collect IDs: `array_filter` removes falsy values, `array_unique` deduplicates, `array_map( 'intval', ... )` casts to integers.
+
+**Form A — IDs only (no loaded objects):**
+
+Use when you have raw variation IDs and no `WC_Product_Variation` objects yet. Read attachment meta directly from the now-warm postmeta cache. `_product_image_gallery` is stored as a comma-separated string (`"12,34,56"`) — use `explode( ',', ... )`, not `maybe_unserialize`.
+
+```php
+if ( ! empty( $variation_ids ) ) {
+    // Phase 1: prime variation posts + postmeta.
+    _prime_post_caches( $variation_ids );
+
+    // Phase 2: extract all attachment IDs from now-warm postmeta and prime them in one batch.
+    $attachment_ids = array();
+    foreach ( $variation_ids as $vid ) {
+        $attachment_ids[] = array( get_post_meta( $vid, '_thumbnail_id', true ) );
+        $attachment_ids[] = explode( ',', (string) get_post_meta( $vid, '_product_image_gallery', true ) );
+    }
+    $attachment_ids = array_map( 'intval', array_unique( array_filter( array_merge( ...$attachment_ids ) ) ) );
+    if ( ! empty( $attachment_ids ) ) {
+        // Prime caches to reduce future queries.
+        _prime_post_caches( $attachment_ids );
+    }
+}
+```
+
+**Form B — objects already loaded:**
+
+Use when `WC_Product_Variation` objects are already in memory (their postmeta is warm from phase 1). Use the object API in the same context the render path uses — typically the default `'view'` context. `get_image_id()` returns a scalar, so wrap it in `array()`; `get_gallery_image_ids()` returns an array directly.
+
+```php
+if ( ! empty( $variation_ids ) ) {
+    // Phase 1: prime variation posts + postmeta.
+    _prime_post_caches( $variation_ids );
+    $variations = array_filter( array_map( 'wc_get_product', $variation_ids ) );
+}
+
+// ... filter $variations as needed (visibility, stock) ...
+
+if ( ! empty( $variations ) ) {
+    // Phase 2: collect attachment IDs from loaded objects and prime them in one batch.
+    $attachment_ids = array();
+    foreach ( $variations as $variation ) {
+        $attachment_ids[] = array( $variation->get_image_id() );
+        $attachment_ids[] = $variation->get_gallery_image_ids();
+    }
+    $attachment_ids = array_map( 'intval', array_unique( array_filter( array_merge( ...$attachment_ids ) ) ) );
+    if ( ! empty( $attachment_ids ) ) {
+        // Prime caches to reduce future queries.
+        _prime_post_caches( $attachment_ids );
+    }
+}
+```
+
+**Choosing between forms:** prefer Form B when variation objects are already loaded — it avoids raw meta key knowledge and scopes the attachment prime to post-filter variations only, so out-of-stock or invisible variations excluded before phase 2 don't contribute attachment IDs that will never be rendered.
+
+**Prefer `ProductUtil::prime_image_caches()` over Form B inline code** when `WC_Product` objects are already in memory. It encapsulates the full collect-and-prime cycle (featured + gallery, dedup, intval, empty guard) in one call:
+
+```php
+wc_get_container()->get( \Automattic\WooCommerce\Internal\Utilities\ProductUtil::class )->prime_image_caches( $variations );
+```
+
+`WC_Product_Variation` extends `WC_Product` so variation collections are accepted directly. Use Form B inline code only when `ProductUtil` is not available in the call context (e.g. a legacy `includes/` file that cannot reach the DI container).
+
+**Scope priming to attachment IDs your code actually accesses.** Prime attachment posts only when the render path calls `get_post()`, `wp_attachment_is_image()`, `wp_get_attachment_image_src()`, or `get_the_title()` on each ID. Returning IDs as raw integers in a response array (e.g. a REST API `gallery_image_ids` field that passes IDs through without hydrating them) requires no attachment priming — the attachment `wp_posts` rows are never read.
+
 ---

 ### 3. Priming the full ID list instead of only uncached IDs
diff --git a/plugins/woocommerce/changelog/performance-prime-caches-variations-gallery b/plugins/woocommerce/changelog/performance-prime-caches-variations-gallery
new file mode 100644
index 00000000000..16aa05230fa
--- /dev/null
+++ b/plugins/woocommerce/changelog/performance-prime-caches-variations-gallery
@@ -0,0 +1,4 @@
+Significance: patch
+Type: performance
+
+Performance: Prime caches for variations images.
diff --git a/plugins/woocommerce/includes/class-wc-product-variable.php b/plugins/woocommerce/includes/class-wc-product-variable.php
index 943c2db3546..0fa5a938edc 100644
--- a/plugins/woocommerce/includes/class-wc-product-variable.php
+++ b/plugins/woocommerce/includes/class-wc-product-variable.php
@@ -8,8 +8,9 @@
  * @package WooCommerce\Classes\Products
  */

-use Automattic\WooCommerce\Enums\ProductType;
 use Automattic\WooCommerce\Enums\ProductStockStatus;
+use Automattic\WooCommerce\Enums\ProductType;
+use Automattic\WooCommerce\Internal\Utilities\ProductUtil;
 use Automattic\WooCommerce\Internal\VariationGallery\Package as VariationGalleryPackage;

 defined( 'ABSPATH' ) || exit;
@@ -326,9 +327,9 @@ class WC_Product_Variable extends WC_Product {
 	 * @phpstan-return ($return is 'array' ? array[] : WC_Product_Variation[])
 	 */
 	public function get_available_variations( $return = 'array' ) {
+		$variations              = array();
 		$variation_ids           = $this->get_children();
 		$hide_out_of_stock_items = ( 'yes' === get_option( 'woocommerce_hide_out_of_stock_items' ) );
-		$available_variations    = array();

 		if ( ! empty( $variation_ids ) ) {
 			// Prime caches to reduce future queries.
@@ -336,7 +337,6 @@ class WC_Product_Variable extends WC_Product {
 		}

 		foreach ( $variation_ids as $variation_id ) {
-
 			$variation = wc_get_product( $variation_id );

 			// Hide out of stock variations if 'Hide out of stock items from the catalog' is checked.
@@ -357,18 +357,19 @@ class WC_Product_Variable extends WC_Product {
 				continue;
 			}

-			if ( 'array' === $return ) {
-				$available_variations[] = $this->get_available_variation( $variation );
-			} else {
-				$available_variations[] = $variation;
-			}
+			$variations[] = $variation;
 		}

-		if ( 'array' === $return ) {
-			$available_variations = array_values( array_filter( $available_variations ) );
+		if ( 'array' === $return && ! empty( $variations ) ) {
+			wc_get_container()->get( ProductUtil::class )->prime_image_caches( $variations );
+			$variations_data = array_values( array_filter( array_map( fn ( $variation ) => $this->get_available_variation( $variation ), $variations ) ) );
+
+			/** @var array[] $variations_data */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort
+			return $variations_data;
 		}

-		return $available_variations;
+		/** @var WC_Product_Variation[] $variations */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort
+		return $variations;
 	}

 	/**
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 4648b53fa8e..8265f01ccb0 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -13860,12 +13860,6 @@ parameters:
 			count: 1
 			path: includes/class-wc-product-variable.php

-		-
-			message: '#^Method WC_Product_Variable\:\:get_available_variations\(\) should return array\<array\|WC_Product_Variation\> but returns list\<array\|bool\|WC_Product\>\.$#'
-			identifier: return.type
-			count: 1
-			path: includes/class-wc-product-variable.php
-
 		-
 			message: '#^Method WC_Product_Variable\:\:set_children\(\) has no return type specified\.$#'
 			identifier: missingType.return
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-product-variable-test.php b/plugins/woocommerce/tests/php/includes/class-wc-product-variable-test.php
index c7c446db40f..144f358331c 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-product-variable-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-product-variable-test.php
@@ -1,5 +1,7 @@
 <?php

+use Automattic\WooCommerce\Internal\VariationGallery\Package;
+
 /**
  * Tests for WC_Product_Variable.
  */
@@ -8,7 +10,7 @@ class WC_Product_Variable_Test extends \WC_Unit_Test_Case {
 	 * Reset variation gallery feature-flag option leaked by individual tests.
 	 */
 	public function tearDown(): void {
-		delete_option( \Automattic\WooCommerce\Internal\VariationGallery\Package::ENABLE_OPTION_NAME );
+		delete_option( Package::ENABLE_OPTION_NAME );
 		parent::tearDown();
 	}

@@ -171,11 +173,34 @@ class WC_Product_Variable_Test extends \WC_Unit_Test_Case {
 		$this->assertFalse( $has_purchasable_variations );
 	}

+	/**
+	 * @testdox 'get_available_variations' with 'array' return includes image and gallery data for variations that have images set.
+	 */
+	public function test_get_available_variations_array_includes_image_data_when_variation_has_images(): void {
+		update_option( Package::ENABLE_OPTION_NAME, 'yes' );
+
+		$image_id   = $this->create_image_attachment( 'Variation Image', 'variation-image.jpg' );
+		$gallery_id = $this->create_image_attachment( 'Variation Gallery Image', 'variation-gallery.jpg' );
+
+		$product   = WC_Helper_Product::create_variation_product();
+		$variation = wc_get_product( $product->get_children()[0] );
+		$variation->set_image_id( $image_id );
+		$variation->set_gallery_image_ids( array( $gallery_id ) );
+		$variation->save();
+
+		$variations = $product->get_available_variations( 'array' );
+
+		$this->assertSame( $image_id, $variations[0]['image_id'] );
+		$this->assertSame( array( $gallery_id ), $variations[0]['gallery_image_ids'] );
+
+		$product->delete( true );
+	}
+
 	/**
 	 * @testdox 'get_available_variation' exposes typed variation gallery image IDs.
 	 */
 	public function test_get_available_variation_includes_gallery_image_ids() {
-		update_option( \Automattic\WooCommerce\Internal\VariationGallery\Package::ENABLE_OPTION_NAME, 'yes' );
+		update_option( Package::ENABLE_OPTION_NAME, 'yes' );

 		$product   = WC_Helper_Product::create_variation_product();
 		$variation = wc_get_product( $product->get_children()[0] );
@@ -222,7 +247,7 @@ class WC_Product_Variable_Test extends \WC_Unit_Test_Case {
 	 * @testdox 'get_available_variation' omits multi-image gallery data when the variation gallery feature flag is disabled.
 	 */
 	public function test_get_available_variation_returns_single_image_shape_when_feature_flag_disabled() {
-		update_option( \Automattic\WooCommerce\Internal\VariationGallery\Package::ENABLE_OPTION_NAME, 'no' );
+		update_option( Package::ENABLE_OPTION_NAME, 'no' );

 		$product   = WC_Helper_Product::create_variation_product();
 		$variation = wc_get_product( $product->get_children()[0] );
@@ -267,7 +292,7 @@ class WC_Product_Variable_Test extends \WC_Unit_Test_Case {
 	 * @testdox 'get_available_variation' falls back to the variation's own gallery when the variation featured image is stale.
 	 */
 	public function test_get_available_variation_falls_back_to_variation_gallery_when_featured_is_stale() {
-		update_option( \Automattic\WooCommerce\Internal\VariationGallery\Package::ENABLE_OPTION_NAME, 'yes' );
+		update_option( Package::ENABLE_OPTION_NAME, 'yes' );

 		$product              = WC_Helper_Product::create_variation_product();
 		$variation            = wc_get_product( $product->get_children()[0] );
@@ -300,7 +325,7 @@ class WC_Product_Variable_Test extends \WC_Unit_Test_Case {
 	 * @testdox 'get_available_variation' falls back to the parent featured image when both the variation featured image and gallery are absent.
 	 */
 	public function test_get_available_variation_falls_back_to_parent_featured_when_variation_has_no_images() {
-		update_option( \Automattic\WooCommerce\Internal\VariationGallery\Package::ENABLE_OPTION_NAME, 'yes' );
+		update_option( Package::ENABLE_OPTION_NAME, 'yes' );

 		$product            = WC_Helper_Product::create_variation_product();
 		$variation          = wc_get_product( $product->get_children()[0] );