Email editor: applying a template update duplicates a block when the merchant has deleted one
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
A merchant who deletes a block from a customised block email, and then applies a core template update, can end up with a duplicated paragraph: core's updated version is inserted and the merchant's stale version is kept. The email gains a block the merchant never wrote, with near-identical wording to one already there, and nothing in the UI warns that this happened.
In the case below the merchant's email goes from two paragraphs to three, with NEW CORE A and OLD BLOCK A sitting next to each other.
This is the most damaging of three defects found on this surface, because it writes visibly wrong content into a live transactional email rather than leaving content stale.
Reproduced on trunk at 0f7de959fb (11.3.0-dev) with only WooCommerce active.
Expected behavior
Applying a template update never produces near-duplicate blocks. Core's changed block updates the merchant's corresponding block, or is skipped — it is not inserted alongside it.
Actual behavior
<!-- merchant's email before Apply -->
<p>OLD BLOCK A</p>
<p>MERCHANT EDITED C</p>
<!-- after Apply -->
<p>NEW CORE A</p>
<p>OLD BLOCK A</p>
<p>MERCHANT EDITED C</p>The review drawer had advertised two changes and offered no conflicts to resolve. The Apply request body was {"choices":[]}. The response reported "status":"applied" and "structural_skipped":false — no signal that anything unusual happened.
Steps to reproduce
Setup is identical to the companion issue on auto-resolved content updates — a clean pnpm env:e2e:start environment with the wc-email-template-sync-test-helper plugin from tests/e2e/test-plugins/, the block email editor feature enabled, and the helper REST routes used to pin an old canonical template, seed a customised post, then move core forward.
Seed this shape. The key ingredient is that the merchant deleted a block that core still has, and core changed a different block whose text is textually similar to the deleted one's neighbour.
| Old canonical | <p>OLD BLOCK A</p><p>OLD BLOCK B</p><p>OLD BLOCK C</p> |
| Merchant post | <p>OLD BLOCK A</p><p>MERCHANT EDITED C</p> — B deleted, C edited |
| New canonical | <p>NEW CORE A</p><p>OLD BLOCK B</p><p>OLD BLOCK C</p> — core changed A |
- Run the detection sweep. The post classifies as
core_updated_customized. - Go to WooCommerce → Settings → Emails, open the New order email in the editor. The canvas shows two paragraphs:
OLD BLOCK AandMERCHANT EDITED C. - The "Template update available" banner appears. Click Review (with no conflicts present the banner also offers a direct Apply, which reaches the same outcome without showing the list).
- The drawer shows Auto-resolved · 2 blocks and no conflicts. Click Apply (2).
- Reload the editor.
- Observed: three paragraphs —
NEW CORE A,OLD BLOCK A,MERCHANT EDITED C. Expected: two, with no duplicate. - Confirm on the server with
wp post get <post_id> --field=post_content.
WordPress Environment
Reproduced on a clean wp-env environment rather than a merchant site.
| 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 merge runs its own LCS pairing of core_records against post_records by text similarity, independently of the three-way summary. Deleting B shifts that alignment: the merchant's OLD BLOCK A is textually closer to core's OLD BLOCK B than to core's NEW CORE A, so those pair up and NEW CORE A is left unmatched.
From there two independent code paths each do something locally reasonable and jointly wrong:
- Pass 1 pairs core's
OLD BLOCK Bwith the merchant'sOLD BLOCK A. Their text differs, the path is incopy_changes, and with no explicit choice sent the decision falls back tokeep_yours(WCEmailTemplateSelectiveApplier.php:569), so the merchant'sOLD BLOCK Ais preserved. - Pass 2 treats
NEW CORE Aas an unmatched core record and inserts it unconditionally (WCEmailTemplateSelectiveApplier.php:583-600). This pass consults no choice and no classification — a non-structural unmatched core record is always inserted.
Result: the same logical block is represented twice.
Two things are worth separating here. The keep_yours fallback is the subject of a companion issue and is a real bug in its own right, but fixing it alone would not fix this: with the mis-pairing in place, applying core to that pair would write OLD BLOCK B into the merchant's first paragraph, which is also wrong. The root problem in this scenario is that Pass 2's unconditional insertion has no interlock with Pass 1's pairing — nothing checks whether an "unmatched" core record is actually the counterpart of a block already matched elsewhere.
Proposal
I do not have a confident fix to propose, and I would rather flag that than guess at one, since the merge is doing several things at once here. Some directions, in rough order of how much I trust them:
Make Pass 2 respect the three-way summary the way Pass 1 does. Pass 1 already gates on the precomputed summary — it rejects pairs the summary classified as separate add/remove, and only applies use_core for paths in copy_changes. Pass 2 has no equivalent gate. In the scenario above the summary correctly reported one copy_changes entry for path [0] and no added_blocks at all, so a Pass 2 that only inserted records the summary listed as added_blocks would have inserted nothing, and the duplication would not occur. This looks like the smallest change consistent with the existing design.
Reconsider similarity-based pairing when a three-way base is available. The base gives an anchor that similarity guessing does not; the summary already pairs blocks against it correctly. Having the applier re-derive its own pairing invites exactly this divergence between what the merchant was shown and what gets written.
Either way, this scenario belongs in the test suite: a merchant deletion plus a core edit is not exotic, and the failure is silent.
Test coverage
WCEmailTemplateSelectiveApplierTest.php has 25 tests including three-way cases, but none combines a merchant deletion with a core edit elsewhere in the template. No test asserts that the merged output contains no duplicated blocks. Adding a case on this shape, asserting the exact merged content, would have caught it.
Related issues
Found while verifying #68896. All three surface in the same dialog; the root causes are different.
- #68896 — auto-resolved content updates are dropped on Apply.
- #68897 — structural entries labelled "applied automatically" when they are never applied.
Source: woocommerce/woocommerce