Commit a341def78c3 for woocommerce

commit a341def78c3d35115a26830841ee467dc3ba1153
Author: Raluca Stan <ralucastn@gmail.com>
Date:   Wed Aug 12 15:32:43 2026 +0200

    Fix: register blocks on demand for product descriptions rendered via do_blocks (#66672)

    * fix: register blocks on demand for descriptions rendered via do_blocks

    PR #65781 skips WooCommerce block registration on non-rendering requests,
    but product and variation descriptions still run through do_blocks (via the
    woocommerce_short_description filter) on the products REST API, the Store API,
    the ?wc-ajax=get_variation endpoint and product webhooks. So a dynamic
    woocommerce/* block in a description rendered blank while the API returned 200.

    Register block types on demand from a woocommerce_short_description callback
    (priority 8, before do_blocks), only when the description actually contains a
    WooCommerce block, so the gate's fast path is preserved. register_blocks() is
    made idempotent so the eager and on-demand paths can both run.

    Closes #66511.

    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

    * update tests, comments, versions and function name

    * fix: register block types on demand for descriptions rendered via do_blocks

    The BlockRegistrationContext gate (#65781) skips WooCommerce block registration on
    non-rendering requests for performance, but product and variation descriptions
    still run through do_blocks (via woocommerce_short_description / wc_format_content)
    on the products REST API, the Store API, ?wc-ajax=get_variation and product
    webhooks — so a dynamic woocommerce/* block in a description rendered blank while
    the API returned 200.

    Register block types on demand from a woocommerce_short_description callback
    (priority 8, before do_blocks), guarded at the caller: register_blocks() only runs
    when woocommerce/product-price is not registered yet. register_blocks()
    instantiates every block class and wires their hooks, so this keeps it to at most
    once per request even though the filter can fire many times (e.g. one Store API
    cart response per item). The registry is the request-wide record of what is
    registered, so it stays correct across the multiple BlockTypesController instances
    a container rebuild can create, and register_blocks() itself is left unchanged.

    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

    * test: cover on-demand block registration across the affected endpoints

    Add BootstrapTest coverage: the woocommerce_short_description filter registers
    block types when a description contains a block, skips plain content, skips when
    blocks are already registered, and real requests through the products REST API and
    the Store API cart trigger on-demand registration. Add a ?wc-ajax=get_variation
    test for the variation description path. Assertions target a foundational,
    unconditional block (woocommerce/product-price) so they stay stable regardless of
    theme- or feature-gated blocks in the registry.

    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

    * refactor: check the registry before scanning content in on-demand block registration

    Check whether block types are already registered before scanning the description
    content, so every fire after registration short-circuits without a strpos over the
    content. Tighten the surrounding comments and add the missing @testdox to the
    get_variation AJAX test.

    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

    * refactor: track block registration with a flag, not a registry probe

    * fix: make block-registration flag static so it survives container resets

    The per-instance flag broke under the full test suite: MainFile resets the
    Blocks container (Package::container(true)) and re-runs Bootstrap::init(),
    leaving the live woocommerce_short_description callback bound to a different
    BlockTypesController than the one under test. Whether the blocks are
    registered is a process-global fact that mirrors the WP block registry, so
    track it in a static property shared across all instances.

    * Make on-demand block detection tolerate parser whitespace variants

    The block parser grammar allows any run of whitespace after the comment
    opener ('<!--\s+wp:'), so a strict substring check missed formatting
    variants like extra spaces or a newline; match the grammar instead.

    * Register block types when a description references a synced pattern

    A wp:block reference resolves to pattern content that is not available
    at detection time, so the on-demand registration fires defensively; a
    registered block set covers nested WooCommerce blocks at any depth.

    * Pin on-demand registration ordering test to do_blocks priority

    Asserting only our hardcoded priority 8 would keep passing if do_blocks
    moved to an earlier priority while the fix silently stopped working;
    compare the two registered priorities instead.

    * Add rendered-output coverage for description blocks in wc/v3 responses

    The existing tests only assert is_registered; this one checks the
    symptom users see, asserting the response carries the processed
    accordion markup and no serialized block comments. Uses the same block
    as the manual testing instructions.

    * Use the accordion block in whitespace variant tests, trim docblock

    The whitespace variants now use the accordion as a simple void tag,
    matching the block the manual testing instructions use; the detection
    docblock keeps only the synced pattern note.

    ---------

    Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/fix-block-registration-products-endpoints b/plugins/woocommerce/changelog/fix-block-registration-products-endpoints
new file mode 100644
index 00000000000..0a3566fd8eb
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-block-registration-products-endpoints
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Register WooCommerce block types on demand when a product or variation description is rendered through do_blocks, so dynamic blocks placed in a description no longer render blank in the products REST API, the Store API, the variation AJAX endpoint or product webhooks.
diff --git a/plugins/woocommerce/src/Blocks/BlockTypesController.php b/plugins/woocommerce/src/Blocks/BlockTypesController.php
index f59b01ed6a6..3171d79d05a 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypesController.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypesController.php
@@ -41,6 +41,18 @@ final class BlockTypesController {
 	 */
 	private $registered_blocks_with_woocommerce_parents;

+	/**
+	 * Whether register_blocks() has run in this request.
+	 *
+	 * Static because it mirrors the WordPress block-type registry, which is a process-global singleton: once
+	 * any controller has registered the blocks they are registered for the whole request, regardless of which
+	 * container instance owns the controller. Only tracks the AbstractBlock-based block types registered by
+	 * register_blocks(); blocks registered through other paths are not reflected here.
+	 *
+	 * @var bool
+	 */
+	private static $register_blocks_has_run = false;
+
 	/**
 	 * Constructor.
 	 *
@@ -112,6 +124,9 @@ final class BlockTypesController {
 	 * Register blocks, hooking up assets and render functions as needed.
 	 */
 	public function register_blocks() {
+		// Set before registering rather than after: it guards against re-entry through the on-demand
+		// registration in Bootstrap, and a registration failure must not be retried on later filter fires.
+		self::$register_blocks_has_run = true;
 		$this->register_block_metadata();
 		$block_types = $this->get_block_types();

@@ -122,6 +137,22 @@ final class BlockTypesController {
 		}
 	}

+	/**
+	 * Whether register_blocks() has run in this request.
+	 *
+	 * Covers only the AbstractBlock-based block types that register_blocks() registers — blocks registered
+	 * through other paths are not tracked. Used by the on-demand registration on the
+	 * woocommerce_short_description filter (see Bootstrap::maybe_register_blocks_from_content) to avoid
+	 * re-registering the block set when eager registration already ran on init.
+	 *
+	 * @since 11.1.0
+	 *
+	 * @return bool True if register_blocks() has already run.
+	 */
+	public function register_blocks_has_run() {
+		return self::$register_blocks_has_run;
+	}
+
 	/**
 	 * Register block metadata collections for WooCommerce blocks.
 	 *
diff --git a/plugins/woocommerce/src/Blocks/Domain/BlockRegistrationContext.php b/plugins/woocommerce/src/Blocks/Domain/BlockRegistrationContext.php
index fc9476ba4f0..4ce63b86f62 100644
--- a/plugins/woocommerce/src/Blocks/Domain/BlockRegistrationContext.php
+++ b/plugins/woocommerce/src/Blocks/Domain/BlockRegistrationContext.php
@@ -30,7 +30,9 @@ class BlockRegistrationContext {
 		 *
 		 * Registration is skipped on known non-rendering contexts (the Store API and other WooCommerce REST
 		 * namespaces, cron, AJAX, XML-RPC, favicon, robots.txt and XML sitemaps) as a performance optimisation.
-		 * An extension that renders WooCommerce blocks in one of those contexts can return true here to opt back in.
+		 * Product and variation descriptions rendered through do_blocks are already handled on demand (see the
+		 * woocommerce_short_description hook in Bootstrap), so this filter is only needed to opt back in when an
+		 * extension renders WooCommerce blocks some other way in one of those contexts.
 		 *
 		 * @since 11.1.0
 		 *
@@ -49,7 +51,8 @@ class BlockRegistrationContext {
 	 * @return bool True unless the request is a known non-rendering context.
 	 */
 	private function is_rendering_request(): bool {
-		// Store API renders no blocks.
+		// The Store API returns data, not rendered pages; description blocks are registered on demand instead
+		// (see the woocommerce_short_description hook in Bootstrap).
 		if ( wc()->is_store_api_request() ) {
 			return false;
 		}
diff --git a/plugins/woocommerce/src/Blocks/Domain/Bootstrap.php b/plugins/woocommerce/src/Blocks/Domain/Bootstrap.php
index 51b8c2fc52f..2bb55635556 100644
--- a/plugins/woocommerce/src/Blocks/Domain/Bootstrap.php
+++ b/plugins/woocommerce/src/Blocks/Domain/Bootstrap.php
@@ -145,9 +145,13 @@ class Bootstrap {
 			$this->container->get( is_admin() ? CheckoutFieldsAdmin::class : CheckoutFieldsFrontend::class )->init();
 		}

+		// Register block types on demand (priority 8, before do_blocks at 9) so blocks in a description are not empty.
+		add_filter( 'woocommerce_short_description', array( $this, 'maybe_register_blocks_from_content' ), 8 );
+
 		// Load assets unless this is a request specifically for the store API.
 		if ( ! $is_store_api_request ) {
-			// Skip block/pattern registration on non-rendering requests. See BlockRegistrationContext.
+			// Skip eager block/pattern/asset registration on non-rendering requests; the block types needed for
+			// a description block are still registered on demand (see the hook above). See BlockRegistrationContext.
 			if ( ( new BlockRegistrationContext() )->should_register() ) {
 				$this->container->get( BlockPatterns::class );
 				$this->container->get( BlockTypesController::class );
@@ -165,6 +169,33 @@ class Bootstrap {
 		}
 	}

+	/**
+	 * Register WooCommerce block types on demand when a description containing one is rendered.
+	 *
+	 * Eager block registration is skipped on non-rendering requests (Store API, REST, AJAX, webhooks), but
+	 * product and variation descriptions still run through do_blocks there, so a WooCommerce block in a
+	 * description would render empty. Hooked to woocommerce_short_description just before do_blocks.
+	 *
+	 * The detection also fires on a synced pattern reference (core/block, `wp:block`): the referenced pattern's
+	 * content is not available here without fetching it, so registration happens defensively in case it
+	 * contains a WooCommerce block; registering covers nested blocks at any depth.
+	 *
+	 * @since 11.1.0
+	 *
+	 * @param string $content The description content passed through the filter.
+	 * @return string The unchanged content.
+	 */
+	public function maybe_register_blocks_from_content( $content ) {
+		if ( is_string( $content ) && preg_match( '/<!--\s+wp:(woocommerce\/|block\s)/', $content ) ) {
+			$block_types_controller = $this->container->get( BlockTypesController::class );
+			if ( ! $block_types_controller->register_blocks_has_run() ) {
+				$block_types_controller->register_blocks();
+			}
+		}
+
+		return $content;
+	}
+
 	/**
 	 * See if files have been built or not.
 	 *
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php b/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
index cd2e4d28fbe..11ef63149e4 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
@@ -822,6 +822,108 @@ class WC_AJAX_Test extends \WP_Ajax_UnitTestCase {
 		unset( $_POST['security'], $_POST['order_id'], $_POST['refund_amount'], $_POST['refunded_amount'], $_POST['refund_reason'], $_POST['line_item_qtys'], $_POST['line_item_totals'], $_POST['line_item_tax_totals'], $_POST['api_refund'] );
 	}

+	/**
+	 * The ?wc-ajax=get_variation endpoint renders the matched variation's description through
+	 * wc_format_content(), which fires the woocommerce_short_description filter. Eager block registration is
+	 * skipped on AJAX requests, so Bootstrap registers WooCommerce block types on demand there — otherwise a
+	 * block in a variation description would render empty. See Bootstrap::maybe_register_blocks_from_content.
+	 *
+	 * @testdox The get_variation AJAX endpoint registers WooCommerce block types on demand for a variation description block.
+	 */
+	public function test_get_variation_registers_block_types_on_demand_for_description(): void {
+		$registry = WP_Block_Type_Registry::get_instance();
+
+		// Snapshot and unregister WooCommerce blocks so this test mirrors a request whose eager registration
+		// was skipped; on-demand registration should then re-register them when the description is rendered.
+		$snapshot = array();
+		foreach ( $registry->get_all_registered() as $name => $block_type ) {
+			if ( 0 === strpos( $name, 'woocommerce/' ) ) {
+				$snapshot[ $name ] = $block_type;
+				$registry->unregister( $name );
+			}
+		}
+
+		// The on-demand registration asks the shared BlockTypesController whether register_blocks() already ran
+		// this request; the test bootstrap ran it once for the whole PHPUnit process, so clear the flag too.
+		$this->set_register_blocks_has_run_flag( false );
+
+		// A foundational block register_blocks() always registers (not gated behind a theme/feature flag).
+		$sample = 'woocommerce/product-price';
+		$this->assertNotEmpty( $snapshot, 'The test bootstrap should have registered WooCommerce blocks to snapshot.' );
+
+		$posted_keys = array();
+
+		try {
+			$product   = WC_Helper_Product::create_variation_product();
+			$children  = $product->get_children();
+			$variation = wc_get_product( $children[0] );
+			$variation->set_description( '<!-- wp:woocommerce/product-price /-->' );
+			$variation->save();
+
+			$_POST['product_id'] = $product->get_id();
+			$posted_keys[]       = 'product_id';
+			foreach ( $variation->get_attributes() as $attribute_name => $attribute_value ) {
+				$key           = 'attribute_' . $attribute_name;
+				$_POST[ $key ] = $attribute_value;
+				$posted_keys[] = $key;
+			}
+
+			$this->assertFalse( $registry->is_registered( $sample ), 'Blocks should start unregistered for this test.' );
+
+			$response = $this->do_ajax( 'woocommerce_get_variation' );
+
+			$this->assertIsArray( $response, 'The get_variation endpoint should return the matched variation.' );
+			$this->assertSame(
+				$variation->get_id(),
+				$response['variation_id'],
+				'The endpoint should match the variation carrying the block description.'
+			);
+			$this->assertTrue(
+				$registry->is_registered( $sample ),
+				'Hitting ?wc-ajax=get_variation should register block types on demand so a variation description block renders.'
+			);
+		} finally {
+			foreach ( $posted_keys as $key ) {
+				unset( $_POST[ $key ] );
+			}
+
+			// Delete the created posts so they do not leak into later tests. Guarded because
+			// create_variation_product() could throw before either is assigned.
+			if ( isset( $variation ) ) {
+				$variation->delete( true );
+			}
+			if ( isset( $product ) ) {
+				$product->delete( true );
+			}
+
+			foreach ( array_keys( $registry->get_all_registered() ) as $name ) {
+				if ( 0 === strpos( (string) $name, 'woocommerce/' ) ) {
+					$registry->unregister( $name );
+				}
+			}
+			foreach ( $snapshot as $block_type ) {
+				$registry->register( $block_type );
+			}
+			$this->set_register_blocks_has_run_flag( true );
+		}
+	}
+
+	/**
+	 * Set the static registration flag on BlockTypesController.
+	 *
+	 * The flag records whether register_blocks() ran in the current request, and Bootstrap's on-demand block
+	 * registration consults it. Tests that simulate a request whose eager registration was skipped must clear
+	 * it alongside unregistering the block types, and restore it afterwards. It is static, so this sets it on
+	 * the class, not on any one container instance.
+	 *
+	 * @param bool $has_run The flag value to set.
+	 */
+	private function set_register_blocks_has_run_flag( bool $has_run ): void {
+		$property = new \ReflectionProperty( \Automattic\WooCommerce\Blocks\BlockTypesController::class, 'register_blocks_has_run' );
+		$property->setAccessible( true );
+		$property->setValue( null, $has_run );
+	}
+
 	/**
 	 * Does the 'hard work' of triggering an ajax endpoint and capturing the response.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Domain/BootstrapTest.php b/plugins/woocommerce/tests/php/src/Blocks/Domain/BootstrapTest.php
new file mode 100644
index 00000000000..11da8dcc349
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/Domain/BootstrapTest.php
@@ -0,0 +1,351 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\Domain;
+
+use Automattic\WooCommerce\Blocks\BlockTypesController;
+use WC_Unit_Test_Case;
+use WP_Block_Type_Registry;
+
+/**
+ * Tests for the on-demand WooCommerce block-type registration wired up by Bootstrap.
+ *
+ * Product and variation descriptions are run through do_blocks on the woocommerce_short_description filter in
+ * contexts where eager block registration is skipped (the products REST endpoints, the Store API schemas, the
+ * variation AJAX endpoint and product webhooks). Bootstrap registers block types on demand there so those blocks
+ * do not render empty.
+ */
+class BootstrapTest extends WC_Unit_Test_Case {
+
+	/**
+	 * A foundational WooCommerce block that register_blocks() always registers (it is not gated behind a theme
+	 * or feature flag), so it is a stable subject for the on-demand registration assertions.
+	 *
+	 * @var string
+	 */
+	private const SAMPLE_BLOCK = 'woocommerce/product-price';
+
+	/**
+	 * Snapshot of the WooCommerce block types registered before each test, restored afterwards so the shared
+	 * global registry is not left in a modified state for other tests.
+	 *
+	 * @var array<string, \WP_Block_Type>
+	 */
+	private array $registered_woo_blocks = array();
+
+	/**
+	 * Snapshot and unregister the WooCommerce blocks, and clear the BlockTypesController registration flag, so
+	 * each test starts from a state that mirrors a fresh request whose eager block registration was skipped.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$registry = WP_Block_Type_Registry::get_instance();
+		foreach ( $registry->get_all_registered() as $name => $block_type ) {
+			if ( 0 === strpos( $name, 'woocommerce/' ) ) {
+				$this->registered_woo_blocks[ $name ] = $block_type;
+				$registry->unregister( $name );
+			}
+		}
+
+		$this->set_register_blocks_has_run_flag( false );
+	}
+
+	/**
+	 * Restore the exact set of WooCommerce blocks that was registered before the test ran, and mark them as
+	 * registered again on the shared BlockTypesController so later tests see a consistent state.
+	 */
+	public function tearDown(): void {
+		$registry = WP_Block_Type_Registry::get_instance();
+		foreach ( array_keys( $registry->get_all_registered() ) as $name ) {
+			if ( 0 === strpos( (string) $name, 'woocommerce/' ) ) {
+				$registry->unregister( $name );
+			}
+		}
+		foreach ( $this->registered_woo_blocks as $block_type ) {
+			$registry->register( $block_type );
+		}
+		$this->registered_woo_blocks = array();
+
+		$this->set_register_blocks_has_run_flag( true );
+
+		parent::tearDown();
+	}
+
+	/**
+	 * Set the static registration flag on BlockTypesController.
+	 *
+	 * The flag records whether register_blocks() ran in the current request. The test bootstrap registers the
+	 * blocks once for the whole PHPUnit process, so simulating a request whose eager registration was skipped
+	 * requires clearing the flag alongside unregistering the block types. It is static (see the property
+	 * docblock), so this sets it on the class, not on any one container instance.
+	 *
+	 * @param bool $has_run The flag value to set.
+	 */
+	private function set_register_blocks_has_run_flag( bool $has_run ): void {
+		$property = new \ReflectionProperty( BlockTypesController::class, 'register_blocks_has_run' );
+		$property->setAccessible( true );
+		$property->setValue( null, $has_run );
+	}
+
+	/**
+	 * @testdox Bootstrap hooks on-demand block-type registration to woocommerce_short_description before do_blocks.
+	 */
+	public function test_short_description_registration_is_hooked_before_do_blocks(): void {
+		global $wp_filter;
+
+		$this->assertArrayHasKey( 'woocommerce_short_description', $wp_filter, 'The filter should have registered callbacks.' );
+
+		$do_blocks_priority = has_filter( 'woocommerce_short_description', 'do_blocks' );
+		$this->assertNotFalse( $do_blocks_priority, 'do_blocks should be hooked to woocommerce_short_description.' );
+
+		$registration_priority = false;
+		foreach ( $wp_filter['woocommerce_short_description']->callbacks as $priority => $callbacks ) {
+			foreach ( $callbacks as $callback ) {
+				if (
+					is_array( $callback['function'] )
+					&& is_object( $callback['function'][0] ?? null )
+					&& 'maybe_register_blocks_from_content' === ( $callback['function'][1] ?? '' )
+				) {
+					$registration_priority = $priority;
+					break 2;
+				}
+			}
+		}
+
+		$this->assertNotFalse( $registration_priority, 'Bootstrap should hook on-demand block registration to woocommerce_short_description.' );
+		$this->assertLessThan(
+			$do_blocks_priority,
+			$registration_priority,
+			'On-demand block registration must run at an earlier priority than do_blocks, or description blocks render empty.'
+		);
+	}
+
+	/**
+	 * @testdox Filtering a description that contains a WooCommerce block registers block types that were missing.
+	 */
+	public function test_short_description_filter_registers_missing_block_types(): void {
+		$registry = WP_Block_Type_Registry::get_instance();
+
+		$this->assertNotEmpty( $this->registered_woo_blocks, 'The test bootstrap should have registered WooCommerce blocks to snapshot.' );
+		$this->assertFalse( $registry->is_registered( self::SAMPLE_BLOCK ), 'WooCommerce blocks should start unregistered for this test.' );
+
+		// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Firing an existing core filter to exercise its callbacks, not declaring a new hook.
+		apply_filters( 'woocommerce_short_description', 'Intro <!-- wp:woocommerce/product-price /--> outro' );
+
+		$this->assertTrue(
+			$registry->is_registered( self::SAMPLE_BLOCK ),
+			'Block types should be registered on demand when a description containing a block is rendered.'
+		);
+	}
+
+	/**
+	 * Block markup variants the block parser accepts: its grammar allows any run of whitespace after the
+	 * comment opener (`<!--\s+wp:`), so the detection must tolerate the same formatting.
+	 *
+	 * @return array<string, array{string}>
+	 */
+	public function block_markup_whitespace_variants(): array {
+		return array(
+			'extra spaces after opener' => array( '<!--  wp:woocommerce/accordion-group /-->' ),
+			'newline after opener'      => array( "<!--\nwp:woocommerce/accordion-group /-->" ),
+			'tab after opener'          => array( "<!--\twp:woocommerce/accordion-group /-->" ),
+		);
+	}
+
+	/**
+	 * @testdox Detection tolerates the whitespace variants the block parser accepts.
+	 * @dataProvider block_markup_whitespace_variants
+	 *
+	 * @param string $markup Block markup with non-canonical whitespace.
+	 */
+	public function test_short_description_filter_detects_blocks_regardless_of_whitespace( string $markup ): void {
+		$registry = WP_Block_Type_Registry::get_instance();
+
+		$this->assertFalse( $registry->is_registered( 'woocommerce/accordion-group' ), 'WooCommerce blocks should start unregistered for this test.' );
+
+		// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Firing an existing core filter to exercise its callbacks, not declaring a new hook.
+		apply_filters( 'woocommerce_short_description', $markup );
+
+		$this->assertTrue(
+			$registry->is_registered( 'woocommerce/accordion-group' ),
+			'Detection should accept any whitespace the block parser accepts after the comment opener.'
+		);
+	}
+
+	/**
+	 * @testdox A description referencing a synced pattern registers block types defensively.
+	 */
+	public function test_short_description_filter_registers_block_types_for_synced_pattern_reference(): void {
+		$registry = WP_Block_Type_Registry::get_instance();
+
+		$this->assertFalse( $registry->is_registered( self::SAMPLE_BLOCK ), 'WooCommerce blocks should start unregistered for this test.' );
+
+		// The referenced pattern's content cannot be inspected without fetching it, so a wp:block reference
+		// must register the block types in case the pattern contains a WooCommerce block at any depth.
+		// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Firing an existing core filter to exercise its callbacks, not declaring a new hook.
+		apply_filters( 'woocommerce_short_description', '<!-- wp:block {"ref":129} /-->' );
+
+		$this->assertTrue(
+			$registry->is_registered( self::SAMPLE_BLOCK ),
+			'A synced pattern reference should register block types, since the pattern may contain WooCommerce blocks.'
+		);
+	}
+
+	/**
+	 * @testdox Filtering a description without WooCommerce block markup does not register block types.
+	 */
+	public function test_short_description_filter_skips_registration_for_plain_content(): void {
+		$registry = WP_Block_Type_Registry::get_instance();
+
+		$this->assertNotEmpty( $this->registered_woo_blocks, 'The test bootstrap should have registered WooCommerce blocks to snapshot.' );
+		$this->assertFalse( $registry->is_registered( self::SAMPLE_BLOCK ), 'WooCommerce blocks should start unregistered for this test.' );
+
+		// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Firing an existing core filter to exercise its callbacks, not declaring a new hook.
+		apply_filters( 'woocommerce_short_description', 'Just plain text with no blocks, or only a core <!-- wp:paragraph -->.' );
+
+		$this->assertFalse(
+			$registry->is_registered( self::SAMPLE_BLOCK ),
+			'A description without WooCommerce block markup should stay on the fast path and register nothing.'
+		);
+
+		// Without whitespace after the comment opener this is not a block per the parser grammar (`<!--\s+wp:`),
+		// so do_blocks would leave it untouched and registration would be wasted.
+		// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Firing an existing core filter to exercise its callbacks, not declaring a new hook.
+		apply_filters( 'woocommerce_short_description', '<!--wp:woocommerce/product-price /-->' );
+
+		$this->assertFalse(
+			$registry->is_registered( self::SAMPLE_BLOCK ),
+			'Markup the block parser does not recognise as a block should not trigger registration.'
+		);
+	}
+
+	/**
+	 * @testdox Fetching a product through the wc/v3 REST API registers block types on demand for its description.
+	 */
+	public function test_products_rest_request_registers_block_types_on_demand(): void {
+		$registry = WP_Block_Type_Registry::get_instance();
+
+		$product = new \WC_Product_Simple();
+		$product->set_short_description( '<!-- wp:woocommerce/product-price /-->' );
+		$product->save();
+
+		$this->assertFalse( $registry->is_registered( self::SAMPLE_BLOCK ), 'Blocks should start unregistered for this test.' );
+
+		wp_set_current_user( $this->factory->user->create( array( 'role' => 'administrator' ) ) );
+		$response = rest_do_request( new \WP_REST_Request( 'GET', '/wc/v3/products/' . $product->get_id() ) );
+
+		$this->assertSame( 200, $response->get_status(), 'The products REST request should succeed.' );
+		$this->assertTrue(
+			$registry->is_registered( self::SAMPLE_BLOCK ),
+			'Fetching a product through the REST API should register block types on demand so description blocks render.'
+		);
+
+		$product->delete( true );
+	}
+
+	/**
+	 * @testdox The wc/v3 product response carries the rendered output of a description block, not empty markup.
+	 */
+	public function test_products_rest_response_contains_rendered_block_output(): void {
+		// The accordion renders complete markup server-side with no post or cart context, so it works in a
+		// REST request; blocks like mini-cart or product-price render empty there regardless of registration
+		// and cannot show the difference. Same block the manual testing instructions use.
+		$markup = <<<'HTML'
+<!-- wp:woocommerce/accordion-group -->
+<div class="wp-block-woocommerce-accordion-group"><!-- wp:woocommerce/accordion-item -->
+<div class="wp-block-woocommerce-accordion-item"><!-- wp:woocommerce/accordion-header -->
+<h3 class="wp-block-woocommerce-accordion-header accordion-item__heading"><button class="accordion-item__toggle"><span>Care instructions</span><span class="accordion-item__toggle-icon" style="width:1.2em;height:1.2em"></span></button></h3>
+<!-- /wp:woocommerce/accordion-header -->
+
+<!-- wp:woocommerce/accordion-panel -->
+<div class="wp-block-woocommerce-accordion-panel"><div class="accordion-content__wrapper"><!-- wp:paragraph -->
+<p>Machine wash cold, tumble dry low.</p>
+<!-- /wp:paragraph --></div></div>
+<!-- /wp:woocommerce/accordion-panel --></div>
+<!-- /wp:woocommerce/accordion-item --></div>
+<!-- /wp:woocommerce/accordion-group -->
+HTML;
+
+		$product = new \WC_Product_Simple();
+		$product->set_short_description( $markup );
+		$product->save();
+
+		wp_set_current_user( $this->factory->user->create( array( 'role' => 'administrator' ) ) );
+		$response = rest_do_request( new \WP_REST_Request( 'GET', '/wc/v3/products/' . $product->get_id() ) );
+
+		$this->assertSame( 200, $response->get_status(), 'The products REST request should succeed.' );
+
+		$short_description = $response->get_data()['short_description'] ?? '';
+		$this->assertStringContainsString(
+			'data-wp-interactive="woocommerce/accordion"',
+			$short_description,
+			'The short description should contain the accordion markup processed by its render callback.'
+		);
+		$this->assertStringContainsString(
+			'Machine wash cold, tumble dry low.',
+			$short_description,
+			'The short description should contain the accordion panel content.'
+		);
+		$this->assertStringNotContainsString(
+			'<!-- wp:',
+			$short_description,
+			'The serialized block comments should have been consumed by do_blocks.'
+		);
+
+		$product->delete( true );
+	}
+
+	/**
+	 * @testdox Fetching the cart through the Store API registers block types on demand for an item's description.
+	 */
+	public function test_store_api_cart_request_registers_block_types_on_demand(): void {
+		$registry = WP_Block_Type_Registry::get_instance();
+
+		$product = new \WC_Product_Simple();
+		$product->set_regular_price( '10' );
+		$product->set_short_description( '<!-- wp:woocommerce/product-price /-->' );
+		$product->save();
+
+		$cart_item_key = wc()->cart->add_to_cart( $product->get_id() );
+		$this->assertNotEmpty( $cart_item_key, 'The product should be added to the cart so the item renders in the response.' );
+
+		$this->assertFalse( $registry->is_registered( self::SAMPLE_BLOCK ), 'Blocks should start unregistered for this test.' );
+
+		$response = rest_do_request( new \WP_REST_Request( 'GET', '/wc/store/v1/cart' ) );
+
+		$this->assertSame( 200, $response->get_status(), 'The Store API cart request should succeed.' );
+		$this->assertTrue(
+			$registry->is_registered( self::SAMPLE_BLOCK ),
+			'Fetching the cart through the Store API should register block types on demand so description blocks render.'
+		);
+
+		wc()->cart->empty_cart();
+		$product->delete( true );
+	}
+
+	/**
+	 * @testdox Filtering a description does not re-register block types when they are already registered.
+	 */
+	public function test_short_description_filter_skips_registration_when_blocks_already_registered(): void {
+		$registry = WP_Block_Type_Registry::get_instance();
+
+		// Restore the blocks and the controller flag so the request looks like a normal one whose eager
+		// registration already ran.
+		foreach ( $this->registered_woo_blocks as $block_type ) {
+			$registry->register( $block_type );
+		}
+		$this->set_register_blocks_has_run_flag( true );
+		$this->assertTrue( $registry->is_registered( self::SAMPLE_BLOCK ), 'Blocks should be registered before the filter runs.' );
+
+		// Firing the filter must not call register_blocks() again — doing so would re-register already-registered
+		// block types and trigger a doing_it_wrong failure, which WC_Unit_Test_Case turns into a test failure.
+		// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Firing an existing core filter to exercise its callbacks, not declaring a new hook.
+		apply_filters( 'woocommerce_short_description', 'Intro <!-- wp:woocommerce/product-price /--> outro' );
+
+		$this->assertTrue(
+			$registry->is_registered( self::SAMPLE_BLOCK ),
+			'Block types should remain registered and must not be re-registered on demand.'
+		);
+	}
+}