Skip to content

Commit

Permalink
Control <Image /> prefetching with React (#18904)
Browse files Browse the repository at this point in the history
This pull request fixes `<Image />` not updating when new props are passed by removing external DOM mutations and relying on React to do it instead.

As an added bonus, I've extracted the intersection observer from both the `<Image />` and `<Link />` component, as their instance can be shared!

The increase in size is minor (+3B), and actually a decrease for apps using both `<Image />` and `<Link />`.

---

Fixes #18698
Fixes #18369
  • Loading branch information
Timer committed Nov 6, 2020
1 parent 37fb0ad commit c8fa284
Show file tree
Hide file tree
Showing 7 changed files with 183 additions and 161 deletions.
99 changes: 16 additions & 83 deletions packages/next/client/image.tsx
@@ -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
@@ -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
@@ -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
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

0 comments on commit c8fa284

Please sign in to comment.