Commit 0484c5f5ed2 for woocommerce

commit 0484c5f5ed2e65f308deca1de3b327c2a2c4f537
Author: Yuliyan Slavchev <yuliyan.slavchev@gmail.com>
Date:   Mon Mar 2 10:14:11 2026 +0200

    Email Editor: Rework snackbar notices (#63451)

    * Rework email editor notices overrides

    * Remove EditorSnackbars in favor of SnackbarNotices

    * Remove SnackbarNotices with `email-editor` context

    * Add setting to disable snackbar notices

    * Add changelog

    * Switch to EditorSnackbars from @wordpress/editor for version compatiblity

    * Add docs for email editor specific settings

    * Use custom EditorSnackbars component for better compatibility

diff --git a/packages/js/email-editor/README.md b/packages/js/email-editor/README.md
index 2753a98f44a..a593bc8084d 100644
--- a/packages/js/email-editor/README.md
+++ b/packages/js/email-editor/README.md
@@ -18,7 +18,10 @@ window.WooCommerceEmailEditor = {
     current_post_type: '', // The post type of the current post
     current_post_id: '', // The ID of the current post
     current_wp_user_email: '', // The email of the current user
-    editor_settings: {}, // The block editor settings
+    editor_settings: {
+        // Standard block editor settings, plus email-editor-specific options.
+        // See the "Editor settings" section below for available options.
+    },
     editor_theme: {}, // The block editor theme
     user_theme_post_id: '', // The ID of the user theme post
     urls: {
@@ -39,6 +42,16 @@ The `initializeEditor` function accepts a single parameter:

 Make sure to set up the required data on `window.WooCommerceEmailEditor` before calling `initializeEditor`.

+### Editor settings
+
+The `editor_settings` object (or `config.editorSettings` when using `ExperimentalEmailEditor`) accepts all standard WordPress block editor settings plus the following email-editor-specific options:
+
+| Setting                  | Type      | Default | Description                                                                                                                                                          |
+| ------------------------ | --------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `isFullScreenForced`     | `boolean` | `false` | When `true`, the editor is always rendered in fullscreen mode and the user cannot toggle it off. The "More menu" is hidden and a back button is shown in the header. |
+| `displaySendEmailButton` | `boolean` | `false` | When `true`, a "Send" button is displayed in the editor header, allowing users to publish/send the email directly from the editor.                                   |
+| `disableSnackbarNotices` | `boolean` | `false` | When `true`, the editor does not render its own snackbar notices. Pinned and validation notices are unaffected.                                                      |
+
 ## Exports

 ### Components
@@ -56,13 +69,18 @@ import { ExperimentalEmailEditor } from '@woocommerce/email-editor';
     postId="123"
     postType="email"
     config={ {
-        editorSettings: { /* ... */ },
-        theme: { /* ... */ },
+        editorSettings: {
+            // Standard block editor settings, plus email-editor-specific
+            // options. See the "Editor settings" section for details.
+        },
+        theme: {
+            /* ... */
+        },
         urls: { listings: '/emails', back: '/' },
         userEmail: 'user@example.com',
         globalStylesPostId: 456,
     } }
-/>
+/>;
 ```

 #### `SendPreviewEmail`
@@ -76,7 +94,7 @@ import { createStore, SendPreviewEmail } from '@woocommerce/email-editor';

 createStore();
 // ...
-<SendPreviewEmail />
+<SendPreviewEmail />;
 ```

 #### `RichTextWithButton`
@@ -96,7 +114,7 @@ createStore();
     attributeName="subject"
     attributeValue={ currentSubject }
     updateProperty={ ( name, value ) => setEmailProperty( name, value ) }
-/>
+/>;
 ```

 ### Hooks
@@ -166,7 +184,11 @@ createStore();
 Analytics tracking utilities. Events are prefixed with `email_editor_events_` and only recorded when tracking is enabled. `recordEventOnce` deduplicates per session. `debouncedRecordEvent` waits 700ms to batch rapid actions.

 ```js
-import { recordEvent, recordEventOnce, debouncedRecordEvent } from '@woocommerce/email-editor';
+import {
+    recordEvent,
+    recordEventOnce,
+    debouncedRecordEvent,
+} from '@woocommerce/email-editor';

 recordEvent( 'button_clicked', { buttonType: 'save' } );
 recordEventOnce( 'editor_loaded' );
@@ -229,8 +251,8 @@ new DependencyExtractionWebpackPlugin( {
             return null;
         }
         // ... handle other dependencies
-    }
-} )
+    },
+} );
 ```

 ### Email Editor
diff --git a/packages/js/email-editor/changelog/wooprd-2541-double-snackbar b/packages/js/email-editor/changelog/wooprd-2541-double-snackbar
new file mode 100644
index 00000000000..3fc6ba16911
--- /dev/null
+++ b/packages/js/email-editor/changelog/wooprd-2541-double-snackbar
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Rework snackbar notices by using the `EditorSnackbars` component from `@wordpress/editor` to render notices, removing the `email-editor` context snackbar notices renderer, and adding a `disableSnackbarNotices` setting to the email editor component
diff --git a/packages/js/email-editor/src/components/block-editor/editor.tsx b/packages/js/email-editor/src/components/block-editor/editor.tsx
index 434b362a023..fb0ae9d9893 100644
--- a/packages/js/email-editor/src/components/block-editor/editor.tsx
+++ b/packages/js/email-editor/src/components/block-editor/editor.tsx
@@ -107,7 +107,11 @@ export function InnerEditor( {
 		};
 	}, [] );

-	const { isFullScreenForced, displaySendEmailButton } = settings;
+	const {
+		isFullScreenForced,
+		displaySendEmailButton,
+		disableSnackbarNotices,
+	} = settings;

 	// @ts-expect-error Type is missing in @types/wordpress__editor
 	const { removeEditorPanel } = useDispatch( editorStore );
@@ -192,7 +196,11 @@ export function InnerEditor( {
 						<SettingsPanel />
 					) }
 					{ displaySendEmailButton && <PublishSave /> }
-					<EditorNotices />
+					<EditorNotices
+						disableSnackbarNotices={
+							disableSnackbarNotices as boolean | undefined
+						}
+					/>
 					<BlockCompatibilityWarnings />
 					<PluginArea scope="woocommerce-email-editor" />
 				</Editor>
diff --git a/packages/js/email-editor/src/components/notices/index.ts b/packages/js/email-editor/src/components/notices/index.ts
index 5fb4d392add..769aec2deae 100644
--- a/packages/js/email-editor/src/components/notices/index.ts
+++ b/packages/js/email-editor/src/components/notices/index.ts
@@ -1,3 +1,2 @@
 export { EditorNotices } from './notices';
-export { EditorSnackbars } from './snackbars';
 export { SentEmailNotice } from './sent-email-notice';
diff --git a/packages/js/email-editor/src/components/notices/notices.tsx b/packages/js/email-editor/src/components/notices/notices.tsx
index 818425ef27b..9af5ec33f23 100644
--- a/packages/js/email-editor/src/components/notices/notices.tsx
+++ b/packages/js/email-editor/src/components/notices/notices.tsx
@@ -14,7 +14,13 @@ import { NoticesSlot } from '../../hacks/notices-slot';

 // See: https://github.com/WordPress/gutenberg/blob/5be0ec4153c3adf9f0f2513239f4f7a358ba7948/packages/editor/src/components/editor-notices/index.js

-export function EditorNotices() {
+interface EditorNoticesProps {
+	disableSnackbarNotices?: boolean;
+}
+
+export function EditorNotices( {
+	disableSnackbarNotices = false,
+}: EditorNoticesProps = {} ) {
 	const { notices } = useSelect(
 		( select ) => ( {
 			notices: select( noticesStore ).getNotices( 'email-editor' ),
@@ -46,8 +52,7 @@ export function EditorNotices() {
 				/>
 				<ValidationNotices />
 			</NoticesSlot>
-			<EditorSnackbars context="global" />
-			<EditorSnackbars context="email-editor" />
+			{ ! disableSnackbarNotices && <EditorSnackbars /> }
 		</>
 	);
 }
diff --git a/packages/js/email-editor/src/components/notices/snackbars.tsx b/packages/js/email-editor/src/components/notices/snackbars.tsx
index 5ea053fe0f9..5b84c2ded00 100644
--- a/packages/js/email-editor/src/components/notices/snackbars.tsx
+++ b/packages/js/email-editor/src/components/notices/snackbars.tsx
@@ -1,70 +1,32 @@
 /**
  * External dependencies
  */
-import { useMemo } from '@wordpress/element';
 import { SnackbarList } from '@wordpress/components';
-import { __ } from '@wordpress/i18n';
 import { useSelect, useDispatch } from '@wordpress/data';
 import { store as noticesStore } from '@wordpress/notices';

-// See: https://github.com/WordPress/gutenberg/blob/2788a9cf8b8149be3ee52dd15ce91fa55815f36a/packages/editor/src/components/editor-snackbars/index.js
+// Based on https://github.com/WordPress/gutenberg/blob/2788a9cf8b8149be3ee52dd15ce91fa55815f36a/packages/editor/src/components/editor-snackbars/index.js
+// Uses both class names to support pre-7.0 and 7.0+ Gutenberg layouts.

-export function EditorSnackbars( { context = 'email-editor' } ) {
-	const { notices } = useSelect(
-		( select ) => ( {
-			notices: select( noticesStore ).getNotices( context ),
-		} ),
-		[ context ]
-	);
+const MAX_VISIBLE_NOTICES = -3;

-	// Some global notices are not suitable for the email editor context
-	// This map allows us to change the content of the notice
-	const globalNoticeChangeMap = useMemo( () => {
-		return {
-			'site-editor-save-success': {
-				content: __( 'Email design updated.', 'woocommerce' ),
-				removeActions: true,
-			},
-			'editor-save': {
-				content: __( 'Email saved.', 'woocommerce' ),
-				removeActions: false,
-				contentCheck: ( notice ) => {
-					// eslint-disable-next-line @wordpress/i18n-text-domain
-					return notice.content.includes( __( 'Post updated.' ) ); // It is intentionally without domain to match core translation
-				},
-			},
-		};
-	}, [] );
+export function EditorSnackbars() {
+	const notices = useSelect(
+		( select ) => select( noticesStore ).getNotices(),
+		[]
+	);

 	const { removeNotice } = useDispatch( noticesStore );

 	const snackbarNotices = notices
 		.filter( ( { type } ) => type === 'snackbar' )
-		.map( ( notice ) => {
-			if ( ! globalNoticeChangeMap[ notice.id ] ) {
-				return notice;
-			}
-			if (
-				globalNoticeChangeMap[ notice.id ].contentCheck &&
-				! globalNoticeChangeMap[ notice.id ].contentCheck( notice )
-			) {
-				return notice;
-			}
-			return {
-				...notice,
-				content: globalNoticeChangeMap[ notice.id ].content,
-				spokenMessage: globalNoticeChangeMap[ notice.id ].content,
-				actions: globalNoticeChangeMap[ notice.id ].removeActions
-					? []
-					: notice.actions,
-			};
-		} );
+		.slice( MAX_VISIBLE_NOTICES );

 	return (
 		<SnackbarList
 			notices={ snackbarNotices }
-			className="components-editor-notices__snackbar"
-			onRemove={ ( id ) => removeNotice( id, context ) }
+			className="components-editor-notices__snackbar edit-post-layout__snackbar"
+			onRemove={ removeNotice }
 		/>
 	);
 }
diff --git a/packages/js/email-editor/src/editor.tsx b/packages/js/email-editor/src/editor.tsx
index 04bcd506f36..b8c801292f9 100644
--- a/packages/js/email-editor/src/editor.tsx
+++ b/packages/js/email-editor/src/editor.tsx
@@ -31,8 +31,9 @@ import { initContentValidationMiddleware } from './middleware/content-validation
 import { initHacks } from './hacks';
 import {
 	useContentValidation,
-	useRemoveSavingFailedNotices,
 	useFilterEditorContentStylesheets,
+	useNoticeOverrides,
+	useRemoveSavingFailedNotices,
 } from './hooks';
 import { cleanupConfigurationChanges } from './config-tools';
 import { getEditorConfigFromWindow } from './store/settings';
@@ -61,6 +62,7 @@ function Editor( {

 	useContentValidation();
 	useRemoveSavingFailedNotices();
+	useNoticeOverrides();

 	const { setEmailPost } = useDispatch( storeName );
 	useEffect( () => {
diff --git a/packages/js/email-editor/src/hooks/index.ts b/packages/js/email-editor/src/hooks/index.ts
index 730d9e3e908..ac636a4be26 100644
--- a/packages/js/email-editor/src/hooks/index.ts
+++ b/packages/js/email-editor/src/hooks/index.ts
@@ -8,3 +8,4 @@ export * from './use-editor-mode';
 export * from './use-remove-saving-failed-notices';
 export * from './use-is-email-editor';
 export * from './use-filter-editor-content-stylesheets';
+export * from './use-notice-overrides';
diff --git a/packages/js/email-editor/src/hooks/use-notice-overrides.ts b/packages/js/email-editor/src/hooks/use-notice-overrides.ts
new file mode 100644
index 00000000000..2088149c652
--- /dev/null
+++ b/packages/js/email-editor/src/hooks/use-notice-overrides.ts
@@ -0,0 +1,122 @@
+/**
+ * External dependencies
+ */
+import { useEffect } from '@wordpress/element';
+import { createSelector, use } from '@wordpress/data';
+import { __ } from '@wordpress/i18n';
+import { store as noticesStore } from '@wordpress/notices';
+
+/**
+ * Wraps the `getNotices` selector on the notices store so that specific
+ * notices are returned with email-editor-appropriate content.
+ *
+ * Use the `useNoticeOverrides` hook when mounting the email editor. On
+ * mount it applies a data plugin that overrides the selector; on unmount
+ * it applies a plugin that restores the original select.
+ */
+
+interface NoticeOverride {
+	content: string;
+	removeActions: boolean;
+	contentCheck?: ( content: string ) => boolean;
+}
+
+function getNoticeOverrides(): Record< string, NoticeOverride > {
+	return {
+		'site-editor-save-success': {
+			content: __( 'Email design updated.', 'woocommerce' ),
+			removeActions: true,
+		},
+		'editor-save': {
+			content: __( 'Email saved.', 'woocommerce' ),
+			removeActions: false,
+			contentCheck: ( content: string ) =>
+				// Intentionally without text domain to match the core translation.
+				// eslint-disable-next-line @wordpress/i18n-text-domain
+				content.includes( __( 'Post updated.' ) ),
+		},
+	};
+}
+
+interface Notice {
+	id: string;
+	content: string;
+	spokenMessage: string;
+	actions: unknown[];
+	[ key: string ]: unknown;
+}
+
+function transformNotice( notice: Notice ): Notice {
+	const overrides = getNoticeOverrides();
+	const override = overrides[ notice.id ];
+	if ( ! override ) {
+		return notice;
+	}
+	if ( override.contentCheck && ! override.contentCheck( notice.content ) ) {
+		return notice;
+	}
+	return {
+		...notice,
+		content: override.content,
+		spokenMessage: override.content,
+		actions: override.removeActions ? [] : notice.actions,
+	};
+}
+
+function applyOverridesToNotices( notices: Notice[] ): Notice[] {
+	return notices.map( ( notice ) => transformNotice( notice ) );
+}
+
+function getStoreName( namespace: string | { name: string } ): string {
+	return typeof namespace === 'object' ? namespace.name : namespace;
+}
+
+/**
+ * Applies notice overrides when the email editor is mounted and restores
+ * the original select when it unmounts.
+ */
+export function useNoticeOverrides(): void {
+	useEffect( () => {
+		let originalSelect: ( namespace: string | { name: string } ) => unknown;
+
+		use( ( registry: { select: ( ...args: unknown[] ) => unknown } ) => {
+			originalSelect = registry.select;
+
+			return {
+				select: ( namespace: string | { name: string } ) => {
+					if ( getStoreName( namespace ) !== noticesStore.name ) {
+						return originalSelect( namespace );
+					}
+
+					const selectors = originalSelect( namespace ) as {
+						getNotices?: ( context?: string ) => Notice[];
+						[ key: string ]: unknown;
+					};
+
+					const originalGetNotices = selectors.getNotices;
+					if ( ! originalGetNotices ) {
+						return selectors;
+					}
+
+					const getNoticesWithOverrides = createSelector(
+						( notices: Notice[] ) =>
+							applyOverridesToNotices( notices ),
+						( notices: Notice[] ) => [ notices ]
+					);
+
+					return {
+						...selectors,
+						getNotices: ( context?: string ) =>
+							getNoticesWithOverrides(
+								originalGetNotices( context )
+							),
+					};
+				},
+			};
+		} );
+
+		return () => {
+			use( () => ( { select: originalSelect } ) );
+		};
+	}, [] );
+}