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(