Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Images Preloading #2061

Draft
wants to merge 6 commits into
base: main
Choose a base branch
from
Draft

Images Preloading #2061

wants to merge 6 commits into from

Conversation

juanpprieto
Copy link
Contributor

@juanpprieto juanpprieto commented Apr 30, 2024

Hydrogen Image Preloading

Preloading above-the-fold content has a powerful effect on Largest Contentful Paint (LCP). Specially when it comes to fonts and images, as both images and text nodes can be LCP candidates. Hero images and large runs of text that are rendered using web fonts can benefit significantly from a well-placed preload hint, and should be used when there are opportunities to deliver these important bits of content to the user faster.

Current situation

Hydrogen currently does not offer a native abstraction, example or guide on how to effectively implement image preloading. Although Remix offers all the right primitives, developers have to go out of their way to recognize the importance and implementation details of preloading critical resources to improve performance.

preload.mp4

👎🏼 Main homepage hero image not preloaded

Screenshot 2024-04-30 at 10 38 18 AM

👍🏼 Main homepage hero image preloaded in the homepage

Screenshot 2024-04-30 at 10 35 34 AM

Example generated tag for skeleton's home route.

Screenshot 2024-04-30 at 10 35 56 AM

Proposal

The follow driven-development doc, demonstrates a proposal to facilitate images preloading.

Note

A separate proposal will be made for handling web fonts and other critical resources

Code Changes

If we prefer to keep this as userland feature we should export hydrogen-react Image component inner utilities within @shopify/hydrogen

Utility Function
generateImageWidths Generates an array of sizes for Shopify images, for both fixed and responsive images.
generateSizes Generates an array of widths, heights and crops needed for Imagery loader
generateSrcSet Generates a srcSet for Shopify images based on an array of sizes
shopifyLoader Appends a width, height and crop url query params to a given Shopify CDN url

Otherwise, we can export the genPreloadImageLinkMeta from hydrogen which internally uses the Image utilities

// src/genPreloadImageLinkMeta.ts in hydrogen-react
import {
  generateImageWidths,
  generateSizes,
  generateSrcSet,
  shopifyLoader,
} from '@shopify/hydrogen';

type GenerateImageWidthsParams = Parameters<typeof generateImageWidths>;
type GenerateSizesParams = Parameters<typeof generateSizes>;

type GenPreloadImageLinkMetaProps = {
  /* The URL of the image to generate the srcset for */
  url: string;
  /* The width of the image to generate the srcset for */
  width?: GenerateImageWidthsParams[0];
  // The step parameters to use for generating the image widths array
  srcSet?: {
    interval: GenerateImageWidthsParams[1];
    startingWidth: GenerateImageWidthsParams[2];
    incrementSize: GenerateImageWidthsParams[3];
  };
  // The aspect ratio and crop to use for the image sizes
  sizes?: {
    aspectRatio: GenerateSizesParams[1];
    crop: GenerateSizesParams[2];
  };
  /** An optional loader function to use for generating the image URLs based on a given CDN */
  loader?: typeof shopifyLoader;
};

const defaultProps = {
  url: '',
  width: '100%',
  srcSet: {interval: 15, startingWidth: 200, incrementSize: 200},
  sizes: {aspectRatio: '1/1', crop: 'center'},
  loader: shopifyLoader,
} as const;

/**
 * Generates a link meta tag for preloading an image with a srcset
 * @param {GenPreloadImageLinkMetaProps} props - The props to generate the preload link meta tag
 * @returns {object} - The link meta tag object
 * @example
 * Basic usage with default <Image /> component props:
 * ```
 * const heroImageLink = genPreloadImageLinkMeta({
 *  url: 'https://cdn.shopify.com/s/files/1/0000/0000/0000/files/hero.jpg',
 * });
 * ```
 *
 * @example
 * Usage with custom `width` set in the <Image /> component props:
 * ```
 * const heroImageLink = genPreloadImageLinkMeta({
 *  url: 'https://cdn.shopify.com/s/files/1/0000/0000/0000/files/hero.jpg',
 *  width: '(min-width: 45em) 50vw, 100vw',
 * });
 * ```
 */
