Commit 1775830f136 for woocommerce

commit 1775830f1360e4c8de3ae4ceca7631013dc33713
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Tue Sep 1 09:01:09 2026 +0300

    Fix Featured Items image editor losing natural image dimensions (#68189)

    * fix(blocks): restore Featured Items image editor natural dimensions

    The Featured Product and Featured Category image editor carried a
    compatibility branch for WordPress 6.1 and lower, which wrapped the
    image editor in `__experimentalImageEditingProvider`. WordPress 6.2
    merged that provider into `__experimentalImageEditor`, so the guard
    `typeof ImageEditingProvider === 'function'` has been false ever since,
    and the provider is no longer exported by the bundled block editor at
    all.

    The two branches were not equivalent. The provider branch passed
    `naturalHeight`/`naturalWidth` with a `DEFAULT_EDITOR_SIZE` fallback;
    the branch that became live passed the raw values. `backgroundImageSize`
    starts life as an empty object in `with-featured-item.tsx`, so opening
    the crop UI before the image resolves handed the editor `undefined`
    natural dimensions, leaving it without the values it uses to compute
    crop ratio and zoom.

    Drop the unreachable branch and give the merged editor the complete
    contract, including the natural-dimension fallback the old path had.
    The `isEditingImage` prop goes with it: the HOC only renders this
    component inside `if ( isEditingImage )`, so it was always true.

    Both halves predate the Blocks library merge (#54911); the natural-size
    regression became live behavior on WordPress 6.2.

    * fix(blocks): accept partial attribute updates in the image editor props

    The Featured Items image editor saves an edited image by calling
    setAttributes with just the media fields it changed:

        setAttributes( { mediaId: id, mediaSrc: url } );

    ImageEditorProps declared that callback as taking a full
    MediaAttributes, which also requires `align`. TypeScript rejected the
    call with TS2345 ("Property 'align' is missing"), and the same
    interface disagreed with WithImageEditorRequiredProps, which had
    already typed the identical callback as Partial< MediaAttributes >.
    The HOC passes its own setAttributes straight down to ImageEditor, so
    the two declarations describe one function through two contradictory
    types.

    The error went unnoticed because the package typecheck script ends in
    `|| true`, so type failures never fail CI.

    Widen the prop to Partial< MediaAttributes >, matching the HOC and the
    way WordPress setAttributes actually behaves: callers send only the
    attributes they are updating. ImageEditorProps is module-private, so
    this changes no exported surface.

    * fix(blocks): open the image editor at the real natural image size

    The image editor sized its crop frame from backgroundImageSize, which
    with-featured-item.tsx fills in from the rendered background <img>
    onLoad handler. That element only exists when the background is
    neither repeated nor parallax:

        const isImgElement = ! isRepeated && ! hasParallax;

    Nothing else ever writes that state. So for a repeated or parallax
    background there is no <img>, onLoad never fires, and the size stays
    the initial useState( {} ) for as long as the block keeps that
    setting. The editor then fell back to the 500x500 DEFAULT_EDITOR_SIZE
    and offered a square crop frame for an image of any shape. The same
    gap appears briefly for plain backgrounds when the editor is opened
    before the image finishes loading.

    useBackgroundImage() already measures the file off-screen through a
    shadow Image() and returns it as originalImgDimension, independent of
    how the background is rendered. withImageEditor calls that hook
    already, so the correct size was being computed and discarded a few
    lines from where it was needed.

    Prefer the measured <img> size, fall back to the off-screen
    measurement, and keep the constant only for the window before either
    has landed. Also widen the two backgroundImageSize declarations to
    Partial< MediaSize >, since the runtime value starts as {} and the
    old type claimed both dimensions were always present.

    Note that this widening documents the shape rather than enforcing it:
    __experimentalImageEditor ships no type declarations, so its props
    are `any` and an undefined dimension still passes the compiler. The
    unit tests are what hold the fallback chain in place.

diff --git a/plugins/woocommerce/changelog/fix-featured-items-image-editor-natural-size b/plugins/woocommerce/changelog/fix-featured-items-image-editor-natural-size
new file mode 100644
index 00000000000..d3f58b65850
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-featured-items-image-editor-natural-size
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Restore the real image dimensions in the Featured Product and Featured Category image editor, which opened at a square default after WordPress 6.2 merged the image editing provider into the editor component.
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/featured-items/image-editor.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/featured-items/image-editor.tsx
index 737d83ae187..a8d5980c5c4 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/featured-items/image-editor.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/featured-items/image-editor.tsx
@@ -6,11 +6,8 @@
 import { useCallback, useEffect, useRef, useState } from '@wordpress/element';
 import { WP_REST_API_Category } from 'wp-types';
 import { ProductResponseItem } from '@woocommerce/types';
-import {
-	__experimentalImageEditingProvider as ImageEditingProvider,
-	__experimentalImageEditor as GutenbergImageEditor,
-} from '@wordpress/block-editor';
-import type { ComponentType, Dispatch, SetStateAction } from 'react';
+import { __experimentalImageEditor as GutenbergImageEditor } from '@wordpress/block-editor';
+import type { ComponentType, Dispatch, RefObject, SetStateAction } from 'react';

 /**
  * Internal dependencies
@@ -24,7 +21,7 @@ type MediaSize = { height: number; width: number };

 interface WithImageEditorRequiredProps< T > {
 	attributes: MediaAttributes & EditorBlock< T >[ 'attributes' ];
-	backgroundImageSize: MediaSize;
+	backgroundImageSize: Partial< MediaSize >;
 	setAttributes: ( attrs: Partial< MediaAttributes > ) => void;
 	useEditingImage: [ boolean, Dispatch< SetStateAction< boolean > > ];
 }
@@ -48,18 +45,18 @@ type WithImageEditorProps< T extends EditorBlock< T > > =
 interface ImageEditorProps {
 	align: string;
 	backgroundImageId: number;
-	backgroundImageSize: MediaSize;
+	backgroundImageSize: Partial< MediaSize >;
 	backgroundImageSrc: string;
-	containerRef: React.RefObject< HTMLDivElement >;
-	isEditingImage: boolean;
-	setAttributes: ( attrs: MediaAttributes ) => void;
+	containerRef: RefObject< HTMLDivElement >;
+	originalImgDimension: MediaSize;
+	setAttributes: ( attrs: Partial< MediaAttributes > ) => void;
 	setIsEditingImage: ( value: boolean ) => void;
 }

 // Adapted from:
 // https://github.com/WordPress/gutenberg/blob/v15.6.1/packages/block-library/src/image/use-client-width.js
 function useClientWidth(
-	ref: React.RefObject< HTMLDivElement >,
+	ref: RefObject< HTMLDivElement >,
 	dependencies: string[]
 ) {
 	const [ clientWidth, setClientWidth ]: [
@@ -100,52 +97,33 @@ export const ImageEditor = ( {
 	backgroundImageSize,
 	backgroundImageSrc,
 	containerRef,
-	isEditingImage,
+	originalImgDimension,
 	setAttributes,
 	setIsEditingImage,
 }: ImageEditorProps ) => {
 	const clientWidth = useClientWidth( containerRef, [ align ] );

-	// Fallback for WP 6.1 or lower. In WP 6.2. ImageEditingProvider was merged
-	// with ImageEditor, see: https://github.com/WordPress/gutenberg/pull/47171
-	if ( typeof ImageEditingProvider === 'function' ) {
-		return (
-			<ImageEditingProvider
-				id={ backgroundImageId }
-				url={ backgroundImageSrc }
-				naturalHeight={
-					backgroundImageSize.height || DEFAULT_EDITOR_SIZE.height
-				}
-				naturalWidth={
-					backgroundImageSize.width || DEFAULT_EDITOR_SIZE.width
-				}
-				onSaveImage={ ( { id, url }: { id: number; url: string } ) => {
-					setAttributes( { mediaId: id, mediaSrc: url } );
-				} }
-				isEditing={ isEditingImage }
-				onFinishEditing={ () => setIsEditingImage( false ) }
-			>
-				<GutenbergImageEditor
-					url={ backgroundImageSrc }
-					height={
-						backgroundImageSize.height || DEFAULT_EDITOR_SIZE.height
-					}
-					width={
-						backgroundImageSize.width || DEFAULT_EDITOR_SIZE.width
-					}
-				/>
-			</ImageEditingProvider>
-		);
-	}
+	// The rendered <img> only exists for plain backgrounds, so its measured
+	// size is missing for repeated/parallax ones and until it finishes
+	// loading. useBackgroundImage() measures the same file off-screen, which
+	// covers both cases; the constant is the last resort before either lands.
+	const editorHeight =
+		backgroundImageSize.height ||
+		originalImgDimension.height ||
+		DEFAULT_EDITOR_SIZE.height;
+	const editorWidth =
+		backgroundImageSize.width ||
+		originalImgDimension.width ||
+		DEFAULT_EDITOR_SIZE.width;

 	return (
 		<GutenbergImageEditor
 			id={ backgroundImageId }
 			url={ backgroundImageSrc }
-			height={ backgroundImageSize.height || DEFAULT_EDITOR_SIZE.height }
-			width={ backgroundImageSize.width || DEFAULT_EDITOR_SIZE.width }
-			naturalHeight={ backgroundImageSize.height }
-			naturalWidth={ backgroundImageSize.width }
+			height={ editorHeight }
+			width={ editorWidth }
+			naturalHeight={ editorHeight }
+			naturalWidth={ editorWidth }
 			onSaveImage={ ( { id, url }: { id: number; url: string } ) => {
 				setAttributes( { mediaId: id, mediaSrc: url } );
 			} }
@@ -169,12 +147,13 @@ export const withImageEditor =
 				? props.product
 				: props.category;

-		const { backgroundImageId, backgroundImageSrc } = useBackgroundImage( {
-			item,
-			mediaId,
-			mediaSrc,
-			blockName: name,
-		} );
+		const { backgroundImageId, backgroundImageSrc, originalImgDimension } =
+			useBackgroundImage( {
+				item,
+				mediaId,
+				mediaSrc,
+				blockName: name,
+			} );

 		if ( isEditingImage ) {
 			return (
@@ -185,7 +164,7 @@ export const withImageEditor =
 						backgroundImageSize={ backgroundImageSize }
 						backgroundImageSrc={ backgroundImageSrc }
 						containerRef={ ref }
-						isEditingImage={ isEditingImage }
+						originalImgDimension={ originalImgDimension }
 						setAttributes={ setAttributes }
 						setIsEditingImage={ setIsEditingImage }
 					/>
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/featured-items/test/image-editor.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/featured-items/test/image-editor.tsx
new file mode 100644
index 00000000000..021aa0391b8
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/featured-items/test/image-editor.tsx
@@ -0,0 +1,136 @@
+/* eslint-disable @wordpress/no-unsafe-wp-apis */
+
+/**
+ * External dependencies
+ */
+import { render } from '@testing-library/react';
+import type { ReactNode } from 'react';
+import {
+	__experimentalImageEditor as GutenbergImageEditor,
+	__experimentalImageEditingProvider as LegacyImageEditingProvider,
+} from '@wordpress/block-editor';
+
+/**
+ * Internal dependencies
+ */
+import { ImageEditor } from '../image-editor';
+
+jest.mock( '@wordpress/block-editor', () => ( {
+	...jest.requireActual( '@wordpress/block-editor' ),
+	__experimentalImageEditor: jest.fn( () => null ),
+	__experimentalImageEditingProvider: jest.fn(
+		( { children }: { children: ReactNode } ) => children
+	),
+} ) );
+
+const mockGutenbergImageEditor = GutenbergImageEditor as jest.Mock;
+const mockLegacyImageEditingProvider = LegacyImageEditingProvider as jest.Mock;
+
+describe( 'Featured Items image editor', () => {
+	beforeEach( () => {
+		jest.clearAllMocks();
+	} );
+
+	it( 'passes the complete editing contract to the merged image editor', () => {
+		const setAttributes = jest.fn();
+		const setIsEditingImage = jest.fn();
+		const container = document.createElement( 'div' );
+
+		render(
+			<ImageEditor
+				align="center"
+				backgroundImageId={ 42 }
+				backgroundImageSize={ { height: 640, width: 960 } }
+				backgroundImageSrc="https://example.com/product.jpg"
+				containerRef={ { current: container } }
+				originalImgDimension={ { height: 100, width: 200 } }
+				setAttributes={ setAttributes }
+				setIsEditingImage={ setIsEditingImage }
+			/>
+		);
+
+		expect( mockLegacyImageEditingProvider ).not.toHaveBeenCalled();
+		expect( mockGutenbergImageEditor ).toHaveBeenCalled();
+		const editorProps = mockGutenbergImageEditor.mock.lastCall?.[ 0 ];
+		expect( editorProps ).toEqual(
+			expect.objectContaining( {
+				id: 42,
+				url: 'https://example.com/product.jpg',
+				height: 640,
+				width: 960,
+				naturalHeight: 640,
+				naturalWidth: 960,
+				onSaveImage: expect.any( Function ),
+				onFinishEditing: expect.any( Function ),
+			} )
+		);
+
+		editorProps.onSaveImage( {
+			id: 84,
+			url: 'https://example.com/product-edited.jpg',
+		} );
+		editorProps.onFinishEditing();
+
+		expect( setAttributes ).toHaveBeenCalledWith( {
+			mediaId: 84,
+			mediaSrc: 'https://example.com/product-edited.jpg',
+		} );
+		expect( setIsEditingImage ).toHaveBeenCalledWith( false );
+	} );
+
+	// Repeated and parallax backgrounds render a <div>, never an <img>, so the
+	// measured size stays empty for as long as the block keeps that setting.
+	it( 'falls back to the off-screen measurement when no image was measured', () => {
+		render(
+			<ImageEditor
+				align="center"
+				backgroundImageId={ 42 }
+				backgroundImageSize={ {} }
+				backgroundImageSrc="https://example.com/product.jpg"
+				containerRef={ {
+					current: document.createElement( 'div' ),
+				} }
+				originalImgDimension={ { height: 640, width: 960 } }
+				setAttributes={ jest.fn() }
+				setIsEditingImage={ jest.fn() }
+			/>
+		);
+
+		const editorProps = mockGutenbergImageEditor.mock.lastCall?.[ 0 ];
+		expect( editorProps ).toEqual(
+			expect.objectContaining( {
+				height: 640,
+				width: 960,
+				naturalHeight: 640,
+				naturalWidth: 960,
+			} )
+		);
+	} );
+
+	it( 'uses the editor default only when no size has been measured yet', () => {
+		render(
+			<ImageEditor
+				align="center"
+				backgroundImageId={ 42 }
+				backgroundImageSize={ { height: 0, width: 0 } }
+				backgroundImageSrc="https://example.com/product.jpg"
+				containerRef={ {
+					current: document.createElement( 'div' ),
+				} }
+				originalImgDimension={ { height: 0, width: 0 } }
+				setAttributes={ jest.fn() }
+				setIsEditingImage={ jest.fn() }
+			/>
+		);
+
+		const editorProps = mockGutenbergImageEditor.mock.lastCall?.[ 0 ];
+		expect( editorProps ).toEqual(
+			expect.objectContaining( {
+				height: 500,
+				width: 500,
+				naturalHeight: 500,
+				naturalWidth: 500,
+			} )
+		);
+	} );
+} );