Commit af9cb1ae398 for woocommerce
commit af9cb1ae398fffefb4b72e4e2eab39caf5fa303e
Author: Patrick Zielinski <patrick.zielinski@a8c.com>
Date: Fri May 8 11:29:22 2026 -0400
[Email Editor] Surface update-available indicator in the editor (RSM-141) (#64675)
* Expose source_hash and backfilled meta over REST on woo_email
* Add source_hash_to to email template change-summary response
* Add banner dismiss and viewed-dedup state to email integration store
* Add suppressSnackbarOnError option to useApplyUpdate
* Add useUpdateBanner glue hook with eligibility, gates, and Tracks events
* Add UpdateBanner component and Portal-mounted plugin entry
* Mount update banner plugin and strip review-drawer URL param
* Add changelog entry for RSM-141 update banner
* Skip dismissed Tracks event on success morph auto-dismiss
* Address CodeRabbit review feedback
* Address review feedback on banner stability and snackbar copy
* Stamp _wc_email_backfilled flag during RSM-149 sync backfill
* Gate update indicator on canonical version compare across both surfaces
* Hide banner when summary reports no actual changes
* Defer backfilled-flag writer to follow-up PR and fix SCSS lint
---------
Co-authored-by: PZ01 <patrick.zielinski@automattic.com>
diff --git a/plugins/woocommerce/changelog/rsm-141-update-banner b/plugins/woocommerce/changelog/rsm-141-update-banner
new file mode 100644
index 00000000000..a002390ecf9
--- /dev/null
+++ b/plugins/woocommerce/changelog/rsm-141-update-banner
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Surface update-available indicator in the block email editor (RSM-141)
diff --git a/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-listing-update-cell.test.tsx b/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-listing-update-cell.test.tsx
index 2a2a02e5875..245cb8122f6 100644
--- a/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-listing-update-cell.test.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-listing-update-cell.test.tsx
@@ -44,6 +44,7 @@ const baseEmail: EmailType = {
status: 'enabled',
templateStatus: null,
templateVersion: null,
+ currentVersion: null,
};
describe( '<UpdatesCell>', () => {
@@ -66,12 +67,56 @@ describe( '<UpdatesCell>', () => {
( window as any ).location = originalLocation;
} );
- it( 'renders a Review update button when status is core_updated_customized', () => {
+ it( 'renders a Review update button when status is core_updated_customized and merchant version is older than current', () => {
render(
<UpdatesCell
post={ {
...baseEmail,
templateStatus: 'core_updated_customized',
+ templateVersion: '10.6.0',
+ currentVersion: '10.7.0',
+ } }
+ />
+ );
+
+ expect(
+ screen.getByRole( 'button', { name: /review update/i } )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'renders em-dash when status is core_updated_customized but merchant version equals current (already reviewed)', () => {
+ // Canonical detector check: status alone isn't enough — the merchant
+ // is "up to date" once they've reviewed this version, even if they
+ // kept some customizations during the apply.
+ render(
+ <UpdatesCell
+ post={ {
+ ...baseEmail,
+ templateStatus: 'core_updated_customized',
+ templateVersion: '10.7.0',
+ currentVersion: '10.7.0',
+ } }
+ />
+ );
+
+ expect(
+ screen.queryByRole( 'button', { name: /review update/i } )
+ ).not.toBeInTheDocument();
+ expect( screen.getByLabelText( /up to date/i ) ).toHaveTextContent(
+ '—'
+ );
+ } );
+
+ it( 'falls back to status-only gating when version metadata is missing (legacy posts)', () => {
+ // Posts that haven't been backfilled yet won't have templateVersion;
+ // keep showing the indicator on status alone so legacy posts surface.
+ render(
+ <UpdatesCell
+ post={ {
+ ...baseEmail,
+ templateStatus: 'core_updated_customized',
+ templateVersion: null,
+ currentVersion: '10.7.0',
} }
/>
);
diff --git a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-data.ts b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-data.ts
index 9afc2a4f3f4..bce10524a99 100644
--- a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-data.ts
+++ b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-data.ts
@@ -17,6 +17,7 @@ import {
EmailStatus,
TemplateStatus,
} from './settings-email-listing-slotfill';
+import { shouldShowReviewUpdate } from './settings-email-listing-update-state';
import { getAdminSetting } from '~/utils/admin-settings';
/**
@@ -105,12 +106,25 @@ export const useTransactionalEmails = (
? rawVersion
: null;
+ // PHP serializes the registry's current version under
+ // `current_version` (snake) on the slotfill payload; project to
+ // `currentVersion` (camel) for the row's TS contract.
+ const rawCurrentVersion = (
+ emailType as unknown as { current_version?: string | null }
+ ).current_version;
+ const currentVersion: string | null =
+ typeof rawCurrentVersion === 'string' &&
+ rawCurrentVersion.length > 0
+ ? rawCurrentVersion
+ : null;
+
return {
...emailType,
link: post?.link || '',
status: status as EmailStatus,
templateStatus,
templateVersion,
+ currentVersion,
};
} ),
[ emailTypesData, emailPosts, postIdsMap ]
@@ -212,10 +226,9 @@ export const useTransactionalEmails = (
const selected = Array.isArray( updatesFilter.value )
? ( updatesFilter.value as string[] )
: [ updatesFilter.value as string ];
- const emailValue =
- email.templateStatus === 'core_updated_customized'
- ? 'available'
- : 'none';
+ const emailValue = shouldShowReviewUpdate( email )
+ ? 'available'
+ : 'none';
return selected.includes( emailValue );
} );
diff --git a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-listview.tsx b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-listview.tsx
index 8d1c54162a0..c1f2f849798 100644
--- a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-listview.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-listview.tsx
@@ -14,6 +14,7 @@ import { DataViews, View } from '@wordpress/dataviews/wp'; // eslint-disable-lin
*/
import { EmailType } from './settings-email-listing-slotfill';
import { useTransactionalEmails } from './settings-email-listing-data';
+import { shouldShowReviewUpdate } from './settings-email-listing-update-state';
import { Status, EMAIL_STATUSES } from './settings-email-listing-status';
import { RecipientsList } from './settings-email-listing-recipients';
import { UpdatesCell } from './settings-email-listing-update-cell';
@@ -111,9 +112,7 @@ export const ListView = ( { emailTypes }: { emailTypes: EmailType[] } ) => {
enableHiding: true,
enableSorting: false,
getValue: ( { item }: { item: EmailType } ) =>
- item.templateStatus === 'core_updated_customized'
- ? 'available'
- : 'none',
+ shouldShowReviewUpdate( item ) ? 'available' : 'none',
elements: [
{
value: 'available',
diff --git a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-slotfill.tsx b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-slotfill.tsx
index 8df8a061b7a..85fc70cdb24 100644
--- a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-slotfill.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-slotfill.tsx
@@ -44,6 +44,16 @@ export type EmailType = {
status?: EmailStatus;
templateStatus: TemplateStatus | null;
templateVersion: string | null;
+ /**
+ * Registry-side current version of the canonical core template for this
+ * email. Sourced from `WCEmailTemplateSyncRegistry::get_email_sync_config()`
+ * server-side; serialized as `current_version` in the slotfill payload
+ * and projected to camelCase in the data hook. Combined with
+ * `templateVersion` to gate the "update available" indicator on both
+ * surfaces (list cell + RSM-141 editor banner): show only when the
+ * merchant has not yet reviewed this version.
+ */
+ currentVersion: string | null;
};
const { Fill } = createSlotFill( SETTINGS_SLOT_FILL_CONSTANT );
diff --git a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-update-cell.story.tsx b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-update-cell.story.tsx
index 1a5b53d04cf..d28ec62c29a 100644
--- a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-update-cell.story.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-update-cell.story.tsx
@@ -15,6 +15,7 @@ const baseEmail: EmailType = {
recipients: { to: '', cc: '', bcc: '' },
templateStatus: null,
templateVersion: null,
+ currentVersion: null,
};
export const CoreUpdatedCustomized = () => (
@@ -22,6 +23,8 @@ export const CoreUpdatedCustomized = () => (
post={ {
...baseEmail,
templateStatus: 'core_updated_customized',
+ templateVersion: '10.6.0',
+ currentVersion: '10.7.0',
} }
/>
);
diff --git a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-update-cell.tsx b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-update-cell.tsx
index 7dfdababc6c..a753d9df1b7 100644
--- a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-update-cell.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-update-cell.tsx
@@ -27,13 +27,14 @@ import { getAdminLink } from '@woocommerce/settings';
*/
import type { EmailType } from './settings-email-listing-slotfill';
import { buildEmailEditorReviewUrl } from './build-email-editor-review-url';
+import { shouldShowReviewUpdate } from './settings-email-listing-update-state';
interface UpdatesCellProps {
post: EmailType;
}
export const UpdatesCell = ( { post }: UpdatesCellProps ) => {
- if ( post.templateStatus !== 'core_updated_customized' ) {
+ if ( ! shouldShowReviewUpdate( post ) ) {
return <span aria-label={ __( 'Up to date', 'woocommerce' ) }>—</span>;
}
diff --git a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-update-state.ts b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-update-state.ts
new file mode 100644
index 00000000000..0a742f5878f
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-update-state.ts
@@ -0,0 +1,52 @@
+/**
+ * Internal dependencies
+ */
+import type { EmailType } from './settings-email-listing-slotfill';
+
+/**
+ * Numeric semver compare. Negative if `a < b`, zero if equal, positive
+ * if `a > b`. Lightweight on purpose — template versions don't carry
+ * pre-release / build metadata so we don't need full semver semantics.
+ */
+export function compareTemplateVersions( a: string, b: string ): number {
+ const partsA = a.split( '.' ).map( ( s ) => parseInt( s, 10 ) || 0 );
+ const partsB = b.split( '.' ).map( ( s ) => parseInt( s, 10 ) || 0 );
+ const len = Math.max( partsA.length, partsB.length );
+ for ( let i = 0; i < len; i++ ) {
+ const diff = ( partsA[ i ] ?? 0 ) - ( partsB[ i ] ?? 0 );
+ if ( diff !== 0 ) {
+ return diff;
+ }
+ }
+ return 0;
+}
+
+/**
+ * Mirrors the canonical "update available" check from
+ * {@see WCEmailTemplateDivergenceDetector}'s docblock:
+ *
+ * $reviewed = (string) get_post_meta( $post_id, VERSION_META_KEY, true );
+ * $current = (string) ( $sync_registry[ $email_id ]['version'] ?? '' );
+ * $show_indicator = $current !== '' && version_compare( $reviewed, $current, '<' );
+ *
+ * Status alone isn't enough — a post stays `core_updated_customized` after
+ * a drawer apply where the merchant kept some customizations on purpose,
+ * but they have reviewed the version. Both surfaces (the email list cell
+ * and RSM-141's editor banner) gate on this combined check so they stay
+ * in lockstep.
+ *
+ * Falls back to status-only when version metadata is missing — that
+ * happens for legacy posts before the RSM-149 backfill, so we surface
+ * the indicator rather than silently hide it.
+ */
+export function shouldShowReviewUpdate( post: EmailType ): boolean {
+ if ( post.templateStatus !== 'core_updated_customized' ) {
+ return false;
+ }
+ if ( ! post.templateVersion || ! post.currentVersion ) {
+ return true;
+ }
+ return (
+ compareTemplateVersions( post.templateVersion, post.currentVersion ) < 0
+ );
+}
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/__tests__/store.test.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/__tests__/store.test.ts
new file mode 100644
index 00000000000..077bbc3d02c
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/__tests__/store.test.ts
@@ -0,0 +1,89 @@
+/**
+ * External dependencies
+ */
+import { dispatch, select } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import { registerStore, STORE_NAME } from '../store';
+
+describe( 'email-editor-integration store', () => {
+ beforeAll( () => {
+ registerStore();
+ } );
+
+ describe( 'dismiss state', () => {
+ it( 'returns false for an unseen postId', () => {
+ expect(
+ select( STORE_NAME ).isUpdateBannerDismissedFor( 100 )
+ ).toBe( false );
+ } );
+
+ it( 'flips to true only for the dismissed postId', () => {
+ dispatch( STORE_NAME ).dismissUpdateBanner( 101 );
+
+ expect(
+ select( STORE_NAME ).isUpdateBannerDismissedFor( 101 )
+ ).toBe( true );
+ expect(
+ select( STORE_NAME ).isUpdateBannerDismissedFor( 200 )
+ ).toBe( false );
+ } );
+
+ it( 'clearDismissedForPost removes the entry', () => {
+ dispatch( STORE_NAME ).dismissUpdateBanner( 9001 );
+ expect(
+ select( STORE_NAME ).isUpdateBannerDismissedFor( 9001 )
+ ).toBe( true );
+
+ dispatch( STORE_NAME ).clearDismissedForPost( 9001 );
+ expect(
+ select( STORE_NAME ).isUpdateBannerDismissedFor( 9001 )
+ ).toBe( false );
+ } );
+
+ it( 'clearing an absent postId is a no-op', () => {
+ expect( () => {
+ dispatch( STORE_NAME ).clearDismissedForPost( 424242 );
+ } ).not.toThrow();
+ expect(
+ select( STORE_NAME ).isUpdateBannerDismissedFor( 424242 )
+ ).toBe( false );
+ } );
+ } );
+
+ describe( 'viewed dedup state', () => {
+ it( 'returns false for an unseen (postId, versionTo) pair', () => {
+ expect(
+ select( STORE_NAME ).wasUpdateBannerViewedFor( 300, '9.5' )
+ ).toBe( false );
+ } );
+
+ it( 'flips only the marked pair, not other versions or other posts', () => {
+ dispatch( STORE_NAME ).markUpdateBannerViewed( 300, '9.5' );
+
+ expect(
+ select( STORE_NAME ).wasUpdateBannerViewedFor( 300, '9.5' )
+ ).toBe( true );
+ // Same post, different version stays false.
+ expect(
+ select( STORE_NAME ).wasUpdateBannerViewedFor( 300, '9.6' )
+ ).toBe( false );
+ // Different post, same version stays false.
+ expect(
+ select( STORE_NAME ).wasUpdateBannerViewedFor( 301, '9.5' )
+ ).toBe( false );
+ } );
+ } );
+
+ describe( 'unrelated drawer state preserved', () => {
+ it( 'openReviewDrawer + closeReviewDrawer still work after extensions', () => {
+ dispatch( STORE_NAME ).openReviewDrawer();
+ expect( select( STORE_NAME ).isReviewDrawerOpen() ).toBe( true );
+
+ dispatch( STORE_NAME ).closeReviewDrawer();
+ expect( select( STORE_NAME ).isReviewDrawerOpen() ).toBe( false );
+ } );
+ } );
+} );
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/__tests__/update-banner-plugin.test.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/__tests__/update-banner-plugin.test.tsx
new file mode 100644
index 00000000000..a9624e13152
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/__tests__/update-banner-plugin.test.tsx
@@ -0,0 +1,86 @@
+/**
+ * External dependencies
+ */
+import { render } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import { UpdateBannerPlugin } from '../update-banner-plugin';
+
+// ---- useUpdateBanner mock -------------------------------------------------
+//
+// Drive the plugin's render decision from the test by overriding the
+// hook's return value. Each test sets `useUpdateBannerMock` to the
+// shape it wants, then renders the plugin; the mock is consulted on
+// render and again after the rAF in the impl, so changing it
+// per-test is sufficient.
+const useUpdateBannerMock = jest.fn();
+jest.mock( '../hooks/use-update-banner', () => ( {
+ useUpdateBanner: () => useUpdateBannerMock(),
+} ) );
+
+const baseHookReturn = {
+ shouldRender: false,
+ summary: null,
+ isLoadingSummary: false,
+ summaryError: null,
+ applyState: 'idle' as const,
+ canApply: true,
+ canReview: true,
+ disabledReason: null,
+ hasConflicts: false,
+ expanded: false,
+ toggleExpanded: jest.fn(),
+ apply: jest.fn(),
+ openReview: jest.fn(),
+ dismiss: jest.fn(),
+ autoDismiss: jest.fn(),
+};
+
+describe( 'UpdateBannerPlugin', () => {
+ beforeEach( () => {
+ // Reset the DOM between tests so the canvas selector / body
+ // fallback assertions stay isolated.
+ document.body.innerHTML = '';
+ useUpdateBannerMock.mockReset();
+ } );
+
+ it( 'returns null when shouldRender is false', () => {
+ useUpdateBannerMock.mockReturnValue( {
+ ...baseHookReturn,
+ shouldRender: false,
+ } );
+
+ const { container } = render( <UpdateBannerPlugin /> );
+ expect( container.firstChild ).toBeNull();
+ } );
+
+ it( 'portals the banner into the editor canvas target when present', () => {
+ const target = document.createElement( 'div' );
+ target.className = 'edit-post-visual-editor';
+ document.body.appendChild( target );
+
+ useUpdateBannerMock.mockReturnValue( {
+ ...baseHookReturn,
+ shouldRender: true,
+ } );
+
+ render( <UpdateBannerPlugin /> );
+
+ expect( target.querySelector( '.wc-update-banner' ) ).not.toBeNull();
+ } );
+
+ it( 'falls back to document.body when no canvas target is present', () => {
+ useUpdateBannerMock.mockReturnValue( {
+ ...baseHookReturn,
+ shouldRender: true,
+ } );
+
+ render( <UpdateBannerPlugin /> );
+
+ expect(
+ document.body.querySelector( '.wc-update-banner' )
+ ).not.toBeNull();
+ } );
+} );
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/__tests__/update-banner.test.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/__tests__/update-banner.test.tsx
new file mode 100644
index 00000000000..a4539a332ab
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/__tests__/update-banner.test.tsx
@@ -0,0 +1,315 @@
+/**
+ * External dependencies
+ */
+import { act, fireEvent, render, screen } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import { UpdateBanner } from '../update-banner';
+import type { ChangeSummary } from '../hooks/use-change-summary';
+import type { ApplyState } from '../hooks/use-update-banner';
+
+const baseSummary: ChangeSummary = {
+ version_from: '1.0.0',
+ version_to: '1.1.0',
+ source_hash_to: 'abc123',
+ added_blocks: [],
+ removed_blocks: [],
+ copy_changes: [],
+ structural_changes: [],
+ summary_lines: [
+ 'Added Heading and Paragraph blocks.',
+ 'Updated footer copy.',
+ ],
+ is_fallback: false,
+ cache_hit: false,
+};
+
+interface BannerOverrides {
+ summary?: ChangeSummary | null;
+ applyState?: ApplyState;
+ canApply?: boolean;
+ canReview?: boolean;
+ disabledReason?: 'dirty' | 'read_only' | 'has_conflicts' | null;
+ expanded?: boolean;
+ onApply?: jest.Mock;
+ onReview?: jest.Mock;
+ onDismiss?: jest.Mock;
+ onAutoDismiss?: jest.Mock;
+ onToggleExpanded?: jest.Mock;
+}
+
+function renderBanner( overrides: BannerOverrides = {} ) {
+ const onApply = overrides.onApply ?? jest.fn();
+ const onReview = overrides.onReview ?? jest.fn();
+ const onDismiss = overrides.onDismiss ?? jest.fn();
+ const onAutoDismiss = overrides.onAutoDismiss ?? jest.fn();
+ const onToggleExpanded = overrides.onToggleExpanded ?? jest.fn();
+
+ const result = render(
+ <UpdateBanner
+ summary={
+ overrides.summary === undefined
+ ? baseSummary
+ : overrides.summary
+ }
+ applyState={ overrides.applyState ?? 'idle' }
+ canApply={ overrides.canApply ?? true }
+ canReview={ overrides.canReview ?? true }
+ disabledReason={ overrides.disabledReason ?? null }
+ expanded={ overrides.expanded ?? false }
+ onApply={ onApply }
+ onReview={ onReview }
+ onDismiss={ onDismiss }
+ onAutoDismiss={ onAutoDismiss }
+ onToggleExpanded={ onToggleExpanded }
+ />
+ );
+
+ return {
+ ...result,
+ onApply,
+ onReview,
+ onDismiss,
+ onAutoDismiss,
+ onToggleExpanded,
+ };
+}
+
+describe( 'UpdateBanner', () => {
+ afterEach( () => {
+ jest.useRealTimers();
+ } );
+
+ it( 'renders the default state with title, summary subtitle, and actions', () => {
+ renderBanner();
+
+ expect(
+ screen.getByText( 'Template update available' )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText( 'Added Heading and Paragraph blocks.' )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole( 'button', { name: /^apply$/i } )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole( 'button', { name: /^review$/i } )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole( 'button', {
+ name: /dismiss for this session/i,
+ } )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'renders the generic fallback subtitle when summary.is_fallback is true', () => {
+ renderBanner( {
+ summary: { ...baseSummary, is_fallback: true },
+ } );
+
+ expect(
+ screen.getByText( 'WooCommerce updated this template.' )
+ ).toBeInTheDocument();
+ // Expand toggle is hidden in the fallback variant.
+ expect(
+ screen.queryByRole( 'button', { name: /show \d+ change/i } )
+ ).not.toBeInTheDocument();
+ } );
+
+ it( 'renders the bullet list when expanded is true', () => {
+ renderBanner( { expanded: true } );
+
+ expect(
+ screen.getByRole( 'button', { name: /hide changes/i } )
+ ).toBeInTheDocument();
+
+ const items = screen.getAllByRole( 'listitem' );
+ expect( items.length ).toBe( 2 );
+ expect( items[ 0 ] ).toHaveTextContent(
+ 'Added Heading and Paragraph blocks.'
+ );
+ expect( items[ 1 ] ).toHaveTextContent( 'Updated footer copy.' );
+ } );
+
+ it( 'replaces the Apply button with a primary "Review changes" button when has_conflicts', () => {
+ renderBanner( {
+ canApply: false,
+ disabledReason: 'has_conflicts',
+ } );
+
+ expect(
+ screen.getByRole( 'button', { name: /review changes/i } )
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole( 'button', { name: /^apply$/i } )
+ ).not.toBeInTheDocument();
+ // The plain tertiary "Review" should NOT also be present.
+ expect(
+ screen.queryByRole( 'button', { name: /^review$/i } )
+ ).not.toBeInTheDocument();
+ } );
+
+ it( 'aria-disables the Apply button and surfaces a tooltip when dirty', () => {
+ renderBanner( {
+ canApply: false,
+ disabledReason: 'dirty',
+ } );
+
+ const applyButton = screen.getByRole( 'button', { name: /^apply$/i } );
+ expect( applyButton ).toHaveAttribute( 'aria-disabled', 'true' );
+
+ // The tip is rendered inline as visually-hidden text alongside
+ // the disabled button, so it's announced by screen readers and
+ // queryable here directly.
+ expect(
+ screen.getAllByText( 'Save your changes first.' ).length
+ ).toBeGreaterThan( 0 );
+ } );
+
+ it( 'aria-disables both Apply and Review and surfaces a tooltip when read_only', () => {
+ renderBanner( {
+ canApply: false,
+ canReview: false,
+ disabledReason: 'read_only',
+ } );
+
+ const applyButton = screen.getByRole( 'button', { name: /^apply$/i } );
+ const reviewButton = screen.getByRole( 'button', {
+ name: /^review$/i,
+ } );
+ expect( applyButton ).toHaveAttribute( 'aria-disabled', 'true' );
+ expect( reviewButton ).toHaveAttribute( 'aria-disabled', 'true' );
+
+ expect(
+ screen.getAllByText(
+ "You don't have permission to update this email."
+ ).length
+ ).toBeGreaterThan( 0 );
+ } );
+
+ it( 'shows the applying label and disables Review/Dismiss when applyState is applying', () => {
+ renderBanner( { applyState: 'applying' } );
+
+ // "Applying…" label.
+ const applyButton = screen.getByRole( 'button', {
+ name: /applying/i,
+ } );
+ expect( applyButton ).toBeInTheDocument();
+
+ const reviewButton = screen.getByRole( 'button', {
+ name: /^review$/i,
+ } );
+ const dismissButton = screen.getByRole( 'button', {
+ name: /dismiss for this session/i,
+ } );
+ expect( reviewButton ).toBeDisabled();
+ expect( dismissButton ).toBeDisabled();
+ } );
+
+ it( 'renders the success morph and auto-dismisses after 2s via onAutoDismiss', () => {
+ jest.useFakeTimers();
+ const onDismiss = jest.fn();
+ const onAutoDismiss = jest.fn();
+ renderBanner( { applyState: 'applied', onDismiss, onAutoDismiss } );
+
+ expect( screen.getByText( 'Template updated' ) ).toBeInTheDocument();
+ expect(
+ screen.getByText( 'Your customizations were preserved.' )
+ ).toBeInTheDocument();
+
+ act( () => {
+ jest.advanceTimersByTime( 2000 );
+ } );
+ // Spec §9.2: success auto-dismiss does NOT fire `_dismissed`. The
+ // banner routes through the no-event path so the hook can skip
+ // `recordEvent`.
+ expect( onAutoDismiss ).toHaveBeenCalledTimes( 1 );
+ expect( onDismiss ).not.toHaveBeenCalled();
+ } );
+
+ it( 'success-state × button routes to onAutoDismiss, not onDismiss', () => {
+ const onDismiss = jest.fn();
+ const onAutoDismiss = jest.fn();
+ renderBanner( { applyState: 'applied', onDismiss, onAutoDismiss } );
+
+ fireEvent.click(
+ screen.getByRole( 'button', {
+ name: /dismiss for this session/i,
+ } )
+ );
+ expect( onAutoDismiss ).toHaveBeenCalledTimes( 1 );
+ expect( onDismiss ).not.toHaveBeenCalled();
+ } );
+
+ it( 'renders the failure morph with role=alert and does not auto-dismiss', () => {
+ jest.useFakeTimers();
+ const onDismiss = jest.fn();
+ const { container } = renderBanner( {
+ applyState: 'failed',
+ onDismiss,
+ } );
+
+ expect( screen.getByText( /couldn't apply/i ) ).toBeInTheDocument();
+ expect(
+ screen.getByRole( 'button', { name: /try again/i } )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole( 'button', { name: /review changes/i } )
+ ).toBeInTheDocument();
+
+ // Use the container query — failure morph carries role="alert".
+ expect( container.querySelector( '[role="alert"]' ) ).not.toBeNull();
+
+ act( () => {
+ jest.advanceTimersByTime( 5000 );
+ } );
+ expect( onDismiss ).not.toHaveBeenCalled();
+ } );
+
+ it( 'renders default container with role=status and aria-live=polite', () => {
+ const { container } = renderBanner();
+ const statusEl = container.querySelector(
+ '[role="status"][aria-live="polite"]'
+ );
+ expect( statusEl ).not.toBeNull();
+ } );
+
+ it( 'fires the click callbacks for Apply, Review, and Dismiss', () => {
+ const onApply = jest.fn();
+ const onReview = jest.fn();
+ const onDismiss = jest.fn();
+ const onAutoDismiss = jest.fn();
+ renderBanner( { onApply, onReview, onDismiss, onAutoDismiss } );
+
+ fireEvent.click( screen.getByRole( 'button', { name: /^apply$/i } ) );
+ fireEvent.click( screen.getByRole( 'button', { name: /^review$/i } ) );
+ fireEvent.click(
+ screen.getByRole( 'button', {
+ name: /dismiss for this session/i,
+ } )
+ );
+
+ expect( onApply ).toHaveBeenCalledTimes( 1 );
+ expect( onReview ).toHaveBeenCalledTimes( 1 );
+ // Default-state × routes to onDismiss (the user-initiated path that
+ // fires the `_dismissed` Tracks event in the hook).
+ expect( onDismiss ).toHaveBeenCalledTimes( 1 );
+ expect( onAutoDismiss ).not.toHaveBeenCalled();
+ } );
+
+ it( 'failure-state × routes to onDismiss (Tracks event fires)', () => {
+ const onDismiss = jest.fn();
+ const onAutoDismiss = jest.fn();
+ renderBanner( { applyState: 'failed', onDismiss, onAutoDismiss } );
+
+ fireEvent.click(
+ screen.getByRole( 'button', {
+ name: /dismiss for this session/i,
+ } )
+ );
+ expect( onDismiss ).toHaveBeenCalledTimes( 1 );
+ expect( onAutoDismiss ).not.toHaveBeenCalled();
+ } );
+} );
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/__tests__/use-apply-update-suppress-snackbar.test.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/__tests__/use-apply-update-suppress-snackbar.test.ts
new file mode 100644
index 00000000000..23c10626fa6
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/__tests__/use-apply-update-suppress-snackbar.test.ts
@@ -0,0 +1,161 @@
+/**
+ * External dependencies
+ */
+import { renderHook, act } from '@testing-library/react';
+import apiFetch from '@wordpress/api-fetch';
+
+/**
+ * Internal dependencies
+ */
+import { useApplyUpdate } from '../hooks/use-apply-update';
+
+const createSuccessNoticeMock = jest.fn();
+const createErrorNoticeMock = jest.fn();
+const receiveEntityRecordsMock = jest.fn();
+
+jest.mock( '@wordpress/api-fetch', () => jest.fn() );
+jest.mock( '@wordpress/data', () => ( {
+ useDispatch: () => ( {
+ createSuccessNotice: createSuccessNoticeMock,
+ createErrorNotice: createErrorNoticeMock,
+ receiveEntityRecords: receiveEntityRecordsMock,
+ } ),
+ select: () => ( {
+ getEntityRecord: () => ( { content: { raw: 'old content' } } ),
+ } ),
+} ) );
+jest.mock( '@wordpress/notices', () => ( { store: 'core/notices' } ) );
+jest.mock( '@wordpress/core-data', () => ( { store: 'core' } ) );
+
+const mockedApiFetch = apiFetch as unknown as jest.Mock;
+
+describe( 'useApplyUpdate — suppressSnackbarOnError option', () => {
+ beforeEach( () => {
+ createSuccessNoticeMock.mockClear();
+ createErrorNoticeMock.mockClear();
+ receiveEntityRecordsMock.mockClear();
+ mockedApiFetch.mockReset();
+ } );
+
+ it( 'fires error snackbar by default when /apply fails', async () => {
+ mockedApiFetch.mockRejectedValueOnce( new Error( 'boom' ) );
+
+ const { result } = renderHook( () => useApplyUpdate( 42 ) );
+
+ await act( async () => {
+ await result.current.apply( [] );
+ } );
+
+ expect( createErrorNoticeMock ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ it( 'suppresses error snackbar when suppressSnackbarOnError is true', async () => {
+ mockedApiFetch.mockRejectedValueOnce( new Error( 'boom' ) );
+
+ const { result } = renderHook( () =>
+ useApplyUpdate( 42, { suppressSnackbarOnError: true } )
+ );
+
+ await act( async () => {
+ await result.current.apply( [] );
+ } );
+
+ expect( createErrorNoticeMock ).not.toHaveBeenCalled();
+ } );
+
+ it( 'still fires success snackbar when suppressSnackbarOnError is true', async () => {
+ mockedApiFetch.mockResolvedValueOnce( {
+ merged_content: 'merged',
+ revision_id: 'rev1',
+ version_to: '9.5',
+ status: 'applied',
+ structural_skipped: false,
+ aliases_migrated: [],
+ } );
+
+ const { result } = renderHook( () =>
+ useApplyUpdate( 42, { suppressSnackbarOnError: true } )
+ );
+
+ await act( async () => {
+ await result.current.apply( [] );
+ } );
+
+ expect( createSuccessNoticeMock ).toHaveBeenCalledWith(
+ 'Update applied · customizations preserved',
+ expect.objectContaining( { type: 'snackbar' } )
+ );
+ } );
+
+ it( 'success snackbar copy is the new RSM-141 string', async () => {
+ mockedApiFetch.mockResolvedValueOnce( {
+ merged_content: 'merged',
+ revision_id: 'rev1',
+ version_to: '9.5',
+ status: 'applied',
+ structural_skipped: false,
+ aliases_migrated: [],
+ } );
+
+ const { result } = renderHook( () => useApplyUpdate( 42 ) );
+
+ await act( async () => {
+ await result.current.apply( [] );
+ } );
+
+ expect( createSuccessNoticeMock ).toHaveBeenCalledWith(
+ 'Update applied · customizations preserved',
+ expect.objectContaining( { type: 'snackbar' } )
+ );
+ } );
+
+ it( 'snackbar copy drops the "customizations preserved" suffix when every choice is use_core', async () => {
+ mockedApiFetch.mockResolvedValueOnce( {
+ merged_content: 'merged',
+ revision_id: 'rev1',
+ version_to: '9.5',
+ status: 'applied',
+ structural_skipped: false,
+ aliases_migrated: [],
+ } );
+
+ const { result } = renderHook( () => useApplyUpdate( 42 ) );
+
+ await act( async () => {
+ await result.current.apply( [
+ { path: [ 0 ], decision: 'use_core' },
+ { path: [ 1 ], decision: 'use_core' },
+ ] );
+ } );
+
+ expect( createSuccessNoticeMock ).toHaveBeenCalledWith(
+ 'Update applied',
+ expect.objectContaining( { type: 'snackbar' } )
+ );
+ } );
+
+ it( 'snackbar keeps the suffix when at least one choice is keep_yours', async () => {
+ mockedApiFetch.mockResolvedValueOnce( {
+ merged_content: 'merged',
+ revision_id: 'rev1',
+ version_to: '9.5',
+ status: 'applied',
+ structural_skipped: false,
+ aliases_migrated: [],
+ } );
+
+ const { result } = renderHook( () => useApplyUpdate( 42 ) );
+
+ await act( async () => {
+ await result.current.apply( [
+ { path: [ 0 ], decision: 'use_core' },
+ { path: [ 1 ], decision: 'keep_yours' },
+ ] );
+ } );
+
+ expect( createSuccessNoticeMock ).toHaveBeenCalledWith(
+ 'Update applied · customizations preserved',
+ expect.objectContaining( { type: 'snackbar' } )
+ );
+ } );
+} );
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/__tests__/use-update-banner.test.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/__tests__/use-update-banner.test.ts
new file mode 100644
index 00000000000..bf381c7db47
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/__tests__/use-update-banner.test.ts
@@ -0,0 +1,603 @@
+/**
+ * External dependencies
+ */
+import { act, renderHook } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import { useUpdateBanner } from '../hooks/use-update-banner';
+
+// jsdom ships `crypto` (Node global) but not `crypto.subtle`, and
+// doesn't expose `TextEncoder` either. The hook's `sha1Hex` helper
+// relies on both. The actual hash value doesn't matter for these
+// tests — `had_customizations` is asserted with `expect.any(Boolean)` —
+// so a no-op mock that resolves to a fixed digest is sufficient.
+if (
+ typeof ( globalThis as { TextEncoder?: unknown } ).TextEncoder ===
+ 'undefined'
+) {
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const { TextEncoder: NodeTextEncoder } = require( 'util' );
+ (
+ globalThis as unknown as { TextEncoder: typeof TextEncoder }
+ ).TextEncoder = NodeTextEncoder;
+}
+if ( ! ( globalThis as { crypto?: { subtle?: unknown } } ).crypto?.subtle ) {
+ Object.defineProperty( globalThis, 'crypto', {
+ value: {
+ ...( globalThis as { crypto?: object } ).crypto,
+ subtle: {
+ digest: jest
+ .fn()
+ .mockResolvedValue( new Uint8Array( 20 ).buffer ),
+ },
+ },
+ configurable: true,
+ writable: true,
+ } );
+}
+
+// ---- recordEvent mock (Tracks) -------------------------------------------
+const recordEventMock = jest.fn();
+jest.mock( '@woocommerce/tracks', () => ( {
+ recordEvent: ( ...args: unknown[] ) => recordEventMock( ...args ),
+} ) );
+
+// ---- @wordpress/data mock ------------------------------------------------
+//
+// `useSelect` is mocked to return whatever the per-test `selectShape`
+// describes. The hook's `useSelect` lambda is bypassed; we hand back the
+// flat shape directly (matches the lambda's computed return type).
+//
+// `useDispatch` returns spies for every action the hook may dispatch
+// against the integration store. `select` (imperative) is used by the
+// hook to consult the viewed-pair dedup selector at the moment of firing
+// `_viewed`.
+const dispatchMocks = {
+ dismissUpdateBanner: jest.fn(),
+ clearDismissedForPost: jest.fn(),
+ markUpdateBannerViewed: jest.fn(),
+ openReviewDrawer: jest.fn(),
+};
+let wasViewedReturn = false;
+let useSelectReturn: Record< string, unknown > = {};
+let useEntityRecordReturn: {
+ record: {
+ slug?: string;
+ meta?: Record< string, unknown >;
+ content?: { raw?: string };
+ } | null;
+} = { record: null };
+
+jest.mock( '@wordpress/data', () => ( {
+ // `useSelect` is called by the hook with a lambda + deps; we ignore both
+ // and return the per-test flat shape directly.
+ useSelect: () => useSelectReturn,
+ useDispatch: () => dispatchMocks,
+ select: () => ( {
+ wasUpdateBannerViewedFor: () => wasViewedReturn,
+ } ),
+} ) );
+
+// Stubs for store-shaped imports the hook makes from `@wordpress/core-data`
+// and the project's own modules.
+jest.mock( '@wordpress/core-data', () => ( {
+ store: 'core',
+ useEntityRecord: () => useEntityRecordReturn,
+} ) );
+
+// Default summary mock: returns whatever `useChangeSummaryReturn` says.
+let useChangeSummaryReturn: {
+ summary: unknown;
+ isLoading: boolean;
+ error: Error | null;
+ refetch: () => void;
+} = {
+ summary: null,
+ isLoading: false,
+ error: null,
+ refetch: jest.fn(),
+};
+jest.mock( '../hooks/use-change-summary', () => ( {
+ useChangeSummary: () => useChangeSummaryReturn,
+} ) );
+
+// useApplyUpdate mock; per-test override available via `applyMock`.
+let applyMock: jest.Mock = jest.fn().mockResolvedValue( {
+ merged_content: 'merged',
+ revision_id: 'rev-1',
+ version_to: '9.5',
+ status: 'applied',
+ structural_skipped: false,
+ aliases_migrated: [],
+} );
+jest.mock( '../hooks/use-apply-update', () => ( {
+ useApplyUpdate: () => ( {
+ apply: ( ...args: unknown[] ) => applyMock( ...args ),
+ isApplying: false,
+ } ),
+} ) );
+
+// ---- Per-test setup helpers ---------------------------------------------
+
+interface SelectShape {
+ postId: number | null;
+ postType: string | null;
+ isDirty: boolean;
+ canUserUpdate: boolean;
+ isDismissed: boolean;
+ wasViewed: boolean;
+}
+
+const defaultSelectShape: SelectShape = {
+ postId: 42,
+ postType: 'woo_email',
+ isDirty: false,
+ canUserUpdate: true,
+ isDismissed: false,
+ wasViewed: false,
+};
+
+const defaultRecord = {
+ slug: 'customer_processing_order',
+ meta: {
+ _wc_email_template_status: 'core_updated_customized',
+ _wc_email_template_version: '9.4',
+ _wc_email_template_source_hash: 'abc123',
+ _wc_email_backfilled: false,
+ },
+ content: { raw: '<!-- wp:paragraph --><p>hi</p><!-- /wp:paragraph -->' },
+};
+
+function summaryFixture( overrides: Record< string, unknown > = {} ) {
+ return {
+ version_from: '9.4',
+ version_to: '9.5',
+ source_hash_to: 'def456',
+ added_blocks: [],
+ removed_blocks: [],
+ copy_changes: [],
+ structural_changes: [],
+ summary_lines: [ 'Header logo updated.', 'Footer text refreshed.' ],
+ is_fallback: false,
+ cache_hit: false,
+ ...overrides,
+ };
+}
+
+function setUpMocks(
+ overrides: {
+ selectShape?: Partial< SelectShape >;
+ record?: typeof defaultRecord | null | Partial< typeof defaultRecord >;
+ recordMeta?: Record< string, unknown >;
+ summary?: unknown;
+ summaryLoading?: boolean;
+ summaryError?: Error | null;
+ wasViewed?: boolean;
+ apply?: jest.Mock;
+ } = {}
+) {
+ useSelectReturn = {
+ ...defaultSelectShape,
+ ...( overrides.selectShape ?? {} ),
+ };
+
+ if ( overrides.record === null ) {
+ useEntityRecordReturn = { record: null };
+ } else {
+ const baseRecord = {
+ ...defaultRecord,
+ ...( overrides.record ?? {} ),
+ };
+ if ( overrides.recordMeta ) {
+ baseRecord.meta = { ...baseRecord.meta, ...overrides.recordMeta };
+ }
+ useEntityRecordReturn = { record: baseRecord };
+ }
+
+ useChangeSummaryReturn = {
+ summary: overrides.summary !== undefined ? overrides.summary : null,
+ isLoading: overrides.summaryLoading ?? false,
+ error: overrides.summaryError ?? null,
+ refetch: jest.fn(),
+ };
+
+ wasViewedReturn = overrides.wasViewed ?? false;
+
+ if ( overrides.apply ) {
+ applyMock = overrides.apply;
+ } else {
+ applyMock = jest.fn().mockResolvedValue( {
+ merged_content: 'merged',
+ revision_id: 'rev-1',
+ version_to: '9.5',
+ status: 'applied',
+ structural_skipped: false,
+ aliases_migrated: [],
+ } );
+ }
+}
+
+beforeEach( () => {
+ dispatchMocks.dismissUpdateBanner.mockClear();
+ dispatchMocks.clearDismissedForPost.mockClear();
+ dispatchMocks.markUpdateBannerViewed.mockClear();
+ dispatchMocks.openReviewDrawer.mockClear();
+ recordEventMock.mockClear();
+ setUpMocks();
+} );
+
+// ==========================================================================
+// Sub-phase 6a — eligibility predicate
+// ==========================================================================
+describe( 'useUpdateBanner — eligibility (6a)', () => {
+ it.each( [
+ [ 'in_sync' ],
+ [ 'core_updated_uncustomized' ],
+ [ null ],
+ [ 'something_unexpected' ],
+ ] )(
+ 'shouldRender is false when status is %p',
+ ( status: string | null ) => {
+ setUpMocks( {
+ recordMeta: { _wc_email_template_status: status },
+ } );
+ const { result } = renderHook( () => useUpdateBanner() );
+ expect( result.current.shouldRender ).toBe( false );
+ }
+ );
+
+ it( 'shouldRender is true when status is core_updated_customized and all gates pass', () => {
+ setUpMocks();
+ const { result } = renderHook( () => useUpdateBanner() );
+ expect( result.current.shouldRender ).toBe( true );
+ } );
+
+ it( 'shouldRender is false when postId is null', () => {
+ setUpMocks( { selectShape: { postId: null } } );
+ const { result } = renderHook( () => useUpdateBanner() );
+ expect( result.current.shouldRender ).toBe( false );
+ } );
+
+ it( 'shouldRender is false when postType is not woo_email', () => {
+ setUpMocks( { selectShape: { postType: 'post' } } );
+ const { result } = renderHook( () => useUpdateBanner() );
+ expect( result.current.shouldRender ).toBe( false );
+ } );
+
+ it( 'shouldRender flips to false when isDismissed is true', () => {
+ setUpMocks( { selectShape: { isDismissed: true } } );
+ const { result } = renderHook( () => useUpdateBanner() );
+ expect( result.current.shouldRender ).toBe( false );
+ } );
+} );
+
+// ==========================================================================
+// Sub-phase 6b — change-summary integration + conflict gate
+// ==========================================================================
+describe( 'useUpdateBanner — change summary + conflicts (6b)', () => {
+ it( 'exposes the summary returned by useChangeSummary', () => {
+ const fixture = summaryFixture();
+ setUpMocks( { summary: fixture } );
+ const { result } = renderHook( () => useUpdateBanner() );
+ expect( result.current.summary ).toEqual( fixture );
+ } );
+
+ it( 'hasConflicts is true when copy_changes is non-empty', () => {
+ setUpMocks( {
+ summary: summaryFixture( {
+ copy_changes: [
+ {
+ block: 'Paragraph',
+ before: 'old',
+ after: 'new',
+ occurrence: 1,
+ total: 1,
+ path: [ 0 ],
+ },
+ ],
+ } ),
+ } );
+ const { result } = renderHook( () => useUpdateBanner() );
+ expect( result.current.hasConflicts ).toBe( true );
+ } );
+
+ it( 'hasConflicts stays false when only structural_changes are present', () => {
+ setUpMocks( {
+ summary: summaryFixture( {
+ structural_changes: [
+ {
+ kind: 'reorder',
+ description: 'Two top-level blocks reordered.',
+ },
+ ],
+ } ),
+ } );
+ const { result } = renderHook( () => useUpdateBanner() );
+ expect( result.current.hasConflicts ).toBe( false );
+ } );
+
+ it( 'shouldRender flips to false when summary reports merchant reviewed this version (version_from >= version_to)', () => {
+ // Canonical detector check: when stored version >= current registry
+ // version the merchant has already reviewed this release — hide the
+ // indicator even if status is still customized (which happens after
+ // a drawer apply that kept any customizations).
+ setUpMocks( {
+ summary: summaryFixture( {
+ version_from: '10.7.0',
+ version_to: '10.7.0',
+ } ),
+ } );
+ const { result } = renderHook( () => useUpdateBanner() );
+ expect( result.current.shouldRender ).toBe( false );
+ } );
+
+ it( 'shouldRender stays true when merchant version is older than current (version_from < version_to)', () => {
+ setUpMocks( {
+ summary: summaryFixture( {
+ version_from: '10.6.0',
+ version_to: '10.7.0',
+ } ),
+ } );
+ const { result } = renderHook( () => useUpdateBanner() );
+ expect( result.current.shouldRender ).toBe( true );
+ } );
+
+ it( 'shouldRender flips to false when summary reports no real diff (status stale despite older version)', () => {
+ // Defensive guard: even when version-compare says merchant hasn't
+ // reviewed yet, hide the banner if the summary has no real changes.
+ // Avoids surfacing a "Review update" → drawer with `Apply (0)` dead
+ // end (test fixtures or stale meta can produce this state).
+ setUpMocks( {
+ summary: summaryFixture( {
+ version_from: '9.4.0-test',
+ version_to: '10.7.0',
+ summary_lines: [],
+ added_blocks: [],
+ removed_blocks: [],
+ copy_changes: [],
+ structural_changes: [],
+ is_fallback: false,
+ } ),
+ } );
+ const { result } = renderHook( () => useUpdateBanner() );
+ expect( result.current.shouldRender ).toBe( false );
+ } );
+} );
+
+// ==========================================================================
+// Sub-phase 6c — apply state machine + dirty/read-only gates + dispatchers
+// ==========================================================================
+describe( 'useUpdateBanner — apply / gates / dispatchers (6c)', () => {
+ it( 'apply transitions idle -> applying -> applied on success', async () => {
+ // `applyResolve` lets us hold the in-flight promise so we can
+ // observe the intermediate `applying` state.
+ let applyResolve: ( v: unknown ) => void = () => {};
+ const apply = jest.fn(
+ () =>
+ new Promise( ( resolve ) => {
+ applyResolve = resolve;
+ } )
+ );
+ setUpMocks( {
+ summary: summaryFixture(),
+ apply: apply as unknown as jest.Mock,
+ } );
+
+ const { result } = renderHook( () => useUpdateBanner() );
+ expect( result.current.applyState ).toBe( 'idle' );
+
+ let applyPromise: Promise< void > = Promise.resolve();
+ // `apply()` flips to `'applying'` synchronously before the sha1
+ // microtask, so a single microtask flush is enough.
+ await act( async () => {
+ applyPromise = result.current.apply();
+ await Promise.resolve();
+ } );
+ // In flight.
+ expect( result.current.applyState ).toBe( 'applying' );
+
+ await act( async () => {
+ applyResolve( {
+ merged_content: 'merged',
+ revision_id: 'rev-1',
+ version_to: '9.5',
+ status: 'applied',
+ structural_skipped: false,
+ aliases_migrated: [],
+ } );
+ await applyPromise;
+ } );
+
+ expect( result.current.applyState ).toBe( 'applied' );
+ } );
+
+ it( 'apply transitions idle -> applying -> failed when doApply resolves with null (falsy result treated as failure)', async () => {
+ setUpMocks( {
+ summary: summaryFixture(),
+ apply: jest.fn().mockResolvedValue( null ),
+ } );
+
+ const { result } = renderHook( () => useUpdateBanner() );
+
+ await act( async () => {
+ await result.current.apply();
+ } );
+
+ expect( result.current.applyState ).toBe( 'failed' );
+ } );
+
+ it( 'apply transitions to failed when doApply rejects (so banner can recover)', async () => {
+ setUpMocks( {
+ summary: summaryFixture(),
+ apply: jest.fn().mockRejectedValue( new Error( 'network down' ) ),
+ } );
+
+ const { result } = renderHook( () => useUpdateBanner() );
+
+ await act( async () => {
+ await result.current.apply();
+ } );
+
+ expect( result.current.applyState ).toBe( 'failed' );
+ } );
+
+ it( 'canApply is false and disabledReason is "dirty" when post is dirty; canReview is true', () => {
+ setUpMocks( {
+ summary: summaryFixture(),
+ selectShape: { isDirty: true },
+ } );
+ const { result } = renderHook( () => useUpdateBanner() );
+ expect( result.current.canApply ).toBe( false );
+ expect( result.current.canReview ).toBe( true );
+ expect( result.current.disabledReason ).toBe( 'dirty' );
+ } );
+
+ it( 'canApply and canReview are both false when canUserUpdate is false; disabledReason is "read_only"', () => {
+ setUpMocks( {
+ summary: summaryFixture(),
+ selectShape: { canUserUpdate: false },
+ } );
+ const { result } = renderHook( () => useUpdateBanner() );
+ expect( result.current.canApply ).toBe( false );
+ expect( result.current.canReview ).toBe( false );
+ expect( result.current.disabledReason ).toBe( 'read_only' );
+ } );
+
+ it( 'canApply is false and canReview is true when conflicts exist; disabledReason is "has_conflicts"', () => {
+ setUpMocks( {
+ summary: summaryFixture( {
+ copy_changes: [
+ {
+ block: 'Paragraph',
+ before: 'old',
+ after: 'new',
+ occurrence: 1,
+ total: 1,
+ path: [ 0 ],
+ },
+ ],
+ } ),
+ } );
+ const { result } = renderHook( () => useUpdateBanner() );
+ expect( result.current.canApply ).toBe( false );
+ expect( result.current.canReview ).toBe( true );
+ expect( result.current.disabledReason ).toBe( 'has_conflicts' );
+ } );
+
+ it( 'dismiss() dispatches dismissUpdateBanner with the postId', () => {
+ setUpMocks( { summary: summaryFixture() } );
+ const { result } = renderHook( () => useUpdateBanner() );
+ act( () => result.current.dismiss() );
+ expect( dispatchMocks.dismissUpdateBanner ).toHaveBeenCalledWith( 42 );
+ } );
+
+ it( 'openReview() dispatches openReviewDrawer', () => {
+ setUpMocks( { summary: summaryFixture() } );
+ const { result } = renderHook( () => useUpdateBanner() );
+ act( () => result.current.openReview() );
+ expect( dispatchMocks.openReviewDrawer ).toHaveBeenCalledTimes( 1 );
+ } );
+} );
+
+// ==========================================================================
+// Sub-phase 6d — Tracks events
+// ==========================================================================
+describe( 'useUpdateBanner — Tracks (6d)', () => {
+ const sharedPayloadMatcher = {
+ email_type: 'customer_processing_order',
+ template_version_from: '9.4',
+ template_version_to: '9.5',
+ source_hash_from: 'abc123',
+ source_hash_to: 'def456',
+ classification: 'core_updated_customized',
+ was_backfilled: false,
+ };
+
+ beforeEach( () => {
+ recordEventMock.mockClear();
+ } );
+
+ it( '_viewed fires on first eligible render with the shared payload', () => {
+ setUpMocks( { summary: summaryFixture() } );
+ renderHook( () => useUpdateBanner() );
+ expect( recordEventMock ).toHaveBeenCalledWith(
+ 'woocommerce_block_email_update_viewed',
+ expect.objectContaining( {
+ ...sharedPayloadMatcher,
+ viewed_from: 'editor_banner',
+ } )
+ );
+ } );
+
+ it( '_viewed does NOT fire when the dedup selector reports the pair was already viewed', () => {
+ setUpMocks( { summary: summaryFixture(), wasViewed: true } );
+ renderHook( () => useUpdateBanner() );
+ expect( recordEventMock ).not.toHaveBeenCalledWith(
+ 'woocommerce_block_email_update_viewed',
+ expect.anything()
+ );
+ } );
+
+ it( '_dismissed fires when dismiss() is called, with the shared payload', () => {
+ setUpMocks( { summary: summaryFixture() } );
+ const { result } = renderHook( () => useUpdateBanner() );
+ recordEventMock.mockClear();
+ act( () => result.current.dismiss() );
+ expect( recordEventMock ).toHaveBeenCalledWith(
+ 'woocommerce_block_email_update_dismissed',
+ expect.objectContaining( sharedPayloadMatcher )
+ );
+ } );
+
+ it( 'autoDismiss() does NOT fire _dismissed but DOES dispatch dismissUpdateBanner', () => {
+ // Spec §9.2: the success-morph auto-dismiss path must NOT fire the
+ // `_dismissed` Tracks event. The store dispatch still has to fire,
+ // otherwise the banner wouldn't unmount on success morph timeout.
+ setUpMocks( { summary: summaryFixture() } );
+ const { result } = renderHook( () => useUpdateBanner() );
+ recordEventMock.mockClear();
+ dispatchMocks.dismissUpdateBanner.mockClear();
+ act( () => result.current.autoDismiss() );
+ expect( recordEventMock ).not.toHaveBeenCalledWith(
+ 'woocommerce_block_email_update_dismissed',
+ expect.anything()
+ );
+ expect( dispatchMocks.dismissUpdateBanner ).toHaveBeenCalledWith( 42 );
+ } );
+
+ it( '_applied fires on apply success with shared payload + applied_from + auto_resolved + had_customizations', async () => {
+ setUpMocks( { summary: summaryFixture() } );
+ const { result } = renderHook( () => useUpdateBanner() );
+ recordEventMock.mockClear();
+ await act( async () => {
+ await result.current.apply();
+ } );
+ expect( recordEventMock ).toHaveBeenCalledWith(
+ 'woocommerce_block_email_update_applied',
+ expect.objectContaining( {
+ ...sharedPayloadMatcher,
+ applied_from: 'editor_banner',
+ auto_resolved: true,
+ had_customizations: expect.any( Boolean ),
+ } )
+ );
+ } );
+
+ it( '_applied does NOT fire on apply failure', async () => {
+ setUpMocks( {
+ summary: summaryFixture(),
+ apply: jest.fn().mockResolvedValue( null ),
+ } );
+ const { result } = renderHook( () => useUpdateBanner() );
+ recordEventMock.mockClear();
+ await act( async () => {
+ await result.current.apply();
+ } );
+ expect( recordEventMock ).not.toHaveBeenCalledWith(
+ 'woocommerce_block_email_update_applied',
+ expect.anything()
+ );
+ } );
+} );
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 aa3edc2eb63..b55794340fd 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
@@ -38,6 +38,20 @@ interface UndoResponse {
status: 'restored';
}
+interface UseApplyUpdateOptions {
+ /**
+ * When true, suppress the `createErrorNotice` snackbar fired on
+ * `/apply` failure. Use when the calling surface (e.g., RSM-141's
+ * editor banner) carries the error feedback itself and would
+ * otherwise produce a double-error UI.
+ *
+ * Default: false — existing behavior (error snackbar fires).
+ *
+ * @since RSM-141
+ */
+ suppressSnackbarOnError?: boolean;
+}
+
interface UseApplyUpdateResult {
apply: ( choices: ApplyChoice[] ) => Promise< ApplyResponse | null >;
isApplying: boolean;
@@ -56,9 +70,13 @@ interface UseApplyUpdateResult {
* matching pending edits, so no follow-up `saveEditedEntityRecord`
* round-trip is needed.
*
- * @param postId The `woo_email` post ID.
+ * @param postId The `woo_email` post ID.
+ * @param options Optional behavior flags (e.g., `suppressSnackbarOnError`).
*/
-export function useApplyUpdate( postId: number | null ): UseApplyUpdateResult {
+export function useApplyUpdate(
+ postId: number | null,
+ options: UseApplyUpdateOptions = {}
+): UseApplyUpdateResult {
const { createSuccessNotice, createErrorNotice } =
useDispatch( noticesStore );
const { receiveEntityRecords } = useDispatch( coreStore );
@@ -141,7 +159,21 @@ export function useApplyUpdate( postId: number | null ): UseApplyUpdateResult {
syncEditorState( res.merged_content );
- createSuccessNotice( __( 'Update applied.', 'woocommerce' ), {
+ // 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.
+ const allUseCore =
+ choices.length > 0 &&
+ choices.every( ( c ) => c.decision === 'use_core' );
+ const successMessage = allUseCore
+ ? __( 'Update applied', 'woocommerce' )
+ : __(
+ 'Update applied · customizations preserved',
+ 'woocommerce'
+ );
+ createSuccessNotice( successMessage, {
type: 'snackbar',
actions: [
{
@@ -159,7 +191,9 @@ export function useApplyUpdate( postId: number | null ): UseApplyUpdateResult {
err && typeof err === 'object' && 'message' in err
? String( err.message )
: __( 'Could not apply the update.', 'woocommerce' );
- createErrorNotice( message, { type: 'snackbar' } );
+ if ( ! options.suppressSnackbarOnError ) {
+ createErrorNotice( message, { type: 'snackbar' } );
+ }
return null;
} finally {
setIsApplying( false );
@@ -171,6 +205,7 @@ export function useApplyUpdate( postId: number | null ): UseApplyUpdateResult {
createErrorNotice,
undo,
syncEditorState,
+ options.suppressSnackbarOnError,
]
);
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/hooks/use-change-summary.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/hooks/use-change-summary.ts
index 93e4b73ac19..26c980f1ca4 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/hooks/use-change-summary.ts
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/hooks/use-change-summary.ts
@@ -54,6 +54,8 @@ export interface ChangeSummaryStructuralChange {
export interface ChangeSummary {
version_from: string;
version_to: string;
+ /** sha1 of the canonical core content; consumed by RSM-145 Tracks instrumentation. */
+ source_hash_to: string;
added_blocks: ChangeSummaryBlockEntry[];
removed_blocks: ChangeSummaryBlockEntry[];
copy_changes: ChangeSummaryCopyChange[];
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/hooks/use-update-banner.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/hooks/use-update-banner.ts
new file mode 100644
index 00000000000..006e675bd74
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/hooks/use-update-banner.ts
@@ -0,0 +1,528 @@
+/**
+ * External dependencies
+ */
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from '@wordpress/element';
+import { select, useDispatch, useSelect } from '@wordpress/data';
+import { useEntityRecord } from '@wordpress/core-data';
+import { recordEvent } from '@woocommerce/tracks';
+
+/**
+ * Internal dependencies
+ */
+import { useChangeSummary, type ChangeSummary } from './use-change-summary';
+import { useApplyUpdate } from './use-apply-update';
+import { STORE_NAME } from '../store';
+
+/**
+ * Shape of the Tracks payload shared by every banner-fired event.
+ *
+ * RSM-145 standardizes these keys across the divergence-related events
+ * so they can be correlated end-to-end (`viewed` → `applied`/`dismissed`).
+ */
+interface SharedTracksPayload {
+ email_type: string;
+ template_version_from: string;
+ template_version_to: string | null;
+ source_hash_from: string | null;
+ source_hash_to: string | null;
+ classification: string;
+ was_backfilled: boolean;
+ // `recordEvent` types its payload as `{ [k: string]: unknown }`;
+ // the index signature lets us pass `SharedTracksPayload` directly.
+ [ key: string ]: unknown;
+}
+
+interface SharedPayloadInputs {
+ record: {
+ slug?: unknown;
+ meta?: Record< string, unknown >;
+ } | null;
+ summary: ChangeSummary | null;
+}
+
+/**
+ * Build the shared Tracks payload from the current entity record + summary.
+ *
+ * Returns `null` when there's no record yet (eligibility hasn't fired,
+ * so no event should fire either). Falsy `summary` is permitted: in
+ * that case the version_to / source_hash_to fields are `null`.
+ */
+function buildSharedTracksPayload( {
+ record,
+ summary,
+}: SharedPayloadInputs ): SharedTracksPayload | null {
+ const meta = record?.meta;
+ if ( ! meta ) {
+ return null;
+ }
+ const slug = typeof record?.slug === 'string' ? record.slug : '';
+ const versionFrom =
+ typeof meta._wc_email_template_version === 'string'
+ ? ( meta._wc_email_template_version as string )
+ : '';
+ const sourceHashFrom =
+ typeof meta._wc_email_template_source_hash === 'string'
+ ? ( meta._wc_email_template_source_hash as string )
+ : null;
+ const wasBackfilled =
+ meta._wc_email_backfilled === true ||
+ meta._wc_email_backfilled === '1' ||
+ meta._wc_email_backfilled === 1;
+ const classification =
+ typeof meta._wc_email_template_status === 'string'
+ ? ( meta._wc_email_template_status as string )
+ : '';
+
+ return {
+ email_type: slug,
+ template_version_from: versionFrom,
+ template_version_to: summary?.version_to ?? null,
+ source_hash_from: sourceHashFrom,
+ source_hash_to: summary?.source_hash_to ?? null,
+ classification,
+ was_backfilled: wasBackfilled,
+ };
+}
+
+/**
+ * Numeric semver compare. Returns negative if `a < b`, zero if equal, positive
+ * if `a > b`. Lightweight implementation — template versions don't carry
+ * pre-release / build metadata so we don't need full semver semantics.
+ * Mirrors the helper in `settings-email-listing-update-cell.tsx` so the
+ * banner and the list cell stay in lockstep.
+ */
+function compareTemplateVersions( a: string, b: string ): number {
+ const partsA = a.split( '.' ).map( ( s ) => parseInt( s, 10 ) || 0 );
+ const partsB = b.split( '.' ).map( ( s ) => parseInt( s, 10 ) || 0 );
+ const len = Math.max( partsA.length, partsB.length );
+ for ( let i = 0; i < len; i++ ) {
+ const diff = ( partsA[ i ] ?? 0 ) - ( partsB[ i ] ?? 0 );
+ if ( diff !== 0 ) {
+ return diff;
+ }
+ }
+ return 0;
+}
+
+/**
+ * Compute sha1(input) as a lowercase hex string. Used to detect whether
+ * the merchant has customized the post body (`had_customizations`) by
+ * comparing against the `source_hash_from` recorded at upgrade time.
+ */
+async function sha1Hex( input: string ): Promise< string > {
+ const buf = new TextEncoder().encode( input );
+ const digest = await crypto.subtle.digest( 'SHA-1', buf );
+ return Array.from( new Uint8Array( digest ) )
+ .map( ( b ) => b.toString( 16 ).padStart( 2, '0' ) )
+ .join( '' );
+}
+
+/**
+ * Apply state machine values surfaced by `useUpdateBanner`.
+ *
+ * `idle` — no apply has been initiated, or the previous apply settled
+ * and the banner is back to its default surface.
+ * `applying` — `/apply` is in flight.
+ * `applied` — `/apply` succeeded; the editor canvas now reflects the
+ * merged content.
+ * `failed` — `/apply` failed (network, permission, conflict, …).
+ */
+export type ApplyState = 'idle' | 'applying' | 'applied' | 'failed';
+
+interface UseUpdateBannerResult {
+ shouldRender: boolean;
+ summary: ChangeSummary | null;
+ isLoadingSummary: boolean;
+ summaryError: Error | null;
+ applyState: ApplyState;
+ canApply: boolean;
+ canReview: boolean;
+ disabledReason: 'dirty' | 'read_only' | 'has_conflicts' | null;
+ hasConflicts: boolean;
+ expanded: boolean;
+ toggleExpanded: () => void;
+ apply: () => Promise< void >;
+ openReview: () => void;
+ /**
+ * User-initiated dismiss. Removes the banner for this session and
+ * fires the `_dismissed` Tracks event (spec §9.2).
+ */
+ dismiss: () => void;
+ /**
+ * Non-user dismiss path used by the success morph (auto-dismiss timer
+ * and the success-state × click). Removes the banner without firing
+ * the `_dismissed` Tracks event — spec §9.2 explicitly excludes the
+ * post-success unmount from the dismissed-event surface.
+ */
+ autoDismiss: () => void;
+}
+
+/**
+ * The single template-divergence status that should surface the banner.
+ * Anything else (`in_sync`, `core_updated_uncustomized`, null, unknown
+ * future values) is treated as "do nothing".
+ */
+const VALID_STATUS = 'core_updated_customized' as const;
+
+/**
+ * Glue hook for the "update available" editor banner (RSM-141).
+ *
+ * Reads the editor's current post + integration store state, decides
+ * whether the banner should render, and exposes the actions the
+ * `<UpdateBannerPlugin>` component needs to drive review / apply /
+ * dismiss interactions.
+ *
+ * Owns:
+ * - eligibility predicate (status / postId / postType / dismiss state)
+ * - change-summary fetch + conflict derivation
+ * - apply state machine (`idle → applying → applied|failed`)
+ * - dirty / read-only / has_conflicts gates
+ * - per-(postId, version_to) dedup of the `_viewed` Tracks event
+ * - `_viewed` / `_dismissed` / `_applied` Tracks event firing
+ */
+export function useUpdateBanner(): UseUpdateBannerResult {
+ // One `useSelect` lambda computes everything that depends on store
+ // reads. Keeping the shape flat (and the property names matching the
+ // test mock) means the lambda's contract is obvious from one
+ // glance — and the test can bypass the lambda entirely.
+ const { postId, postType, isDirty, canUserUpdate, isDismissed } = useSelect(
+ ( selectFn ) => {
+ // We pass string store keys here because importing `store as
+ // editorStore` from `@wordpress/editor` pulls in a transitive
+ // module that Jest can't resolve in this package's test setup.
+ // Stable WP store keys: `core/editor`, `core` (core-data).
+ const { getCurrentPostId, getCurrentPostType, isEditedPostDirty } =
+ selectFn( 'core/editor' );
+ const { canUser } = selectFn( 'core' );
+ const { isUpdateBannerDismissedFor } = selectFn( STORE_NAME );
+
+ const rawId = getCurrentPostId();
+ const id = typeof rawId === 'number' ? rawId : null;
+ const type = getCurrentPostType() ?? null;
+ const dirty = Boolean( isEditedPostDirty() );
+
+ // `canUser` is `undefined` while the resolver is in flight; treat
+ // undefined as permissive so we don't flicker the banner away
+ // during the initial load. Only an explicit `false` denies.
+ const canUpdateRaw =
+ id !== null
+ ? canUser( 'update', {
+ kind: 'postType',
+ name: 'woo_email',
+ id,
+ } )
+ : undefined;
+ const canUpdate = canUpdateRaw === false ? false : true;
+
+ const dismissed =
+ id !== null
+ ? Boolean( isUpdateBannerDismissedFor( id ) )
+ : false;
+
+ return {
+ postId: id,
+ postType: type,
+ isDirty: dirty,
+ canUserUpdate: canUpdate,
+ isDismissed: dismissed,
+ };
+ },
+ []
+ );
+
+ // `useEntityRecord` always wants a non-null id; pass a harmless `0`
+ // when there's no post yet and ignore the result in that case.
+ const { record } = useEntityRecord(
+ 'postType',
+ 'woo_email',
+ postId ?? 0
+ ) as {
+ record: {
+ slug?: unknown;
+ meta?: Record< string, unknown >;
+ content?: { raw?: string };
+ } | null;
+ };
+
+ const meta = postId !== null ? record?.meta ?? null : null;
+ const status =
+ meta && typeof meta._wc_email_template_status === 'string'
+ ? ( meta._wc_email_template_status as string )
+ : null;
+
+ const isEligibleByStatus = status === VALID_STATUS;
+ const isCorrectPostType = postType === 'woo_email';
+ const shouldRender =
+ postId !== null &&
+ isCorrectPostType &&
+ isEligibleByStatus &&
+ ! isDismissed;
+
+ // Fetch the change-summary only once we've decided the banner is
+ // eligible to render — avoids a needless network call on every
+ // editor load.
+ const {
+ summary: rawSummary,
+ isLoading: isLoadingSummary,
+ error: summaryError,
+ refetch: refetchSummary,
+ } = useChangeSummary( postId, shouldRender );
+
+ // When the review drawer closes — typically right after a drawer-driven
+ // `/apply` succeeds — refresh the change-summary so its `version_from`
+ // reflects the merchant's now-bumped meta. The canonical
+ // `summaryShowsReviewed` check below then sees `version_from >= version_to`
+ // and unmounts the banner. A merchant who just opens and closes the
+ // drawer without applying triggers an extra fetch — acceptable cost.
+ const isReviewDrawerOpen = useSelect(
+ ( selectFn ) => selectFn( STORE_NAME ).isReviewDrawerOpen(),
+ []
+ );
+ const prevDrawerOpenRef = useRef< boolean >( false );
+ useEffect( () => {
+ if ( prevDrawerOpenRef.current && ! isReviewDrawerOpen ) {
+ refetchSummary();
+ }
+ prevDrawerOpenRef.current = isReviewDrawerOpen;
+ }, [ isReviewDrawerOpen, refetchSummary ] );
+
+ // Cache the most recent non-null summary so the banner doesn't briefly
+ // flip variants while a refetch is in flight (which clears `rawSummary`
+ // to null inside `useChangeSummary` before the new response arrives).
+ const lastNonNullSummaryRef = useRef< ChangeSummary | null >( null );
+ if ( rawSummary !== null ) {
+ lastNonNullSummaryRef.current = rawSummary;
+ }
+ const effectiveSummary: ChangeSummary | null =
+ rawSummary ?? lastNonNullSummaryRef.current;
+
+ // Canonical "has the merchant reviewed this version?" check, mirroring
+ // the detector docblock's `version_compare( $reviewed, $current, '<' )`
+ // formula. When the change-summary reports `version_from >= version_to`
+ // the merchant's stored version is at-or-above the registry's current,
+ // so they've reviewed this release — even if status stays
+ // `core_updated_customized` because they kept some customizations on
+ // purpose during a drawer apply. Hide the indicator; the cell uses
+ // the same check via `currentVersion` on the slotfill payload.
+ const summaryShowsReviewed =
+ effectiveSummary !== null &&
+ effectiveSummary.version_from !== '' &&
+ effectiveSummary.version_to !== '' &&
+ compareTemplateVersions(
+ effectiveSummary.version_from,
+ effectiveSummary.version_to
+ ) >= 0;
+
+ // Defensive: even when version-compare says the merchant hasn't reviewed
+ // yet, hide the banner if there's nothing actually different — sending
+ // the merchant into a drawer that says `Apply (0)` is a dead end. This
+ // catches stale-status scenarios where meta says `core_updated_customized`
+ // but the post content matches canonical core (test fixtures, race
+ // conditions during core upgrade, manual meta edits).
+ const summaryShowsNoChanges =
+ effectiveSummary !== null &&
+ ! effectiveSummary.is_fallback &&
+ effectiveSummary.summary_lines.length === 0 &&
+ effectiveSummary.added_blocks.length === 0 &&
+ effectiveSummary.removed_blocks.length === 0 &&
+ effectiveSummary.copy_changes.length === 0 &&
+ effectiveSummary.structural_changes.length === 0;
+
+ const finalShouldRender =
+ shouldRender && ! summaryShowsReviewed && ! summaryShowsNoChanges;
+ const summary: ChangeSummary | null = finalShouldRender
+ ? effectiveSummary
+ : null;
+ const hasConflicts = summary !== null && summary.copy_changes.length > 0;
+
+ // `@wordpress/data`'s typed dispatch surface isn't exhaustive for
+ // custom stores; cast loosely to grab our integration-store actions.
+ const integrationDispatch = useDispatch( STORE_NAME ) as unknown as {
+ dismissUpdateBanner: ( id: number ) => void;
+ clearDismissedForPost: ( id: number ) => void;
+ markUpdateBannerViewed: ( id: number, versionTo: string ) => void;
+ openReviewDrawer: () => void;
+ };
+ const {
+ dismissUpdateBanner,
+ clearDismissedForPost,
+ markUpdateBannerViewed,
+ openReviewDrawer,
+ } = integrationDispatch;
+
+ // Build the shared Tracks payload once per render of the eligible
+ // banner; reused by `_viewed`, `_dismissed`, `_applied`.
+ const sharedPayload = useMemo< SharedTracksPayload | null >(
+ () =>
+ finalShouldRender
+ ? buildSharedTracksPayload( { record, summary } )
+ : null,
+ // `record` is the upstream entity reference; `summary` is the
+ // fetched change-summary. Both are stable across renders unless
+ // the underlying data actually changed.
+ [ finalShouldRender, record, summary ]
+ );
+
+ const { apply: doApply } = useApplyUpdate( postId, {
+ // The banner surfaces its own failure state via `applyState`;
+ // suppress the global snackbar to avoid double-error UI.
+ suppressSnackbarOnError: true,
+ } );
+
+ const [ applyState, setApplyState ] = useState< ApplyState >( 'idle' );
+
+ const canApply =
+ finalShouldRender &&
+ canUserUpdate &&
+ ! isDirty &&
+ ! hasConflicts &&
+ applyState === 'idle';
+
+ const canReview =
+ finalShouldRender && canUserUpdate && applyState === 'idle';
+
+ let disabledReason: UseUpdateBannerResult[ 'disabledReason' ] = null;
+ if ( ! canUserUpdate ) {
+ disabledReason = 'read_only';
+ } else if ( isDirty ) {
+ disabledReason = 'dirty';
+ } else if ( hasConflicts ) {
+ disabledReason = 'has_conflicts';
+ }
+
+ const [ expanded, setExpanded ] = useState< boolean >( false );
+ const toggleExpanded = useCallback( () => setExpanded( ( v ) => ! v ), [] );
+
+ // Clear the previous post's dismiss flag when the editor swaps to a
+ // different `woo_email` post, so the user dismissing the banner on
+ // post A doesn't keep it suppressed on post B.
+ const prevPostIdRef = useRef< number | null >( null );
+ useEffect( () => {
+ const prev = prevPostIdRef.current;
+ if ( prev !== null && prev !== postId ) {
+ clearDismissedForPost( prev );
+ }
+ prevPostIdRef.current = postId;
+ }, [ postId, clearDismissedForPost ] );
+
+ // Fire the `_viewed` Tracks event exactly once per
+ // (postId, version_to) pair — store-backed dedup survives
+ // re-renders and unmount/remount cycles within a session.
+ useEffect( () => {
+ if (
+ ! finalShouldRender ||
+ ! sharedPayload ||
+ postId === null ||
+ sharedPayload.template_version_to === null
+ ) {
+ return;
+ }
+ // Imperative consultation (not a `useSelect` lambda dep) so the
+ // effect doesn't re-run just because the dedup set mutates.
+ const integration = select( STORE_NAME ) as unknown as {
+ wasUpdateBannerViewedFor: ( id: number, v: string ) => boolean;
+ };
+ if (
+ integration.wasUpdateBannerViewedFor(
+ postId,
+ sharedPayload.template_version_to
+ )
+ ) {
+ return;
+ }
+ markUpdateBannerViewed( postId, sharedPayload.template_version_to );
+ recordEvent( 'woocommerce_block_email_update_viewed', {
+ ...sharedPayload,
+ viewed_from: 'editor_banner',
+ } );
+ }, [ finalShouldRender, postId, sharedPayload, markUpdateBannerViewed ] );
+
+ const apply = useCallback( async () => {
+ if ( ! sharedPayload || ! record ) {
+ setApplyState( 'failed' );
+ return;
+ }
+ // Flip to `applying` synchronously so the UI reflects the click
+ // even before the (fast) sha1 microtask + the apply round-trip.
+ setApplyState( 'applying' );
+ try {
+ // Compute `had_customizations` BEFORE the apply round-trip so
+ // the comparison is against the pre-apply content, not the
+ // merged content that core-data caches mid-flight.
+ const contentRaw =
+ ( record as { content?: { raw?: string } } ).content?.raw ?? '';
+ const hadCustomizations = sharedPayload.source_hash_from
+ ? ( await sha1Hex( contentRaw ) ) !==
+ sharedPayload.source_hash_from
+ : false;
+
+ const res = await doApply( [] );
+ if ( res ) {
+ setApplyState( 'applied' );
+ recordEvent( 'woocommerce_block_email_update_applied', {
+ ...sharedPayload,
+ applied_from: 'editor_banner',
+ auto_resolved: true,
+ had_customizations: hadCustomizations,
+ } );
+ } else {
+ setApplyState( 'failed' );
+ }
+ } catch {
+ // `doApply` already swallows fetch errors, but `sha1Hex` (Web
+ // Crypto) and any future async work in here can throw — make
+ // sure the banner can recover instead of getting stuck in
+ // `applying`.
+ setApplyState( 'failed' );
+ }
+ }, [ doApply, sharedPayload, record ] );
+
+ const openReview = useCallback( () => {
+ openReviewDrawer();
+ }, [ openReviewDrawer ] );
+
+ const dismiss = useCallback( () => {
+ if ( postId === null || ! sharedPayload ) {
+ return;
+ }
+ dismissUpdateBanner( postId );
+ recordEvent(
+ 'woocommerce_block_email_update_dismissed',
+ sharedPayload
+ );
+ }, [ postId, sharedPayload, dismissUpdateBanner ] );
+
+ // Auto-dismiss path — used by the success morph (timer + ×). Mirrors
+ // `dismiss` minus the Tracks event; spec §9.2 excludes the success
+ // auto-dismiss from the `_dismissed` event surface.
+ const autoDismiss = useCallback( () => {
+ if ( postId === null ) {
+ return;
+ }
+ dismissUpdateBanner( postId );
+ }, [ postId, dismissUpdateBanner ] );
+
+ return {
+ shouldRender: finalShouldRender,
+ summary,
+ isLoadingSummary,
+ summaryError,
+ applyState,
+ canApply,
+ canReview,
+ disabledReason,
+ hasConflicts,
+ expanded,
+ toggleExpanded,
+ apply,
+ openReview,
+ dismiss,
+ autoDismiss,
+ };
+}
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/index.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/index.ts
index ecdfd872f00..84255c74fcb 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/index.ts
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/index.ts
@@ -21,12 +21,14 @@ import { modifySidebar } from './sidebar_settings';
import { registerEmailValidationRules } from './email-validation';
import getResetNotificationEmailContentAction from './reset-notification-email-content';
import { ReviewUpdatePlugin } from './review-update-plugin';
+import { UpdateBannerPlugin } from './update-banner-plugin';
import {
registerStore as registerIntegrationStore,
STORE_NAME as INTEGRATION_STORE_NAME,
} from './store';
import './style.scss';
+import './update-banner.scss';
addFilter( 'woocommerce_email_editor_send_button_label', NAME_SPACE, () =>
__( 'Save email', 'woocommerce' )
@@ -96,6 +98,16 @@ registerPlugin( 'woocommerce-email-editor-review-update', {
render: ReviewUpdatePlugin,
} );
+// Register the update banner plugin (RSM-141). Mounts a floating banner
+// over the editor canvas when the open email post is classified
+// `core_updated_customized`. Reads dismiss + viewed-dedup state from the
+// integration store; consumes useChangeSummary (RSM-142) and
+// useApplyUpdate (RSM-143) for content + apply.
+registerPlugin( 'woocommerce-email-editor-update-banner', {
+ scope: 'woocommerce-email-editor',
+ render: UpdateBannerPlugin,
+} );
+
// Deep-link contract: opens the review drawer when arriving with
// `?wc_email_review_drawer=1` (set by the email list page's update indicator).
if (
@@ -104,6 +116,12 @@ if (
) === '1'
) {
dispatch( INTEGRATION_STORE_NAME ).openReviewDrawer();
+
+ // Strip the param from the URL so a refresh doesn't re-trigger the
+ // drawer auto-open. RSM-141 §5.2.
+ const url = new URL( window.location.href );
+ url.searchParams.delete( 'wc_email_review_drawer' );
+ window.history.replaceState( {}, '', url.pathname + url.search + url.hash );
}
/**
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/store.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/store.ts
index b8a16f23b6e..2258c39408d 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/store.ts
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/store.ts
@@ -15,24 +15,63 @@ import type {
* than passing props across siblings, since the trigger and the drawer
* live in different React subtrees mounted by `registerPlugin`.
*
+ * RSM-141 extends this store with two additional pieces of session-only
+ * state for the "update available" banner:
+ * - `dismissedPostIds`: which posts the user has dismissed the banner
+ * for during this editor session, so we don't re-show it after they
+ * explicitly close it.
+ * - `viewedPostVersionPairs`: which (postId, versionTo) pairs have
+ * already fired the "viewed" Tracks event, so re-renders don't
+ * double-count impressions.
+ *
* @internal
*/
export const STORE_NAME = 'woocommerce/email-editor-integration';
interface State {
isReviewDrawerOpen: boolean;
+ dismissedPostIds: Set< number >;
+ viewedPostVersionPairs: Set< string >;
}
const initialState: State = {
isReviewDrawerOpen: false,
+ dismissedPostIds: new Set< number >(),
+ viewedPostVersionPairs: new Set< string >(),
};
-type Action = { type: 'SET_REVIEW_DRAWER_OPEN'; open: boolean };
+type Action =
+ | { type: 'SET_REVIEW_DRAWER_OPEN'; open: boolean }
+ | { type: 'DISMISS_UPDATE_BANNER'; postId: number }
+ | { type: 'CLEAR_DISMISSED_FOR_POST'; postId: number }
+ | { type: 'MARK_UPDATE_BANNER_VIEWED'; postId: number; versionTo: string };
const reducer = ( state: State = initialState, action: Action ): State => {
switch ( action.type ) {
case 'SET_REVIEW_DRAWER_OPEN':
return { ...state, isReviewDrawerOpen: action.open };
+ case 'DISMISS_UPDATE_BANNER': {
+ const nextDismissed = new Set( state.dismissedPostIds );
+ nextDismissed.add( action.postId );
+ return { ...state, dismissedPostIds: nextDismissed };
+ }
+ case 'CLEAR_DISMISSED_FOR_POST': {
+ if ( ! state.dismissedPostIds.has( action.postId ) ) {
+ return state;
+ }
+ const nextDismissed = new Set( state.dismissedPostIds );
+ nextDismissed.delete( action.postId );
+ return { ...state, dismissedPostIds: nextDismissed };
+ }
+ case 'MARK_UPDATE_BANNER_VIEWED': {
+ const key = `${ action.postId }:${ action.versionTo }`;
+ if ( state.viewedPostVersionPairs.has( key ) ) {
+ return state;
+ }
+ const nextViewed = new Set( state.viewedPostVersionPairs );
+ nextViewed.add( key );
+ return { ...state, viewedPostVersionPairs: nextViewed };
+ }
default:
return state;
}
@@ -45,10 +84,24 @@ const actions = {
( { type: 'SET_REVIEW_DRAWER_OPEN', open: true } as const ),
closeReviewDrawer: () =>
( { type: 'SET_REVIEW_DRAWER_OPEN', open: false } as const ),
+ dismissUpdateBanner: ( postId: number ) =>
+ ( { type: 'DISMISS_UPDATE_BANNER', postId } as const ),
+ clearDismissedForPost: ( postId: number ) =>
+ ( { type: 'CLEAR_DISMISSED_FOR_POST', postId } as const ),
+ markUpdateBannerViewed: ( postId: number, versionTo: string ) =>
+ ( { type: 'MARK_UPDATE_BANNER_VIEWED', postId, versionTo } as const ),
};
const selectors = {
isReviewDrawerOpen: ( state: State ): boolean => state.isReviewDrawerOpen,
+ isUpdateBannerDismissedFor: ( state: State, postId: number ): boolean =>
+ state.dismissedPostIds.has( postId ),
+ wasUpdateBannerViewedFor: (
+ state: State,
+ postId: number,
+ versionTo: string
+ ): boolean =>
+ state.viewedPostVersionPairs.has( `${ postId }:${ versionTo }` ),
};
const config = { reducer, actions, selectors };
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/update-banner-plugin.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/update-banner-plugin.tsx
new file mode 100644
index 00000000000..df2aa71cd1b
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/update-banner-plugin.tsx
@@ -0,0 +1,85 @@
+/**
+ * External dependencies
+ */
+import { createPortal, useEffect, useState } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import { UpdateBanner } from './update-banner';
+import { useUpdateBanner } from './hooks/use-update-banner';
+
+/**
+ * DOM selectors we try in order to find the editor canvas to portal
+ * the banner into. The first match wins; if none match we fall back
+ * to `document.body` so the banner never silently disappears.
+ */
+const PORTAL_SELECTORS = [
+ '.edit-post-visual-editor',
+ '.editor-canvas-container',
+ '.wp-block-post-content',
+];
+
+/**
+ * Walk the selector list and return the first matching element, or
+ * `null` if nothing matches. Pure DOM lookup — no React state.
+ */
+function resolvePortalTarget(): HTMLElement | null {
+ for ( const selector of PORTAL_SELECTORS ) {
+ const node = document.querySelector( selector );
+ if ( node instanceof HTMLElement ) {
+ return node;
+ }
+ }
+ return null;
+}
+
+/**
+ * Mounts the `<UpdateBanner>` into the email editor's canvas via a
+ * portal (RSM-141). Pure orchestration: reads the banner state from
+ * `useUpdateBanner` and forwards it to the presentational component.
+ *
+ * Resolves the portal target on mount AND once more on the next
+ * animation frame, since the canvas can mount on a later paint than
+ * the plugin itself. Falls back to `document.body` if no canvas
+ * selector matches — the banner never silently disappears.
+ */
+export function UpdateBannerPlugin(): JSX.Element | null {
+ const banner = useUpdateBanner();
+ const [ portalTarget, setPortalTarget ] = useState< HTMLElement | null >(
+ () => resolvePortalTarget() ?? document.body
+ );
+
+ useEffect( () => {
+ // First-paint resolution may have run before the canvas
+ // mounted; re-resolve once on the next frame to catch up.
+ const handle = window.requestAnimationFrame( () => {
+ const next = resolvePortalTarget() ?? document.body;
+ setPortalTarget( ( prev ) => ( prev === next ? prev : next ) );
+ } );
+ return () => {
+ window.cancelAnimationFrame( handle );
+ };
+ }, [] );
+
+ if ( ! banner.shouldRender || ! portalTarget ) {
+ return null;
+ }
+
+ return createPortal(
+ <UpdateBanner
+ summary={ banner.summary }
+ applyState={ banner.applyState }
+ canApply={ banner.canApply }
+ canReview={ banner.canReview }
+ disabledReason={ banner.disabledReason }
+ expanded={ banner.expanded }
+ onApply={ banner.apply }
+ onReview={ banner.openReview }
+ onDismiss={ banner.dismiss }
+ onAutoDismiss={ banner.autoDismiss }
+ onToggleExpanded={ banner.toggleExpanded }
+ />,
+ portalTarget
+ );
+}
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/update-banner.scss b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/update-banner.scss
new file mode 100644
index 00000000000..ea82bd90f90
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/update-banner.scss
@@ -0,0 +1,98 @@
+/**
+ * <UpdateBanner> — RSM-141.
+ *
+ * Floating card pinned 16px from the canvas top, centered horizontally.
+ * Uses WPDS tokens via CSS custom properties with sensible WP-admin
+ * fallbacks; no custom design tokens introduced.
+ */
+
+.wc-update-banner {
+ position: absolute;
+ top: 16px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: min(720px, calc(100% - 32px));
+ background: var(--wpds-color-bg-surface, #fff);
+ border-radius: var(--wpds-border-radius-sm, 2px);
+ box-shadow:
+ var(--wpds-elevation-md, 0 1px 2px rgba(0, 0, 0, 0.06)),
+ 0 4px 12px rgba(0, 0, 0, 0.08);
+ border-left: 3px solid var(--wpds-color-bg-interactive-brand-strong, #3858e9);
+ padding: 12px 16px;
+ display: flex;
+ gap: 16px;
+ align-items: flex-start;
+ z-index: 100;
+ pointer-events: auto;
+
+ &__body {
+ flex: 1;
+ min-width: 0;
+ }
+
+ &__title {
+ font-weight: 600;
+ font-size: 13px;
+ line-height: 1.4;
+ }
+
+ &__subtitle {
+ margin-top: 4px;
+ font-size: 12px;
+ color: var(--wpds-color-text-secondary, #757575);
+ }
+
+ &__expand {
+ appearance: none;
+ background: none;
+ border: 0;
+ padding: 4px 0 0 0;
+ margin-top: 6px;
+ color: var(--wpds-color-bg-interactive-brand-strong, #3858e9);
+ cursor: pointer;
+ font-size: 12px;
+ font-weight: 500;
+ }
+
+ &__changes {
+ margin: 8px 0 0 16px;
+ padding: 0;
+ font-size: 12px;
+ line-height: 1.5;
+ }
+
+ &__actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-shrink: 0;
+ }
+
+ &__dismiss {
+ appearance: none;
+ background: none;
+ border: 0;
+ padding: 4px 8px;
+ font-size: 16px;
+ line-height: 1;
+ cursor: pointer;
+ color: var(--wpds-color-text-secondary, #757575);
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+ }
+
+ /* Success morph */
+ &--success {
+ background: var(--wpds-color-bg-surface-success-weak, #ebffed);
+ border-left-color: var(--wpds-color-bg-interactive-success-strong, #00ba37);
+ }
+
+ /* Failure morph */
+ &--failure {
+ background: var(--wpds-color-bg-surface-warning, #fde6be);
+ border-left-color: #b26200;
+ }
+}
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/update-banner.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/update-banner.tsx
new file mode 100644
index 00000000000..db4659793de
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/update-banner.tsx
@@ -0,0 +1,343 @@
+/**
+ * External dependencies
+ */
+import { useEffect } from '@wordpress/element';
+import { Button, Tooltip } from '@wordpress/components';
+import { __, _n, sprintf } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import type { ChangeSummary } from './hooks/use-change-summary';
+import type { ApplyState } from './hooks/use-update-banner';
+
+/**
+ * Auto-dismiss the success morph this many milliseconds after it
+ * appears. Long enough for the merchant to see the confirmation, short
+ * enough that the banner doesn't linger after the work is done.
+ */
+const SUCCESS_AUTODISMISS_MS = 2000;
+
+/**
+ * Stable id used to wire `aria-controls` from the expand toggle to the
+ * `<ul>` of summary lines.
+ */
+const CHANGES_LIST_ID = 'wc-update-banner-changes';
+
+interface UpdateBannerProps {
+ summary: ChangeSummary | null;
+ applyState: ApplyState;
+ canApply: boolean;
+ canReview: boolean;
+ disabledReason: 'dirty' | 'read_only' | 'has_conflicts' | null;
+ expanded: boolean;
+ onApply: () => void;
+ onReview: () => void;
+ /**
+ * User-initiated dismiss (× click in the default or failure morph).
+ * The hook fires the `_dismissed` Tracks event from this path —
+ * spec §9.2 scopes the event to the user-initiated paths only.
+ */
+ onDismiss: () => void;
+ /**
+ * Non-user dismiss path used by the success morph: both the 2s
+ * auto-dismiss timer and the success-morph × click route here so
+ * the `_dismissed` Tracks event does NOT fire (per spec §9.2 —
+ * "does NOT fire on auto-dismiss after success").
+ */
+ onAutoDismiss: () => void;
+ onToggleExpanded: () => void;
+}
+
+/**
+ * Tooltip text for a guard reason. Returns `null` when no tooltip
+ * should be surfaced — for `has_conflicts` the Apply slot already
+ * morphs into a primary "Review changes" button so no tooltip is
+ * needed; for `null` (no guard) the buttons are enabled.
+ */
+function tooltipText(
+ reason: UpdateBannerProps[ 'disabledReason' ]
+): string | null {
+ switch ( reason ) {
+ case 'dirty':
+ return __( 'Save your changes first.', 'woocommerce' );
+ case 'read_only':
+ return __(
+ "You don't have permission to update this email.",
+ 'woocommerce'
+ );
+ case 'has_conflicts':
+ case null:
+ default:
+ return null;
+ }
+}
+
+/**
+ * Compute the subtitle for the default banner branch.
+ *
+ * Falls back to a generic message when the summary is missing, the
+ * change-summary detector flagged the diff as a fallback, or the
+ * detector returned an empty `summary_lines`.
+ */
+function defaultSubtitle( summary: ChangeSummary | null ): string {
+ if ( summary === null || summary.is_fallback ) {
+ return __( 'WooCommerce updated this template.', 'woocommerce' );
+ }
+ if ( summary.summary_lines.length === 0 ) {
+ return sprintf(
+ // translators: %s is a WooCommerce version number, e.g. "1.2.3".
+ __( 'WooCommerce %s refreshed this template.', 'woocommerce' ),
+ summary.version_to
+ );
+ }
+ return summary.summary_lines[ 0 ];
+}
+
+/**
+ * Wrap a disabled button in a Tooltip when there's a guard reason that
+ * carries help text. The wrapping `<span>` is required: a disabled
+ * native button doesn't fire pointer events on its own, so Tooltip
+ * needs an enabled element to attach hover/focus listeners to.
+ *
+ * The tip is also rendered inline as visually-hidden screen-reader
+ * text, so assistive tech announces the guard reason even when the
+ * tooltip's hover/focus heuristics don't fire (and so tests can query
+ * the help text without simulating hover delays).
+ */
+function MaybeTooltip( {
+ tip,
+ children,
+}: {
+ tip: string | null;
+ children: JSX.Element;
+} ): JSX.Element {
+ if ( ! tip ) {
+ return children;
+ }
+ return (
+ <Tooltip text={ tip }>
+ <span className="wc-update-banner__tooltip-wrap">
+ { children }
+ <span className="screen-reader-text">{ tip }</span>
+ </span>
+ </Tooltip>
+ );
+}
+
+/**
+ * Presentational floating banner that surfaces template-divergence
+ * info to the merchant inside the email editor (RSM-141).
+ *
+ * Pure: receives every piece of state via props from `useUpdateBanner`
+ * and never reads from the data layer itself. Three render branches
+ * keyed off `applyState`:
+ *
+ * - `applied` → success morph, auto-dismisses after 2s
+ * - `failed` → failure morph, manual recovery only
+ * - default → idle / applying — the actionable banner
+ */
+export function UpdateBanner( {
+ summary,
+ applyState,
+ canApply,
+ canReview,
+ disabledReason,
+ expanded,
+ onApply,
+ onReview,
+ onDismiss,
+ onAutoDismiss,
+ onToggleExpanded,
+}: UpdateBannerProps ): JSX.Element {
+ // ---- Success morph ---------------------------------------------------
+ // Schedule the auto-dismiss timer once the morph mounts; clean it up
+ // on unmount or if `onAutoDismiss` changes mid-lifecycle.
+ useEffect( () => {
+ if ( applyState !== 'applied' ) {
+ return;
+ }
+ const handle = setTimeout( () => {
+ onAutoDismiss();
+ }, SUCCESS_AUTODISMISS_MS );
+ return () => {
+ clearTimeout( handle );
+ };
+ }, [ applyState, onAutoDismiss ] );
+
+ if ( applyState === 'applied' ) {
+ return (
+ <div
+ className="wc-update-banner wc-update-banner--success"
+ role="status"
+ aria-live="polite"
+ >
+ <div className="wc-update-banner__body">
+ <div className="wc-update-banner__title">
+ { __( 'Template updated', 'woocommerce' ) }
+ </div>
+ <div className="wc-update-banner__subtitle">
+ { __(
+ 'Your customizations were preserved.',
+ 'woocommerce'
+ ) }
+ </div>
+ </div>
+ <button
+ type="button"
+ className="wc-update-banner__dismiss"
+ aria-label={ __(
+ 'Dismiss for this session',
+ 'woocommerce'
+ ) }
+ onClick={ onAutoDismiss }
+ >
+ { '×' }
+ </button>
+ </div>
+ );
+ }
+
+ // ---- Failure morph ---------------------------------------------------
+ if ( applyState === 'failed' ) {
+ return (
+ <div
+ className="wc-update-banner wc-update-banner--failure"
+ role="alert"
+ >
+ <div className="wc-update-banner__body">
+ <div className="wc-update-banner__title">
+ { __( "Couldn't apply", 'woocommerce' ) }
+ </div>
+ </div>
+ <div className="wc-update-banner__actions">
+ <Button variant="primary" onClick={ onApply }>
+ { __( 'Try again', 'woocommerce' ) }
+ </Button>
+ <Button variant="tertiary" onClick={ onReview }>
+ { __( 'Review changes', 'woocommerce' ) }
+ </Button>
+ </div>
+ <button
+ type="button"
+ className="wc-update-banner__dismiss"
+ aria-label={ __(
+ 'Dismiss for this session',
+ 'woocommerce'
+ ) }
+ onClick={ onDismiss }
+ >
+ { '×' }
+ </button>
+ </div>
+ );
+ }
+
+ // ---- Default / applying branch --------------------------------------
+ const isApplying = applyState === 'applying';
+ const isConflict = disabledReason === 'has_conflicts';
+ // Pull out the lines once so the JSX below doesn't need non-null
+ // assertions on `summary` — the local is narrowed to `string[]`.
+ const expandableLines: string[] =
+ summary !== null && ! summary.is_fallback ? summary.summary_lines : [];
+ const hasExpandableChanges = expandableLines.length > 0;
+ const subtitle = defaultSubtitle( summary );
+ const tip = tooltipText( disabledReason );
+
+ const applyLabel = isApplying
+ ? __( 'Applying…', 'woocommerce' )
+ : __( 'Apply', 'woocommerce' );
+
+ // In the conflict variant the Apply slot becomes the primary
+ // "Review changes" CTA — a disabled Apply button would be the wrong
+ // affordance, since the user can't apply until they resolve the
+ // conflicts in the review drawer.
+ const applySlot = isConflict ? (
+ <Button variant="primary" onClick={ onReview }>
+ { __( 'Review changes', 'woocommerce' ) }
+ </Button>
+ ) : (
+ <MaybeTooltip tip={ tip }>
+ <Button
+ variant="primary"
+ onClick={ onApply }
+ disabled={ ! canApply || isApplying }
+ aria-disabled={ ! canApply ? 'true' : undefined }
+ isBusy={ isApplying }
+ >
+ { applyLabel }
+ </Button>
+ </MaybeTooltip>
+ );
+
+ const reviewSlot = isConflict ? null : (
+ <MaybeTooltip tip={ tip }>
+ <Button
+ variant="tertiary"
+ onClick={ onReview }
+ disabled={ ! canReview || isApplying }
+ aria-disabled={ ! canReview ? 'true' : undefined }
+ >
+ { __( 'Review', 'woocommerce' ) }
+ </Button>
+ </MaybeTooltip>
+ );
+
+ return (
+ <div className="wc-update-banner" role="status" aria-live="polite">
+ <div className="wc-update-banner__body">
+ <div className="wc-update-banner__title">
+ { __( 'Template update available', 'woocommerce' ) }
+ </div>
+ <div className="wc-update-banner__subtitle">{ subtitle }</div>
+ { hasExpandableChanges && (
+ <>
+ <button
+ type="button"
+ className="wc-update-banner__expand"
+ aria-expanded={ expanded }
+ aria-controls={ CHANGES_LIST_ID }
+ onClick={ onToggleExpanded }
+ >
+ { expanded
+ ? __( 'Hide changes', 'woocommerce' )
+ : sprintf(
+ /* translators: %d is the number of summary changes. */
+ _n(
+ 'Show %d change',
+ 'Show %d changes',
+ expandableLines.length,
+ 'woocommerce'
+ ),
+ expandableLines.length
+ ) }
+ </button>
+ { expanded && (
+ <ul
+ id={ CHANGES_LIST_ID }
+ className="wc-update-banner__changes"
+ >
+ { expandableLines.map( ( line, i ) => (
+ <li key={ i }>{ line }</li>
+ ) ) }
+ </ul>
+ ) }
+ </>
+ ) }
+ </div>
+ <div className="wc-update-banner__actions">
+ { applySlot }
+ { reviewSlot }
+ </div>
+ <button
+ type="button"
+ className="wc-update-banner__dismiss"
+ aria-label={ __( 'Dismiss for this session', 'woocommerce' ) }
+ onClick={ onDismiss }
+ disabled={ isApplying }
+ >
+ { '×' }
+ </button>
+ </div>
+ );
+}
diff --git a/plugins/woocommerce/includes/admin/settings/class-wc-settings-emails.php b/plugins/woocommerce/includes/admin/settings/class-wc-settings-emails.php
index 493b9d2c680..0c7011293f3 100644
--- a/plugins/woocommerce/includes/admin/settings/class-wc-settings-emails.php
+++ b/plugins/woocommerce/includes/admin/settings/class-wc-settings-emails.php
@@ -12,6 +12,7 @@ use Automattic\WooCommerce\Internal\Email\EmailFont;
use Automattic\WooCommerce\Internal\Email\EmailStyleSync;
use Automattic\WooCommerce\Internal\EmailEditor\EmailTemplates\WooEmailTemplate;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncRegistry;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Utilities\FeaturesUtil;
@@ -600,16 +601,24 @@ class WC_Settings_Emails extends WC_Settings_Page {
$email_types = array();
$post_id_for_template = null;
foreach ( $emails as $email_key => $email ) {
- $post_id = $email_post_manager->get_email_template_post_id( $email->id );
- $email_types[] = array(
- 'title' => $email->get_title(),
- 'description' => $email->get_description(),
- 'id' => $email->id,
- 'email_key' => strtolower( $email_key ),
- 'post_id' => $post_id,
- 'enabled' => $email->is_enabled(),
- 'manual' => $email->is_manual(),
- 'recipients' => array(
+ $post_id = $email_post_manager->get_email_template_post_id( $email->id );
+ $sync_config = WCEmailTemplateSyncRegistry::get_email_sync_config( $email->id );
+ // `current_version` is the canonical version core ships right now;
+ // the list view's "Review update" cell and RSM-141's editor banner
+ // gate on `merchant_reviewed_version < current_version` so a row
+ // stays customized but stops showing the indicator once the
+ // merchant has reviewed this release.
+ $current_version = is_array( $sync_config ) ? (string) ( $sync_config['version'] ?? '' ) : '';
+ $email_types[] = array(
+ 'title' => $email->get_title(),
+ 'description' => $email->get_description(),
+ 'id' => $email->id,
+ 'email_key' => strtolower( $email_key ),
+ 'post_id' => $post_id,
+ 'enabled' => $email->is_enabled(),
+ 'manual' => $email->is_manual(),
+ 'current_version' => '' !== $current_version ? $current_version : null,
+ 'recipients' => array(
'to' => $email->is_customer_email() ? __( 'Customers', 'woocommerce' ) : $email->get_recipient(),
'cc' => $email->get_cc_recipient(),
'bcc' => $email->get_bcc_recipient(),
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateChangeSummary.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateChangeSummary.php
index c8d91defad3..a2fe23f95ea 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateChangeSummary.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateChangeSummary.php
@@ -99,6 +99,7 @@ class WCEmailTemplateChangeSummary {
*
* - `version_from` — `string` — `_wc_email_template_version` meta on the post (may be empty).
* - `version_to` — `string` — registry-side current version.
+ * - `source_hash_to` — `string` — sha1 of the canonical core content for this email type. Mirrors the post's `_wc_email_template_source_hash` meta. Empty string in fallback / no-config paths where the core content can't be computed.
* - `added_blocks` — `array<int, array{name:string, label:string, path:array<int|string>}>` — blocks that would be added to the post by applying (in core, not in post). `name` is the post-alias-normalized block name (e.g. `core/heading`); `label` is its humanized form for display; `path` is the core-side index path.
* - `removed_blocks` — `array<int, array{name:string, label:string, path:array<int|string>}>` — blocks that would be removed from the post by applying (in post, not in core). Same field semantics as `added_blocks`; `path` is the post-side index path.
* - `copy_changes` — `array<int, array{block:string, before:string, after:string, occurrence:int, total:int, path:array<int|string>}>`.
@@ -180,9 +181,10 @@ class WCEmailTemplateChangeSummary {
// by emptiness alone — they construct any "you're up to date" copy
// themselves.
if ( $post_hash === $core_hash ) {
- $payload = self::empty_payload();
- $payload['version_from'] = $version_from;
- $payload['version_to'] = $version_to;
+ $payload = self::empty_payload();
+ $payload['version_from'] = $version_from;
+ $payload['version_to'] = $version_to;
+ $payload['source_hash_to'] = $core_hash;
self::write_cache( $cache_key, $payload );
return $payload;
}
@@ -220,6 +222,7 @@ class WCEmailTemplateChangeSummary {
$payload = array(
'version_from' => $version_from,
'version_to' => $version_to,
+ 'source_hash_to' => $core_hash,
'added_blocks' => $structured['added_blocks'],
'removed_blocks' => $structured['removed_blocks'],
'copy_changes' => $structured['copy_changes'],
@@ -274,6 +277,7 @@ class WCEmailTemplateChangeSummary {
return array(
'version_from' => '',
'version_to' => '',
+ 'source_hash_to' => '',
'added_blocks' => array(),
'removed_blocks' => array(),
'copy_changes' => array(),
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php
index 85006ec1d5e..5504eaedf0c 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php
@@ -105,6 +105,20 @@ class WCEmailTemplateDivergenceDetector {
*/
public const LAST_SYNCED_AT_META_KEY = '_wc_email_last_synced_at';
+ /**
+ * Informational flag intended to be set to `true` by the RSM-149 backfill
+ * on every pre-existing `woo_email` post it stamps. Registered + surfaced
+ * read-only over REST here so RSM-145 Tracks instrumentation can distinguish
+ * backfilled posts from natively generated ones, but the writer is staged
+ * in a separate follow-up PR after the 10.8 feature freeze for the backfill
+ * class. Until then, the field defaults to `false` everywhere — Tracks will
+ * report `was_backfilled: false` for all posts. Safe default; no behavior
+ * depends on it being `true`.
+ *
+ * @var string
+ */
+ public const BACKFILLED_META_KEY = '_wc_email_backfilled';
+
/**
* Classification outcomes.
*/
@@ -120,18 +134,24 @@ class WCEmailTemplateDivergenceDetector {
private static $logger = null;
/**
- * Register `_wc_email_template_status` and `_wc_email_template_version` post meta on
- * the `woo_email` post type as REST-readable, server-write-only meta.
+ * Register the four sync-related post meta keys on the `woo_email` post type as
+ * REST-readable, server-write-only meta:
+ *
+ * - {@see self::STATUS_META_KEY} (`_wc_email_template_status`)
+ * - {@see self::VERSION_META_KEY} (`_wc_email_template_version`)
+ * - {@see self::SOURCE_HASH_META_KEY} (`_wc_email_template_source_hash`)
+ * - {@see self::BACKFILLED_META_KEY} (`_wc_email_backfilled`)
*
* Because the `woo_email` post type declares `'custom-fields'` support (see
* {@see Integration::add_email_post_type()}), WP core auto-surfaces every
* `show_in_rest = true` meta key under the standard `meta` property of the
* `wp/v2/woo_email` response — no custom REST field registration is needed.
*
- * This is a stable read contract for the email list UI and any downstream consumer
- * (extensions, headless admins). Renaming or removing either meta key, or changing
- * the meaning of an existing status string value, is a breaking change. Vocabulary
- * expansion (adding new status values) is fine.
+ * This is a stable read contract for the email list UI, the RSM-141 editor
+ * "update available" banner, and the RSM-145 Tracks instrumentation. Renaming
+ * or removing any of these meta keys, or changing the meaning of an existing
+ * status string value, is a breaking change. Vocabulary expansion (adding new
+ * status values) is fine.
*
* Hook: `init`.
*
@@ -165,6 +185,32 @@ class WCEmailTemplateDivergenceDetector {
'sanitize_callback' => 'sanitize_text_field',
)
);
+
+ register_post_meta(
+ 'woo_email',
+ self::SOURCE_HASH_META_KEY,
+ array(
+ 'type' => 'string',
+ 'description' => 'SHA-1 stamp of the canonical core post content recorded the last time this email post was generated, applied, or reset. Consumed by RSM-145 Tracks instrumentation to fingerprint the baseline the merchant was reviewing. Read-only over REST.',
+ 'single' => true,
+ 'show_in_rest' => true,
+ 'auth_callback' => array( self::class, 'rest_meta_auth_read_only' ),
+ 'sanitize_callback' => 'sanitize_text_field',
+ )
+ );
+
+ register_post_meta(
+ 'woo_email',
+ self::BACKFILLED_META_KEY,
+ array(
+ 'type' => 'boolean',
+ 'description' => 'True when the post was stamped by the RSM-149 backfill rather than created natively by the modern generator. Consumed by RSM-145 Tracks instrumentation to segment update-banner interactions on backfilled posts. Read-only over REST.',
+ 'single' => true,
+ 'show_in_rest' => true,
+ 'auth_callback' => array( self::class, 'rest_meta_auth_read_only' ),
+ 'sanitize_callback' => 'rest_sanitize_boolean',
+ )
+ );
}
/**
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 72908eb928d..5a0031572cb 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateChangeSummaryTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateChangeSummaryTest.php
@@ -480,6 +480,40 @@ class WCEmailTemplateChangeSummaryTest extends \WC_Unit_Test_Case {
$this->assertSame( array(), $second['summary_lines'] );
}
+ /**
+ * `source_hash_to` is the sha1 of the canonical core content for the
+ * email type. It mirrors the post's `_wc_email_template_source_hash`
+ * meta and is consumed by the RSM-145 Tracks instrumentation to identify
+ * which canonical revision a merchant is comparing against.
+ *
+ * @testdox Should expose source_hash_to as a non-empty sha1 hex string on the success-path payload.
+ */
+ public function test_summarize_includes_source_hash_to_for_customized_post(): void {
+ $email_id = 'change_summary_source_hash';
+ $this->register_fixture_email( $email_id );
+
+ $core_content = "<!-- wp:heading -->\n<h2>Welcome</h2>\n<!-- /wp:heading -->\n\n"
+ . "<!-- wp:paragraph -->\n<p>Original line.</p>\n<!-- /wp:paragraph -->";
+
+ // Merchant edited the paragraph copy.
+ $post_content = "<!-- wp:heading -->\n<h2>Welcome</h2>\n<!-- /wp:heading -->\n\n"
+ . "<!-- wp:paragraph -->\n<p>Edited line.</p>\n<!-- /wp:paragraph -->";
+
+ $this->use_canonical_content( $email_id, $core_content );
+ $post_id = $this->create_woo_email_post( $email_id, $post_content );
+
+ $summary = WCEmailTemplateChangeSummary::summarize( $post_id );
+
+ $this->assertArrayHasKey( 'source_hash_to', $summary );
+ $this->assertIsString( $summary['source_hash_to'] );
+ $this->assertNotEmpty( $summary['source_hash_to'] );
+ $this->assertSame( 40, strlen( $summary['source_hash_to'] ) );
+ $this->assertTrue(
+ ctype_xdigit( $summary['source_hash_to'] ),
+ 'source_hash_to must be a hex-only sha1 (40 hex chars).'
+ );
+ }
+
/**
* Cache: first call computes and stores; second call with same inputs hits
* the cache; mutating the post invalidates by content hash.
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetectorTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetectorTest.php
index 29f486575e6..c81c1f29c38 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetectorTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetectorTest.php
@@ -93,6 +93,44 @@ class WCEmailTemplateDivergenceDetectorTest extends \WC_Unit_Test_Case {
$this->assertSame( 'string', $args['type'] );
}
+ /**
+ * @testdox Should register _wc_email_template_source_hash post meta on woo_email with show_in_rest.
+ */
+ public function test_registers_template_source_hash_meta_with_show_in_rest(): void {
+ $this->initialize_email_editor_integration();
+
+ $this->assertTrue(
+ registered_meta_key_exists( 'post', WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, 'woo_email' ),
+ 'Expected _wc_email_template_source_hash to be registered for woo_email.'
+ );
+
+ $args = get_registered_meta_keys( 'post', 'woo_email' )[ WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY ];
+
+ $this->assertTrue( $args['show_in_rest'], 'Expected show_in_rest = true.' );
+ $this->assertTrue( $args['single'], 'Expected single = true.' );
+ $this->assertSame( 'string', $args['type'] );
+ $this->assertIsCallable( $args['auth_callback'] );
+ }
+
+ /**
+ * @testdox Should register _wc_email_backfilled post meta on woo_email with show_in_rest.
+ */
+ public function test_registers_email_backfilled_meta_with_show_in_rest(): void {
+ $this->initialize_email_editor_integration();
+
+ $this->assertTrue(
+ registered_meta_key_exists( 'post', WCEmailTemplateDivergenceDetector::BACKFILLED_META_KEY, 'woo_email' ),
+ 'Expected _wc_email_backfilled to be registered for woo_email.'
+ );
+
+ $args = get_registered_meta_keys( 'post', 'woo_email' )[ WCEmailTemplateDivergenceDetector::BACKFILLED_META_KEY ];
+
+ $this->assertTrue( $args['show_in_rest'], 'Expected show_in_rest = true.' );
+ $this->assertTrue( $args['single'], 'Expected single = true.' );
+ $this->assertSame( 'boolean', $args['type'] );
+ $this->assertIsCallable( $args['auth_callback'] );
+ }
+
/**
* @testdox Should deny REST writes to template meta even for administrators.
*/
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateMetaRestExposureTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateMetaRestExposureTest.php
index e273e05ec8f..0e98ab34ad5 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateMetaRestExposureTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateMetaRestExposureTest.php
@@ -100,6 +100,16 @@ class WCEmailTemplateMetaRestExposureTest extends \WC_REST_Unit_Test_Case {
WCEmailTemplateDivergenceDetector::VERSION_META_KEY,
'9.4.0'
);
+ update_post_meta(
+ $post_id,
+ WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY,
+ 'abc123def456'
+ );
+ update_post_meta(
+ $post_id,
+ WCEmailTemplateDivergenceDetector::BACKFILLED_META_KEY,
+ true
+ );
$request = new WP_REST_Request( 'GET', "/wp/v2/woo_email/{$post_id}" );
$response = rest_do_request( $request );
@@ -131,6 +141,27 @@ class WCEmailTemplateMetaRestExposureTest extends \WC_REST_Unit_Test_Case {
$data['meta'][ WCEmailTemplateDivergenceDetector::VERSION_META_KEY ],
'Version meta value must reflect the stamped post meta.'
);
+
+ $this->assertArrayHasKey(
+ WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY,
+ $data['meta'],
+ 'Source hash meta must be auto-surfaced under the meta property of the wp/v2/woo_email response.'
+ );
+ $this->assertSame(
+ 'abc123def456',
+ $data['meta'][ WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY ] ?? null,
+ 'Source hash meta value must reflect the stamped post meta.'
+ );
+
+ $this->assertArrayHasKey(
+ WCEmailTemplateDivergenceDetector::BACKFILLED_META_KEY,
+ $data['meta'],
+ 'Backfilled meta must be auto-surfaced under the meta property of the wp/v2/woo_email response.'
+ );
+ $this->assertTrue(
+ $data['meta'][ WCEmailTemplateDivergenceDetector::BACKFILLED_META_KEY ] ?? null,
+ 'Backfilled meta value must reflect the stamped post meta.'
+ );
}
/**
@@ -172,5 +203,20 @@ class WCEmailTemplateMetaRestExposureTest extends \WC_REST_Unit_Test_Case {
$data['meta'][ WCEmailTemplateDivergenceDetector::VERSION_META_KEY ],
'Unstamped posts must surface an empty version; the JS data hook normalises this to null.'
);
+
+ // WP core surfaces registered string meta with an empty-string default when no value is stored.
+ $this->assertArrayHasKey( WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, $data['meta'] );
+ $this->assertSame(
+ '',
+ $data['meta'][ WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY ],
+ 'Unstamped posts must surface an empty source hash.'
+ );
+
+ // WP core surfaces registered boolean meta as `false` when no value is stored.
+ $this->assertArrayHasKey( WCEmailTemplateDivergenceDetector::BACKFILLED_META_KEY, $data['meta'] );
+ $this->assertFalse(
+ $data['meta'][ WCEmailTemplateDivergenceDetector::BACKFILLED_META_KEY ],
+ 'Unstamped posts must surface false for the backfilled flag.'
+ );
}
}