#68896·woocommerce

Email editor: review drawer's auto-resolved content updates are dropped on Apply

Author: vladolaruCreated Sep 18, 2026Updated Sep 18, 2026
LabelsBugEmail

Prerequisites

  • I have carried out troubleshooting steps and I believe I have found a bug.
  • I have searched for similar bugs in both open and closed issues and cannot find a duplicate.

Describe the bug

When a block email template gets a core update, the review drawer splits the changed blocks into two groups: conflicts, where core and the merchant both edited a block and the merchant picks a side, and auto-resolved, where only core changed the block. For an auto-resolved block the drawer states plainly: "Core updated this text. Your version was unchanged, so the update will apply."

It does not apply. The drawer sends only the conflict choices the merchant clicked, the server treats every block path missing from that request as "keep yours", and the auto-resolved block silently keeps its old content. The banner then disappears, so the merchant has no remaining signal that a change they were shown, and were told would be applied, was dropped.

This is quiet content loss in the direction of staleness: the email keeps content that core has replaced, and nothing in the UI says so.

Reproduced on trunk at 0f7de959fb (11.3.0-dev) with only WooCommerce active.

What this does and does not cover. The "Auto-resolved" group in the drawer holds four different kinds of entry, each with its own promise. Only one of them is the subject of this report:

Entry kind Drawer copy Behaviour
Auto-resolvable copy change "Core updated this text. Your version was unchanged, so the update will apply." Broken — this report. Never applied.
Added block "Added by core. Will appear in your email." Works. Pass 2 of the merge inserts these unconditionally, without consulting choices.
Removed block "Not in core. Your block is preserved." Works. Deliberately a no-op.
Structural change "Structural change applied automatically." Also broken, but a separate defect with its own root cause — filed separately, see Related issues.

Expected behavior

After Apply, a block the drawer listed as an auto-resolved copy change takes core's new content, matching what the drawer said would happen. Conflict blocks keep whichever side the merchant selected, defaulting to "Keep yours".

Actual behavior

Conflict blocks behave correctly. Auto-resolved copy changes keep the merchant's old content.

With three paragraphs where the merchant had edited the first two and left the third untouched:

Block Drawer group and promise After Apply
Paragraph 1 Conflict, merchant chose "Use core" NEW CORE A correct
Paragraph 2 Conflict, left on "Keep yours" MERCHANT EDITED B correct
Paragraph 3 Auto-resolved, "the update will apply" OLD BLOCK C wrong, expected NEW CORE C

Steps to reproduce

The hard part is simulating a core template update without shipping a release. The repo already has tooling for exactly this, so these steps use it. Start from a clean environment with nothing else installed.

Setup

  1. From the repo root, run pnpm install --frozen-lockfile, then pnpm --filter=@woocommerce/plugin-woocommerce build.
  2. From plugins/woocommerce, run pnpm env:e2e:start. This brings up a clean WordPress with WooCommerce active and installs the wc-email-template-sync-test-helper plugin from tests/e2e/test-plugins/, which exposes the REST routes used below. The site is at http://localhost:8086 unless you have overridden the port; the admin is admin / password.
  3. Turn on the block email editor by setting the woocommerce_feature_block_email_editor_enabled option to yes (WooCommerce → Settings → Advanced → Features, "Block Email Editor (alpha)").

Seed a core template update where the merchant edited two of three blocks

