Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/wild-rabbits-reflect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/hydrogen': patch
---

[#1245] - Generate a default srcset for an image returned by the Shopify CDN on the Image component and allow using a custom set of `widths.`
1 change: 1 addition & 0 deletions docs/components/primitive/image.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export default function ExternalImageWithLoader() {
| height | <code>height &#124; string</code> | The integer or string value for the height of the image. This is a required prop when `src` is present. |
| loader? | <code>(props: ShopifyLoaderParams &#124; LoaderOptions) => string</code> | A custom function that generates the image URL. Parameters passed in are either `ShopifyLoaderParams` if using the `data` prop, or the `LoaderOptions` object that you pass to `loaderOptions`. |
| loaderOptions? | <code>ShopifyLoaderOptions &#124; LoaderOptions</code> | An object of `loader` function options. For example, if the `loader` function requires a `scale` option, then the value can be a property of the `loaderOptions` object (for example, `{scale: 2}`). When the `data` prop is used, the object shape will be `ShopifyLoaderOptions`. When the `src` prop is used, the data shape is whatever you define it to be, and this shape will be passed to `loader`. |
| widths? | <code>(number &#124; string)[]</code> | An array of pixel widths to overwrite the default generated srcset. For example, `[300, 600, 800]`. It only Applies to images from the Shopify CDN.

## Component type

Expand Down
71 changes: 70 additions & 1 deletion packages/hydrogen/src/components/Image/Image.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import * as React from 'react';
import {getShopifyImageDimensions, shopifyImageLoader} from '../../utilities';
import {
getShopifyImageDimensions,
shopifyImageLoader,
addImageSizeParametersToUrl,
} from '../../utilities';
import type {Image as ImageType} from '../../storefront-api-types';
import type {PartialDeep, Simplify, SetRequired} from 'type-fest';

Expand Down Expand Up @@ -62,6 +66,10 @@ export type ShopifyImageProps = Omit<HtmlImageProps, 'src'> & {
* 'src' shouldn't be passed when 'data' is used.
*/
src?: never;
/**
* An array of pixel widths to overwrite the default generated srcset. For example, `[300, 600, 800]`.
*/
widths?: (HtmlImageProps['width'] | ImageType['width'])[];
};

function ShopifyImage({
Expand All @@ -71,6 +79,7 @@ function ShopifyImage({
loading,
loader = shopifyImageLoader,
loaderOptions,
widths,
...rest
}: ShopifyImageProps) {
if (!data.url) {
Expand Down Expand Up @@ -107,6 +116,20 @@ function ShopifyImage({
});
}

// determining what the intended width of the image is. For example, if the width is specified and lower than the image width, then that is the maximum image width
// to prevent generating a srcset with widths bigger than needed or to generate images that would distort because of being larger than original
const maxWidth =
width && finalWidth && width < finalWidth ? width : finalWidth;
const finalSrcset =
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably do something similar to this for the ExternalImage component as well. We'd probably need to check for if there's a loader prop or not, too.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably do something similar to this for the ExternalImage component as well. We'd probably need to check for if there's a loader prop or not, too.

It might be complicated for external images since we can't know how their CDN manages search parameters. We can explore this or have a curated list of CDN that we can support.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I was trying to say that on ExternalImage we should also do something like

const finalSrcset = rest.srcSet ?? (loader && widths)? widths.map(width => loader(...)) : null

if that makes sense?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is now a srcset generated for ExternalImage that has a loader and widths prop.

/** External images */

let finalSrcset = rest.srcSet ?? undefined;

  if (!finalSrcset && loader && widths) {
    // Height is a requirement in the LoaderProps, so  to keep the aspect ratio, we must determine the height based on the default values
    const heightToWidthRatio =
      parseInt(height as string) / parseInt(width as string);
    finalSrcset = widths
      ?.map((width) => parseInt(width as string, 10))
      ?.map(
        (width) =>
          `${loader({
            ...loaderOptions,
            src,
            width,
            height: Math.floor(width * heightToWidthRatio),
          })} ${width}w`
      )
      .join(', ');
  }

  /* eslint-disable hydrogen/prefer-image-component */
  return (
    <img
      {...rest}
      src={finalSrc}
      width={width}
      height={height}
      alt={alt ?? ''}
      loading={loading ?? 'lazy'}
      srcSet={finalSrcset}
    />
  );

rest.srcSet ??
internalImageSrcSet({
...loaderOptions,
widths,
src: data.url,
width: maxWidth,
loader,
});

/* eslint-disable hydrogen/prefer-image-component */
return (
<img
Expand All @@ -117,6 +140,7 @@ function ShopifyImage({
src={finalSrc}
width={finalWidth ?? undefined}
height={finalHeight ?? undefined}
srcSet={finalSrcset}
/>
);
/* eslint-enable hydrogen/prefer-image-component */
Expand Down Expand Up @@ -213,3 +237,48 @@ function ExternalImage<GenericLoaderOpts>({
);
/* eslint-enable hydrogen/prefer-image-component */
}

type InternalShopifySrcSetGeneratorsParams = Simplify<
ShopifyLoaderOptions & {
src: ImageType['url'];
widths?: (HtmlImageProps['width'] | ImageType['width'])[];
loader?: (params: ShopifyLoaderParams) => string;
}
>;
// based on the default width sizes used by the Shopify liquid HTML tag img_tag plus a 2560 width to account for 2k resolutions
// reference: https://shopify.dev/api/liquid/filters/html-filters#image_tag
const IMG_SRC_SET_SIZES = [352, 832, 1200, 1920, 2560];
function internalImageSrcSet({
src,
width,
crop,
scale,
widths,
loader,
}: InternalShopifySrcSetGeneratorsParams) {
const hasCustomWidths = widths && Array.isArray(widths);
if (hasCustomWidths && widths.some((size) => isNaN(size as number)))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I love this check. 👍

throw new Error(
`<Image/>: the 'widths' property of 'ShopifyLoaderOptions' must be an array of numbers`
);

let setSizes = hasCustomWidths ? widths : IMG_SRC_SET_SIZES;
if (
!hasCustomWidths &&
width &&
width < IMG_SRC_SET_SIZES[IMG_SRC_SET_SIZES.length - 1]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this check to make it so that there isn't a srcset option that has a width wider than the set width of the Image? Would it work the same if you removed this from the if statement and still filtered out the srcset on the line below?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this check to make it so that there isn't a srcset option that has a width wider than the set width of the Image? Would it work the same if you removed this from the if statement and still filtered out the srcset on the line below?

Yes, that is correct, srcsets are kept within the max image width to prevent distortion and poor quality images.

It would work the same without the if check, but I opted to check because the filter method returns a new array, and I wanted to avoid using extra memory if it's not needed. This optimization is minor. The computation and memory cost is negligible. PLP or pages with multiple images would benefit from this check. I can be convinced to remove it.

)
setSizes = IMG_SRC_SET_SIZES.filter((size) => size <= width);
const srcGenerator = loader ? loader : addImageSizeParametersToUrl;
return setSizes
.map(
(size) =>
`${srcGenerator({
src,
width: size,
crop,
scale,
})} ${size}w`
)
.join(', ');
}
17 changes: 17 additions & 0 deletions packages/hydrogen/src/components/Image/tests/Image.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,23 @@ describe('<Image />', () => {
alt: 'Fancy image',
});
});

it('generates a default srcset', () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you duplicate this test but instead use src instead of data?

const mockUrl = 'https://cdn.shopify.com/someimage.jpg';
const sizes = [352, 832, 1200, 1920, 2560];
const expectedSrcset = sizes
.map((size) => `${mockUrl}?width=${size} ${size}w`)
.join(', ');
const image = getPreviewImage({
url: mockUrl,
});

const component = mount(<Image data={image} />);

expect(component).toContainReactComponent('img', {
srcSet: expectedSrcset,
});
});
});

describe('External image', () => {
Expand Down