export function genPreloadImageLinkMeta({
  url,
  width = defaultProps.width,
  srcSet = defaultProps.srcSet,
  sizes = defaultProps.sizes,
  loader = shopifyLoader,
}: GenPreloadImageLinkMetaProps) {
  // Assign default values if not provided
  const interval = srcSet?.interval ?? defaultProps?.srcSet?.interval ?? 15;
  const startingWidth =
    srcSet?.startingWidth ?? defaultProps.srcSet.startingWidth ?? 200;
  const incrementSize =
    srcSet?.incrementSize ?? defaultProps.srcSet.incrementSize ?? 200;
  const aspectRatio =
    sizes?.aspectRatio ?? defaultProps.sizes.aspectRatio ?? '1/1';
  const crop = sizes?.crop ?? defaultProps.sizes.crop ?? 'center';
  const activeLoader = loader ?? defaultProps.loader;

  const widths = generateImageWidths(
    width,
    interval,
    startingWidth,
    incrementSize,
  );
  const imagesizes = generateSizes(widths, aspectRatio, crop);
  const imagesrcset = generateSrcSet(url, imagesizes, activeLoader);

  return {
    as: 'image',
    href: url,
    imagesrcset,
    rel: 'preload',
    imagesizes: width,
    tagName: 'link',
  };
}

Implementation

On each applicable route, import the genPreloadImageLinkMeta utility and call it if a preload prop is present
in the loader return

Preloading product featured image

// app/routes/product.$handle.tsx

+ import {genPreloadImageLinkMeta} from '@shopify/hydrogen'

export const meta: MetaFunction<typeof loader> = ({data}) => {
  const metas = [
    {title: `Hydrogen | ${data?.product.title ?? ''}`},
  ] as MetaDescriptor[];

+  if (data?.product?.selectedVariant?.image) {
+    const preloadImageLink = genPreloadImageLinkMeta({
+      url: data?.product.selectedVariant.image.url,
+      width: '(min-width: 45em) 50vw, 100vw',
+    });
+    metas.push(preloadImageLink);
+  }

  return metas;
};

Preloading the first 4 product images in collections

// app/routes/collections.$handle.tsx

+ import {genPreloadImageLinkMeta} from '@shopify/hydrogen'

export const meta: MetaFunction<typeof loader> = ({data}) => {
  const metas = [
    {title: `Hydrogen | ${data?.collection.title ?? 'Collection'}`},
  ] as MetaDescriptor[];

+  const hasProducts = Number(data?.collection?.products?.nodes?.length) > 0;
+  if (hasProducts) {
+    // Preload the first 4 product images
+    for (const node of data?.collection?.products?.nodes.slice(0, 4) ?? []) {
+      if (!node.featuredImage) continue;
+      const preloadImageLink = genPreloadImageLinkMeta({
+        url: node.featuredImage.url,
+        width: '(min-width: 45em) 400px, 100vw',
+      });
+      metas.push(preloadImageLink);
+    }
+  }

  return metas;
};

[!NOTE] Repeat for any other routes requiring it such as index (main Hero)

Additional Reading

Copy link
Contributor

shopify bot commented Apr 30, 2024

Oxygen deployed a preview of your juanpprieto/preload-images branch. Details:

Storefront Status Preview link Deployment details Last update (UTC)
subscriptions ✅ Successful (Logs) Preview deployment Inspect deployment May 6, 2024 6:57 PM
custom-cart-method ✅ Successful (Logs) Preview deployment Inspect deployment May 6, 2024 6:57 PM
third-party-queries-caching ✅ Successful (Logs) Preview deployment Inspect deployment May 6, 2024 6:57 PM
vite ✅ Successful (Logs) Preview deployment Inspect deployment May 6, 2024 6:57 PM
optimistic-cart-ui - (Logs) - Inspect deployment May 14, 2024 6:11 PM
Skeleton (skeleton.hydrogen.shop) - (Logs) - Inspect deployment May 14, 2024 5:53 AM

Learn more about Hydrogen's GitHub integration.

@frandiox
Copy link
Contributor

frandiox commented May 1, 2024

For the sake of brainstorming, here's a different idea that we could test:

What about using the Link header for preloading instead? I think it would be easier to integrate, especially when using headers in Remix' Single Fetch:

export async function loader({request, response, context}: LoaderFunctionArgs) {
   // ...other code
+  response.headers.set('Link', genPreloadImageLinkHeader({url: product.selectedVariant.image}))
   return {product,variants};
}

Or maybe the utility direclty gets the response object and ensures we append the Link properly:

export async function loader({request, response, context}: LoaderFunctionArgs) {
   // ...other code
+  addPreloadImageLinkHeader(response, [{url: product.selectedVariant.image}, ...])
   return {product, variants};
}

Therefore, no need to return the image from the loader and use it in a separate function.

There's also a (probably very) minor perf improvement, since the browser can see the preloading links before parsing the HTML.

