Commit 9d39c41a6cc for woocommerce

commit 9d39c41a6cc8ba1bd74451e6069a7c9baca60848
Author: Karol Manijak <20098064+kmanijak@users.noreply.github.com>
Date:   Tue Jun 2 15:03:22 2026 +0200

    Product Gallery block: show parent gallery for single-image variations (#65267)

    * Show parent gallery for single-image variations in Product Gallery block

    * Add changelog entry

    * Rename hero to featured and clarify variation gallery comments

    * Refactor variation gallery ID filtering

    * Clarify placeholder image ID fallback

    * Refactor parent gallery extras filtering

    * Refactor product gallery variation test setup

diff --git a/plugins/woocommerce/changelog/product-gallery-block-variation-image-decision-tree b/plugins/woocommerce/changelog/product-gallery-block-variation-image-decision-tree
new file mode 100644
index 00000000000..15980b5dc9b
--- /dev/null
+++ b/plugins/woocommerce/changelog/product-gallery-block-variation-image-decision-tree
@@ -0,0 +1,4 @@
+Significance: patch
+Type: tweak
+
+Show parent gallery extras alongside single-image variations in the Product Gallery block.
diff --git a/plugins/woocommerce/src/Blocks/Utils/ProductGalleryUtils.php b/plugins/woocommerce/src/Blocks/Utils/ProductGalleryUtils.php
index abe9b2300c6..bd8c7b57695 100644
--- a/plugins/woocommerce/src/Blocks/Utils/ProductGalleryUtils.php
+++ b/plugins/woocommerce/src/Blocks/Utils/ProductGalleryUtils.php
@@ -159,18 +159,20 @@ class ProductGalleryUtils {
 			_prime_post_caches( $variations );
 		}

-		$parent_image_ids = array_values(
-			array_filter(
-				array_map( 'intval', self::get_product_gallery_image_ids( $product ) ),
-				static function ( $id ) {
-					return $id > 0 && wp_attachment_is_image( $id );
-				}
-			)
-		);
+		// 0 is placeholder image ID.
+		$parent_featured_id = 0;
+		$product_image_id   = (int) $product->get_image_id();
+		if ( $product_image_id && wp_attachment_is_image( $product_image_id ) ) {
+			$parent_featured_id = $product_image_id;
+		}
+
+		$parent_gallery_ids    = array_map( 'intval', $product->get_gallery_image_ids() );
+		$parent_gallery_ids    = array_filter( $parent_gallery_ids, 'wp_attachment_is_image' );
+		$parent_gallery_extras = array_values( array_diff( $parent_gallery_ids, array( $parent_featured_id ) ) );

 		foreach ( $variations as $variation_id ) {
 			$variation_id = (int) $variation_id;
-			$entry        = self::build_variation_gallery_entry( $variation_id, $parent_image_ids );
+			$entry        = self::build_variation_gallery_entry( $variation_id, $parent_featured_id, $parent_gallery_extras );

 			if ( null !== $entry ) {
 				$variation_gallery_data[ $variation_id ] = $entry;
@@ -182,61 +184,75 @@ class ProductGalleryUtils {

 	/**
 	 * Build the gallery payload for a single variation, or null when the
-	 * variation has no images or isn't a real variation.
+	 * post isn't a real variation.
+	 *
+	 * Decision tree (variation chosen):
+	 * - no variation images → parent featured + parent gallery
+	 * - own featured only → variation featured + parent gallery extras
+	 * - own featured + gallery (flag on) → variation images only
+	 * - gallery only, no own featured (potential AVI shape) → parent featured + variation gallery
 	 *
-	 * @param int   $variation_id     Variation post ID.
-	 * @param int[] $parent_image_ids Parent product's full gallery (featured + extras),
-	 *                                used as fallback when the variation has no images.
+	 * @param int   $variation_id          Variation post ID.
+	 * @param int   $parent_featured_id    Parent product's featured image ID (0 if missing/invalid).
+	 * @param int[] $parent_gallery_extras Parent gallery image IDs, with the featured filtered out.
 	 * @return array<string, mixed>|null
 	 */
-	private static function build_variation_gallery_entry( int $variation_id, array $parent_image_ids ): ?array {
+	private static function build_variation_gallery_entry( int $variation_id, int $parent_featured_id, array $parent_gallery_extras ): ?array {
 		$variation = wc_get_product( $variation_id );

 		if ( ! $variation instanceof \WC_Product_Variation ) {
 			return null;
 		}

-		$variation_image_id    = (int) $variation->get_image_id();
-		$variation_image_valid = $variation_image_id && wp_attachment_is_image( $variation_image_id );
-		$parent_fallback       = ! empty( $parent_image_ids )
-			? array(
-				'image_id'  => $parent_image_ids[0],
-				'image_ids' => $parent_image_ids,
-			)
-			: array(
-				'image_id'  => 0,
-				'image_ids' => array( 0 ),
+		$featured_id    = (int) $variation->get_image_id();
+		$featured_valid = $featured_id && wp_attachment_is_image( $featured_id );
+
+		$variation_gallery_ids = array();
+		if ( VariationGalleryPackage::is_enabled() ) {
+			$variation_gallery_ids = array_map( 'intval', $variation->get_gallery_image_ids() );
+			$variation_gallery_ids = array_filter( $variation_gallery_ids, 'wp_attachment_is_image' );
+			$variation_gallery_ids = array_values( $variation_gallery_ids );
+		}
+
+		// No images from variation - full parent fallback.
+		if ( ! $featured_valid && empty( $variation_gallery_ids ) ) {
+			$parent_image_ids = array_values(
+				array_filter( array_merge( array( $parent_featured_id ), $parent_gallery_extras ) )
 			);

-		if ( ! VariationGalleryPackage::is_enabled() ) {
-			if ( $variation_image_valid ) {
+			if ( empty( $parent_image_ids ) ) {
 				return array(
-					'image_id'  => $variation_image_id,
-					'image_ids' => array( $variation_image_id ),
+					'image_id'  => 0,
+					'image_ids' => array( 0 ),
 				);
 			}
-			return $parent_fallback;
-		}
-
-		$image_ids = self::get_variation_gallery_image_ids( $variation );

-		if ( empty( $image_ids ) ) {
-			return $parent_fallback;
+			return array(
+				'image_id'  => $parent_image_ids[0],
+				'image_ids' => $parent_image_ids,
+			);
 		}

-		// Prefer variation-owned images over the parent fallback.
-		if ( $variation_image_valid ) {
-			$selected_image_id = $variation_image_id;
-		} else {
-			$selected_image_id = $image_ids[0];
-		}
+		// Variation has featured image and gallery - full variation gallery.
+		if ( ! empty( $variation_gallery_ids ) ) {
+			$featured  = $featured_valid ? $featured_id : $variation_gallery_ids[0];
+			$image_ids = array_values(
+				array_unique( array_merge( array( $featured ), $variation_gallery_ids ) )
+			);

-		if ( ! in_array( $selected_image_id, $image_ids, true ) ) {
-			array_unshift( $image_ids, $selected_image_id );
+			return array(
+				'image_id'  => $featured,
+				'image_ids' => $image_ids,
+			);
 		}

+		// Variation has only featured image - variation featured and parent gallery.
+		$image_ids = array_values(
+			array_unique( array_merge( array( $featured_id ), $parent_gallery_extras ) )
+		);
+
 		return array(
-			'image_id'  => $selected_image_id,
+			'image_id'  => $featured_id,
 			'image_ids' => $image_ids,
 		);
 	}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Utils/ProductGalleryUtilsTest.php b/plugins/woocommerce/tests/php/src/Blocks/Utils/ProductGalleryUtilsTest.php
index 128fe7c1cb3..519ad9c1e5b 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/Utils/ProductGalleryUtilsTest.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/Utils/ProductGalleryUtilsTest.php
@@ -1,4 +1,5 @@
 <?php
+declare( strict_types = 1 );

 namespace Automattic\WooCommerce\Tests\Blocks\Utils;

@@ -218,6 +219,179 @@ class ProductGalleryUtilsTest extends \WP_UnitTestCase {
 		);
 	}

+	/**
+	 * Variation has only its own featured image (no gallery) → the
+	 * variation featured replaces the parent's hero, parent gallery extras
+	 * stay. Applies whether the feature flag is on or off.
+	 */
+	public function test_get_product_variation_gallery_data_case_3_single_image_appends_parent_gallery_extras() {
+		$parent_featured_id     = $this->create_image_attachment( 'Parent Featured', 'parent-featured.jpg' );
+		$parent_gallery_extra_a = $this->create_image_attachment( 'Parent Gallery A', 'parent-gallery-a.jpg' );
+		$parent_gallery_extra_b = $this->create_image_attachment( 'Parent Gallery B', 'parent-gallery-b.jpg' );
+		$variation_featured_id  = $this->create_image_attachment( 'Variation Featured', 'variation-featured.jpg' );
+
+		$entry = $this->create_variation_gallery_entry(
+			$parent_featured_id,
+			array( $parent_gallery_extra_a, $parent_gallery_extra_b ),
+			$variation_featured_id
+		);
+
+		$this->assertSame( $variation_featured_id, $entry['image_id'] );
+		$this->assertSame(
+			array( $variation_featured_id, $parent_gallery_extra_a, $parent_gallery_extra_b ),
+			$entry['image_ids']
+		);
+	}
+
+	/**
+	 * Variation has its own featured plus gallery images (feature flag
+	 * on) → the variation's images replace the parent's entirely.
+	 */
+	public function test_get_product_variation_gallery_data_case_4_multiple_images_replaces_parent_set() {
+		$parent_featured_id     = $this->create_image_attachment( 'Parent Featured', 'parent-featured.jpg' );
+		$parent_gallery_extra   = $this->create_image_attachment( 'Parent Gallery', 'parent-gallery.jpg' );
+		$variation_featured_id  = $this->create_image_attachment( 'Variation Featured', 'variation-featured.jpg' );
+		$variation_gallery_id_a = $this->create_image_attachment( 'Variation Gallery A', 'variation-gallery-a.jpg' );
+		$variation_gallery_id_b = $this->create_image_attachment( 'Variation Gallery B', 'variation-gallery-b.jpg' );
+
+		$entry = $this->create_variation_gallery_entry(
+			$parent_featured_id,
+			array( $parent_gallery_extra ),
+			$variation_featured_id,
+			array( $variation_gallery_id_a, $variation_gallery_id_b )
+		);
+
+		$this->assertSame( $variation_featured_id, $entry['image_id'] );
+		$this->assertSame(
+			array( $variation_featured_id, $variation_gallery_id_a, $variation_gallery_id_b ),
+			$entry['image_ids']
+		);
+	}
+
+	/**
+	 * (Edge case - legacy AVI shape): variation has gallery images but no own featured →
+	 * parent featured anchors the set, variation gallery follows. No parent
+	 * gallery extras appear — the variation owns the rest of the lineup.
+	 */
+	public function test_get_product_variation_gallery_data_case_5_avi_shape_uses_parent_featured_plus_variation_gallery() {
+		$parent_featured_id     = $this->create_image_attachment( 'Parent Featured', 'parent-featured.jpg' );
+		$parent_gallery_extra   = $this->create_image_attachment( 'Parent Gallery Extra', 'parent-gallery-extra.jpg' );
+		$variation_gallery_id_a = $this->create_image_attachment( 'Variation Gallery A', 'variation-gallery-a.jpg' );
+		$variation_gallery_id_b = $this->create_image_attachment( 'Variation Gallery B', 'variation-gallery-b.jpg' );
+
+		$entry = $this->create_variation_gallery_entry(
+			$parent_featured_id,
+			array( $parent_gallery_extra ),
+			0,
+			array( $variation_gallery_id_a, $variation_gallery_id_b )
+		);
+
+		$this->assertSame( $parent_featured_id, $entry['image_id'] );
+		$this->assertSame(
+			array( $parent_featured_id, $variation_gallery_id_a, $variation_gallery_id_b ),
+			$entry['image_ids']
+		);
+	}
+
+	/**
+	 * Variation has no images of its own → full parent gallery
+	 * (featured + extras) is used as a fallback.
+	 */
+	public function test_get_product_variation_gallery_data_case_2_no_variation_images_uses_full_parent_gallery() {
+		$parent_featured_id   = $this->create_image_attachment( 'Parent Featured', 'parent-featured.jpg' );
+		$parent_gallery_extra = $this->create_image_attachment( 'Parent Gallery Extra', 'parent-gallery-extra.jpg' );
+
+		$entry = $this->create_variation_gallery_entry(
+			$parent_featured_id,
+			array( $parent_gallery_extra )
+		);
+
+		$this->assertSame( $parent_featured_id, $entry['image_id'] );
+		$this->assertSame(
+			array( $parent_featured_id, $parent_gallery_extra ),
+			$entry['image_ids']
+		);
+	}
+
+	/**
+	 * Feature flag off: Variation gallery is treated as empty even if rows exist in postmeta,
+	 * so the single-image rule applies (variation featured + parent gallery extras).
+	 */
+	public function test_get_product_variation_gallery_data_case_3_applies_with_feature_flag_off() {
+		$parent_featured_id     = $this->create_image_attachment( 'Parent Featured', 'parent-featured.jpg' );
+		$parent_gallery_extra   = $this->create_image_attachment( 'Parent Gallery Extra', 'parent-gallery-extra.jpg' );
+		$variation_featured_id  = $this->create_image_attachment( 'Variation Featured', 'variation-featured.jpg' );
+		$variation_gallery_id_a = $this->create_image_attachment( 'Variation Gallery A (ignored)', 'variation-gallery-a.jpg' );
+
+		$entry = $this->create_variation_gallery_entry(
+			$parent_featured_id,
+			array( $parent_gallery_extra ),
+			$variation_featured_id,
+			array( $variation_gallery_id_a ),
+			'no'
+		);
+
+		$this->assertSame( $variation_featured_id, $entry['image_id'] );
+		$this->assertSame(
+			array( $variation_featured_id, $parent_gallery_extra ),
+			$entry['image_ids']
+		);
+	}
+
+	/**
+	 * The variation featured is also present in the parent gallery — output
+	 * must dedup so the image doesn't render twice in a row.
+	 */
+	public function test_get_product_variation_gallery_data_dedups_variation_featured_overlapping_parent_gallery() {
+		$parent_featured_id   = $this->create_image_attachment( 'Parent Featured', 'parent-featured.jpg' );
+		$shared_id            = $this->create_image_attachment( 'Shared Image', 'shared.jpg' );
+		$parent_gallery_extra = $this->create_image_attachment( 'Parent Gallery Extra', 'parent-gallery-extra.jpg' );
+
+		$entry = $this->create_variation_gallery_entry(
+			$parent_featured_id,
+			array( $shared_id, $parent_gallery_extra ),
+			$shared_id
+		);
+
+		$this->assertSame( $shared_id, $entry['image_id'] );
+		$this->assertSame(
+			array( $shared_id, $parent_gallery_extra ),
+			$entry['image_ids']
+		);
+	}
+
+	/**
+	 * Create a variation gallery fixture and return the selected variation entry.
+	 *
+	 * @param int    $parent_featured_id    Parent product featured image ID.
+	 * @param int[]  $parent_gallery_ids    Parent product gallery image IDs.
+	 * @param int    $variation_featured_id Variation featured image ID.
+	 * @param int[]  $variation_gallery_ids Variation gallery image IDs.
+	 * @param string $feature_flag          Variation gallery feature flag value.
+	 * @return array<string, mixed>
+	 */
+	private function create_variation_gallery_entry(
+		int $parent_featured_id,
+		array $parent_gallery_ids = array(),
+		int $variation_featured_id = 0,
+		array $variation_gallery_ids = array(),
+		string $feature_flag = 'yes'
+	): array {
+		update_option( \Automattic\WooCommerce\Internal\VariationGallery\Package::ENABLE_OPTION_NAME, $feature_flag );
+
+		$variable_product = \WC_Helper_Product::create_variation_product();
+		$variable_product->set_image_id( $parent_featured_id );
+		$variable_product->set_gallery_image_ids( $parent_gallery_ids );
+		$variable_product->save();
+
+		$variation = wc_get_product( $variable_product->get_children()[0] );
+		$variation->set_image_id( $variation_featured_id );
+		$variation->set_gallery_image_ids( $variation_gallery_ids );
+		$variation->save();
+
+		return ProductGalleryUtils::get_product_variation_gallery_data( $variable_product )[ $variation->get_id() ];
+	}
+
 	/**
 	 * Create a real image attachment that passes `wp_attachment_is_image()`.
 	 *