Commit 42e8d4405a8 for woocommerce

commit 42e8d4405a82bf23f1b942c94156e4bd1482614a
Author: Allison Levine <1689238+allilevine@users.noreply.github.com>
Date:   Fri Mar 13 10:43:40 2026 -0400

    Email Editor: Fix render hook leak in Initializer (#63666)

    * Email Editor: Fix render hook leak in Initializer

    Add an $initialized flag to Initializer::initialize() to prevent
    hooks (adjust_theme_json, allow_styles, reset_renderers) from being
    registered more than once. This fixes a hook leak where callbacks
    could accumulate in batch email scenarios.

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

    * Tests: Use spy subclass to verify reset_renderers() is called

    Replace generic closure counter with an Initializer_Spy that overrides
    reset_renderers() to count invocations, directly proving the method
    fires exactly once per render_start action.

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

    * Tests: Ensure spy cleanup runs even on assertion failure

    Wrap the spy test in try/finally so remove_action() always runs,
    preventing the spy from leaking into subsequent tests if the
    assertion fails.

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

    * Tests: Exercise multiple initializations and clean up all spy hooks

    Call spy.initialize() three times to match the "even after multiple
    initializations" docblock, and remove all three hooks (theme_json,
    allow_styles, reset_renderers) in the finally block to prevent leaks.

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

    * Tests: Remove no-op duplicate hooks test

    The priority-comparison test passes even without the $initialized
    guard because WordPress deduplicates identical object-method
    callbacks. The spy-based test already proves the guard works.

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

    ---------

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

diff --git a/packages/php/email-editor/changelog/fix-render-hook-leak-initializer b/packages/php/email-editor/changelog/fix-render-hook-leak-initializer
new file mode 100644
index 00000000000..a4972ee1be8
--- /dev/null
+++ b/packages/php/email-editor/changelog/fix-render-hook-leak-initializer
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent hook accumulation in email editor Initializer by guarding against duplicate hook registration.
diff --git a/packages/php/email-editor/src/Integrations/Core/class-initializer.php b/packages/php/email-editor/src/Integrations/Core/class-initializer.php
index 604d21ea686..bf9bf19ca09 100644
--- a/packages/php/email-editor/src/Integrations/Core/class-initializer.php
+++ b/packages/php/email-editor/src/Integrations/Core/class-initializer.php
@@ -85,10 +85,22 @@ class Initializer {
 	 */
 	private array $renderers = array();

+	/**
+	 * Whether hooks have already been registered.
+	 *
+	 * @var bool
+	 */
+	private bool $initialized = false;
+
 	/**
 	 * Initializes the core blocks renderers.
 	 */
 	public function initialize(): void {
+		if ( $this->initialized ) {
+			return;
+		}
+		$this->initialized = true;
+
 		add_filter( 'woocommerce_email_editor_theme_json', array( $this, 'adjust_theme_json' ), 10, 1 );
 		add_filter( 'safe_style_css', array( $this, 'allow_styles' ) );
 		add_action( 'woocommerce_email_editor_render_start', array( $this, 'reset_renderers' ) );
diff --git a/packages/php/email-editor/tests/integration/Integrations/Core/Initializer_Test.php b/packages/php/email-editor/tests/integration/Integrations/Core/Initializer_Test.php
new file mode 100644
index 00000000000..e27fcf69392
--- /dev/null
+++ b/packages/php/email-editor/tests/integration/Integrations/Core/Initializer_Test.php
@@ -0,0 +1,94 @@
+<?php
+/**
+ * This file is part of the WooCommerce Email Editor package
+ *
+ * @package Automattic\WooCommerce\EmailEditor
+ */
+
+declare( strict_types = 1 );
+namespace Automattic\WooCommerce\EmailEditor\Tests\Integration\Integrations\Core;
+
+use Automattic\WooCommerce\EmailEditor\Integrations\Core\Initializer;
+
+/**
+ * Integration test for Initializer class
+ */
+class Initializer_Test extends \Email_Editor_Integration_Test_Case {
+	/**
+	 * Initializer instance
+	 *
+	 * @var Initializer
+	 */
+	private Initializer $initializer;
+
+	/**
+	 * Set up before each test.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->initializer = new Initializer();
+	}
+
+	/**
+	 * Clean up after each test.
+	 */
+	public function tearDown(): void {
+		remove_filter( 'woocommerce_email_editor_theme_json', array( $this->initializer, 'adjust_theme_json' ) );
+		remove_filter( 'safe_style_css', array( $this->initializer, 'allow_styles' ) );
+		remove_action( 'woocommerce_email_editor_render_start', array( $this->initializer, 'reset_renderers' ) );
+		parent::tearDown();
+	}
+
+	/**
+	 * Test that initialize registers hooks.
+	 */
+	public function testInitializeRegistersHooks(): void {
+		$this->initializer->initialize();
+
+		$this->assertNotFalse( has_filter( 'woocommerce_email_editor_theme_json', array( $this->initializer, 'adjust_theme_json' ) ) );
+		$this->assertNotFalse( has_filter( 'safe_style_css', array( $this->initializer, 'allow_styles' ) ) );
+		$this->assertNotFalse( has_action( 'woocommerce_email_editor_render_start', array( $this->initializer, 'reset_renderers' ) ) );
+	}
+
+	/**
+	 * Test that reset_renderers fires exactly once per render_start action even after multiple initializations.
+	 */
+	public function testResetRenderersFiresOncePerRenderStart(): void {
+		$spy = new Initializer_Spy();
+		$spy->initialize();
+		$spy->initialize();
+		$spy->initialize();
+
+		try {
+			// Simulate multiple renders.
+			do_action( 'woocommerce_email_editor_render_start' );
+			do_action( 'woocommerce_email_editor_render_start' );
+
+			$this->assertSame( 2, $spy->reset_renderers_call_count );
+		} finally {
+			remove_filter( 'woocommerce_email_editor_theme_json', array( $spy, 'adjust_theme_json' ) );
+			remove_filter( 'safe_style_css', array( $spy, 'allow_styles' ) );
+			remove_action( 'woocommerce_email_editor_render_start', array( $spy, 'reset_renderers' ) );
+		}
+	}
+}
+
+/**
+ * Test spy that counts reset_renderers() calls.
+ */
+class Initializer_Spy extends Initializer { // phpcs:ignore -- Multiple classes needed for test spy.
+	/**
+	 * Number of times reset_renderers() was called.
+	 *
+	 * @var int
+	 */
+	public int $reset_renderers_call_count = 0;
+
+	/**
+	 * Override to count calls.
+	 */
+	public function reset_renderers(): void {
+		++$this->reset_renderers_call_count;
+		parent::reset_renderers();
+	}
+}