Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/perf-anchored-position-cache-scrollables.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 24 additions & 1 deletion packages/react/src/hooks/useAnchoredPosition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,17 @@ export function useAnchoredPosition(
useResizeObserver(updatePosition, undefined, [], enabled) // watches for changes in window size
useResizeObserver(updatePosition, floatingElementRef as React.RefObject<HTMLElement | null>, [], 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<Element | Window>}>({
anchor: null,
scrollables: [],
})

// Recalculate position when any scrollable ancestor of the anchor scrolls.
// Uses requestAnimationFrame to avoid layout thrashing during scroll.
React.useEffect(() => {
Expand All @@ -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<Element | Window>
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)
Expand Down
Loading