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

Control <Image /> prefetching with React #18904

Merged
merged 12 commits into from
Nov 6, 2020
99 changes: 16 additions & 83 deletions packages/next/client/image.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { ReactElement, useEffect, useRef } from 'react'
import React, { ReactElement } from 'react'
import Head from '../next-server/lib/head'
import { useIntersection } from './use-intersection'

const VALID_LOADING_VALUES = ['lazy', 'eager', undefined] as const
type LoadingValue = typeof VALID_LOADING_VALUES[number]
Expand Down Expand Up @@ -71,45 +72,6 @@ const allSizes = [...configDeviceSizes, ...configImageSizes]
configDeviceSizes.sort((a, b) => a - b)
allSizes.sort((a, b) => a - b)

let cachedObserver: IntersectionObserver

function getObserver(): IntersectionObserver | undefined {
const IntersectionObserver =
typeof window !== 'undefined' ? window.IntersectionObserver : null
// 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
unLazifyImage(lazyImage)
cachedObserver.unobserve(lazyImage)
}
})
},
{ rootMargin: '200px' }
))
}

function unLazifyImage(lazyImage: HTMLImageElement): void {
if (lazyImage.dataset.src) {
lazyImage.src = lazyImage.dataset.src
}
if (lazyImage.dataset.srcset) {
lazyImage.srcset = lazyImage.dataset.srcset
}
lazyImage.style.visibility = 'visible'
lazyImage.classList.remove('__lazy')
}

