From 3ea4ed027aef764a66147c013a22f91952e53876 Mon Sep 17 00:00:00 2001
From: "sentry-junior[bot]"
<264270552+sentry-junior[bot]@users.noreply.github.com>
Date: Tue, 3 Nov 2026 09:28:42 +0000
Subject: [PATCH 1/2] perf(dropdown): Virtualize the All Projects settings
submenu
The All Projects submenu in the settings navigation rendered a full menu row
for every project, and each hover re-rendered every row. Large organizations
made opening the submenu and moving through it slow.
DropdownMenu submenus now accept virtualizeThreshold. Above it, only the
visible rows render, sections keep their group semantics, and keyboard focus
scrolls rows that are not rendered into view. The project settings submenu
turns this on above 50 projects.
Co-Authored-By: Priscila Andre de Oliveira <29228205+priscilawebdev@users.noreply.github.com>
---
.../core/dropdownMenu/dropdownMenu.spec.tsx | 113 ++++++
.../app/components/core/dropdownMenu/item.tsx | 9 +-
.../app/components/core/dropdownMenu/list.tsx | 329 ++++++++++++++++--
.../components/core/dropdownMenu/section.tsx | 39 ++-
.../primary/organizationDropdown.tsx | 2 +
5 files changed, 462 insertions(+), 30 deletions(-)
diff --git a/static/app/components/core/dropdownMenu/dropdownMenu.spec.tsx b/static/app/components/core/dropdownMenu/dropdownMenu.spec.tsx
index 04b88da3074e..4e4ecd49fd6a 100644
--- a/static/app/components/core/dropdownMenu/dropdownMenu.spec.tsx
+++ b/static/app/components/core/dropdownMenu/dropdownMenu.spec.tsx
@@ -468,6 +468,119 @@ describe('DropdownMenu', () => {
expect(onAction).toHaveBeenCalledTimes(1);
});
+ describe('virtualized submenu', () => {
+ const projects = Array.from({length: 100}, (_, i) => ({
+ key: `project-${i}`,
+ label: `project-${i}`,
+ }));
+
+ const rowHeight = 32;
+ const originalScrollTo = HTMLElement.prototype.scrollTo;
+
+ function mockHeight(
+ property: 'offsetHeight' | 'clientHeight' | 'scrollHeight',
+ menuHeight: number
+ ) {
+ jest.spyOn(HTMLElement.prototype, property, 'get').mockImplementation(function (
+ this: HTMLElement
+ ) {
+ return this.getAttribute('role') === 'menu' ? menuHeight : rowHeight;
+ });
+ }
+
+ beforeEach(() => {
+ // JSDOM has no layout. Give the submenu a fixed height and every row the
+ // same height, so the virtualizer renders a window of rows.
+ mockHeight('offsetHeight', 240);
+ mockHeight('clientHeight', 240);
+ mockHeight('scrollHeight', (projects.length + 1) * rowHeight);
+ // JSDOM does not scroll. Like a browser, emit the scroll event the
+ // virtualizer listens for after the current task.
+ HTMLElement.prototype.scrollTo = function (
+ this: HTMLElement,
+ options?: ScrollToOptions | number
+ ) {
+ this.scrollTop = typeof options === 'object' ? (options.top ?? 0) : 0;
+ setTimeout(() => this.dispatchEvent(new Event('scroll')));
+ };
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ HTMLElement.prototype.scrollTo = originalScrollTo;
+ });
+
+ function renderVirtualizedMenu() {
+ render(
+
+ );
+ }
+
+ it('renders only the visible rows of a long submenu', async () => {
+ renderVirtualizedMenu();
+
+ await userEvent.click(screen.getByRole('button', {name: 'Projects'}));
+ await userEvent.hover(screen.getByRole('menuitemradio', {name: 'All Projects'}));
+
+ expect(screen.getByRole('group', {name: 'My Projects'})).toBeInTheDocument();
+ expect(screen.getByRole('menuitemradio', {name: 'project-0'})).toBeInTheDocument();
+ expect(
+ screen.queryByRole('menuitemradio', {name: 'project-99'})
+ ).not.toBeInTheDocument();
+ expect(screen.getAllByRole('menuitemradio').length).toBeLessThan(30);
+ expect(
+ screen.getByRole('menuitemradio', {name: 'project-0'}).closest('[data-overlay]')
+ ).toHaveStyle({width: 'min(258px, 100%)'});
+
+ await userEvent.type(
+ screen.getByRole('textbox', {name: 'Search projects'}),
+ 'project-99'
+ );
+ expect(screen.getByRole('menuitemradio', {name: 'project-99'})).toBeInTheDocument();
+ });
+
+ it('moves keyboard focus between search and rows that are not rendered', async () => {
+ renderVirtualizedMenu();
+
+ await userEvent.click(screen.getByRole('button', {name: 'Projects'}));
+ await userEvent.hover(screen.getByRole('menuitemradio', {name: 'All Projects'}));
+ const search = screen.getByRole('textbox', {name: 'Search projects'});
+ expect(search).toHaveFocus();
+
+ await userEvent.keyboard('{ArrowDown}');
+ expect(screen.getByRole('menuitemradio', {name: 'project-0'})).toHaveFocus();
+ await userEvent.keyboard('{End}');
+ expect(
+ await screen.findByRole('menuitemradio', {name: 'project-99'})
+ ).toHaveFocus();
+ await userEvent.keyboard('{ArrowDown}');
+ expect(await screen.findByRole('menuitemradio', {name: 'project-0'})).toHaveFocus();
+ await userEvent.keyboard('{End}');
+ expect(
+ await screen.findByRole('menuitemradio', {name: 'project-99'})
+ ).toHaveFocus();
+ await userEvent.keyboard('{Home}');
+ expect(await screen.findByRole('menuitemradio', {name: 'project-0'})).toHaveFocus();
+ await userEvent.keyboard('{ArrowUp}');
+ expect(search).toHaveFocus();
+ });
+ });
+
it('returns from the first enabled item to search when a disabled item comes first', async () => {
render(
; type: 'heading'}
+ | {
+ hasGapAfter: boolean;
+ node: Node;
+ type: 'item';
+ section?: Node;
+ }
+ | {type: 'separator'};
+
+const VIRTUAL_ROW_HEIGHT_ESTIMATES = {
+ heading: 36,
+ item: 32,
+ separator: 8,
+} satisfies Record;
+
+/**
+ * Flattens the collection into the rows that `DropdownMenuCollection` renders,
+ * so the virtualizer can measure and position each one.
+ */
+function getVirtualRows(collection: Array>): VirtualRow[] {
+ return collection.flatMap((node, i): VirtualRow[] => {
+ const rows: VirtualRow[] = [];
+ if (node.type === 'section') {
+ if (node.rendered) {
+ rows.push({type: 'heading', section: node});
+ }
+ const children = [...node.childNodes];
+ children.forEach((child, childIndex) =>
+ rows.push({
+ type: 'item',
+ node: child,
+ section: node,
+ hasGapAfter: childIndex < children.length - 1,
+ })
+ );
+ } else {
+ rows.push({type: 'item', node, hasGapAfter: false});
+ }
+
+ const next = collection[i + 1];
+ if (next && (node.type === 'section' || next.type === 'section')) {
+ rows.push({type: 'separator'});
+ }
+ return rows;
+ });
+}
+
+interface DropdownMenuVirtualCollectionProps extends Omit<
+ DropdownMenuCollectionProps,
+ 'collection' | 'disableTextSelection' | 'size'
+> {
+ measureElement: (element: Element | null) => void;
+ paddingBottom: number;
+ paddingTop: number;
+ rows: VirtualRow[];
+ virtualItems: VirtualItem[];
+}
+
+type IndexedVirtualRow = {index: number; row: VirtualRow};
+
+function DropdownMenuVirtualCollection({
+ rows,
+ virtualItems,
+ paddingTop,
+ paddingBottom,
+ measureElement,
+ separatorProps,
+ closeOnSelect,
+ onClose,
+ state,
+}: DropdownMenuVirtualCollectionProps) {
+ // Visible rows are contiguous, so each section appears in at most one segment.
+ const segments: Array<{rows: IndexedVirtualRow[]; section?: Node}> = [];
+ for (const {index} of virtualItems) {
+ const row = rows[index];
+ if (!row) {
+ continue;
+ }
+ const section = row.type === 'separator' ? undefined : row.section;
+ const segment = segments.at(-1);
+ if (section && segment?.section === section) {
+ segment.rows.push({index, row});
+ } else {
+ segments.push({section, rows: [{index, row}]});
+ }
+ }
+
+ const renderRow = ({index, row}: IndexedVirtualRow) => {
+ if (row.type === 'heading') {
+ // Rendered by its section.
+ return null;
+ }
+ if (row.type === 'separator') {
+ return (
+
+
+
+ );
+ }
+ return (
+
+
+
+ );
+ };
+
+ return (
+
+ {paddingTop > 0 && (
+
+ )}
+ {segments.map(segment => {
+ if (!segment.section) {
+ return segment.rows.map(renderRow);
+ }
+ const heading = segment.rows.find(({row}) => row.type === 'heading');
+ return (
+
+ {segment.rows.map(renderRow)}
+
+ );
+ })}
+ {paddingBottom > 0 && (
+
+ )}
+
+ );
+}
+
function DropdownMenuList({
closeOnSelect = true,
onClose,
@@ -218,6 +392,7 @@ function DropdownMenuList({
disableTextSelection,
overlayState,
overlayPositionProps,
+ virtualized = false,
zIndex,
...props
}: DropdownMenuListProps) {
@@ -240,16 +415,81 @@ function DropdownMenuList({
const {menuProps} = useMenu({...props, selectionMode: 'single'}, state, menuRef);
const {separatorProps} = useSeparator({elementType: 'li'});
+ const virtualRows = useMemo(
+ () => (virtualized ? getVirtualRows(stateCollection) : []),
+ [virtualized, stateCollection]
+ );
+ const {virtualizer, virtualItems, paddingTop, paddingBottom} = useVirtualRows({
+ count: virtualRows.length,
+ enabled: virtualized,
+ getScrollElement: () => menuRef.current,
+ estimateSize: index =>
+ VIRTUAL_ROW_HEIGHT_ESTIMATES[virtualRows[index]?.type ?? 'item'],
+ });
+ const enabledVirtualKeys = useMemo(
+ () =>
+ virtualRows.flatMap(row =>
+ row.type === 'item' && !state.disabledKeys.has(row.node.key) ? [row.node.key] : []
+ ),
+ [virtualRows, state.disabledKeys]
+ );
+
+ // React Aria cannot scroll to rows that are not rendered, so follow keyboard
+ // focus here. Pointer focus only reaches rendered rows.
+ const {focusedKey, isFocused} = state.selectionManager;
+ useEffect(() => {
+ if (
+ !virtualized ||
+ !isFocused ||
+ focusedKey === null ||
+ getInteractionModality() !== 'keyboard'
+ ) {
+ return;
+ }
+ const index = virtualRows.findIndex(
+ row => row.type === 'item' && row.node.key === focusedKey
+ );
+ if (index !== -1) {
+ virtualizer.scrollToIndex(index, {align: 'auto'});
+ }
+ }, [virtualized, isFocused, focusedKey, virtualRows, virtualizer]);
+
+ // A virtualized menu does not render every item, so compare keys instead of
+ // the rendered elements. Read the key from the element, because React Aria
+ // moves the focused key before this menu's key handler runs.
+ const isFirstEnabledItem = (element: EventTarget | null) =>
+ virtualized
+ ? isMenuItemWithKey(element, enabledVirtualKeys[0])
+ : element === getEnabledMenuItems(menuRef.current)[0];
+ const isLastEnabledItem = (element: EventTarget | null) =>
+ virtualized
+ ? isMenuItemWithKey(element, enabledVirtualKeys.at(-1))
+ : element === getEnabledMenuItems(menuRef.current).at(-1);
+ const focusFirstEnabledItem = () => {
+ if (!virtualized) {
+ getEnabledMenuItems(menuRef.current)[0]?.focus();
+ return;
+ }
+ const firstKey = enabledVirtualKeys[0];
+ if (firstKey === undefined || !menuRef.current) {
+ return;
+ }
+ // The first item renders once the list scrolls back to the top, then
+ // focuses itself because it is the focused key.
+ menuRef.current.scrollTop = 0;
+ state.selectionManager.setFocused(true);
+ state.selectionManager.setFocusedKey(firstKey);
+ };
+
const {keyboardProps} = useKeyboard({
onKeyDown: e => {
- const enabledMenuItems = getEnabledMenuItems(menuRef.current);
// When keyboard navigation wraps past the last item, show the top of the
// list again so the search field and first item are visible.
if (
e.key === 'ArrowDown' &&
searchRef.current &&
menuRef.current &&
- e.target === enabledMenuItems.at(-1)
+ isLastEnabledItem(e.target)
) {
menuRef.current.scrollTop = 0;
}
@@ -258,7 +498,7 @@ function DropdownMenuList({
e.key === 'ArrowUp' &&
searchRef.current &&
menuRef.current &&
- document.activeElement === enabledMenuItems[0]
+ isFirstEnabledItem(document.activeElement)
) {
e.preventDefault();
menuRef.current.scrollTop = 0;
@@ -331,11 +571,15 @@ function DropdownMenuList({
// ArrowDown moves from the search field into the filtered menu items.
if (event.key === 'ArrowDown') {
event.preventDefault();
- getEnabledMenuItems(menuRef.current)[0]?.focus();
+ focusFirstEnabledItem();
}
}}
/>
);
+ const overlayMaxWidth =
+ maxWidth === undefined
+ ? '100%'
+ : `min(${typeof maxWidth === 'number' ? `${maxWidth}px` : maxWidth}, 100%)`;
const showEmptyMessage = Boolean(
searchState?.value.trim() && stateCollection.length === 0
);
@@ -368,10 +612,10 @@ function DropdownMenuList({
maxHeight,
overlayPositionProps.style?.maxHeight
),
- maxWidth:
- maxWidth === undefined
- ? '100%'
- : `min(${typeof maxWidth === 'number' ? `${maxWidth}px` : maxWidth}, 100%)`,
+ maxWidth: overlayMaxWidth,
+ // Only the visible rows render, so they cannot size the menu.
+ width:
+ virtualized && maxWidth !== undefined ? overlayMaxWidth : undefined,
}}
>
{menuTitle && (
@@ -389,15 +633,29 @@ function DropdownMenuList({
{...mergeProps(modifiedMenuProps, keyboardProps)}
>
-
+ {virtualized ? (
+
+ ) : (
+
+ )}
{showEmptyMessage && (
item.getAttribute('aria-disabled') !== 'true');
}
+function isMenuItemWithKey(element: EventTarget | null, key: Key | undefined) {
+ return (
+ key !== undefined &&
+ element instanceof HTMLElement &&
+ element.dataset.key === `${key}`
+ );
+}
+
/**
* Recursively finds and returns disabled items
*/
@@ -505,16 +771,31 @@ function getDisabledKeys(source: MenuItemProps[]): Array {
}, []);
}
-export type DropdownMenuContentProps = Omit & {
+export type DropdownMenuContentProps = Omit<
+ DropdownMenuListProps,
+ 'searchState' | 'virtualized'
+> & {
items: MenuItemProps[];
search?: DropdownMenuSearchOptions;
+ /**
+ * Number of items above which only the visible rows render.
+ */
+ virtualizeThreshold?: number;
};
+function countMenuItems(items: MenuItemProps[]): number {
+ return items.reduce(
+ (count, item) => count + (item.children && !item.submenu ? item.children.length : 1),
+ 0
+ );
+}
+
export function DropdownMenuContent({
items,
disabledKeys,
size,
search,
+ virtualizeThreshold,
...props
}: DropdownMenuContentProps) {
const [searchValue, setSearchValue] = useState('');
@@ -529,10 +810,15 @@ export function DropdownMenuContent({
[visibleItems]
);
+ const virtualized =
+ virtualizeThreshold !== undefined &&
+ countMenuItems(visibleItems) > virtualizeThreshold;
+
return (
;
+ /**
+ * Set by virtualized menus: `null` when the heading is scrolled out of view,
+ * otherwise props that let the virtualizer measure the heading.
+ */
+ virtualHeadingProps?: {'data-index': number; ref: React.Ref} | null;
};
/**
* A wrapper component for menu sections. See:
* https://react-spectrum.adobe.com/react-aria/useMenu.html
*/
-export function DropdownMenuSection({node, children}: DropdownMenuSectionProps) {
+export function DropdownMenuSection({
+ node,
+ children,
+ virtualHeadingProps,
+}: DropdownMenuSectionProps) {
+ const isVirtualized = virtualHeadingProps !== undefined;
+ const showHeading = Boolean(node.rendered) && virtualHeadingProps !== null;
+ // Keep the group labelled while a virtualized heading is scrolled out of view.
+ const hiddenHeading =
+ !showHeading && typeof node.rendered === 'string' ? node.rendered : undefined;
const {itemProps, headingProps, groupProps} = useMenuSection({
- heading: node.rendered,
- 'aria-label': node['aria-label'],
+ heading: showHeading ? node.rendered : undefined,
+ 'aria-label': node['aria-label'] ?? hiddenHeading,
});
+ const heading = showHeading && (
+
+ {node.rendered}
+
+ );
+ // Virtualized rows carry their own spacing so the virtualizer can measure it.
+ const gap: FlexProps['gap'] = isVirtualized ? '0' : '2xs';
+
return (
- {node.rendered && (
-
- {node.rendered}
-
+ {heading && virtualHeadingProps ? (
+ {heading}
+ ) : (
+ heading
)}
-
+
{children}
diff --git a/static/app/views/navigation/primary/organizationDropdown.tsx b/static/app/views/navigation/primary/organizationDropdown.tsx
index 015ae2efbbe9..d195c3f78ce7 100644
--- a/static/app/views/navigation/primary/organizationDropdown.tsx
+++ b/static/app/views/navigation/primary/organizationDropdown.tsx
@@ -269,6 +269,8 @@ export function OrganizationDropdown(props: OrganizationDropdownProps) {
search: {placeholder: t('Search')},
maxHeight: 240,
maxWidth: 258,
+ // Organizations can have hundreds of projects.
+ virtualizeThreshold: 50,
},
hidden: projects.length === 0,
children: [
From 77772db46d96c615eaa4bcccf9ab7ce09cde2604 Mon Sep 17 00:00:00 2001
From: Priscila Oliveira
Date: Tue, 3 Nov 2026 11:37:12 +0100
Subject: [PATCH 2/2] ref(dropdown): Move menu virtualization into its own
module
Move the virtualized row rendering, the virtualizer setup, and its keyboard helpers out of
list.tsx into virtualCollection.tsx. Both render paths now share one separator component, so
they do not drift apart.
Menus with nested sub-menus no longer virtualize. A sub-menu trigger must stay mounted to
keep its sub-menu open, so these menus render every row and warn in development instead.
---
.../core/dropdownMenu/dropdownMenu.spec.tsx | 34 ++
.../app/components/core/dropdownMenu/item.tsx | 3 +-
.../app/components/core/dropdownMenu/list.tsx | 292 ++---------------
.../components/core/dropdownMenu/section.tsx | 39 ++-
.../core/dropdownMenu/virtualCollection.tsx | 301 ++++++++++++++++++
5 files changed, 409 insertions(+), 260 deletions(-)
create mode 100644 static/app/components/core/dropdownMenu/virtualCollection.tsx
diff --git a/static/app/components/core/dropdownMenu/dropdownMenu.spec.tsx b/static/app/components/core/dropdownMenu/dropdownMenu.spec.tsx
index 4e4ecd49fd6a..dd6182caad4e 100644
--- a/static/app/components/core/dropdownMenu/dropdownMenu.spec.tsx
+++ b/static/app/components/core/dropdownMenu/dropdownMenu.spec.tsx
@@ -579,6 +579,40 @@ describe('DropdownMenu', () => {
await userEvent.keyboard('{ArrowUp}');
expect(search).toHaveFocus();
});
+
+ it('renders every row and warns when the submenu has nested submenus', async () => {
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
+ render(
+
+ );
+
+ await userEvent.click(screen.getByRole('button', {name: 'Projects'}));
+ await userEvent.hover(screen.getByRole('menuitemradio', {name: 'All Projects'}));
+
+ expect(screen.getByRole('menuitemradio', {name: 'project-99'})).toBeInTheDocument();
+ expect(screen.getByRole('menuitemradio', {name: 'Nested'})).toBeInTheDocument();
+ expect(warn).toHaveBeenCalledWith(
+ expect.stringContaining('`virtualizeThreshold` is ignored')
+ );
+ });
});
it('returns from the first enabled item to search when a disabled item comes first', async () => {
diff --git a/static/app/components/core/dropdownMenu/item.tsx b/static/app/components/core/dropdownMenu/item.tsx
index 4fa1ae4f344e..80fc6ca67377 100644
--- a/static/app/components/core/dropdownMenu/item.tsx
+++ b/static/app/components/core/dropdownMenu/item.tsx
@@ -92,7 +92,8 @@ export interface MenuItemProps extends ListItemProps {
/**
* Number of items above which only the visible rows render. A
* virtualized sub-menu uses `maxWidth` as its width, because hidden
- * rows cannot size it, and cannot contain nested sub-menus.
+ * rows cannot size it. Ignored, with a development warning, when the
+ * sub-menu contains its own nested sub-menus.
*/
virtualizeThreshold?: number;
});
diff --git a/static/app/components/core/dropdownMenu/list.tsx b/static/app/components/core/dropdownMenu/list.tsx
index 3bd17fcde4b8..9ba57027d3d8 100644
--- a/static/app/components/core/dropdownMenu/list.tsx
+++ b/static/app/components/core/dropdownMenu/list.tsx
@@ -10,7 +10,7 @@ import {
import {useTheme, css} from '@emotion/react';
import styled from '@emotion/styled';
import {FocusScope} from '@react-aria/focus';
-import {getInteractionModality, useKeyboard} from '@react-aria/interactions';
+import {useKeyboard} from '@react-aria/interactions';
import type {AriaMenuOptions} from '@react-aria/menu';
import {useMenu, useMenuTrigger} from '@react-aria/menu';
import {useSeparator} from '@react-aria/separator';
@@ -18,8 +18,7 @@ import {mergeProps} from '@react-aria/utils';
import {Item, Section} from '@react-stately/collections';
import type {TreeProps, TreeState} from '@react-stately/tree';
import {useTreeState} from '@react-stately/tree';
-import type {Key, Node} from '@react-types/shared';
-import type {VirtualItem} from '@tanstack/react-virtual';
+import type {Node} from '@react-types/shared';
import type {LocationDescriptor} from 'history';
import {Container, Stack} from '@sentry/scraps/layout';
@@ -28,7 +27,6 @@ import {
Header,
ReserveLeadingSlot,
Search,
- SectionSeparator,
Title,
} from '@sentry/scraps/menu';
import {Overlay, PositionWrapper} from '@sentry/scraps/overlay';
@@ -36,7 +34,6 @@ import {Text} from '@sentry/scraps/text';
import {useTranslation} from '@sentry/scraps/translation/useTranslation';
import {useOverlay} from '@sentry/scraps/useOverlay';
-import {useVirtualRows} from 'sentry/components/tables/useVirtualRows';
import {normalizeUrl} from 'sentry/utils/url/normalizeUrl';
import type {OverlayState} from './context';
@@ -47,8 +44,14 @@ import type {
MenuItemProps,
} from './item';
import {DropdownMenuItem} from './item';
-import {DropdownMenuSection} from './section';
+import {DropdownMenuSection, DropdownMenuSeparator} from './section';
import {useSafetyTriangle} from './useSafetyTriangle';
+import {
+ canVirtualizeMenu,
+ DropdownMenuVirtualCollection,
+ hasSubmenuItems,
+ useVirtualMenu,
+} from './virtualCollection';
export interface DropdownMenuListProps
extends
@@ -201,18 +204,7 @@ function DropdownMenuCollection({
size={size}
state={state}
/>
- {showSeparator && (
-
-
-
- )}
+ {showSeparator && }
);
})}
@@ -220,165 +212,6 @@ function DropdownMenuCollection({
);
}
-type VirtualRow =
- | {section: Node; type: 'heading'}
- | {
- hasGapAfter: boolean;
- node: Node;
- type: 'item';
- section?: Node;
- }
- | {type: 'separator'};
-
-const VIRTUAL_ROW_HEIGHT_ESTIMATES = {
- heading: 36,
- item: 32,
- separator: 8,
-} satisfies Record;
-
-/**
- * Flattens the collection into the rows that `DropdownMenuCollection` renders,
- * so the virtualizer can measure and position each one.
- */
-function getVirtualRows(collection: Array>): VirtualRow[] {
- return collection.flatMap((node, i): VirtualRow[] => {
- const rows: VirtualRow[] = [];
- if (node.type === 'section') {
- if (node.rendered) {
- rows.push({type: 'heading', section: node});
- }
- const children = [...node.childNodes];
- children.forEach((child, childIndex) =>
- rows.push({
- type: 'item',
- node: child,
- section: node,
- hasGapAfter: childIndex < children.length - 1,
- })
- );
- } else {
- rows.push({type: 'item', node, hasGapAfter: false});
- }
-
- const next = collection[i + 1];
- if (next && (node.type === 'section' || next.type === 'section')) {
- rows.push({type: 'separator'});
- }
- return rows;
- });
-}
-
-interface DropdownMenuVirtualCollectionProps extends Omit<
- DropdownMenuCollectionProps,
- 'collection' | 'disableTextSelection' | 'size'
-> {
- measureElement: (element: Element | null) => void;
- paddingBottom: number;
- paddingTop: number;
- rows: VirtualRow[];
- virtualItems: VirtualItem[];
-}
-
-type IndexedVirtualRow = {index: number; row: VirtualRow};
-
-function DropdownMenuVirtualCollection({
- rows,
- virtualItems,
- paddingTop,
- paddingBottom,
- measureElement,
- separatorProps,
- closeOnSelect,
- onClose,
- state,
-}: DropdownMenuVirtualCollectionProps) {
- // Visible rows are contiguous, so each section appears in at most one segment.
- const segments: Array<{rows: IndexedVirtualRow[]; section?: Node}> = [];
- for (const {index} of virtualItems) {
- const row = rows[index];
- if (!row) {
- continue;
- }
- const section = row.type === 'separator' ? undefined : row.section;
- const segment = segments.at(-1);
- if (section && segment?.section === section) {
- segment.rows.push({index, row});
- } else {
- segments.push({section, rows: [{index, row}]});
- }
- }
-
- const renderRow = ({index, row}: IndexedVirtualRow) => {
- if (row.type === 'heading') {
- // Rendered by its section.
- return null;
- }
- if (row.type === 'separator') {
- return (
-
-
-
- );
- }
- return (
-
-
-
- );
- };
-
- return (
-
- {paddingTop > 0 && (
-
- )}
- {segments.map(segment => {
- if (!segment.section) {
- return segment.rows.map(renderRow);
- }
- const heading = segment.rows.find(({row}) => row.type === 'heading');
- return (
-
- {segment.rows.map(renderRow)}
-
- );
- })}
- {paddingBottom > 0 && (
-
- )}
-
- );
-}
-
function DropdownMenuList({
closeOnSelect = true,
onClose,
@@ -415,71 +248,24 @@ function DropdownMenuList({
const {menuProps} = useMenu({...props, selectionMode: 'single'}, state, menuRef);
const {separatorProps} = useSeparator({elementType: 'li'});
- const virtualRows = useMemo(
- () => (virtualized ? getVirtualRows(stateCollection) : []),
- [virtualized, stateCollection]
- );
- const {virtualizer, virtualItems, paddingTop, paddingBottom} = useVirtualRows({
- count: virtualRows.length,
+ const virtualMenu = useVirtualMenu({
+ collection: stateCollection,
enabled: virtualized,
- getScrollElement: () => menuRef.current,
- estimateSize: index =>
- VIRTUAL_ROW_HEIGHT_ESTIMATES[virtualRows[index]?.type ?? 'item'],
+ menuRef,
+ state,
});
- const enabledVirtualKeys = useMemo(
- () =>
- virtualRows.flatMap(row =>
- row.type === 'item' && !state.disabledKeys.has(row.node.key) ? [row.node.key] : []
- ),
- [virtualRows, state.disabledKeys]
- );
-
- // React Aria cannot scroll to rows that are not rendered, so follow keyboard
- // focus here. Pointer focus only reaches rendered rows.
- const {focusedKey, isFocused} = state.selectionManager;
- useEffect(() => {
- if (
- !virtualized ||
- !isFocused ||
- focusedKey === null ||
- getInteractionModality() !== 'keyboard'
- ) {
- return;
- }
- const index = virtualRows.findIndex(
- row => row.type === 'item' && row.node.key === focusedKey
- );
- if (index !== -1) {
- virtualizer.scrollToIndex(index, {align: 'auto'});
- }
- }, [virtualized, isFocused, focusedKey, virtualRows, virtualizer]);
-
- // A virtualized menu does not render every item, so compare keys instead of
- // the rendered elements. Read the key from the element, because React Aria
- // moves the focused key before this menu's key handler runs.
const isFirstEnabledItem = (element: EventTarget | null) =>
virtualized
- ? isMenuItemWithKey(element, enabledVirtualKeys[0])
+ ? virtualMenu.isFirstEnabledItem(element)
: element === getEnabledMenuItems(menuRef.current)[0];
const isLastEnabledItem = (element: EventTarget | null) =>
virtualized
- ? isMenuItemWithKey(element, enabledVirtualKeys.at(-1))
+ ? virtualMenu.isLastEnabledItem(element)
: element === getEnabledMenuItems(menuRef.current).at(-1);
- const focusFirstEnabledItem = () => {
- if (!virtualized) {
- getEnabledMenuItems(menuRef.current)[0]?.focus();
- return;
- }
- const firstKey = enabledVirtualKeys[0];
- if (firstKey === undefined || !menuRef.current) {
- return;
- }
- // The first item renders once the list scrolls back to the top, then
- // focuses itself because it is the focused key.
- menuRef.current.scrollTop = 0;
- state.selectionManager.setFocused(true);
- state.selectionManager.setFocusedKey(firstKey);
- };
+ const focusFirstEnabledItem = () =>
+ virtualized
+ ? virtualMenu.focusFirstEnabledItem()
+ : getEnabledMenuItems(menuRef.current)[0]?.focus();
const {keyboardProps} = useKeyboard({
onKeyDown: e => {
@@ -635,11 +421,7 @@ function DropdownMenuList({
{virtualized ? (
item.getAttribute('aria-disabled') !== 'true');
}
-function isMenuItemWithKey(element: EventTarget | null, key: Key | undefined) {
- return (
- key !== undefined &&
- element instanceof HTMLElement &&
- element.dataset.key === `${key}`
- );
-}
-
/**
* Recursively finds and returns disabled items
*/
@@ -778,18 +552,12 @@ export type DropdownMenuContentProps = Omit<
items: MenuItemProps[];
search?: DropdownMenuSearchOptions;
/**
- * Number of items above which only the visible rows render.
+ * Number of items above which only the visible rows render. Ignored, with a
+ * development warning, when the menu contains nested sub-menus.
*/
virtualizeThreshold?: number;
};
-function countMenuItems(items: MenuItemProps[]): number {
- return items.reduce(
- (count, item) => count + (item.children && !item.submenu ? item.children.length : 1),
- 0
- );
-}
-
export function DropdownMenuContent({
items,
disabledKeys,
@@ -810,9 +578,17 @@ export function DropdownMenuContent({
[visibleItems]
);
- const virtualized =
- virtualizeThreshold !== undefined &&
- countMenuItems(visibleItems) > virtualizeThreshold;
+ const virtualized = canVirtualizeMenu(visibleItems, virtualizeThreshold);
+ const hasNestedSubmenus =
+ virtualizeThreshold !== undefined && hasSubmenuItems(activeItems);
+ useEffect(() => {
+ if (process.env.NODE_ENV !== 'production' && hasNestedSubmenus) {
+ // eslint-disable-next-line no-console
+ console.warn(
+ 'DropdownMenu: `virtualizeThreshold` is ignored because the menu contains nested sub-menus.'
+ );
+ }
+ }, [hasNestedSubmenus]);
return (
['separatorProps'];
+ 'data-index'?: number;
+ ref?: React.Ref;
+}
+
+/**
+ * The divider rendered between sections and their neighboring items.
+ */
+export function DropdownMenuSeparator({
+ separatorProps,
+ ref,
+ 'data-index': dataIndex,
+}: DropdownMenuSeparatorProps) {
+ return (
+
+
+
+ );
+}
+
type DropdownMenuSectionProps = {
children: React.ReactNode;
node: Node;
diff --git a/static/app/components/core/dropdownMenu/virtualCollection.tsx b/static/app/components/core/dropdownMenu/virtualCollection.tsx
new file mode 100644
index 000000000000..aa5c1ff239ea
--- /dev/null
+++ b/static/app/components/core/dropdownMenu/virtualCollection.tsx
@@ -0,0 +1,301 @@
+import {Fragment, useEffect, useMemo} from 'react';
+import {getInteractionModality} from '@react-aria/interactions';
+import type {useSeparator} from '@react-aria/separator';
+import type {TreeState} from '@react-stately/tree';
+import type {Key, Node} from '@react-types/shared';
+
+import {Container} from '@sentry/scraps/layout';
+
+import {useVirtualRows} from 'sentry/components/tables/useVirtualRows';
+
+import type {MenuItemProps} from './item';
+import {DropdownMenuItem} from './item';
+import {DropdownMenuSection, DropdownMenuSeparator} from './section';
+
+/**
+ * Virtualized rendering for `DropdownMenuList`. Only the rows inside the
+ * scroll viewport render, which keeps menus with hundreds of items fast to
+ * open and hover.
+ *
+ * Limitations:
+ * - Items cannot open nested sub-menus. `canVirtualizeMenu` falls back to the
+ * regular collection when they do.
+ * - Hidden rows cannot size the menu, so callers should set `maxWidth` and
+ * `maxHeight`.
+ */
+
+type VirtualRow =
+ | {section: Node; type: 'heading'}
+ | {
+ hasGapAfter: boolean;
+ node: Node;
+ type: 'item';
+ section?: Node;
+ }
+ | {type: 'separator'};
+
+const VIRTUAL_ROW_HEIGHT_ESTIMATES = {
+ heading: 36,
+ item: 32,
+ separator: 8,
+} satisfies Record;
+
+function countMenuItems(items: MenuItemProps[]): number {
+ return items.reduce(
+ (count, item) => count + (item.children && !item.submenu ? item.children.length : 1),
+ 0
+ );
+}
+
+/**
+ * Whether any item, at the top level or inside a section, opens a sub-menu.
+ */
+export function hasSubmenuItems(items: MenuItemProps[]): boolean {
+ return items.some(item =>
+ item.submenu ? true : (item.children?.some(child => child.submenu) ?? false)
+ );
+}
+
+/**
+ * Whether a menu should render only its visible rows. Menus with nested
+ * sub-menus never virtualize, because a sub-menu trigger must stay mounted to
+ * keep its sub-menu open.
+ */
+export function canVirtualizeMenu(
+ items: MenuItemProps[],
+ virtualizeThreshold: number | undefined
+): boolean {
+ return (
+ virtualizeThreshold !== undefined &&
+ countMenuItems(items) > virtualizeThreshold &&
+ !hasSubmenuItems(items)
+ );
+}
+
+/**
+ * Flattens the collection into the rows that `DropdownMenuCollection` renders,
+ * so the virtualizer can measure and position each one.
+ */
+function getVirtualRows(collection: Array>): VirtualRow[] {
+ return collection.flatMap((node, i): VirtualRow[] => {
+ const rows: VirtualRow[] = [];
+ if (node.type === 'section') {
+ if (node.rendered) {
+ rows.push({type: 'heading', section: node});
+ }
+ const children = [...node.childNodes];
+ children.forEach((child, childIndex) =>
+ rows.push({
+ type: 'item',
+ node: child,
+ section: node,
+ hasGapAfter: childIndex < children.length - 1,
+ })
+ );
+ } else {
+ rows.push({type: 'item', node, hasGapAfter: false});
+ }
+
+ const next = collection[i + 1];
+ if (next && (node.type === 'section' || next.type === 'section')) {
+ rows.push({type: 'separator'});
+ }
+ return rows;
+ });
+}
+
+function isMenuItemWithKey(element: EventTarget | null, key: Key | undefined) {
+ return (
+ key !== undefined &&
+ element instanceof HTMLElement &&
+ element.dataset.key === `${key}`
+ );
+}
+
+interface UseVirtualMenuOptions {
+ collection: Array>;
+ enabled: boolean;
+ menuRef: React.RefObject;
+ state: TreeState;
+}
+
+/**
+ * Virtualizes a menu's rows and replaces the keyboard helpers that rely on
+ * every item being rendered.
+ */
+export function useVirtualMenu({
+ collection,
+ enabled,
+ menuRef,
+ state,
+}: UseVirtualMenuOptions) {
+ const rows = useMemo(
+ () => (enabled ? getVirtualRows(collection) : []),
+ [enabled, collection]
+ );
+ const {virtualizer, virtualItems, paddingTop, paddingBottom} = useVirtualRows({
+ count: rows.length,
+ enabled,
+ getScrollElement: () => menuRef.current,
+ estimateSize: index => VIRTUAL_ROW_HEIGHT_ESTIMATES[rows[index]?.type ?? 'item'],
+ });
+ const enabledKeys = useMemo(
+ () =>
+ rows.flatMap(row =>
+ row.type === 'item' && !state.disabledKeys.has(row.node.key) ? [row.node.key] : []
+ ),
+ [rows, state.disabledKeys]
+ );
+
+ // React Aria cannot scroll to rows that are not rendered, so follow keyboard
+ // focus here. Pointer focus only reaches rendered rows.
+ const {focusedKey, isFocused} = state.selectionManager;
+ useEffect(() => {
+ if (
+ !enabled ||
+ !isFocused ||
+ focusedKey === null ||
+ getInteractionModality() !== 'keyboard'
+ ) {
+ return;
+ }
+ const index = rows.findIndex(
+ row => row.type === 'item' && row.node.key === focusedKey
+ );
+ if (index !== -1) {
+ virtualizer.scrollToIndex(index, {align: 'auto'});
+ }
+ }, [enabled, isFocused, focusedKey, rows, virtualizer]);
+
+ return {
+ collectionProps: {
+ rows,
+ virtualItems,
+ paddingTop,
+ paddingBottom,
+ measureElement: virtualizer.measureElement,
+ },
+ // Not every item is rendered, so compare keys instead of elements. Read the
+ // key from the element, because React Aria moves the focused key before the
+ // menu's key handler runs.
+ isFirstEnabledItem: (element: EventTarget | null) =>
+ isMenuItemWithKey(element, enabledKeys[0]),
+ isLastEnabledItem: (element: EventTarget | null) =>
+ isMenuItemWithKey(element, enabledKeys.at(-1)),
+ focusFirstEnabledItem: () => {
+ const firstKey = enabledKeys[0];
+ if (firstKey === undefined || !menuRef.current) {
+ return;
+ }
+ // The first item renders once the list scrolls back to the top, then
+ // focuses itself because it is the focused key.
+ menuRef.current.scrollTop = 0;
+ state.selectionManager.setFocused(true);
+ state.selectionManager.setFocusedKey(firstKey);
+ },
+ };
+}
+
+type VirtualCollectionProps = ReturnType['collectionProps'] & {
+ closeOnSelect: boolean;
+ onClose: (() => void) | undefined;
+ separatorProps: ReturnType['separatorProps'];
+ state: TreeState;
+};
+
+type IndexedVirtualRow = {index: number; row: VirtualRow};
+
+/**
+ * Renders the visible rows, wrapping rows that belong to a section in that
+ * section's group so it keeps its menu semantics.
+ */
+export function DropdownMenuVirtualCollection({
+ rows,
+ virtualItems,
+ paddingTop,
+ paddingBottom,
+ measureElement,
+ separatorProps,
+ closeOnSelect,
+ onClose,
+ state,
+}: VirtualCollectionProps) {
+ // Visible rows are contiguous, so each section appears in at most one segment.
+ const segments: Array<{rows: IndexedVirtualRow[]; section?: Node}> = [];
+ for (const {index} of virtualItems) {
+ const row = rows[index];
+ if (!row) {
+ continue;
+ }
+ const section = row.type === 'separator' ? undefined : row.section;
+ const segment = segments.at(-1);
+ if (section && segment?.section === section) {
+ segment.rows.push({index, row});
+ } else {
+ segments.push({section, rows: [{index, row}]});
+ }
+ }
+
+ const renderRow = ({index, row}: IndexedVirtualRow) => {
+ if (row.type === 'heading') {
+ // Rendered by its section.
+ return null;
+ }
+ if (row.type === 'separator') {
+ return (
+
+ );
+ }
+ return (
+
+
+
+ );
+ };
+
+ return (
+
+ {paddingTop > 0 && (
+
+ )}
+ {segments.map(segment => {
+ if (!segment.section) {
+ return segment.rows.map(renderRow);
+ }
+ const heading = segment.rows.find(({row}) => row.type === 'heading');
+ return (
+
+ {segment.rows.map(renderRow)}
+
+ );
+ })}
+ {paddingBottom > 0 && (
+
+ )}
+
+ );
+}