Commit 31b7ae61717 for woocommerce
commit 31b7ae61717f1e4f8163191e93dc0241d67a5277
Author: Rostislav Wolný <1082140+costasovo@users.noreply.github.com>
Date: Mon Oct 12 16:36:05 2026 +0200
[Email Editor] Apply the template updates the review drawer says will apply (#69057)
* Apply the template updates the review drawer says will apply
The drawer splits a template update into conflicts the merchant resolves and auto-resolved blocks it states will apply, then sends only the conflict choices. The server treated every unsent path as keep-yours, so the auto-resolved blocks silently kept their old content while the banner disappeared. The default now follows the same classification the drawer showed, which also covers callers that send no choices at all.
Applying by default is only safe if "the merchant did not edit this block" is reliable. The check compared visible text only, so a restyled block, a bolded word or a swapped personalization tag read as untouched and would have been overwritten. It now compares the block's markup and attributes. Both are normalized for the rewrites the block editor makes on save to blocks nobody edited: decoded entities, trimmed padding, and the paragraph `align` attribute moving under `style.typography`. An unknown rewrite makes a block read as edited, which asks the merchant rather than overwriting them, and a Playwright test saves every shipped template through the editor to catch one early.
diff --git a/plugins/woocommerce/changelog/68896-fix-email-template-auto-resolved-updates b/plugins/woocommerce/changelog/68896-fix-email-template-auto-resolved-updates
new file mode 100644
index 00000000000..d518115e783
--- /dev/null
+++ b/plugins/woocommerce/changelog/68896-fix-email-template-auto-resolved-updates
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Email editor: template updates listed as applying automatically are now applied, and blocks you edited wait for your decision.
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/hooks/use-apply-update.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/hooks/use-apply-update.ts
index 4b9320fd00a..460bc953d0b 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/hooks/use-apply-update.ts
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/hooks/use-apply-update.ts
@@ -163,10 +163,10 @@ export function useApplyUpdate(
syncEditorState( res.merged_content );
// Snackbar wording follows what the merchant actually chose.
- // Smart Apply (`choices: []`) defaults every conflict to
- // `keep_yours`, so customizations are preserved. If the
- // drawer-driven path picked `use_core` for everything, the
- // merchant explicitly chose to overwrite — say so plainly.
+ // Smart Apply (`choices: []`) keeps every block the merchant
+ // edited; only blocks they never touched take core's version.
+ // If the drawer-driven path picked `use_core` for everything,
+ // the merchant explicitly chose to overwrite — say so plainly.
const allUseCore =
choices.length > 0 &&
choices.every( ( c ) => c.decision === 'use_core' );
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/EmailApiController.php b/plugins/woocommerce/src/Internal/EmailEditor/EmailApiController.php
index 855073207d9..642089beb01 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/EmailApiController.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/EmailApiController.php
@@ -348,7 +348,7 @@ class EmailApiController {
'sanitize_callback' => 'absint',
),
'choices' => array(
- 'description' => __( 'Per-conflict apply decisions: an array of {path, decision} entries. `decision` is `keep_yours` or `use_core`.', 'woocommerce' ),
+ 'description' => __( 'Per-conflict apply decisions: an array of {path, decision} entries. `decision` is `keep_yours` or `use_core`. A block with no entry follows the change summary: if only core changed the block, it takes core\'s version; if the merchant also edited it, it keeps the merchant\'s version. Without a stored base render, a block with no entry keeps the merchant\'s version.', 'woocommerce' ),
'type' => 'array',
'required' => false,
'default' => array(),
@@ -713,7 +713,7 @@ class EmailApiController {
'type' => 'object',
'properties' => array(
'merged_content' => array(
- 'description' => __( 'The merged block content written to the post. May differ from the input `post_content` even when every choice was `keep_yours` — the namespace-alias migration (see `aliases_migrated`) rewrites legacy block names unconditionally.', 'woocommerce' ),
+ 'description' => __( 'The merged block content written to the post. May differ from the input `post_content` even when every choice was `keep_yours`: blocks the merchant never edited take core\'s version, and the namespace-alias migration (see `aliases_migrated`) rewrites legacy block names unconditionally.', 'woocommerce' ),
'type' => 'string',
'readonly' => true,
),
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateChangeSummary.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateChangeSummary.php
index f429496c6fd..4f771e4d373 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateChangeSummary.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateChangeSummary.php
@@ -56,6 +56,14 @@ class WCEmailTemplateChangeSummary {
*/
private const COPY_TRUNCATE_CHARS = 120;
+ /**
+ * Bumped whenever the classification rules change. Cached payloads survive a
+ * WooCommerce upgrade — the content hashes they are keyed on don't move when
+ * only this code changes — and a payload classified under the old rules would
+ * otherwise drive the new apply behaviour.
+ */
+ private const CLASSIFIER_VERSION = 2;
+
/**
* Summary-inversion guard: minimum count of post-side unmatched blocks
* (with no copy changes and a heavily larger post) before we refuse to
@@ -376,7 +384,7 @@ class WCEmailTemplateChangeSummary {
return sprintf(
'wc_email_change_summary_%d_%s',
$post_id,
- md5( $post_hash . '|' . $core_hash . '|' . $base_hash . '|' . $locale )
+ md5( self::CLASSIFIER_VERSION . '|' . $post_hash . '|' . $core_hash . '|' . $base_hash . '|' . $locale )
);
}
@@ -399,7 +407,7 @@ class WCEmailTemplateChangeSummary {
* @param array<int|string> $path Current index path from root.
* @param string|null $parent_name Normalized parent block name, null at root.
*
- * @return array<int, array{path:array<int|string>, parent_name:?string, name:string, inner_text:string}>
+ * @return array<int, array{path:array<int|string>, parent_name:?string, name:string, inner_text:string, attrs_hash:string, inner_html_hash:string, holds_blocks:bool}>
*/
public static function flatten_blocks( array $blocks, array $path = array(), ?string $parent_name = null ): array {
$records = array();
@@ -410,11 +418,17 @@ class WCEmailTemplateChangeSummary {
$name = self::normalize_block_name( (string) $block['blockName'] );
$current_path = array_merge( $path, array( $idx ) );
+ $attrs = $block['attrs'] ?? array();
+ $inner_html = (string) ( $block['innerHTML'] ?? '' );
+
$records[] = array(
- 'path' => $current_path,
- 'parent_name' => $parent_name,
- 'name' => $name,
- 'inner_text' => self::clean_inner_text( (string) ( $block['innerHTML'] ?? '' ) ),
+ 'path' => $current_path,
+ 'parent_name' => $parent_name,
+ 'name' => $name,
+ 'inner_text' => self::clean_inner_text( $inner_html ),
+ 'attrs_hash' => self::attrs_hash( $name, is_array( $attrs ) ? $attrs : array() ),
+ 'inner_html_hash' => self::inner_html_hash( $inner_html ),
+ 'holds_blocks' => ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ),
);
if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
@@ -427,6 +441,85 @@ class WCEmailTemplateChangeSummary {
return $records;
}
+ /**
+ * Hash a block's attributes so two attribute sets can be compared as strings.
+ * Keys are sorted at every level, so key order never reads as an edit.
+ *
+ * The block editor rewrites some attributes on save, on blocks nobody edited.
+ * Each rewrite we know of is folded into its saved form first, so the PHP
+ * render of a template and the editor's save of it hash the same. The list
+ * is deliberately short and explicit: an unknown rewrite makes a block read
+ * as merchant-edited, which asks the merchant instead of overwriting them.
+ *
+ * @param string $block_name Normalized block name.
+ * @param array<string, mixed> $attrs Block attributes from `parse_blocks()`.
+ */
+ private static function attrs_hash( string $block_name, array $attrs ): string {
+ // Since WordPress 7.1 text alignment is saved as `style.typography.textAlign`:
+ // the `textAlign` attribute on any block, and `align` on paragraphs. Elsewhere
+ // `align` is layout alignment (wide, full, float) and is left alone.
+ $legacy_key = isset( $attrs['textAlign'] ) ? 'textAlign' : ( 'core/paragraph' === $block_name && isset( $attrs['align'] ) ? 'align' : null );
+ // Hand-authored block JSON can carry a non-array `style`; leave it alone.
+ if (
+ null !== $legacy_key
+ && is_array( $attrs['style'] ?? array() )
+ && is_array( $attrs['style']['typography'] ?? array() )
+ && ! isset( $attrs['style']['typography']['textAlign'] )
+ ) {
+ $attrs['style']['typography']['textAlign'] = $attrs[ $legacy_key ];
+ unset( $attrs[ $legacy_key ] );
+ }
+
+ self::ksort_recursive( $attrs );
+ $encoded = wp_json_encode( $attrs );
+
+ // An encode failure (invalid UTF-8, or nesting past the depth limit)
+ // would hash every such block alike and read as "unchanged". Fall back
+ // to serialize(), which handles both.
+ return sha1( false === $encoded ? serialize( $attrs ) : $encoded ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- Hash input only; never unserialized.
+ }
+
+ /**
+ * Sort an array by key at every level, in place.
+ *
+ * @param array<string, mixed> $value Array to sort.
+ */
+ private static function ksort_recursive( array &$value ): void {
+ ksort( $value );
+ foreach ( $value as &$item ) {
+ if ( is_array( $item ) ) {
+ self::ksort_recursive( $item );
+ }
+ }
+ }
+
+ /**
+ * Hash a block's markup. Unlike `inner_text` this keeps inline markup and
+ * HTML comments, which is how personalization tags (`<!--[woocommerce/…]-->`),
+ * links, emphasis and inline styling reach the comparison at all.
+ *
+ * The base side is a PHP template render and the post side was saved by the
+ * block editor, which decodes entities and trims the space just inside a
+ * block's outer tag even on blocks the merchant never touched. Both are
+ * normalized away here so such a block does not read as edited. Spacing
+ * around inline tags is kept: it changes what the merchant sees.
+ *
+ * @param string $inner_html Raw `innerHTML` string from a parsed block.
+ */
+ private static function inner_html_hash( string $inner_html ): string {
+ $decoded = html_entity_decode( $inner_html, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
+ $collapsed = preg_replace( '/[\s\x{00A0}]+/u', ' ', $decoded );
+ // Invalid UTF-8 makes `preg_replace()` return null; hashing the raw
+ // markup keeps two such blocks from reading as the same.
+ if ( null === $collapsed ) {
+ return sha1( $inner_html );
+ }
+ $collapsed = trim( $collapsed );
+ $trimmed = (string) preg_replace( '/^(<[^>]+>) /', '$1', $collapsed );
+ $trimmed = (string) preg_replace( '/ (<\/[^>]+>)$/', '$1', $trimmed );
+ return sha1( $trimmed );
+ }
+
/**
* Apply the namespace-alias map.
*
@@ -465,8 +558,8 @@ class WCEmailTemplateChangeSummary {
* RSM-143's selective-merge UI uses `path` to map per-block "Keep yours /
* Use core" choices back to specific blocks during merge.
*
- * @param array<int, array{path:array<int|string>, parent_name:?string, name:string, inner_text:string}> $core_records Core side.
- * @param array<int, array{path:array<int|string>, parent_name:?string, name:string, inner_text:string}> $post_records Post side.
+ * @param array<int, array{path:array<int|string>, parent_name:?string, name:string, inner_text:string, attrs_hash:string, inner_html_hash:string, holds_blocks:bool}> $core_records Core side.
+ * @param array<int, array{path:array<int|string>, parent_name:?string, name:string, inner_text:string, attrs_hash:string, inner_html_hash:string, holds_blocks:bool}> $post_records Post side.
*
* @return array{added_blocks:array<int, array<string, mixed>>, removed_blocks:array<int, array<string, mixed>>, copy_changes:array<int, array<string, mixed>>, structural_changes:array<int, array<string, mixed>>}
*/
@@ -534,7 +627,9 @@ class WCEmailTemplateChangeSummary {
}
}
- if ( $core['inner_text'] !== $post_r['inner_text'] ) {
+ // Blocks holding other blocks are never replaced by the apply step, so a
+ // change to their own markup is not offered either.
+ if ( $core['inner_text'] !== $post_r['inner_text'] && ! $core['holds_blocks'] && ! $post_r['holds_blocks'] ) {
// `before` = merchant's current text (what they have now), `after` = canonical
// core text (what they would get if they applied the update). Matches the
// design's "yours" → "core" diff convention.
@@ -690,9 +785,9 @@ class WCEmailTemplateChangeSummary {
*
* @internal
*
- * @param array<int, array{path:array<int|string>, parent_name:?string, name:string, inner_text:string}> $core_records Core side (current canonical).
- * @param array<int, array{path:array<int|string>, parent_name:?string, name:string, inner_text:string}> $base_records Base side (canonical at last system write).
- * @param array<int, array{path:array<int|string>, parent_name:?string, name:string, inner_text:string}> $post_records Post side (merchant's current post_content).
+ * @param array<int, array{path:array<int|string>, parent_name:?string, name:string, inner_text:string, attrs_hash:string, inner_html_hash:string, holds_blocks:bool}> $core_records Core side (current canonical).
+ * @param array<int, array{path:array<int|string>, parent_name:?string, name:string, inner_text:string, attrs_hash:string, inner_html_hash:string, holds_blocks:bool}> $base_records Base side (canonical at last system write).
+ * @param array<int, array{path:array<int|string>, parent_name:?string, name:string, inner_text:string, attrs_hash:string, inner_html_hash:string, holds_blocks:bool}> $post_records Post side (merchant's current post_content).
*
* @return array{added_blocks:array<int, array<string, mixed>>, removed_blocks:array<int, array<string, mixed>>, copy_changes:array<int, array<string, mixed>>, structural_changes:array<int, array<string, mixed>>}
*
@@ -762,11 +857,7 @@ class WCEmailTemplateChangeSummary {
$name = $core['name'];
$occurrence_index[ $name ] = ( $occurrence_index[ $name ] ?? 0 ) + 1;
- // Known limitation: comparison is `inner_text` only; block `attrs` (colors,
- // alignment, etc.) don't register as changes. With `auto_resolvable: true`
- // the drawer can silently overwrite an attr-only merchant edit. Follow-up
- // to extend the comparison to a stable hash of `attrs`.
- $yours_changed = ( $base['inner_text'] !== $post['inner_text'] );
+ $yours_changed = self::merchant_changed( $base, $post );
$core_changed = ( $base['inner_text'] !== $core['inner_text'] );
if ( ! $yours_changed && ! $core_changed ) {
@@ -777,6 +868,20 @@ class WCEmailTemplateChangeSummary {
continue;
}
+ // The merchant already has core's version, having typed it themselves.
+ // Offering a choice between two identical blocks reads as a bug, and
+ // it counts as a conflict, which holds up the one-click update.
+ if ( $core['inner_html_hash'] === $post['inner_html_hash'] ) {
+ continue;
+ }
+
+ // Apply never takes the content of a block holding other blocks, so
+ // listing one here would promise a change that cannot happen. Only
+ // its styling follows core, which needs no decision either way.
+ if ( ( $core['holds_blocks'] ?? false ) || ( $post['holds_blocks'] ?? false ) ) {
+ continue;
+ }
+
// Core changed (with or without yours also changing).
$copy_changes[] = array(
'block' => self::block_label( $name ),
@@ -852,6 +957,34 @@ class WCEmailTemplateChangeSummary {
);
}
+ /**
+ * Whether the merchant has touched a block since the base render. Compares
+ * the markup and the attributes, not the stripped text: a swapped
+ * personalization tag, an added link, a colour or a block lock are all
+ * merchant edits, and a block they have edited is never updated without
+ * asking them. When in doubt the answer is "edited": that asks the merchant
+ * rather than overwriting them.
+ *
+ * Core's side is deliberately compared on `inner_text` alone by the callers.
+ * A core change the merchant cannot see as a wording change would otherwise
+ * reach the drawer as an entry whose before and after text are identical.
+ *
+ * Public so {@see WCEmailTemplateSelectiveApplier} decides from the same rule
+ * that produced the summary the merchant reviewed, rather than reading the
+ * classification back out of a cached payload.
+ *
+ * @internal
+ *
+ * @param array{inner_text:string, attrs_hash:string, inner_html_hash:string} $base Base-side record.
+ * @param array{inner_text:string, attrs_hash:string, inner_html_hash:string} $post Post-side record.
+ *
+ * @since 11.3.0
+ */
+ public static function merchant_changed( array $base, array $post ): bool {
+ return $base['inner_html_hash'] !== $post['inner_html_hash']
+ || $base['attrs_hash'] !== $post['attrs_hash'];
+ }
+
/**
* Match each base block to its counterpart in core and in the post. A core
* block and a post block are the same logical block only when both match
@@ -862,9 +995,9 @@ class WCEmailTemplateChangeSummary {
*
* @internal
*
- * @param array<int, array{path:array<int|string>, parent_name:?string, name:string, inner_text:string}> $core_records Core side (current canonical).
- * @param array<int, array{path:array<int|string>, parent_name:?string, name:string, inner_text:string}> $base_records Base side (canonical at last system write).
- * @param array<int, array{path:array<int|string>, parent_name:?string, name:string, inner_text:string}> $post_records Post side (merchant's current post_content).
+ * @param array<int, array{path:array<int|string>, parent_name:?string, name:string, inner_text:string, attrs_hash:string, inner_html_hash:string, holds_blocks:bool}> $core_records Core side (current canonical).
+ * @param array<int, array{path:array<int|string>, parent_name:?string, name:string, inner_text:string, attrs_hash:string, inner_html_hash:string, holds_blocks:bool}> $base_records Base side (canonical at last system write).
+ * @param array<int, array{path:array<int|string>, parent_name:?string, name:string, inner_text:string, attrs_hash:string, inner_html_hash:string, holds_blocks:bool}> $post_records Post side (merchant's current post_content).
*
* @return array<int, array{core:?int, post:?int}> Keyed by base record index; values are core and post record indices.
*
@@ -938,8 +1071,8 @@ class WCEmailTemplateChangeSummary {
*
* @internal
*
- * @param array<int, array{path:array<int|string>, parent_name:?string, name:string, inner_text:string}> $a Core records.
- * @param array<int, array{path:array<int|string>, parent_name:?string, name:string, inner_text:string}> $b Post records.
+ * @param array<int, array{path:array<int|string>, parent_name:?string, name:string, inner_text:string, attrs_hash:string, inner_html_hash:string, holds_blocks:bool}> $a Core records.
+ * @param array<int, array{path:array<int|string>, parent_name:?string, name:string, inner_text:string, attrs_hash:string, inner_html_hash:string, holds_blocks:bool}> $b Post records.
*
* @return array<int, array{0:int, 1:int}>
*/
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSelectiveApplier.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSelectiveApplier.php
index 1aa6ebb8944..42fb30512b1 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSelectiveApplier.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSelectiveApplier.php
@@ -17,11 +17,12 @@ use Automattic\WooCommerce\Internal\EmailEditor\Logger;
*
* V1 algorithm (spine = the merchant's post):
*
- * - **`copy_changes`** (matched pair, different inner_text): default decision
- * is `keep_yours`. When the merchant explicitly opts into `use_core`, the
- * matched block's `innerHTML` / `innerContent` is replaced with core's
- * version. Block `attrs` are preserved from the post side (no attribute
- * diff in v1).
+ * - **`copy_changes`** (matched pair, different inner_text): the default is
+ * `use_core` for a block only core changed and `keep_yours` for one the
+ * merchant also touched. An explicit choice overrides either. Applying
+ * `use_core` replaces the matched block's `innerHTML` / `innerContent` and
+ * `attrs` with core's, so the block is core's version whole. On the legacy
+ * two-way path the post's `attrs` are preserved, as before.
* - **`added_blocks`** (in core, not in post): always applied. Inserted at
* the equivalent position from core's path; if the path can't be navigated
* in the post tree, falls back to appending at the closest level.
@@ -36,21 +37,23 @@ use Automattic\WooCommerce\Internal\EmailEditor\Logger;
* subsequent apply overwrites the snapshot. {@see self::undo()} restores from
* the snapshot when the supplied `revision_id` matches.
*
- * Three-way payload consumption (since 10.9.0): when the post has
+ * Three-way merge (since 10.9.0): when the post has
* {@see WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY} meta,
- * `apply_selectively()` passes the change-summary's payload through to
- * `merge()`, together with that base render:
+ * `apply_selectively()` hands that base render to `merge()`:
*
- * - Core and post blocks are paired through the base, exactly as the summary
- * pairs them, so the merge writes what the merchant reviewed. Blocks the
- * merchant deleted are not re-inserted.
- * - Matched pairs whose paths are NOT in `copy_changes` are silently
- * preserved (yours-only edits aren't conflicts; the `use_core` decision
- * is ignored on those paths).
+ * - Core and post blocks are paired through the base, exactly as the change
+ * summary pairs them, so the merge writes what the merchant reviewed. Blocks
+ * the merchant deleted are not re-inserted.
+ * - Each pair is classified with the summary's own rule
+ * ({@see WCEmailTemplateChangeSummary::merchant_changed()}) rather than read
+ * back out of the cached summary payload, so an apply can't act on a
+ * classification the merchant was never shown.
+ * - A block only the merchant changed is preserved silently — it isn't a
+ * conflict, and a `use_core` decision for it is ignored.
*
* Posts without the meta keep the legacy two-way behavior — `merge()` runs
- * its own LCS and treats every text-divergent matched pair as a candidate
- * for `use_core`.
+ * its own LCS, treats every text-divergent matched pair as a candidate for
+ * `use_core`, and defaults each to `keep_yours`.
*
* @package Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails
* @since 10.9.0
@@ -89,7 +92,7 @@ class WCEmailTemplateSelectiveApplier {
* Apply the selected set of core template changes to a `woo_email` post.
*
* @param int $post_id The `woo_email` post ID.
- * @param array<int, array{path:array<int|string>, decision:string}> $choices Per-conflict choices keyed implicitly by `path`. `decision` is `'keep_yours'` (default if absent) or `'use_core'`. Choices for paths that don't correspond to a `copy_changes` entry are ignored — auto-resolved entries are non-overridable in v1.
+ * @param array<int, array{path:array<int|string>, decision:string}> $choices Per-conflict choices keyed implicitly by `path`. `decision` is `'keep_yours'` or `'use_core'`. A path with no choice falls back to the summary's classification: `use_core` when only core changed the block, `keep_yours` otherwise. Choices for paths that don't correspond to a `copy_changes` entry are ignored — added, removed and structural entries cannot be overridden.
*
* @return array<string, mixed>|\WP_Error On success, an array with keys
* `merged_content`, `revision_id`,
@@ -187,13 +190,12 @@ class WCEmailTemplateSelectiveApplier {
);
}//end try
- // When the post has `last_core_render` meta, the change-summary already classified
- // each block via three-way attribution (yours-vs-base, core-vs-base). The merge pairs
- // blocks through the same base and only accepts use_core on the summary's conflicts.
+ // When the post has `last_core_render` meta, the merge pairs blocks through that base
+ // and classifies each pair with the same rule the change summary used, so it applies
+ // what the merchant reviewed rather than reading a cached payload back.
$base_render_for_merge = (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY, true );
- $summary_for_merge = '' !== $base_render_for_merge ? $summary : null;
- $merged_result = self::merge( $post_content, $core_content, $choices, $summary_for_merge, $base_render_for_merge );
+ $merged_result = self::merge( $post_content, $core_content, $choices, $base_render_for_merge );
$merged_content = $merged_result['content'];
$structural_skipped = $merged_result['structural_skipped'];
$aliases_migrated = $merged_result['aliases_migrated'];
@@ -451,29 +453,34 @@ class WCEmailTemplateSelectiveApplier {
* Compute the merged block tree, starting from the merchant's post and
* layering on core's changes per the v1 algorithm.
*
- * When `$precomputed_summary` is provided (the caller's `last_core_render`
- * meta was set, so the change-summary ran three-way attribution), core and
- * post blocks are paired through `$base_content` with
+ * With `$base_content` (the caller's `last_core_render` meta was set), core
+ * and post blocks are paired through the base with
* {@see WCEmailTemplateChangeSummary::align_to_base()}, the same pairing the
- * summary shown to the merchant uses:
- *
- * - Only pairs whose path is in `copy_changes` accept `use_core`; yours-only
- * edits are preserved even if a `use_core` decision was passed.
+ * summary shown to the merchant uses, and each pair is classified with
+ * {@see WCEmailTemplateChangeSummary::merchant_changed()}, the same rule:
+ *
+ * - Only a block core reworded is on offer, as in the drawer. A styling-only
+ * core change is not applied, and neither is anything on a block that
+ * holds other blocks.
+ * - A reworded block the merchant never touched defaults to `use_core` — the
+ * drawer lists it as auto-resolved and states it will apply.
+ * - A reworded block the merchant also changed defaults to `keep_yours`.
+ * - Whichever way `use_core` is reached, the block takes core's `attrs`
+ * along with its content, so it is core's version whole.
* - Core blocks the merchant deleted are not re-inserted.
*
- * Without `$precomputed_summary` (legacy two-way fallback), core and post
- * are paired with a direct LCS and every matched pair with differing
- * inner_text is eligible for `use_core`.
+ * Without `$base_content` (legacy two-way fallback), core and post are paired
+ * with a direct LCS, every matched pair with differing inner_text is eligible
+ * for `use_core`, and the default stays `keep_yours`.
*
- * @param string $post_content Merchant's current `post_content`.
- * @param string $core_content Canonical core render.
- * @param array<int, array{path:array<int|string>, decision:string}> $choices Per-conflict choices.
- * @param array<string, mixed>|null $precomputed_summary Optional three-way summary payload from {@see WCEmailTemplateChangeSummary::summarize()}; pass `null` to use the legacy two-way merge.
- * @param string $base_content The `last_core_render` the summary was computed against. Used only with `$precomputed_summary`.
+ * @param string $post_content Merchant's current `post_content`.
+ * @param string $core_content Canonical core render.
+ * @param array<int, array{path:array<int|string>, decision:string}> $choices Per-conflict choices.
+ * @param string $base_content The `last_core_render`, or `''` for the legacy two-way merge.
*
* @return array{content:string, structural_skipped:bool, aliases_migrated:string[]}
*/
- private static function merge( string $post_content, string $core_content, array $choices, ?array $precomputed_summary = null, string $base_content = '' ): array {
+ private static function merge( string $post_content, string $core_content, array $choices, string $base_content = '' ): array {
$post_blocks = parse_blocks( $post_content );
$core_blocks = parse_blocks( $core_content );
@@ -488,11 +495,13 @@ class WCEmailTemplateSelectiveApplier {
$post_records = WCEmailTemplateChangeSummary::flatten_blocks( $post_blocks );
$core_records = WCEmailTemplateChangeSummary::flatten_blocks( $core_blocks );
+ $three_way = '' !== $base_content;
$merchant_removed_core_set = array();
- if ( null !== $precomputed_summary ) {
+ $base_of_post = array();
+ if ( $three_way ) {
$base_records = WCEmailTemplateChangeSummary::flatten_blocks( parse_blocks( $base_content ) );
$matches = array();
- foreach ( WCEmailTemplateChangeSummary::align_to_base( $core_records, $base_records, $post_records ) as $sides ) {
+ foreach ( WCEmailTemplateChangeSummary::align_to_base( $core_records, $base_records, $post_records ) as $base_idx => $sides ) {
if ( null === $sides['core'] ) {
continue;
}
@@ -500,7 +509,8 @@ class WCEmailTemplateSelectiveApplier {
$merchant_removed_core_set[ $sides['core'] ] = true;
continue;
}
- $matches[] = array( $sides['core'], $sides['post'] );
+ $matches[] = array( $sides['core'], $sides['post'] );
+ $base_of_post[ $sides['post'] ] = $base_records[ $base_idx ];
}
} else {
$matches = WCEmailTemplateChangeSummary::lcs_matches( $core_records, $post_records );
@@ -518,17 +528,6 @@ class WCEmailTemplateSelectiveApplier {
$choice_map[ self::path_key( $choice['path'] ) ] = $decision;
}
- // `null` signals the legacy two-way path (every differing pair is eligible).
- $copy_change_paths = null;
- if ( null !== $precomputed_summary ) {
- $copy_change_paths = array();
- foreach ( $precomputed_summary['copy_changes'] ?? array() as $cc ) {
- if ( isset( $cc['path'] ) && is_array( $cc['path'] ) ) {
- $copy_change_paths[ self::path_key( $cc['path'] ) ] = true;
- }
- }
- }
-
// Pass 1: matched pairs. Apply use_core decisions on copy changes;
// detect parent-name diffs (structural punted, but we still flag
// `structural_skipped` so the caller can surface it).
@@ -547,27 +546,56 @@ class WCEmailTemplateSelectiveApplier {
$structural_skipped = true;
}
- if ( $core_rec['inner_text'] === $post_rec['inner_text'] ) {
+ $core_block = self::block_at_path( $core_blocks, $core_rec['path'] );
+ $post_block = self::block_at_path( $post_blocks, $post_rec['path'] );
+ if ( null === $core_block || null === $post_block ) {
continue;
}
- // Three-way gate: only paths the summary surfaced as `copy_changes`
- // are eligible for `use_core`. Yours-only edits are silently
- // preserved — they aren't conflicts.
- if ( null !== $copy_change_paths && ! isset( $copy_change_paths[ $post_key ] ) ) {
+ // A block holding other blocks is left alone. Taking core's content
+ // would replace `innerBlocks` wholesale and discard merchant edits this
+ // same pass is preserving, since the children are paired separately and
+ // each gets its own turn. The quote in the customer-note template is one.
+ if ( ! empty( $core_block['innerBlocks'] ) || ! empty( $post_block['innerBlocks'] ) ) {
continue;
}
- $decision = $choice_map[ $post_key ] ?? 'keep_yours';
- if ( 'use_core' !== $decision ) {
+ $auto_resolvable = false;
+ if ( $three_way ) {
+ $base_rec = $base_of_post[ $pair[1] ];
+
+ // Only a block core reworded is on offer, which is what the drawer
+ // lists. A styling-only core change has no wording to show there,
+ // so apply leaves it out too rather than change a block unannounced.
+ if ( $base_rec['inner_text'] === $core_rec['inner_text'] ) {
+ continue;
+ }
+
+ // The merchant already has core's markup, having typed it themselves.
+ if ( $core_rec['inner_html_hash'] === $post_rec['inner_html_hash'] ) {
+ continue;
+ }
+
+ $auto_resolvable = ! WCEmailTemplateChangeSummary::merchant_changed( $base_rec, $post_rec );
+ } elseif ( $core_rec['inner_text'] === $post_rec['inner_text'] ) {
continue;
}
- $core_block = self::block_at_path( $core_blocks, $core_rec['path'] );
- if ( null === $core_block ) {
+ // A block only core changed is listed in the drawer as auto-resolved,
+ // stating the update will apply, and no choice is sent for it — by the
+ // drawer or by a bulk or CLI caller — so the default has to carry it.
+ // A choice, when one is sent, still wins.
+ $decision = $choice_map[ $post_key ] ?? ( $auto_resolvable ? 'use_core' : 'keep_yours' );
+ if ( 'use_core' !== $decision ) {
continue;
}
- $post_blocks = self::replace_block_content_at_path( $post_blocks, $post_rec['path'], $core_block );
+
+ // Core's `attrs` come along with its content: taking the text while
+ // keeping the merchant's styling leaves the block describing markup it
+ // no longer has, and "Use core" says the merchant's edit is discarded.
+ // The legacy two-way path has no base to judge against, so it keeps
+ // its old attrs-preserving behaviour.
+ $post_blocks = self::replace_block_content_at_path( $post_blocks, $post_rec['path'], $core_block, $three_way );
}//end foreach
// Pass 2: unmatched core records. Insert non-structural blocks at
@@ -710,22 +738,22 @@ class WCEmailTemplateSelectiveApplier {
}
/**
- * Replace the block at the given path with another block's content.
- * Preserves the post block's `attrs` (no attribute-level apply in v1);
- * copies the source block's `innerHTML`, `innerContent`, and
- * `innerBlocks` over the target.
+ * Replace the block at the given path with another block's content: the
+ * source block's `innerHTML`, `innerContent` and `innerBlocks`, plus its
+ * `attrs` when `$copy_attrs` is set.
*
* @param array<int|string, array<string, mixed>> $blocks Mutable block tree.
* @param array<int|string> $path Index path through `parse_blocks` output.
* @param array<string, mixed> $source_block The block whose content to copy in.
+ * @param bool $copy_attrs Whether to take the source block's `attrs` as well.
*
* @return array<int|string, array<string, mixed>>
*/
- private static function replace_block_content_at_path( array $blocks, array $path, array $source_block ): array {
+ private static function replace_block_content_at_path( array $blocks, array $path, array $source_block, bool $copy_attrs = false ): array {
if ( empty( $path ) ) {
return $blocks;
}
- return self::replace_recursive( $blocks, array_values( $path ), 0, $source_block );
+ return self::replace_recursive( $blocks, array_values( $path ), 0, $source_block, $copy_attrs );
}
/**
@@ -735,24 +763,31 @@ class WCEmailTemplateSelectiveApplier {
* @param array<int|string> $path Path indices.
* @param int $depth Current depth.
* @param array<string, mixed> $source_block Source block to copy content from.
+ * @param bool $copy_attrs Whether to take the source block's `attrs` as well.
*
* @return array<int|string, array<string, mixed>>
*/
- private static function replace_recursive( array $blocks, array $path, int $depth, array $source_block ): array {
+ private static function replace_recursive( array $blocks, array $path, int $depth, array $source_block, bool $copy_attrs = false ): array {
$idx = (int) $path[ $depth ];
if ( ! isset( $blocks[ $idx ] ) ) {
return $blocks;
}
if ( count( $path ) - 1 === $depth ) {
+ // Callers keep blocks holding other blocks away from here: this
+ // replaces `innerBlocks` wholesale, which would discard whatever the
+ // merchant had inside.
$blocks[ $idx ]['innerHTML'] = $source_block['innerHTML'] ?? '';
$blocks[ $idx ]['innerContent'] = $source_block['innerContent'] ?? array();
$blocks[ $idx ]['innerBlocks'] = $source_block['innerBlocks'] ?? array();
+ if ( $copy_attrs ) {
+ $blocks[ $idx ]['attrs'] = $source_block['attrs'] ?? array();
+ }
return $blocks;
}
$inner = $blocks[ $idx ]['innerBlocks'] ?? array();
- $blocks[ $idx ]['innerBlocks'] = self::replace_recursive( is_array( $inner ) ? $inner : array(), $path, $depth + 1, $source_block );
+ $blocks[ $idx ]['innerBlocks'] = self::replace_recursive( is_array( $inner ) ? $inner : array(), $path, $depth + 1, $source_block, $copy_attrs );
return $blocks;
}
diff --git a/plugins/woocommerce/tests/e2e/test-plugins/wc-email-template-sync-test-helper/includes/class-rest-controller.php b/plugins/woocommerce/tests/e2e/test-plugins/wc-email-template-sync-test-helper/includes/class-rest-controller.php
index c099e81249a..d96f0640c5e 100644
--- a/plugins/woocommerce/tests/e2e/test-plugins/wc-email-template-sync-test-helper/includes/class-rest-controller.php
+++ b/plugins/woocommerce/tests/e2e/test-plugins/wc-email-template-sync-test-helper/includes/class-rest-controller.php
@@ -93,6 +93,29 @@ class REST_Controller {
)
);
+ register_rest_route(
+ self::NAMESPACE,
+ '/sync-enabled-emails',
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'sync_enabled_emails' ),
+ 'permission_callback' => array( self::class, 'require_admin' ),
+ )
+ );
+
+ register_rest_route(
+ self::NAMESPACE,
+ '/merchant-edited-blocks/(?P<post_id>\d+)',
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'merchant_edited_blocks' ),
+ 'permission_callback' => array( self::class, 'require_admin' ),
+ 'args' => array(
+ 'post_id' => array( 'sanitize_callback' => 'absint' ),
+ ),
+ )
+ );
+
register_rest_route(
self::NAMESPACE,
'/seed-bulk',
@@ -284,6 +307,68 @@ class REST_Controller {
);
}
+ /**
+ * List the email IDs enrolled in template sync, so a test can cover every shipped template.
+ *
+ * @param WP_REST_Request $request The REST request (unused).
+ * @return WP_REST_Response
+ */
+ public function sync_enabled_emails( WP_REST_Request $request ): WP_REST_Response {
+ unset( $request );
+
+ $registry = \Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncRegistry::get_sync_enabled_emails();
+
+ return new WP_REST_Response( array( 'email_ids' => array_map( 'strval', array_keys( $registry ) ) ), 200 );
+ }
+
+ /**
+ * Run the change summary's own "did the merchant edit this block?" rule on a post,
+ * comparing its content with the stored base render block by block. Lets a test
+ * assert that a save the merchant made no edits in leaves every block untouched.
+ *
+ * @param WP_REST_Request $request The REST request. Expects `post_id` route parameter.
+ * @return WP_REST_Response
+ */
+ public function merchant_edited_blocks( WP_REST_Request $request ): WP_REST_Response {
+ $post_id = (int) $request->get_param( 'post_id' );
+ $post = get_post( $post_id );
+
+ if ( ! $post ) {
+ return new WP_REST_Response( array( 'error' => "Post {$post_id} not found" ), 404 );
+ }
+
+ $summary = \Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateChangeSummary::class;
+ $base = (string) get_post_meta( $post_id, \Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY, true );
+
+ $base_records = $summary::flatten_blocks( parse_blocks( $base ) );
+ $post_records = $summary::flatten_blocks( parse_blocks( (string) $post->post_content ) );
+
+ $edited = array();
+ foreach ( $base_records as $i => $base_record ) {
+ $post_record = $post_records[ $i ] ?? null;
+ if ( null !== $post_record && ! $summary::merchant_changed( $base_record, $post_record ) ) {
+ continue;
+ }
+ $edited[] = array(
+ 'index' => $i,
+ 'block' => $base_record['name'],
+ 'text' => $base_record['inner_text'],
+ 'attrs_changed' => null === $post_record || $base_record['attrs_hash'] !== $post_record['attrs_hash'],
+ 'markup_changed' => null === $post_record || $base_record['inner_html_hash'] !== $post_record['inner_html_hash'],
+ );
+ }
+
+ return new WP_REST_Response(
+ array(
+ 'post_id' => $post_id,
+ 'base_blocks' => count( $base_records ),
+ 'post_blocks' => count( $post_records ),
+ 'edited' => $edited,
+ ),
+ 200
+ );
+ }
+
/**
* Read all post meta for a post. Mirrors seed-meta's response shape (without writes).
*
diff --git a/plugins/woocommerce/tests/e2e/tests/email-editor/update-propagation/core-flows.spec.ts b/plugins/woocommerce/tests/e2e/tests/email-editor/update-propagation/core-flows.spec.ts
index ffc3c3f09c1..9726cd75336 100644
--- a/plugins/woocommerce/tests/e2e/tests/email-editor/update-propagation/core-flows.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/email-editor/update-propagation/core-flows.spec.ts
@@ -154,12 +154,110 @@ test.describe(
firstRadioGroup.getByRole( 'radio', { name: /use core/i } )
).toHaveAttribute( 'aria-checked', 'true' );
+ // B and C were changed by core only. Assert the drawer's promise about
+ // them before applying, so the claim and the result stay tied together.
+ await expect(
+ drawer.getByText(
+ /your version was unchanged, so the update will apply/i
+ )
+ ).toHaveCount( 2 );
+
await drawer.getByRole( 'button', { name: /^apply/i } ).click();
await expect( drawer ).toBeHidden( { timeout: 15000 } );
const content = await getWooEmailPostContent( postId );
expect( content ).toContain( 'NEW CORE A' );
expect( content ).not.toContain( 'MERCHANT EDITED A' );
+
+ expect( content ).toContain( 'NEW CORE B' );
+ expect( content ).toContain( 'NEW CORE C' );
+ expect( content ).not.toContain( 'OLD BLOCK B' );
+ expect( content ).not.toContain( 'OLD BLOCK C' );
+ } );
+
+ /**
+ * Guards the "did the merchant edit this block?" check against the
+ * block editor itself. Saving in the editor rewrites blocks nobody
+ * edited: it migrates the `align` attribute, decodes entities and
+ * trims the space inside a block's outer tag. Those blocks must still
+ * count as untouched, or the update turns into conflicts the merchant
+ * never created.
+ */
+ test( '@pr Blocks the editor rewrote on save still auto-resolve', async ( {
+ page,
+ } ) => {
+ const oldHtml =
+ '<!-- wp:paragraph --><p>EDITABLE LINE</p><!-- /wp:paragraph -->' +
+ '<!-- wp:paragraph {"align":"center"} --><p class="has-text-align-center"> OLD CENTERED LINE </p><!-- /wp:paragraph -->' +
+ '<!-- wp:paragraph --><p>OLD LINE WE COULDN'T SKIP</p><!-- /wp:paragraph -->';
+
+ const newCanonical = oldHtml
+ .replace( 'OLD CENTERED LINE', 'NEW CENTERED LINE' )
+ .replace( 'OLD LINE WE', 'NEW LINE WE' );
+
+ await simulateCoreBump( 'new_order', oldHtml );
+ const postId = await seedWooEmailPost( {
+ emailId: 'new_order',
+ storedSourceHash: 'AUTO_CURRENT',
+ status: STATUS.IN_SYNC,
+ version: '10.0.0',
+ } );
+ seededPostId = postId;
+
+ // Edit one block and save, so the editor re-serializes the whole post.
+ await accessTheEmailEditor( page, 'New order' );
+ const canvas = page
+ .locator( 'iframe[name="editor-canvas"]' )
+ .contentFrame();
+ const editable = canvas.getByText( 'EDITABLE LINE' );
+ await editable.click();
+ await expect( editable ).toBeEditable();
+ await editable.fill( 'MERCHANT LINE' );
+ await page
+ .getByRole( 'button', { name: 'Save', exact: true } )
+ .click();
+ await expect(
+ page
+ .locator( '.components-snackbar' )
+ .filter( { hasText: 'Email saved.' } )
+ ).toBeVisible();
+
+ await setTemplateHtmlOverride( 'new_order', newCanonical );
+ await triggerDetectionSweep();
+
+ await accessTheEmailEditor( page, 'New order' );
+ await expect(
+ page.getByText( /template update available/i ).first()
+ ).toBeVisible( { timeout: 15000 } );
+
+ // Neither rewritten block is a conflict, so the banner offers the
+ // plain "Review" button, not "Review changes".
+ await page
+ .getByRole( 'button', { name: 'Review', exact: true } )
+ .click();
+
+ const drawer = page.getByRole( 'dialog', {
+ name: /review template update/i,
+ } );
+ await expect( drawer ).toBeVisible( { timeout: 15000 } );
+ await expect(
+ drawer.getByText(
+ /your version was unchanged, so the update will apply/i
+ )
+ ).toHaveCount( 2 );
+ await expect(
+ drawer.getByRole( 'heading', { name: /needs your attention/i } )
+ ).toHaveCount( 0 );
+
+ await drawer.getByRole( 'button', { name: /^apply/i } ).click();
+ await expect( drawer ).toBeHidden( { timeout: 15000 } );
+
+ const content = await getWooEmailPostContent( postId );
+ expect( content ).toContain( 'MERCHANT LINE' );
+ expect( content ).toContain( 'NEW CENTERED LINE' );
+ expect( content ).toContain( 'NEW LINE WE' );
+ expect( content ).not.toContain( 'OLD CENTERED LINE' );
+ expect( content ).not.toContain( 'OLD LINE WE' );
} );
}
);
diff --git a/plugins/woocommerce/tests/e2e/tests/email-editor/update-propagation/editor-rewrites.spec.ts b/plugins/woocommerce/tests/e2e/tests/email-editor/update-propagation/editor-rewrites.spec.ts
new file mode 100644
index 00000000000..440d1d92033
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e/tests/email-editor/update-propagation/editor-rewrites.spec.ts
@@ -0,0 +1,166 @@
+/**
+ * External dependencies
+ */
+import { test, expect } from '@playwright/test';
+
+/**
+ * Internal dependencies
+ */
+import { ADMIN_STATE_PATH } from '../../../playwright.config';
+import {
+ deleteEmailPost,
+ disableEmailEditor,
+ enableEmailEditor,
+} from '../helpers/enable-email-editor-feature';
+import { resetWooEmailPost } from './helpers/seed-woo-email';
+import {
+ getMerchantEditedBlocks,
+ listSyncEnabledEmails,
+} from './helpers/editor-rewrites';
+import { locks } from '../../../fixtures/fixtures';
+
+/**
+ * The "did the merchant edit this block?" check compares the PHP render of a
+ * template with what the block editor saved. The editor rewrites blocks nobody
+ * edited when it saves — it migrates attributes, decodes entities and trims
+ * padding — and the check folds in every rewrite it knows about. A WordPress
+ * release can add a new one. When that happens, untouched blocks read as
+ * edited, every core update to them turns into a conflict, and this is the
+ * test that says so.
+ *
+ * Not tagged `@pr`: it covers every shipped template and matters most on the
+ * WordPress pre-release job.
+ */
+test.describe(
+ 'Update propagation — editor rewrites',
+ { lock: locks.EMAIL_FEATURE_FLAGS },
+ () => {
+ test.use( { storageState: ADMIN_STATE_PATH } );
+ const seededPostIds: number[] = [];
+
+ test.beforeAll( async ( { baseURL } ) => {
+ await enableEmailEditor( baseURL! );
+ } );
+
+ test.afterEach( async ( { baseURL } ) => {
+ const cleanupErrors: unknown[] = [];
+
+ for ( const postId of seededPostIds.splice( 0 ) ) {
+ try {
+ await deleteEmailPost( baseURL!, String( postId ) );
+ } catch ( error ) {
+ cleanupErrors.push( error );
+ }
+ }
+
+ if ( cleanupErrors.length > 0 ) {
+ throw new AggregateError(
+ cleanupErrors,
+ 'Editor rewrites cleanup failed.'
+ );
+ }
+ } );
+
+ test.afterAll( async ( { baseURL } ) => {
+ await disableEmailEditor( baseURL! );
+ } );
+
+ test( 'Saving a shipped template in the editor without editing it leaves every block untouched', async ( {
+ page,
+ } ) => {
+ const emailIds = await listSyncEnabledEmails();
+ expect( emailIds.length ).toBeGreaterThan( 0 );
+
+ // One post per template, holding the template's own render as both
+ // content and stored base.
+ const postIds: Record< string, number > = {};
+ for ( const emailId of emailIds ) {
+ postIds[ emailId ] = await resetWooEmailPost( emailId );
+ seededPostIds.push( postIds[ emailId ] );
+ }
+
+ // Any email editor page carries the editor's serializer with every
+ // email block registered, so one page can re-save all the posts.
+ await page.goto(
+ `/wp-admin/post.php?post=${
+ postIds[ emailIds[ 0 ] ]
+ }&action=edit`
+ );
+ await expect(
+ page.locator( '#woocommerce-email-editor' )
+ ).toBeVisible( { timeout: 20000 } );
+ await page.waitForFunction( () =>
+ Boolean(
+ window.wp?.blocks?.getBlockType?.(
+ 'woocommerce/email-content'
+ )
+ )
+ );
+
+ const saved = await page.evaluate( async ( ids ) => {
+ const result = { count: 0, rewritten: 0 };
+ for ( const id of Object.values( ids ) ) {
+ const post = await window.wp.apiFetch( {
+ path: `/wp/v2/woo_email/${ id }?context=edit`,
+ } );
+ const raw: string = post.content.raw;
+ const serialized: string = window.wp.blocks.serialize(
+ window.wp.blocks.parse( raw )
+ );
+ if ( serialized !== raw ) {
+ result.rewritten++;
+ }
+ await window.wp.apiFetch( {
+ path: `/wp/v2/woo_email/${ id }`,
+ method: 'POST',
+ data: { content: serialized },
+ } );
+ result.count++;
+ }
+ return result;
+ }, postIds );
+ expect( saved.count ).toBe( emailIds.length );
+ test.info().annotations.push( {
+ type: 'editor rewrites',
+ description: `${ saved.rewritten } of ${ saved.count } templates changed on save`,
+ } );
+ // A pass means little when the editor rewrote nothing.
+ expect
+ .soft(
+ saved.rewritten,
+ 'The editor saved every template byte-for-byte, so this guard checked nothing.'
+ )
+ .toBeGreaterThan( 0 );
+
+ const flagged: string[] = [];
+ for ( const [ emailId, postId ] of Object.entries( postIds ) ) {
+ const check = await getMerchantEditedBlocks( postId );
+ expect(
+ check.base_blocks,
+ `${ emailId } has no blocks to compare`
+ ).toBeGreaterThan( 0 );
+ if ( check.post_blocks !== check.base_blocks ) {
+ flagged.push(
+ `${ emailId }: ${ check.base_blocks } blocks before save, ${ check.post_blocks } after`
+ );
+ }
+ for ( const entry of check.edited ) {
+ const what = [
+ entry.attrs_changed ? 'attrs' : '',
+ entry.markup_changed ? 'markup' : '',
+ ]
+ .filter( Boolean )
+ .join( '+' );
+ flagged.push(
+ `${ emailId } #${ entry.index } ${ entry.block } (${ what }): ${ entry.text }`
+ );
+ }
+ }
+
+ expect(
+ flagged,
+ 'Blocks nobody edited read as merchant-edited after an editor save. The editor has a new rewrite; fold it into WCEmailTemplateChangeSummary.'
+ ).toEqual( [] );
+ } );
+ }
+);
diff --git a/plugins/woocommerce/tests/e2e/tests/email-editor/update-propagation/helpers/editor-rewrites.ts b/plugins/woocommerce/tests/e2e/tests/email-editor/update-propagation/helpers/editor-rewrites.ts
new file mode 100644
index 00000000000..341f7f46182
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e/tests/email-editor/update-propagation/helpers/editor-rewrites.ts
@@ -0,0 +1,56 @@
+/**
+ * External dependencies
+ */
+import { createClient } from '@woocommerce/e2e-utils-playwright';
+
+/**
+ * Internal dependencies
+ */
+import { admin } from '../../../../test-data/data';
+import playwrightConfig from '../../../../playwright.config';
+import { TEST_HELPER_API_BASE } from './classifications';
+
+const baseURL = playwrightConfig.use?.baseURL ?? '';
+
+function apiClient() {
+ return createClient( baseURL, {
+ type: 'basic',
+ username: admin.username,
+ password: admin.password,
+ } );
+}
+
+export async function listSyncEnabledEmails(): Promise< string[] > {
+ const client = apiClient();
+ const res = await client.get(
+ `${ TEST_HELPER_API_BASE }/sync-enabled-emails`
+ );
+ return ( res?.data?.email_ids ?? [] ) as string[];
+}
+
+export type MerchantEditedBlocks = {
+ post_id: number;
+ base_blocks: number;
+ post_blocks: number;
+ edited: Array< {
+ index: number;
+ block: string;
+ text: string;
+ attrs_changed: boolean;
+ markup_changed: boolean;
+ } >;
+};
+
+/**
+ * Run the change summary's own "did the merchant edit this block?" rule on a
+ * post, against the base render stored on it.
+ */
+export async function getMerchantEditedBlocks(
+ postId: number
+): Promise< MerchantEditedBlocks > {
+ const client = apiClient();
+ const res = await client.get(
+ `${ TEST_HELPER_API_BASE }/merchant-edited-blocks/${ postId }`
+ );
+ return res?.data as MerchantEditedBlocks;
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateChangeSummaryTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateChangeSummaryTest.php
index 0587dbfe1a9..ee7ddb79089 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateChangeSummaryTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateChangeSummaryTest.php
@@ -593,6 +593,361 @@ class WCEmailTemplateChangeSummaryTest extends \WC_Unit_Test_Case {
$this->assertCount( 1, $diff['copy_changes'] );
$this->assertSame( 'Hi.', $diff['copy_changes'][0]['before'] );
$this->assertSame( 'Hello.', $diff['copy_changes'][0]['after'] );
+ $this->assertTrue(
+ $diff['copy_changes'][0]['auto_resolvable'],
+ 'A block the merchant never touched must stay auto-resolvable — the applier takes core\'s version from this flag.'
+ );
+ }
+
+ /**
+ * @testdox A block the editor rewrote on save, without a merchant edit, does not read as merchant-edited.
+ *
+ * @dataProvider provider_editor_rewrites_of_untouched_blocks
+ *
+ * @param string $template_markup The block as the PHP template renders it.
+ * @param string $saved_markup The same block after the block editor saved the post.
+ */
+ public function test_editor_rewrite_of_untouched_block_is_not_a_merchant_edit( string $template_markup, string $saved_markup ): void {
+ $base = WCEmailTemplateChangeSummary::flatten_blocks( parse_blocks( $template_markup ) )[0];
+ $post = WCEmailTemplateChangeSummary::flatten_blocks( parse_blocks( $saved_markup ) )[0];
+
+ $this->assertFalse(
+ WCEmailTemplateChangeSummary::merchant_changed( $base, $post ),
+ 'The editor made this change by itself; reading it as an edit turns an update the merchant should get into a conflict.'
+ );
+ }
+
+ /**
+ * Rewrites the block editor makes when it saves a block nobody edited,
+ * as seen on the shipped templates with WordPress 7.1.
+ *
+ * @return array<string, array{string, string}>
+ */
+ public function provider_editor_rewrites_of_untouched_blocks(): array {
+ return array(
+ 'align attribute migrated to style' => array(
+ '<!-- wp:paragraph {"align":"center"} --><p class="has-text-align-center">Thanks again!</p><!-- /wp:paragraph -->',
+ '<!-- wp:paragraph {"style":{"typography":{"textAlign":"center"}}} --><p class="has-text-align-center">Thanks again!</p><!-- /wp:paragraph -->',
+ ),
+ 'heading textAlign migrated to style' => array(
+ '<!-- wp:heading {"textAlign":"center"} --><h2 class="wp-block-heading has-text-align-center">Thanks again!</h2><!-- /wp:heading -->',
+ '<!-- wp:heading {"style":{"typography":{"textAlign":"center"}}} --><h2 class="wp-block-heading has-text-align-center">Thanks again!</h2><!-- /wp:heading -->',
+ ),
+ 'button textAlign migrated to style' => array(
+ '<!-- wp:button {"textAlign":"center"} --><div class="wp-block-button"><a class="wp-block-button__link has-text-align-center wp-element-button">Go</a></div><!-- /wp:button -->',
+ '<!-- wp:button {"style":{"typography":{"textAlign":"center"}}} --><div class="wp-block-button"><a class="wp-block-button__link has-text-align-center wp-element-button">Go</a></div><!-- /wp:button -->',
+ ),
+ 'entities decoded' => array(
+ '<!-- wp:paragraph --><p>We couldn't process your order — sorry.</p><!-- /wp:paragraph -->',
+ '<!-- wp:paragraph --><p>We couldn\'t process your order — sorry.</p><!-- /wp:paragraph -->',
+ ),
+ 'padding inside the outer tag' => array(
+ '<!-- wp:woocommerce/email-content --><div class="wp-block-woocommerce-email-content"> ##WOO_CONTENT## </div><!-- /wp:woocommerce/email-content -->',
+ '<!-- wp:woocommerce/email-content --><div class="wp-block-woocommerce-email-content">##WOO_CONTENT##</div><!-- /wp:woocommerce/email-content -->',
+ ),
+ );
+ }
+
+ /**
+ * @testdox An edit the merchant made in the editor reads as merchant-edited, even when the visible text is unchanged.
+ *
+ * @dataProvider provider_merchant_edits_with_unchanged_text
+ *
+ * @param string $saved_markup The block after the merchant's edit.
+ */
+ public function test_merchant_edit_with_unchanged_text_is_detected( string $saved_markup ): void {
+ $template_markup = '<!-- wp:paragraph --><p>Hi <!--[woocommerce/customer-first-name]-->, read more today.</p><!-- /wp:paragraph -->';
+
+ $base = WCEmailTemplateChangeSummary::flatten_blocks( parse_blocks( $template_markup ) )[0];
+ $post = WCEmailTemplateChangeSummary::flatten_blocks( parse_blocks( $saved_markup ) )[0];
+
+ $this->assertSame( $base['inner_text'], $post['inner_text'], 'Fixture check: the visible text has to be the same, or the text comparison would catch it anyway.' );
+ $this->assertTrue(
+ WCEmailTemplateChangeSummary::merchant_changed( $base, $post ),
+ 'This is the merchant\'s own work; an update must not replace it without asking.'
+ );
+ }
+
+ /**
+ * @return array<string, array{string}>
+ */
+ public function provider_merchant_edits_with_unchanged_text(): array {
+ return array(
+ 'colour' => array( '<!-- wp:paragraph {"style":{"color":{"text":"#ff0000"}}} --><p class="has-text-color" style="color:#ff0000">Hi <!--[woocommerce/customer-first-name]-->, read more today.</p><!-- /wp:paragraph -->' ),
+ 'bold' => array( '<!-- wp:paragraph --><p>Hi <!--[woocommerce/customer-first-name]-->, read <strong>more</strong> today.</p><!-- /wp:paragraph -->' ),
+ 'link' => array( '<!-- wp:paragraph --><p>Hi <!--[woocommerce/customer-first-name]-->, <a href="https://example.com">read more</a> today.</p><!-- /wp:paragraph -->' ),
+ 'swapped personalization tag' => array( '<!-- wp:paragraph --><p>Hi <!--[woocommerce/customer-full-name]-->, read more today.</p><!-- /wp:paragraph -->' ),
+ 'block lock, no markup change' => array( '<!-- wp:paragraph {"lock":{"move":false,"remove":true}} --><p>Hi <!--[woocommerce/customer-first-name]-->, read more today.</p><!-- /wp:paragraph -->' ),
+ );
+ }
+
+ /**
+ * @testdox A paragraph carrying both `align` and `style.typography.textAlign` is not folded, so a differing save reads as a merchant edit.
+ *
+ * The fold only covers the editor's own migration of a lone `align`. With
+ * both present there is no single rewrite to undo, so the conservative
+ * answer is "ask the merchant".
+ */
+ public function test_paragraph_with_both_align_forms_is_not_folded(): void {
+ $template_markup = '<!-- wp:paragraph {"align":"center","style":{"typography":{"textAlign":"left"}}} --><p class="has-text-align-center">Thanks again!</p><!-- /wp:paragraph -->';
+ $saved_markup = '<!-- wp:paragraph {"style":{"typography":{"textAlign":"center"}}} --><p class="has-text-align-center">Thanks again!</p><!-- /wp:paragraph -->';
+
+ $base = WCEmailTemplateChangeSummary::flatten_blocks( parse_blocks( $template_markup ) )[0];
+ $post = WCEmailTemplateChangeSummary::flatten_blocks( parse_blocks( $saved_markup ) )[0];
+
+ $this->assertTrue( WCEmailTemplateChangeSummary::merchant_changed( $base, $post ) );
+ }
+
+ /**
+ * @testdox `align` on a block other than a paragraph is layout alignment and is not folded into text alignment.
+ */
+ public function test_layout_align_on_other_blocks_is_not_folded(): void {
+ $template_markup = '<!-- wp:image {"align":"center"} --><figure class="wp-block-image aligncenter"><img src="x.png" alt=""/></figure><!-- /wp:image -->';
+ $saved_markup = '<!-- wp:image {"style":{"typography":{"textAlign":"center"}}} --><figure class="wp-block-image aligncenter"><img src="x.png" alt=""/></figure><!-- /wp:image -->';
+
+ $base = WCEmailTemplateChangeSummary::flatten_blocks( parse_blocks( $template_markup ) )[0];
+ $post = WCEmailTemplateChangeSummary::flatten_blocks( parse_blocks( $saved_markup ) )[0];
+
+ $this->assertTrue( WCEmailTemplateChangeSummary::merchant_changed( $base, $post ) );
+ }
+
+ /**
+ * @testdox Hand-authored block JSON with a non-array `style` does not break the attribute comparison.
+ */
+ public function test_malformed_style_attribute_is_compared_without_error(): void {
+ $markup = '<!-- wp:paragraph {"align":"center","style":"x"} --><p class="has-text-align-center">Thanks again!</p><!-- /wp:paragraph -->';
+
+ $record = WCEmailTemplateChangeSummary::flatten_blocks( parse_blocks( $markup ) )[0];
+
+ $this->assertFalse( WCEmailTemplateChangeSummary::merchant_changed( $record, $record ) );
+ }
+
+ /**
+ * @testdox Two blocks with different invalid UTF-8 markup do not hash the same.
+ */
+ public function test_invalid_utf8_markup_still_tells_blocks_apart(): void {
+ $one = WCEmailTemplateChangeSummary::flatten_blocks( parse_blocks( "<!-- wp:paragraph --><p>\xff abc</p><!-- /wp:paragraph -->" ) )[0];
+ $two = WCEmailTemplateChangeSummary::flatten_blocks( parse_blocks( "<!-- wp:paragraph --><p>\xfe xyz</p><!-- /wp:paragraph -->" ) )[0];
+
+ $this->assertNotSame( $one['inner_html_hash'], $two['inner_html_hash'] );
+ }
+
+ /**
+ * @testdox Two-way diff: a block holding other blocks produces no entry, matching what apply does on both paths.
+ */
+ public function test_two_way_block_holding_other_blocks_yields_no_entry(): void {
+ $quote = static fn( string $text ): array => array(
+ array(
+ 'name' => 'core/quote',
+ 'inner_text' => $text,
+ 'holds_blocks' => true,
+ ),
+ );
+
+ $two_way_method = new \ReflectionMethod( WCEmailTemplateChangeSummary::class, 'diff_records' );
+ $two_way_method->setAccessible( true );
+ $diff = $two_way_method->invoke( null, self::records( $quote( 'A word from us.' ) ), self::records( $quote( 'A note from us.' ) ) );
+
+ $this->assertSame( array(), $diff['copy_changes'] );
+ }
+
+ /**
+ * @testdox Three-way diff: a merchant edit to a block's settings alone makes core's text change a conflict.
+ */
+ public function test_three_way_settings_only_merchant_edit_is_a_conflict(): void {
+ $base = self::records(
+ array(
+ array(
+ 'name' => 'core/paragraph',
+ 'inner_text' => 'Hi.',
+ ),
+ )
+ );
+ $core = self::records(
+ array(
+ array(
+ 'name' => 'core/paragraph',
+ 'inner_text' => 'Hello.',
+ ),
+ )
+ );
+ $post = self::records(
+ array(
+ array(
+ 'name' => 'core/paragraph',
+ 'inner_text' => 'Hi.',
+ 'attrs_hash' => 'attrs-merchant-locked',
+ ),
+ )
+ );
+
+ $diff = WCEmailTemplateChangeSummary::diff_records_three_way( $core, $base, $post );
+
+ $this->assertCount( 1, $diff['copy_changes'] );
+ $this->assertFalse(
+ $diff['copy_changes'][0]['auto_resolvable'],
+ 'A settings-only merchant edit is still a merchant edit; applying core over it must be the merchant\'s call.'
+ );
+ }
+
+ /**
+ * @testdox Three-way diff: a merchant edit to a block's markup alone, such as swapping a personalization tag, makes core's text change a conflict.
+ */
+ public function test_three_way_markup_only_merchant_edit_is_a_conflict(): void {
+ // Both texts strip to "Hi , thanks" — the swapped tag lives only in the markup.
+ $base = self::records(
+ array(
+ array(
+ 'name' => 'core/paragraph',
+ 'inner_text' => 'Hi , thanks',
+ 'inner_html_hash' => 'html-first-name-tag',
+ ),
+ )
+ );
+ $core = self::records(
+ array(
+ array(
+ 'name' => 'core/paragraph',
+ 'inner_text' => 'Hey , thanks',
+ 'inner_html_hash' => 'html-core-reworded',
+ ),
+ )
+ );
+ $post = self::records(
+ array(
+ array(
+ 'name' => 'core/paragraph',
+ 'inner_text' => 'Hi , thanks',
+ 'inner_html_hash' => 'html-full-name-tag',
+ ),
+ )
+ );
+
+ $diff = WCEmailTemplateChangeSummary::diff_records_three_way( $core, $base, $post );
+
+ $this->assertCount( 1, $diff['copy_changes'] );
+ $this->assertFalse(
+ $diff['copy_changes'][0]['auto_resolvable'],
+ 'Swapping a personalization tag is a merchant edit even though the stripped text is unchanged.'
+ );
+ }
+
+ /**
+ * @testdox Three-way diff: no entry when the merchant already wrote what core now says, so the drawer never offers a choice between two identical blocks.
+ */
+ public function test_three_way_merchant_already_matches_core_yields_no_entry(): void {
+ $base = self::records(
+ array(
+ array(
+ 'name' => 'core/paragraph',
+ 'inner_text' => 'Hi.',
+ ),
+ )
+ );
+ $already_updated = array(
+ array(
+ 'name' => 'core/paragraph',
+ 'inner_text' => 'Hello.',
+ ),
+ );
+
+ $diff = WCEmailTemplateChangeSummary::diff_records_three_way(
+ self::records( $already_updated ),
+ $base,
+ self::records( $already_updated )
+ );
+
+ $this->assertSame(
+ array(),
+ $diff['copy_changes'],
+ 'Both sides already read the same; a conflict here would be a no-op the merchant still has to clear before the one-click update.'
+ );
+ }
+
+ /**
+ * @testdox Three-way diff: a block holding other blocks produces no entry, because apply never takes its content.
+ */
+ public function test_three_way_block_holding_other_blocks_yields_no_entry(): void {
+ $quote = static fn( string $text ): array => array(
+ array(
+ 'name' => 'core/quote',
+ 'inner_text' => $text,
+ 'holds_blocks' => true,
+ ),
+ );
+
+ $diff = WCEmailTemplateChangeSummary::diff_records_three_way(
+ self::records( $quote( 'A word from us.' ) ),
+ self::records( $quote( 'A note from us.' ) ),
+ self::records( $quote( 'A note from us.' ) )
+ );
+
+ $this->assertSame(
+ array(),
+ $diff['copy_changes'],
+ 'Apply leaves the content of such a block alone, so listing it would promise a change that never happens.'
+ );
+ }
+
+ /**
+ * @testdox Three-way diff: a core change with no change of wording produces no entry, so the drawer never shows a change with identical text on both sides.
+ *
+ * Guards the deliberate asymmetry: the merchant's side is compared on markup,
+ * core's on text alone. Making the two sides symmetric with
+ * `merchant_changed()` is the tempting cleanup this test exists to stop.
+ */
+ public function test_three_way_core_change_without_rewording_yields_no_entry(): void {
+ $unchanged = array(
+ array(
+ 'name' => 'core/paragraph',
+ 'inner_text' => 'Hi.',
+ ),
+ );
+
+ $base = self::records( $unchanged );
+ $post = self::records( $unchanged );
+ $core = self::records(
+ array(
+ array(
+ 'name' => 'core/paragraph',
+ 'inner_text' => 'Hi.',
+ 'inner_html_hash' => 'html-core-restyled',
+ ),
+ )
+ );
+
+ $diff = WCEmailTemplateChangeSummary::diff_records_three_way( $core, $base, $post );
+
+ $this->assertSame( array(), $diff['copy_changes'], 'Core styling a block is not a wording change; it must not reach the drawer as one.' );
+ }
+
+ /**
+ * @testdox flatten_blocks hashes the markup that inner_text discards, ignoring line breaks and indentation.
+ */
+ public function test_flatten_blocks_hashes_markup(): void {
+ $record = static fn( string $markup ): array => WCEmailTemplateChangeSummary::flatten_blocks( parse_blocks( $markup ) )[0];
+
+ $first_name = '<!-- wp:paragraph --><p>Hi <!--[woocommerce/customer-first-name]-->, thanks</p><!-- /wp:paragraph -->';
+ $full_name = '<!-- wp:paragraph --><p>Hi <!--[woocommerce/customer-full-name]-->, thanks</p><!-- /wp:paragraph -->';
+ $respaced = "<!-- wp:paragraph --><p>Hi\n <!--[woocommerce/customer-first-name]-->, thanks</p><!-- /wp:paragraph -->";
+
+ $this->assertSame(
+ $record( $first_name )['inner_text'],
+ $record( $full_name )['inner_text'],
+ 'Fixture check: stripping tags hides the swapped personalization tag, which is why the markup hash exists.'
+ );
+ $this->assertNotSame( $record( $first_name )['inner_html_hash'], $record( $full_name )['inner_html_hash'], 'A swapped personalization tag must read as a change.' );
+ $this->assertSame( $record( $first_name )['inner_html_hash'], $record( $respaced )['inner_html_hash'], 'Whitespace from re-serialization must not read as a change.' );
+
+ // Spacing next to a tag is content: these two render differently.
+ $spaced = '<!-- wp:paragraph --><p>Read <strong>more</strong> today</p><!-- /wp:paragraph -->';
+ $unspaced = '<!-- wp:paragraph --><p>Read<strong>more</strong>today</p><!-- /wp:paragraph -->';
+
+ $this->assertNotSame(
+ $record( $spaced )['inner_html_hash'],
+ $record( $unspaced )['inner_html_hash'],
+ 'Spacing around inline markup changes what the merchant sees, so it counts as their edit.'
+ );
}
/**
@@ -1119,17 +1474,25 @@ class WCEmailTemplateChangeSummaryTest extends \WC_Unit_Test_Case {
* Build a list of flatten_blocks-shaped records from a simple list of name + inner_text pairs.
* Each record gets a top-level path (`[$idx]`) and a null parent_name.
*
- * @param array<int, array{name:string, inner_text:string}> $simple Simple record specs.
- * @return array<int, array{path:array<int|string>, parent_name:?string, name:string, inner_text:string}>
+ * The two hashes are opaque tokens to the diff, which only compares them, so
+ * fixtures pass literals rather than real hashes. `inner_html_hash` defaults
+ * to one derived from `inner_text`, so a record's markup moves with its text
+ * unless a test pins it separately.
+ *
+ * @param array<int, array{name:string, inner_text:string, attrs_hash?:string, inner_html_hash?:string, holds_blocks?:bool}> $simple Simple record specs.
+ * @return array<int, array{path:array<int|string>, parent_name:?string, name:string, inner_text:string, attrs_hash:string, inner_html_hash:string, holds_blocks:bool}>
*/
private static function records( array $simple ): array {
$out = array();
foreach ( $simple as $i => $r ) {
$out[] = array(
- 'path' => array( $i ),
- 'parent_name' => null,
- 'name' => $r['name'],
- 'inner_text' => $r['inner_text'],
+ 'path' => array( $i ),
+ 'parent_name' => null,
+ 'name' => $r['name'],
+ 'inner_text' => $r['inner_text'],
+ 'attrs_hash' => $r['attrs_hash'] ?? 'attrs-default',
+ 'inner_html_hash' => $r['inner_html_hash'] ?? 'html-of:' . $r['inner_text'],
+ 'holds_blocks' => $r['holds_blocks'] ?? false,
);
}
return $out;
@@ -1250,4 +1613,22 @@ class WCEmailTemplateChangeSummaryTest extends \WC_Unit_Test_Case {
$property->setValue( $emails_container, $current );
$this->injected_email_keys = array();
}
+
+ /**
+ * @testdox The change-summary cache key carries a classifier version, so a payload cached under the previous rules is never served.
+ */
+ public function test_change_summary_cache_key_is_versioned(): void {
+ $cache_key = new \ReflectionMethod( WCEmailTemplateChangeSummary::class, 'cache_key' );
+ $cache_key->setAccessible( true );
+
+ $key = $cache_key->invoke( null, 12, 'post-hash', 'core-hash', 'base-hash', 'en_US' );
+
+ $unversioned = sprintf( 'wc_email_change_summary_%d_%s', 12, md5( 'post-hash|core-hash|base-hash|en_US' ) );
+
+ $this->assertNotSame(
+ $unversioned,
+ $key,
+ 'Content hashes do not move when only the classification code changes, so without a version in the key a payload cached under the old rules would survive the upgrade and drive the new behaviour. The exact key format is not pinned on purpose.'
+ );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSelectiveApplierTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSelectiveApplierTest.php
index dbf7cb3b72f..590d1796aa7 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSelectiveApplierTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSelectiveApplierTest.php
@@ -9,6 +9,7 @@ use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTem
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateDivergenceDetector;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSelectiveApplier;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncRegistry;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsGenerator;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager;
/**
@@ -105,7 +106,7 @@ class WCEmailTemplateSelectiveApplierTest extends \WC_Unit_Test_Case {
$this->register_fixture_email( $email_id );
$core_content = "<!-- wp:paragraph -->\n<p>Core copy.</p>\n<!-- /wp:paragraph -->";
- $post_content = "<!-- wp:paragraph -->\n<p>Merchant copy.</p>\n<!-- /wp:paragraph -->";
+ $post_content = "<!-- wp:paragraph {\"style\":{\"color\":{\"text\":\"#b32d2e\"}}} -->\n<p class=\"has-text-color\" style=\"color:#b32d2e\">Merchant copy.</p>\n<!-- /wp:paragraph -->";
$this->use_canonical_content( $email_id, $core_content );
$post_id = $this->create_woo_email_post( $email_id, $post_content );
@@ -124,6 +125,11 @@ class WCEmailTemplateSelectiveApplierTest extends \WC_Unit_Test_Case {
$this->assertSame( 'applied', $result['status'] );
$this->assertStringContainsString( 'Core copy.', $result['merged_content'] );
$this->assertStringNotContainsString( 'Merchant copy.', $result['merged_content'] );
+ $this->assertStringContainsString(
+ '"color":{"text":"#b32d2e"}',
+ $result['merged_content'],
+ 'Without a stored base there is no way to tell a merchant setting from an editor rewrite, so the two-way path keeps the post\'s attributes.'
+ );
}
/**
@@ -408,8 +414,9 @@ class WCEmailTemplateSelectiveApplierTest extends \WC_Unit_Test_Case {
/**
* @testdox Three-way: when the merchant deleted one block and core edited another, apply updates the right block and adds no duplicate.
*
- * @testWith [[], ["OLD BLOCK A", "MERCHANT EDITED C"]]
+ * @testWith [[], ["NEW CORE A", "MERCHANT EDITED C"]]
* [[{"path": [0], "decision": "use_core"}], ["NEW CORE A", "MERCHANT EDITED C"]]
+ * [[{"path": [0], "decision": "keep_yours"}], ["OLD BLOCK A", "MERCHANT EDITED C"]]
*
* @param array<int, array{path:array<int>, decision:string}> $choices Choices sent to apply.
* @param string[] $expected Paragraph texts expected in the merged content, in order.
@@ -432,6 +439,289 @@ class WCEmailTemplateSelectiveApplierTest extends \WC_Unit_Test_Case {
$this->assertSame( $expected, $this->paragraph_texts( $result['merged_content'] ) );
}
+ /**
+ * @testdox Three-way: a block only core changed takes core's text when no choice is sent for it.
+ */
+ public function test_apply_selectively_three_way_applies_auto_resolvable_change_without_a_choice(): void {
+ $email_id = 'sa_three_way_auto_resolvable';
+ $this->register_fixture_email( $email_id );
+
+ $base_and_post = $this->paragraphs( array( 'Thanks for your order.', 'We will be in touch.' ) );
+
+ $this->use_canonical_content( $email_id, $this->paragraphs( array( 'Thanks for your order.', 'We will email you when it ships.' ) ) );
+ $post_id = $this->create_woo_email_post( $email_id, $base_and_post );
+ update_post_meta(
+ $post_id,
+ WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY,
+ $base_and_post
+ );
+
+ $result = WCEmailTemplateSelectiveApplier::apply_selectively( $post_id, array() );
+
+ $this->assertIsArray( $result );
+ $this->assertSame(
+ array( 'Thanks for your order.', 'We will email you when it ships.' ),
+ $this->paragraph_texts( $result['merged_content'] ),
+ 'The drawer lists this block as auto-resolved, so Apply must take core\'s text.'
+ );
+ }
+
+ /**
+ * @testdox Three-way: a block the merchant only restyled is never auto-applied over, and an explicit use_core replaces it whole.
+ *
+ * @testWith [[], "Thanks for your order.", true]
+ * [[{"path": [0], "decision": "use_core"}], "Thanks for shopping with us.", false]
+ *
+ * @param array<int, array{path:array<int>, decision:string}> $choices Choices sent to apply.
+ * @param string $expected_text Paragraph text expected after the apply.
+ * @param bool $keeps_colour Whether the merchant's colour survives.
+ */
+ public function test_apply_selectively_three_way_styling_only_merchant_edit( array $choices, string $expected_text, bool $keeps_colour ): void {
+ $email_id = 'sa_three_way_styling_only_edit';
+ $this->register_fixture_email( $email_id );
+
+ $base = "<!-- wp:paragraph -->\n<p>Thanks for your order.</p>\n<!-- /wp:paragraph -->";
+ $core = "<!-- wp:paragraph -->\n<p>Thanks for shopping with us.</p>\n<!-- /wp:paragraph -->";
+ $post = "<!-- wp:paragraph {\"style\":{\"color\":{\"text\":\"#ff0000\"}}} -->\n<p class=\"has-text-color\" style=\"color:#ff0000\">Thanks for your order.</p>\n<!-- /wp:paragraph -->";
+
+ $this->use_canonical_content( $email_id, $core );
+ $post_id = $this->create_woo_email_post( $email_id, $post );
+ update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY, $base );
+
+ $result = WCEmailTemplateSelectiveApplier::apply_selectively( $post_id, $choices );
+
+ $this->assertIsArray( $result );
+ $this->assertSame(
+ array( $expected_text ),
+ $this->paragraph_texts( $result['merged_content'] ),
+ 'A block the merchant changed takes core\'s text only when they ask for it.'
+ );
+ $this->assertSame(
+ $keeps_colour,
+ str_contains( $result['merged_content'], '#ff0000' ),
+ 'Their colour stays until they ask for core\'s version, which the drawer calls discarding their edit.'
+ );
+ }
+
+ /**
+ * @testdox Three-way: a block whose settings alone the merchant changed is never auto-applied over, and an explicit use_core replaces it whole.
+ *
+ * @testWith [[], "Thanks for your order.", true]
+ * [[{"path": [0], "decision": "use_core"}], "Thanks for shopping with us.", false]
+ *
+ * @param array<int, array{path:array<int>, decision:string}> $choices Choices sent to apply.
+ * @param string $expected_text Paragraph text expected after the apply.
+ * @param bool $keeps_lock Whether the merchant's block lock survives.
+ */
+ public function test_apply_selectively_three_way_settings_only_merchant_edit( array $choices, string $expected_text, bool $keeps_lock ): void {
+ $email_id = 'sa_three_way_settings_only_edit';
+ $this->register_fixture_email( $email_id );
+
+ // A block lock leaves no trace in the markup, so only the settings
+ // comparison can tell this block apart from an untouched one.
+ $base = "<!-- wp:paragraph -->\n<p>Thanks for your order.</p>\n<!-- /wp:paragraph -->";
+ $core = "<!-- wp:paragraph -->\n<p>Thanks for shopping with us.</p>\n<!-- /wp:paragraph -->";
+ $post = "<!-- wp:paragraph {\"lock\":{\"move\":false,\"remove\":true}} -->\n<p>Thanks for your order.</p>\n<!-- /wp:paragraph -->";
+
+ $this->use_canonical_content( $email_id, $core );
+ $post_id = $this->create_woo_email_post( $email_id, $post );
+ update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY, $base );
+
+ $result = WCEmailTemplateSelectiveApplier::apply_selectively( $post_id, $choices );
+
+ $this->assertIsArray( $result );
+ $this->assertSame( array( $expected_text ), $this->paragraph_texts( $result['merged_content'] ) );
+ $this->assertSame(
+ $keeps_lock,
+ str_contains( $result['merged_content'], '"lock":{"move":false,"remove":true}' ),
+ 'The merchant\'s lock stays until they ask for core\'s version.'
+ );
+ }
+
+ /**
+ * @testdox Three-way: a block the editor rewrote on save, without a merchant edit, still takes core's new wording.
+ */
+ public function test_apply_selectively_three_way_applies_to_block_the_editor_rewrote(): void {
+ $email_id = 'sa_three_way_editor_rewrite';
+ $this->register_fixture_email( $email_id );
+
+ // The template form, and what the block editor saves for the same
+ // untouched block: migrated attribute, decoded entity, trimmed padding.
+ $base = "<!-- wp:paragraph {\"align\":\"center\"} -->\n<p class=\"has-text-align-center\"> We couldn't be happier! </p>\n<!-- /wp:paragraph -->";
+ $post = "<!-- wp:paragraph {\"style\":{\"typography\":{\"textAlign\":\"center\"}}} -->\n<p class=\"has-text-align-center\">We couldn't be happier!</p>\n<!-- /wp:paragraph -->";
+ $core = "<!-- wp:paragraph {\"align\":\"center\"} -->\n<p class=\"has-text-align-center\"> Thanks again for your order! </p>\n<!-- /wp:paragraph -->";
+
+ $this->use_canonical_content( $email_id, $core );
+ $post_id = $this->create_woo_email_post( $email_id, $post );
+ update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY, $base );
+
+ $summary = WCEmailTemplateChangeSummary::summarize( $post_id );
+ $this->assertTrue(
+ $summary['copy_changes'][0]['auto_resolvable'] ?? null,
+ 'The drawer must list this block as auto-resolved, not as a conflict over text the merchant never wrote.'
+ );
+
+ $result = WCEmailTemplateSelectiveApplier::apply_selectively( $post_id, array() );
+
+ $this->assertIsArray( $result );
+ $this->assertSame( array( 'Thanks again for your order!' ), $this->paragraph_texts( $result['merged_content'] ) );
+ }
+
+ /**
+ * @testdox Three-way: a styling-only core change is not applied, because the drawer has no wording change to list for it.
+ */
+ public function test_apply_selectively_three_way_leaves_styling_only_core_change(): void {
+ $email_id = 'sa_three_way_core_styling_only';
+ $this->register_fixture_email( $email_id );
+
+ $base_and_post = $this->paragraphs( array( 'Thanks for your order.', 'We will be in touch.' ) );
+ $core = "<!-- wp:paragraph {\"fontSize\":\"large\"} -->\n<p class=\"has-large-font-size\">Thanks for your order.</p>\n<!-- /wp:paragraph -->\n\n"
+ . "<!-- wp:paragraph -->\n<p>We will email you when it ships.</p>\n<!-- /wp:paragraph -->";
+
+ $this->use_canonical_content( $email_id, $core );
+ $post_id = $this->create_woo_email_post( $email_id, $base_and_post );
+ update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY, $base_and_post );
+
+ $result = WCEmailTemplateSelectiveApplier::apply_selectively( $post_id, array() );
+
+ $this->assertIsArray( $result );
+ $this->assertSame(
+ array( 'Thanks for your order.', 'We will email you when it ships.' ),
+ $this->paragraph_texts( $result['merged_content'] ),
+ 'The reworded block is listed in the drawer and applies.'
+ );
+ $this->assertStringNotContainsString(
+ 'fontSize',
+ $result['merged_content'],
+ 'Apply changes only what the drawer listed; a restyle with no wording change is not listed.'
+ );
+ }
+
+ /**
+ * @testdox Three-way: apply follows the blocks themselves, not a cached summary that says there is nothing to do.
+ */
+ public function test_apply_selectively_three_way_ignores_stale_cached_summary(): void {
+ $email_id = 'sa_three_way_stale_summary';
+ $this->register_fixture_email( $email_id );
+
+ $base_and_post = $this->paragraphs( array( 'We will be in touch.' ) );
+ $core_content = $this->paragraphs( array( 'We will email you when it ships.' ) );
+
+ $this->use_canonical_content( $email_id, $core_content );
+ $post_id = $this->create_woo_email_post( $email_id, $base_and_post );
+ update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY, $base_and_post );
+
+ // A payload from before the classification rules changed: it reports the
+ // block as nothing to act on. The merge must not take its word for it.
+ $summary = WCEmailTemplateChangeSummary::summarize( $post_id );
+ $summary['copy_changes'] = array();
+ $this->prime_change_summary_cache( $post_id, $summary );
+
+ $this->assertTrue(
+ WCEmailTemplateChangeSummary::summarize( $post_id )['cache_hit'],
+ 'Fixture check: the stale payload has to be the one the apply reads, or this test proves nothing.'
+ );
+
+ $result = WCEmailTemplateSelectiveApplier::apply_selectively( $post_id, array() );
+
+ $this->assertIsArray( $result );
+ $this->assertSame(
+ array( 'We will email you when it ships.' ),
+ $this->paragraph_texts( $result['merged_content'] ),
+ 'The merge classifies the blocks it holds; a cached payload cannot talk it out of an update.'
+ );
+ }
+
+ /**
+ * @testdox Three-way: a merchant edit visible only in the markup, such as a swapped personalization tag, is not auto-applied over.
+ */
+ public function test_apply_selectively_three_way_preserves_markup_only_merchant_edit(): void {
+ $email_id = 'sa_three_way_markup_only_edit';
+ $this->register_fixture_email( $email_id );
+
+ $base = "<!-- wp:paragraph -->\n<p>Hi <!--[woocommerce/customer-first-name]-->, thanks.</p>\n<!-- /wp:paragraph -->";
+ $core = "<!-- wp:paragraph -->\n<p>Hey <!--[woocommerce/customer-first-name]-->, thanks a lot.</p>\n<!-- /wp:paragraph -->";
+ $post = "<!-- wp:paragraph -->\n<p>Hi <!--[woocommerce/customer-full-name]-->, thanks.</p>\n<!-- /wp:paragraph -->";
+
+ $this->use_canonical_content( $email_id, $core );
+ $post_id = $this->create_woo_email_post( $email_id, $post );
+ update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY, $base );
+
+ $result = WCEmailTemplateSelectiveApplier::apply_selectively( $post_id, array() );
+
+ $this->assertIsArray( $result );
+ $this->assertStringContainsString(
+ 'woocommerce/customer-full-name',
+ $result['merged_content'],
+ 'The merchant chose the full-name tag; an update they never reviewed must not swap it back.'
+ );
+ }
+
+ /**
+ * @testdox Three-way: when core changes a block's text and its styling, an auto-resolved apply takes both.
+ */
+ public function test_apply_selectively_three_way_auto_resolved_takes_core_attributes(): void {
+ $email_id = 'sa_three_way_core_text_and_attrs';
+ $this->register_fixture_email( $email_id );
+
+ $base_and_post = "<!-- wp:paragraph -->\n<p>Thanks for your order.</p>\n<!-- /wp:paragraph -->";
+ $core = "<!-- wp:paragraph {\"style\":{\"color\":{\"text\":\"#0000ff\"}}} -->\n<p class=\"has-text-color\" style=\"color:#0000ff\">Thanks for shopping with us.</p>\n<!-- /wp:paragraph -->";
+
+ $this->use_canonical_content( $email_id, $core );
+ $post_id = $this->create_woo_email_post( $email_id, $base_and_post );
+ update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY, $base_and_post );
+
+ $result = WCEmailTemplateSelectiveApplier::apply_selectively( $post_id, array() );
+
+ $this->assertIsArray( $result );
+ $this->assertSame( array( 'Thanks for shopping with us.' ), $this->paragraph_texts( $result['merged_content'] ) );
+ $this->assertStringContainsString(
+ '"color":{"text":"#0000ff"}',
+ $result['merged_content'],
+ 'Core\'s attributes must land with its text: leaving the old ones behind makes the block read as merchant-edited on every later update.'
+ );
+ }
+
+ /**
+ * @testdox Three-way: rewording a block that holds other blocks never discards what the merchant put inside it.
+ *
+ * Shape taken from the customer-note template, which wraps an editable
+ * paragraph in a locked quote. Core changes the quote's own markup (its
+ * citation), which on a leaf block would auto-resolve to core's version;
+ * here that would replace the quote wholesale, children included.
+ */
+ public function test_apply_selectively_three_way_keeps_children_of_a_reworded_container(): void {
+ $email_id = 'sa_three_way_container_children';
+ $this->register_fixture_email( $email_id );
+
+ $quote = static fn( string $cite, string $paragraph ): string =>
+ "<!-- wp:quote -->\n<blockquote class=\"wp-block-quote\">\n"
+ . "<!-- wp:paragraph -->\n<p>{$paragraph}</p>\n<!-- /wp:paragraph -->\n"
+ . "<cite>{$cite}</cite></blockquote>\n<!-- /wp:quote -->";
+
+ $base = $quote( 'Store team', 'Order note goes here.' );
+ $post = $quote( 'Store team', 'Our note to you.' );
+ $core = $quote( 'The team', 'Order note goes here.' );
+
+ $this->use_canonical_content( $email_id, $core );
+ $post_id = $this->create_woo_email_post( $email_id, $post );
+ update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY, $base );
+
+ $result = WCEmailTemplateSelectiveApplier::apply_selectively( $post_id, array() );
+
+ $this->assertIsArray( $result );
+ $this->assertSame(
+ array( 'Our note to you.' ),
+ $this->paragraph_texts( $result['merged_content'] ),
+ 'The merchant edited the paragraph inside the quote; restyling the quote must not take it away.'
+ );
+ $this->assertStringNotContainsString(
+ 'The team',
+ $result['merged_content'],
+ 'A block holding other blocks is left exactly as the merchant has it.'
+ );
+ }
+
/**
* @testdox Three-way: a block the merchant deleted is not re-added when core adds a new block elsewhere.
*/
@@ -1142,6 +1432,42 @@ class WCEmailTemplateSelectiveApplierTest extends \WC_Unit_Test_Case {
return (int) $post_id;
}
+ /**
+ * Write a change-summary payload into the transient the class would read for
+ * this post. Reaches the private key builder by reflection so the test can't
+ * drift from the real key format.
+ *
+ * @param int $post_id The `woo_email` post ID.
+ * @param array<string, mixed> $payload The payload to cache.
+ */
+ private function prime_change_summary_cache( int $post_id, array $payload ): void {
+ $post = get_post( $post_id );
+ $this->assertInstanceOf( \WP_Post::class, $post );
+
+ $email = WCTransactionalEmailPostsManager::get_instance()->get_email_by_id(
+ (string) WCTransactionalEmailPostsManager::get_instance()->get_email_type_from_post_id( $post_id )
+ );
+ $this->assertInstanceOf( \WC_Email::class, $email );
+
+ $base = (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY, true );
+
+ $cache_key = new \ReflectionMethod( WCEmailTemplateChangeSummary::class, 'cache_key' );
+ $cache_key->setAccessible( true );
+
+ set_transient(
+ $cache_key->invoke(
+ null,
+ $post_id,
+ sha1( (string) $post->post_content ),
+ sha1( WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $email ) ),
+ '' !== $base ? sha1( $base ) : '',
+ get_user_locale()
+ ),
+ $payload,
+ HOUR_IN_SECONDS
+ );
+ }
+
/**
* Build block markup with one paragraph block per text.
*
@@ -1159,17 +1485,30 @@ class WCEmailTemplateSelectiveApplierTest extends \WC_Unit_Test_Case {
}
/**
- * Extract the text of each top-level paragraph block, in order.
+ * Extract the text of every paragraph block, nested ones included, in order.
*
* @param string $content Serialized block markup.
* @return string[] Paragraph texts.
*/
private function paragraph_texts( string $content ): array {
+ return $this->collect_paragraph_texts( parse_blocks( $content ) );
+ }
+
+ /**
+ * Recursive worker for {@see self::paragraph_texts()}.
+ *
+ * @param array<int, array<string, mixed>> $blocks Parsed blocks.
+ * @return string[] Paragraph texts.
+ */
+ private function collect_paragraph_texts( array $blocks ): array {
$texts = array();
- foreach ( parse_blocks( $content ) as $block ) {
+ foreach ( $blocks as $block ) {
if ( 'core/paragraph' === $block['blockName'] ) {
$texts[] = trim( wp_strip_all_tags( (string) $block['innerHTML'] ) );
}
+ if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
+ $texts = array_merge( $texts, $this->collect_paragraph_texts( $block['innerBlocks'] ) );
+ }
}
return $texts;
}