Commit 1bf6ccbc432 for woocommerce
commit 1bf6ccbc43248b223db4fe8ecd88b12a00ebdf4a
Author: Rostislav Wolný <1082140+costasovo@users.noreply.github.com>
Date: Tue Sep 1 09:35:14 2026 +0200
[Email Editor] Fix broken editor when Yoast SEO Premium is active (#68150)
* Fix email editor breaking when replace_editor filter re-enters
Yoast SEO Premium calls WP_Screen::get()->is_block_editor() from
admin_enqueue_scripts, and the no-arg WP_Screen::get() re-applies the
replace_editor filter on every call. The email editor renders from that
filter, and its render loads wp-admin/admin-header.php, which fires
admin_enqueue_scripts — so the callback re-entered mid-render and echoed
a second editor container inside <head>. The browser closed <head>
early, and the empty duplicate container overlaid the editor and
absorbed every click.
diff --git a/packages/php/email-editor/changelog/fix-yoast-replace-editor-reentry b/packages/php/email-editor/changelog/fix-yoast-replace-editor-reentry
new file mode 100644
index 00000000000..48b8c257075
--- /dev/null
+++ b/packages/php/email-editor/changelog/fix-yoast-replace-editor-reentry
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Render the email editor HTML only once per request — a plugin calling WP_Screen::get() from admin_enqueue_scripts (e.g. Yoast SEO Premium) re-fires the replace_editor filter mid-render and echoed a second editor container that broke the editor
diff --git a/packages/php/email-editor/src/Engine/class-assets-manager.php b/packages/php/email-editor/src/Engine/class-assets-manager.php
index 555fdb5087b..bfd59d371f2 100644
--- a/packages/php/email-editor/src/Engine/class-assets-manager.php
+++ b/packages/php/email-editor/src/Engine/class-assets-manager.php
@@ -56,6 +56,13 @@ class Assets_Manager {
*/
private Email_Editor_Logger $logger;
+ /**
+ * Whether the email editor HTML has been rendered.
+ *
+ * @var bool
+ */
+ private bool $editor_html_rendered = false;
+
/**
* Assets Manager constructor with all dependencies.
*
@@ -124,9 +131,18 @@ class Assets_Manager {
/**
* Render the email editor's required HTML and admin header.
*
+ * Renders at most once per instance; repeated calls are no-ops.
+ *
* @param string $element_id Optional. The ID of the main container element. Default is 'woocommerce-email-editor'.
*/
public function render_email_editor_html( string $element_id = 'woocommerce-email-editor' ): void {
+ // Integrations render from the `replace_editor` filter, which re-fires while
+ // admin-header.php runs whenever a plugin calls WP_Screen::get() from
+ // admin_enqueue_scripts; a second container echoed inside <head> breaks the page.
+ if ( $this->editor_html_rendered ) {
+ return;
+ }
+ $this->editor_html_rendered = true;
// @phpstan-ignore-next-line -- PHPStan tried to check if the file exists.
require_once ABSPATH . 'wp-admin/admin-header.php';
echo '<div id="' . esc_attr( $element_id ) . '" class="block-editor block-editor__container hide-if-no-js"></div>';
diff --git a/packages/php/email-editor/tests/unit/Engine/Assets_Manager_Test.php b/packages/php/email-editor/tests/unit/Engine/Assets_Manager_Test.php
new file mode 100644
index 00000000000..9ebe3cf84e8
--- /dev/null
+++ b/packages/php/email-editor/tests/unit/Engine/Assets_Manager_Test.php
@@ -0,0 +1,61 @@
+<?php
+/**
+ * This file is part of the WooCommerce Email Editor package
+ *
+ * @package Automattic\WooCommerce\EmailEditor
+ */
+
+declare(strict_types = 1);
+namespace Automattic\WooCommerce\EmailEditor\Engine;
+
+use Automattic\WooCommerce\EmailEditor\Engine\Logger\Email_Editor_Logger;
+
+/**
+ * Unit test class for Assets_Manager.
+ */
+class Assets_Manager_Test extends \Email_Editor_Unit_Test {
+ /**
+ * Test that render_email_editor_html outputs the editor container once and nothing on repeated calls.
+ *
+ * Integrations render from the `replace_editor` filter, which can re-enter while
+ * admin-header.php runs (a plugin calling WP_Screen::get() from admin_enqueue_scripts
+ * re-fires the filter); the second call must not echo another editor container.
+ */
+ public function testItRendersEditorHtmlOnlyOnce(): void {
+ $this->define_abspath_with_stub_admin_header();
+
+ $assets_manager = new Assets_Manager(
+ $this->createMock( Settings_Controller::class ),
+ $this->createMock( Theme_Controller::class ),
+ $this->createMock( User_Theme::class ),
+ $this->createMock( Email_Editor_Logger::class )
+ );
+
+ ob_start();
+ $assets_manager->render_email_editor_html();
+ $first_output = ob_get_clean();
+ $this->assertStringContainsString( 'id="woocommerce-email-editor"', (string) $first_output, 'The first render call must output the editor container' );
+
+ ob_start();
+ $assets_manager->render_email_editor_html();
+ $this->assertSame( '', ob_get_clean(), 'A repeated render call must not output a second editor container' );
+ }
+
+ /**
+ * Point ABSPATH at a temp directory with a stub wp-admin/admin-header.php so
+ * render_email_editor_html() can run outside a WordPress install.
+ */
+ private function define_abspath_with_stub_admin_header(): void {
+ if ( defined( 'ABSPATH' ) ) {
+ if ( ! file_exists( ABSPATH . 'wp-admin/admin-header.php' ) ) {
+ $this->markTestSkipped( 'ABSPATH is already defined and has no admin-header.php to stub.' );
+ }
+ return;
+ }
+
+ $abspath = sys_get_temp_dir() . '/email-editor-assets-manager-test-' . uniqid() . '/';
+ mkdir( $abspath . 'wp-admin', 0777, true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir -- No WP_Filesystem in package unit tests.
+ file_put_contents( $abspath . 'wp-admin/admin-header.php', "<?php\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- No WP_Filesystem in package unit tests.
+ define( 'ABSPATH', $abspath );
+ }
+}
diff --git a/plugins/woocommerce/changelog/fix-yoast-replace-editor-reentry b/plugins/woocommerce/changelog/fix-yoast-replace-editor-reentry
new file mode 100644
index 00000000000..8b3359abf44
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-yoast-replace-editor-reentry
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix the block email editor loading in a broken state when a plugin (e.g. Yoast SEO Premium) calls WP_Screen::get() from admin_enqueue_scripts, re-entering the replace_editor filter mid-render
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/Integration.php b/plugins/woocommerce/src/Internal/EmailEditor/Integration.php
index fbc0854c22c..a2a2f02b454 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/Integration.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/Integration.php
@@ -77,6 +77,13 @@ class Integration {
*/
private \WC_Email $wc_email_instance;
+ /**
+ * Whether the email editor page has been rendered.
+ *
+ * @var bool
+ */
+ private bool $editor_rendered = false;
+
/**
* Constructor.
*/
@@ -266,6 +273,14 @@ class Integration {
public function replace_editor( $replace, $post ) {
$current_screen = get_current_screen();
if ( self::EMAIL_POST_TYPE === $post->post_type && $current_screen ) {
+ // This callback re-enters while the editor renders: WP_Screen::get() re-applies
+ // the `replace_editor` filter, and plugins call it from admin_enqueue_scripts,
+ // which admin-header.php fires mid-render. Rendering again would echo a second
+ // editor container inside <head> and break the page.
+ if ( $this->editor_rendered ) {
+ return true;
+ }
+ $this->editor_rendered = true;
$this->maybe_refresh_scratchpad( $post );
$this->editor_page_renderer->render();
return true;
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/IntegrationTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/IntegrationTest.php
index 29d58c81132..4187589a968 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/IntegrationTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/IntegrationTest.php
@@ -5,6 +5,7 @@ namespace Automattic\WooCommerce\Tests\Internal\EmailEditor;
use Automattic\WooCommerce\Internal\EmailEditor\Integration;
use Automattic\WooCommerce\Internal\EmailEditor\Package;
+use Automattic\WooCommerce\Internal\EmailEditor\PageRenderer;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager;
use WC_Unit_Test_Case;
@@ -67,6 +68,7 @@ class IntegrationTest extends WC_Unit_Test_Case {
$this->posts_manager->clear_caches();
update_option( 'woocommerce_feature_block_email_editor_enabled', 'no' );
+ unset( $GLOBALS['current_screen'] );
parent::tearDown();
}
@@ -567,6 +569,74 @@ class IntegrationTest extends WC_Unit_Test_Case {
}
}
+ /**
+ * @testdox Should render the editor only once when the replace_editor filter re-enters during rendering.
+ */
+ public function test_replace_editor_renders_only_once_on_reentry(): void {
+ $post = $this->create_woo_email_post( 'customer_processing_order', 'publish' );
+ set_current_screen( Integration::EMAIL_POST_TYPE );
+
+ // A fresh instance keeps the fake renderer and the tripped guard off the container-cached one.
+ $integration = new Integration();
+
+ $fake_renderer = new class() extends PageRenderer {
+ /**
+ * Number of render() calls.
+ *
+ * @var int
+ */
+ public int $render_calls = 0;
+
+ /**
+ * Callback invoked from render() to simulate the re-entrant filter application.
+ *
+ * @var \Closure|null
+ */
+ public ?\Closure $on_render = null;
+
+ /**
+ * Constructor override skipping the container wiring.
+ */
+ public function __construct() {
+ }
+
+ /**
+ * Count render calls and re-enter like WP_Screen::get() does mid-render.
+ */
+ public function render() {
+ ++$this->render_calls;
+ if ( $this->on_render ) {
+ ( $this->on_render )();
+ }
+ }
+ };
+
+ $renderer_property = new \ReflectionProperty( Integration::class, 'editor_page_renderer' );
+ $renderer_property->setAccessible( true );
+ $renderer_property->setValue( $integration, $fake_renderer );
+
+ // While render() runs, admin-header.php fires admin_enqueue_scripts, where a plugin
+ // calling WP_Screen::get() re-applies the `replace_editor` filter. Re-enter only
+ // once — like the real flow, where the second admin-header require is a no-op —
+ // so a broken guard fails the count assertion instead of recursing forever.
+ $has_reentered = false;
+ $reentrant_result = null;
+ $fake_renderer->on_render = function () use ( $integration, $post, &$has_reentered, &$reentrant_result ) {
+ if ( $has_reentered ) {
+ return;
+ }
+ $has_reentered = true;
+ $reentrant_result = $integration->replace_editor( false, $post );
+ };
+
+ $this->assertTrue( $integration->replace_editor( false, $post ), 'The outer call must replace the editor' );
+ $this->assertTrue( $reentrant_result, 'The re-entrant call must still report the editor as replaced' );
+ $this->assertSame( 1, $fake_renderer->render_calls, 'The re-entrant call must not render the editor again' );
+
+ $ordinary_post = $this->factory()->post->create_and_get( array( 'post_type' => 'post' ) );
+ $this->assertFalse( $integration->replace_editor( false, $ordinary_post ), 'Posts of other types must keep the default editor after the guard trips' );
+ }
+
/**
* Create a `woo_email` post carrying the email type meta.
*