Commit 70480c92597 for woocommerce

commit 70480c92597757c7efb8d29473ed5714166a3df0
Author: Lucio Giannotta <lucio.giannotta@a8c.com>
Date:   Tue Sep 22 15:39:45 2026 +0200

    Prevent recursive variation gallery rendering (#68939)

    * Prevent recursive variation gallery rendering

    * Add changelog entry for variation gallery recursion fix

diff --git a/plugins/woocommerce/changelog/wooplug-7677-variation-gallery-recursion b/plugins/woocommerce/changelog/wooplug-7677-variation-gallery-recursion
new file mode 100644
index 00000000000..b346e4ee7c0
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-7677-variation-gallery-recursion
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent variation gallery rendering from recursively requesting variation data.
diff --git a/plugins/woocommerce/includes/wc-template-functions.php b/plugins/woocommerce/includes/wc-template-functions.php
index a7ae30f526a..45ae9bfe353 100644
--- a/plugins/woocommerce/includes/wc-template-functions.php
+++ b/plugins/woocommerce/includes/wc-template-functions.php
@@ -1965,19 +1965,29 @@ function wc_render_product_image_template_for( WC_Product $product ): string {
  *
  * @param WC_Product $product   Product being rendered.
  * @param mixed      $image_ids Image IDs to substitute. Will be normalized.
- * @return string
+ * @return string Rendered gallery HTML, or an empty string for a re-entrant render of the same product.
  */
 function wc_render_product_image_template_for_image_ids( WC_Product $product, $image_ids ): string {
+	static $rendering_product_galleries = array();
+
+	$product_id = $product->get_id();
+	if ( isset( $rendering_product_galleries[ $product_id ] ) ) {
+		return '';
+	}
+
 	$normalized  = array_values( array_unique( array_map( 'intval', array_filter( (array) $image_ids ) ) ) );
 	$featured_id = $normalized[0] ?? 0;
 	$gallery_ids = array_slice( $normalized, 1 );

 	$remove_overrides = wc_apply_product_image_overrides( $product, $featured_id, $gallery_ids );

+	$rendering_product_galleries[ $product_id ] = true;
+
 	try {
 		return wc_render_product_image_template_for( $product );
 	} finally {
 		$remove_overrides();
+		unset( $rendering_product_galleries[ $product_id ] );
 	}
 }

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 f7f051c7f68..e4e45929659 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
@@ -232,6 +232,82 @@ class WC_Product_Variable_Test extends \WC_Unit_Test_Case {
 		$this->assertNotEmpty( $available_variation['gallery_images_html'] );
 	}

+	/**
+	 * @testdox The product image template does not render variation galleries recursively when a gallery callback requests variation data.
+	 */
+	public function test_product_image_template_does_not_render_variation_galleries_recursively(): void {
+		global $product;
+
+		list( $test_product ) = $this->create_variation_gallery_fixture();
+		$previous_product     = $product;
+		$product              = $test_product;
+		$hook_calls           = 0;
+		$callback_results     = array();
+		$buffer_level         = ob_get_level();
+		$callback             = static function () use ( &$hook_calls, &$callback_results ) {
+			global $product;
+
+			if ( ! $product instanceof WC_Product_Variable ) {
+				throw new RuntimeException( 'Expected the gallery template to expose the variable product.' );
+			}
+
+			++$hook_calls;
+			if ( 2 < $hook_calls ) {
+				throw new RuntimeException( 'The gallery template was rendered recursively.' );
+			}
+
+			$callback_results[] = $product->get_available_variations();
+		};
+		add_action( 'woocommerce_product_thumbnails', $callback );
+
+		ob_start();
+		try {
+			woocommerce_show_product_images();
+			$markup = (string) ob_get_clean();
+		} finally {
+			while ( ob_get_level() > $buffer_level ) {
+				ob_end_clean();
+			}
+			remove_action( 'woocommerce_product_thumbnails', $callback );
+			$product = $previous_product;
+		}
+
+		$this->assertSame( 2, $hook_calls, 'The callback should run for the product template and its first variation gallery render.' );
+		$this->assertCount( 2, $callback_results, 'Both bounded callback invocations should return variation data.' );
+		$this->assertSame( '', $callback_results[0][0]['gallery_images_html'], 'Re-entrant variation data should omit gallery markup.' );
+		$this->assertNotEmpty( $callback_results[1][0]['gallery_images_html'], 'The first variation data request should retain gallery markup.' );
+		$this->assertNotEmpty( $markup, 'The product image template should still render.' );
+	}
+
+	/**
+	 * @testdox 'get_available_variations' allows a gallery callback to render a different product gallery.
+	 */
+	public function test_get_available_variations_allows_nested_gallery_for_different_product(): void {
+		list( $outer_product )  = $this->create_variation_gallery_fixture();
+		list( $nested_product ) = $this->create_variation_gallery_fixture();
+		$rendered_product_ids   = array();
+		$nested_result          = null;
+		$callback               = static function () use ( $outer_product, $nested_product, &$rendered_product_ids, &$nested_result ) {
+			global $product;
+
+			$rendered_product_ids[] = $product->get_id();
+			if ( $outer_product->get_id() === $product->get_id() ) {
+				$nested_result = $nested_product->get_available_variations();
+			}
+		};
+		add_action( 'woocommerce_product_thumbnails', $callback );
+
+		try {
+			$outer_product->get_available_variations();
+		} finally {
+			remove_action( 'woocommerce_product_thumbnails', $callback );
+		}
+
+		$this->assertContains( $outer_product->get_id(), $rendered_product_ids, 'The outer product gallery should render.' );
+		$this->assertContains( $nested_product->get_id(), $rendered_product_ids, 'The nested product gallery should render.' );
+		$this->assertNotEmpty( $nested_result[0]['gallery_images_html'], 'A different product should retain its gallery markup.' );
+	}
+
 	/**
 	 * @testdox 'get_available_variation' falls back to the variation's own gallery when the variation featured image is stale.
 	 */