From 4379780655e03bf80d0b1d6714c75862b76f9291 Mon Sep 17 00:00:00 2001 From: Brennan Kinney <5098581+polarathene@users.noreply.github.com> Date: Tue, 28 May 2019 08:35:49 +1200 Subject: [PATCH] chore(gatsby-image): lazy-load refactor (#14158) Refactor around the recent changes from adding native lazy-loading support. --- .../src/__tests__/__snapshots__/index.js.snap | 3 + packages/gatsby-image/src/__tests__/index.js | 9 +- packages/gatsby-image/src/index.js | 161 +++++++----------- 3 files changed, 70 insertions(+), 103 deletions(-) diff --git a/packages/gatsby-image/src/__tests__/__snapshots__/index.js.snap b/packages/gatsby-image/src/__tests__/__snapshots__/index.js.snap index 53c1acf582abc..dbd0f6218016a 100644 --- a/packages/gatsby-image/src/__tests__/__snapshots__/index.js.snap +++ b/packages/gatsby-image/src/__tests__/__snapshots__/index.js.snap @@ -27,6 +27,7 @@ exports[` should have a transition-delay of 1sec 1`] = ` crossorigin="anonymous" height="100" itemprop="item-prop-for-the-image" + loading="lazy" src="test_image.jpg" srcset="some srcSet" style="position: absolute; top: 0px; left: 0px; width: 100%; height: 100%; object-fit: cover; object-position: center; opacity: 0; transition: opacity 1000ms;" @@ -68,6 +69,7 @@ exports[` should render fixed size images 1`] = ` crossorigin="anonymous" height="100" itemprop="item-prop-for-the-image" + loading="lazy" src="test_image.jpg" srcset="some srcSet" style="position: absolute; top: 0px; left: 0px; width: 100%; height: 100%; object-fit: cover; object-position: center; opacity: 0; transition: opacity 500ms;" @@ -112,6 +114,7 @@ exports[` should render fluid images 1`] = ` alt="Alt text for the image" crossorigin="anonymous" itemprop="item-prop-for-the-image" + loading="lazy" sizes="(max-width: 600px) 100vw, 600px" src="test_image.jpg" srcset="some srcSet" diff --git a/packages/gatsby-image/src/__tests__/index.js b/packages/gatsby-image/src/__tests__/index.js index 2085ba0218c75..102963e2c686b 100644 --- a/packages/gatsby-image/src/__tests__/index.js +++ b/packages/gatsby-image/src/__tests__/index.js @@ -69,6 +69,7 @@ describe(``, () => { expect(imageTag.getAttribute(`title`)).toEqual(`Title for the image`) expect(imageTag.getAttribute(`alt`)).toEqual(`Alt text for the image`) expect(imageTag.getAttribute(`crossOrigin`)).toEqual(`anonymous`) + expect(imageTag.getAttribute(`loading`)).toEqual(`lazy`) }) it(`should have correct placeholder src, title, style and class attributes`, () => { @@ -85,10 +86,16 @@ describe(``, () => { }) it(`should have a transition-delay of 1sec`, () => { - const component = setup(false, { durationFadeIn: `1000` }) + const component = setup(false, { durationFadeIn: 1000 }) expect(component).toMatchSnapshot() }) + it(`should have the the "critical" prop set "loading='eager'"`, () => { + const props = { critical: true } + const imageTag = setup(false, props).querySelector(`picture img`) + expect(imageTag.getAttribute(`loading`)).toEqual(`eager`) + }) + it(`should call onLoad and onError image events`, () => { const onLoadMock = jest.fn() const onErrorMock = jest.fn() diff --git a/packages/gatsby-image/src/index.js b/packages/gatsby-image/src/index.js index feba030e367e0..80c9d440fa894 100644 --- a/packages/gatsby-image/src/index.js +++ b/packages/gatsby-image/src/index.js @@ -1,18 +1,42 @@ import React from "react" import PropTypes from "prop-types" -// Handle legacy names for image queries. +const logDeprecationNotice = (prop, replacement) => { + if (process.env.NODE_ENV === `production`) { + return + } + + console.log( + ` + The "${prop}" prop is now deprecated and will be removed in the next major version + of "gatsby-image". + ` + ) + + if (replacement) { + console.log(`Please use ${replacement} instead of "${prop}".`) + } +} + +// Handle legacy props during their deprecation phase const convertProps = props => { let convertedProps = { ...props } - if (convertedProps.resolutions) { - convertedProps.fixed = convertedProps.resolutions + const { resolutions, sizes, critical } = convertedProps + + if (resolutions) { + convertedProps.fixed = resolutions delete convertedProps.resolutions } - if (convertedProps.sizes) { - convertedProps.fluid = convertedProps.sizes + if (sizes) { + convertedProps.fluid = sizes delete convertedProps.sizes } + if (critical) { + logDeprecationNotice(`critical`, `the native "loading" attribute`) + convertedProps.loading = `eager` + } + return convertedProps } @@ -39,6 +63,14 @@ const activateCacheForImage = props => { imageCache[src] = true } +// Native lazy-loading support: https://addyosmani.com/blog/lazy-loading/ +const hasNativeLazyLoadSupport = + typeof HTMLImageElement !== `undefined` && + `loading` in HTMLImageElement.prototype + +const isBrowser = typeof window !== `undefined` +const hasIOSupport = isBrowser && window.IntersectionObserver + let io const listeners = new WeakMap() @@ -99,12 +131,6 @@ const noscriptImg = props => { const crossOrigin = props.crossOrigin ? `crossorigin="${props.crossOrigin}" ` : `` - - // Since we're in the noscript block for this image (which is rendered during SSR or when js is disabled), - // we have no way to "detect" if native lazy loading is supported by the user's browser - // Since this attribute is a progressive enhancement, it won't break a browser with no support - // Therefore setting it by default is a good idea. - const loading = props.loading ? `loading="${props.loading}" ` : `` return `${srcSetWebp}` @@ -118,17 +144,10 @@ const Img = React.forwardRef((props, ref) => { style, onLoad, onError, - nativeLazyLoadSupported, loading, ...otherProps } = props - let loadingAttribute = {} - - if (nativeLazyLoadSupported) { - loadingAttribute.loading = loading - } - return ( { onLoad={onLoad} onError={onError} ref={ref} - {...loadingAttribute} + loading={loading} style={{ position: `absolute`, top: 0, @@ -163,61 +182,26 @@ class Image extends React.Component { constructor(props) { super(props) - // default settings for browser without Intersection Observer available - let isVisible = true - let imgLoaded = false - let imgCached = false - let IOSupported = false - let fadeIn = props.fadeIn - let nativeLazyLoadSupported = false - // If this image has already been loaded before then we can assume it's // already in the browser cache so it's cheap to just show directly. - const seenBefore = inImageCache(props) - - // browser with Intersection Observer available - if ( - !seenBefore && - typeof window !== `undefined` && - window.IntersectionObserver - ) { - isVisible = false - IOSupported = true - } - - // Chrome Canary 75 added native lazy loading support! - // https://addyosmani.com/blog/lazy-loading/ - if ( - typeof HTMLImageElement !== `undefined` && - `loading` in HTMLImageElement.prototype - ) { - // Setting isVisible to true to short circuit our IO code and let the browser do its magic - isVisible = true - nativeLazyLoadSupported = true - } - - // Never render image during SSR - if (typeof window === `undefined`) { - isVisible = false - } + this.seenBefore = isBrowser && inImageCache(props) - // Force render for critical images - if (props.critical) { - isVisible = true - IOSupported = false - } + this.addNoScript = !(props.critical && !props.fadeIn) + this.useIOSupport = + !hasNativeLazyLoadSupport && + hasIOSupport && + !props.critical && + !this.seenBefore - const hasNoScript = !(props.critical && !props.fadeIn) + const isVisible = + props.critical || + (isBrowser && (hasNativeLazyLoadSupport || !this.useIOSupport)) this.state = { isVisible, - imgLoaded, - imgCached, - IOSupported, - fadeIn, - hasNoScript, - seenBefore, - nativeLazyLoadSupported, + imgLoaded: false, + imgCached: false, + fadeIn: !this.seenBefore && props.fadeIn, } this.imageRef = React.createRef() @@ -243,12 +227,9 @@ class Image extends React.Component { } } + // Specific to IntersectionObserver based lazy-load support handleRef(ref) { - if (this.state.nativeLazyLoadSupported) { - // Bail because the browser natively supports lazy loading - return - } - if (this.state.IOSupported && ref) { + if (this.useIOSupport && ref) { this.cleanUpListeners = listenToIntersections(ref, () => { const imageInCache = inImageCache(this.props) if ( @@ -265,6 +246,8 @@ class Image extends React.Component { this.setState({ isVisible: true }, () => this.setState({ imgLoaded: imageInCache, + // `currentSrc` should be a string, but can be `undefined` in IE, + // !! operator validates the value is not undefined/null/"" imgCached: !!this.imageRef.current.currentSrc, }) ) @@ -276,9 +259,6 @@ class Image extends React.Component { activateCacheForImage(this.props) this.setState({ imgLoaded: true }) - if (this.state.seenBefore) { - this.setState({ fadeIn: false }) - } if (this.props.onLoad) { this.props.onLoad() @@ -300,31 +280,10 @@ class Image extends React.Component { durationFadeIn, Tag, itemProp, - critical, + loading, } = convertProps(this.props) - let { loading } = convertProps(this.props) - - if ( - typeof critical === `boolean` && - process.env.NODE_ENV !== `production` - ) { - console.log( - ` - The "critical" prop is now deprecated and will be removed in the next major version - of "gatsby-image" - - Please use the native "loading" attribute instead of "critical" - ` - ) - // We want to continue supporting critical and in case it is passed in - // we map its value to loading - loading = critical ? `eager` : `lazy` - } - - const { nativeLazyLoadSupported } = this.state - - const shouldReveal = this.state.imgLoaded || this.state.fadeIn === false + const shouldReveal = this.state.fadeIn === false || this.state.imgLoaded const shouldFadeIn = this.state.fadeIn === true && !this.state.imgCached const imageStyle = { @@ -426,14 +385,13 @@ class Image extends React.Component { onLoad={this.handleImageLoaded} onError={this.props.onError} itemProp={itemProp} - nativeLazyLoadSupported={nativeLazyLoadSupported} loading={loading} /> )} {/* Show the original image during server-side rendering if JavaScript is disabled */} - {this.state.hasNoScript && ( + {this.addNoScript && (