diff --git a/.changeset/treeview-sibling-traversal.md b/.changeset/treeview-sibling-traversal.md
new file mode 100644
index 00000000000..99d153f82a4
--- /dev/null
+++ b/.changeset/treeview-sibling-traversal.md
@@ -0,0 +1,5 @@
+---
+'@primer/react': patch
+---
+
+perf(TreeView): replace O(n) TreeWalker with O(depth) sibling traversal
diff --git a/packages/react/src/TreeView/TreeView.test.tsx b/packages/react/src/TreeView/TreeView.test.tsx
index 2252c9d339a..874d719ac57 100644
--- a/packages/react/src/TreeView/TreeView.test.tsx
+++ b/packages/react/src/TreeView/TreeView.test.tsx
@@ -103,6 +103,21 @@ describe('Markup', () => {
expect(subtree).toBeNull()
})
+ it('does not render collapsed subtree children in the DOM', () => {
+ const {queryByRole} = renderWithTheme(
+
+
+ Parent
+
+ Child
+
+
+ ,
+ )
+
+ expect(queryByRole('treeitem', {name: 'Child'})).toBeNull()
+ })
+
it('uses aria-current', () => {
const {getByRole} = renderWithTheme(
diff --git a/packages/react/src/TreeView/useRovingTabIndex.ts b/packages/react/src/TreeView/useRovingTabIndex.ts
index 98a7dbbfa6b..861ae4b8f27 100644
--- a/packages/react/src/TreeView/useRovingTabIndex.ts
+++ b/packages/react/src/TreeView/useRovingTabIndex.ts
@@ -142,30 +142,109 @@ export function getElementState(element: HTMLElement): 'open' | 'closed' | 'end'
}
}
+/**
+ * Find the next or previous visible treeitem using direct DOM traversal.
+ *
+ * PERFORMANCE: This is O(tree depth) instead of O(n) because it walks
+ * siblings and parent/child edges directly, rather than creating a TreeWalker
+ * that scans from the root to find the current element on every keystroke.
+ *
+ * NOTE: This relies on TreeView.SubTree unmounting its children when collapsed
+ * (returning null when !isExpanded). Because collapsed subtree children are
+ * never in the DOM, we can safely skip them by only entering children of nodes
+ * with aria-expanded="true". If SubTree ever changes to keep collapsed children
+ * mounted (e.g. via CSS display:none), this logic would need to add filtering
+ * for items inside collapsed parents.
+ */
export function getVisibleElement(element: HTMLElement, direction: 'next' | 'previous'): HTMLElement | undefined {
- const root = element.closest('[role=tree]')
+ if (direction === 'next') {
+ return getNextVisibleElement(element)
+ }
+ return getPreviousVisibleElement(element)
+}
- if (!root) return
+function getNextVisibleElement(element: HTMLElement): HTMLElement | undefined {
+ // If the current item is expanded, the next visible item is its first child
+ if (element.getAttribute('aria-expanded') === 'true') {
+ const firstChild = getFirstChildElement(element)
+ if (firstChild) return firstChild
+ }
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, node => {
- if (!(node instanceof HTMLElement)) return NodeFilter.FILTER_SKIP
- return node.getAttribute('role') === 'treeitem' ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP
- })
+ // Otherwise, walk up the tree looking for a next sibling
+ let current: HTMLElement | undefined = element
+ while (current) {
+ const next = getNextSiblingTreeItem(current)
+ if (next) return next
+
+ // No next sibling at this level, try the parent's next sibling
+ current = getParentElement(current)
+ }
+
+ return undefined
+}
- let current = walker.firstChild()
+function getPreviousVisibleElement(element: HTMLElement): HTMLElement | undefined {
+ const prev = getPreviousSiblingTreeItem(element)
- while (current !== element) {
- current = walker.nextNode()
+ if (prev) {
+ // Navigate to the deepest last visible descendant of the previous sibling
+ return getDeepestLastDescendant(prev)
}
- let next = direction === 'next' ? walker.nextNode() : walker.previousNode()
+ // No previous sibling, the parent is the previous visible element
+ return getParentElement(element)
+}
+
+/**
+ * Walk into expanded subtrees to find the deepest last visible descendant.
+ * For example, if the last sibling is an expanded directory whose last child
+ * is also an expanded directory, we drill all the way down.
+ */
+function getDeepestLastDescendant(element: HTMLElement): HTMLElement {
+ let current = element
+ while (current.getAttribute('aria-expanded') === 'true') {
+ const lastChild = getLastChildTreeItem(current)
+ if (!lastChild) break
+ current = lastChild
+ }
+ return current
+}
- // If next element is nested inside a collapsed subtree, continue iterating
- while (next instanceof HTMLElement && next.parentElement?.closest('[role=treeitem][aria-expanded=false]')) {
- next = direction === 'next' ? walker.nextNode() : walker.previousNode()
+function getNextSiblingTreeItem(element: HTMLElement): HTMLElement | undefined {
+ let sibling = element.nextElementSibling
+ while (sibling) {
+ if (sibling instanceof HTMLElement && sibling.getAttribute('role') === 'treeitem') {
+ return sibling
+ }
+ sibling = sibling.nextElementSibling
}
+ return undefined
+}
+
+function getPreviousSiblingTreeItem(element: HTMLElement): HTMLElement | undefined {
+ let sibling = element.previousElementSibling
+ while (sibling) {
+ if (sibling instanceof HTMLElement && sibling.getAttribute('role') === 'treeitem') {
+ return sibling
+ }
+ sibling = sibling.previousElementSibling
+ }
+ return undefined
+}
- return next instanceof HTMLElement ? next : undefined
+function getLastChildTreeItem(element: HTMLElement): HTMLElement | undefined {
+ // Find the [role=group] child (the subtree container), then get its last treeitem
+ for (let i = element.children.length - 1; i >= 0; i--) {
+ const child = element.children[i]
+ if (child instanceof HTMLElement && child.getAttribute('role') === 'group') {
+ let lastChild = child.lastElementChild
+ while (lastChild && !(lastChild instanceof HTMLElement && lastChild.getAttribute('role') === 'treeitem')) {
+ lastChild = lastChild.previousElementSibling
+ }
+ return lastChild instanceof HTMLElement ? lastChild : undefined
+ }
+ }
+ return undefined
}
export function getFirstChildElement(element: HTMLElement): HTMLElement | undefined {