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/treeview-defer-scroll.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

perf(TreeView): defer scrollIntoView to coalesce reflows during rapid navigation
27 changes: 24 additions & 3 deletions packages/react/src/TreeView/TreeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,11 @@ const RootContext = React.createContext<{
// across remounts. This is necessary because we unmount tree items
// when their parent is collapsed.
expandedStateCache: React.RefObject<Map<string, boolean> | null>
scrollElementIntoView: (element: Element | null | undefined) => void
}>({
announceUpdate: () => {},
expandedStateCache: {current: new Map()},
scrollElementIntoView: () => {},
})

const ItemContext = React.createContext<{
Expand Down Expand Up @@ -131,6 +133,23 @@ const Root: React.FC<TreeViewProps> = ({
},
})

// Deferred scroll-into-view: coalesces multiple scroll requests within
// the same animation frame so that rapid keyboard navigation (held key)
// only triggers one scrollIntoView + reflow per frame instead of per keystroke.
const pendingScrollRef = React.useRef<number | null>(null)
const scrollElementIntoView = useCallback((element: Element | null | undefined) => {
if (!element) return

if (pendingScrollRef.current !== null) {
cancelAnimationFrame(pendingScrollRef.current)
}

pendingScrollRef.current = requestAnimationFrame(() => {
pendingScrollRef.current = null
element.scrollIntoView({block: 'nearest', inline: 'nearest'})
})
}, [])

Comment on lines +148 to +152
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

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

scrollElementIntoView schedules a rAF callback but there’s no unmount cleanup. If the TreeView unmounts (or items are removed) before the frame fires, the callback can still run and call scrollIntoView on a detached element. Consider adding a useEffect cleanup that cancels any pending animation frame (and clears the ref), and optionally guarding the callback with if (!element.isConnected) return before calling scrollIntoView.

Suggested change
pendingScrollRef.current = null
element.scrollIntoView({block: 'nearest', inline: 'nearest'})
})
}, [])
pendingScrollRef.current = null
// Guard against calling scrollIntoView on a detached element
if (!('isConnected' in element) || !(element as Element & {isConnected: boolean}).isConnected) {
return
}
element.scrollIntoView({block: 'nearest', inline: 'nearest'})
})
}, [])
useEffect(() => {
return () => {
if (pendingScrollRef.current !== null) {
cancelAnimationFrame(pendingScrollRef.current)
pendingScrollRef.current = null
}
}
}, [])

Copilot uses AI. Check for mistakes.
const expandedStateCache = React.useRef<Map<string, boolean> | null>(null)

if (expandedStateCache.current === null) {
Expand All @@ -142,6 +161,7 @@ const Root: React.FC<TreeViewProps> = ({
value={{
announceUpdate,
expandedStateCache,
scrollElementIntoView,
}}
>
<>
Expand Down Expand Up @@ -209,7 +229,7 @@ const Item = React.forwardRef<HTMLElement, TreeViewItemProps>(
leadingVisual: LeadingVisual,
trailingVisual: TrailingVisual,
})
const {expandedStateCache} = React.useContext(RootContext)
const {expandedStateCache, scrollElementIntoView} = React.useContext(RootContext)
const labelId = useId()
const leadingVisualId = useId()
const trailingVisualId = useId()
Expand Down Expand Up @@ -344,8 +364,9 @@ const Item = React.forwardRef<HTMLElement, TreeViewItemProps>(
data-has-leading-action={slots.leadingAction ? true : undefined}
onKeyDown={handleKeyDown}
onFocus={event => {
// Scroll the first child into view when the item receives focus
event.currentTarget.firstElementChild?.scrollIntoView({block: 'nearest', inline: 'nearest'})
// Defer scroll to the next animation frame so that rapid keyboard
// navigation (held key) coalesces into a single reflow per frame
scrollElementIntoView(event.currentTarget.firstElementChild)

// Set the focused state
setIsFocused(true)
Expand Down
Loading