Commit 72da66178d1 for woocommerce
commit 72da66178d1937cd9ab96abf64cbec244adbf7ec
Author: Tung Du <dinhtungdu@gmail.com>
Date: Tue Oct 20 16:19:26 2026 +0700
Fix duplicate block-template compatibility hook output (#69994)
* fix: share block template compatibility instances and callbacks
Amp-Thread-ID: https://ampcode.com/threads/T-01a152f8-bdc2-7053-ad88-32ccc4376934
Co-authored-by: Amp <amp@ampcode.com>
* changelog: document duplicate template hook output fix
Amp-Thread-ID: https://ampcode.com/threads/T-01a152f8-bdc2-7053-ad88-32ccc4376934
Co-authored-by: Amp <amp@ampcode.com>
* fix: remove resolved template PHPStan suppression
Amp-Thread-ID: https://ampcode.com/threads/T-01a152f8-bdc2-7053-ad88-32ccc4376934
Co-authored-by: Amp <amp@ampcode.com>
* test: isolate template hook regression from featured item state
Amp-Thread-ID: https://ampcode.com/threads/T-01a152f8-bdc2-7053-ad88-32ccc4376934
Co-authored-by: Amp <amp@ampcode.com>
* test: cover template compatibility lifecycle and disable filter
Amp-Thread-ID: https://ampcode.com/threads/T-01a152f8-bdc2-7053-ad88-32ccc4376934
Co-authored-by: Amp <amp@ampcode.com>
---------
Co-authored-by: Amp <amp@ampcode.com>
diff --git a/plugins/woocommerce/changelog/fix-shared-template-compatibility b/plugins/woocommerce/changelog/fix-shared-template-compatibility
new file mode 100644
index 00000000000..184a88afc99
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-shared-template-compatibility
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent duplicate extension output on filtered Shop pages when using block themes.
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index ec2b2b75497..88d48e1065b 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -52833,12 +52833,6 @@ parameters:
count: 1
path: src/Blocks/Templates/AbstractTemplateCompatibility.php
- -
- message: '#^Parameter \$parent_block of method Automattic\\WooCommerce\\Blocks\\Templates\\AbstractTemplateCompatibility\:\:update_render_block_data\(\) has invalid type Automattic\\WooCommerce\\Blocks\\Templates\\WP_Block\.$#'
- identifier: class.notFound
- count: 1
- path: src/Blocks/Templates/AbstractTemplateCompatibility.php
-
-
message: '#^Binary operation "\+" between int\|string and 1 results in an error\.$#'
identifier: binaryOp.invalid
diff --git a/plugins/woocommerce/src/Blocks/BlockTemplatesRegistry.php b/plugins/woocommerce/src/Blocks/BlockTemplatesRegistry.php
index b2aa4e5a6ce..2a289e44aaf 100644
--- a/plugins/woocommerce/src/Blocks/BlockTemplatesRegistry.php
+++ b/plugins/woocommerce/src/Blocks/BlockTemplatesRegistry.php
@@ -5,6 +5,7 @@ namespace Automattic\WooCommerce\Blocks;
use Automattic\WooCommerce\Blocks\Utils\BlockTemplateUtils;
use Automattic\WooCommerce\Blocks\Templates\AbstractTemplate;
use Automattic\WooCommerce\Blocks\Templates\AbstractTemplatePart;
+use Automattic\WooCommerce\Blocks\Templates\ArchiveProductTemplatesCompatibility;
use Automattic\WooCommerce\Blocks\Templates\MiniCartTemplate;
use Automattic\WooCommerce\Blocks\Templates\CartTemplate;
use Automattic\WooCommerce\Blocks\Templates\CheckoutTemplate;
@@ -18,6 +19,7 @@ use Automattic\WooCommerce\Blocks\Templates\ProductCategoryTemplate;
use Automattic\WooCommerce\Blocks\Templates\ProductTagTemplate;
use Automattic\WooCommerce\Blocks\Templates\ProductSearchResultsTemplate;
use Automattic\WooCommerce\Blocks\Templates\SingleProductTemplate;
+use Automattic\WooCommerce\Blocks\Templates\SingleProductTemplateCompatibility;
use Automattic\WooCommerce\Blocks\Templates\SimpleProductAddToCartWithOptionsTemplate;
use Automattic\WooCommerce\Blocks\Templates\ExternalProductAddToCartWithOptionsTemplate;
use Automattic\WooCommerce\Blocks\Templates\VariableProductAddToCartWithOptionsTemplate;
@@ -45,6 +47,44 @@ class BlockTemplatesRegistry {
*/
private $template_paths = array();
+ /**
+ * Shared hook compatibility for product archives.
+ *
+ * @var ArchiveProductTemplatesCompatibility|null
+ */
+ private $archive_compatibility;
+
+ /**
+ * Shared hook compatibility for single products.
+ *
+ * @var SingleProductTemplateCompatibility|null
+ */
+ private $single_product_compatibility;
+
+ /**
+ * Initialize the shared product archive compatibility layer.
+ *
+ * @internal
+ */
+ public function init_archive_compatibility(): void {
+ if ( null === $this->archive_compatibility ) {
+ $this->archive_compatibility = new ArchiveProductTemplatesCompatibility();
+ }
+ $this->archive_compatibility->init();
+ }
+
+ /**
+ * Initialize the shared single product compatibility layer.
+ *
+ * @internal
+ */
+ public function init_single_product_compatibility(): void {
+ if ( null === $this->single_product_compatibility ) {
+ $this->single_product_compatibility = new SingleProductTemplateCompatibility();
+ }
+ $this->single_product_compatibility->init();
+ }
+
/**
* Initialization method.
*/
diff --git a/plugins/woocommerce/src/Blocks/Templates/AbstractTemplateCompatibility.php b/plugins/woocommerce/src/Blocks/Templates/AbstractTemplateCompatibility.php
index 19c1d9c76db..0279b5cfe50 100644
--- a/plugins/woocommerce/src/Blocks/Templates/AbstractTemplateCompatibility.php
+++ b/plugins/woocommerce/src/Blocks/Templates/AbstractTemplateCompatibility.php
@@ -1,7 +1,9 @@
<?php
+declare( strict_types=1 );
namespace Automattic\WooCommerce\Blocks\Templates;
use Automattic\WooCommerce\Blocks\Utils\BlockTemplateUtils;
+use WP_Block;
/**
* AbstractTemplateCompatibility class.
@@ -25,62 +27,78 @@ abstract class AbstractTemplateCompatibility {
public function init() {
$this->set_hook_data();
- add_filter(
- 'template_include',
- function ( $template ) {
- $this->set_compatibility_layer_flag();
+ add_filter( 'template_include', array( $this, 'handle_template_include' ) );
+ }
- add_filter(
- 'render_block_data',
- function ( $parsed_block, $source_block, $parent_block ) {
- /**
- * Filter to disable the compatibility layer for the blockified templates.
- *
- * This hook allows to disable the compatibility layer for the blockified templates.
- *
- * @since 7.6.0
- * @param bool $is_disabled_compatibility_layer Whether the compatibility layer should be disabled.
- */
- $is_disabled_compatibility_layer = apply_filters( 'woocommerce_disable_compatibility_layer', false );
+ /**
+ * Register rendering callbacks once the template has been selected.
+ *
+ * @internal
+ *
+ * @param string $template Selected template path.
+ * @return string
+ */
+ public function handle_template_include( $template ) {
+ $this->set_compatibility_layer_flag();
+ add_filter( 'render_block_data', array( $this, 'handle_render_block_data' ), 10, 3 );
+ add_filter( 'render_block', array( $this, 'handle_render_block' ), 10, 2 );
- if ( $is_disabled_compatibility_layer ) {
- return $parsed_block;
- }
+ return $template;
+ }
+
+ /**
+ * Mark blocks for hook compatibility unless the layer is disabled.
+ *
+ * @internal
+ *
+ * @param array $parsed_block Parsed block data.
+ * @param array $source_block Original block data.
+ * @param \WP_Block|null $parent_block Parent block, if any.
+ * @return array
+ */
+ public function handle_render_block_data( $parsed_block, $source_block, $parent_block ) {
+ /**
+ * Filter to disable the compatibility layer for the blockified templates.
+ *
+ * This hook allows to disable the compatibility layer for the blockified templates.
+ *
+ * @since 7.6.0
+ * @param bool $is_disabled_compatibility_layer Whether the compatibility layer should be disabled.
+ */
+ $is_disabled_compatibility_layer = apply_filters( 'woocommerce_disable_compatibility_layer', false );
- return $this->update_render_block_data( $parsed_block, $source_block, $parent_block );
- },
- 10,
- 3
- );
+ if ( $is_disabled_compatibility_layer ) {
+ return $parsed_block;
+ }
- add_filter(
- 'render_block',
- function ( $block_content, $block ) {
- /**
- * Filter to disable the compatibility layer for the blockified templates.
- *
- * This hook allows to disable the compatibility layer for the blockified.
- *
- * @since 7.6.0
- * @param bool $is_disabled_compatibility_layer Whether the compatibility layer should be disabled.
- */
- $is_disabled_compatibility_layer = apply_filters( 'woocommerce_disable_compatibility_layer', false );
+ return $this->update_render_block_data( $parsed_block, $source_block, $parent_block );
+ }
- if ( $is_disabled_compatibility_layer ) {
- return $block_content;
- }
+ /**
+ * Inject hook output unless the compatibility layer is disabled.
+ *
+ * @internal
+ *
+ * @param string $block_content Rendered block content.
+ * @param array $block Parsed block data.
+ * @return string
+ */
+ public function handle_render_block( $block_content, $block ) {
+ /**
+ * Filter to disable the compatibility layer for the blockified templates.
+ *
+ * This hook allows to disable the compatibility layer for the blockified.
+ *
+ * @since 7.6.0
+ * @param bool $is_disabled_compatibility_layer Whether the compatibility layer should be disabled.
+ */
+ $is_disabled_compatibility_layer = apply_filters( 'woocommerce_disable_compatibility_layer', false );
- return $this->inject_hooks( $block_content, $block );
- },
- 10,
- 2
- );
+ if ( $is_disabled_compatibility_layer ) {
+ return $block_content;
+ }
- return $template;
- },
- 10,
- 1
- );
+ return $this->inject_hooks( $block_content, $block );
}
/**
diff --git a/plugins/woocommerce/src/Blocks/Templates/ProductAttributeTemplate.php b/plugins/woocommerce/src/Blocks/Templates/ProductAttributeTemplate.php
index 24d023283ac..4e3ab1c0321 100644
--- a/plugins/woocommerce/src/Blocks/Templates/ProductAttributeTemplate.php
+++ b/plugins/woocommerce/src/Blocks/Templates/ProductAttributeTemplate.php
@@ -2,7 +2,8 @@
declare( strict_types=1 );
namespace Automattic\WooCommerce\Blocks\Templates;
-use Automattic\WooCommerce\Blocks\Templates\ArchiveProductTemplatesCompatibility;
+use Automattic\WooCommerce\Blocks\BlockTemplatesRegistry;
+use Automattic\WooCommerce\Blocks\Package;
use Automattic\WooCommerce\Blocks\Utils\BlockTemplateUtils;
/**
@@ -54,8 +55,7 @@ class ProductAttributeTemplate extends AbstractTemplateWithFallback {
}
if ( isset( $queried_object->taxonomy ) && taxonomy_is_product_attribute( $queried_object->taxonomy ) ) {
- $compatibility_layer = new ArchiveProductTemplatesCompatibility();
- $compatibility_layer->init();
+ Package::container()->get( BlockTemplatesRegistry::class )->init_archive_compatibility();
}
}
diff --git a/plugins/woocommerce/src/Blocks/Templates/ProductBrandTemplate.php b/plugins/woocommerce/src/Blocks/Templates/ProductBrandTemplate.php
index 476c329dfbd..1df92fce490 100644
--- a/plugins/woocommerce/src/Blocks/Templates/ProductBrandTemplate.php
+++ b/plugins/woocommerce/src/Blocks/Templates/ProductBrandTemplate.php
@@ -2,7 +2,8 @@
declare( strict_types = 1 );
namespace Automattic\WooCommerce\Blocks\Templates;
-use Automattic\WooCommerce\Blocks\Templates\ArchiveProductTemplatesCompatibility;
+use Automattic\WooCommerce\Blocks\BlockTemplatesRegistry;
+use Automattic\WooCommerce\Blocks\Package;
use Automattic\WooCommerce\Blocks\Utils\BlockTemplateUtils;
/**
@@ -56,8 +57,7 @@ class ProductBrandTemplate extends AbstractTemplateWithFallback {
*/
public function render_block_template() {
if ( ! is_embed() && is_product_taxonomy() && is_tax( 'product_brand' ) ) {
- $compatibility_layer = new ArchiveProductTemplatesCompatibility();
- $compatibility_layer->init();
+ Package::container()->get( BlockTemplatesRegistry::class )->init_archive_compatibility();
add_filter( 'woocommerce_has_block_template', '__return_true', 10, 0 );
}
diff --git a/plugins/woocommerce/src/Blocks/Templates/ProductCatalogTemplate.php b/plugins/woocommerce/src/Blocks/Templates/ProductCatalogTemplate.php
index 149eec9e4e6..993634a817e 100644
--- a/plugins/woocommerce/src/Blocks/Templates/ProductCatalogTemplate.php
+++ b/plugins/woocommerce/src/Blocks/Templates/ProductCatalogTemplate.php
@@ -1,8 +1,10 @@
<?php
+declare( strict_types=1 );
namespace Automattic\WooCommerce\Blocks\Templates;
-use Automattic\WooCommerce\Blocks\Templates\ArchiveProductTemplatesCompatibility;
+use Automattic\WooCommerce\Blocks\BlockTemplatesRegistry;
+use Automattic\WooCommerce\Blocks\Package;
use Automattic\WooCommerce\Blocks\Utils\BlockTemplateUtils;
/**
@@ -50,8 +52,7 @@ class ProductCatalogTemplate extends AbstractTemplate {
*/
public function render_block_template() {
if ( ! is_embed() && ( is_post_type_archive( 'product' ) || is_page( wc_get_page_id( 'shop' ) ) ) && ! is_search() ) {
- $compatibility_layer = new ArchiveProductTemplatesCompatibility();
- $compatibility_layer->init();
+ Package::container()->get( BlockTemplatesRegistry::class )->init_archive_compatibility();
}
}
diff --git a/plugins/woocommerce/src/Blocks/Templates/ProductCategoryTemplate.php b/plugins/woocommerce/src/Blocks/Templates/ProductCategoryTemplate.php
index 560fd77ce00..42498734f1f 100644
--- a/plugins/woocommerce/src/Blocks/Templates/ProductCategoryTemplate.php
+++ b/plugins/woocommerce/src/Blocks/Templates/ProductCategoryTemplate.php
@@ -2,7 +2,8 @@
declare( strict_types=1 );
namespace Automattic\WooCommerce\Blocks\Templates;
-use Automattic\WooCommerce\Blocks\Templates\ArchiveProductTemplatesCompatibility;
+use Automattic\WooCommerce\Blocks\BlockTemplatesRegistry;
+use Automattic\WooCommerce\Blocks\Package;
use Automattic\WooCommerce\Blocks\Utils\BlockTemplateUtils;
/**
@@ -56,8 +57,7 @@ class ProductCategoryTemplate extends AbstractTemplateWithFallback {
*/
public function render_block_template() {
if ( ! is_embed() && is_product_taxonomy() && is_tax( 'product_cat' ) ) {
- $compatibility_layer = new ArchiveProductTemplatesCompatibility();
- $compatibility_layer->init();
+ Package::container()->get( BlockTemplatesRegistry::class )->init_archive_compatibility();
add_filter( 'woocommerce_has_block_template', '__return_true', 10, 0 );
}
diff --git a/plugins/woocommerce/src/Blocks/Templates/ProductSearchResultsTemplate.php b/plugins/woocommerce/src/Blocks/Templates/ProductSearchResultsTemplate.php
index a26d6a7a794..1eedc0f9b4b 100644
--- a/plugins/woocommerce/src/Blocks/Templates/ProductSearchResultsTemplate.php
+++ b/plugins/woocommerce/src/Blocks/Templates/ProductSearchResultsTemplate.php
@@ -1,7 +1,9 @@
<?php
+declare( strict_types=1 );
namespace Automattic\WooCommerce\Blocks\Templates;
-use Automattic\WooCommerce\Blocks\Templates\ArchiveProductTemplatesCompatibility;
+use Automattic\WooCommerce\Blocks\BlockTemplatesRegistry;
+use Automattic\WooCommerce\Blocks\Package;
use Automattic\WooCommerce\Blocks\Utils\BlockTemplateUtils;
/**
@@ -49,8 +51,7 @@ class ProductSearchResultsTemplate extends AbstractTemplate {
*/
public function render_block_template() {
if ( ! is_embed() && is_post_type_archive( 'product' ) && is_search() ) {
- $compatibility_layer = new ArchiveProductTemplatesCompatibility();
- $compatibility_layer->init();
+ Package::container()->get( BlockTemplatesRegistry::class )->init_archive_compatibility();
}
}
diff --git a/plugins/woocommerce/src/Blocks/Templates/ProductTagTemplate.php b/plugins/woocommerce/src/Blocks/Templates/ProductTagTemplate.php
index 66525dec03e..dcf6f647cab 100644
--- a/plugins/woocommerce/src/Blocks/Templates/ProductTagTemplate.php
+++ b/plugins/woocommerce/src/Blocks/Templates/ProductTagTemplate.php
@@ -2,7 +2,8 @@
declare( strict_types=1 );
namespace Automattic\WooCommerce\Blocks\Templates;
-use Automattic\WooCommerce\Blocks\Templates\ArchiveProductTemplatesCompatibility;
+use Automattic\WooCommerce\Blocks\BlockTemplatesRegistry;
+use Automattic\WooCommerce\Blocks\Package;
use Automattic\WooCommerce\Blocks\Utils\BlockTemplateUtils;
/**
@@ -56,8 +57,7 @@ class ProductTagTemplate extends AbstractTemplateWithFallback {
*/
public function render_block_template() {
if ( ! is_embed() && is_product_taxonomy() && is_tax( 'product_tag' ) ) {
- $compatibility_layer = new ArchiveProductTemplatesCompatibility();
- $compatibility_layer->init();
+ Package::container()->get( BlockTemplatesRegistry::class )->init_archive_compatibility();
add_filter( 'woocommerce_has_block_template', '__return_true', 10, 0 );
}
diff --git a/plugins/woocommerce/src/Blocks/Templates/SingleProductTemplate.php b/plugins/woocommerce/src/Blocks/Templates/SingleProductTemplate.php
index 95fe802cb7b..293d7760993 100644
--- a/plugins/woocommerce/src/Blocks/Templates/SingleProductTemplate.php
+++ b/plugins/woocommerce/src/Blocks/Templates/SingleProductTemplate.php
@@ -1,8 +1,10 @@
<?php
+declare( strict_types=1 );
namespace Automattic\WooCommerce\Blocks\Templates;
+use Automattic\WooCommerce\Blocks\BlockTemplatesRegistry;
+use Automattic\WooCommerce\Blocks\Package;
use Automattic\WooCommerce\Blocks\SharedStores\ProductsStore;
-use Automattic\WooCommerce\Blocks\Templates\SingleProductTemplateCompatibility;
use Automattic\WooCommerce\Blocks\Utils\BlockTemplateUtils;
/**
@@ -52,8 +54,7 @@ class SingleProductTemplate extends AbstractTemplate {
if ( ! is_embed() && is_singular( 'product' ) ) {
global $post;
- $compatibility_layer = new SingleProductTemplateCompatibility();
- $compatibility_layer->init();
+ Package::container()->get( BlockTemplatesRegistry::class )->init_single_product_compatibility();
$product = wc_get_product( $post->ID );
if ( $product ) {
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Templates/ArchiveProductTemplatesCompatibilityTest.php b/plugins/woocommerce/tests/php/src/Blocks/Templates/ArchiveProductTemplatesCompatibilityTest.php
index d78a48a24e6..ac95e267ee4 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/Templates/ArchiveProductTemplatesCompatibilityTest.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/Templates/ArchiveProductTemplatesCompatibilityTest.php
@@ -4,6 +4,10 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\Tests\Blocks\Templates;
use Automattic\WooCommerce\Blocks\Templates\ArchiveProductTemplatesCompatibility;
+use Automattic\WooCommerce\Blocks\Templates\ProductCatalogTemplate;
+use Automattic\WooCommerce\Blocks\Templates\ProductCategoryTemplate;
+use Automattic\WooCommerce\Blocks\Templates\ProductSearchResultsTemplate;
+use Automattic\WooCommerce\Blocks\Templates\ProductTagTemplate;
use WC_Unit_Test_Case;
/**
@@ -32,12 +36,20 @@ class ArchiveProductTemplatesCompatibilityTest extends WC_Unit_Test_Case {
*/
private $shop_page_id = 0;
+ /**
+ * Product archive registration before the test.
+ *
+ * @var string|bool
+ */
+ private $original_product_has_archive;
+
/**
* @inheritdoc
*/
public function setUp(): void {
parent::setUp();
- $this->original_shop_page_id = get_option( 'woocommerce_shop_page_id' );
+ $this->original_shop_page_id = get_option( 'woocommerce_shop_page_id' );
+ $this->original_product_has_archive = get_post_type_object( 'product' )->has_archive;
foreach ( array( 'wp_query', 'wp_the_query' ) as $global_name ) {
$this->query_snapshots[ $global_name ] = array(
@@ -51,6 +63,7 @@ class ArchiveProductTemplatesCompatibilityTest extends WC_Unit_Test_Case {
* @inheritdoc
*/
public function tearDown(): void {
+ get_post_type_object( 'product' )->has_archive = $this->original_product_has_archive;
foreach ( $this->query_snapshots as $global_name => $snapshot ) {
if ( $snapshot['exists'] ) {
$GLOBALS[ $global_name ] = $snapshot['value']; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the exact pre-test query global.
@@ -213,6 +226,138 @@ class ArchiveProductTemplatesCompatibilityTest extends WC_Unit_Test_Case {
$this->assertSame( '', $sut->inject_hooks( '', $empty_product_template ) );
}
+ /**
+ * @testdox Registered rendering callbacks respect the disable filter and resume when it is removed.
+ */
+ public function test_registered_render_callbacks_respect_disable_filter(): void {
+ $sut = $this->initialize_archive_compatibility();
+ $target = $this->get_marked_target_block( $sut, 'core/post-title' );
+ $tree = array(
+ 'blockName' => 'woocommerce/product-collection',
+ 'attrs' => array( 'query' => array( 'inherit' => true ) ),
+ 'innerBlocks' => array(),
+ );
+ add_action(
+ 'woocommerce_shop_loop_item_title',
+ static function () {
+ echo esc_html( '__HOOK_MARKER__' );
+ }
+ );
+
+ foreach ( array( false, true, false ) as $disabled ) {
+ if ( $disabled ) {
+ add_filter( 'woocommerce_disable_compatibility_layer', '__return_true', 99 );
+ } else {
+ remove_filter( 'woocommerce_disable_compatibility_layer', '__return_true', 99 );
+ }
+ // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- WordPress owns this filter.
+ $marked = apply_filters( 'render_block_data', $tree, $tree, null );
+ if ( $disabled ) {
+ $this->assertArrayNotHasKey( 'isInherited', $marked['attrs'], 'Disabled compatibility must not mark inherited blocks.' );
+ } else {
+ $this->assertSame( 1, $marked['attrs']['isInherited'], 'Enabled compatibility must mark inherited blocks.' );
+ }
+
+ // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- WordPress owns this filter.
+ $output = apply_filters( 'render_block', '__BLOCK_SENTINEL__', $target, new \WP_Block( $target ) );
+ $this->assertSame( $disabled ? 0 : 1, substr_count( $output, '__HOOK_MARKER__' ), 'The rendering callback must check the disable filter even for an already-marked block.' );
+ $this->assertSame( 1, substr_count( $output, '__BLOCK_SENTINEL__' ), 'The rendering callback must preserve block content.' );
+ }
+ }
+
+ /**
+ * @testdox Renders archive hooks once per loop and product when template handlers overlap.
+ * @dataProvider overlapping_requests_provider
+ *
+ * @param string $taxonomy Product taxonomy.
+ * @param bool $search Whether the request is a product search.
+ */
+ public function test_overlapping_templates_render_hooks_once( string $taxonomy, bool $search ): void {
+ get_post_type_object( 'product' )->has_archive = 'shop';
+ $term_id = self::factory()->term->create(
+ array(
+ 'taxonomy' => $taxonomy,
+ 'slug' => 'compatibility-overlap',
+ )
+ );
+ $product_ids = array();
+ foreach ( array( 'First', 'Second' ) as $name ) {
+ $product = \WC_Helper_Product::create_simple_product();
+ $product->set_name( 'Compatibility ' . $name );
+ $product->save();
+ wp_set_object_terms( $product->get_id(), $term_id, $taxonomy );
+ $product_ids[] = $product->get_id();
+ }
+ $query_args = array(
+ 'post_type' => 'product',
+ $taxonomy => 'compatibility-overlap',
+ 'orderby' => 'ID',
+ 'order' => 'ASC',
+ );
+ if ( $search ) {
+ $query_args['s'] = 'Compatibility';
+ }
+ $this->go_to( add_query_arg( $query_args, home_url( '/' ) ) );
+ $GLOBALS['wp_the_query'] = $GLOBALS['wp_query']; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Product archive conditionals read the main query.
+ $this->assertTrue( is_post_type_archive( 'product' ) && is_tax( $taxonomy ), 'Both archive handlers must match the request.' );
+
+ $item_ids = array();
+ add_action(
+ 'woocommerce_after_shop_loop_item',
+ static function () use ( &$item_ids ) {
+ $item_ids[] = get_the_ID();
+ echo '<span class="compatibility-item-marker"></span>';
+ },
+ 99
+ );
+ add_action(
+ 'woocommerce_before_shop_loop',
+ static function () {
+ echo '<span class="compatibility-loop-marker"></span>';
+ },
+ 99
+ );
+ $hook_snapshot = array();
+ foreach ( $GLOBALS['wp_filter'] as $hook => $callbacks ) {
+ $hook_snapshot[ $hook ] = clone $callbacks;
+ }
+ // Static inner content avoids Featured Item's unrelated core/post-title global-post reset.
+ $markup = '<!-- wp:woocommerce/product-collection {"query":{"postType":"product","inherit":true}} --><div class="wp-block-woocommerce-product-collection"><!-- wp:woocommerce/product-template --><!-- wp:paragraph --><p>__PRODUCT_CONTENT__</p><!-- /wp:paragraph --><!-- /wp:woocommerce/product-template --></div><!-- /wp:woocommerce/product-collection -->';
+
+ foreach ( array( 'initial', 'repeated setup', 'hook reset' ) as $stage ) {
+ if ( 'hook reset' === $stage ) {
+ $GLOBALS['wp_filter'] = $hook_snapshot; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Simulate the hook restoration between WordPress tests.
+ }
+ $item_ids = array();
+ foreach ( array( ProductCatalogTemplate::class, ProductCategoryTemplate::class, ProductTagTemplate::class, ProductSearchResultsTemplate::class ) as $template_class ) {
+ ( new $template_class() )->render_block_template();
+ }
+ // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- WordPress owns this filter.
+ apply_filters( 'template_include', 'index.php' );
+ $output = do_blocks( $markup );
+ $this->assertSame( $product_ids, $item_ids, $stage . ': each product hook should fire once.' );
+ $this->assertSame( 2, substr_count( $output, 'compatibility-item-marker' ), $stage . ': product hook output should not repeat.' );
+ $this->assertSame( 1, substr_count( $output, 'compatibility-loop-marker' ), $stage . ': loop hook output should not repeat.' );
+ $this->assertSame( 2, substr_count( $output, '__PRODUCT_CONTENT__' ), $stage . ': product content should remain present.' );
+ }
+
+ do_blocks( $markup );
+ $this->assertSame( array_merge( $product_ids, $product_ids ), $item_ids, 'A separate loop should still fire its own product hooks.' );
+ }
+
+ /**
+ * Requests matching both catalog/search and taxonomy handlers.
+ *
+ * @return array
+ */
+ public function overlapping_requests_provider(): array {
+ return array(
+ 'Shop and tag' => array( 'product_tag', false ),
+ 'Shop and category' => array( 'product_cat', false ),
+ 'Search and category' => array( 'product_cat', true ),
+ );
+ }
+
/**
* Initializes the real compatibility layer in a Shop request.
*/
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Templates/SingleProductTemplateCompatibilityTests.php b/plugins/woocommerce/tests/php/src/Blocks/Templates/SingleProductTemplateCompatibilityTests.php
index e2b2b1ef248..ec1d75aaa01 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/Templates/SingleProductTemplateCompatibilityTests.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/Templates/SingleProductTemplateCompatibilityTests.php
@@ -3,6 +3,8 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\Tests\Blocks\Templates;
+use Automattic\WooCommerce\Blocks\BlockTemplatesRegistry;
+use Automattic\WooCommerce\Blocks\Package;
use WP_UnitTestCase;
use Automattic\WooCommerce\Blocks\Templates\SingleProductTemplateCompatibility;
@@ -509,4 +511,51 @@ class SingleProductTemplateCompatibilityTests extends WP_UnitTestCase {
TemplateContentUtils::strip_whitespace( $result )
);
}
+
+ /**
+ * @testdox Renders single-product hooks once after repeated setup, template selection, and hook restoration.
+ */
+ public function test_shared_compatibility_renders_hooks_once_after_reinitialization(): void {
+ $product_id = self::factory()->post->create(
+ array(
+ 'post_type' => 'product',
+ 'post_status' => 'publish',
+ )
+ );
+ $this->go_to( get_permalink( $product_id ) );
+ $this->assertTrue( is_product(), 'The compatibility layer must run in a single-product request.' );
+
+ $rendered_product_ids = array();
+ add_action(
+ 'woocommerce_before_single_product',
+ static function () use ( &$rendered_product_ids ) {
+ $rendered_product_ids[] = get_the_ID();
+ echo esc_html( '__SINGLE_PRODUCT_HOOK__' );
+ },
+ 99
+ );
+ $hook_snapshot = array();
+ foreach ( $GLOBALS['wp_filter'] as $hook_name => $callbacks ) {
+ $hook_snapshot[ $hook_name ] = clone $callbacks;
+ }
+ $sut = Package::container()->get( BlockTemplatesRegistry::class );
+ $markup = SingleProductTemplateCompatibility::add_compatibility_layer( '<!-- wp:paragraph --><p>__SINGLE_PRODUCT_CONTENT__</p><!-- /wp:paragraph -->' );
+
+ foreach ( array( 'initial', 'repeated initialization', 'repeated template selection', 'hook reset' ) as $stage ) {
+ if ( 'hook reset' === $stage ) {
+ $GLOBALS['wp_filter'] = $hook_snapshot; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Simulate the hook restoration between WordPress tests.
+ }
+ if ( 'repeated template selection' !== $stage ) {
+ $sut->init_single_product_compatibility();
+ }
+ // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- WordPress owns this filter.
+ apply_filters( 'template_include', 'index.php' );
+ $rendered_product_ids = array();
+ $output = do_blocks( $markup );
+
+ $this->assertSame( array( $product_id ), $rendered_product_ids, $stage . ': the hook should fire once for the current product.' );
+ $this->assertSame( 1, substr_count( $output, '__SINGLE_PRODUCT_HOOK__' ), $stage . ': hook output should not repeat.' );
+ $this->assertSame( 1, substr_count( $output, '__SINGLE_PRODUCT_CONTENT__' ), $stage . ': product content should remain present.' );
+ }
+ }
}