Skip to content
Draft
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
10 changes: 10 additions & 0 deletions .changeset/perf-use-anchored-position.md
Original file line number Diff line number Diff line change
@@ -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
68 changes: 65 additions & 3 deletions packages/react/src/hooks/useAnchoredPosition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PositionSettings> {
Expand Down Expand Up @@ -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<HTMLElement | null>) // 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 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)
}, [])

// 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,
Expand Down
Loading