All calls below are POST with Content-Type: application/json and basic auth admin:password, against /wp-json/. Substitute the returned post ID where indicated.

  1. Stamp the backfill fence so the detection sweep runs: wc-email-test-helper/v1/set-option with {"option_name":"woocommerce_email_template_sync_backfill_complete","option_value":"yes"}.
  2. Pin the old canonical template for new_order: wc-email-test-helper/v1/set-option with {"option_name":"wc_test_template_html_override","option_value":{"new_order":"<!-- wp:paragraph --><p>OLD BLOCK A</p><!-- /wp:paragraph --><!-- wp:paragraph --><p>OLD BLOCK B</p><!-- /wp:paragraph --><!-- wp:paragraph --><p>OLD BLOCK C</p><!-- /wp:paragraph -->"}}.
  3. Create the merchant's email post: wc-email-test-helper/v1/reset-post/new_order with {}. Note the post_id it returns.
  4. Read the hash of the old canonical: GET wc-email-test-helper/v1/canonical-hash/new_order?mode=current. Note the hash.
  5. Seed the merchant's customisation and mark it in sync with the old template: wc-email-test-helper/v1/seed-meta/<post_id> with {"meta":{"_wc_email_template_status":"in_sync","_wc_email_template_source_hash":"<hash>","_wc_email_template_version":"10.0.0"},"post":{"post_content":"<!-- wp:paragraph --><p>MERCHANT EDITED A</p><!-- /wp:paragraph --><!-- wp:paragraph --><p>MERCHANT EDITED B</p><!-- /wp:paragraph --><!-- wp:paragraph --><p>OLD BLOCK C</p><!-- /wp:paragraph -->"}}. The merchant has now edited paragraphs 1 and 2 and left paragraph 3 alone.
  6. Move core forward: wc-email-test-helper/v1/set-option with {"option_name":"wc_test_template_html_override","option_value":{"new_order":"<!-- wp:paragraph --><p>NEW CORE A</p><!-- /wp:paragraph --><!-- wp:paragraph --><p>NEW CORE B</p><!-- /wp:paragraph --><!-- wp:paragraph --><p>NEW CORE C</p><!-- /wp:paragraph -->"}}.
  7. Classify: wc-email-test-helper/v1/trigger-sweep with {}. It should return {"touched":1,"classifications":{"<post_id>":"core_updated_customized"}}.

Reproduce in the browser

  1. Go to WooCommerce → Settings → Emails. The New order row shows Review update in the UPDATES column. Every other row shows .
  2. On the New order row, open the ⋮ Actions menu and click Edit. The email editor opens with the canvas reading MERCHANT EDITED A, MERCHANT EDITED B, OLD BLOCK C.
  3. A banner reads "Template update available" / "Updated wording in Paragraph 1 of 3". Click Review changes.
  4. The Review template update dialog opens, subtitled "New order · 3 changes". Confirm it shows NEEDS YOUR ATTENTION · 2 CONFLICTS (Paragraph 1 of 3 and Paragraph 2 of 3, each with a Keep yours / Use core radio pair) and AUTO-RESOLVED · 1 BLOCK (Paragraph 3 of 3, tagged "Apply core", reading "Core updated this text. Your version was unchanged, so the update will apply.").
  5. On Paragraph 1 of 3, click Use core. Leave Paragraph 2 of 3 on Keep yours. Do not touch anything else.
  6. Open the browser devtools Network tab, then click Apply (3). The dialog closes.
  7. In the Network tab, find the POST to /wp-json/woocommerce-email-editor/v1/emails/<post_id>/apply. Its request body is {"choices":[{"path":[0],"decision":"use_core"}]} — paragraph 3's path [2] is absent. Its response merged_content already ends with <p>OLD BLOCK C</p>.
  8. Reload the editor. Observed: the canvas reads NEW CORE A, MERCHANT EDITED B, OLD BLOCK C, and the "Template update available" banner is gone. Expected: the third paragraph reads NEW CORE C.
  9. Confirm on the server: wp post get <post_id> --field=post_content returns the same three paragraphs, with OLD BLOCK C still in place.

WordPress Environment

Reproduced on a clean wp-env environment rather than a merchant site, so a System Status Report would describe the test harness rather than anything meaningful. The build under test:

WooCommerce 11.3.0-dev, trunk at 0f7de959fb
WordPress latest, clean install via wp-env
PHP 8.1
Theme Twenty Twenty-Three
Other plugins none active beyond the E2E helper

