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.
 	 *