Skip to content
9 changes: 9 additions & 0 deletions .changeset/perf-use-resize-observer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@primer/react': patch
---

perf(hooks): Add first-immediate throttling to useResizeObserver and useOverflow

- useResizeObserver now fires callback immediately on first observation, then throttles with rAF
- useOverflow now uses the same pattern to avoid initial flash of incorrect overflow state
- Added isFirstCallback ref pattern to skip throttling on initial mount
35 changes: 33 additions & 2 deletions packages/react/src/hooks/useOverflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,51 @@ export function useOverflow<T extends HTMLElement>(ref: React.RefObject<T>) {
return
}

const observer = new ResizeObserver(entries => {
// Track whether this is the first callback (fires immediately on observe())
let isFirstCallback = true
let pendingFrame: number | null = null
let latestEntries: ResizeObserverEntry[] | null = null

const checkOverflow = (entries: ResizeObserverEntry[]) => {
for (const entry of entries) {
if (
entry.target.scrollHeight > entry.target.clientHeight ||
entry.target.scrollWidth > entry.target.clientWidth
) {
setHasOverflow(true)
break
return
}
}
setHasOverflow(false)

@mattcosta7 mattcosta7 Dec 15, 2025

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I believe this is a current bug, since things can never go to not overflow states

}

const observer = new ResizeObserver(entries => {
// First callback must be immediate - ResizeObserver fires synchronously
// on observe() and consumers may depend on this timing
if (isFirstCallback) {
isFirstCallback = false
checkOverflow(entries)
return
}

// Subsequent callbacks are throttled to reduce layout thrashing
// during rapid resize events (e.g., window drag)
latestEntries = entries
if (pendingFrame === null) {
pendingFrame = requestAnimationFrame(() => {
pendingFrame = null
if (latestEntries) {
checkOverflow(latestEntries)
Comment thread
mattcosta7 marked this conversation as resolved.
}
})
}
Comment thread
francinelucca marked this conversation as resolved.
})

observer.observe(ref.current)
return () => {
if (pendingFrame !== null) {
cancelAnimationFrame(pendingFrame)
}
observer.disconnect()
}
}, [ref])
Expand Down
28 changes: 27 additions & 1 deletion packages/react/src/hooks/useResizeObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,39 @@ export function useResizeObserver<T extends HTMLElement>(
}

if (typeof ResizeObserver === 'function') {
// Track whether this is the first callback (fires immediately on observe())
let isFirstCallback = true
let pendingFrame: number | null = null
let latestEntries: ResizeObserverEntry[] | null = null

const observer = new ResizeObserver(entries => {
savedCallback.current(entries)
// First callback must be immediate - ResizeObserver fires synchronously
// on observe() and consumers may depend on this timing
if (isFirstCallback) {
isFirstCallback = false
savedCallback.current(entries)
return
}

// Subsequent callbacks are throttled to reduce layout thrashing
// during rapid resize events (e.g., window drag)
latestEntries = entries
if (pendingFrame === null) {
pendingFrame = requestAnimationFrame(() => {
pendingFrame = null
if (latestEntries) {
savedCallback.current(latestEntries)
Comment thread
mattcosta7 marked this conversation as resolved.
}
})
}
Comment thread
francinelucca marked this conversation as resolved.
})

observer.observe(targetEl)

return () => {
if (pendingFrame !== null) {
cancelAnimationFrame(pendingFrame)
}
observer.disconnect()
}
} else {
Expand Down
Loading