Commit d6dc8bb20bf for woocommerce

commit d6dc8bb20bfb923ae69b8e065fa12f090e588c3f
Author: Raluca Stan <ralucastn@gmail.com>
Date:   Fri Sep 18 10:35:40 2026 +0200

    Fix double block registration when a description renders before init (#68522)

    * Fix register_blocks() re-registering block types on init after an on-demand run

    register_blocks() set $register_blocks_has_run but never read it. Only the
    on-demand call site in Bootstrap checked the flag, so when an extension
    rendered a product description before init, the queued init callback ran
    the full registration a second time: every block class was constructed
    again and hooked a second set of callbacks, and the registry emitted a
    _doing_it_wrong notice per block.

    Return early from register_blocks() itself so both entry paths share the
    guard.

    * Stop expecting the block re-registration notice in the email-editor tests

    These tests fire init a second time in the same process and swallowed the
    WP_Block_Type_Registry::register notice that the second run produced. With
    register_blocks() guarding itself, blocks register once per process and the
    notice no longer fires, so the expectation would fail as untriggered.

    * Add a regression test for the init registration after an on-demand run

    Render a description with a block before init, then call register_blocks()
    as the queued init callback would. Count the enqueue_block_editor_assets
    callbacks, which every constructed block instance hooks, and assert the
    count is unchanged after the second call. Without the fix a double run
    hooks a second set of callbacks.

    * Add changelog entry for the register_blocks() double-run fix

diff --git a/plugins/woocommerce/changelog/fix-register-blocks-double-run b/plugins/woocommerce/changelog/fix-register-blocks-double-run
new file mode 100644
index 00000000000..83d3e7c4a23
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-register-blocks-double-run
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent WooCommerce block types from being registered twice when a product description is rendered before init.
diff --git a/plugins/woocommerce/src/Blocks/BlockTypesController.php b/plugins/woocommerce/src/Blocks/BlockTypesController.php
index 21c3ac94c89..2dac45e144d 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypesController.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypesController.php
@@ -131,8 +131,11 @@ 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.
+		if ( self::$register_blocks_has_run ) {
+			return;
+		}
+
+		// Set before registering rather than after, so a registration failure is not retried on a later call.
 		self::$register_blocks_has_run = true;
 		$this->register_block_metadata();
 		$block_types = $this->get_block_types();
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Domain/BootstrapTest.php b/plugins/woocommerce/tests/php/src/Blocks/Domain/BootstrapTest.php
index ae532bda43b..216ca11d94d 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/Domain/BootstrapTest.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/Domain/BootstrapTest.php
@@ -4,6 +4,7 @@ declare( strict_types = 1 );
 namespace Automattic\WooCommerce\Tests\Blocks\Domain;

 use Automattic\WooCommerce\Blocks\BlockTypesController;
+use Automattic\WooCommerce\Blocks\Package;
 use WC_Unit_Test_Case;
 use WP_Block_Type_Registry;

@@ -368,4 +369,51 @@ HTML;
 			'Block types should remain registered and must not be re-registered on demand.'
 		);
 	}
+
+	/**
+	 * @testdox The init registration is a no-op after a description registered the block types on demand before init.
+	 */
+	public function test_register_blocks_is_a_no_op_after_on_demand_registration(): void {
+		$registry   = WP_Block_Type_Registry::get_instance();
+		$controller = Package::container()->get( BlockTypesController::class );
+		$this->assertFalse( $registry->is_registered( self::SAMPLE_BLOCK ), 'Blocks should start unregistered.' );
+
+		// An extension renders a description before init on a request where register_blocks() is queued on init.
+		apply_filters( 'woocommerce_short_description', 'Intro <!-- wp:woocommerce/product-price /--> outro' );
+		$this->assertTrue( $controller->register_blocks_has_run(), 'The filter should have run register_blocks() on demand.' );
+		$this->assertTrue( $registry->is_registered( self::SAMPLE_BLOCK ), 'The filter should register block types on demand.' );
+		$hooked_block_instances = $this->count_block_editor_asset_callbacks();
+		$this->assertGreaterThan( 0, $hooked_block_instances, 'Each registered block instance should have hooked its editor assets.' );
+
+		// Then the queued init callback fires. The registry rejects duplicate block types with a doing_it_wrong
+		// notice (a test failure), but a second run would still construct every block class again and hook each
+		// new instance a second time.
+		$controller->register_blocks();
+
+		$this->assertSame(
+			$hooked_block_instances,
+			$this->count_block_editor_asset_callbacks(),
+			'A second register_blocks() call must not construct the block types again and hook duplicate callbacks.'
+		);
+		$this->assertTrue( $registry->is_registered( self::SAMPLE_BLOCK ), 'Block types should remain registered.' );
+	}
+
+	/**
+	 * Count the callbacks hooked to enqueue_block_editor_assets.
+	 *
+	 * Every block instance that register_blocks() constructs hooks its enqueue_editor_assets method there, so
+	 * the count grows by one per block type each time the registration runs.
+	 *
+	 * @return int Number of hooked callbacks.
+	 */
+	private function count_block_editor_asset_callbacks(): int {
+		global $wp_filter;
+
+		$count = 0;
+		foreach ( $wp_filter['enqueue_block_editor_assets']->callbacks ?? array() as $callbacks_at_priority ) {
+			$count += count( $callbacks_at_priority );
+		}
+
+		return $count;
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetectorTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetectorTest.php
index 21d78667701..33880e07257 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetectorTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetectorTest.php
@@ -563,12 +563,11 @@ class WCEmailTemplateDivergenceDetectorTest extends \WC_Unit_Test_Case {
 	 * `init`-time hooks (notably `WCEmailTemplateDivergenceDetector::register_meta`)
 	 * register on the global hook table, the `woo_email` post type is registered, and
 	 * `init` fires so the meta-registration callback runs. Swallows the doing-it-wrong
-	 * notices that the full chain triggers when re-registering already-registered
-	 * blocks / integrations during a unit-test process; those notices are unrelated
+	 * notice that the full chain triggers when re-registering already-registered
+	 * integrations during a unit-test process; that notice is unrelated
 	 * to the meta-registration wiring under test.
 	 */
 	private function initialize_email_editor_integration(): void {
-		$this->setExpectedIncorrectUsage( 'WP_Block_Type_Registry::register' );
 		$this->setExpectedIncorrectUsage( 'Automattic\WooCommerce\Blocks\Integrations\IntegrationRegistry::register' );

 		add_option( 'woocommerce_feature_block_email_editor_enabled', 'yes' );