Commit 69ac95a31ab for woocommerce

commit 69ac95a31abd9a6e058643b78ef18f40a362457c
Author: Thomas Roberts <5656702+opr@users.noreply.github.com>
Date:   Fri Mar 20 14:05:17 2026 +0000

    Add new thumbnail srcset and sizes properties to ImageAttachmentSchema (#63731)

    * Fix Store API image srcset using wrong size for candidate generation

    wp_get_attachment_image_srcset with 'full' produces candidates matching
    the full image's aspect ratio. Since thumbnail is a square crop,
    the square candidates (150x150, 300x300) were excluded from srcset,
    making it useless for consumers that render the square thumbnail.

    Switch to 'woocommerce_thumbnail' so srcset candidates share the
    same aspect ratio as the thumbnail that's actually displayed.

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

    * Add thumbnail_srcset and thumbnail_sizes to Store API image schema

    The existing srcset/sizes fields use 'full' as the reference size,
    producing candidates that match the full image's aspect ratio. Since
    the thumbnail is a square crop, these candidates have a different
    aspect ratio and are not useful for contexts that render the thumbnail.

    Add separate thumbnail_srcset and thumbnail_sizes fields generated
    from 'woocommerce_thumbnail', so consumers displaying thumbnails
    (cart, mini-cart, checkout) get srcset candidates with matching
    aspect ratios. The original srcset/sizes fields remain unchanged
    for consumers that display full-size images (product gallery).

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

    * Add thumbnail_srcset and thumbnail_sizes to cart image types

    Update CartImageItem, CartResponseImageItem, and CartItemImage JSDoc
    to include the new fields from the Store API.

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

    * Wire thumbnail_srcset into ProductImage for cart and checkout blocks

    Pass thumbnail_srcset through to the img element and compute sizes
    from the display width prop so the browser picks an appropriately
    sized candidate. Pass explicit 80x80 dimensions in the cart block
    and the checkout already passes 48x48.

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

    * Wire thumbnail_srcset into mini-cart block images

    Add itemSrcset and itemSizes getters using thumbnail_srcset and bind
    them to img elements in the mini-cart PHP template. Uses sizes=64px
    to match the mini-cart's display size.

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

    * Fix lint issue

    * Add changefile(s) from automation for the following project(s): woocommerce

    ---------

    Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/63731-fix-image-srcsets b/plugins/woocommerce/changelog/63731-fix-image-srcsets
new file mode 100644
index 00000000000..a745627a6eb
--- /dev/null
+++ b/plugins/woocommerce/changelog/63731-fix-image-srcsets
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add smaller image options for product images in srcset to reduce bandwidth/load time on cart/checkout pages
\ No newline at end of file
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/cart-line-items-table/cart-line-item-row.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/cart-line-items-table/cart-line-item-row.tsx
index abb2ff78164..a05ab74801f 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/cart-line-items-table/cart-line-item-row.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/cart-line-items-table/cart-line-item-row.tsx
@@ -232,12 +232,16 @@ const CartLineItemRow: React.ForwardRefExoticComponent<
 						<ProductImage
 							image={ firstImage }
 							fallbackAlt={ name }
+							width={ 80 }
+							height={ 80 }
 						/>
 					) : (
 						<a href={ permalink } tabIndex={ -1 }>
 							<ProductImage
 								image={ firstImage }
 								fallbackAlt={ name }
+								width={ 80 }
+								height={ 80 }
 							/>
 						</a>
 					) }
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/product-image/index.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/product-image/index.tsx
index af6dc1ad95d..a0d84d8f8ba 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/product-image/index.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/product-image/index.tsx
@@ -5,7 +5,12 @@ import { decodeEntities } from '@wordpress/html-entities';
 import { PLACEHOLDER_IMG_SRC } from '@woocommerce/settings';

 interface ProductImageProps {
-	image: { alt?: string; thumbnail?: string };
+	image: {
+		alt?: string;
+		thumbnail?: string;
+		thumbnail_srcset?: string;
+		thumbnail_sizes?: string;
+	};
 	fallbackAlt: string;
 	width?: number;
 	height?: number;
@@ -25,20 +30,33 @@ const ProductImage = ( {
 }: ProductImageProps ): JSX.Element => {
 	const rawAlt = image.alt || fallbackAlt;

+	// Use display width for sizes so the browser picks an appropriately
+	// sized source from the thumbnail srcset.
+	let sizesAttr;
+	if ( image.thumbnail_srcset ) {
+		sizesAttr = width ? `${ width }px` : '100px';
+	}
+
 	const imageProps = image.thumbnail
 		? {
 				src: image.thumbnail,
 				alt: rawAlt ? decodeEntities( rawAlt ) : 'Product Image',
+				srcSet: image.thumbnail_srcset || undefined,
+				sizes: sizesAttr,
 		  }
 		: {
 				src: PLACEHOLDER_IMG_SRC,
 				alt: '',
+				srcSet: undefined,
+				sizes: undefined,
 		  };

 	return (
 		<img
 			src={ imageProps.src }
 			alt={ imageProps.alt }
+			srcSet={ imageProps.srcSet }
+			sizes={ imageProps.sizes }
 			width={ width }
 			height={ height }
 		/>
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/iapi-frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/iapi-frontend.ts
index dbf28c9a39e..c09d3a202d8 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/iapi-frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/iapi-frontend.ts
@@ -594,6 +594,18 @@ const { state: cartItemState } = store(
 				);
 			},

+			get itemSrcset(): string {
+				return (
+					cartItemState.cartItem.images[ 0 ]?.thumbnail_srcset || ''
+				);
+			},
+
+			get itemSizes(): string {
+				return cartItemState.cartItem.images[ 0 ]?.thumbnail_srcset
+					? '64px'
+					: '';
+			},
+
 			get priceWithoutDiscount(): string {
 				const { raw_prices: rawPrices } = cartItemState.cartItem.prices;
 				const priceWithoutDiscount = scalePrice( {
diff --git a/plugins/woocommerce/client/blocks/assets/js/types/type-defs/cart-response.ts b/plugins/woocommerce/client/blocks/assets/js/types/type-defs/cart-response.ts
index 1fbace3e932..8f8d652e95e 100644
--- a/plugins/woocommerce/client/blocks/assets/js/types/type-defs/cart-response.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/types/type-defs/cart-response.ts
@@ -86,6 +86,8 @@ export interface CartResponseImageItem {
 	thumbnail: string;
 	srcset: string;
 	sizes: string;
+	thumbnail_srcset: string;
+	thumbnail_sizes: string;
 	name: string;
 	alt: string;
 }
diff --git a/plugins/woocommerce/client/blocks/assets/js/types/type-defs/cart.js b/plugins/woocommerce/client/blocks/assets/js/types/type-defs/cart.js
index 514168bb373..67149a1bf0c 100644
--- a/plugins/woocommerce/client/blocks/assets/js/types/type-defs/cart.js
+++ b/plugins/woocommerce/client/blocks/assets/js/types/type-defs/cart.js
@@ -17,8 +17,10 @@
  * @property {number} id        Image id.
  * @property {string} src       Full size image URL.
  * @property {string} thumbnail Thumbnail URL.
- * @property {string} srcset    Thumbnail srcset for responsive image.
- * @property {string} sizes     Thumbnail sizes for responsive images.
+ * @property {string} srcset           Full size image srcset for responsive images.
+ * @property {string} sizes            Full size image sizes for responsive images.
+ * @property {string} thumbnail_srcset Thumbnail srcset for responsive images.
+ * @property {string} thumbnail_sizes  Thumbnail sizes for responsive images.
  * @property {string} name      Image name.
  * @property {string} alt       Image alternative text.
  */
diff --git a/plugins/woocommerce/client/blocks/assets/js/types/type-defs/cart.ts b/plugins/woocommerce/client/blocks/assets/js/types/type-defs/cart.ts
index 97287d3bda9..dc2576d53d0 100644
--- a/plugins/woocommerce/client/blocks/assets/js/types/type-defs/cart.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/types/type-defs/cart.ts
@@ -93,6 +93,8 @@ export interface CartImageItem {
 	thumbnail: string;
 	srcset: string;
 	sizes: string;
+	thumbnail_srcset: string;
+	thumbnail_sizes: string;
 	name: string;
 	alt: string;
 }
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/MiniCartProductsTableBlock.php b/plugins/woocommerce/src/Blocks/BlockTypes/MiniCartProductsTableBlock.php
index 050c626ca40..09c2a19a24f 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/MiniCartProductsTableBlock.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/MiniCartProductsTableBlock.php
@@ -119,8 +119,10 @@ class MiniCartProductsTableBlock extends AbstractInnerBlock {
 							<td data-wp-context='{ "isImageHidden": false }' class="wc-block-cart-item__image" aria-hidden="true">
 								<img
 									data-wp-bind--hidden="!state.isProductHiddenFromCatalog"
-									data-wp-bind--src="state.itemThumbnail"
+									data-wp-bind--src="state.itemThumbnail"
 									data-wp-bind--alt="state.cartItemName"
+									data-wp-bind--srcset="state.itemSrcset"
+									data-wp-bind--sizes="state.itemSizes"
 									data-wp-on--error="actions.hideImage"
 								>
 								<a data-wp-bind--hidden="state.isProductHiddenFromCatalog" data-wp-bind--href="state.cartItem.permalink" tabindex="-1">
@@ -128,6 +130,8 @@ class MiniCartProductsTableBlock extends AbstractInnerBlock {
 										data-wp-bind--hidden="context.isImageHidden"
 										data-wp-bind--src="state.itemThumbnail"
 										data-wp-bind--alt="state.cartItemName"
+										data-wp-bind--srcset="state.itemSrcset"
+										data-wp-bind--sizes="state.itemSizes"
 										data-wp-on--error="actions.hideImage"
 									>
 								</a>
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/ImageAttachmentSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/ImageAttachmentSchema.php
index 30b0465962e..e686b47ebf8 100644
--- a/plugins/woocommerce/src/StoreApi/Schemas/V1/ImageAttachmentSchema.php
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/ImageAttachmentSchema.php
@@ -26,39 +26,49 @@ class ImageAttachmentSchema extends AbstractSchema {
 	 */
 	public function get_properties() {
 		return [
-			'id'        => [
+			'id'               => [
 				'description' => __( 'Image ID.', 'woocommerce' ),
 				'type'        => 'integer',
 				'context'     => [ 'view', 'edit', 'embed' ],
 			],
-			'src'       => [
+			'src'              => [
 				'description' => __( 'Full size image URL.', 'woocommerce' ),
 				'type'        => 'string',
 				'format'      => 'uri',
 				'context'     => [ 'view', 'edit', 'embed' ],
 			],
-			'thumbnail' => [
+			'thumbnail'        => [
 				'description' => __( 'Thumbnail URL.', 'woocommerce' ),
 				'type'        => 'string',
 				'format'      => 'uri',
 				'context'     => [ 'view', 'edit', 'embed' ],
 			],
-			'srcset'    => [
+			'srcset'           => [
+				'description' => __( 'Full size image srcset for responsive images.', 'woocommerce' ),
+				'type'        => 'string',
+				'context'     => [ 'view', 'edit', 'embed' ],
+			],
+			'sizes'            => [
+				'description' => __( 'Full size image sizes for responsive images.', 'woocommerce' ),
+				'type'        => 'string',
+				'context'     => [ 'view', 'edit', 'embed' ],
+			],
+			'thumbnail_srcset' => [
 				'description' => __( 'Thumbnail srcset for responsive images.', 'woocommerce' ),
 				'type'        => 'string',
 				'context'     => [ 'view', 'edit', 'embed' ],
 			],
-			'sizes'     => [
+			'thumbnail_sizes'  => [
 				'description' => __( 'Thumbnail sizes for responsive images.', 'woocommerce' ),
 				'type'        => 'string',
 				'context'     => [ 'view', 'edit', 'embed' ],
 			],
-			'name'      => [
+			'name'             => [
 				'description' => __( 'Image name.', 'woocommerce' ),
 				'type'        => 'string',
 				'context'     => [ 'view', 'edit', 'embed' ],
 			],
-			'alt'       => [
+			'alt'              => [
 				'description' => __( 'Image alternative text.', 'woocommerce' ),
 				'type'        => 'string',
 				'context'     => [ 'view', 'edit', 'embed' ],
@@ -86,13 +96,15 @@ class ImageAttachmentSchema extends AbstractSchema {
 		$thumbnail = wp_get_attachment_image_src( $attachment_id, 'woocommerce_thumbnail' );

 		return (object) [
-			'id'        => (int) $attachment_id,
-			'src'       => current( $attachment ),
-			'thumbnail' => current( $thumbnail ),
-			'srcset'    => (string) wp_get_attachment_image_srcset( $attachment_id, 'full' ),
-			'sizes'     => (string) wp_get_attachment_image_sizes( $attachment_id, 'full' ),
-			'name'      => get_the_title( $attachment_id ),
-			'alt'       => get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ),
+			'id'               => (int) $attachment_id,
+			'src'              => current( $attachment ),
+			'thumbnail'        => current( $thumbnail ),
+			'srcset'           => (string) wp_get_attachment_image_srcset( $attachment_id, 'full' ),
+			'sizes'            => (string) wp_get_attachment_image_sizes( $attachment_id, 'full' ),
+			'thumbnail_srcset' => (string) wp_get_attachment_image_srcset( $attachment_id, 'woocommerce_thumbnail' ),
+			'thumbnail_sizes'  => (string) wp_get_attachment_image_sizes( $attachment_id, 'woocommerce_thumbnail' ),
+			'name'             => get_the_title( $attachment_id ),
+			'alt'              => get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ),
 		];
 	}