Commit d5b03695bcf for woocommerce
commit d5b03695bcf798babd3e82b6f809509142c59fc6
Author: Tom Cafferkey <tjcafferkey@gmail.com>
Date: Fri Aug 21 15:15:31 2026 +0100
Fix product description recursion guard (#67908)
* Fix product description recursion guard
* Add changelog entry for product description recursion fix
* Add tests for product description recursion guard
* Test product description recursion callers
* Tests
* Test same-product product description guard
* Same guard against short description
diff --git a/plugins/woocommerce/changelog/fix-product-description-recursion-guard b/plugins/woocommerce/changelog/fix-product-description-recursion-guard
new file mode 100644
index 00000000000..3733ca4f769
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-product-description-recursion-guard
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent recursive product description formatting between the Product Description block and Store API product schema.
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductDescription.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductDescription.php
index 3ca6aff6ad7..96be982b74e 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductDescription.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductDescription.php
@@ -2,6 +2,8 @@
declare(strict_types=1);
namespace Automattic\WooCommerce\Blocks\BlockTypes;
+use Automattic\WooCommerce\Blocks\Utils\ProductDescriptionUtils;
+
/**
* ProductDescription class.
*/
@@ -13,14 +15,6 @@ class ProductDescription extends AbstractBlock {
*/
protected $block_name = 'product-description';
- /**
- * Keeps track of seen product IDs to prevent recursive rendering.
- *
- * @var array
- */
- private static $seen_ids = array();
-
-
/**
* Render the block.
*
@@ -36,41 +30,30 @@ class ProductDescription extends AbstractBlock {
return '';
}
- $product_id = $block->context['postId'];
-
- // Prevent recursive rendering.
- if ( isset( self::$seen_ids[ $product_id ] ) ) {
- if ( defined( 'WP_DEBUG' ) && WP_DEBUG && defined( 'WP_DEBUG_DISPLAY' ) && WP_DEBUG_DISPLAY ) {
- return __( '[product description rendering halted]', 'woocommerce' );
- }
- return '';
- }
-
- self::$seen_ids[ $product_id ] = true;
+ $product_id = absint( $block->context['postId'] );
// Get the product.
$product = wc_get_product( $product_id );
if ( ! $product ) {
- unset( self::$seen_ids[ $product_id ] );
return '';
}
- // Get the description content.
- $description = $product->get_description();
- /**
- * This filter is documented in wp-includes/post-template.php.
- * We follow core/content block to replace ]]> with ]>
- */
- // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
- $description = apply_filters( 'the_content', str_replace( ']]>', ']]>', $description ) );
+ $description = ProductDescriptionUtils::guarded_format(
+ $product,
+ function () use ( $product ) {
+ /**
+ * This filter is documented in wp-includes/post-template.php.
+ * We follow core/content block to replace ]]> with ]>
+ */
+ // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
+ return apply_filters( 'the_content', str_replace( ']]>', ']]>', $product->get_description() ) );
+ }
+ );
+
if ( empty( $description ) ) {
- unset( self::$seen_ids[ $product_id ] );
return '';
}
- // Remove this product from the seen array.
- unset( self::$seen_ids[ $product_id ] );
-
// Add wrapper with block attributes.
$wrapper_attributes = get_block_wrapper_attributes(
array( 'class' => 'wc-block-product-description' )
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductSummary.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductSummary.php
index 1425405e9ee..9656ec0d0bf 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductSummary.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductSummary.php
@@ -2,6 +2,7 @@
namespace Automattic\WooCommerce\Blocks\BlockTypes;
+use Automattic\WooCommerce\Blocks\Utils\ProductDescriptionUtils;
use Automattic\WooCommerce\Blocks\Utils\StyleAttributesUtils;
/**
@@ -200,7 +201,12 @@ class ProductSummary extends AbstractBlock {
}
$show_description_if_empty = isset( $attributes['showDescriptionIfEmpty'] ) && $attributes['showDescriptionIfEmpty'];
- $source = $this->get_source( $product, $show_description_if_empty );
+ $source = ProductDescriptionUtils::guarded_format(
+ $product,
+ function () use ( $product, $show_description_if_empty ) {
+ return $this->get_source( $product, $show_description_if_empty );
+ }
+ );
if ( ! $source ) {
return '';
diff --git a/plugins/woocommerce/src/Blocks/Utils/ProductDescriptionUtils.php b/plugins/woocommerce/src/Blocks/Utils/ProductDescriptionUtils.php
new file mode 100644
index 00000000000..88d5fc96aa3
--- /dev/null
+++ b/plugins/woocommerce/src/Blocks/Utils/ProductDescriptionUtils.php
@@ -0,0 +1,42 @@
+<?php
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Blocks\Utils;
+
+/**
+ * Product description utilities.
+ */
+class ProductDescriptionUtils {
+
+ /**
+ * Product IDs whose descriptions are currently being formatted.
+ *
+ * @var array<int, true>
+ */
+ private static array $formatting_product_descriptions = array();
+
+ /**
+ * Format a product description while preventing same-product recursive formatting.
+ *
+ * @param \WC_Product $product Product object.
+ * @param callable $format_callback Callback that formats the product description.
+ * @return string Formatted product description.
+ */
+ public static function guarded_format( \WC_Product $product, callable $format_callback ): string {
+ $product_id = $product->get_id();
+
+ if ( isset( self::$formatting_product_descriptions[ $product_id ] ) ) {
+ return '';
+ }
+
+ self::$formatting_product_descriptions[ $product_id ] = true;
+
+ try {
+ $result = $format_callback();
+
+ return is_string( $result ) ? $result : '';
+ } finally {
+ unset( self::$formatting_product_descriptions[ $product_id ] );
+ }
+ }
+}
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/ProductSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/ProductSchema.php
index c9accd279b3..a2ece51e115 100644
--- a/plugins/woocommerce/src/StoreApi/Schemas/V1/ProductSchema.php
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/ProductSchema.php
@@ -6,6 +6,7 @@ use Automattic\WooCommerce\StoreApi\SchemaController;
use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
use Automattic\WooCommerce\StoreApi\Utilities\QuantityLimits;
use Automattic\WooCommerce\Blocks\Utils\ProductAvailabilityUtils;
+use Automattic\WooCommerce\Blocks\Utils\ProductDescriptionUtils;
use Automattic\WooCommerce\Enums\ProductStockStatus;
use Automattic\WooCommerce\Enums\StockDisplayFormat;
use Automattic\WooCommerce\Enums\TaxDisplayMode;
@@ -595,8 +596,22 @@ class ProductSchema extends AbstractSchema {
public function get_item_response( $product ) {
$availability = ProductAvailabilityUtils::get_product_availability( $product );
$password_required = post_password_required( $product->get_id() );
- $short_description = $password_required ? '' : $this->prepare_html_response( wc_format_content( wp_kses_post( $product->get_short_description() ) ) );
- $description = $password_required ? '' : $this->prepare_html_response( wc_format_content( wp_kses_post( $product->get_description() ) ) );
+ $short_description = $password_required ? '' : $this->prepare_html_response(
+ ProductDescriptionUtils::guarded_format(
+ $product,
+ function () use ( $product ) {
+ return wc_format_content( wp_kses_post( $product->get_short_description() ) );
+ }
+ )
+ );
+ $description = $password_required ? '' : $this->prepare_html_response(
+ ProductDescriptionUtils::guarded_format(
+ $product,
+ function () use ( $product ) {
+ return wc_format_content( wp_kses_post( $product->get_description() ) );
+ }
+ )
+ );
return [
'id' => $product->get_id(),
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Utils/ProductDescriptionUtilsTest.php b/plugins/woocommerce/tests/php/src/Blocks/Utils/ProductDescriptionUtilsTest.php
new file mode 100644
index 00000000000..3a8a0254b17
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/Utils/ProductDescriptionUtilsTest.php
@@ -0,0 +1,64 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\Utils;
+
+use Automattic\WooCommerce\Blocks\Utils\ProductDescriptionUtils;
+use WC_Helper_Product;
+
+/**
+ * Tests for ProductDescriptionUtils.
+ */
+class ProductDescriptionUtilsTest extends \WC_Unit_Test_Case {
+
+ /**
+ * @testdox guarded_format() returns the formatted product description.
+ */
+ public function test_guarded_format_returns_formatted_product_description(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_description( 'A formatted product description.' );
+ $product->save();
+
+ try {
+ $nested_result = null;
+ $result = ProductDescriptionUtils::guarded_format(
+ $product,
+ function () use ( $product, &$nested_result ) {
+ $nested_result = ProductDescriptionUtils::guarded_format(
+ $product,
+ function () use ( $product ) {
+ return $product->get_description();
+ }
+ );
+
+ return $product->get_description();
+ }
+ );
+
+ $this->assertSame( '', $nested_result );
+ $this->assertSame( 'A formatted product description.', $result );
+ } finally {
+ WC_Helper_Product::delete_product( $product->get_id() );
+ }
+ }
+
+ /**
+ * @testdox guarded_format() returns an empty string for non-string formatted values.
+ */
+ public function test_guarded_format_returns_empty_string_for_non_string_result(): void {
+ $product = WC_Helper_Product::create_simple_product();
+
+ try {
+ $result = ProductDescriptionUtils::guarded_format(
+ $product,
+ function () {
+ return array( 'not a string' );
+ }
+ );
+
+ $this->assertSame( '', $result );
+ } finally {
+ WC_Helper_Product::delete_product( $product->get_id() );
+ }
+ }
+}