Isolating the problem

  • I have deactivated other plugins and confirmed this bug occurs when only WooCommerce plugin is active.
  • This bug happens with a default WordPress theme active.
  • I can reproduce this bug consistently using the steps above.

Technical findings

The server already knows the block should take core. WCEmailTemplateChangeSummary::summarize() classifies each copy change and emits the flag at WCEmailTemplateChangeSummary.php:800:

php
'auto_resolvable' => ! $yours_changed,

For the seed above, the live change summary returns auto_resolvable: false for paths [0] and [1], and auto_resolvable: true for path [2]. The classification is correct. It simply never survives to the merge.

Three places combine to drop it:

  1. The drawer never sends auto-resolved paths. handleApply builds its request from the choices map (review-drawer.tsx:446-451). choices is only ever written by setChoice, which is wired to the conflict radios. Auto-resolved entries render through AutoResolvedGroup (review-drawer.tsx:228), which has no radio, so they can never enter choices. The two groups are split by auto_resolvable at review-drawer.tsx:578 and :586.

  2. The applier discards the flag while reading the summary. At WCEmailTemplateSelectiveApplier.php:511-517 it walks $precomputed_summary['copy_changes'] but keeps only $cc['path'] into $copy_change_paths, throwing auto_resolvable away. The flag is in scope in the same function as the decision that needs it.

  3. Anything unmentioned falls back to keep-yours. WCEmailTemplateSelectiveApplier.php:569:

php
$decision = $choice_map[ $post_key ] ?? 'keep_yours';

Path [2] is not in $choice_map, so it is skipped and the merchant's old content survives.

Two probes separate cause from correlation. Replaying the drawer's exact request body against a freshly seeded post reproduces OLD BLOCK C. Sending the identical scenario with {"path":[2],"decision":"use_core"} appended yields NEW CORE C. The applier applies the path perfectly well when it is told about it, so the omission is the cause, not an inability to merge.

Worth noting for whoever picks this up: the @param docblock at WCEmailTemplateSelectiveApplier.php:92 says "auto-resolved entries are non-overridable in v1". The second probe shows that is not true of auto-resolvable copy changes — they live in copy_changes, pass the $copy_change_paths gate, and an explicit use_core for them is honoured. The sentence holds only for added, removed and structural entries, and currently reads as a stronger guarantee than the code gives.

Proposal

Fix the applier. Carry auto_resolvable through the loop at lines 511-517 and make the default at line 569 classification-aware, roughly:

php
// keep-yours for an untouched conflict; use-core for a block only core changed.
$default  = ! empty( $auto_resolvable_paths[ $post_key ] ) ? 'use_core' : 'keep_yours';
$decision = $choice_map[ $post_key ] ?? $default;

The reasoning:

  • Auto-resolved is not a choice. There is no control for it in the UI and the copy states it as a fact. Requiring the client to post back a classification the server itself computed makes correctness depend on the client faithfully echoing what it was told — which is precisely what failed here.
  • The endpoint has other callers. A future bulk apply, a WP-CLI path, or a second UI surface would each have to re-derive the same set to avoid silently dropping core updates. A field being absent from a request should not mean "discard a core change".
  • It keeps the correct behaviour for untouched conflicts. Flipping the default wholesale would be wrong: paragraph 2 above must still default to "Keep yours". Keying the default on auto_resolvable preserves both cases, and every path in the request keeps winning over the default.

Also worth doing, as defence in depth rather than instead: have handleApply seed choices with the auto-resolved paths so the request is explicit about its full intent. On its own it fixes this scenario but leaves the endpoint contract just as easy for the next caller to get wrong.

While in there, correct the line 92 docblock to say what the code does.

Decide before fixing: this fix activates a known attribute-overwrite risk

Verified, and it needs a decision rather than just a note.

