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

Image component lazy loading #17916

Merged
merged 24 commits into from Oct 17, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
5cdd37a
Create stub of image component
atcastle Sep 14, 2020
03e3b96
Expand test coverage to include client-side component usage
atcastle Sep 14, 2020
4a7243a
Basic pass through src on image component
atcastle Sep 16, 2020
354e0ab
Data pipeline from config to browser and server on all targets
atcastle Sep 16, 2020
22e1ddf
Add support for multiple hosts
atcastle Sep 23, 2020
144812a
Add support for the 'unoptimized' attribute to the image component
atcastle Sep 23, 2020
3090223
Add basic loader support and srcsets
atcastle Sep 24, 2020
6749745
Minor cleanups
atcastle Sep 24, 2020
241e835
Simplify image config loading
timneutkens Oct 5, 2020
016aef7
Add priority attribute
atcastle Oct 5, 2020
2aee017
Handle unregistered host
atcastle Oct 5, 2020
f02580b
Simplify image config loading
timneutkens Oct 5, 2020
bcf5464
Add checking for malformed images property in next.config.js
atcastle Oct 6, 2020
68f7e71
Merge branch 'image-component' of github.com:azukaru/next.js into ima…
atcastle Oct 6, 2020
a76af8a
Update packages/next/build/webpack-config.ts
timneutkens Oct 6, 2020
f7b832d
fix obsolete test and filepath issues
atcastle Oct 8, 2020
87d650e
Merge branch 'image-component' of github.com:azukaru/next.js into ima…
atcastle Oct 8, 2020
60e057b
Normalize host and src paths
atcastle Oct 9, 2020
2f0098b
Merge remote-tracking branch 'origin/canary' into image-component
atcastle Oct 13, 2020
5adfcfd
Merge remote-tracking branch 'origin/canary' into image-component
atcastle Oct 14, 2020
24ec9ee
Add lazy loading
atcastle Oct 15, 2020
9c2e235
Move all image intersectionObserver logic into image instance
atcastle Oct 16, 2020
959c419
Make sure extra function is not compiled
timneutkens Oct 17, 2020
946ef4b
Merge branch 'canary' of github.com:vercel/next.js into azukaru-image…
timneutkens Oct 17, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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()
})
})