Commit 9c347aa4003 for woocommerce

commit 9c347aa40032dfe4879e5530eba76f2d0d46bb94
Author: Allison Levine <1689238+allilevine@users.noreply.github.com>
Date:   Tue Oct 20 08:57:57 2026 -0400

    [Email Editor] Fix italic Appearance options not being saved (#70006)

    * Save italic Appearance options in the email editor Styles sidebar

    `setFontAppearance` dispatched twice from one handler, once for `fontStyle`
    and once for `fontWeight`. Both dispatches derived from the same captured
    `userTheme`, because React has not re-rendered between two synchronous calls,
    so the second overwrote the first. The weight landed and the style was always
    dropped: picking "Bold Italic" stored only `fontWeight: 700` and the control
    re-read the theme's inherited `fontStyle: normal`, showing "Bold".

    Write both props in a single `updateStyles` call built with nested
    `setImmutably`, the shape `resetAll` in the same file already uses. The
    element path logic moves out of `updateElementStyleProp` into
    `getElementStylePath` so both call sites share it.

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * Cover the Appearance deselect path in the typography panel tests

    `onDeselect` routes through `setFontAppearance` with both props undefined,
    which the single-update rewrite has to clear together. Assert that with
    `toStrictEqual` so a missing key fails rather than matching `undefined`.

    Fold the four `onChange` invocations into a `selectAppearance` helper that
    checks the control rendered first, so a panel that stops rendering
    FontAppearanceControl fails on the assertion instead of a TypeError.

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

diff --git a/packages/js/email-editor/changelog/fix-NL-1053-appearance-font-style b/packages/js/email-editor/changelog/fix-NL-1053-appearance-font-style
new file mode 100644
index 00000000000..fe4b3f17d4e
--- /dev/null
+++ b/packages/js/email-editor/changelog/fix-NL-1053-appearance-font-style
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Save the italic Appearance options in the email editor Styles sidebar instead of silently reverting to the non-italic equivalent.
diff --git a/packages/js/email-editor/src/components/styles-sidebar/panels/test/typography-element-panel.spec.tsx b/packages/js/email-editor/src/components/styles-sidebar/panels/test/typography-element-panel.spec.tsx
index 4468f9e36cf..502d2d5047a 100644
--- a/packages/js/email-editor/src/components/styles-sidebar/panels/test/typography-element-panel.spec.tsx
+++ b/packages/js/email-editor/src/components/styles-sidebar/panels/test/typography-element-panel.spec.tsx
@@ -1,7 +1,7 @@
 /**
  * External dependencies
  */
-import { render } from '@testing-library/react';
+import { act, render } from '@testing-library/react';
 import { FontSizePicker } from '@wordpress/components';
 import { useSettings } from '@wordpress/block-editor';

@@ -10,6 +10,20 @@ import { useSettings } from '@wordpress/block-editor';
  */
 import { TypographyElementPanel } from '../typography-element-panel';

+// Holds the `onChange` the panel passes to FontAppearanceControl, so tests can
+// fire an Appearance selection the way the dropdown does.
+const fontAppearance: {
+	onChange?: ( value: { fontStyle?: string; fontWeight?: string } ) => void;
+} = {};
+
+const emailStyles = {
+	styles: {},
+	defaultStyles: {},
+	userStyles: {} as Record< string, unknown >,
+	updateStyleProp: jest.fn(),
+	updateStyles: jest.fn(),
+};
+
 jest.mock( '@wordpress/components', () => ( {
 	FontSizePicker: jest.fn( () => null ),
 	__experimentalToolsPanel: ( { children } ) => <div>{ children }</div>,
@@ -18,7 +32,10 @@ jest.mock( '@wordpress/components', () => ( {

 jest.mock( '@wordpress/block-editor', () => ( {
 	useSettings: jest.fn(),
-	__experimentalFontAppearanceControl: () => null,
+	__experimentalFontAppearanceControl: ( props ) => {
+		fontAppearance.onChange = props.onChange;
+		return null;
+	},
 	__experimentalLetterSpacingControl: () => null,
 	__experimentalFontFamilyControl: () => null,
 	LineHeightControl: () => null,
@@ -40,15 +57,18 @@ jest.mock( '@wordpress/global-styles-engine', () => ( {
 	getPresetVariableFromValue: jest.fn(),
 } ) );

+// Stubbed so the real `use-email-styles` module below can load without
+// pulling in `@wordpress/core-data`.
+jest.mock( '../../../../hooks/use-user-theme', () => ( {
+	useUserTheme: () => ( { userTheme: {}, updateUserTheme: jest.fn() } ),
+} ) );
+
+// `setImmutably` stays real: the panel builds the saved styles object with it,
+// and a stub would hide whether both Appearance props survive the write.
 jest.mock( '../../../../hooks', () => ( {
-	useEmailStyles: () => ( {
-		styles: {},
-		defaultStyles: {},
-		userStyles: {},
-		updateStyleProp: jest.fn(),
-		updateStyles: jest.fn(),
-	} ),
-	setImmutably: jest.fn(),
+	setImmutably: jest.requireActual( '../../../../hooks/use-email-styles' )
+		.setImmutably,
+	useEmailStyles: () => emailStyles,
 } ) );

 jest.mock( '../../utils', () => ( {
@@ -71,18 +91,29 @@ jest.mock( '../../../../events', () => ( {
 const useSettingsMock = useSettings as jest.Mock;
 const fontSizePickerMock = FontSizePicker as unknown as jest.Mock;

+const selectAppearance = ( value: {
+	fontStyle?: string;
+	fontWeight?: string;
+} ) => {
+	expect( fontAppearance.onChange ).toBeDefined();
+	act( () => fontAppearance.onChange( value ) );
+};
+
 describe( 'TypographyElementPanel', () => {
 	beforeEach( () => {
 		fontSizePickerMock.mockClear();
-	} );
-
-	it( 'passes the spacing units from the email theme to the font size picker', () => {
+		emailStyles.updateStyleProp.mockClear();
+		emailStyles.updateStyles.mockClear();
+		emailStyles.userStyles = {};
+		delete fontAppearance.onChange;
 		useSettingsMock.mockImplementation( ( ...paths: string[] ) =>
 			paths[ 0 ] === 'spacing.units'
 				? [ [ 'px' ] ]
 				: [ [], { default: [] } ]
 		);
+	} );

+	it( 'passes the spacing units from the email theme to the font size picker', () => {
 		render( <TypographyElementPanel element="text" headingLevel="" /> );

 		expect( fontSizePickerMock ).toHaveBeenCalled();
@@ -90,4 +121,88 @@ describe( 'TypographyElementPanel', () => {
 			'px',
 		] );
 	} );
+
+	it( 'stores both Appearance props in a single update, keeping other user styles', () => {
+		emailStyles.userStyles = {
+			typography: { fontSize: '16px' },
+			color: { text: '#111111' },
+		};
+
+		render( <TypographyElementPanel element="text" headingLevel="" /> );
+
+		selectAppearance( { fontStyle: 'italic', fontWeight: '700' } );
+
+		// Two separate writes would both read the same stale theme, so the
+		// second would overwrite the first and drop `fontStyle`.
+		expect( emailStyles.updateStyleProp ).not.toHaveBeenCalled();
+		expect( emailStyles.updateStyles ).toHaveBeenCalledTimes( 1 );
+		expect( emailStyles.updateStyles ).toHaveBeenCalledWith( {
+			typography: {
+				fontSize: '16px',
+				fontStyle: 'italic',
+				fontWeight: '700',
+			},
+			color: { text: '#111111' },
+		} );
+	} );
+
+	it( 'stores both Appearance props under the heading level for headings', () => {
+		render(
+			<TypographyElementPanel element="heading" headingLevel="h2" />
+		);
+
+		selectAppearance( { fontStyle: 'italic', fontWeight: '400' } );
+
+		expect( emailStyles.updateStyles ).toHaveBeenCalledTimes( 1 );
+		expect( emailStyles.updateStyles ).toHaveBeenCalledWith( {
+			elements: {
+				h2: {
+					typography: { fontStyle: 'italic', fontWeight: '400' },
+				},
+			},
+		} );
+	} );
+
+	it( 'clears both Appearance props in a single update when deselected', () => {
+		emailStyles.userStyles = {
+			typography: {
+				fontSize: '16px',
+				fontStyle: 'italic',
+				fontWeight: '700',
+			},
+			color: { text: '#111111' },
+		};
+
+		render( <TypographyElementPanel element="text" headingLevel="" /> );
+
+		// What the Appearance tool's onDeselect hands back.
+		selectAppearance( { fontStyle: undefined, fontWeight: undefined } );
+
+		expect( emailStyles.updateStyleProp ).not.toHaveBeenCalled();
+		expect( emailStyles.updateStyles ).toHaveBeenCalledTimes( 1 );
+		// toStrictEqual, so a missing key fails rather than matching undefined.
+		expect( emailStyles.updateStyles.mock.calls[ 0 ][ 0 ] ).toStrictEqual( {
+			typography: {
+				fontSize: '16px',
+				fontStyle: undefined,
+				fontWeight: undefined,
+			},
+			color: { text: '#111111' },
+		} );
+	} );
+
+	it( 'stores both Appearance props under the element for other elements', () => {
+		render( <TypographyElementPanel element="link" headingLevel="" /> );
+
+		selectAppearance( { fontStyle: 'italic', fontWeight: '700' } );
+
+		expect( emailStyles.updateStyles ).toHaveBeenCalledTimes( 1 );
+		expect( emailStyles.updateStyles ).toHaveBeenCalledWith( {
+			elements: {
+				link: {
+					typography: { fontStyle: 'italic', fontWeight: '700' },
+				},
+			},
+		} );
+	} );
 } );
diff --git a/packages/js/email-editor/src/components/styles-sidebar/panels/typography-element-panel.tsx b/packages/js/email-editor/src/components/styles-sidebar/panels/typography-element-panel.tsx
index e3d92d55734..da05489cc93 100644
--- a/packages/js/email-editor/src/components/styles-sidebar/panels/typography-element-panel.tsx
+++ b/packages/js/email-editor/src/components/styles-sidebar/panels/typography-element-panel.tsx
@@ -139,20 +139,26 @@ export function TypographyElementPanel( {
 	const showToolFontSize =
 		element !== 'heading' || headingLevel !== 'heading';

-	const updateElementStyleProp = useCallback(
-		( path, newValue ) => {
+	// Text styles live at the root of the styles object, every other element
+	// under `elements`, and headings under their level.
+	const getElementStylePath = useCallback(
+		( path ) => {
 			if ( element === 'heading' ) {
-				updateStyleProp(
-					[ 'elements', headingLevel, ...path ],
-					newValue
-				);
-			} else if ( element === 'text' ) {
-				updateStyleProp( [ ...path ], newValue );
-			} else {
-				updateStyleProp( [ 'elements', element, ...path ], newValue );
+				return [ 'elements', headingLevel, ...path ];
+			}
+			if ( element === 'text' ) {
+				return [ ...path ];
 			}
+			return [ 'elements', element, ...path ];
 		},
-		[ element, updateStyleProp, headingLevel ]
+		[ element, headingLevel ]
+	);
+
+	const updateElementStyleProp = useCallback(
+		( path, newValue ) => {
+			updateStyleProp( getElementStylePath( path ), newValue );
+		},
+		[ getElementStylePath, updateStyleProp ]
 	);

 	const setTextColor = ( newValue ) => {
@@ -256,8 +262,20 @@ export function TypographyElementPanel( {
 		fontStyle: newFontStyle,
 		fontWeight: newFontWeight,
 	} ) => {
-		updateElementStyleProp( [ 'typography', 'fontStyle' ], newFontStyle );
-		updateElementStyleProp( [ 'typography', 'fontWeight' ], newFontWeight );
+		// Single update — a second updateStyleProp call in the same handler
+		// would work from a stale userTheme and clobber this edit, dropping
+		// fontStyle.
+		updateStyles(
+			setImmutably(
+				setImmutably(
+					userStyles ?? {},
+					getElementStylePath( [ 'typography', 'fontStyle' ] ),
+					newFontStyle
+				),
+				getElementStylePath( [ 'typography', 'fontWeight' ] ),
+				newFontWeight
+			)
+		);
 		debouncedRecordEvent(
 			'styles_sidebar_screen_typography_element_panel_set_font_appearance',
 			{