Commit 424b4c902fd for woocommerce

commit 424b4c902fd5a457be8ba6a99a37a0db37f9d778
Author: Albert Juhé Lluveras <contact@albertjuhe.com>
Date:   Fri Oct 9 19:38:46 2026 +0200

    Product Image: constrain padding to inner blocks (#69329)

    * Add changelog

    * Product Image: constrain padding to inner blocks

    * Use wp_style_engine_get_styles() instead of StyleAttributesUtils::get_classes_and_styles_by_attributes()

    * Fix Product Badge when its not an inner block

    * Expand tests

    * Simplify selectors

    * Prevent padding specific rules overriding the shorthand

    * Linting

diff --git a/plugins/woocommerce/changelog/fix-69279-product-image-padding b/plugins/woocommerce/changelog/fix-69279-product-image-padding
new file mode 100644
index 00000000000..4e48228b6c5
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-69279-product-image-padding
@@ -0,0 +1,4 @@
+Significance: patch
+Type: update
+
+Product Image: constrain padding to inner blocks
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/image/block.json b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/image/block.json
index f7475a39103..31ae61b2642 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/image/block.json
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/image/block.json
@@ -32,7 +32,8 @@
 		},
 		"spacing": {
 			"margin": true,
-			"padding": true
+			"padding": true,
+			"__experimentalSkipSerialization": [ "padding" ]
 		},
 		"dimensions": {
 			"aspectRatio": true,
@@ -41,7 +42,10 @@
 		"email": true
 	},
 	"selectors": {
-		"root": ".wp-block-woocommerce-product-image.wc-block-components-product-image, .wp-block-woocommerce-product-image .wc-block-components-product-image"
+		"root": ".wp-block-woocommerce-product-image.wc-block-components-product-image, .wp-block-woocommerce-product-image .wc-block-components-product-image",
+		"spacing": {
+			"padding": ".wp-block-woocommerce-product-image .wc-block-components-product-image__inner-container"
+		}
 	},
 	"ancestor": [
 		"woocommerce/all-products",
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/image/block.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/image/block.tsx
index 1b81f87a5f8..f3b58b96862 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/image/block.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/image/block.tsx
@@ -1,7 +1,7 @@
 /**
  * External dependencies
  */
-import { Fragment } from '@wordpress/element';
+import { Fragment, cloneElement, isValidElement } from '@wordpress/element';
 import { __, sprintf } from '@wordpress/i18n';
 import clsx from 'clsx';
 import { PLACEHOLDER_IMG_SRC, getSetting } from '@woocommerce/settings';
@@ -12,7 +12,7 @@ import {
 import { useStyleProps } from '@woocommerce/base-hooks';
 import { withProductDataContext } from '@woocommerce/shared-hocs';
 import { useStoreEvents } from '@woocommerce/base-context/hooks';
-import type { HTMLAttributes } from 'react';
+import type { CSSProperties, HTMLAttributes } from 'react';
 import { decodeEntities } from '@wordpress/html-entities';
 import { isEmpty, ProductResponseItem } from '@woocommerce/types';
 import { ProductEntityResponse } from '@woocommerce/entities';
@@ -35,6 +35,38 @@ const buildStyles = ( props: Partial< ImageProps > ) => {
 	};
 };

+/**
+ * Unset sides are omitted on purpose: assigning them as undefined clears a
+ * shorthand `padding` value when React writes the style.
+ */
+const getDefinedPaddingStyles = ( {
+	padding,
+	paddingTop,
+	paddingRight,
+	paddingBottom,
+	paddingLeft,
+}: CSSProperties ): CSSProperties => {
+	const paddingStyles: CSSProperties = {};
+
+	if ( padding !== undefined ) {
+		paddingStyles.padding = padding;
+	}
+	if ( paddingTop !== undefined ) {
+		paddingStyles.paddingTop = paddingTop;
+	}
+	if ( paddingRight !== undefined ) {
+		paddingStyles.paddingRight = paddingRight;
+	}
+	if ( paddingBottom !== undefined ) {
+		paddingStyles.paddingBottom = paddingBottom;
+	}
+	if ( paddingLeft !== undefined ) {
+		paddingStyles.paddingLeft = paddingLeft;
+	}
+
+	return paddingStyles;
+};
+
 const chooseImage = ( product: ProductResponseItem, imageId?: number ) => {
 	// Default to placeholder image if no product images are available.
 	if ( ! product.images.length ) {
@@ -170,6 +202,28 @@ export const Block = ( props: Props ): JSX.Element | null => {
 	} = props;

 	const styleProps = useStyleProps( props );
+	const {
+		padding,
+		paddingTop,
+		paddingRight,
+		paddingBottom,
+		paddingLeft,
+		...imageStyle
+	} = styleProps.style;
+	const innerBlocks = isValidElement< { style?: CSSProperties } >( children )
+		? cloneElement( children, {
+				style: {
+					...children.props.style,
+					...getDefinedPaddingStyles( {
+						padding,
+						paddingTop,
+						paddingRight,
+						paddingBottom,
+						paddingLeft,
+					} ),
+				},
+			} )
+		: children;
 	const { parentClassName } = useInnerBlockLayoutContext();
 	const { product, isLoading } = useProductDataContext( {
 		isAdmin,
@@ -213,11 +267,11 @@ export const Block = ( props: Props ): JSX.Element | null => {
 						},
 						styleProps.className
 					) }
-					style={ styleProps.style }
+					style={ imageStyle }
 				>
 					<ImagePlaceholder style={ imageStyles } />
 				</div>
-				{ children }
+				{ innerBlocks }
 			</>
 		);
 	}
@@ -258,7 +312,7 @@ export const Block = ( props: Props ): JSX.Element | null => {
 					},
 					styleProps.className
 				) }
