From 1cb9afea2ece04f31b96b11169c26f5b15b70884 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 15 Dec 2025 14:57:40 +0000 Subject: [PATCH 1/2] 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 Part of #7312 --- .changeset/perf-use-anchored-position.md | 10 +++ .../react/src/hooks/useAnchoredPosition.ts | 68 ++++++++++++++++++- 2 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 .changeset/perf-use-anchored-position.md 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..ed932272e74 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('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, From 8372dd2332c6f232766dc731301851f0f37d0809 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 15 Dec 2025 15:54:57 -0500 Subject: [PATCH 2/2] Update packages/react/src/hooks/useAnchoredPosition.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/react/src/hooks/useAnchoredPosition.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/hooks/useAnchoredPosition.ts b/packages/react/src/hooks/useAnchoredPosition.ts index ed932272e74..4196e249a52 100644 --- a/packages/react/src/hooks/useAnchoredPosition.ts +++ b/packages/react/src/hooks/useAnchoredPosition.ts @@ -108,7 +108,7 @@ export function useAnchoredPosition( // Uses updatePositionRef to avoid re-subscribing on every dependency change. React.useEffect(() => { const handleResize = () => updatePositionRef.current() - // eslint-disable-next-line github/prefer-observers + // 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) }, [])