UNPKG

@react-md/utils

Version:
269 lines (241 loc) 7.11 kB
import type { Ref } from "react"; import { ResizeObserver } from "@juggle/resize-observer"; import type { EnsuredRefs } from "../useEnsuredRef"; import { useEnsuredRef } from "../useEnsuredRef"; import { useIsomorphicLayoutEffect } from "../useIsomorphicLayoutEffect"; /** * @remarks \@since 2.3.0 */ export interface UseResizeObserverOptions<E extends HTMLElement> { /** * An optional ref to merge with the returned ref handler function */ ref?: Ref<E | null>; /** * Boolean if the `onResize` callback should not be triggered if only the * height has changed for the watched element. */ disableHeight?: boolean; /** * Boolean if the `onResize` callback should not be triggered if only the * width has changed for the watched element. */ disableWidth?: boolean; } /** * @remarks \@since 2.3.0 */ export interface ResizeObserverElementSize { /** * The height for the element that was changed. */ height: number; /** * The width for the element that was changed. */ width: number; /** * The scroll height for the element that was changed. */ scrollHeight: number; /** * The scroll height for the element that was changed. */ scrollWidth: number; } /** * @remarks \@since 2.3.0 */ export interface ResizeObserverElementData<E extends HTMLElement = HTMLElement> extends ResizeObserverElementSize { /** * The element that changed due to the resize observer. */ element: E; } /** * The callback that is triggered each time an element's size change has been * observed. * @remarks \@since 2.3.0 */ export type OnResizeObserverChange<E extends HTMLElement = HTMLElement> = ( resizeData: ResizeObserverElementData<E> ) => void; /** * @internal */ interface ResizeObserverSubscription<E extends HTMLElement> { readonly target: E; readonly handler: OnResizeObserverChange<E>; readonly disableHeight: boolean; readonly disableWidth: boolean; prevSize: ResizeObserverElementSize | undefined; } /** * @internal */ function isHeightChange( prevSize: ResizeObserverElementSize | undefined, nextSize: ResizeObserverElementSize ): boolean { return ( !prevSize || prevSize.height !== nextSize.height || prevSize.scrollHeight !== nextSize.scrollHeight ); } /** * @internal */ function isWidthChange( prevSize: ResizeObserverElementSize | undefined, nextSize: ResizeObserverElementSize ): boolean { return ( !prevSize || prevSize.width !== nextSize.width || prevSize.scrollWidth !== nextSize.scrollWidth ); } /** * Why is there a single shared observer instead of multiple and a * "subscription" model? * * Note: Probably a bit of a premature optimization right now... * * @see https://github.com/WICG/resize-observer/issues/59 * @internal */ let sharedObserver: ResizeObserver | undefined; /** * * @internal */ const subscriptions: ResizeObserverSubscription<HTMLElement>[] = []; /** * Lazy initializes the shared resize observer which will loop through all the * subscriptions when a resize event is called. * * @internal */ function init(): void { if (sharedObserver || typeof document === "undefined") { return; } sharedObserver = new ResizeObserver((entries) => { // Note: might need to wait until an requestAnimationFrame has completed to // fix the resize observer loop exceeded error if switching to // `useIsomorphicLayoutEffect` and a shared observer didn't fix that error: // https://stackoverflow.com/questions/49384120/resizeobserver-loop-limit-exceeded for (let i = 0; i < entries.length; i += 1) { const entry = entries[i]; const currentSubscriptions = subscriptions.filter( ({ target }) => target === entry.target ); if (!currentSubscriptions.length) { return; } const { height, width } = entry.contentRect; const { scrollHeight, scrollWidth } = entry.target; const nextSize: ResizeObserverElementSize = { height, width, scrollHeight, scrollWidth, }; for (let j = 0; j < currentSubscriptions.length; j += 1) { const subscription = currentSubscriptions[j]; const { handler, prevSize, disableHeight, disableWidth } = subscription; const isNewHeight = isHeightChange(prevSize, nextSize); const isNewWidth = isWidthChange(prevSize, nextSize); if ((isNewHeight && !disableHeight) || (isNewWidth && !disableWidth)) { subscription.prevSize = nextSize; handler({ ...nextSize, element: entry.target as typeof subscription.target, }); } } } }); } /** * * @internal */ function subscribe<E extends HTMLElement>( target: E, onResize: OnResizeObserverChange<E>, disableHeight: boolean, disableWidth: boolean ): void { const exists = subscriptions.find((sub) => sub.target === target); subscriptions.push({ target, handler: onResize as OnResizeObserverChange<HTMLElement>, disableWidth, disableHeight, prevSize: undefined, }); if (!exists) { // I'll silently fail non-initialized observers for now until it becomes an // issue... But how will I ever know? sharedObserver?.observe(target); } } /** * * @internal */ function unsubscribe<E extends HTMLElement>( target: E, onResize: OnResizeObserverChange<E>, disableHeight: boolean, disableWidth: boolean ): void { const i = subscriptions.findIndex( (sub) => sub.target === target && sub.handler === onResize && sub.disableWidth === disableWidth && sub.disableHeight === disableHeight ); if (i !== -1) { subscriptions.splice(i, 1); } const remaining = subscriptions.some((sub) => sub.target === target); if (!remaining) { // I'll silently fail non-initialized observers for now until it becomes an // issue... But how will I ever know? sharedObserver?.unobserve(target); } } /** * The new resize observer API that returns a `refHandler` to attach to a DOM * node instead of using the weird `target` API. * * @remarks \@since 2.3.0 * @param onResize - The resize handler to call when the element has changed * height or width. If you notice performance issues or other oddities, it is * recommended to wrap this function in `useCallback`. * @param options - Any additional options to use for the resize observer. */ export function useResizeObserver<E extends HTMLElement>( onResize: OnResizeObserverChange<E>, options: UseResizeObserverOptions<E> = {} ): EnsuredRefs<E> { const { ref: propRef, disableWidth = false, disableHeight = false } = options; const [ref, refHandler] = useEnsuredRef<E>(propRef); useIsomorphicLayoutEffect(() => { const target = ref.current; if ((disableHeight && disableWidth) || !target) { return; } init(); subscribe(target, onResize, disableHeight, disableWidth); return () => { unsubscribe(target, onResize, disableHeight, disableWidth); }; }, [disableHeight, disableWidth, onResize]); return [ref, refHandler]; }