From 9df1815a16ebb3cd5e5b32b20834868ef1ab3d02 Mon Sep 17 00:00:00 2001 From: Joe Haddad Date: Fri, 6 Nov 2020 12:24:29 -0500 Subject: [PATCH 1/9] Control prefetching with React --- packages/next/client/image.tsx | 95 +++--------------- packages/next/client/link.tsx | 93 ++++-------------- packages/next/client/use-intersection.tsx | 114 ++++++++++++++++++++++ 3 files changed, 146 insertions(+), 156 deletions(-) create mode 100644 packages/next/client/use-intersection.tsx diff --git a/packages/next/client/image.tsx b/packages/next/client/image.tsx index 24454bc6358bd..47e9e674d4a47 100644 --- a/packages/next/client/image.tsx +++ b/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] @@ -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 @@ -255,8 +217,6 @@ export default function Image({ objectPosition, ...all }: ImageProps) { - const thisEl = useRef(null) - let rest: Partial = all let layout: NonNullable = sizes ? 'responsive' : 'intrinsic' let unsized = false @@ -306,34 +266,20 @@ export default function Image({ } } - let lazy = loading === 'lazy' - if (!priority && typeof loading === 'undefined') { - lazy = true - } - + const [setRef, isVisible] = useIntersection({ + rootMargin: '200px', + disabled: priority, + }) + let lazy = + !isVisible && + !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 } - 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 widthInt = getInt(width) const heightInt = getInt(height) const qualityInt = getInt(quality) @@ -451,14 +397,9 @@ export default function Image({ }) let imgAttributes: - | { - src: string - srcSet?: string - } - | { - 'data-src': string - 'data-srcset'?: string - } + | Pick + | undefined + if (!lazy) { imgAttributes = { src: imgSrc, @@ -466,13 +407,7 @@ export default function Image({ if (imgSrcSet) { imgAttributes.srcSet = imgSrcSet } - } else { - imgAttributes = { - 'data-src': imgSrc, - } - if (imgSrcSet) { - imgAttributes['data-srcset'] = imgSrcSet - } + } else if (process.env.__NEXT_TEST_MODE) { className = className ? className + ' __lazy' : '__lazy' } @@ -516,7 +451,7 @@ export default function Image({ decoding="async" className={className} sizes={sizes} - ref={thisEl} + ref={setRef} style={imgStyle} /> diff --git a/packages/next/client/link.tsx b/packages/next/client/link.tsx index 9c005fcc3f50a..1f53da32c45b2 100644 --- a/packages/next/client/link.tsx +++ b/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, @@ -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 = { @@ -31,60 +32,8 @@ export type LinkProps = { type LinkPropsRequired = RequiredKeys type LinkPropsOptional = OptionalKeys -let cachedObserver: IntersectionObserver -const listeners = new Map 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, @@ -285,30 +234,12 @@ function Link(props: React.PropsWithChildren) { 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') { @@ -316,8 +247,18 @@ function Link(props: React.PropsWithChildren) { } } }, - [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 diff --git a/packages/next/client/use-intersection.tsx b/packages/next/client/use-intersection.tsx new file mode 100644 index 0000000000000..45fb780a6adbc --- /dev/null +++ b/packages/next/client/use-intersection.tsx @@ -0,0 +1,114 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +type UseIntersectionObserverInit = Pick +type UseIntersection = { disabled?: boolean } & UseIntersectionObserverInit +type ObserveCallback = (isVisible: boolean) => void + +const hasIntersectionObserver = typeof IntersectionObserver !== 'undefined' + +export function useIntersection({ + rootMargin, + disabled, +}: UseIntersection): [(element: T | null) => void, boolean] { + const isDisabled = disabled || hasIntersectionObserver + + const unobserve = useRef() + 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) { + unobserve.current = observe( + el, + (isVisible) => isVisible && setVisible(isVisible), + { rootMargin } + ) + } + }, + [isDisabled, rootMargin, visible] + ) + + useEffect(() => { + if (!hasIntersectionObserver) { + setVisible(true) + } + }, []) + + return [setRef, visible] +} + +function observe( + element: Element, + callback: ObserveCallback, + options: UseIntersectionObserverInit +) { + const { id, observer, elements } = createObserver(options) + if (!elements.has(element)) { + elements.set(element, []) + } + + const callbacks = elements.get(element)! + callbacks.push(callback) + observer.observe(element) + + return function unobserve() { + callbacks.splice(callbacks.indexOf(callback), 1) + + // Unobserve element when there are no remaining listeners: + if (callbacks.length === 0) { + elements.delete(element) + 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> + } +>() +function createObserver(options: UseIntersectionObserverInit) { + const id = options.rootMargin || '' + let instance = observers.get(id) + if (instance) { + return instance + } + + const elements = new Map>() + const observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + const el = elements.get(entry.target) + if (el) { + el.forEach((callback) => { + callback(entry.isIntersecting || entry.intersectionRatio > 0) + }) + } + }) + }, options) + + observers.set( + id, + (instance = { + id, + observer, + elements, + }) + ) + return instance +} From 516b9bf2d01dfdc7e38936685181e8f881aacbf4 Mon Sep 17 00:00:00 2001 From: Joe Haddad Date: Fri, 6 Nov 2020 12:45:57 -0500 Subject: [PATCH 2/9] Fix has check --- packages/next/client/use-intersection.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/next/client/use-intersection.tsx b/packages/next/client/use-intersection.tsx index 45fb780a6adbc..953abd0f292fe 100644 --- a/packages/next/client/use-intersection.tsx +++ b/packages/next/client/use-intersection.tsx @@ -4,13 +4,13 @@ type UseIntersectionObserverInit = Pick type UseIntersection = { disabled?: boolean } & UseIntersectionObserverInit type ObserveCallback = (isVisible: boolean) => void -const hasIntersectionObserver = typeof IntersectionObserver !== 'undefined' +const hasIntersectionObserver = !!self.IntersectionObserver export function useIntersection({ rootMargin, disabled, }: UseIntersection): [(element: T | null) => void, boolean] { - const isDisabled = disabled || hasIntersectionObserver + const isDisabled = disabled || !hasIntersectionObserver const unobserve = useRef() const [visible, setVisible] = useState(false) From f5c7bdf0d21b1b24aa1eb7be3e437a511548f742 Mon Sep 17 00:00:00 2001 From: Joe Haddad Date: Fri, 6 Nov 2020 12:47:28 -0500 Subject: [PATCH 3/9] minimize updates --- packages/next/client/use-intersection.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/next/client/use-intersection.tsx b/packages/next/client/use-intersection.tsx index 953abd0f292fe..ac2094e731761 100644 --- a/packages/next/client/use-intersection.tsx +++ b/packages/next/client/use-intersection.tsx @@ -37,9 +37,9 @@ export function useIntersection({ useEffect(() => { if (!hasIntersectionObserver) { - setVisible(true) + if (!visible) setVisible(true) } - }, []) + }, [visible]) return [setRef, visible] } From d772031955268c2e38a27944d7cc57940a948c66 Mon Sep 17 00:00:00 2001 From: Joe Haddad Date: Fri, 6 Nov 2020 12:52:48 -0500 Subject: [PATCH 4/9] Remove reused element support --- packages/next/client/use-intersection.tsx | 28 +++++++---------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/packages/next/client/use-intersection.tsx b/packages/next/client/use-intersection.tsx index ac2094e731761..5448d435fa458 100644 --- a/packages/next/client/use-intersection.tsx +++ b/packages/next/client/use-intersection.tsx @@ -50,22 +50,11 @@ function observe( options: UseIntersectionObserverInit ) { const { id, observer, elements } = createObserver(options) - if (!elements.has(element)) { - elements.set(element, []) - } + elements.set(element, callback) - const callbacks = elements.get(element)! - callbacks.push(callback) observer.observe(element) - return function unobserve() { - callbacks.splice(callbacks.indexOf(callback), 1) - - // Unobserve element when there are no remaining listeners: - if (callbacks.length === 0) { - elements.delete(element) - observer.unobserve(element) - } + observer.unobserve(element) // Destroy observer when there's nothing left to watch: if (elements.size === 0) { @@ -80,7 +69,7 @@ const observers = new Map< { id: string observer: IntersectionObserver - elements: Map> + elements: Map } >() function createObserver(options: UseIntersectionObserverInit) { @@ -90,14 +79,13 @@ function createObserver(options: UseIntersectionObserverInit) { return instance } - const elements = new Map>() + const elements = new Map() const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { - const el = elements.get(entry.target) - if (el) { - el.forEach((callback) => { - callback(entry.isIntersecting || entry.intersectionRatio > 0) - }) + const callback = elements.get(entry.target) + const isVisible = entry.isIntersecting || entry.intersectionRatio > 0 + if (callback && isVisible) { + callback(isVisible) } }) }, options) From 38e9aabed96eaf24a09d417548c856581cb570e3 Mon Sep 17 00:00:00 2001 From: Joe Haddad Date: Fri, 6 Nov 2020 13:00:26 -0500 Subject: [PATCH 5/9] sigh --- packages/next/client/use-intersection.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/next/client/use-intersection.tsx b/packages/next/client/use-intersection.tsx index 5448d435fa458..e5526592df7c1 100644 --- a/packages/next/client/use-intersection.tsx +++ b/packages/next/client/use-intersection.tsx @@ -4,7 +4,7 @@ type UseIntersectionObserverInit = Pick type UseIntersection = { disabled?: boolean } & UseIntersectionObserverInit type ObserveCallback = (isVisible: boolean) => void -const hasIntersectionObserver = !!self.IntersectionObserver +const hasIntersectionObserver = typeof IntersectionObserver !== 'undefined' export function useIntersection({ rootMargin, From 1eb99e5b90af822309c07c822407792877b482a1 Mon Sep 17 00:00:00 2001 From: Joe Haddad Date: Fri, 6 Nov 2020 13:27:32 -0500 Subject: [PATCH 6/9] fix intersection --- packages/next/client/use-intersection.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/next/client/use-intersection.tsx b/packages/next/client/use-intersection.tsx index e5526592df7c1..90c38cb8c1635 100644 --- a/packages/next/client/use-intersection.tsx +++ b/packages/next/client/use-intersection.tsx @@ -24,7 +24,7 @@ export function useIntersection({ if (isDisabled || visible) return - if (el) { + if (el && el.tagName) { unobserve.current = observe( el, (isVisible) => isVisible && setVisible(isVisible), From 58cc08f4bbf280f7588955bafb14bb67a3cbfd25 Mon Sep 17 00:00:00 2001 From: Joe Haddad Date: Fri, 6 Nov 2020 15:39:44 -0500 Subject: [PATCH 7/9] bump size --- test/integration/size-limit/test/index.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/size-limit/test/index.test.js b/test/integration/size-limit/test/index.test.js index 522fc5680ee42..a5c288666abfe 100644 --- a/test/integration/size-limit/test/index.test.js +++ b/test/integration/size-limit/test/index.test.js @@ -80,7 +80,7 @@ describe('Production response size', () => { ) // These numbers are without gzip compression! - const delta = responseSizesBytes - 281 * 1024 + const delta = responseSizesBytes - 282 * 1024 expect(delta).toBeLessThanOrEqual(1024) // don't increase size more than 1kb expect(delta).toBeGreaterThanOrEqual(-1024) // don't decrease size more than 1kb without updating target }) From db4ade7b4df57e6761f47cecd5ae25885260e535 Mon Sep 17 00:00:00 2001 From: Joe Haddad Date: Fri, 6 Nov 2020 16:39:07 -0500 Subject: [PATCH 8/9] add test --- .../image-component/default/pages/update.js | 23 +++++++++++++++++++ .../default/test/index.test.js | 23 +++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 test/integration/image-component/default/pages/update.js diff --git a/test/integration/image-component/default/pages/update.js b/test/integration/image-component/default/pages/update.js new file mode 100644 index 0000000000000..c0a7929a5641f --- /dev/null +++ b/test/integration/image-component/default/pages/update.js @@ -0,0 +1,23 @@ +import React, { useState } from 'react' +import Image from 'next/image' + +const Page = () => { + const [toggled, setToggled] = useState(false) + return ( +
+

Update Page

+ +

This is the index page

+ +
+ ) +} + +export default Page diff --git a/test/integration/image-component/default/test/index.test.js b/test/integration/image-component/default/test/index.test.js index 35ad401a47a1d..32342e2b73864 100644 --- a/test/integration/image-component/default/test/index.test.js +++ b/test/integration/image-component/default/test/index.test.js @@ -90,6 +90,29 @@ function runTests(mode) { } }) + it('should update the image on src change', async () => { + let browser + try { + browser = await webdriver(appPort, '/update') + + await check( + () => browser.eval(`document.getElementById("update-image").src`), + /test\.jpg/ + ) + + await browser.eval(`document.getElementById("toggle").click()`) + + await check( + () => browser.eval(`document.getElementById("update-image").src`), + /test\.png/ + ) + } finally { + if (browser) { + await browser.close() + } + } + }) + it('should work when using flexbox', async () => { let browser try { From a6d487f172afd103e6b15fa15425382a307a1ae9 Mon Sep 17 00:00:00 2001 From: Joe Haddad Date: Fri, 6 Nov 2020 17:12:39 -0500 Subject: [PATCH 9/9] update --- packages/next/client/image.tsx | 24 +++++++++---------- .../image-component/basic/test/index.test.js | 2 +- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/packages/next/client/image.tsx b/packages/next/client/image.tsx index 47e9e674d4a47..088ad92a1697e 100644 --- a/packages/next/client/image.tsx +++ b/packages/next/client/image.tsx @@ -266,20 +266,20 @@ export default function Image({ } } - const [setRef, isVisible] = useIntersection({ - rootMargin: '200px', - disabled: priority, - }) - let lazy = - !isVisible && - !priority && - (loading === 'lazy' || typeof loading === 'undefined') + 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 } + const [setRef, isIntersected] = useIntersection({ + rootMargin: '200px', + disabled: !isLazy, + }) + const isVisible = !isLazy || isIntersected + const widthInt = getInt(width) const heightInt = getInt(height) const qualityInt = getInt(quality) @@ -288,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, @@ -400,15 +400,13 @@ export default function Image({ | Pick | undefined - if (!lazy) { + if (isVisible) { imgAttributes = { src: imgSrc, } if (imgSrcSet) { imgAttributes.srcSet = imgSrcSet } - } else if (process.env.__NEXT_TEST_MODE) { - className = className ? className + ' __lazy' : '__lazy' } // No need to add preloads on the client side--by the time the application is hydrated, diff --git a/test/integration/image-component/basic/test/index.test.js b/test/integration/image-component/basic/test/index.test.js index f637d729c3c4a..c02ca2d569c4e 100644 --- a/test/integration/image-component/basic/test/index.test.js +++ b/test/integration/image-component/basic/test/index.test.js @@ -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 () => {