Skip to content

useAnchoredPosition: recalculate position on scroll#7652

Open
owenniblock wants to merge 4 commits intomainfrom
useAnchoredPosition-scroll-recalculation
Open

useAnchoredPosition: recalculate position on scroll#7652
owenniblock wants to merge 4 commits intomainfrom
useAnchoredPosition-scroll-recalculation

Conversation

@owenniblock
Copy link
Contributor

@owenniblock owenniblock commented Mar 11, 2026

Closes #2184

useAnchoredPosition currently watches for resize events (via useResizeObserver) but not scroll. When a user scrolls a page or a scrollable container, the floating overlay stays at its initial absolute position while the anchor element moves — causing the overlay to visually detach from its trigger.

This adds scroll event listeners to all scrollable ancestors of the anchor element, so the position is recalculated on scroll.

Note: The ideal long-term fix here would be to move to the Popover API with CSS Anchor Positioning, which handles scroll-aware positioning natively in the browser without any JavaScript listeners. This PR is a quick fix to unblock the issue until we can make that more fundamental change.

Changelog

New

  • useAnchoredPosition now recalculates the overlay position when any scrollable ancestor of the anchor element (or the window) is scrolled
  • New internal getScrollableAncestors() utility that finds all ancestors with overflow: auto|scroll|overlay

Changed

Removed

Rollout strategy

  • Patch release
  • Minor release
  • Major release; if selected, include a written rollout or migration plan
  • None; if selected, include a brief description as to why

Testing & Reviewing

  • Added two new tests in useAnchoredPosition.test.tsx:
    • 'should recalculate position when window scrolls' — dispatches scroll on window, asserts recalculation
    • 'should recalculate position when a scrollable ancestor scrolls' — creates a scrollable overflow: auto container, dispatches scroll, asserts recalculation
  • Both tests confirmed to fail without the implementation and pass with it
  • Performance: scroll listeners are passive, recalculation is throttled via requestAnimationFrame (at most one per frame), and listeners are only attached to actual scrollable ancestors

Merge checklist

  • Added/updated tests
  • Added/updated documentation
  • Added/updated previews (Storybook)
  • Changes are SSR compatible (scroll listeners are in a useEffect that only runs client-side; getScrollableAncestors uses getComputedStyle which is DOM-only)
  • Tested in Chrome
  • Tested in Firefox
  • Tested in Safari
  • Tested in Edge
  • Integration tests pass at github/github-ui

@changeset-bot
Copy link

changeset-bot bot commented Mar 11, 2026

🦋 Changeset detected

Latest commit: fbacefb

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@primer/react Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions github-actions bot added the integration-tests: recommended This change needs to be tested for breaking changes. See https://arc.net/l/quote/tdmpakpm label Mar 11, 2026
@github-actions
Copy link
Contributor

⚠️ Action required

👋 Hi, this pull request contains changes to the source code that github/github-ui depends on. If you are GitHub staff, test these changes with github/github-ui using the integration workflow. Or, apply the integration-tests: skipped manually label to skip these checks.

@primer
Copy link
Contributor

primer bot commented Mar 11, 2026

🤖 Lint issues have been automatically fixed and committed to this PR.

@github-actions github-actions bot temporarily deployed to storybook-preview-7652 March 11, 2026 14:12 Inactive
@primer
Copy link
Contributor

primer bot commented Mar 11, 2026

🤖 Lint issues have been automatically fixed and committed to this PR.

@primer
Copy link
Contributor

primer bot commented Mar 11, 2026

🤖 Lint issues have been automatically fixed and committed to this PR.

owenniblock and others added 3 commits March 12, 2026 09:40
Add two new Storybook stories under Components/AnchoredOverlay/Dev:

- ScrollRecalculation: demonstrates the scroll detachment bug inside a
  scrollable overflow:auto container
- WindowScrollRecalculation: demonstrates the same bug with window-level
  scrolling

Both stories include step-by-step instructions explaining how to
reproduce the issue and what to expect before and after the fix.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@owenniblock owenniblock force-pushed the useAnchoredPosition-scroll-recalculation branch from 95cca6b to 8d19b43 Compare March 12, 2026 09:40
@primer
Copy link
Contributor

primer bot commented Mar 12, 2026

🤖 Lint issues have been automatically fixed and committed to this PR.

- Add eslint-disable for scroll listener (IntersectionObserver cannot
  detect continuous scroll position changes needed for repositioning)
- Replace sx prop with style prop on Text components in dev stories
- Add patch changeset for useAnchoredPosition scroll recalculation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates useAnchoredPosition (used by components like AnchoredOverlay, SelectPanel, etc.) to keep floating overlays visually anchored while the page or a scrollable ancestor container scrolls, addressing the “overlay detaches on scroll” issue.

Changes:

  • Add scroll listeners for all scrollable ancestors (plus window) to trigger anchored-position recalculation on scroll (rAF-throttled).
  • Add unit tests asserting recalculation occurs on both window scroll and scrollable-ancestor scroll.
  • Add dev Storybook scenarios demonstrating scroll-aware repositioning and include a patch changeset entry.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
packages/react/src/hooks/useAnchoredPosition.ts Adds scroll-ancestor detection and scroll-triggered repositioning logic.
packages/react/src/hooks/tests/useAnchoredPosition.test.tsx Adds tests for scroll-driven reposition recalculation.
packages/react/src/AnchoredOverlay/AnchoredOverlay.dev.stories.tsx Adds dev stories to manually verify scroll recalculation behavior.
.changeset/anchored-position-scroll-recalculation.md Patch changeset documenting the behavior change.
Comments suppressed due to low confidence (1)

packages/react/src/hooks/tests/useAnchoredPosition.test.tsx:105

  • Same timing issue here: using setTimeout(50) to wait for the rAF callback can make this test flaky and unnecessarily slow. Consider fake timers / rAF flushing so the test can synchronously advance to the next animation frame and assert immediately.
    // Wait a tick to let any pending effects settle
    await new Promise(resolve => setTimeout(resolve, 50))
    const callCountBefore = cb.mock.calls.length
    const scrollContainer = container.firstElementChild!

    // Simulate scroll on the scrollable ancestor
    act(() => {
      scrollContainer.dispatchEvent(new Event('scroll'))
    })

    // Wait for rAF-throttled handler to fire and trigger re-render
    await new Promise(resolve => setTimeout(resolve, 50))

    expect(cb.mock.calls.length).toBeGreaterThan(callCountBefore)

Comment on lines +126 to +129
React.useEffect(() => {
const anchorEl = anchorElementRef.current
if (!anchorEl) return

Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The scroll-listener effect bails out when anchorElementRef.current is null, but the effect only depends on the ref object and updatePosition. If the anchor element is conditionally rendered later (so current becomes non-null without changing the ref object), this effect will not re-run and scroll listeners will never be attached. Consider keying the effect off the resolved anchorEl value (e.g., compute const anchorEl = anchorElementRef.current during render and include that in the deps) so listeners are attached/updated when the anchor element appears or changes.

Copilot uses AI. Check for mistakes.
Comment on lines +139 to +143
const scrollables = getScrollableAncestors(anchorEl)
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)
}
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description mentions scroll listeners are passive, but the implementation adds them without an options object. Either update the PR description or pass a consistent options object (and use the same options for removal) so the code matches the stated behavior.

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to utilize passive here, or are we okay with our usage of requestAnimationFrame?

Comment on lines +48 to +60
// Wait a tick to let any pending effects settle
await new Promise(resolve => setTimeout(resolve, 50))
const callCountBefore = cb.mock.calls.length

// Simulate a window scroll event
act(() => {
window.dispatchEvent(new Event('scroll'))
})

// Wait for rAF-throttled handler to fire and trigger re-render
await new Promise(resolve => setTimeout(resolve, 50))

expect(cb.mock.calls.length).toBeGreaterThan(callCountBefore)
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests rely on real timers (setTimeout(50)) to wait for the rAF-throttled scroll handler, which makes the suite slower and can be flaky under load. Prefer using fake timers (e.g., vi.useFakeTimers() + advancing timers) or waiting on requestAnimationFrame deterministically (and/or waitFor) so the assertion doesn't depend on an arbitrary delay.

This issue also appears on line 92 of the same file.

Copilot uses AI. Check for mistakes.
@llastflowers llastflowers requested a review from TylerJDev March 12, 2026 17:41
Copy link
Member

@TylerJDev TylerJDev left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, thanks for the contribution too! Left some comments. Let me know what you think! 😁

Comment on lines +13 to +27
function getScrollableAncestors(element: Element): Array<Element | Window> {
const scrollables: Array<Element | Window> = []
let current = element.parentElement
while (current) {
const style = getComputedStyle(current)
const overflowY = style.overflowY
const overflowX = style.overflowX
if (/auto|scroll|overlay/.test(overflowY) || /auto|scroll|overlay/.test(overflowX)) {
scrollables.push(current)
}
current = current.parentElement
}
scrollables.push(window)
return scrollables
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need to run when the AnchoredOverlay is closed? I'm wondering if there are any performance implications, but I doubt it, as nothing here stands out as being slow.

// Uses requestAnimationFrame to avoid layout thrashing during scroll.
React.useEffect(() => {
const anchorEl = anchorElementRef.current
if (!anchorEl) return
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Random question - could we only run this when the overlay is active? I'm assuming no, but wanted to ask 😁

Same goes for the scroll event; if we couldn't return early here if the overlay active, would we only want to add the scroll event if the overlay is open?

Comment on lines +139 to +143
const scrollables = getScrollableAncestors(anchorEl)
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)
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to utilize passive here, or are we okay with our usage of requestAnimationFrame?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

integration-tests: recommended This change needs to be tested for breaking changes. See https://arc.net/l/quote/tdmpakpm

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SelectPanel not scrolling with anchoring element

4 participants