Skip to content

Commit

Permalink
Add lazy loading
Browse files Browse the repository at this point in the history
  • Loading branch information
atcastle committed Oct 15, 2020
1 parent 5adfcfd commit 24ec9ee
Show file tree
Hide file tree
Showing 6 changed files with 212 additions and 3 deletions.
48 changes: 45 additions & 3 deletions packages/next/client/image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ type ImageProps = {
sizes: string
breakpoints: number[]
priority: boolean
lazy: boolean
className: string
unoptimized: boolean
rest: any[]
}
Expand Down Expand Up @@ -108,6 +110,8 @@ export default function Image({
sizes,
unoptimized,
priority,
lazy,
className,
...rest
}: ImageProps) {
// Sanity Checks:
Expand All @@ -124,6 +128,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 @@ -134,18 +147,47 @@ export default function Image({

// 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} {...imgAttributes} className={className} sizes={sizes} />
)
} else {
imgElement = <img {...rest} {...imgAttributes} sizes={sizes} />
}

return (
<div>
{shouldPreload
Expand All @@ -157,7 +199,7 @@ export default function Image({
sizes,
})
: ''}
<img {...rest} {...imgAttributes} sizes={sizes} />
{imgElement}
</div>
)
}
Expand Down
33 changes: 33 additions & 0 deletions packages/next/next-server/lib/router/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -830,6 +830,7 @@ export default class Router implements BaseRouter {
window.scrollTo((options as any)._N_X, (options as any)._N_Y)
}
}
this.updateLazyObserver()
Router.events.emit('routeChangeComplete', as)

return true
Expand Down Expand Up @@ -1215,6 +1216,38 @@ export default class Router implements BaseRouter {
})
}

updateLazyObserver(): void {
// @ts-ignore This has to be global since it's initially set before hydration
window.__NEXT_lazy_observer.disconnect()
var lazyImages = [].slice.call(document.querySelectorAll('img.__lazy'))
if ('IntersectionObserver' in window) {
let lazyImageObserver = new IntersectionObserver(
function (entries) {
entries.forEach(function (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')
lazyImageObserver.unobserve(lazyImage)
}
})
},
{ rootMargin: '0px 0px 200px 0px' }
)

lazyImages.forEach(function (lazyImage) {
lazyImageObserver.observe(lazyImage)
})
// @ts-ignore see above
window.__NEXT_lazy_observer = lazyImageObserver
}
}

abortComponentLoad(as: string): void {
if (this.clc) {
Router.events.emit('routeChangeError', buildCancellationError(), as)
Expand Down
9 changes: 9 additions & 0 deletions packages/next/pages/_document.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,10 @@ export class NextScript extends Component<OriginProps> {
static safariNomoduleFix =
'!function(){var e=document,t=e.createElement("script");if(!("noModule"in t)&&"onbeforeload"in t){var n=!1;e.addEventListener("beforeload",function(e){if(e.target===t)n=!0;else if(!e.target.hasAttribute("nomodule")||!n)return;e.preventDefault()},!0),t.type="module",t.src=".",e.head.appendChild(t),t.remove()}}();'

// Source: https://gist.github.com/atcastle/046c0f416dc69c84cca20a173fb0344e
static lazyImageScript = `
document.addEventListener("DOMContentLoaded",function(){var e=[].slice.call(document.querySelectorAll("img.__lazy"));if("IntersectionObserver"in window){let t=new IntersectionObserver(function(e,n){e.forEach(function(e){if(e.isIntersecting){let n=e.target;n.src=n.dataset.src,n.dataset.srcset&&(n.srcset=n.dataset.srcset),n.classList.remove("__lazy"),t.unobserve(n)}})},{rootMargin:"0px 0px 200px 0px"});e.forEach(function(e){t.observe(e)}),window.__NEXT_lazy_observer=t}});
`
getDynamicChunks(files: DocumentFiles) {
const {
dynamicImports,
Expand Down Expand Up @@ -754,6 +758,11 @@ export class NextScript extends Component<OriginProps> {
}}
/>
)}
<script
dangerouslySetInnerHTML={{
__html: NextScript.lazyImageScript,
}}
/>
{process.env.__NEXT_MODERN_BUILD && !disableRuntimeJS ? (
<script
nonce={this.props.nonce}
Expand Down
3 changes: 3 additions & 0 deletions test/integration/image-component/basic/pages/index.js
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
85 changes: 85 additions & 0 deletions test/integration/image-component/basic/test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,71 @@ 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)})`
)
await waitFor(5000)
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 +230,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 24ec9ee

Please sign in to comment.