Commit ce7fe4f4ede for woocommerce

commit ce7fe4f4ede837d8d962064f327bd3d3c9d95c5f
Author: Albert Juhé Lluveras <contact@albertjuhe.com>
Date:   Mon Sep 14 09:10:29 2026 +0200

    Fix warning when certain blocks were used in the Single Product template of a password-protected block (#68477)

    * Fix warning when certain blocks were used in the Single Product template of a password-protected block

    * Move complex logic to simpler guards

    * Add test

    * Convert test comment to testdox

diff --git a/plugins/woocommerce/src/Blocks/Templates/SingleProductTemplate.php b/plugins/woocommerce/src/Blocks/Templates/SingleProductTemplate.php
index ef3f56373f1..c4e4d2f42ae 100644
--- a/plugins/woocommerce/src/Blocks/Templates/SingleProductTemplate.php
+++ b/plugins/woocommerce/src/Blocks/Templates/SingleProductTemplate.php
@@ -126,13 +126,103 @@ class SingleProductTemplate extends AbstractTemplate {
 	/**
 	 * Replace the first single product template block with the password form. Remove all other single product template blocks.
 	 *
-	 * @param array   $parsed_blocks Array of parsed block objects.
-	 * @param boolean $is_already_replaced If the password form has already been added.
-	 * @return array Parsed blocks
+	 * @param array $parsed_blocks Array of parsed block objects.
+	 * @return array Parsed blocks.
 	 */
-	private static function replace_first_single_product_template_block_with_password_form( $parsed_blocks, $is_already_replaced ) {
-		// We want to replace the first single product template block with the password form. We also want to remove all other single product template blocks.
-		// This array doesn't contains all the blocks. For example, it missing the breadcrumbs blocks: it doesn't make sense replace the breadcrumbs with the password form.
+	private static function replace_first_single_product_template_block_with_password_form( $parsed_blocks ) {
+		$blocks              = array();
+		$is_already_replaced = false;
+
+		foreach ( $parsed_blocks as $block ) {
+			$processed           = self::process_block_for_password_form( $block, $is_already_replaced );
+			$is_already_replaced = $processed['is_already_replaced'];
+			$blocks              = array_merge( $blocks, $processed['blocks'] );
+		}
+
+		return $blocks;
+	}
+
+	/**
+	 * Replace or remove a parsed block when rendering a password-protected product.
+	 *
+	 * @param array $block               Parsed block.
+	 * @param bool  $is_already_replaced If the password form has already been added.
+	 * @return array{blocks: array, is_already_replaced: bool} Replacement blocks (empty when removed).
+	 */
+	private static function process_block_for_password_form( $block, $is_already_replaced ) {
+		if ( self::is_password_protected_replacement_block( $block ) ) {
+			if ( $is_already_replaced ) {
+				return array(
+					'blocks'              => array(),
+					'is_already_replaced' => true,
+				);
+			}
+
+			return array(
+				'blocks'              => array( parse_blocks( '<!-- wp:html -->' . get_the_password_form() . '<!-- /wp:html -->' )[0] ),
+				'is_already_replaced' => true,
+			);
+		}
+
+		if ( empty( $block['innerBlocks'] ) || ! isset( $block['innerContent'] ) || ! is_array( $block['innerContent'] ) ) {
+			return array(
+				'blocks'              => array( $block ),
+				'is_already_replaced' => $is_already_replaced,
+			);
+		}
+
+		$new_inner_blocks  = array();
+		$new_inner_content = array();
+		$inner_block_index = 0;
+
+		foreach ( $block['innerContent'] as $chunk ) {
+			if ( is_string( $chunk ) ) {
+				$new_inner_content[] = $chunk;
+				continue;
+			}
+
+			if ( ! isset( $block['innerBlocks'][ $inner_block_index ] ) ) {
+				continue;
+			}
+
+			$processed           = self::process_block_for_password_form( $block['innerBlocks'][ $inner_block_index ], $is_already_replaced );
+			$is_already_replaced = $processed['is_already_replaced'];
+			++$inner_block_index;
+
+			foreach ( $processed['blocks'] as $processed_block ) {
+				$new_inner_blocks[]  = $processed_block;
+				$new_inner_content[] = null;
+			}
+		}
+
+		if ( count( $new_inner_blocks ) === 0 ) {
+			return array(
+				'blocks'              => array(),
+				'is_already_replaced' => $is_already_replaced,
+			);
+		}
+
+		$block['innerBlocks']  = $new_inner_blocks;
+		$block['innerContent'] = $new_inner_content;
+
+		return array(
+			'blocks'              => array( $block ),
+			'is_already_replaced' => $is_already_replaced,
+		);
+	}
+
+	/**
+	 * Whether this block should be replaced with the password form (or removed after the first replacement).
+	 *
+	 * This list is not every block that can appear on a single product template. Blocks such as breadcrumbs
+	 * stay visible on password-protected products.
+	 *
+	 * @param array $block Parsed block.
+	 * @return bool
+	 */
+	private static function is_password_protected_replacement_block( $block ) {
+		$block_name = $block['blockName'] ?? '';
+
 		$single_product_template_blocks = array(
 			'woocommerce/product-image-gallery',
 			'woocommerce/product-details',
@@ -149,98 +239,13 @@ class SingleProductTemplate extends AbstractTemplate {
 			'core/post-excerpt',
 		);

-		return array_reduce(
-			$parsed_blocks,
-			function ( $carry, $block ) use ( $single_product_template_blocks ) {
-				if ( in_array( $block['blockName'], $single_product_template_blocks, true ) || ( 'core/pattern' === $block['blockName'] && isset( $block['attrs']['slug'] ) && 'woocommerce-blocks/related-products' === $block['attrs']['slug'] ) ) {
-					if ( $carry['is_already_replaced'] ) {
-						return array(
-							'blocks'              => $carry['blocks'],
-							'html_block'          => null,
-							'removed'             => true,
-							'is_already_replaced' => true,
-
-						);
-					}
-
-					return array(
-						'blocks'              => $carry['blocks'],
-						'html_block'          => parse_blocks( '<!-- wp:html -->' . get_the_password_form() . '<!-- /wp:html -->' )[0],
-						'removed'             => false,
-						'is_already_replaced' => $carry['is_already_replaced'],
-					);
-
-				}
-
-				if ( isset( $block['innerBlocks'] ) && count( $block['innerBlocks'] ) > 0 ) {
-					$index              = 0;
-					$new_inner_blocks   = array();
-					$new_inner_contents = $block['innerContent'];
-					foreach ( $block['innerContent'] as $inner_content ) {
-						// Don't process the closing tag of the block.
-						if ( count( $block['innerBlocks'] ) === $index ) {
-							break;
-						}
-
-						$blocks                       = self::replace_first_single_product_template_block_with_password_form( array( $block['innerBlocks'][ $index ] ), $carry['is_already_replaced'] );
-						$new_blocks                   = $blocks['blocks'];
-						$html_block                   = $blocks['html_block'];
-						$is_removed                   = $blocks['removed'];
-						$carry['is_already_replaced'] = $blocks['is_already_replaced'];
-
-						if ( isset( $html_block ) ) {
-							$new_inner_blocks             = array_merge( $new_inner_blocks, $new_blocks, array( $html_block ) );
-							$carry['is_already_replaced'] = true;
-						} else {
-							$new_inner_blocks = array_merge( $new_inner_blocks, $new_blocks );
-						}
-
-						if ( $is_removed ) {
-							unset( $new_inner_contents[ $index ] );
-							// The last element of the inner contents contains the closing tag of the block. We don't want to remove it.
-							if ( $index + 1 < count( $new_inner_contents ) ) {
-								unset( $new_inner_contents[ $index + 1 ] );
-							}
-							$new_inner_contents = array_values( $new_inner_contents );
-						}
-
-						$index++;
-					}
-
-					$block['innerBlocks']  = $new_inner_blocks;
-					$block['innerContent'] = $new_inner_contents;
-
-					if ( count( $new_inner_blocks ) === 0 ) {
-						return array(
-							'blocks'              => $carry['blocks'],
-							'html_block'          => null,
-							'removed'             => true,
-							'is_already_replaced' => $carry['is_already_replaced'],
-						);
-					}
-
-					return array(
-						'blocks'              => array_merge( $carry['blocks'], array( $block ) ),
-						'html_block'          => null,
-						'removed'             => false,
-						'is_already_replaced' => $carry['is_already_replaced'],
-					);
-				}
+		if ( in_array( $block_name, $single_product_template_blocks, true ) ) {
+			return true;
+		}

-				return array(
-					'blocks'              => array_merge( $carry['blocks'], array( $block ) ),
-					'html_block'          => null,
-					'removed'             => false,
-					'is_already_replaced' => $carry['is_already_replaced'],
-				);
-			},
-			array(
-				'blocks'              => array(),
-				'html_block'          => null,
-				'removed'             => false,
-				'is_already_replaced' => $is_already_replaced,
-			)
-		);
+		return 'core/pattern' === $block_name
+			&& isset( $block['attrs']['slug'] )
+			&& 'woocommerce-blocks/related-products' === $block['attrs']['slug'];
 	}

 	/**
@@ -251,8 +256,7 @@ class SingleProductTemplate extends AbstractTemplate {
 	 */
 	public static function add_password_form( $content ) {
 		$parsed_blocks     = parse_blocks( $content );
-		$blocks            = self::replace_first_single_product_template_block_with_password_form( $parsed_blocks, false );
-		$serialized_blocks = serialize_blocks( $blocks['blocks'] );
+		$serialized_blocks = serialize_blocks( self::replace_first_single_product_template_block_with_password_form( $parsed_blocks ) );

 		return $serialized_blocks;
 	}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Templates/SingleProductTemplateTests.php b/plugins/woocommerce/tests/php/src/Blocks/Templates/SingleProductTemplateTests.php
index 63434bed382..f1bd225de10 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/Templates/SingleProductTemplateTests.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/Templates/SingleProductTemplateTests.php
@@ -362,4 +362,32 @@ class SingleProductTemplateTests extends WP_UnitTestCase {
 			TemplateContentUtils::strip_whitespace_and_password_form_ids( $result )
 		);
 	}
+
+	/**
+	 * @testdox Adds the password form when product blocks are at the top level of the template.
+	 */
+	public function test_replace_top_level_single_product_blocks_with_input_form() {
+		$default_single_product_template = '
+	<!-- wp:template-part {"slug":"header","theme":"twentytwentythree","tagName":"header"} /-->
+	<!-- wp:woocommerce/product-image-gallery /-->
+	<!-- wp:woocommerce/product-price {"isDescendentOfSingleProductTemplate":true} /-->
+	<!-- wp:template-part {"slug":"footer","theme":"twentytwentythree","tagName":"footer"} /-->';
+
+		$expected_single_product_template = sprintf(
+			'
+	<!-- wp:template-part {"slug":"header","theme":"twentytwentythree","tagName":"header"} /-->
+	<!-- wp:html -->%s<!-- /wp:html -->
+	<!-- wp:template-part {"slug":"footer","theme":"twentytwentythree","tagName":"footer"} /-->',
+			get_the_password_form()
+		);
+
+		$result = SingleProductTemplate::add_password_form(
+			$default_single_product_template
+		);
+
+		$this->assertEquals(
+			TemplateContentUtils::strip_whitespace_and_password_form_ids( $result ),
+			TemplateContentUtils::strip_whitespace_and_password_form_ids( $expected_single_product_template )
+		);
+	}
 }