diff --git a/.changeset/perf-anchored-position-cache-scrollables.md b/.changeset/perf-anchored-position-cache-scrollables.md new file mode 100644 index 00000000000..eb9feea9e44 --- /dev/null +++ b/.changeset/perf-anchored-position-cache-scrollables.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +`useAnchoredPosition`: improve performance by caching the scrollable-ancestor walk per anchor, reducing repositioning work for overlays, menus, and tooltips. diff --git a/packages/react/src/hooks/useAnchoredPosition.ts b/packages/react/src/hooks/useAnchoredPosition.ts index 611d1b1e920..f25caf420ef 100644 --- a/packages/react/src/hooks/useAnchoredPosition.ts +++ b/packages/react/src/hooks/useAnchoredPosition.ts @@ -142,6 +142,17 @@ export function useAnchoredPosition( useResizeObserver(updatePosition, undefined, [], enabled) // watches for changes in window size useResizeObserver(updatePosition, floatingElementRef as React.RefObject, [], enabled) // watches for changes in floating element size + // Caches the scrollable-ancestor walk (which calls getComputedStyle on every + // ancestor) keyed by the anchor element, so the scroll-listener effect below can + // re-run on `updatePosition`/`enabled` changes without repeating the DOM walk when + // the anchor is unchanged (the common case). This assumes the anchor is not + // re-parented while it stays mounted — anchored overlays keep a stable anchor for + // their lifetime, so a moved-but-same-identity anchor is not a supported case. + const scrollAncestorsCacheRef = React.useRef<{anchor: Element | null; scrollables: Array}>({ + anchor: null, + scrollables: [], + }) + // Recalculate position when any scrollable ancestor of the anchor scrolls. // Uses requestAnimationFrame to avoid layout thrashing during scroll. React.useEffect(() => { @@ -158,7 +169,19 @@ export function useAnchoredPosition( }) } - const scrollables = getScrollableAncestors(anchorEl) + // The set of scrollable ancestors depends on the anchor element and its + // position in the DOM tree. Anchored overlays keep a stable, non-re-parented + // anchor while mounted, so keying the cache on anchor identity lets us reuse + // the walk when the anchor hasn't changed instead of re-running + // getComputedStyle up the whole ancestor chain on every effect re-run. + const cache = scrollAncestorsCacheRef.current + let scrollables: Array + if (cache.anchor === anchorEl) { + scrollables = cache.scrollables + } else { + scrollables = getScrollableAncestors(anchorEl) + scrollAncestorsCacheRef.current = {anchor: anchorEl, scrollables} + } for (const scrollable of scrollables) { // eslint-disable-next-line github/prefer-observers -- IntersectionObserver cannot detect continuous scroll position changes needed for repositioning scrollable.addEventListener('scroll', handleScroll)