Also, I think this would make it work not only for SSR, but for sub-sequent navigations when Remix returns loader data with their custom JSON format... since it's a header now it should be added independently from the content-type. Which means that it would also start preloading images on link hover. -- But this is just theory, I haven't tested if this works 🤔


Any downsides to this? 🤔

@juanpprieto
Copy link
Contributor Author

juanpprieto commented May 1, 2024

Interesting take @frandiox

I initially tried using the Link header but AFAIK it does not support imagesrcset (to handle responsive images) which is critical in our use case, because our Image component sets them by default. e.g we don't know that far in advance what image source the browser will need for the given viewport.

Even if it did support sourcesets, I'd be worried it might break the response because it would be easy to exceed the header limits as the srcsets we are defaulting to are pretty extensive (see resulting link tag element on the screenshots above) for a single image let alone a few as would be the case in a collections page

@blittle
Copy link
Contributor

blittle commented May 1, 2024

Yeah, I imagine the src set headers could get huge. I wonder though, is the extra loader prop necessary? Why not just:

+ import {genPreloadImageLinkMeta} from '@shopify/hydrogen'

export const meta: MetaFunction<typeof loader> = ({data}) => {
  const metas = [
    {title: `Hydrogen | ${data?.product.title ?? ''}`},
  ] as MetaDescriptor[];

+   const preloadImageLink = genPreloadImageLinkMeta({
+     url: data.product.selectedVariant.image,
+   });
+   metas.push(preloadImageLink);

  return metas;
};

export async function loader({params, request, context}: LoaderFunctionArgs) {
  // ...other code
  return defer({
    product,
    variants,
  });
}

I think the pros are:

  1. Less indirection
  2. Smaller loader response

The only value for a generic prop coming off all the loaders IMO is if there was a <Preload /> component, similar to the Seo component that would aggregate the seo loader props to produce meta. Or maybe there's value down the road for middleware to handle a generic preload property?

@frandiox
Copy link
Contributor

frandiox commented May 1, 2024

Oh I completely missed the imagesrcset attribute, good to know!

Yeah, I imagine the src set headers could get huge. I wonder though, is the extra loader prop necessary? Why not just:

That would sound like a good compromise so that the whole thing only happens in 1 place 🤔


Related to my comment though:

Also, I think this would make it work not only for SSR, but for sub-sequent navigations when Remix returns loader data with their custom JSON format

I guess then there's no way to preload images for sub-sequent navigations? 😞

@juanpprieto
Copy link
Contributor Author

juanpprieto commented May 2, 2024

Yeah, I imagine the src set headers could get huge. I wonder though, is the extra loader prop necessary? Why not just:

Great point! Did the extra loader return because I was initially going to write two proposals one including a at the root, but then I then reverted back considering we just deprecated the component in favor of using straight up meta.

I will update the implementation to eliminate the extra return from the loader.

@blittle
Copy link
Contributor

blittle commented May 2, 2024

I guess then there's no way to preload images for sub-sequent navigations? 😞

I was trying to think through this at the burst, and AFAIK there's no way to do it. Unless we simplify the image loading to not use imagesrcset, which is also undesirable.

@benjaminsehl
Copy link
Member

I actually think it's fine if it doesn't include srcset … as long as it does include src … the LCP will be complete as long as the placeholder image is showing up I'm 99% sure (src) and so then the higher res srcset can load in after that.

Just putting it out there!

@juanpprieto
Copy link
Contributor Author

juanpprieto commented May 6, 2024

I actually think it's fine if it doesn't include srcset … as long as it does include src … the LCP will be complete as long as the placeholder image is showing up I'm 99% sure (src) and so then the higher res srcset can load in after that.

I don't think this would work. If you preload an image format/size that doesn't get rendered then it actually has detrimental impact to performance and the browser will warn about it. Preloading a placeholder seems to me like deop.

--
Did some more testing. Although we can preload images from sub-navigation, we can at least improve the start time and parallelism of these requests. Here's an example landing and navigating to a collection page with and without preloading.

collection_ssr_preloading_cmp

collection_csr_preloading_comp

I've now updated the readme with an instruction to the collection route and removed the returned preload prop from the loader (which was unnecessary)

@juanpprieto juanpprieto marked this pull request as ready for review May 7, 2024 00:52
@juanpprieto juanpprieto marked this pull request as draft May 8, 2024 18:22
@wizardlyhel
Copy link
Contributor

Let's also think about how would React 19 would change all this with document metadata hoisting https://react.dev/blog/2024/04/25/react-19#support-for-metadata-tags

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants