From 064b105604e31486806b8f4ab432540aaca10465 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 6 Jul 2026 15:34:17 +0000 Subject: [PATCH 1/4] perf(useAnchoredPosition): cache scrollable-ancestor walk per anchor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scroll-listener setup effect lists `updatePosition` in its deps, so it re-walked the entire ancestor chain (a getComputedStyle per ancestor) every time a consumer's reposition dependencies changed — even though the set of scrollable ancestors only depends on the anchor element. Cache the walk keyed on the anchor element and reuse it when the anchor is unchanged (the common case). Listener attach/detach behavior is unchanged; only the redundant getComputedStyle walk is skipped. --- ...erf-anchored-position-cache-scrollables.md | 5 +++++ .../react/src/hooks/useAnchoredPosition.ts | 21 ++++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 .changeset/perf-anchored-position-cache-scrollables.md diff --git a/.changeset/perf-anchored-position-cache-scrollables.md b/.changeset/perf-anchored-position-cache-scrollables.md new file mode 100644 index 00000000000..ad594663ba8 --- /dev/null +++ b/.changeset/perf-anchored-position-cache-scrollables.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +`useAnchoredPosition`: avoid re-walking the scrollable-ancestor chain (and its `getComputedStyle` calls) on every reposition dependency change by caching the walk per anchor element, reducing style-recalc work for overlays, menus, and tooltips. diff --git a/packages/react/src/hooks/useAnchoredPosition.ts b/packages/react/src/hooks/useAnchoredPosition.ts index 611d1b1e920..982ce46ee8f 100644 --- a/packages/react/src/hooks/useAnchoredPosition.ts +++ b/packages/react/src/hooks/useAnchoredPosition.ts @@ -142,6 +142,15 @@ 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). + 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 +167,17 @@ export function useAnchoredPosition( }) } - const scrollables = getScrollableAncestors(anchorEl) + // The set of scrollable ancestors only depends on the anchor element, so reuse + // the cached walk when the anchor hasn't changed instead of re-running + // getComputedStyle up the whole ancestor chain. + 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) From d722497ca6cb7f2324ff81fb8951881aa76904c1 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 6 Jul 2026 15:41:36 +0000 Subject: [PATCH 2/4] perf(useAnchoredPosition): mark ancestor scroll listeners as passive `handleScroll` only schedules a rAF and never calls preventDefault, so the scroll listeners attached to the anchor's scrollable ancestors can be passive. This lets Safari/Chrome scroll without waiting to see if the default is cancelled, improving scroll smoothness for overlays, menus, and tooltips. --- .changeset/perf-anchored-position-cache-scrollables.md | 2 +- packages/react/src/hooks/useAnchoredPosition.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.changeset/perf-anchored-position-cache-scrollables.md b/.changeset/perf-anchored-position-cache-scrollables.md index ad594663ba8..b4719aed79e 100644 --- a/.changeset/perf-anchored-position-cache-scrollables.md +++ b/.changeset/perf-anchored-position-cache-scrollables.md @@ -2,4 +2,4 @@ '@primer/react': patch --- -`useAnchoredPosition`: avoid re-walking the scrollable-ancestor chain (and its `getComputedStyle` calls) on every reposition dependency change by caching the walk per anchor element, reducing style-recalc work for overlays, menus, and tooltips. +`useAnchoredPosition`: improve scroll performance for overlays, menus, and tooltips by caching the scrollable-ancestor walk per anchor element (avoiding repeated `getComputedStyle` traversals on reposition dependency changes) and marking the ancestor scroll listeners as passive. diff --git a/packages/react/src/hooks/useAnchoredPosition.ts b/packages/react/src/hooks/useAnchoredPosition.ts index 982ce46ee8f..00a7629c282 100644 --- a/packages/react/src/hooks/useAnchoredPosition.ts +++ b/packages/react/src/hooks/useAnchoredPosition.ts @@ -179,8 +179,11 @@ export function useAnchoredPosition( scrollAncestorsCacheRef.current = {anchor: anchorEl, scrollables} } for (const scrollable of scrollables) { + // `handleScroll` only schedules a rAF and never calls preventDefault, so mark the + // listener passive — this lets Safari/Chrome scroll without waiting to see if the + // default is cancelled, improving scroll smoothness (especially in Safari). // eslint-disable-next-line github/prefer-observers -- IntersectionObserver cannot detect continuous scroll position changes needed for repositioning - scrollable.addEventListener('scroll', handleScroll) + scrollable.addEventListener('scroll', handleScroll, {passive: true}) } return () => { From f233fca78fa212fb12052fe17763d03f359603a1 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 6 Jul 2026 15:44:14 +0000 Subject: [PATCH 3/4] Revert passive scroll listener (scroll events are not cancelable, so passive is a no-op) --- .changeset/perf-anchored-position-cache-scrollables.md | 2 +- packages/react/src/hooks/useAnchoredPosition.ts | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/.changeset/perf-anchored-position-cache-scrollables.md b/.changeset/perf-anchored-position-cache-scrollables.md index b4719aed79e..ad594663ba8 100644 --- a/.changeset/perf-anchored-position-cache-scrollables.md +++ b/.changeset/perf-anchored-position-cache-scrollables.md @@ -2,4 +2,4 @@ '@primer/react': patch --- -`useAnchoredPosition`: improve scroll performance for overlays, menus, and tooltips by caching the scrollable-ancestor walk per anchor element (avoiding repeated `getComputedStyle` traversals on reposition dependency changes) and marking the ancestor scroll listeners as passive. +`useAnchoredPosition`: avoid re-walking the scrollable-ancestor chain (and its `getComputedStyle` calls) on every reposition dependency change by caching the walk per anchor element, reducing style-recalc work for overlays, menus, and tooltips. diff --git a/packages/react/src/hooks/useAnchoredPosition.ts b/packages/react/src/hooks/useAnchoredPosition.ts index 00a7629c282..982ce46ee8f 100644 --- a/packages/react/src/hooks/useAnchoredPosition.ts +++ b/packages/react/src/hooks/useAnchoredPosition.ts @@ -179,11 +179,8 @@ export function useAnchoredPosition( scrollAncestorsCacheRef.current = {anchor: anchorEl, scrollables} } for (const scrollable of scrollables) { - // `handleScroll` only schedules a rAF and never calls preventDefault, so mark the - // listener passive — this lets Safari/Chrome scroll without waiting to see if the - // default is cancelled, improving scroll smoothness (especially in Safari). // eslint-disable-next-line github/prefer-observers -- IntersectionObserver cannot detect continuous scroll position changes needed for repositioning - scrollable.addEventListener('scroll', handleScroll, {passive: true}) + scrollable.addEventListener('scroll', handleScroll) } return () => { From 96dc4a4d5379c109cfed287da93c7ce629fdfc02 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Tue, 7 Jul 2026 15:15:28 +0000 Subject: [PATCH 4/4] docs: clarify scrollable-ancestor cache assumption and terse changeset --- .../perf-anchored-position-cache-scrollables.md | 2 +- packages/react/src/hooks/useAnchoredPosition.ts | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.changeset/perf-anchored-position-cache-scrollables.md b/.changeset/perf-anchored-position-cache-scrollables.md index ad594663ba8..eb9feea9e44 100644 --- a/.changeset/perf-anchored-position-cache-scrollables.md +++ b/.changeset/perf-anchored-position-cache-scrollables.md @@ -2,4 +2,4 @@ '@primer/react': patch --- -`useAnchoredPosition`: avoid re-walking the scrollable-ancestor chain (and its `getComputedStyle` calls) on every reposition dependency change by caching the walk per anchor element, reducing style-recalc work for overlays, menus, and tooltips. +`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 982ce46ee8f..f25caf420ef 100644 --- a/packages/react/src/hooks/useAnchoredPosition.ts +++ b/packages/react/src/hooks/useAnchoredPosition.ts @@ -145,7 +145,9 @@ export function useAnchoredPosition( // 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). + // 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: [], @@ -167,9 +169,11 @@ export function useAnchoredPosition( }) } - // The set of scrollable ancestors only depends on the anchor element, so reuse - // the cached walk when the anchor hasn't changed instead of re-running - // getComputedStyle up the whole ancestor chain. + // 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) {