Commit 4e122e7856e for woocommerce
commit 4e122e7856e24af8daabc15c84f406a906c3ee03
Author: Rostislav Wolný <1082140+costasovo@users.noreply.github.com>
Date: Fri Sep 25 14:04:30 2026 +0200
Show product blocks in block editor emails sent in the background (#69003)
* Add an action for the end of an email render
woocommerce_email_editor_render_start lets an integration prepare for a
render, but there is no way to tell when the render is over, so anything
set up for it, such as a hooked filter, cannot be put back. reset() is
private and the renderer is reused for every email in the request.
The action fires in the same finally block as reset(), so it also fires
when the render throws. By then the renderer has been reset, so it
carries no arguments and a callback must not expect render state. Both
render actions are now documented in the rendering guide, which had no
section on them, along with the pairing they promise.
* Add tests for the email render lifecycle actions
Neither action had coverage: nothing proved the renderer fires them at
all, so the documented promise that the end action fires even when a
render throws rested on reading the code.
* Add changelog entry for the email render end action
* Register WooCommerce blocks when an email render starts
WooCommerce 11.1 stopped registering its block types on requests that
render no pages, such as cron, AJAX, the Store API and the WooCommerce
REST namespaces. Block emails are usually sent from exactly those
requests, and the email renderer needs a registered block type to find a
block's email renderer. Without one it falls back to the block's saved
markup, so product blocks a merchant added to an email, such as Product
Collection, arrived empty.
Registering on demand when the render starts keeps the saving on every
other background request, and covers any email built with the package,
including MailPoet's.
The data- attributes the controller adds to WooCommerce blocks carry
editor attributes for front-end scripts, which an email has no use for,
so they are suspended for the render and put back when it ends. Only a
suspension hooks the restore, so a render whose end action never reached
it is repaired by the next one.
* Add tests for on-demand block registration during email renders
Cover what the fix has to get right: block types are registered with
their email renderer attached, the data- attributes filter is gone for
the render and back afterwards on both the already-registered and the
register-on-demand path, a filter an extension removed stays removed, a
suspension a failed render left behind is repaired, and a failure inside
third-party registration code is reported instead of stopping the send.
The end-to-end test renders a Product Collection email with the block
types unregistered, which is what a cron or AJAX send looks like. It
includes a store notices block because that one has no email renderer,
so its markup, and any data- attribute on it, reaches the sent email.
* Add changelog entries for block emails losing product blocks
diff --git a/packages/php/email-editor/changelog/add-email-render-end-action b/packages/php/email-editor/changelog/add-email-render-end-action
new file mode 100644
index 00000000000..3fd34ea7278
--- /dev/null
+++ b/packages/php/email-editor/changelog/add-email-render-end-action
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add the woocommerce_email_editor_render_end action, fired when rendering an email has finished, so integrations can restore state they changed for the render.
diff --git a/packages/php/email-editor/docs/rendering.md b/packages/php/email-editor/docs/rendering.md
index bf04d3f5717..d552f8f8ce1 100644
--- a/packages/php/email-editor/docs/rendering.md
+++ b/packages/php/email-editor/docs/rendering.md
@@ -11,6 +11,7 @@ The email rendering system includes **Core Blocks Integration** that provides de
- [Renderer Classes](#renderer-classes)
- [Renderer](#renderer)
- [Content_Renderer](#content_renderer)
+- [Render Lifecycle Actions](#render-lifecycle-actions)
- [Rendering Direction](#rendering-direction)
- [Core Blocks Integration](#core-blocks-integration)
- [Table Wrapper Helper](#table-wrapper-helper)
@@ -224,6 +225,50 @@ $html = $result['html'];
$styles = $result['styles'];
```
+## Render Lifecycle Actions
+
+Two actions mark the start and the end of rendering an email's content. Use them to set up state a render needs and to put it back afterwards, for example a filter that must not apply to email HTML. `Content_Renderer` is reused for every email in a request, so anything left behind affects later renders and the rest of the request.
+
+```php
+/**
+ * Fires when rendering an email's content is about to start.
+ *
+ * @since 2.9.2
+ */
+do_action( 'woocommerce_email_editor_render_start' );
+
+/**
+ * Fires when rendering an email has finished, whether it succeeded or threw.
+ *
+ * @since 2.18.0
+ */
+do_action( 'woocommerce_email_editor_render_end' );
+```
+
+**Example Usage:**
+
+```php
+add_action(
+ 'woocommerce_email_editor_render_start',
+ function () {
+ remove_filter( 'render_block', 'my_plugin_add_front_end_markup' );
+ }
+);
+
+add_action(
+ 'woocommerce_email_editor_render_end',
+ function () {
+ add_filter( 'render_block', 'my_plugin_add_front_end_markup' );
+ }
+);
+```
+
+Each start is followed by exactly one end. Renders must not be nested, which the package does not check: a nested render resets the outer one's globals and rendering context. The end action fires from the same `finally` block that resets the renderer, so it also fires when a render throws.
+
+By then the renderer has been reset: the rendering context is gone and the `$post` and template globals are back to what they were, so the action carries no arguments and a callback cannot tell which email finished. A callback that needs the post should read it at the start of the render. A callback must also not throw, because it would mask the render's own error and stop later callbacks from restoring their state.
+
+Versions before 2.18.0 do not fire the end action, so a plugin that supports them cannot rely on the render end alone to undo its setup.
+
## Rendering Direction
Full email rendering resolves text direction once per render and shares it with the template shell, preprocessors, and block renderers.
diff --git a/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/class-content-renderer.php b/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/class-content-renderer.php
index 58945d540ca..41d3d900a3c 100644
--- a/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/class-content-renderer.php
+++ b/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/class-content-renderer.php
@@ -239,10 +239,25 @@ class Content_Renderer {
$this->set_template_globals( $post, $template );
$this->initialize();
try {
+ /**
+ * Fires when rendering an email's content is about to start.
+ *
+ * @since 2.9.2
+ */
do_action( 'woocommerce_email_editor_render_start' );
$rendered_html = get_the_block_template_html();
} finally {
$this->reset();
+ /**
+ * Fires when rendering an email's content has finished, whether it succeeded or threw.
+ *
+ * Counterpart of woocommerce_email_editor_render_start, for integrations that change global state
+ * for the render, such as hooked filters, and have to put it back afterwards. The renderer has
+ * already been reset when it fires, so a callback must not throw and must not expect render state.
+ *
+ * @since 2.18.0
+ */
+ do_action( 'woocommerce_email_editor_render_end' );
}
return array(
diff --git a/packages/php/email-editor/tests/integration/Engine/Renderer/ContentRenderer/Content_Renderer_Test.php b/packages/php/email-editor/tests/integration/Engine/Renderer/ContentRenderer/Content_Renderer_Test.php
index a848f3812fb..efc5af993f0 100644
--- a/packages/php/email-editor/tests/integration/Engine/Renderer/ContentRenderer/Content_Renderer_Test.php
+++ b/packages/php/email-editor/tests/integration/Engine/Renderer/ContentRenderer/Content_Renderer_Test.php
@@ -123,6 +123,62 @@ class Content_Renderer_Test extends \Email_Editor_Integration_Test_Case {
$this->assertStringContainsString( 'Hello!', $result['html'] );
}
+ /**
+ * Test the render start and end actions fire once each, in order, per content render.
+ */
+ public function testItFiresRenderStartAndEndActionsOncePerRender(): void {
+ $fired = array();
+ $track = function () use ( &$fired ) {
+ $fired[] = current_action();
+ };
+ add_action( 'woocommerce_email_editor_render_start', $track );
+ add_action( 'woocommerce_email_editor_render_end', $track );
+
+ $template = new \WP_Block_Template();
+ $template->id = 'template-id';
+ $template->content = '<!-- wp:post-content /-->';
+ $this->renderer->render_without_css_inline( $this->email_post, $template );
+
+ remove_action( 'woocommerce_email_editor_render_start', $track );
+ remove_action( 'woocommerce_email_editor_render_end', $track );
+
+ $this->assertSame(
+ array( 'woocommerce_email_editor_render_start', 'woocommerce_email_editor_render_end' ),
+ $fired,
+ 'Each render should fire the start action and then the end action, once each.'
+ );
+ }
+
+ /**
+ * Test the render end action fires when the render throws, so integrations can restore what they changed.
+ */
+ public function testItFiresTheRenderEndActionWhenTheRenderThrows(): void {
+ $ended = 0;
+ $count = function () use ( &$ended ) {
+ ++$ended;
+ };
+ $fail = function (): void {
+ throw new \RuntimeException( 'Rendering failed' );
+ };
+ add_action( 'woocommerce_email_editor_render_end', $count );
+ add_action( 'woocommerce_email_editor_render_start', $fail );
+
+ $template = new \WP_Block_Template();
+ $template->id = 'template-id';
+ $template->content = '<!-- wp:post-content /-->';
+ try {
+ $this->renderer->render_without_css_inline( $this->email_post, $template );
+ $this->fail( 'The render should have thrown.' );
+ } catch ( \RuntimeException $e ) {
+ $this->assertSame( 'Rendering failed', $e->getMessage() );
+ } finally {
+ remove_action( 'woocommerce_email_editor_render_start', $fail );
+ remove_action( 'woocommerce_email_editor_render_end', $count );
+ }
+
+ $this->assertSame( 1, $ended, 'The end action should fire even when the render throws.' );
+ }
+
/**
* Test render_without_css_inline applies email context once per content render.
*/
diff --git a/plugins/woocommerce/changelog/wooplug-7795-email-block-data-attributes b/plugins/woocommerce/changelog/wooplug-7795-email-block-data-attributes
new file mode 100644
index 00000000000..17c3622dbf2
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-7795-email-block-data-attributes
@@ -0,0 +1,4 @@
+Significance: patch
+Type: tweak
+
+Stop adding block data- attributes to the HTML of emails built with the block email editor.
diff --git a/plugins/woocommerce/changelog/wooplug-7795-email-product-blocks-registration b/plugins/woocommerce/changelog/wooplug-7795-email-product-blocks-registration
new file mode 100644
index 00000000000..5a4ed2862e6
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-7795-email-product-blocks-registration
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Show product blocks, such as Product Collection, in sent emails built with the block email editor.
diff --git a/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md b/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md
index 4959871bf86..b657ad5aceb 100644
--- a/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md
+++ b/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md
@@ -1614,7 +1614,7 @@ apply_filters( 'woocommerce_should_register_blocks', bool $should_register )
### Description
-Registration is skipped on known non-rendering contexts (the Store API and other WooCommerce REST namespaces, cron, AJAX, XML-RPC, favicon, robots.txt and XML sitemaps) as a performance optimisation. Product and variation descriptions rendered through do_blocks are already handled on demand (see the woocommerce_short_description hook in Bootstrap), so this filter is only needed to opt back in when an extension renders WooCommerce blocks some other way in one of those contexts.
+Registration is skipped on known non-rendering contexts (the Store API and other WooCommerce REST namespaces, cron, AJAX, XML-RPC, favicon, robots.txt and XML sitemaps) as a performance optimisation. Product and variation descriptions rendered through do_blocks and emails rendered by the email editor are already handled on demand (see the woocommerce_short_description and woocommerce_email_editor_render_start hooks in Bootstrap), so this filter is only needed to opt back in when an extension renders WooCommerce blocks some other way in one of those contexts.
### Parameters
diff --git a/plugins/woocommerce/src/Blocks/BlockTypesController.php b/plugins/woocommerce/src/Blocks/BlockTypesController.php
index 31aacd7b2e0..00467286d00 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypesController.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypesController.php
@@ -20,6 +20,11 @@ use Automattic\WooCommerce\Internal\ShopperLists\ShopperListsController;
*/
final class BlockTypesController {
+ /**
+ * Priority of the add_data_attributes render_block filter.
+ */
+ private const DATA_ATTRIBUTES_PRIORITY = 10;
+
/**
* Instance of the asset API.
*
@@ -72,7 +77,7 @@ final class BlockTypesController {
add_action( 'init', array( $this, 'register_blocks' ) );
add_action( 'wp_loaded', array( $this, 'register_block_patterns' ) );
add_filter( 'block_categories_all', array( $this, 'register_block_categories' ), 10, 2 );
- add_filter( 'render_block', array( $this, 'add_data_attributes' ), 10, 2 );
+ add_filter( 'render_block', array( $this, 'add_data_attributes' ), self::DATA_ATTRIBUTES_PRIORITY, 2 );
add_action( 'woocommerce_login_form_end', array( $this, 'redirect_to_field' ) );
add_filter( 'widget_types_to_hide_from_legacy_widget_block', array( $this, 'hide_legacy_widgets_with_block_equivalent' ) );
add_filter( 'block_type_metadata_settings', array( $this, 'use_single_block_editor_style' ), 10, 2 );
@@ -140,6 +145,55 @@ final class BlockTypesController {
}
}
+ /**
+ * Prepare block rendering for an email, and register the blocks if this request skipped that.
+ *
+ * Registration runs third-party code, and an error there must not stop the email from being sent.
+ *
+ * @internal
+ */
+ public function register_blocks_for_email(): void {
+ $this->suspend_data_attributes_for_email_render();
+
+ if ( self::$register_blocks_has_run ) {
+ return;
+ }
+
+ try {
+ $this->register_blocks();
+ } catch ( \Throwable $e ) {
+ wc_caught_exception( $e, __METHOD__ );
+ }
+ }
+
+ /**
+ * Stop adding data- attributes to blocks until the email render ends.
+ *
+ * No block that can appear in an email reads the attributes back, so in email HTML they are only weight. A
+ * filter an extension removed is left alone.
+ */
+ private function suspend_data_attributes_for_email_render(): void {
+ $data_attributes_callback = array( $this, 'add_data_attributes' );
+ if ( self::DATA_ATTRIBUTES_PRIORITY !== has_filter( 'render_block', $data_attributes_callback ) ) {
+ return;
+ }
+
+ remove_filter( 'render_block', $data_attributes_callback, self::DATA_ATTRIBUTES_PRIORITY );
+ add_action( 'woocommerce_email_editor_render_end', array( $this, 'restore_data_attributes_after_email_render' ) );
+ }
+
+ /**
+ * Add the data- attributes filter back once the email render has ended.
+ *
+ * Only a suspension hooks this, so a render whose end action never reached it is repaired by the next one.
+ *
+ * @internal
+ */
+ public function restore_data_attributes_after_email_render(): void {
+ add_filter( 'render_block', array( $this, 'add_data_attributes' ), self::DATA_ATTRIBUTES_PRIORITY, 2 );
+ remove_action( 'woocommerce_email_editor_render_end', array( $this, 'restore_data_attributes_after_email_render' ) );
+ }
+
/**
* Whether register_blocks() has run in this request.
*
diff --git a/plugins/woocommerce/src/Blocks/Domain/BlockRegistrationContext.php b/plugins/woocommerce/src/Blocks/Domain/BlockRegistrationContext.php
index 4ce63b86f62..5400a89df2d 100644
--- a/plugins/woocommerce/src/Blocks/Domain/BlockRegistrationContext.php
+++ b/plugins/woocommerce/src/Blocks/Domain/BlockRegistrationContext.php
@@ -30,9 +30,10 @@ class BlockRegistrationContext {
*
* Registration is skipped on known non-rendering contexts (the Store API and other WooCommerce REST
* namespaces, cron, AJAX, XML-RPC, favicon, robots.txt and XML sitemaps) as a performance optimisation.
- * Product and variation descriptions rendered through do_blocks are already handled on demand (see the
- * woocommerce_short_description hook in Bootstrap), so this filter is only needed to opt back in when an
- * extension renders WooCommerce blocks some other way in one of those contexts.
+ * Product and variation descriptions rendered through do_blocks and emails rendered by the email editor are
+ * already handled on demand (see the woocommerce_short_description and woocommerce_email_editor_render_start
+ * hooks in Bootstrap), so this filter is only needed to opt back in when an extension renders WooCommerce
+ * blocks some other way in one of those contexts.
*
* @since 11.1.0
*
diff --git a/plugins/woocommerce/src/Blocks/Domain/Bootstrap.php b/plugins/woocommerce/src/Blocks/Domain/Bootstrap.php
index 1499c6733b1..974024e704d 100644
--- a/plugins/woocommerce/src/Blocks/Domain/Bootstrap.php
+++ b/plugins/woocommerce/src/Blocks/Domain/Bootstrap.php
@@ -135,11 +135,12 @@ class Bootstrap {
// Register block types on demand (priority 8, before do_blocks at 9) so blocks in a description are not empty.
add_filter( 'woocommerce_short_description', array( $this, 'maybe_register_blocks_from_content' ), 8 );
+ add_action( 'woocommerce_email_editor_render_start', array( $this, 'handle_woocommerce_email_editor_render_start' ) );
// Load assets unless this is a request specifically for the store API.
if ( ! $is_store_api_request ) {
// Skip eager block/pattern/asset registration on non-rendering requests; the block types needed for
- // a description block are still registered on demand (see the hook above). See BlockRegistrationContext.
+ // a description or an email are still registered on demand (see the hooks above). See BlockRegistrationContext.
if ( ( new BlockRegistrationContext() )->should_register() ) {
$this->container->get( BlockPatterns::class );
$this->container->get( BlockTypesController::class );
@@ -184,6 +185,18 @@ class Bootstrap {
return $content;
}
+ /**
+ * Register WooCommerce block types on demand before an email is rendered by the email editor package.
+ *
+ * Emails are often sent from requests where eager block registration is skipped (cron, AJAX, Store API),
+ * and unregistered WooCommerce blocks, such as Product Collection, would render empty in the email.
+ *
+ * @internal
+ */
+ public function handle_woocommerce_email_editor_render_start(): void {
+ $this->container->get( BlockTypesController::class )->register_blocks_for_email();
+ }
+
/**
* See if files have been built or not.
*
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Domain/BootstrapTest.php b/plugins/woocommerce/tests/php/src/Blocks/Domain/BootstrapTest.php
index 216ca11d94d..afab487d21e 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/Domain/BootstrapTest.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/Domain/BootstrapTest.php
@@ -5,6 +5,13 @@ namespace Automattic\WooCommerce\Tests\Blocks\Domain;
use Automattic\WooCommerce\Blocks\BlockTypesController;
use Automattic\WooCommerce\Blocks\Package;
+use Automattic\WooCommerce\EmailEditor\Bootstrap as EmailEditorBootstrap;
+use Automattic\WooCommerce\EmailEditor\Email_Editor_Container;
+use Automattic\WooCommerce\EmailEditor\Engine\Dependency_Check;
+use Automattic\WooCommerce\Internal\EmailEditor\BlockEmailRenderer;
+use Automattic\WooCommerce\Internal\EmailEditor\Integration;
+use Automattic\WooCommerce\Internal\EmailEditor\Package as EmailEditorPackage;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager;
use WC_Unit_Test_Case;
use WP_Block_Type_Registry;
@@ -14,7 +21,7 @@ use WP_Block_Type_Registry;
* Product and variation descriptions are run through do_blocks on the woocommerce_short_description filter in
* contexts where eager block registration is skipped (the products REST endpoints, the Store API schemas, the
* variation AJAX endpoint and product webhooks). Bootstrap registers block types on demand there so those blocks
- * do not render empty.
+ * do not render empty. Emails rendered by the email editor package get the same on-demand registration.
*/
class BootstrapTest extends WC_Unit_Test_Case {
@@ -89,6 +96,57 @@ class BootstrapTest extends WC_Unit_Test_Case {
$property->setValue( null, $has_run );
}
+ /**
+ * The priority the data attributes filter is hooked at, or false when it is not hooked.
+ *
+ * The controller instance is not used to look it up: another test class resets the Blocks container mid-suite
+ * (see Blocks\Bootstrap\MainFile), so the instance this test would fetch is not the one Bootstrap uses.
+ *
+ * @return int|false
+ */
+ private function data_attributes_filter_priority() {
+ $hooked = $this->find_data_attributes_filter();
+
+ return null === $hooked ? false : $hooked['priority'];
+ }
+
+ /**
+ * Remove the data attributes filter, the way an extension would.
+ */
+ private function remove_data_attributes_filter(): void {
+ $hooked = $this->find_data_attributes_filter();
+ if ( null !== $hooked ) {
+ remove_filter( 'render_block', $hooked['callback'], $hooked['priority'] );
+ }
+ }
+
+ /**
+ * Find the hooked BlockTypesController::add_data_attributes callback, whichever instance owns it.
+ *
+ * @return array{callback: callable, priority: int}|null
+ */
+ private function find_data_attributes_filter(): ?array {
+ global $wp_filter;
+
+ foreach ( $wp_filter['render_block']->callbacks ?? array() as $priority => $callbacks ) {
+ foreach ( $callbacks as $callback ) {
+ $function = $callback['function'] ?? null;
+ if (
+ is_array( $function )
+ && ( $function[0] ?? null ) instanceof BlockTypesController
+ && 'add_data_attributes' === ( $function[1] ?? '' )
+ ) {
+ return array(
+ 'callback' => $function,
+ 'priority' => (int) $priority,
+ );
+ }
+ }
+ }
+
+ return null;
+ }
+
/**
* @testdox Bootstrap does not register admin-init work that also runs during admin AJAX.
*/
@@ -398,6 +456,190 @@ HTML;
$this->assertTrue( $registry->is_registered( self::SAMPLE_BLOCK ), 'Block types should remain registered.' );
}
+ /**
+ * @testdox Starting an email render registers missing block types with their email renderers.
+ */
+ public function test_email_render_start_registers_missing_block_types(): void {
+ $registry = WP_Block_Type_Registry::get_instance();
+ Email_Editor_Container::container()->get( EmailEditorBootstrap::class )->init();
+ $this->assertFalse( $registry->is_registered( 'woocommerce/product-collection' ), 'Blocks should start unregistered.' );
+
+ do_action( 'woocommerce_email_editor_render_start' );
+
+ $block_type = $registry->get_registered( 'woocommerce/product-collection' );
+ $this->assertNotNull( $block_type, 'Block types should be registered on demand when an email render starts.' );
+ $this->assertTrue( isset( $block_type->render_email_callback ), 'Block types registered on demand should get an email renderer.' );
+ $this->assertIsCallable( $block_type->render_email_callback, 'The email renderer should be callable.' );
+ }
+
+ /**
+ * @testdox An error thrown while registering block types for an email is caught and reported.
+ */
+ public function test_email_render_start_catches_registration_errors(): void {
+ add_filter(
+ 'woocommerce_get_block_types',
+ function () {
+ throw new \Error( 'Broken block integration' );
+ }
+ );
+ $caught = null;
+ add_action(
+ 'woocommerce_caught_exception',
+ function ( $exception ) use ( &$caught ) {
+ $caught = $exception;
+ }
+ );
+
+ do_action( 'woocommerce_email_editor_render_start' );
+
+ $this->assertInstanceOf( \Error::class, $caught, 'The registration error should be reported instead of stopping the email render.' );
+ }
+
+ /**
+ * @testdox The data attributes filter is suspended even when the blocks were registered earlier in the request.
+ */
+ public function test_email_render_start_suspends_data_attributes_when_blocks_are_already_registered(): void {
+ $registry = WP_Block_Type_Registry::get_instance();
+ foreach ( $this->registered_woo_blocks as $block_type ) {
+ $registry->register( $block_type );
+ }
+ $this->set_register_blocks_has_run_flag( true );
+
+ do_action( 'woocommerce_email_editor_render_start' );
+
+ $this->assertFalse(
+ $this->data_attributes_filter_priority(),
+ 'Most emails are sent from requests that registered the blocks eagerly, and they need the filter suspended too.'
+ );
+ }
+
+ /**
+ * @testdox The data attributes filter is put back when the email render ends.
+ */
+ public function test_data_attributes_filter_is_restored_after_the_email_render(): void {
+ do_action( 'woocommerce_email_editor_render_start' );
+ $this->assertFalse( $this->data_attributes_filter_priority(), 'The filter should be suspended for the render.' );
+
+ do_action( 'woocommerce_email_editor_render_end' );
+
+ $this->assertSame(
+ 10,
+ $this->data_attributes_filter_priority(),
+ 'Later front-end rendering in the same request still needs the data attributes filter.'
+ );
+ }
+
+ /**
+ * @testdox A render whose end action never reaches the restore is repaired by the next render.
+ */
+ public function test_data_attributes_filter_is_restored_by_a_later_render(): void {
+ $throwing_callback = function () {
+ throw new \RuntimeException( 'Another plugin failed to clean up' );
+ };
+
+ add_action( 'woocommerce_email_editor_render_end', $throwing_callback, 5 );
+ do_action( 'woocommerce_email_editor_render_start' );
+ try {
+ do_action( 'woocommerce_email_editor_render_end' );
+ } catch ( \RuntimeException $e ) {
+ $this->assertSame( 'Another plugin failed to clean up', $e->getMessage() );
+ } finally {
+ remove_action( 'woocommerce_email_editor_render_end', $throwing_callback, 5 );
+ }
+
+ do_action( 'woocommerce_email_editor_render_start' );
+ do_action( 'woocommerce_email_editor_render_end' );
+
+ $this->assertSame(
+ 10,
+ $this->data_attributes_filter_priority(),
+ 'A later render should put back a filter an earlier render left suspended.'
+ );
+ }
+
+ /**
+ * @testdox An email render does not restore a data attributes filter that an extension removed.
+ */
+ public function test_email_render_does_not_restore_a_removed_data_attributes_filter(): void {
+ $this->remove_data_attributes_filter();
+
+ do_action( 'woocommerce_email_editor_render_start' );
+ do_action( 'woocommerce_email_editor_render_end' );
+
+ $this->assertFalse(
+ $this->data_attributes_filter_priority(),
+ 'A filter an extension removed must stay removed.'
+ );
+ }
+
+ /**
+ * @testdox An email rendered while block types are unregistered shows product blocks without data attributes.
+ *
+ * The store notices block has no email renderer, so the fallback renderer keeps the markup it was given. It is
+ * the kind of block whose data- attributes would otherwise reach the sent email.
+ */
+ public function test_email_rendered_without_registered_blocks_contains_product_collection(): void {
+ if ( ! Email_Editor_Container::container()->get( Dependency_Check::class )->are_dependencies_met() ) {
+ $this->markTestSkipped( 'The test environment does not meet the block email editor requirements.' );
+ }
+
+ $product = new \WC_Product_Simple();
+ $product->set_name( 'Email collection product' );
+ $product->set_regular_price( '10' );
+ $product->save();
+
+ update_option( 'woocommerce_feature_block_email_editor_enabled', 'yes' );
+ wc_get_container()->get( EmailEditorPackage::class )->init();
+ wc_get_container()->get( Integration::class )->initialize();
+ $email_editor_bootstrap = Email_Editor_Container::container()->get( EmailEditorBootstrap::class );
+ $email_editor_bootstrap->init();
+ $email_editor_bootstrap->initialize();
+
+ $email_post = $this->factory()->post->create_and_get(
+ array(
+ 'post_name' => 'test_email',
+ 'post_type' => Integration::EMAIL_POST_TYPE,
+ 'post_status' => 'publish',
+ 'post_content' => '<!-- wp:woocommerce/product-collection {"queryId":1,"query":{"perPage":3,"pages":1,"offset":0,"postType":"product","order":"asc","orderBy":"title","search":"","exclude":[],"inherit":false,"taxQuery":[],"isProductCollectionBlock":true,"woocommerceOnSale":false,"woocommerceStockStatus":["instock","outofstock","onbackorder"],"woocommerceAttributes":[],"woocommerceHandPickedProducts":[]},"tagName":"div","displayLayout":{"type":"flex","columns":3},"collection":"woocommerce/product-collection/product-catalog"} -->
+<div class="wp-block-woocommerce-product-collection"><!-- wp:woocommerce/product-template -->
+<!-- wp:post-title {"isLink":true,"__woocommerceNamespace":"woocommerce/product-collection/product-title"} /-->
+<!-- wp:woocommerce/product-price {"isDescendentOfQueryLoop":true} /-->
+<!-- /wp:woocommerce/product-template --></div>
+<!-- /wp:woocommerce/product-collection -->
+<!-- wp:woocommerce/store-notices /-->',
+ )
+ );
+ WCTransactionalEmailPostsManager::get_instance()->save_email_template_post_id( 'test_email', $email_post->ID );
+
+ $wc_email = $this->createMock( \WC_Email::class );
+ $wc_email->id = 'test_email';
+ $wc_email->method( 'get_recipient' )->willReturn( 'customer@example.com' );
+ $wc_email->method( 'get_subject' )->willReturn( 'Test subject' );
+ $wc_email->method( 'get_preheader' )->willReturn( '' );
+ $wc_email->method( 'get_block_editor_email_template_content' )->willReturn( 'Order details' );
+
+ try {
+ $rendered_email = wc_get_container()->get( BlockEmailRenderer::class )->maybe_render_block_email( $wc_email );
+ } finally {
+ // Process singleton: its post id cache outlives the transaction rollback.
+ WCTransactionalEmailPostsManager::get_instance()->clear_caches();
+ }
+
+ $this->assertIsString( $rendered_email, 'The block email should render.' );
+ $this->assertSame(
+ 10,
+ $this->data_attributes_filter_priority(),
+ 'The renderer should fire the render end action, which puts the data attributes filter back.'
+ );
+ $this->assertStringContainsString( 'Email collection product', $rendered_email, 'The product collection should list the product.' );
+ $this->assertStringContainsString(
+ 'woocommerce-notices-wrapper',
+ $rendered_email,
+ 'The store notices block has to render markup, or the data attribute assertion below passes for the wrong reason.'
+ );
+ $this->assertStringNotContainsString( 'data-block-name=', $rendered_email, 'Block attributes should not be added to the email as data attributes.' );
+ }
+
/**
* Count the callbacks hooked to enqueue_block_editor_assets.
*