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();
+ }
+}