diff --git a/packages/next/client/image.tsx b/packages/next/client/image.tsx index 620dd7f5750118b11daeef10abb067823e31aba7..e54cbea4d42eac25d472032e8f8657057a943f6f 100644 --- a/packages/next/client/image.tsx +++ b/packages/next/client/image.tsx @@ -1,4 +1,4 @@ -import React, { ReactElement } from 'react' +import React, { ReactElement, useEffect } from 'react' import Head from '../next-server/lib/head' const loaders: { [key: string]: (props: LoaderProps) => string } = { @@ -21,12 +21,49 @@ type ImageProps = Omit & { host?: string sizes?: string priority?: boolean + lazy: boolean + className: string unoptimized?: boolean } let imageData: any = process.env.__NEXT_IMAGE_OPTS const breakpoints = imageData.sizes || [640, 1024, 1600] +let cachedObserver: IntersectionObserver +const IntersectionObserver = + typeof window !== 'undefined' ? window.IntersectionObserver : null + +function getObserver(): IntersectionObserver | undefined { + // Return shared instance of IntersectionObserver if already created + if (cachedObserver) { + return cachedObserver + } + + // Only create shared IntersectionObserver if supported in browser + if (!IntersectionObserver) { + return undefined + } + + return (cachedObserver = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + let lazyImage = entry.target as HTMLImageElement + if (lazyImage.dataset.src) { + lazyImage.src = lazyImage.dataset.src + } + if (lazyImage.dataset.srcset) { + lazyImage.srcset = lazyImage.dataset.srcset + } + lazyImage.classList.remove('__lazy') + cachedObserver.unobserve(lazyImage) + } + }) + }, + { rootMargin: '200px' } + )) +} + function computeSrc(src: string, host: string, unoptimized: boolean): string { if (unoptimized) { return src @@ -106,6 +143,8 @@ export default function Image({ sizes, unoptimized = false, priority = false, + lazy, + className, ...rest }: ImageProps) { // Sanity Checks: @@ -122,6 +161,15 @@ export default function Image({ } host = 'default' } + // If priority and lazy are present, log an error and use priority only. + if (priority && lazy) { + if (process.env.NODE_ENV !== 'production') { + console.error( + `Image with src ${src} has both priority and lazy tags. Only one should be used.` + ) + } + lazy = false + } host = host || 'default' @@ -130,20 +178,80 @@ export default function Image({ src = src.slice(1) } + let thisEl: any + + useEffect(() => { + if (lazy) { + const observer = getObserver() + if (observer) { + observer.observe(thisEl) + return () => { + observer.unobserve(thisEl) + } + } + } + }, [thisEl, lazy]) + // Generate attribute values const imgSrc = computeSrc(src, host, unoptimized) - const imgAttributes: { src: string; srcSet?: string } = { src: imgSrc } + let imgSrcset = null if (!unoptimized) { - imgAttributes.srcSet = generateSrcSet({ + imgSrcset = generateSrcSet({ src, host: host, widths: breakpoints, }) } + + const imgAttributes: { + src?: string + srcSet?: string + 'data-src'?: string + 'data-srcset'?: string + } = {} + if (!lazy) { + imgAttributes.src = imgSrc + if (imgSrcset) { + imgAttributes.srcSet = imgSrcset + } + } else { + imgAttributes['data-src'] = imgSrc + if (imgSrcset) { + imgAttributes['data-srcset'] = imgSrcset + } + className = className ? className + ' __lazy' : '__lazy' + } + // No need to add preloads on the client side--by the time the application is hydrated, // it's too late for preloads const shouldPreload = priority && typeof window === 'undefined' + let imgElement + if (className) { + imgElement = ( + { + thisEl = el + }} + {...imgAttributes} + className={className} + sizes={sizes} + /> + ) + } else { + imgElement = ( + { + thisEl = el + }} + {...rest} + {...imgAttributes} + sizes={sizes} + /> + ) + } + return (
{shouldPreload @@ -155,7 +263,7 @@ export default function Image({ sizes, }) : ''} - + {imgElement}
) } diff --git a/test/integration/image-component/basic/pages/index.js b/test/integration/image-component/basic/pages/index.js index a21a0e78815903670f12e7fc1c602df55fc99bee..f541cb5b6c67fef5c351bcd1e84fac3ea4757f61 100644 --- a/test/integration/image-component/basic/pages/index.js +++ b/test/integration/image-component/basic/pages/index.js @@ -36,6 +36,9 @@ const Page = () => { Client Side + + lazy +

This is the index page

) diff --git a/test/integration/image-component/basic/pages/lazy.js b/test/integration/image-component/basic/pages/lazy.js new file mode 100644 index 0000000000000000000000000000000000000000..f352e6bdce413eb32ff5274f4e7ec3014986b988 --- /dev/null +++ b/test/integration/image-component/basic/pages/lazy.js @@ -0,0 +1,37 @@ +import React from 'react' +import Image from 'next/image' + +const Lazy = () => { + return ( +
+

This is a page with lazy-loaded images

+ +
+ +
+ +
+ ) +} + +export default Lazy diff --git a/test/integration/image-component/basic/test/index.test.js b/test/integration/image-component/basic/test/index.test.js index 483d5a579f0e33499bf93c62d9ca2277452ddffb..67315a2b15f89fd98f721b6a855134a36a590347 100644 --- a/test/integration/image-component/basic/test/index.test.js +++ b/test/integration/image-component/basic/test/index.test.js @@ -71,6 +71,70 @@ function runTests() { }) } +function lazyLoadingTests() { + it('should have loaded the first image immediately', async () => { + expect(await browser.elementById('lazy-top').getAttribute('src')).toBe( + 'https://example.com/myaccount/foo1.jpg' + ) + expect(await browser.elementById('lazy-top').getAttribute('srcset')).toBe( + 'https://example.com/myaccount/foo1.jpg?w=480 480w, https://example.com/myaccount/foo1.jpg?w=1024 1024w, https://example.com/myaccount/foo1.jpg?w=1600 1600w' + ) + }) + it('should not have loaded the second image immediately', async () => { + expect( + await browser.elementById('lazy-mid').getAttribute('src') + ).toBeFalsy() + expect( + await browser.elementById('lazy-mid').getAttribute('srcset') + ).toBeFalsy() + }) + it('should pass through classes on a lazy loaded image', async () => { + expect(await browser.elementById('lazy-mid').getAttribute('class')).toBe( + 'exampleclass __lazy' + ) + }) + it('should load the second image after scrolling down', async () => { + let viewportHeight = await browser.eval(`window.innerHeight`) + let topOfMidImage = await browser.eval( + `document.getElementById('lazy-mid').offsetTop` + ) + let buffer = 150 + await browser.eval( + `window.scrollTo(0, ${topOfMidImage - (viewportHeight + buffer)})` + ) + expect(await browser.elementById('lazy-mid').getAttribute('src')).toBe( + 'https://example.com/myaccount/foo2.jpg' + ) + expect(await browser.elementById('lazy-mid').getAttribute('srcset')).toBe( + 'https://example.com/myaccount/foo2.jpg?w=480 480w, https://example.com/myaccount/foo2.jpg?w=1024 1024w, https://example.com/myaccount/foo2.jpg?w=1600 1600w' + ) + }) + it('should not have loaded the third image after scrolling down', async () => { + expect( + await browser.elementById('lazy-bottom').getAttribute('src') + ).toBeFalsy() + expect( + await browser.elementById('lazy-bottom').getAttribute('srcset') + ).toBeFalsy() + }) + it('should load the third image, which is unoptimized, after scrolling further down', async () => { + let viewportHeight = await browser.eval(`window.innerHeight`) + let topOfBottomImage = await browser.eval( + `document.getElementById('lazy-bottom').offsetTop` + ) + let buffer = 150 + await browser.eval( + `window.scrollTo(0, ${topOfBottomImage - (viewportHeight + buffer)})` + ) + expect(await browser.elementById('lazy-bottom').getAttribute('src')).toBe( + 'https://www.otherhost.com/foo3.jpg' + ) + expect( + await browser.elementById('lazy-bottom').getAttribute('srcset') + ).toBeFalsy() + }) +} + async function hasPreloadLinkMatchingUrl(url) { const links = await browser.elementsByCss('link') let foundMatch = false @@ -165,4 +229,24 @@ describe('Image Component Tests', () => { }) }) }) + describe('SSR Lazy Loading Tests', () => { + beforeAll(async () => { + browser = await webdriver(appPort, '/lazy') + }) + afterAll(async () => { + browser = null + }) + lazyLoadingTests() + }) + describe('Client-side Lazy Loading Tests', () => { + beforeAll(async () => { + browser = await webdriver(appPort, '/') + await browser.waitForElementByCss('#lazylink').click() + await waitFor(500) + }) + afterAll(async () => { + browser = null + }) + lazyLoadingTests() + }) })