function getSizes(
width: number | undefined,
layout: LayoutValue
Expand Down Expand Up @@ -255,8 +217,6 @@ export default function Image({
objectPosition,
...all
}: ImageProps) {
const thisEl = useRef<HTMLImageElement>(null)

let rest: Partial<ImageProps> = all
let layout: NonNullable<LayoutValue> = sizes ? 'responsive' : 'intrinsic'
let unsized = false
Expand Down Expand Up @@ -306,33 +266,19 @@ export default function Image({
}
}

let lazy = loading === 'lazy'
if (!priority && typeof loading === 'undefined') {
lazy = true
}

let isLazy =
!priority && (loading === 'lazy' || typeof loading === 'undefined')
if (src && src.startsWith('data:')) {
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs
unoptimized = true
lazy = false
isLazy = false
}

useEffect(() => {
const target = thisEl.current
if (target) {
const observer = lazy && getObserver()

if (observer) {
observer.observe(target)

return () => {
observer.unobserve(target)
}
} else {
unLazifyImage(target)
}
}
}, [thisEl, lazy])
const [setRef, isIntersected] = useIntersection<HTMLImageElement>({
rootMargin: '200px',
disabled: !isLazy,
})
const isVisible = !isLazy || isIntersected

const widthInt = getInt(width)
const heightInt = getInt(height)
Expand All @@ -342,7 +288,7 @@ export default function Image({
let sizerStyle: JSX.IntrinsicElements['div']['style'] | undefined
let sizerSvg: string | undefined
let imgStyle: ImgElementStyle | undefined = {
visibility: lazy ? 'hidden' : 'visible',
visibility: isVisible ? 'visible' : 'hidden',

position: 'absolute',
top: 0,
Expand Down Expand Up @@ -451,29 +397,16 @@ export default function Image({
})

let imgAttributes:
| {
src: string
srcSet?: string
}
| {
'data-src': string
'data-srcset'?: string
}
if (!lazy) {
| Pick<JSX.IntrinsicElements['img'], 'src' | 'srcSet'>
| undefined

if (isVisible) {
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,
Expand Down Expand Up @@ -516,7 +449,7 @@ export default function Image({
decoding="async"
className={className}
sizes={sizes}
ref={thisEl}
ref={setRef}
style={imgStyle}
/>
</div>
Expand Down
93 changes: 17 additions & 76 deletions packages/next/client/link.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { Children } from 'react'
import React, { Children, useEffect } from 'react'
import { UrlObject } from 'url'
import {
addBasePath,
Expand All @@ -9,6 +9,7 @@ import {
resolveHref,
} from '../next-server/lib/router/router'
import { useRouter } from './router'
import { useIntersection } from './use-intersection'

type Url = string | UrlObject
type RequiredKeys<T> = {
Expand All @@ -31,60 +32,8 @@ export type LinkProps = {
type LinkPropsRequired = RequiredKeys<LinkProps>
type LinkPropsOptional = OptionalKeys<LinkProps>

let cachedObserver: IntersectionObserver
const listeners = new Map<Element, () => void>()
const IntersectionObserver =
typeof window !== 'undefined' ? window.IntersectionObserver : null
const prefetched: { [cacheKey: string]: boolean } = {}

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 (!listeners.has(entry.target)) {
return
}

const cb = listeners.get(entry.target)!
if (entry.isIntersecting || entry.intersectionRatio > 0) {
cachedObserver.unobserve(entry.target)
listeners.delete(entry.target)
cb()
}
})
},
{ rootMargin: '200px' }
))
}

const listenToIntersections = (el: Element, cb: () => void) => {
const observer = getObserver()
if (!observer) {
return () => {}
}

observer.observe(el)
listeners.set(el, cb)
return () => {
try {
observer.unobserve(el)
} catch (err) {
console.error(err)
}
listeners.delete(el)
}
}

function prefetch(
router: NextRouter,
href: string,
Expand Down Expand Up @@ -285,39 +234,31 @@ function Link(props: React.PropsWithChildren<LinkProps>) {
const child: any = Children.only(children)
const childRef: any = child && typeof child === 'object' && child.ref

const cleanup = React.useRef<() => void>()
const [setIntersectionRef, isVisible] = useIntersection({
rootMargin: '200px',
})
const setRef = React.useCallback(
(el: Element) => {
// cleanup previous event handlers
if (cleanup.current) {
cleanup.current()
cleanup.current = undefined
}

if (p && IntersectionObserver && el && el.tagName && isLocalURL(href)) {
// Join on an invalid URI character
const isPrefetched = prefetched[href + '%' + as]
if (!isPrefetched) {
cleanup.current = listenToIntersections(el, () => {
prefetch(router, href, as, {
locale:
typeof locale !== 'undefined'
? locale
: router && router.locale,
})
})
}
}

setIntersectionRef(el)
if (childRef) {
if (typeof childRef === 'function') childRef(el)
else if (typeof childRef === 'object') {
childRef.current = el
}
}
},
[p, childRef, href, as, router, locale]
[childRef, setIntersectionRef]
)
useEffect(() => {
const shouldPrefetch = isVisible && p && isLocalURL(href)
const isPrefetched = prefetched[href + '%' + as]
if (shouldPrefetch && !isPrefetched) {
prefetch(router, href, as, {
locale:
typeof locale !== 'undefined' ? locale : router && router.locale,
})
}
}, [as, href, isVisible, locale, p, router])

const childProps: {
onMouseEnter?: React.MouseEventHandler
Expand Down
102 changes: 102 additions & 0 deletions packages/next/client/use-intersection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { useCallback, useEffect, useRef, useState } from 'react'

type UseIntersectionObserverInit = Pick<IntersectionObserverInit, 'rootMargin'>
type UseIntersection = { disabled?: boolean } & UseIntersectionObserverInit
type ObserveCallback = (isVisible: boolean) => void

const hasIntersectionObserver = typeof IntersectionObserver !== 'undefined'

export function useIntersection<T extends Element>({
rootMargin,
disabled,
}: UseIntersection): [(element: T | null) => void, boolean] {
const isDisabled = disabled || !hasIntersectionObserver

const unobserve = useRef<Function>()
const [visible, setVisible] = useState(false)

const setRef = useCallback(
(el: T | null) => {
if (unobserve.current) {
unobserve.current()
unobserve.current = undefined
}

if (isDisabled || visible) return

if (el && el.tagName) {
unobserve.current = observe(
el,
(isVisible) => isVisible && setVisible(isVisible),
{ rootMargin }
)
}
},
[isDisabled, rootMargin, visible]
)

useEffect(() => {
if (!hasIntersectionObserver) {
if (!visible) setVisible(true)
}
}, [visible])

return [setRef, visible]
}

function observe(
element: Element,
callback: ObserveCallback,
options: UseIntersectionObserverInit
) {
const { id, observer, elements } = createObserver(options)
elements.set(element, callback)

observer.observe(element)
return function unobserve() {
observer.unobserve(element)

// Destroy observer when there's nothing left to watch:
if (elements.size === 0) {
observer.disconnect()
observers.delete(id)
}
}
}

const observers = new Map<
string,
{
id: string
observer: IntersectionObserver
elements: Map<Element, ObserveCallback>
}
>()
function createObserver(options: UseIntersectionObserverInit) {
const id = options.rootMargin || ''
let instance = observers.get(id)
if (instance) {
return instance
}

const elements = new Map<Element, ObserveCallback>()
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
const callback = elements.get(entry.target)
const isVisible = entry.isIntersecting || entry.intersectionRatio > 0
if (callback && isVisible) {
callback(isVisible)
}
})
}, options)

observers.set(
id,
(instance = {
id,
observer,
elements,
})
)
return instance
}
2 changes: 1 addition & 1 deletion test/integration/image-component/basic/test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ function lazyLoadingTests() {
})
it('should pass through classes on a lazy loaded image', async () => {
expect(await browser.elementById('lazy-mid').getAttribute('class')).toBe(
'exampleclass __lazy'
'exampleclass'
)
})
it('should load the second image after scrolling down', async () => {
Expand Down
Loading