Detection compares inner_text, which is wp_strip_all_tags() plus whitespace collapse (WCEmailTemplateChangeSummary.php:445-449), so block attrs — colours, font sizes, alignment, link targets, image sources — never register as a change on either side. A standing comment at WCEmailTemplateChangeSummary.php:777-780 already flags this, noting that with auto_resolvable: true the drawer "can silently overwrite an attr-only merchant edit".

Confirmed on the seed above with the colour changed and the text left alone: the block is classified auto_resolvable: true, exactly as the comment predicts. Sending {"choices":[{"path":[0],"decision":"use_core"}]} — what a fixed applier would do internally — produces this:

xml
<!-- before -->
<!-- wp:paragraph {"style":{"color":{"text":"#ff0000"}}} --><p class="has-text-color" style="color:#ff0000">OLD BLOCK A</p><!-- /wp:paragraph -->

<!-- after -->
<!-- wp:paragraph {"style":{"color":{"text":"#ff0000"}}} --><p>NEW CORE A</p><!-- /wp:paragraph -->

The merge copies only innerHTML and innerContent (WCEmailTemplateSelectiveApplier.php:753-754) and leaves attrs alone, so the block ends up internally inconsistent: attrs still declares red text, the serialized markup carries no colour.

That overwrite cannot happen today precisely because auto-resolved entries never apply. Fixing this bug activates it. So the call to make is whether the attrs comparison lands alongside this fix or whether the narrow fix ships first and accepts the exposure.

I did not test whether a sent email renders red or unstyled from that inconsistent block — that depends on whether the email renderer reads attrs or the serialized markup, and I did not exercise the send path. Worth settling, since it decides whether this is silent style loss or an inconsistent-but-harmless record.

Related issues

Two separate defects on the same surface, found while verifying this one. Both have different root causes and want their own fixes; filing them separately.

  • #68897 — Structural entries are labelled "applied automatically" when they are not applied. The drawer gives every structural_changes entry an "Apply core" badge and the text "Structural change applied automatically", while the applier skips structural changes at merge time. Includes the merchant_removed kind, where the entry's own title reads "You removed Paragraph; core still has it." next to an "Apply core" badge.
  • #68898 — Apply can duplicate a block when the merchant removed one core still has. Two paragraphs become three, with core's updated block inserted alongside the merchant's stale one. More severe than this issue — it writes visibly wrong content rather than stale content.

Test coverage

Nothing covers this path today, on either side, which is why it shipped.

  • auto_resolvable appears in no test under plugins/woocommerce/tests/php/. WCEmailTemplateSelectiveApplierTest.php has 25 tests including three-way cases, but none asserting that a core-only change applies when no explicit choice is sent.
  • The drawer's Jest fixtures (__tests__/review-drawer.test.tsx) only ever set auto_resolvable: false, so the auto-resolved group is never exercised.
  • The E2E spec tests/e2e/tests/email-editor/update-propagation/core-flows.spec.ts walks this exact drawer but asserts only the conflict block, and passes today.

A fix should land with a PHP test on the applier default and an assertion on the auto-resolved block added to the existing E2E spec.

Scope

Present in release/10.9, 11.0, 11.1 and 11.2 as well as trunk.

The block email editor is opt-in and off by default (enabled_by_default => false, surfaced as "Block Email Editor (alpha)"), so this reaches only merchants who deliberately turned the alpha on. That caps the blast radius, but it hits them in a bad spot: the affected merchants are exactly those who customised a template, and the failure is silent, contradicts on-screen copy, and leaves no signal afterward.

Bug introduced in PR #64716. Before it, every entry in copy_changes was rendered as a conflict with a radio, so every one was sent. That PR added the three-way split — the auto_resolvable classification, the drawer's auto-resolved group for copy changes, and the ?? 'keep_yours' default — without a path for the classification to reach the merge. PR #64497, which introduced the drawer, is not affected.