Skip to content

Commit

Permalink
Image component lazy loading (#17916)
Browse files Browse the repository at this point in the history
Co-authored-by: Tim Neutkens <timneutkens@me.com>
Co-authored-by: Tim Neutkens <tim@timneutkens.nl>
  • Loading branch information
3 people committed Oct 17, 2020
1 parent b37835a commit c9eb3dc
Show file tree
Hide file tree
Showing 4 changed files with 236 additions and 4 deletions.
116 changes: 112 additions & 4 deletions 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 } = {
Expand All @@ -21,12 +21,49 @@ type ImageProps = Omit<JSX.IntrinsicElements['img'], 'src' | 'sizes'> & {
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
Expand Down Expand Up @@ -106,6 +143,8 @@ export default function Image({
sizes,
unoptimized = false,
priority = false,
lazy,
className,
...rest
}: ImageProps) {
// Sanity Checks:
Expand All @@ -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'

Expand All @@ -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 = (
<img
{...rest}
ref={(el) => {
thisEl = el
}}
{...imgAttributes}
className={className}
sizes={sizes}
/>
)
} else {
imgElement = (
<img
ref={(el) => {
thisEl = el
}}
{...rest}
{...imgAttributes}
sizes={sizes}
/>
)
}

return (
<div>
{shouldPreload
Expand All @@ -155,7 +263,7 @@ export default function Image({
sizes,
})
: ''}
<img {...rest} {...imgAttributes} sizes={sizes} />
{imgElement}
</div>
)
}
Expand Down
3 changes: 3 additions & 0 deletions test/integration/image-component/basic/pages/index.js
Expand Up @@ -36,6 +36,9 @@ const Page = () => {
<Link href="/client-side">
<a id="clientlink">Client Side</a>
</Link>
<Link href="/lazy">
<a id="lazylink">lazy</a>
</Link>
<p id="stubtext">This is the index page</p>
</div>
)
Expand Down
37 changes: 37 additions & 0 deletions test/integration/image-component/basic/pages/lazy.js
@@ -0,0 +1,37 @@
import React from 'react'
import Image from 'next/image'

const Lazy = () => {
return (
<div>
<p id="stubtext">This is a page with lazy-loaded images</p>
<Image
id="lazy-top"
src="foo1.jpg"
height="400px"
width="300px"
lazy
></Image>
<div style={{ height: '2000px' }}></div>
<Image
id="lazy-mid"
src="foo2.jpg"
lazy
height="400px"
width="300px"
className="exampleclass"
></Image>
<div style={{ height: '2000px' }}></div>
<Image
id="lazy-bottom"
src="https://www.otherhost.com/foo3.jpg"
height="400px"
width="300px"
unoptimized
lazy
></Image>
</div>
)
}

export default Lazy
84 changes: 84 additions & 0 deletions test/integration/image-component/basic/test/index.test.js
Expand Up @@ -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
Expand Down Expand Up @@ -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()
})
})

0 comments on commit c9eb3dc

Please sign in to comment.