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.
*