-				style={ styleProps.style }
+				style={ imageStyle }
 			>
 				{ /* For backwards compatibility in All Products blocks. */ }
 				{ displayLegacySaleBadge( props ) && (
@@ -281,7 +335,7 @@ export const Block = ( props: Props ): JSX.Element | null => {
 					/>
 				</ParentComponent>
 			</div>
-			{ children }
+			{ innerBlocks }
 		</>
 	);
 };
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/image/style.scss b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/image/style.scss
index 63bbba957be..996ec3e5fa0 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/image/style.scss
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/image/style.scss
@@ -26,15 +26,17 @@
 		}
 	}

-	.wc-block-components-product-sale-badge {
-		&--align-left {
+	// Legacy badge added by the Product Image block. This selectors don't
+	// target the Product Badge when used as an inner block.
+	:where(.wc-block-components-product-sale-badge:not(.wc-block-components-product-image__inner-container *)) {
+		&.wc-block-components-product-sale-badge--align-left {
 			position: absolute;
 			left: $gap-smaller * 0.5;
 			top: $gap-smaller * 0.5;
 			right: auto;
 			margin: 0;
 		}
-		&--align-center {
+		&.wc-block-components-product-sale-badge--align-center {
 			position: absolute;
 			top: $gap-smaller * 0.5;
 			left: 50%;
@@ -42,7 +44,7 @@
 			transform: translateX(-50%);
 			margin: 0;
 		}
-		&--align-right {
+		&.wc-block-components-product-sale-badge--align-right {
 			position: absolute;
 			right: $gap-smaller * 0.5;
 			top: $gap-smaller * 0.5;
@@ -65,7 +67,7 @@
 	&__inner-container {
 		display: flex;
 		flex-direction: column;
-		padding: $gap-small;
+		padding: $gap-smaller * 0.5;
 		position: absolute;
 		bottom: 0;
 		left: 0;
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductImage.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductImage.php
index ed24b14043f..ec3c337aa1f 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductImage.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductImage.php
@@ -103,8 +103,17 @@ class ProductImage extends AbstractBlock {

 		$is_link = isset( $attributes['showProductLink'] ) ? $attributes['showProductLink'] : true;

+		$padding_styles = wp_style_engine_get_styles(
+			array(
+				'spacing' => array(
+					'padding' => $attributes['style']['spacing']['padding'] ?? null,
+				),
+			)
+		);
+
 		$inner_blocks_container = sprintf(
-			'<div class="wc-block-components-product-image__inner-container">%s</div>',
+			'<div class="wc-block-components-product-image__inner-container" style="%1$s">%2$s</div>',
+			esc_attr( isset( $padding_styles['css'] ) ? $padding_styles['css'] : '' ),
 			$inner_blocks_content
 		);

@@ -348,7 +357,7 @@ class ProductImage extends AbstractBlock {
 	 */
 	protected function render( $attributes, $content, $block ) {
 		$parsed_attributes     = $this->parse_attributes( $attributes );
-		$classes_and_styles    = StyleAttributesUtils::get_classes_and_styles_by_attributes( $attributes, array(), array( 'extra_classes' ) );
+		$classes_and_styles    = StyleAttributesUtils::get_classes_and_styles_by_attributes( $attributes, array(), array( 'extra_classes', 'padding' ) );
 		$post_id               = isset( $block->context['postId'] ) ? $block->context['postId'] : '';
 		$image_id              = isset( $block->context['imageId'] ) ? (int) $block->context['imageId'] : null;
 		$product               = wc_get_product( $post_id );
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductImage.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductImage.php
index 1b4fefddd9c..1ee078b9daf 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductImage.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductImage.php
@@ -320,13 +320,18 @@ class ProductImage extends \WP_UnitTestCase {
 	public function test_product_image_render_with_inner_blocks() {
 		$data = $this->create_product_with_image();

-		$markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $data['product']->get_id() . '} --><!-- wp:woocommerce/product-image --><div class="custom-inner-block">Custom content</div><!-- /wp:woocommerce/product-image --><!-- /wp:woocommerce/single-product -->' );
+		$markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $data['product']->get_id() . '} --><!-- wp:woocommerce/product-image {"style":{"spacing":{"padding":{"top":"22px"}}}} --><div class="custom-inner-block">Custom content</div><!-- /wp:woocommerce/product-image --><!-- /wp:woocommerce/single-product -->' );

 		$this->assertStringContainsString( 'wc-block-components-product-image', $markup );
-		$this->assertStringContainsString( 'wc-block-components-product-image__inner-container', $markup );
+		$this->assertStringContainsString( 'wc-block-components-product-image__inner-container" style="padding-top:22px;"', $markup );
 		$this->assertStringContainsString( 'custom-inner-block', $markup );
 		$this->assertStringContainsString( 'Custom content', $markup );

+		$markup_before_inner_container = strstr( $markup, 'wc-block-components-product-image__inner-container', true );
+
+		// Verify padding is not added to the wrapper.
+		$this->assertStringNotContainsString( 'padding-top:22px', $markup_before_inner_container );
+
 		// Clean up.
 		$data['product']->delete( true );
 		wp_delete_attachment( $data['image_id'], true );