diff --git a/static/app/components/core/dropdownMenu/dropdownMenu.spec.tsx b/static/app/components/core/dropdownMenu/dropdownMenu.spec.tsx
index 04b88da3074e..dd6182caad4e 100644
--- a/static/app/components/core/dropdownMenu/dropdownMenu.spec.tsx
+++ b/static/app/components/core/dropdownMenu/dropdownMenu.spec.tsx
@@ -468,6 +468,153 @@ 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('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 () => {
render(
- {showSeparator && (
-
-
-
- )}
+ {showSeparator && }
);
})}
@@ -218,6 +225,7 @@ function DropdownMenuList({
disableTextSelection,
overlayState,
overlayPositionProps,
+ virtualized = false,
zIndex,
...props
}: DropdownMenuListProps) {
@@ -240,16 +248,34 @@ function DropdownMenuList({
const {menuProps} = useMenu({...props, selectionMode: 'single'}, state, menuRef);
const {separatorProps} = useSeparator({elementType: 'li'});
+ const virtualMenu = useVirtualMenu({
+ collection: stateCollection,
+ enabled: virtualized,
+ menuRef,
+ state,
+ });
+ const isFirstEnabledItem = (element: EventTarget | null) =>
+ virtualized
+ ? virtualMenu.isFirstEnabledItem(element)
+ : element === getEnabledMenuItems(menuRef.current)[0];
+ const isLastEnabledItem = (element: EventTarget | null) =>
+ virtualized
+ ? virtualMenu.isLastEnabledItem(element)
+ : element === getEnabledMenuItems(menuRef.current).at(-1);
+ const focusFirstEnabledItem = () =>
+ virtualized
+ ? virtualMenu.focusFirstEnabledItem()
+ : getEnabledMenuItems(menuRef.current)[0]?.focus();
+
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 +284,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 +357,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 +398,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 +419,25 @@ function DropdownMenuList({
{...mergeProps(modifiedMenuProps, keyboardProps)}
>
-
+ {virtualized ? (
+
+ ) : (
+
+ )}
{showEmptyMessage && (
{
}, []);
}
-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. Ignored, with a
+ * development warning, when the menu contains nested sub-menus.
+ */
+ virtualizeThreshold?: number;
};
export function DropdownMenuContent({
@@ -515,6 +563,7 @@ export function DropdownMenuContent({
disabledKeys,
size,
search,
+ virtualizeThreshold,
...props
}: DropdownMenuContentProps) {
const [searchValue, setSearchValue] = useState('');
@@ -529,10 +578,23 @@ export function DropdownMenuContent({
[visibleItems]
);
+ 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;
+ /**
+ * 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/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 && (
+
+ )}
+
+ );
+}
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: [