Skip to content

Commit

Permalink
Generate a default srcset for an image returned by the Shopify CDN (#…
Browse files Browse the repository at this point in the history
…1330)

* feat: add default img srcset to Shopify images

* feat: add custom widths to Image srcset

* doc: add srcset documentation

* doc: add change set

* feat: lift widths prop to Image level

* feat: use the available loader to generate srcset

* feat:reduce srcset to max-width without distortion

* test: add default img srcset test

* Update packages/hydrogen/src/components/Image/Image.tsx

Co-authored-by: Michelle Vinci <[email protected]>

* doc: update Image docs with widths prop changes

* feat: generate srcset for extarnal images

* Update docs/components/primitive/image.md

Co-authored-by: Michelle Vinci <[email protected]>

* test: add test for images using src

Co-authored-by: Michelle Vinci <[email protected]>
Co-authored-by: Anthony Frehner <[email protected]>
  • Loading branch information
3 people authored Jun 7, 2022
1 parent ed1586a commit c7dc644
Show file tree
Hide file tree
Showing 4 changed files with 172 additions and 1 deletion.
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 Shopify CDN.

## Component type

Expand Down
103 changes: 102 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 @@ -116,6 +125,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 =
rest.srcSet ??
internalImageSrcSet({
...loaderOptions,
widths,
src: data.url,
width: maxWidth,
loader,
});

/* eslint-disable hydrogen/prefer-image-component */
return (
<img
Expand All @@ -126,6 +149,7 @@ function ShopifyImage({
src={finalSrc}
width={finalWidth ?? undefined}
height={finalHeight ?? undefined}
srcSet={finalSrcset}
/>
);
/* eslint-enable hydrogen/prefer-image-component */
Expand Down Expand Up @@ -176,6 +200,10 @@ type ExternalImageProps<GenericLoaderOpts> = SetRequired<
* 'data' shouldn't be passed when 'src' is used.
*/
data?: never;
/**
* An array of pixel widths to generate a srcset. For example, `[300, 600, 800]`.
*/
widths?: HtmlImageProps['width'][];
};

function ExternalImage<GenericLoaderOpts>({
Expand All @@ -185,6 +213,7 @@ function ExternalImage<GenericLoaderOpts>({
alt,
loader,
loaderOptions,
widths,
loading,
...rest
}: ExternalImageProps<GenericLoaderOpts>) {
Expand All @@ -200,6 +229,15 @@ function ExternalImage<GenericLoaderOpts>({
);
}

if (
widths &&
Array.isArray(widths) &&
widths.some((size) => isNaN(size as number))
)
throw new Error(
`<Image/>: the 'widths' property must be an array of numbers`
);

let finalSrc = src;

if (loader) {
Expand All @@ -208,6 +246,25 @@ function ExternalImage<GenericLoaderOpts>({
throw new Error(`<Image/>: 'loader' did not return a valid string`);
}
}
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 (
Expand All @@ -218,7 +275,51 @@ function ExternalImage<GenericLoaderOpts>({
height={height}
alt={alt ?? ''}
loading={loading ?? 'lazy'}
srcSet={finalSrcset}
/>
);
/* 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)))
throw new Error(`<Image/>: the 'widths' 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]
)
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(', ');
}
64 changes: 64 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', () => {
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 Expand Up @@ -281,6 +298,53 @@ describe('<Image />', () => {
alt: 'Fancy image',
});
});

it('generates a srcset when a loader and a widths prop are provided', () => {
const mockUrl = 'https://cdn.externalImg.com/someimage.jpg';
const sizes = [352, 832, 1200];
const loaderOptions = {
width: 600,
height: 800,
scale: 1,
};

const loader = (loaderOptions: {
src: string;
width: number;
height: number;
scale: number;
}): string =>
`${loaderOptions.src}?w=${loaderOptions.width}&h=${loaderOptions.height}`;

const heightToWidthRatio = loaderOptions.height / loaderOptions.width;
const expectedSrcset = sizes
.map(
(size) =>
`${loader({
src: mockUrl,
width: size,
height: Math.floor(size * heightToWidthRatio),
scale: loaderOptions.scale,
})} ${size}w`
)
.join(', ');

const component = mount(
<Image
src={mockUrl}
loader={loader as any}
loaderOptions={loaderOptions}
widths={sizes}
width={loaderOptions.width}
height={loaderOptions.height}
alt={'Fancy image'}
/>
);

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

// eslint-disable-next-line jest/expect-expect
Expand Down

0 comments on commit c7dc644

Please sign in to comment.