Commit cda4ff681e2 for woocommerce

commit cda4ff681e2e3e5b7187e226f3e16290aec7f63c
Author: Pavel Dohnal <pavel.dohnal@automattic.com>
Date:   Thu Sep 24 14:05:15 2026 +0200

    Report the design wording when saving a template from the editor (#69035)

    * Read the post type being edited for the save notice

    The save notice override took its post type from the email editor
    store, which is written once when the editor loads and records how the
    page was opened rather than what is on screen. Opening an email and
    then moving into its template left it reporting the email post type, so
    a template save missed the design wording.

    It was worse than a missed rename. The labels were fetched for the
    email post type while the notice had been built from the template's, so
    nothing matched and the text was left alone entirely, showing
    WordPress's generic template wording inside the email editor.

    The post type now comes from the editor store's current post, the same
    source use-is-email-editor already treats as canonical, and feeds both
    the template check and the label lookup so the two always describe the
    same post.

    * Let the notice decide its own wording

    A notice's text is written when the save happens, from the labels of
    the post type being saved, but the override resolved a post type when
    getNotices ran instead. Notices stay on screen for several seconds, so
    saving an email and then moving into its template swapped the text back
    to WordPress's own wording while the snackbar was still visible, and
    the same in reverse.

    The content is now matched against the labels of both the post type
    being edited and the one the editor was opened on, and whichever
    matched decides the wording. The text a notice already carries settles
    what it says, so nothing the reader does afterwards can change it.
    Labels are read once when the two are the same post type.

    * Decide the candidate post types in one place

    Whether the two candidates are the same post type was decided twice,
    once to skip the second label lookup and once to skip adding the second
    candidate. The two agreed, but nothing held them together: editing one
    would have left the other behind, and no test would have noticed, since
    adding a duplicate candidate changes no output.

    Both now read one boolean. Also records why the first matching
    candidate wins when two post types carry the same label text, so the
    ordering is not later read as an oversight and swapped, which would
    break a first-time template save.

diff --git a/packages/js/email-editor/changelog/wooairr-405-template-notice-current-post-type b/packages/js/email-editor/changelog/wooairr-405-template-notice-current-post-type
new file mode 100644
index 00000000000..c98c2db042f
--- /dev/null
+++ b/packages/js/email-editor/changelog/wooairr-405-template-notice-current-post-type
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Show "Email design updated." when saving an email template opened from inside the email editor
diff --git a/packages/js/email-editor/src/hooks/test/use-notice-overrides.spec.ts b/packages/js/email-editor/src/hooks/test/use-notice-overrides.spec.ts
index f4eac360601..8c25568bf6c 100644
--- a/packages/js/email-editor/src/hooks/test/use-notice-overrides.spec.ts
+++ b/packages/js/email-editor/src/hooks/test/use-notice-overrides.spec.ts
@@ -9,6 +9,8 @@ import { renderHook } from '@testing-library/react';
 import { useNoticeOverrides } from '../use-notice-overrides';
 import { storeName as EMAIL_EDITOR_STORE_NAME } from '../../store/constants';

+const CORE_EDITOR_STORE = 'core/editor';
+
 // Keep a reference to the plugin callback registered via `use()`.
 let capturedPlugin: ( registry: {
 	select: ( namespace: string ) => unknown;
@@ -82,17 +84,17 @@ describe( 'useNoticeOverrides — memoized selector stability', () => {
 	 * selector objects so the hook's cross-store label lookup can be
 	 * exercised.
 	 *
-	 * @param notices  Notices `core/notices`' `getNotices` should return.
-	 * @param labels   Labels `core`'s `getPostType` should return, keyed by
-	 *                 post type. `undefined` means the post type isn't
-	 *                 loaded yet.
-	 * @param postType Post type the email editor store's
-	 *                 `getEmailPostType` should return.
+	 * @param notices         Notices `core/notices`' `getNotices` should return.
+	 * @param labels          Labels `core`'s `getPostType` should return, keyed by
+	 *                        post type. `undefined` means the post type isn't
+	 *                        loaded yet.
+	 * @param currentPostType Post type `core/editor`'s `getCurrentPostType`
+	 *                        should return — the post actually being edited.
 	 */
 	function buildSelectOverride(
 		notices: Notice[],
 		labels?: Labels,
-		postType = 'email'
+		currentPostType = 'email'
 	) {
 		const originalGetNotices = jest.fn().mockReturnValue( notices );
 		const noticesSelectors = { getNotices: originalGetNotices };
@@ -102,8 +104,8 @@ describe( 'useNoticeOverrides — memoized selector stability', () => {
 			.mockReturnValue( labels === undefined ? undefined : { labels } );
 		const coreSelectors = { getPostType };

-		const getEmailPostType = jest.fn().mockReturnValue( postType );
-		const emailEditorSelectors = { getEmailPostType };
+		const getCurrentPostType = jest.fn().mockReturnValue( currentPostType );
+		const editorSelectors = { getCurrentPostType };

 		const originalSelect = jest
 			.fn()
@@ -115,8 +117,8 @@ describe( 'useNoticeOverrides — memoized selector stability', () => {
 				if ( name === 'core' ) {
 					return coreSelectors;
 				}
-				if ( name === EMAIL_EDITOR_STORE_NAME ) {
-					return emailEditorSelectors;
+				if ( name === CORE_EDITOR_STORE ) {
+					return editorSelectors;
 				}
 				return undefined;
 			} );
@@ -129,7 +131,7 @@ describe( 'useNoticeOverrides — memoized selector stability', () => {
 			originalSelect,
 			originalGetNotices,
 			getPostType,
-			getEmailPostType,
+			getCurrentPostType,
 		};
 	}

@@ -157,8 +159,8 @@ describe( 'useNoticeOverrides — memoized selector stability', () => {
 				labels: { item_updated: 'Post updated.' },
 			} ),
 		};
-		const emailEditorSelectors = {
-			getEmailPostType: jest.fn().mockReturnValue( 'email' ),
+		const editorSelectors = {
+			getCurrentPostType: jest.fn().mockReturnValue( 'email' ),
 		};
 		const originalSelect = jest
 			.fn()
@@ -170,8 +172,8 @@ describe( 'useNoticeOverrides — memoized selector stability', () => {
 				if ( name === 'core' ) {
 					return coreSelectors;
 				}
-				if ( name === EMAIL_EDITOR_STORE_NAME ) {
-					return emailEditorSelectors;
+				if ( name === CORE_EDITOR_STORE ) {
+					return editorSelectors;
 				}
 				return undefined;
 			} );
@@ -204,8 +206,8 @@ describe( 'useNoticeOverrides — memoized selector stability', () => {
 			.fn()
 			.mockReturnValue( { labels: { item_updated: 'Post updated.' } } );
 		const coreSelectors = { getPostType };
-		const emailEditorSelectors = {
-			getEmailPostType: jest.fn().mockReturnValue( 'email' ),
+		const editorSelectors = {
+			getCurrentPostType: jest.fn().mockReturnValue( 'email' ),
 		};
 		const originalSelect = jest
 			.fn()
@@ -217,8 +219,8 @@ describe( 'useNoticeOverrides — memoized selector stability', () => {
 				if ( name === 'core' ) {
 					return coreSelectors;
 				}
-				if ( name === EMAIL_EDITOR_STORE_NAME ) {
-					return emailEditorSelectors;
+				if ( name === CORE_EDITOR_STORE ) {
+					return editorSelectors;
 				}
 				return undefined;
 			} );
@@ -455,7 +457,7 @@ describe( 'useNoticeOverrides — memoized selector stability', () => {
 		expect( result[ 0 ].actions ).toEqual( [] );
 	} );

-	it( 'skips the postType/labels lookup when the email post type is not set yet, but still removes the action', () => {
+	it( 'skips the postType/labels lookup when the current post type is not set yet, but still removes the action', () => {
 		const originalNotice = makeNotice( {
 			id: 'editor-save',
 			content: 'Post updated.',
@@ -469,8 +471,8 @@ describe( 'useNoticeOverrides — memoized selector stability', () => {
 			.fn()
 			.mockReturnValue( { labels: { item_updated: 'Post updated.' } } );
 		const coreSelectors = { getPostType };
-		const getEmailPostType = jest.fn().mockReturnValue( undefined );
-		const emailEditorSelectors = { getEmailPostType };
+		const getCurrentPostType = jest.fn().mockReturnValue( undefined );
+		const editorSelectors = { getCurrentPostType };

 		const originalSelect = jest
 			.fn()
@@ -482,8 +484,8 @@ describe( 'useNoticeOverrides — memoized selector stability', () => {
 				if ( name === 'core' ) {
 					return coreSelectors;
 				}
-				if ( name === EMAIL_EDITOR_STORE_NAME ) {
-					return emailEditorSelectors;
+				if ( name === CORE_EDITOR_STORE ) {
+					return editorSelectors;
 				}
 				return undefined;
 			} );
@@ -500,7 +502,7 @@ describe( 'useNoticeOverrides — memoized selector stability', () => {
 		expect( getPostType ).not.toHaveBeenCalled();
 	} );

-	it( 'leaves notices unchanged without throwing when the email editor store is not registered', () => {
+	it( 'leaves notices unchanged without throwing when the core/editor store is not registered', () => {
 		const originalNotice = makeNotice( {
 			id: 'editor-save',
 			content: 'Post updated.',
@@ -526,7 +528,7 @@ describe( 'useNoticeOverrides — memoized selector stability', () => {
 				if ( name === 'core' ) {
 					return coreSelectors;
 				}
-				// Email editor store not registered.
+				// core/editor store not registered.
 				return undefined;
 			} );

@@ -552,8 +554,8 @@ describe( 'useNoticeOverrides — memoized selector stability', () => {
 			.fn()
 			.mockReturnValue( [ originalNotice ] );
 		const noticesSelectors = { getNotices: originalGetNotices };
-		const emailEditorSelectors = {
-			getEmailPostType: jest.fn().mockReturnValue( 'email' ),
+		const editorSelectors = {
+			getCurrentPostType: jest.fn().mockReturnValue( 'email' ),
 		};

 		const originalSelect = jest
@@ -563,8 +565,8 @@ describe( 'useNoticeOverrides — memoized selector stability', () => {
 				if ( name === 'core/notices' ) {
 					return noticesSelectors;
 				}
-				if ( name === EMAIL_EDITOR_STORE_NAME ) {
-					return emailEditorSelectors;
+				if ( name === CORE_EDITOR_STORE ) {
+					return editorSelectors;
 				}
 				// Core store not resolvable in this registry.
 				return undefined;
@@ -591,8 +593,8 @@ describe( 'useNoticeOverrides — memoized selector stability', () => {
 			.fn()
 			.mockReturnValue( [ originalNotice ] );
 		const noticesSelectors = { getNotices: originalGetNotices };
-		const getEmailPostType = jest.fn().mockReturnValue( 'email' );
-		const emailEditorSelectors = { getEmailPostType };
+		const getCurrentPostType = jest.fn().mockReturnValue( 'email' );
+		const editorSelectors = { getCurrentPostType };
 		const getPostType = jest
 			.fn()
 			.mockReturnValue( { labels: { item_updated: 'Post updated.' } } );
@@ -608,8 +610,8 @@ describe( 'useNoticeOverrides — memoized selector stability', () => {
 				if ( name === 'core' ) {
 					return coreSelectors;
 				}
-				if ( name === EMAIL_EDITOR_STORE_NAME ) {
-					return emailEditorSelectors;
+				if ( name === CORE_EDITOR_STORE ) {
+					return editorSelectors;
 				}
 				return undefined;
 			} );
@@ -622,7 +624,7 @@ describe( 'useNoticeOverrides — memoized selector stability', () => {
 		const result = selectors.getNotices();

 		expect( result[ 0 ] ).toBe( originalNotice );
-		expect( getEmailPostType ).not.toHaveBeenCalled();
+		expect( getCurrentPostType ).not.toHaveBeenCalled();
 		expect( getPostType ).not.toHaveBeenCalled();
 	} );

@@ -713,4 +715,300 @@ describe( 'useNoticeOverrides — memoized selector stability', () => {
 		expect( result[ 0 ].content ).toBe( 'Updating failed.' );
 		expect( result[ 0 ].actions ).toEqual( [] );
 	} );
+
+	it( "matches against the currently edited post type when it diverges from the email editor store's post type", () => {
+		// In-app navigation case: the editor was opened on an email (the
+		// email editor store still reports the email post type), but the
+		// user has since navigated into that email's template without a
+		// page reload, so `core/editor`'s `getCurrentPostType` reports
+		// `wp_template`. Gutenberg built the notice from the template's own
+		// labels, so only the template's labels can match it. Both
+		// candidates are resolved (the email post type is now a candidate
+		// too), but the current post type's labels are the ones that match.
+		const originalNotice = makeNotice( {
+			id: 'editor-save',
+			content: 'Template updated.',
+			actions: [ { label: 'View', url: '#' } ],
+		} );
+		const originalGetNotices = jest
+			.fn()
+			.mockReturnValue( [ originalNotice ] );
+		const noticesSelectors = { getNotices: originalGetNotices };
+		const getPostType = jest.fn().mockReturnValue( {
+			labels: { item_updated: 'Template updated.' },
+		} );
+		const coreSelectors = { getPostType };
+		const getCurrentPostType = jest.fn().mockReturnValue( 'wp_template' );
+		const editorSelectors = { getCurrentPostType };
+		const getEmailPostType = jest.fn().mockReturnValue( 'email' );
+		const emailEditorSelectors = { getEmailPostType };
+
+		const originalSelect = jest
+			.fn()
+			.mockImplementation( ( ns: string | { name: string } ) => {
+				const name = resolveStoreName( ns );
+				if ( name === 'core/notices' ) {
+					return noticesSelectors;
+				}
+				if ( name === 'core' ) {
+					return coreSelectors;
+				}
+				if ( name === CORE_EDITOR_STORE ) {
+					return editorSelectors;
+				}
+				if ( name === EMAIL_EDITOR_STORE_NAME ) {
+					return emailEditorSelectors;
+				}
+				return undefined;
+			} );
+
+		renderHook( () => useNoticeOverrides() );
+		const pluginResult = capturedPlugin( { select: originalSelect } );
+		const selectors = pluginResult.select( 'core/notices' ) as {
+			getNotices: () => Notice[];
+		};
+		const result = selectors.getNotices();
+
+		expect( result[ 0 ].content ).toBe( 'Email design updated.' );
+		expect( result[ 0 ].spokenMessage ).toBe( 'Email design updated.' );
+		expect( result[ 0 ].actions ).toEqual( [] );
+		expect( getEmailPostType ).toHaveBeenCalled();
+	} );
+
+	/**
+	 * Builds an `originalSelect` stub where `core/editor` and the email
+	 * editor store can report different post types, each with its own
+	 * labels — used to test that a notice is matched against whichever
+	 * candidate's labels its content equals, not against the timing of
+	 * which post type happens to be current.
+	 *
+	 * @param notices         Notices `core/notices`' `getNotices` should return.
+	 * @param currentPostType Post type `core/editor`'s `getCurrentPostType`
+	 *                        should return.
+	 * @param emailPostType   Post type the email editor store's
+	 *                        `getEmailPostType` should return.
+	 * @param labelsByType    Labels `core`'s `getPostType` should return,
+	 *                        keyed by post type.
+	 */
+	function buildTwoCandidateSelectOverride(
+		notices: Notice[],
+		currentPostType: string,
+		emailPostType: string,
+		labelsByType: Record< string, Labels >
+	) {
+		const noticesSelectors = {
+			getNotices: jest.fn().mockReturnValue( notices ),
+		};
+		const getPostType = jest
+			.fn()
+			.mockImplementation( ( postType: string ) =>
+				labelsByType[ postType ] === undefined
+					? undefined
+					: { labels: labelsByType[ postType ] }
+			);
+		const coreSelectors = { getPostType };
+		const editorSelectors = {
+			getCurrentPostType: jest.fn().mockReturnValue( currentPostType ),
+		};
+		const emailEditorSelectors = {
+			getEmailPostType: jest.fn().mockReturnValue( emailPostType ),
+		};
+
+		const originalSelect = jest
+			.fn()
+			.mockImplementation( ( ns: string | { name: string } ) => {
+				const name = resolveStoreName( ns );
+				if ( name === 'core/notices' ) {
+					return noticesSelectors;
+				}
+				if ( name === 'core' ) {
+					return coreSelectors;
+				}
+				if ( name === CORE_EDITOR_STORE ) {
+					return editorSelectors;
+				}
+				if ( name === EMAIL_EDITOR_STORE_NAME ) {
+					return emailEditorSelectors;
+				}
+				return undefined;
+			} );
+
+		renderHook( () => useNoticeOverrides() );
+		const pluginResult = capturedPlugin( { select: originalSelect } );
+		return { pluginResult, getPostType };
+	}
+
+	it( 'rewrites a stale notice to "Email saved." when its content matches the email post type\'s labels while a template is current', () => {
+		// The user saved the email, then navigated into its template before
+		// the notice dismissed: `getCurrentPostType` now reports the
+		// template, but the notice's own content was written for the email.
+		const originalNotice = makeNotice( {
+			id: 'editor-save',
+			content: 'Post updated.',
+			actions: [ { label: 'View', url: '#' } ],
+		} );
+		const { pluginResult } = buildTwoCandidateSelectOverride(
+			[ originalNotice ],
+			'wp_template',
+			'email',
+			{
+				wp_template: { item_updated: 'Template updated.' },
+				email: { item_updated: 'Post updated.' },
+			}
+		);
+
+		const selectors = pluginResult.select( 'core/notices' ) as {
+			getNotices: () => Notice[];
+		};
+		const result = selectors.getNotices();
+
+		expect( result[ 0 ].content ).toBe( 'Email saved.' );
+		expect( result[ 0 ].actions ).toEqual( [] );
+	} );
+
+	it( 'rewrites a stale notice to "Email design updated." when its content matches the template\'s labels while the email post type is current', () => {
+		// The reverse: the user saved the template, then navigated back to
+		// the email before the notice dismissed.
+		const originalNotice = makeNotice( {
+			id: 'editor-save',
+			content: 'Template updated.',
+			actions: [ { label: 'View', url: '#' } ],
+		} );
+		const { pluginResult } = buildTwoCandidateSelectOverride(
+			[ originalNotice ],
+			'email',
+			'wp_template',
+			{
+				email: { item_updated: 'Post updated.' },
+				wp_template: { item_updated: 'Template updated.' },
+			}
+		);
+
+		const selectors = pluginResult.select( 'core/notices' ) as {
+			getNotices: () => Notice[];
+		};
+		const result = selectors.getNotices();
+
+		expect( result[ 0 ].content ).toBe( 'Email design updated.' );
+		expect( result[ 0 ].actions ).toEqual( [] );
+	} );
+
+	it( "leaves a notice unchanged when its content matches neither candidate post type's labels", () => {
+		const originalNotice = makeNotice( {
+			id: 'editor-save',
+			content: 'Something else.',
+			actions: [ { label: 'View', url: '#' } ],
+		} );
+		const { pluginResult } = buildTwoCandidateSelectOverride(
+			[ originalNotice ],
+			'email',
+			'wp_template',
+			{
+				email: { item_updated: 'Post updated.' },
+				wp_template: { item_updated: 'Template updated.' },
+			}
+		);
+
+		const selectors = pluginResult.select( 'core/notices' ) as {
+			getNotices: () => Notice[];
+		};
+		const result = selectors.getNotices();
+
+		expect( result[ 0 ].content ).toBe( 'Something else.' );
+		expect( result[ 0 ].actions ).toEqual( [] );
+	} );
+
+	it( 'rewrites correctly and looks up labels only once when both candidate post types are the same', () => {
+		const originalNotice = makeNotice( {
+			id: 'editor-save',
+			content: 'Post updated.',
+			actions: [ { label: 'View', url: '#' } ],
+		} );
+		const { pluginResult, getPostType } = buildTwoCandidateSelectOverride(
+			[ originalNotice ],
+			'email',
+			'email',
+			{ email: { item_updated: 'Post updated.' } }
+		);
+
+		const selectors = pluginResult.select( 'core/notices' ) as {
+			getNotices: () => Notice[];
+		};
+		const result = selectors.getNotices();
+
+		expect( result[ 0 ].content ).toBe( 'Email saved.' );
+		expect( result[ 0 ].actions ).toEqual( [] );
+		// Pins the single "are these the same post type" decision: labels
+		// are looked up once, for the shared post type, not once per
+		// candidate.
+		expect( getPostType ).toHaveBeenCalledTimes( 1 );
+		expect( getPostType ).toHaveBeenCalledWith( 'email' );
+	} );
+
+	it( 'looks up labels for the current post type, not any other cached post type', () => {
+		const originalNotice = makeNotice( {
+			id: 'editor-save',
+			content: 'Template updated.',
+			actions: [ { label: 'View', url: '#' } ],
+		} );
+		const { pluginResult, getPostType } = buildSelectOverride(
+			[ originalNotice ],
+			{ item_updated: 'Template updated.' },
+			'wp_template'
+		);
+
+		const selectors = pluginResult.select( 'core/notices' ) as {
+			getNotices: () => Notice[];
+		};
+		selectors.getNotices();
+
+		expect( getPostType ).toHaveBeenCalledWith( 'wp_template' );
+	} );
+
+	it( 'does not throw and still removes actions when getCurrentPostType is unavailable', () => {
+		const originalNotice = makeNotice( {
+			id: 'editor-save',
+			content: 'Post updated.',
+			actions: [ { label: 'View', url: '#' } ],
+		} );
+		const originalGetNotices = jest
+			.fn()
+			.mockReturnValue( [ originalNotice ] );
+		const noticesSelectors = { getNotices: originalGetNotices };
+		const getPostType = jest
+			.fn()
+			.mockReturnValue( { labels: { item_updated: 'Post updated.' } } );
+		const coreSelectors = { getPostType };
+		// `core/editor` is registered, but returns no selector for
+		// `getCurrentPostType` (e.g. an older Gutenberg version).
+		const editorSelectors = {};
+
+		const originalSelect = jest
+			.fn()
+			.mockImplementation( ( ns: string | { name: string } ) => {
+				const name = resolveStoreName( ns );
+				if ( name === 'core/notices' ) {
+					return noticesSelectors;
+				}
+				if ( name === 'core' ) {
+					return coreSelectors;
+				}
+				if ( name === CORE_EDITOR_STORE ) {
+					return editorSelectors;
+				}
+				return undefined;
+			} );
+
+		renderHook( () => useNoticeOverrides() );
+		const pluginResult = capturedPlugin( { select: originalSelect } );
+		const selectors = pluginResult.select( 'core/notices' ) as {
+			getNotices: () => Notice[];
+		};
+
+		expect( () => selectors.getNotices() ).not.toThrow();
+		const result = selectors.getNotices();
+		expect( result[ 0 ].content ).toBe( 'Post updated.' );
+		expect( result[ 0 ].actions ).toEqual( [] );
+		expect( getPostType ).not.toHaveBeenCalled();
+	} );
 } );
diff --git a/packages/js/email-editor/src/hooks/use-notice-overrides.ts b/packages/js/email-editor/src/hooks/use-notice-overrides.ts
index 37ff9464ada..9eb44d676cc 100644
--- a/packages/js/email-editor/src/hooks/use-notice-overrides.ts
+++ b/packages/js/email-editor/src/hooks/use-notice-overrides.ts
@@ -10,7 +10,16 @@ import { store as coreStore } from '@wordpress/core-data';
 /**
  * Internal dependencies
  */
-import { storeName } from '../store/constants';
+import { storeName as emailEditorStoreName } from '../store/constants';
+
+/**
+ * Store name of the WordPress editor store.
+ * Importing `@wordpress/editor` pulls in `@wordpress/block-editor`'s
+ * `transform-styles` util, which depends on the ESM-only `parsel-js`
+ * package — Jest can't transform it, so unit tests fail to load. The
+ * hardcoded string avoids that dependency chain.
+ */
+const CORE_EDITOR_STORE = 'core/editor';

 /**
  * Wraps the `getNotices` selector on the notices store so that specific
@@ -93,10 +102,37 @@ function isTemplatePostType( postType: string | undefined ): boolean {
 	return !! postType && TEMPLATE_POST_TYPES.includes( postType );
 }

+// A notice's wording is decided by which post type's labels its content
+// matches, not by whichever post type happens to be current when
+// `getNotices()` runs — see the comments inside `getNoticeOverrides` for why.
+interface PostTypeCandidate {
+	postType: string | undefined;
+	labels: PostTypeLabels;
+}
+
+// If both candidates are different post types but happen to share the same
+// text for the matched label (e.g. neither `wp_template` nor the email post
+// type declares `item_published`, so both fall back to WordPress's default
+// "Post published."), there's no way to tell which one produced the notice.
+// The first candidate — the current post type — wins. This is accepted:
+// with identical label text there's nothing left to disambiguate with, and
+// current-first is what makes a first-time template save read correctly.
+function findMatchingCandidate(
+	candidates: PostTypeCandidate[],
+	labelKeys: string[],
+	content: string
+): PostTypeCandidate | undefined {
+	return candidates.find( ( candidate ) =>
+		labelKeys.some(
+			( key ) =>
+				candidate.labels?.[ key ] && candidate.labels[ key ] === content
+		)
+	);
+}
+
 function transformNotice(
 	notice: Notice,
-	labels: PostTypeLabels,
-	postType: string | undefined
+	candidates: PostTypeCandidate[]
 ): Notice {
 	const overrides = getNoticeOverrides();
 	// A plain lookup would resolve ids like `constructor` or `toString` to
@@ -106,14 +142,19 @@ function transformNotice(
 	}
 	const override = overrides[ notice.id ];

-	const rewriteText =
-		! override.labelKeys ||
-		override.labelKeys.some(
-			( key ) => labels?.[ key ] && labels[ key ] === notice.content
-		);
+	const matchedCandidate = override.labelKeys
+		? findMatchingCandidate(
+				candidates,
+				override.labelKeys,
+				notice.content
+		  )
+		: undefined;
+
+	const rewriteText = ! override.labelKeys || !! matchedCandidate;

 	const content =
-		notice.id === 'editor-save' && isTemplatePostType( postType )
+		notice.id === 'editor-save' &&
+		isTemplatePostType( matchedCandidate?.postType )
 			? EMAIL_DESIGN_UPDATED_MESSAGE
 			: override.content;

@@ -126,12 +167,9 @@ function transformNotice(

 function applyOverridesToNotices(
 	notices: Notice[],
-	labels: PostTypeLabels,
-	postType: string | undefined
+	candidates: PostTypeCandidate[]
 ): Notice[] {
-	return notices.map( ( notice ) =>
-		transformNotice( notice, labels, postType )
-	);
+	return notices.map( ( notice ) => transformNotice( notice, candidates ) );
 }

 function getStoreName( namespace: string | { name: string } ): string {
@@ -141,14 +179,35 @@ function getStoreName( namespace: string | { name: string } ): string {
 const getNoticesWithOverrides = createSelector(
 	(
 		notices: Notice[],
-		labels: PostTypeLabels,
-		postType: string | undefined
-	) => applyOverridesToNotices( notices, labels, postType ),
+		currentPostType: string | undefined,
+		currentLabels: PostTypeLabels,
+		emailPostType: string | undefined,
+		emailLabels: PostTypeLabels,
+		isSamePostType: boolean
+	) => {
+		const candidates: PostTypeCandidate[] = [
+			{ postType: currentPostType, labels: currentLabels },
+		];
+		if ( ! isSamePostType ) {
+			candidates.push( { postType: emailPostType, labels: emailLabels } );
+		}
+		return applyOverridesToNotices( notices, candidates );
+	},
 	(
 		notices: Notice[],
-		labels: PostTypeLabels,
-		postType: string | undefined
-	) => [ notices, labels, postType ]
+		currentPostType: string | undefined,
+		currentLabels: PostTypeLabels,
+		emailPostType: string | undefined,
+		emailLabels: PostTypeLabels,
+		isSamePostType: boolean
+	) => [
+		notices,
+		currentPostType,
+		currentLabels,
+		emailPostType,
+		emailLabels,
+		isSamePostType,
+	]
 );

 /**
@@ -195,33 +254,80 @@ export function useNoticeOverrides(): void {
 								return getNoticesWithOverrides(
 									notices,
 									undefined,
-									undefined
+									undefined,
+									undefined,
+									undefined,
+									true
 								);
 							}

-							const postType = (
-								originalSelect( storeName ) as
-									| { getEmailPostType?: () => string }
+							const getLabelsFor = (
+								postType: string | undefined
+							): PostTypeLabels =>
+								postType
+									? (
+											originalSelect( coreStore ) as
+												| {
+														getPostType: (
+															postType: string
+														) => {
+															labels?: PostTypeLabels;
+														};
+												  }
+												| undefined
+									   )?.getPostType( postType )?.labels
+									: undefined;
+
+							// The post type currently being edited: navigating
+							// from an email into its template (without a page
+							// reload) changes what's on screen without
+							// touching the email editor store's own post
+							// type, so this can differ from the one below.
+							const currentPostType = (
+								originalSelect( CORE_EDITOR_STORE ) as
+									| {
+											getCurrentPostType?: () =>
+												| string
+												| undefined;
+									  }
+									| undefined
+							 )?.getCurrentPostType?.();
+							const currentLabels =
+								getLabelsFor( currentPostType );
+
+							// The post type the email editor was opened on. A
+							// notice's text is written when the save happens,
+							// from the labels of the post type being saved —
+							// matching it against both candidates keeps the
+							// wording correct regardless of which one is
+							// current by the time this selector re-runs.
+							const emailPostType = (
+								originalSelect( emailEditorStoreName ) as
+									| {
+											getEmailPostType?: () =>
+												| string
+												| undefined;
+									  }
 									| undefined
 							 )?.getEmailPostType?.();
-							const labels = postType
-								? (
-										originalSelect( coreStore ) as
-											| {
-													getPostType: (
-														postType: string
-													) => {
-														labels?: PostTypeLabels;
-													};
-											  }
-											| undefined
-								   )?.getPostType( postType )?.labels
-								: undefined;
+							// Single source of truth for "are these the same
+							// post type": both the label lookup below and the
+							// candidate list built inside
+							// `getNoticesWithOverrides` follow from it, so
+							// they can't drift apart.
+							const isSamePostType =
+								emailPostType === currentPostType;
+							const emailLabels = isSamePostType
+								? currentLabels
+								: getLabelsFor( emailPostType );

 							return getNoticesWithOverrides(
 								notices,
-								labels,
-								postType
+								currentPostType,
+								currentLabels,
+								emailPostType,
+								emailLabels,
+								isSamePostType
 							);
 						},
 					};