diff --git a/.changeset/perf-use-anchored-position.md b/.changeset/perf-use-anchored-position.md new file mode 100644 index 00000000000..44f7487bdc3 --- /dev/null +++ b/.changeset/perf-use-anchored-position.md @@ -0,0 +1,10 @@ +--- +'@primer/react': patch +--- + +perf(hooks): Optimize useAnchoredPosition to avoid duplicate observers and throttle updates + +- Use window resize listener instead of ResizeObserver on documentElement +- Add ResizeObserver for floating element with first-immediate throttling +- Use updatePositionRef to avoid callback identity changes +- Deduplicate observer setup to avoid redundant work diff --git a/packages/react/src/hooks/useAnchoredPosition.ts b/packages/react/src/hooks/useAnchoredPosition.ts index 32777aad1d7..4196e249a52 100644 --- a/packages/react/src/hooks/useAnchoredPosition.ts +++ b/packages/react/src/hooks/useAnchoredPosition.ts @@ -2,7 +2,6 @@ import React from 'react' import {getAnchoredPosition} from '@primer/behaviors' import type {AnchorPosition, PositionSettings} from '@primer/behaviors' import {useProvidedRefOrCreate} from './useProvidedRefOrCreate' -import {useResizeObserver} from './useResizeObserver' import useLayoutEffect from '../utils/useIsomorphicLayoutEffect' export interface AnchoredPositionHookSettings extends Partial { @@ -91,14 +90,77 @@ export function useAnchoredPosition( [floatingElementRef, anchorElementRef, ...dependencies], ) + // Store updatePosition in a ref to avoid re-subscribing listeners when dependencies change. + // The ref always has the latest function, so listeners don't need updatePosition in their deps. + const updatePositionRef = React.useRef(updatePosition) + useLayoutEffect(() => { + updatePositionRef.current = updatePosition + }) + useLayoutEffect(() => { savedOnPositionChange.current = settings?.onPositionChange }, [settings?.onPositionChange]) + // Recalculate position when dependencies change useLayoutEffect(updatePosition, [updatePosition]) - useResizeObserver(updatePosition) // watches for changes in window size - useResizeObserver(updatePosition, floatingElementRef as React.RefObject) // watches for changes in floating element size + // Window resize listener for viewport changes. + // Uses updatePositionRef to avoid re-subscribing on every dependency change. + React.useEffect(() => { + const handleResize = () => updatePositionRef.current() + // eslint-disable-next-line github/prefer-observers -- window.addEventListener is used here to handle viewport (window) resize events, which cannot be detected by ResizeObserver (which only observes element size changes). + window.addEventListener('resize', handleResize) + return () => window.removeEventListener('resize', handleResize) + }, []) + + // Single coalesced ResizeObserver for floating element and anchor element. + // This reduces layout reads during resize events (better INP) by batching + // observations into one callback instead of triggering updatePosition 2x. + // Uses updatePositionRef to avoid re-creating observer on dependency changes. + useLayoutEffect(() => { + const floatingEl = floatingElementRef.current + const anchorEl = anchorElementRef.current + + if (typeof ResizeObserver !== 'function') { + return + } + + // First callback must be immediate - ResizeObserver fires synchronously + // on observe() and positioning must be correct before paint + let isFirstCallback = true + let pendingFrame: number | null = null + + const observer = new ResizeObserver(() => { + if (isFirstCallback) { + isFirstCallback = false + updatePositionRef.current() + return + } + + // Subsequent callbacks are throttled with rAF for better INP + if (pendingFrame === null) { + pendingFrame = requestAnimationFrame(() => { + pendingFrame = null + updatePositionRef.current() + }) + } + }) + + // Observe floating and anchor elements if available + if (floatingEl instanceof Element) { + observer.observe(floatingEl) + } + if (anchorEl instanceof Element) { + observer.observe(anchorEl) + } + + return () => { + if (pendingFrame !== null) { + cancelAnimationFrame(pendingFrame) + } + observer.disconnect() + } + }, [floatingElementRef, anchorElementRef]) return { floatingElementRef,