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

Add native lazy-loading support #688

Closed
wants to merge 16 commits into from
4 changes: 4 additions & 0 deletions packages/thumbprint-react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Changed

- [Patch] Use built-in browser image lazy-loading when supported.

## 12.5.0 - 2020-05-01

### Fixed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ exports[`EntityAvatar renders an image when the user has one 1`] = `
alt=""
className="imageStart"
height="48px"
loading="lazy"
onError={[Function]}
onLoad={[Function]}
sizes="0px"
Expand Down Expand Up @@ -104,6 +105,7 @@ exports[`adds the \`fullName\` as \`alt\` text when image is provided 1`] = `
alt="Avatar for Duck Goose"
className="imageStart"
height="48px"
loading="lazy"
onError={[Function]}
onLoad={[Function]}
sizes="0px"
Expand Down Expand Up @@ -181,6 +183,7 @@ exports[`adds the \`fullName\` as \`title\` text 2`] = `
alt="Avatar for Duck Goose"
className="imageStart"
height="48px"
loading="lazy"
onError={[Function]}
onLoad={[Function]}
sizes="0px"
Expand Down Expand Up @@ -663,6 +666,7 @@ exports[`renders an image when the user has one 1`] = `
alt=""
className="imageStart"
height="48px"
loading="lazy"
onError={[Function]}
onLoad={[Function]}
sizes="0px"
Expand Down
68 changes: 46 additions & 22 deletions packages/thumbprint-react/components/Image/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,36 +118,50 @@ const Image = forwardRef<HTMLElement, ImagePropTypes>((props: ImagePropTypes, ou
// Lazy-loading: library setup and polyfill
// --------------------------------------------------------------------------------------------

const browserSupportsNativeLazyLoading = canUseDOM && 'loading' in HTMLImageElement.prototype;
const [browserSupportIntersectionObserver, setBrowserSupportIntersectionObserver] = useState<
boolean
>(canUseDOM && typeof window.IntersectionObserver !== 'undefined');

// IntersectionObserver's `root` property identifies the element whose bounds are treated as
// the bounding box of the viewport for this element. By default, it uses `window`. Instead
// of using the default, we use the nearest scrollable parent. This is the same approach that
// React Waypoint and lazysizes use. The React Waypoint README explains this concept well:
// https://git.io/fj00H
let parent;
let root;

const parent = canUseDOM && containerRef ? scrollparent(containerRef) : null;
const root = parent && (parent.tagName === 'HTML' || parent.tagName === 'BODY') ? null : parent;
// Skip over the scroll parent calculation if the browser supports native lazy-loading.
if (!browserSupportsNativeLazyLoading) {
parent = canUseDOM && containerRef ? scrollparent(containerRef) : null;
root = parent && (parent.tagName === 'HTML' || parent.tagName === 'BODY') ? null : parent;
}

// `shouldLoadImage` becomes `true` when the lazy-loading functionality decides that we should
// load the image.
// `isInView` becomes `true` when the lazy-loading functionality decides that we should load
// the image. We'll only add the `inViewRef` to the `picture` element if the browser doesn't
// support the `loading` attribute.
const [inViewRef, isInView] = useInView({
root,
rootMargin: '100px',
triggerOnce: true,
});

const [browserSupportIntersectionObserver, setBrowserSupportIntersectionObserver] = useState<
boolean
>(canUseDOM && typeof window.IntersectionObserver !== 'undefined');

// Loads the `IntersectionObserver` polyfill asynchronously on browsers that don't support it.
if (canUseDOM && typeof window.IntersectionObserver === 'undefined') {
import('intersection-observer').then(() => {
setBrowserSupportIntersectionObserver(true);
});
}

// If `forceEarlyRender` is truthy, bypass lazy loading and load the image.
const shouldLoadImage = isInView || forceEarlyRender;
// If `browserSupportsNativeLazyLoading` or `forceEarlyRender` are truthy, bypass our
// `IntersectionObserver` lazy-loading and defer to the browser.
const [shouldAddSrcAttributes, setShouldAddSrcAttributes] = useState(!!forceEarlyRender);

useEffect(() => {
if (browserSupportsNativeLazyLoading || isInView) {
setShouldAddSrcAttributes(true);
}
}, [browserSupportsNativeLazyLoading, isInView]);

// --------------------------------------------------------------------------------------------
// Object Fit: polyfill and CSS styles
Expand All @@ -171,15 +185,15 @@ const Image = forwardRef<HTMLElement, ImagePropTypes>((props: ImagePropTypes, ou

useEffect(() => {
// We polyfill `object-fit` for browsers that don't support it. We only do it if we're
// using a `height` or `containerAspectRatio`. The `shouldLoadImage` variable ensures
// that we don't try to polyfill the image before the `src` exists. This can happy
// using a `height` or `containerAspectRatio`. The `shouldAddSrcAttributes` variable
// ensures that we don't try to polyfill the image before the `src` exists. This can happen
// when we lazy-load.
if (shouldObjectFit && containerRef && shouldLoadImage && shouldPolyfillObjectFit) {
if (shouldObjectFit && containerRef && shouldAddSrcAttributes && shouldPolyfillObjectFit) {
import('object-fit-images').then(({ default: ObjectFitImages }) => {
ObjectFitImages(containerRef.querySelector('img'));
});
}
}, [shouldObjectFit, containerRef, shouldLoadImage, shouldPolyfillObjectFit]);
}, [shouldObjectFit, containerRef, shouldAddSrcAttributes, shouldPolyfillObjectFit]);

if (shouldObjectFit) {
objectFitProps.style = {
Expand Down Expand Up @@ -244,9 +258,9 @@ const Image = forwardRef<HTMLElement, ImagePropTypes>((props: ImagePropTypes, ou
// element.
setContainerRef(node);

// We don't want to turn on the `react-intersection-observer` functionality until
// the polyfill is done loading.
if (browserSupportIntersectionObserver) {
// We should turn on `react-intersection-observer` if the browser doesn't natively
// support lazy-loading and the `IntersectionObserver` polyfill is done loading.
if (!browserSupportsNativeLazyLoading && browserSupportIntersectionObserver) {
inViewRef(node);
}

Expand All @@ -255,7 +269,13 @@ const Image = forwardRef<HTMLElement, ImagePropTypes>((props: ImagePropTypes, ou
outerRef(node);
}
},
[inViewRef, outerRef, setContainerRef, browserSupportIntersectionObserver],
[
inViewRef,
outerRef,
setContainerRef,
browserSupportIntersectionObserver,
browserSupportsNativeLazyLoading,
],
);

return (
Expand All @@ -264,8 +284,9 @@ const Image = forwardRef<HTMLElement, ImagePropTypes>((props: ImagePropTypes, ou
{webpSource && (
<source
type={webpSource.type}
// Only add this attribute if lazyload has been triggered.
srcSet={shouldLoadImage ? webpSource.srcSet : undefined}
// Add this attribute if we're ready to defer to the browser, letting it
// either load the image immediately or handle the lazy-loading itself.
srcSet={shouldAddSrcAttributes ? webpSource.srcSet : undefined}
sizes={sizes}
/>
)}
Expand All @@ -275,9 +296,11 @@ const Image = forwardRef<HTMLElement, ImagePropTypes>((props: ImagePropTypes, ou
// `img` tag and using `source` tags.
sizes={sizes}
// Only add this attribute if lazyload has been triggered.
srcSet={shouldLoadImage && imgTagSource ? imgTagSource.srcSet : undefined}
srcSet={
shouldAddSrcAttributes && imgTagSource ? imgTagSource.srcSet : undefined
}
// Only add this attribute if lazyload has been triggered.
src={shouldLoadImage ? src : undefined}
src={shouldAddSrcAttributes ? src : undefined}
// Height is generally only used for full-width hero images.
height={height}
alt={alt}
Expand All @@ -302,6 +325,7 @@ const Image = forwardRef<HTMLElement, ImagePropTypes>((props: ImagePropTypes, ou
// For SSR we want this to fire instantly.
[styles.imageEnd]: isLoaded || isError || forceEarlyRender,
})}
loading={forceEarlyRender ? 'eager' : 'lazy'}
/>
</picture>
{!forceEarlyRender && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ exports[`ServiceCardImage render works 1`] = `
<img
alt="duck duck goose"
className="imageStart"
loading="lazy"
onError={[Function]}
onLoad={[Function]}
sizes="0px"
Expand Down
7 changes: 7 additions & 0 deletions typings/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,10 @@ declare module '*.module.scss' {
const classes: { [key: string]: string };
export default classes;
}

declare namespace React {
interface ImgHTMLAttributes<T> extends HTMLAttributes<T> {
// https://developer.mozilla.org/en-US/docs/Web/Performance/Lazy_loading#Images_and_iframes
loading?: 'lazy' | 'eager' | 'auto';
}
}
84 changes: 84 additions & 0 deletions www/src/pages/components/image/react/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,90 @@ This example uses the `height` prop to "crop" the image. By default, the cropped
/>
```

## Within a `Carousel`

This example uses the `Carousel` and `ServiceCard` components. The `ServiceCardImage` uses the `Image` component behind the scenes and will behave like the other examples on this page.

```jsx
function CarouselDemo() {
const [selectedIndex, setSelectedIndex] = React.useState(0);

function onSelectedIndexChange(newIndex) {
setSelectedIndex(Math.round(newIndex));
}

return (
<React.Fragment>
<Carousel
visibleCount={3}
spacing="16px"
selectedIndex={selectedIndex}
onSelectedIndexChange={onSelectedIndexChange}
>
<ServiceCard url="https://www.thumbtack.com/k/massage/near-me/">
<ServiceCardImage
alt="Personal Training"
url="https://d1vg1gqh4nkuns.cloudfront.net/i/318810408927723609/width/640/aspect/8-5.jpeg"
/>
<ServiceCardTitle>1. Personal Training</ServiceCardTitle>
</ServiceCard>
<ServiceCard url="https://www.thumbtack.com/k/massage/near-me/">
<ServiceCardImage
alt="Dog Training"
url="https://d1vg1gqh4nkuns.cloudfront.net/i/323761411685384319/width/640/aspect/8-5.jpeg"
/>
<ServiceCardTitle>2. Dog Training</ServiceCardTitle>
</ServiceCard>
<ServiceCard url="https://www.thumbtack.com/k/massage/near-me/">
<ServiceCardImage
alt="Local Moving (under 50 miles)"
url="https://d1vg1gqh4nkuns.cloudfront.net/i/323760317171040266/width/640/aspect/8-5.jpeg"
/>
<ServiceCardTitle>3. Local Moving</ServiceCardTitle>
</ServiceCard>
<ServiceCard url="https://www.thumbtack.com/k/massage/near-me/">
<ServiceCardImage
alt="Massage Therapy"
url="https://d1vg1gqh4nkuns.cloudfront.net/i/323761720722374783/width/640/aspect/8-5.jpeg"
/>
<ServiceCardTitle>4. Massage Therapy</ServiceCardTitle>
</ServiceCard>
<ServiceCard url="https://www.thumbtack.com/k/interior-painting/near-me/">
<ServiceCardImage
alt="Interior Painting"
url="https://d1vg1gqh4nkuns.cloudfront.net/i/323761857295261793/width/640/aspect/8-5.jpeg"
/>
<ServiceCardTitle>5. Interior Painting</ServiceCardTitle>
</ServiceCard>
<ServiceCard url="https://www.thumbtack.com/k/junk-removal/near-me/">
<ServiceCardImage
alt="Junk Removal"
url="https://d1vg1gqh4nkuns.cloudfront.net/i/323234733715669089/width/640/aspect/8-5.jpeg"
/>
<ServiceCardTitle>6. Junk Removal</ServiceCardTitle>
</ServiceCard>
<ServiceCard url="https://www.thumbtack.com/k/new-home-building/near-me/">
<ServiceCardImage
alt="New Home Construction"
url="https://d1vg1gqh4nkuns.cloudfront.net/i/327799254292824113/width/640/aspect/8-5.jpeg"
/>
<ServiceCardTitle>7. New Home Construction</ServiceCardTitle>
</ServiceCard>
</Carousel>

<ButtonRow>
<Button size="small" onClick={() => setSelectedIndex(selectedIndex - 1)}>
Prev
</Button>
<Button size="small" onClick={() => setSelectedIndex(selectedIndex + 1)}>
Next
</Button>
</ButtonRow>
</React.Fragment>
);
}
```

## Forcing early render

The `Image` component behavior uses JavaScript to lazy load the image and and calculate the `sizes` attribute. While this is beneficial in most cases, it also means that the image will not start downloading until the client-side JavaScript is parsed and the image is scrolled into view. The `forceEarlyRender` prop allows developers to turn off lazy loading and the `sizes` calculation so that the image can load as soon as possible. This is useful for "above-the-fold" images with a predictable width in server-side-rendered environments.
Expand Down