From f39078ffab59001eab38e4c703104308e44fbcc9 Mon Sep 17 00:00:00 2001 From: Julien Date: Mon, 5 Feb 2024 14:34:57 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Improve=20useIntersectionObserver?= =?UTF-8?q?=20(#464)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ Improve useIntersectionObserver * 🔖 Add changeset * 🐛 Typo --- .changeset/polite-pianos-live.md | 5 + .../useIntersectionObserver.demo.tsx | 12 +- .../useIntersectionObserver.md | 23 ++- .../useIntersectionObserver.ts | 185 +++++++++++++++--- 4 files changed, 189 insertions(+), 36 deletions(-) create mode 100644 .changeset/polite-pianos-live.md diff --git a/.changeset/polite-pianos-live.md b/.changeset/polite-pianos-live.md new file mode 100644 index 00000000..e747d007 --- /dev/null +++ b/.changeset/polite-pianos-live.md @@ -0,0 +1,5 @@ +--- +'usehooks-ts': minor +--- + +Updated `useIntersectionObserver` API and fixed #395, #271 and #182, see #464. diff --git a/packages/usehooks-ts/src/useIntersectionObserver/useIntersectionObserver.demo.tsx b/packages/usehooks-ts/src/useIntersectionObserver/useIntersectionObserver.demo.tsx index cca886f5..d03c6066 100644 --- a/packages/usehooks-ts/src/useIntersectionObserver/useIntersectionObserver.demo.tsx +++ b/packages/usehooks-ts/src/useIntersectionObserver/useIntersectionObserver.demo.tsx @@ -1,13 +1,13 @@ -import { useRef } from 'react' - import { useIntersectionObserver } from './useIntersectionObserver' const Section = (props: { title: string }) => { - const ref = useRef(null) - const entry = useIntersectionObserver(ref, {}) - const isVisible = !!entry?.isIntersecting + const { isIntersecting, ref } = useIntersectionObserver({ + threshold: 0.5, + }) - console.log(`Render Section ${props.title}`, { isVisible }) + console.log(`Render Section ${props.title}`, { + isIntersecting, + }) return (
+For more information on the Intersection Observer API and its options, refer to the [MDN Intersection Observer API documentation](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API). -**Source:** +### Return -I discovered this way of using `IntersectionObserver` via this [post medium](https://medium.com/the-non-traditional-developer/how-to-use-an-intersectionobserver-in-a-react-hook-9fb061ac6cb5) while playing to build a [lazy-loaded collection of images](https://react-gallery.juliencaron.com/). +The `IntersectionResult` type supports both array and object destructuring and includes the following properties: + +- `ref`: A function that can be used as a ref callback to set the target element. +- `isIntersecting`: A boolean indicating if the target element is intersecting with the viewport. +- `entry`: An optional `IntersectionObserverEntry` object representing the state of the intersection. diff --git a/packages/usehooks-ts/src/useIntersectionObserver/useIntersectionObserver.ts b/packages/usehooks-ts/src/useIntersectionObserver/useIntersectionObserver.ts index a490a4c5..3166ce49 100644 --- a/packages/usehooks-ts/src/useIntersectionObserver/useIntersectionObserver.ts +++ b/packages/usehooks-ts/src/useIntersectionObserver/useIntersectionObserver.ts @@ -1,23 +1,65 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import type { RefObject } from 'react' +type State = { + isIntersecting: boolean + entry?: IntersectionObserverEntry +} + +type ObserverCallback = ( + isIntersecting: boolean, + entry: IntersectionObserverEntry, +) => void + /** * Represents the options for configuring the Intersection Observer. - * @interface Args - * @property {number} [threshold=0] - A threshold indicating the percentage of the target's visibility needed to trigger the callback. - * @property {Element | null} [root=null] - The element that is used as the viewport for checking visibility of the target. + * @interface IntersectionObserverOptions + * @property {number | number[]} [threshold=0] - A threshold indicating the percentage of the target's visibility needed to trigger the callback. + * @property {Element | Document | null} [root=null] - The element that is used as the viewport for checking visibility of the target. * @property {string} [rootMargin='0%'] - A margin around the root. * @property {boolean} [freezeOnceVisible=false] - If true, freezes the intersection state once the element becomes visible. + * @property {ObserverCallback} [onChange] - A callback function to be invoked when the intersection state changes. + * @property {boolean} [initialIsIntersecting=false] - The initial state of the intersection. */ -interface Args extends IntersectionObserverInit { +interface IntersectionObserverOptions extends IntersectionObserverInit { freezeOnceVisible?: boolean + onChange?: ObserverCallback + initialIsIntersecting?: boolean +} + +/** Supports both array and object destructing */ +type IntersectionResult = [ + (node?: Element | null) => void, + boolean, + IntersectionObserverEntry | undefined, +] & { + ref: (node?: Element | null) => void + isIntersecting: boolean + entry?: IntersectionObserverEntry } /** + * Custom hook for tracking the intersection of a DOM element with its containing element or the viewport. + * @param {IntersectionObserverOptions} options - The options for the Intersection Observer. + * @returns {IntersectionResult} The ref callback, a boolean indicating if the element is intersecting, and the intersection observer entry. + * @see [Documentation](https://usehooks-ts.com/react-hook/use-intersection-observer) + * @see [MDN Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) + * @example + * // Example 1 + * const [ref, isIntersecting, entry] = useIntersectionObserver({ threshold: 0.5 }); + * + * // Example 2 + * const { ref, isIntersecting, entry } = useIntersectionObserver({ threshold: 0.5 }); + */ +export function useIntersectionObserver( + options: IntersectionObserverOptions, +): IntersectionResult +/** + * @deprecated Use the new signature with an unique option object instead. * Custom hook for tracking the intersection of a DOM element with its containing element or the viewport. * @param {RefObject} elementRef - The ref object for the DOM element to observe. - * @param {Args} options - The options for the Intersection Observer (optional). + * @param {IntersectionObserverOptions} [options] - The options for the Intersection Observer (optional). * @returns {IntersectionObserverEntry | undefined} The intersection observer entry representing the state of the intersection. * @see [Documentation](https://usehooks-ts.com/react-hook/use-intersection-observer) * @see [MDN Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) @@ -29,39 +71,136 @@ interface Args extends IntersectionObserverInit { */ export function useIntersectionObserver( elementRef: RefObject, - { + legacyOptions: IntersectionObserverOptions, +): IntersectionObserverEntry | undefined +/** + * Custom hook for tracking the intersection of a DOM element with its containing element or the viewport. + * @param {IntersectionObserverOptions | RefObject} optionsOrLegacyRef - The options for the Intersection Observer. + * @param {?IntersectionObserverOptions} [legacyOptions] - The options for the Intersection Observer (optional, legacy). + * @returns {NewIntersectionResult | IntersectionObserverEntry | undefined} The ref callback, a boolean indicating if the element is intersecting, and the intersection observer entry. + * @see [Documentation](https://usehooks-ts.com/react-hook/use-intersection-observer) + * @see [MDN Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) + * @example + * // Example 1 + * const [ref, isIntersecting, entry] = useIntersectionObserver({ threshold: 0.5 }); + * + * // Example 2 + * const { ref, isIntersecting, entry } = useIntersectionObserver({ threshold: 0.5 }); + */ +export function useIntersectionObserver( + optionsOrLegacyRef: IntersectionObserverOptions | RefObject, + legacyOptions?: IntersectionObserverOptions, +): IntersectionResult | IntersectionObserverEntry | undefined { + // TODO: Remove this mess when the old signature is removed. + const isLegacySignature = 'current' in optionsOrLegacyRef + const options = isLegacySignature ? legacyOptions : optionsOrLegacyRef + const { threshold = 0, root = null, rootMargin = '0%', freezeOnceVisible = false, - }: Args, -): IntersectionObserverEntry | undefined { - const [entry, setEntry] = useState() + initialIsIntersecting = false, + } = options ?? {} - const frozen = entry?.isIntersecting && freezeOnceVisible + const [newRef, setNewRef] = useState(null) + const ref = isLegacySignature ? optionsOrLegacyRef.current : newRef - const updateEntry = ([entry]: IntersectionObserverEntry[]): void => { - setEntry(entry) - } + const [state, setState] = useState(() => ({ + isIntersecting: initialIsIntersecting, + entry: undefined, + })) + + const callbackRef = useRef() + + callbackRef.current = options?.onChange + + const frozen = state.entry?.isIntersecting && freezeOnceVisible useEffect(() => { - const node = elementRef.current // DOM Ref - if (!node) return + // Ensure we have a ref to observe + if (!ref) return + + // Ensure the browser supports the Intersection Observer API + if (!('IntersectionObserver' in window)) return + + // Skip if frozen + if (frozen) return + + let unobserve: (() => void) | undefined + + const observer = new IntersectionObserver( + (entries: IntersectionObserverEntry[]): void => { + const thresholds = Array.isArray(observer.thresholds) + ? observer.thresholds + : [observer.thresholds] + + entries.forEach(entry => { + const isIntersecting = + entry.isIntersecting && + thresholds.some(threshold => entry.intersectionRatio >= threshold) + + setState({ isIntersecting, entry }) - const hasIOSupport = !!window.IntersectionObserver - if (!hasIOSupport || frozen) return + if (callbackRef.current) { + callbackRef.current(isIntersecting, entry) + } - const observerParams = { threshold, root, rootMargin } - const observer = new IntersectionObserver(updateEntry, observerParams) + if (isIntersecting && freezeOnceVisible && unobserve) { + unobserve() + unobserve = undefined + } + }) + }, + { threshold, root, rootMargin }, + ) - observer.observe(node) + observer.observe(ref) return () => { observer.disconnect() } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [elementRef?.current, JSON.stringify(threshold), root, rootMargin, frozen]) + }, [ + ref, + // eslint-disable-next-line react-hooks/exhaustive-deps + JSON.stringify(threshold), + root, + rootMargin, + frozen, + freezeOnceVisible, + ]) + + // ensures that if the observed element changes, the intersection observer is reinitialized + const prevRef = useRef(null) + + useEffect(() => { + if ( + !ref && + state.entry?.target && + !freezeOnceVisible && + !frozen && + prevRef.current !== state.entry.target + ) { + prevRef.current = state.entry.target + setState({ isIntersecting: initialIsIntersecting, entry: undefined }) + } + }, [ref, state.entry, freezeOnceVisible, frozen, initialIsIntersecting]) + + if (isLegacySignature) { + return state.entry + } + + const result = [ + setNewRef, + !!state.isIntersecting, + state.entry, + ] as IntersectionResult + + // Support object destructuring, by adding the specific values. + result.ref = result[0] + result.isIntersecting = result[1] + result.entry = result[2] - return entry + return result }