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 ]&gt;
-		 */
-		// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
-		$description = apply_filters( 'the_content', str_replace( ']]>', ']]&gt;', $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 ]&gt;
+				 */
+				// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
+				return apply_filters( 'the_content', str_replace( ']]>', ']]&gt;', $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() );
+		}
+	}
+}