Commit 8d6ac852d96 for woocommerce

commit 8d6ac852d964d395a368b52959fb4f662bac7c3b
Author: Tung Du <dinhtungdu@gmail.com>
Date:   Mon Sep 21 18:35:14 2026 +0700

    Fix product context handling for Single Product block (#68533)

    * Fix product context in nested Single Product queries

    * Add changelog entry for Single Product context fix

    * Simplify Single Product post restoration

    * Improve Single Product context regression tests

diff --git a/plugins/woocommerce/changelog/fix-single-product-nested-query-context b/plugins/woocommerce/changelog/fix-single-product-nested-query-context
new file mode 100644
index 00000000000..d6edd2bc1f4
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-single-product-nested-query-context
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Preserve product context in nested queries and Single Product blocks.
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-template/block.json b/plugins/woocommerce/client/blocks/assets/js/blocks/product-template/block.json
index 8ec84087c5d..66597927b97 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-template/block.json
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-template/block.json
@@ -14,6 +14,7 @@
 		"displayLayout",
 		"templateSlug",
 		"postId",
+		"singleProduct",
 		"queryContextIncludes",
 		"collection",
 		"__privateProductCollectionPreviewState"
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/SingleProduct.php b/plugins/woocommerce/src/Blocks/BlockTypes/SingleProduct.php
index 8cb78ff6c85..0909bf263e0 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/SingleProduct.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/SingleProduct.php
@@ -20,20 +20,11 @@ class SingleProduct extends AbstractBlock {
 	protected $block_name = 'single-product';

 	/**
-	 * Product ID of the current product to be displayed in the Single Product block.
-	 * This is used to replace the global post for the Single Product inner blocks.
+	 * Post temporarily replaced while rendering a product title or excerpt.
 	 *
-	 * @var int
+	 * @var \WP_Post|null|false
 	 */
-	protected $product_id = 0;
-
-	/**
-	 * Single Product inner blocks names.
-	 * This is used to map all the inner blocks for a Single Product block.
-	 *
-	 * @var array
-	 */
-	protected $single_product_inner_blocks_names = [];
+	private $previous_post = false;

 	/**
 	 * Initialize the block and Hook into the `render_block_context` filter
@@ -49,13 +40,7 @@ class SingleProduct extends AbstractBlock {
 	}

 	/**
-	 * Restore the global post variable right before generating the render output for the post title and/or post excerpt blocks.
-	 *
-	 * This is required due to the changes made via the replace_post_for_single_product_inner_block method.
-	 * It is a temporary fix to ensure these blocks work as expected until Gutenberg versions 15.2 and 15.6 are part of the core of WordPress.
-	 *
-	 * @see https://github.com/WordPress/gutenberg/pull/48001
-	 * @see https://github.com/WordPress/gutenberg/pull/49495
+	 * Restore the post that was current before rendering a product title or excerpt.
 	 *
 	 * @param  string    $block_content  The block content.
 	 * @param  array     $parsed_block  The full block, including name and attributes.
@@ -64,16 +49,18 @@ class SingleProduct extends AbstractBlock {
 	 * @return mixed
 	 */
 	public function restore_global_post( $block_content, $parsed_block, $block_instance ) {
-		if ( isset( $block_instance->context['singleProduct'] ) && $block_instance->context['singleProduct'] ) {
-			wp_reset_postdata();
+		if ( false !== $this->previous_post ) {
+			global $post;
+			// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the exact enclosing post, including secondary queries.
+			$post                = $this->previous_post;
+			$this->previous_post = false;
 		}

 		return $block_content;
 	}

 	/**
-	 * Update the context by injecting the correct post data
-	 * for each one of the Single Product inner blocks.
+	 * Set the selected product context and let WordPress propagate it to inner blocks.
 	 *
 	 * @param array    $context Block context.
 	 * @param array    $block Block attributes.
@@ -84,11 +71,9 @@ class SingleProduct extends AbstractBlock {
 	public function update_context( $context, $block, $parent_block ) {
 		if ( 'woocommerce/single-product' === $block['blockName']
 			&& isset( $block['attrs']['productId'] ) ) {
-				$this->product_id = $block['attrs']['productId'];
-
-				$this->single_product_inner_blocks_names = array_reverse(
-					$this->extract_single_product_inner_block_names( $block )
-				);
+			$context['postId']        = $block['attrs']['productId'];
+			$context['postType']      = 'product';
+			$context['singleProduct'] = true;
 		}

 		$this->replace_post_for_single_product_inner_block( $block, $context );
@@ -97,80 +82,29 @@ class SingleProduct extends AbstractBlock {
 	}

 	/**
-	 * Extract the inner block names for the Single Product block. This way it's possible
-	 * to map all the inner blocks for a Single Product block and manipulate the data as needed.
-	 *
-	 * @param array $block The Single Product block or its inner blocks.
-	 * @param array $result Array of inner block names.
+	 * Use the resolved product context for core blocks that read the global post.
 	 *
-	 * @return array Array containing all the inner block names of a Single Product block.
+	 * @param array $block Block attributes.
+	 * @param array $context Block context.
 	 */
-	protected function extract_single_product_inner_block_names( $block, &$result = [] ) {
-		if ( isset( $block['blockName'] ) ) {
-			$result[] = $block['blockName'];
+	protected function replace_post_for_single_product_inner_block( $block, &$context ) {
+		if ( ! in_array( $block['blockName'], array( 'core/post-title', 'core/post-excerpt' ), true ) ) {
+			return;
 		}

-		if ( 'woocommerce/product-template' === $block['blockName'] || 'core/post-template' === $block['blockName'] ) {
-			return $result;
+		global $post;
+		$this->previous_post = $post;
+		if ( empty( $context['postId'] ) ) {
+			return;
 		}

-		if ( isset( $block['innerBlocks'] ) ) {
-			foreach ( $block['innerBlocks'] as $inner_block ) {
-				$this->extract_single_product_inner_block_names( $inner_block, $result );
-			}
+		$product_post = get_post( $context['postId'] );
+		if ( ! $product_post instanceof \WP_Post || 'product' !== $product_post->post_type ) {
+			return;
 		}
-		return $result;
-	}
-
-	/**
-	 * Replace the global post for the Single Product inner blocks and reset it after.
-	 *
-	 * This is needed because some of the inner blocks may use the global post
-	 * instead of fetching the product through the `productId` attribute, so even if the
-	 * `productId` is passed to the inner block, it will still use the global post.
-	 *
-	 * @param array $block Block attributes.
-	 * @param array $context Block context.
-	 */
-	protected function replace_post_for_single_product_inner_block( $block, &$context ) {
-		if ( $this->single_product_inner_blocks_names ) {
-			// Find the block index in $this->single_product_inner_blocks_names
-			// starting from the end.
-			$block_index_reversed = array_search( $block['blockName'], array_reverse( $this->single_product_inner_blocks_names ), true );
-
-			if ( false !== $block_index_reversed ) {
-				$block_index = count( $this->single_product_inner_blocks_names ) - (int) $block_index_reversed - 1;
-
-				$block_name = $block['blockName'];
-
-				// Remove all blocks after the current one. In some cases, like
-				// in the Product Gallery block, inner blocks are rendered
-				// directly by the parent block, so we need to skip them.
-				$this->single_product_inner_blocks_names = array_slice( $this->single_product_inner_blocks_names, 0, $block_index );
-
-				/**
-				 * This is a temporary fix to ensure the Post Title and Excerpt blocks work as expected
-				 * until Gutenberg versions 15.2 and 15.6 are included in the core of WordPress.
-				 *
-				 * Important: the original post data is restored in the restore_global_post method.
-				 *
-				 * @see https://github.com/WordPress/gutenberg/pull/48001
-				 * @see https://github.com/WordPress/gutenberg/pull/49495
-				 */
-				if ( 'core/post-excerpt' === $block_name || 'core/post-title' === $block_name ) {
-					global $post;
-					// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
-					$post = get_post( $this->product_id );
-
-					if ( $post instanceof \WP_Post ) {
-						setup_postdata( $post );
-					}
-				}

-				$context['postId']        = $this->product_id;
-				$context['singleProduct'] = true;
-			}
-		}
+		// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Core Post Title reads get_the_title() without a post ID.
+		$post = $product_post;
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/SingleProduct.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/SingleProduct.php
index 18ed382ee68..5f32b925fa2 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/SingleProduct.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/SingleProduct.php
@@ -10,6 +10,169 @@ use WC_Helper_Product;
  * Tests for the SingleProduct block type.
  */
 class SingleProduct extends \WP_UnitTestCase {
+	/**
+	 * @testdox Query results and the following Single Product title retain their own products.
+	 * @testWith ["woocommerce/product-collection", "woocommerce/product-template"]
+	 *           ["core/query", "core/post-template"]
+	 * @param string $query_block Query block name.
+	 * @param string $template_block Template block name.
+	 */
+	public function test_nested_query_context( string $query_block, string $template_block ): void {
+		$selected = WC_Helper_Product::create_simple_product( true, array( 'name' => 'Selected product' ) );
+		$first    = WC_Helper_Product::create_simple_product( true, array( 'name' => 'Query first' ) );
+		$second   = WC_Helper_Product::create_simple_product( true, array( 'name' => 'Query second' ) );
+		$page_id  = self::factory()->post->create( array( 'post_title' => 'Containing page' ) );
+		$this->go_to( get_permalink( $page_id ) );
+		$previous_post = $GLOBALS['post'];
+		$location      = null;
+		add_filter(
+			'render_block_woocommerce/product-template',
+			static function ( $content, $parsed_block, $instance ) use ( &$location ) {
+				$location = $instance->context['productCollectionLocation'] ?? null;
+				return $content;
+			},
+			10,
+			3
+		);
+		$query = wp_json_encode(
+			array(
+				'query' => array(
+					'isProductCollectionBlock'      => 'woocommerce/product-collection' === $query_block,
+					'woocommerceHandPickedProducts' => array( $first->get_id(), $second->get_id() ),
+					'postType'                      => 'product',
+					'perPage'                       => 2,
+					'pages'                         => 0,
+					'offset'                        => 0,
+					'order'                         => 'asc',
+					'orderBy'                       => 'id',
+					'inherit'                       => false,
+					'include'                       => array( $first->get_id(), $second->get_id() ),
+					'exclude'                       => array( $selected->get_id() ),
+				),
+			)
+		);
+		$html  = do_blocks(
+			sprintf(
+				'<!-- wp:group --><div><!-- wp:woocommerce/single-product {"productId":%1$d} --><div>
+				<!-- wp:%2$s %3$s --><div><!-- wp:%4$s --><!-- wp:post-title {"isLink":true} /--><!-- /wp:%4$s --></div><!-- /wp:%2$s -->
+				<!-- wp:post-title /--></div><!-- /wp:woocommerce/single-product --></div><!-- /wp:group --><!-- wp:post-title /-->',
+				$selected->get_id(),
+				$query_block,
+				$query,
+				$template_block
+			)
+		);
+		$this->assertSame( array( 'Query first', 'Query second', 'Selected product', 'Containing page' ), $this->get_element_texts( $html, array( 'tag_name' => 'H2' ) ), 'Each title should use the nearest product/query context.' );
+		$this->assertSame( $previous_post, $GLOBALS['post'], 'Rendering must restore the containing post.' );
+		if ( 'woocommerce/product-collection' === $query_block ) {
+			$this->assertSame( 'product', $location['type'] ?? null, 'The collection must recognize its enclosing Single Product.' );
+			$this->assertSame( $selected->get_id(), $location['sourceData']['productId'] ?? null, 'Collection filters must use the selected product as their source.' );
+		}
+	}
+
+	/**
+	 * @testdox Nested and consecutive Single Product blocks restore their enclosing context.
+	 */
+	public function test_nested_single_product_context(): void {
+		$outer = WC_Helper_Product::create_simple_product( true, array( 'name' => 'Outer product' ) );
+		$inner = WC_Helper_Product::create_simple_product( true, array( 'name' => 'Inner product' ) );
+		$page  = self::factory()->post->create( array( 'post_title' => 'Containing page' ) );
+		$this->go_to( get_permalink( $page ) );
+		$previous_post = $GLOBALS['post'];
+		$html          = do_blocks(
+			sprintf(
+				'<!-- wp:group --><div><!-- wp:woocommerce/single-product {"productId":%1$d} --><div>
+				<!-- wp:post-title /--><!-- wp:woocommerce/single-product {"productId":%2$d} --><div><!-- wp:post-title /--></div><!-- /wp:woocommerce/single-product -->
+				<!-- wp:post-title /--></div><!-- /wp:woocommerce/single-product -->
+				<!-- wp:woocommerce/single-product {"productId":%2$d} --><div><!-- wp:post-title /--></div><!-- /wp:woocommerce/single-product -->
+				<!-- wp:post-title /--></div><!-- /wp:group -->',
+				$outer->get_id(),
+				$inner->get_id()
+			)
+		);
+		$this->assertSame( array( 'Outer product', 'Inner product', 'Outer product', 'Inner product', 'Containing page' ), $this->get_element_texts( $html, array( 'tag_name' => 'H2' ) ), 'Nested selections must not replace their enclosing product.' );
+		$this->assertSame( $previous_post, $GLOBALS['post'], 'Rendering must restore the containing post.' );
+	}
+
+	/**
+	 * @testdox A short-circuited title leaves the enclosing post and product unchanged.
+	 */
+	public function test_short_circuited_title_preserves_globals(): void {
+		$selected = WC_Helper_Product::create_simple_product();
+		$page_id  = self::factory()->post->create();
+		$this->go_to( get_permalink( $page_id ) );
+		$previous_post    = $GLOBALS['post'];
+		$previous_product = $GLOBALS['product'] ?? null;
+		add_filter(
+			'pre_render_block',
+			static function ( $content, $block ) {
+				return 'core/post-title' === $block['blockName'] ? '<h2>Cached title</h2>' : $content;
+			},
+			10,
+			2
+		);
+		$html = do_blocks( sprintf( '<!-- wp:woocommerce/single-product {"productId":%d} --><div><!-- wp:post-title /--></div><!-- /wp:woocommerce/single-product -->', $selected->get_id() ) );
+		$this->assertStringContainsString( '<h2>Cached title</h2>', $html );
+		$this->assertSame( $previous_post, $GLOBALS['post'] );
+		$this->assertSame( $previous_product, $GLOBALS['product'] ?? null );
+	}
+
+	/**
+	 * @testdox Product excerpts use their own context and preserve the enclosing globals.
+	 */
+	public function test_post_excerpt_preserves_globals(): void {
+		$selected = WC_Helper_Product::create_simple_product( true, array( 'short_description' => 'Selected excerpt' ) );
+		$outer    = WC_Helper_Product::create_simple_product( true, array( 'short_description' => 'Outer excerpt' ) );
+		$this->go_to( get_permalink( $outer->get_id() ) );
+		$GLOBALS['wp_query']->the_post();
+		$previous_post    = $GLOBALS['post'];
+		$previous_product = $GLOBALS['product'];
+
+		$html = do_blocks(
+			sprintf(
+				'<!-- wp:woocommerce/single-product {"productId":%d} --><div><!-- wp:post-excerpt /--></div><!-- /wp:woocommerce/single-product --><!-- wp:post-excerpt /-->',
+				$selected->get_id()
+			)
+		);
+
+		$excerpts = $this->get_element_texts(
+			$html,
+			array(
+				'tag_name'   => 'P',
+				'class_name' => 'wp-block-post-excerpt__excerpt',
+			)
+		);
+		$this->assertSame( array( 'Selected excerpt', 'Outer excerpt' ), $excerpts );
+		$this->assertSame( $previous_post, $GLOBALS['post'] );
+		$this->assertSame( $previous_product, $GLOBALS['product'] );
+	}
+
+	/**
+	 * Collect text from matching headings or excerpt paragraphs, including inline markup.
+	 *
+	 * @param string $html Rendered blocks.
+	 * @param array  $query Tag processor query.
+	 * @return string[] Element texts in document order.
+	 */
+	private function get_element_texts( string $html, array $query ): array {
+		$processor = new \WP_HTML_Tag_Processor( $html );
+		$texts     = array();
+		while ( $processor->next_tag( $query ) ) {
+			$tag  = $processor->get_tag();
+			$text = '';
+			while ( $processor->next_token() ) {
+				if ( $processor->is_tag_closer() && $tag === $processor->get_tag() ) {
+					break;
+				}
+				if ( '#text' === $processor->get_token_type() ) {
+					$text .= $processor->get_modifiable_text();
+				}
+			}
+			$texts[] = trim( $text );
+		}
+		return $texts;
+	}
+
 	/**
 	 * Creates a simple product with a featured image and gallery images.
 	 *