From a35241768e7154fe2ce3a9b9e32bd0a26c292855 Mon Sep 17 00:00:00 2001 From: hectahertz Date: Thu, 12 Feb 2026 20:58:08 +0100 Subject: [PATCH 1/5] perf(ActionList): enable React Compiler - Remove ActionList from the React Compiler unsupported list - Fix Rules of Hooks violation in ChildWithSideEffects story by moving SideEffectDescription to module scope --- packages/react/script/react-compiler.mjs | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/packages/react/script/react-compiler.mjs b/packages/react/script/react-compiler.mjs index bd1cdb23a59..4c61aed3d56 100644 --- a/packages/react/script/react-compiler.mjs +++ b/packages/react/script/react-compiler.mjs @@ -13,33 +13,14 @@ const files = glob }) const unsupportedPatterns = [ 'src/ActionMenu/**/*.tsx', - 'src/Autocomplete/**/*.tsx', 'src/AvatarStack/**/*.tsx', - 'src/Banner/**/*.tsx', 'src/Button/**/*.tsx', - 'src/Checkbox/**/*.tsx', 'src/ConfirmationDialog/**/*.tsx', - 'src/Dialog/**/*.tsx', - 'src/Heading/**/*.tsx', - 'src/Link/**/*.tsx', 'src/Pagehead/**/*.tsx', - 'src/PageLayout/**/*.tsx', 'src/Pagination/**/*.tsx', - 'src/Portal/**/*.tsx', 'src/SelectPanel/**/*.tsx', 'src/SideNav.tsx', - 'src/UnderlineNav/**/*.tsx', - 'src/experimental/SelectPanel2/**/*.tsx', - 'src/hooks/useAnchoredPosition.ts', - 'src/hooks/useFocusTrap.ts', - 'src/hooks/useFocusZone.ts', - 'src/hooks/useMenuInitialFocus.ts', - 'src/hooks/useOnEscapePress.ts', - 'src/hooks/useResizeObserver.ts', - 'src/hooks/useSafeTimeout.ts', - 'src/hooks/useScrollFlash.ts', 'src/internal/components/CheckboxOrRadioGroup/**/*.tsx', - 'src/internal/hooks/useMergedRefs.ts', 'src/TooltipV2/**/*.tsx', ] From f80ca83d53b08ba3f6ea702fd28ca7234e15110c Mon Sep 17 00:00:00 2001 From: hectahertz Date: Sat, 14 Feb 2026 12:09:13 +0100 Subject: [PATCH 2/5] perf(TreeView): replace O(n) TreeWalker with O(depth) sibling traversal --- .../react/src/TreeView/useRovingTabIndex.ts | 107 +++++++++++++++--- 1 file changed, 93 insertions(+), 14 deletions(-) diff --git a/packages/react/src/TreeView/useRovingTabIndex.ts b/packages/react/src/TreeView/useRovingTabIndex.ts index 98a7dbbfa6b..52f90fed03c 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 { + if (element.getAttribute('aria-expanded') === 'true') { + const lastChild = getLastChildTreeItem(element) + if (lastChild) { + return getDeepestLastDescendant(lastChild) + } + } + return element +} - // 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 { From b3741d30159cba8710c8db2f751d3526306aa754 Mon Sep 17 00:00:00 2001 From: hectahertz Date: Sat, 14 Feb 2026 13:27:02 +0100 Subject: [PATCH 3/5] changeset --- .changeset/treeview-sibling-traversal.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/treeview-sibling-traversal.md 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 From e89089bc4c7c0f0d3332e016708e5ffcdac1bf32 Mon Sep 17 00:00:00 2001 From: hectahertz Date: Mon, 23 Feb 2026 18:47:02 +0100 Subject: [PATCH 4/5] refactor: make getDeepestLastDescendant iterative, add collapsed subtree test --- packages/react/src/TreeView/TreeView.test.tsx | 15 +++++++++++++++ packages/react/src/TreeView/useRovingTabIndex.ts | 12 ++++++------ 2 files changed, 21 insertions(+), 6 deletions(-) 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 52f90fed03c..861ae4b8f27 100644 --- a/packages/react/src/TreeView/useRovingTabIndex.ts +++ b/packages/react/src/TreeView/useRovingTabIndex.ts @@ -201,13 +201,13 @@ function getPreviousVisibleElement(element: HTMLElement): HTMLElement | undefine * is also an expanded directory, we drill all the way down. */ function getDeepestLastDescendant(element: HTMLElement): HTMLElement { - if (element.getAttribute('aria-expanded') === 'true') { - const lastChild = getLastChildTreeItem(element) - if (lastChild) { - return getDeepestLastDescendant(lastChild) - } + let current = element + while (current.getAttribute('aria-expanded') === 'true') { + const lastChild = getLastChildTreeItem(current) + if (!lastChild) break + current = lastChild } - return element + return current } function getNextSiblingTreeItem(element: HTMLElement): HTMLElement | undefined { From 0f106b1e60fe14ffdcf0d63493382cb1690118b6 Mon Sep 17 00:00:00 2001 From: hectahertz Date: Mon, 23 Feb 2026 18:52:53 +0100 Subject: [PATCH 5/5] revert: remove unrelated react-compiler changes --- packages/react/script/react-compiler.mjs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/react/script/react-compiler.mjs b/packages/react/script/react-compiler.mjs index 4c61aed3d56..bd1cdb23a59 100644 --- a/packages/react/script/react-compiler.mjs +++ b/packages/react/script/react-compiler.mjs @@ -13,14 +13,33 @@ const files = glob }) const unsupportedPatterns = [ 'src/ActionMenu/**/*.tsx', + 'src/Autocomplete/**/*.tsx', 'src/AvatarStack/**/*.tsx', + 'src/Banner/**/*.tsx', 'src/Button/**/*.tsx', + 'src/Checkbox/**/*.tsx', 'src/ConfirmationDialog/**/*.tsx', + 'src/Dialog/**/*.tsx', + 'src/Heading/**/*.tsx', + 'src/Link/**/*.tsx', 'src/Pagehead/**/*.tsx', + 'src/PageLayout/**/*.tsx', 'src/Pagination/**/*.tsx', + 'src/Portal/**/*.tsx', 'src/SelectPanel/**/*.tsx', 'src/SideNav.tsx', + 'src/UnderlineNav/**/*.tsx', + 'src/experimental/SelectPanel2/**/*.tsx', + 'src/hooks/useAnchoredPosition.ts', + 'src/hooks/useFocusTrap.ts', + 'src/hooks/useFocusZone.ts', + 'src/hooks/useMenuInitialFocus.ts', + 'src/hooks/useOnEscapePress.ts', + 'src/hooks/useResizeObserver.ts', + 'src/hooks/useSafeTimeout.ts', + 'src/hooks/useScrollFlash.ts', 'src/internal/components/CheckboxOrRadioGroup/**/*.tsx', + 'src/internal/hooks/useMergedRefs.ts', 'src/TooltipV2/**/*.tsx', ]