Commit e58acb1b344 for woocommerce

commit e58acb1b3445aa03a827a3d43a54e37fc55a8f7a
Author: Pavel Dohnal <pavel.dohnal@automattic.com>
Date:   Wed Apr 1 12:17:13 2026 +0200

    Fix useSelect instability warning for notices in the email editor (#63943)

    * Fix useSelect instability warning for notices in the email editor

    Hoist createSelector out of the select() override so it's created once
    per mount, and cache the selectors object to avoid returning a new
    reference on every call.

    * Move createSelector to module scope per review feedback

    Simplifies the fix by placing the memoized selector at module scope
    instead of inside useEffect, removing the need for manual object caching.

diff --git a/packages/js/email-editor/changelog/fix-email-editor-console-log-notices b/packages/js/email-editor/changelog/fix-email-editor-console-log-notices
new file mode 100644
index 00000000000..860bb7839db
--- /dev/null
+++ b/packages/js/email-editor/changelog/fix-email-editor-console-log-notices
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix useSelect instability warning for notices in 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
new file mode 100644
index 00000000000..ce4c3364f98
--- /dev/null
+++ b/packages/js/email-editor/src/hooks/test/use-notice-overrides.spec.ts
@@ -0,0 +1,173 @@
+/* eslint-disable @woocommerce/dependency-group -- mocks must be imported first */
+/**
+ * External dependencies
+ */
+import { renderHook } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import { useNoticeOverrides } from '../use-notice-overrides';
+
+// Keep a reference to the plugin callback registered via `use()`.
+let capturedPlugin: ( registry: {
+	select: ( namespace: string ) => unknown;
+} ) => { select: ( namespace: string ) => unknown };
+
+jest.mock( '@wordpress/data', () => {
+	const actual =
+		jest.requireActual< typeof import('@wordpress/data') >(
+			'@wordpress/data'
+		);
+	return {
+		...actual,
+		use: jest.fn(
+			(
+				plugin: ( registry: {
+					select: ( namespace: string ) => unknown;
+				} ) => { select: ( namespace: string ) => unknown }
+			) => {
+				capturedPlugin = plugin;
+			}
+		),
+	};
+} );
+
+jest.mock( '@wordpress/notices', () => ( {
+	store: { name: 'core/notices' },
+} ) );
+
+jest.mock( '@wordpress/i18n', () => ( {
+	__: ( text: string ) => text,
+} ) );
+
+interface Notice {
+	id: string;
+	content: string;
+	spokenMessage: string;
+	actions: unknown[];
+}
+
+const makeNotice = ( partial: Partial< Notice > = {} ): Notice => ( {
+	id: 'test-notice',
+	content: 'Test notice',
+	spokenMessage: 'Test notice',
+	actions: [],
+	...partial,
+} );
+
+describe( 'useNoticeOverrides — memoized selector stability', () => {
+	beforeEach( () => {
+		jest.clearAllMocks();
+	} );
+
+	function buildSelectOverride( notices: Notice[] ) {
+		const originalGetNotices = jest.fn().mockReturnValue( notices );
+		const originalSelectors = { getNotices: originalGetNotices };
+
+		const originalSelect = jest.fn().mockReturnValue( originalSelectors );
+
+		renderHook( () => useNoticeOverrides() );
+
+		const pluginResult = capturedPlugin( { select: originalSelect } );
+		return { pluginResult, originalSelect, originalGetNotices };
+	}
+
+	it( 'getNotices returns the same array reference when notices input is unchanged', () => {
+		const notices = [ makeNotice() ];
+		const { pluginResult } = buildSelectOverride( notices );
+
+		const selectors = pluginResult.select( 'core/notices' ) as {
+			getNotices: () => Notice[];
+		};
+
+		const firstResult = selectors.getNotices();
+		const secondResult = selectors.getNotices();
+
+		expect( firstResult ).toBe( secondResult );
+	} );
+
+	it( 'getNotices returns a new array reference when notices input changes', () => {
+		const originalGetNotices = jest.fn();
+		const originalSelectors = { getNotices: originalGetNotices };
+		const originalSelect = jest.fn().mockReturnValue( originalSelectors );
+
+		renderHook( () => useNoticeOverrides() );
+		const pluginResult = capturedPlugin( { select: originalSelect } );
+
+		const firstNotices = [ makeNotice( { id: 'a' } ) ];
+		originalGetNotices.mockReturnValue( firstNotices );
+		const selectors = pluginResult.select( 'core/notices' ) as {
+			getNotices: () => Notice[];
+		};
+		const firstResult = selectors.getNotices();
+
+		const secondNotices = [ makeNotice( { id: 'b' } ) ];
+		originalGetNotices.mockReturnValue( secondNotices );
+		const secondResult = selectors.getNotices();
+
+		expect( firstResult ).not.toBe( secondResult );
+	} );
+
+	it( 'passes through select for non-notices stores unchanged', () => {
+		const notices = [ makeNotice() ];
+		const otherSelectors = { getSomething: jest.fn() };
+		const originalGetNotices = jest.fn().mockReturnValue( notices );
+		const originalSelectors = { getNotices: originalGetNotices };
+
+		const originalSelect = jest
+			.fn()
+			.mockImplementation( ( ns: string ) =>
+				ns === 'core/notices' ? originalSelectors : otherSelectors
+			);
+
+		renderHook( () => useNoticeOverrides() );
+		const pluginResult = capturedPlugin( { select: originalSelect } );
+
+		const result = pluginResult.select( 'some/other-store' );
+		expect( result ).toBe( otherSelectors );
+	} );
+
+	it( 'transforms known notice content via getNotices', () => {
+		const originalNotice = makeNotice( {
+			id: 'editor-save',
+			content: 'Post updated.',
+		} );
+		const { pluginResult } = buildSelectOverride( [ originalNotice ] );
+
+		const selectors = pluginResult.select( 'core/notices' ) as {
+			getNotices: () => Notice[];
+		};
+		const result = selectors.getNotices();
+
+		expect( result[ 0 ].content ).toBe( 'Email saved.' );
+	} );
+
+	it( 'transforms site-editor-save-success notice and removes actions', () => {
+		const originalNotice = makeNotice( {
+			id: 'site-editor-save-success',
+			content: 'Site updated.',
+			actions: [ { label: 'View', url: '#' } ],
+		} );
+		const { pluginResult } = buildSelectOverride( [ originalNotice ] );
+
+		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( 'returns selectors unchanged when getNotices is not present', () => {
+		const originalSelectors = { someOtherSelector: jest.fn() };
+		const originalSelect = jest.fn().mockReturnValue( originalSelectors );
+
+		renderHook( () => useNoticeOverrides() );
+		const pluginResult = capturedPlugin( { select: originalSelect } );
+
+		const result = pluginResult.select( 'core/notices' );
+		expect( result ).toBe( originalSelectors );
+	} );
+} );
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 2088149c652..008891c6bb6 100644
--- a/packages/js/email-editor/src/hooks/use-notice-overrides.ts
+++ b/packages/js/email-editor/src/hooks/use-notice-overrides.ts
@@ -71,6 +71,11 @@ function getStoreName( namespace: string | { name: string } ): string {
 	return typeof namespace === 'object' ? namespace.name : namespace;
 }

+const getNoticesWithOverrides = createSelector(
+	( notices: Notice[] ) => applyOverridesToNotices( notices ),
+	( notices: Notice[] ) => [ notices ]
+);
+
 /**
  * Applies notice overrides when the email editor is mounted and restores
  * the original select when it unmounts.
@@ -98,12 +103,6 @@ export function useNoticeOverrides(): void {
 						return selectors;
 					}

-					const getNoticesWithOverrides = createSelector(
-						( notices: Notice[] ) =>
-							applyOverridesToNotices( notices ),
-						( notices: Notice[] ) => [ notices ]
-					);
-
 					return {
 						...selectors,
 						getNotices: ( context?: string ) =>