From af5cce71d9d0287bccad5ac2c1182e17c27cb341 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Mon, 13 Jul 2026 20:18:21 -0700 Subject: [PATCH 1/3] deps(ui): Upgrade TanStack Virtual TanStack Virtual now reads offset dimensions for the scroll element, which left our JSDOM tests with an empty virtual range. Update the shared fixture and affected tests to mock the dimensions the library actually uses. Memoize the existing item key callbacks so the new virtual core can keep its measurement cache across renders. Co-Authored-By: OpenAI Codex --- package.json | 2 +- pnpm-lock.yaml | 18 +++++------ .../breadcrumbsDataSection.spec.tsx | 14 ++------- .../breadcrumbs/breadcrumbsDrawer.spec.tsx | 16 ++-------- .../eventFeatureFlagDrawer.spec.tsx | 16 ++-------- .../eventFeatureFlagSection.spec.tsx | 14 ++------- static/app/utils/fixtures/virtualization.ts | 30 ++++++++++++------- .../app/views/explore/logs/content.spec.tsx | 6 ++-- .../app/views/explore/logs/logsTab.spec.tsx | 6 ++-- .../explore/logs/tables/logsInfiniteTable.tsx | 7 ++++- .../explore/replays/detail/console/index.tsx | 6 +++- .../newTraceDetails/traceOurlogs.spec.tsx | 6 ++-- .../snapshots/main/snapshotListView.spec.tsx | 13 ++------ .../snapshots/main/snapshotListView.tsx | 3 +- .../sidebar/snapshotSidebarContent.spec.tsx | 13 ++------ .../dynamicSampling/projectsTable.spec.tsx | 13 ++------ .../dynamicSampling/projectsTable.tsx | 7 ++++- .../components/scmRepositoryTable.spec.tsx | 22 ++------------ .../components/scmRepositoryTable.tsx | 7 ++++- .../organizationRepositories/index.spec.tsx | 22 ++------------ .../views/settings/projectSeer/index.spec.tsx | 14 ++------- 21 files changed, 87 insertions(+), 168 deletions(-) diff --git a/package.json b/package.json index e4d4e059c18a..37f9e4102550 100644 --- a/package.json +++ b/package.json @@ -132,7 +132,7 @@ "@tanstack/react-query": "5.96.0", "@tanstack/react-query-devtools": "5.96.0", "@tanstack/react-query-persist-client": "5.96.0", - "@tanstack/react-virtual": "^3.13.6", + "@tanstack/react-virtual": "3.14.6", "@types/gtag.js": "^0.0.12", "@types/history": "^3.2.5", "@types/invariant": "^2.2.35", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd14d94d1948..c1de97ab12e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -257,8 +257,8 @@ importers: specifier: 5.96.0 version: 5.96.0(@tanstack/react-query@5.96.0(react@19.2.3))(react@19.2.3) '@tanstack/react-virtual': - specifier: ^3.13.6 - version: 3.13.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: 3.14.6 + version: 3.14.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@types/gtag.js': specifier: ^0.0.12 version: 0.0.12 @@ -3718,8 +3718,8 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/react-virtual@3.13.6': - resolution: {integrity: sha512-WT7nWs8ximoQ0CDx/ngoFP7HbQF9Q2wQe4nh2NB+u2486eX3nZRE40P9g6ccCVq7ZfTSH5gFOuCoVH5DLNS/aA==} + '@tanstack/react-virtual@3.14.6': + resolution: {integrity: sha512-4+Uq8m0/gzO4kMCHUEpTtGX1RnONK0C+g88b2ltwPMWUBiaVarBuWKoPJaz7gj1cKCVRAdyu+U8GcKhwCc2beA==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -3741,8 +3741,8 @@ packages: '@tanstack/store@0.9.3': resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} - '@tanstack/virtual-core@3.13.6': - resolution: {integrity: sha512-cnQUeWnhNP8tJ4WsGcYiX24Gjkc9ALstLbHcBj1t3E7EimN6n6kHH+DPV4PpDnuw00NApQp+ViojMj1GRdwYQg==} + '@tanstack/virtual-core@3.17.4': + resolution: {integrity: sha512-nGm5KteqxasUdThLc2izl6dHUqLv0LQj7Nuyo5gYalTPf/U8a9ermvsl7reT+6ioBW1l8WfpP/mcU338nLXpqw==} '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} @@ -12559,9 +12559,9 @@ snapshots: react-dom: 19.2.3(react@19.2.3) use-sync-external-store: 1.6.0(react@19.2.3) - '@tanstack/react-virtual@3.13.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@tanstack/react-virtual@3.14.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@tanstack/virtual-core': 3.13.6 + '@tanstack/virtual-core': 3.17.4 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) @@ -12578,7 +12578,7 @@ snapshots: '@tanstack/store@0.9.3': {} - '@tanstack/virtual-core@3.13.6': {} + '@tanstack/virtual-core@3.17.4': {} '@testing-library/dom@10.4.1': dependencies: diff --git a/static/app/components/events/breadcrumbs/breadcrumbsDataSection.spec.tsx b/static/app/components/events/breadcrumbs/breadcrumbsDataSection.spec.tsx index 7a5eb9bed49d..89c43fce8c34 100644 --- a/static/app/components/events/breadcrumbs/breadcrumbsDataSection.spec.tsx +++ b/static/app/components/events/breadcrumbs/breadcrumbsDataSection.spec.tsx @@ -15,19 +15,9 @@ import { MOCK_EXCEPTION_ENTRY, } from 'sentry/components/events/breadcrumbs/testUtils'; import {EntryType} from 'sentry/types/event'; +import {mockElementSize} from 'sentry/utils/fixtures/virtualization'; -// Needed to mock useVirtualizer lists. -jest.spyOn(window.Element.prototype, 'getBoundingClientRect').mockImplementation(() => ({ - x: 0, - y: 0, - width: 0, - height: 30, - left: 0, - top: 0, - right: 0, - bottom: 0, - toJSON: jest.fn(), -})); +mockElementSize({width: 0, height: 30}); describe('BreadcrumbsDataSection', () => { it('renders a summary of breadcrumbs with a button to view them all', async () => { diff --git a/static/app/components/events/breadcrumbs/breadcrumbsDrawer.spec.tsx b/static/app/components/events/breadcrumbs/breadcrumbsDrawer.spec.tsx index 79afc887a407..1b8aef335430 100644 --- a/static/app/components/events/breadcrumbs/breadcrumbsDrawer.spec.tsx +++ b/static/app/components/events/breadcrumbs/breadcrumbsDrawer.spec.tsx @@ -5,22 +5,10 @@ import { MOCK_BREADCRUMBS, MOCK_DATA_SECTION_PROPS, } from 'sentry/components/events/breadcrumbs/testUtils'; +import {mockElementSize} from 'sentry/utils/fixtures/virtualization'; async function renderBreadcrumbDrawer() { - // Needed to mock useVirtualizer lists. - jest - .spyOn(window.Element.prototype, 'getBoundingClientRect') - .mockImplementation(() => ({ - x: 0, - y: 0, - width: 0, - height: 30, - left: 0, - top: 0, - right: 0, - bottom: 0, - toJSON: jest.fn(), - })); + mockElementSize({width: 0, height: 30}); render(); await userEvent.click(screen.getByRole('button', {name: 'View 2 more'})); return screen.getByRole('complementary', {name: 'breadcrumb drawer'}); diff --git a/static/app/components/events/featureFlags/eventFeatureFlagDrawer.spec.tsx b/static/app/components/events/featureFlags/eventFeatureFlagDrawer.spec.tsx index 475ae7eccea0..7252b2c6de93 100644 --- a/static/app/components/events/featureFlags/eventFeatureFlagDrawer.spec.tsx +++ b/static/app/components/events/featureFlags/eventFeatureFlagDrawer.spec.tsx @@ -8,23 +8,11 @@ import { MOCK_DATA_SECTION_PROPS_ONE_EXTRA_FLAG, MOCK_FLAGS, } from 'sentry/components/events/featureFlags/testUtils'; +import {mockElementSize} from 'sentry/utils/fixtures/virtualization'; import {GroupDataContextProvider} from 'sentry/views/issueDetails/groupDataContext'; async function renderFlagDrawer() { - // Needed to mock useVirtualizer lists. - jest - .spyOn(window.Element.prototype, 'getBoundingClientRect') - .mockImplementation(() => ({ - x: 0, - y: 0, - width: 0, - height: 30, - left: 0, - top: 0, - right: 0, - bottom: 0, - toJSON: jest.fn(), - })); + mockElementSize({width: 0, height: 30}); render( ({ - x: 0, - y: 0, - width: 0, - height: 30, - left: 0, - top: 0, - right: 0, - bottom: 0, - toJSON: jest.fn(), -})); +mockElementSize({width: 0, height: 30}); describe('EventFeatureFlagList', () => { beforeEach(() => { diff --git a/static/app/utils/fixtures/virtualization.ts b/static/app/utils/fixtures/virtualization.ts index 3c092e1fe7da..ac2287739ee3 100644 --- a/static/app/utils/fixtures/virtualization.ts +++ b/static/app/utils/fixtures/virtualization.ts @@ -1,18 +1,28 @@ +interface MockElementSizeOptions { + height?: number; + width?: number; +} + /** - * Tanstack Virtual renders zero items in the default zero-sized JSDom environment. - * This forces all elements to have a non-zero size to render at least a few rows. - * https://github.com/TanStack/virtual/issues/641 + * TanStack Virtual renders zero items in the default zero-sized JSDOM environment. + * Mock the element dimensions read by the virtualizer so it can calculate a range. + * The bounding rect remains mocked for components that perform their own layout reads. */ -export function mockGetBoundingClientRect() { - Element.prototype.getBoundingClientRect = jest.fn(() => ({ - width: 500, - height: 500, +export function mockElementSize({ + width = 500, + height = 500, +}: MockElementSizeOptions = {}) { + jest.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(width); + jest.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(height); + jest.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ + width, + height, top: 0, left: 0, - bottom: 500, - right: 500, + bottom: height, + right: width, x: 0, y: 0, toJSON: () => {}, - })); + }); } diff --git a/static/app/views/explore/logs/content.spec.tsx b/static/app/views/explore/logs/content.spec.tsx index e7a775b3d50e..253902241473 100644 --- a/static/app/views/explore/logs/content.spec.tsx +++ b/static/app/views/explore/logs/content.spec.tsx @@ -16,13 +16,15 @@ import {ProjectsStore} from 'sentry/stores/projectsStore'; import {TeamStore} from 'sentry/stores/teamStore'; import type {Organization} from 'sentry/types/organization'; import type {Project} from 'sentry/types/project'; -import {mockGetBoundingClientRect} from 'sentry/utils/fixtures/virtualization'; +import {mockElementSize} from 'sentry/utils/fixtures/virtualization'; import {LOGS_AUTO_REFRESH_KEY} from 'sentry/views/explore/contexts/logs/logsAutoRefreshContext'; import type {OurLogsResponseItem} from 'sentry/views/explore/logs/types'; import LogsPage from './content'; -beforeEach(mockGetBoundingClientRect); +beforeEach(() => { + mockElementSize(); +}); describe('LogsPage', () => { let organization: Organization; diff --git a/static/app/views/explore/logs/logsTab.spec.tsx b/static/app/views/explore/logs/logsTab.spec.tsx index 953b31a28e11..7c685bdaeedd 100644 --- a/static/app/views/explore/logs/logsTab.spec.tsx +++ b/static/app/views/explore/logs/logsTab.spec.tsx @@ -5,7 +5,7 @@ import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrar import type {DatePageFilterProps} from 'sentry/components/pageFilters/date/datePageFilter'; import {LogsAnalyticsPageSource} from 'sentry/utils/analytics/logsAnalyticsEvent'; -import {mockGetBoundingClientRect} from 'sentry/utils/fixtures/virtualization'; +import {mockElementSize} from 'sentry/utils/fixtures/virtualization'; import {LOGS_AUTO_REFRESH_KEY} from 'sentry/views/explore/contexts/logs/logsAutoRefreshContext'; import {LogsPageDataProvider} from 'sentry/views/explore/contexts/logs/logsPageData'; import { @@ -37,7 +37,9 @@ const datePageFilterProps: DatePageFilterProps = { }), }; -beforeEach(mockGetBoundingClientRect); +beforeEach(() => { + mockElementSize(); +}); describe('LogsTabContent', () => { const {organization, project, setupPageFilters} = initializeLogsTest(); diff --git a/static/app/views/explore/logs/tables/logsInfiniteTable.tsx b/static/app/views/explore/logs/tables/logsInfiniteTable.tsx index 4d213b4cd6bb..202412e6ee54 100644 --- a/static/app/views/explore/logs/tables/logsInfiniteTable.tsx +++ b/static/app/views/explore/logs/tables/logsInfiniteTable.tsx @@ -277,12 +277,17 @@ export function LogsInfiniteTable({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchString, localOnlyItemFilters?.filterText]); + const getItemKey = useCallback( + (index: number) => data?.[index]?.[OurLogKnownFieldKey.ID] ?? index, + [data] + ); + const virtualizer = useVirtualizer({ count: data?.length ?? 0, estimateSize, overscan: 35, getScrollElement: () => tableBodyRef?.current, - getItemKey: (index: number) => data?.[index]?.[OurLogKnownFieldKey.ID] ?? index, + getItemKey, }); useLayoutEffect(() => { diff --git a/static/app/views/explore/replays/detail/console/index.tsx b/static/app/views/explore/replays/detail/console/index.tsx index 904690e304e5..1521dd0e1bc3 100644 --- a/static/app/views/explore/replays/detail/console/index.tsx +++ b/static/app/views/explore/replays/detail/console/index.tsx @@ -49,13 +49,17 @@ export function Console() { const clearSearchTerm = () => setSearchTerm(''); const scrollContainerRef = useRef(null); + const getItemKey = useCallback( + (index: number) => getVirtualItemKey(items[index], index), + [items] + ); const virtualizer = useVirtualizer({ count: items.length, getScrollElement: () => scrollContainerRef.current, estimateSize: () => ESTIMATED_ROW_HEIGHT, overscan: 12, - getItemKey: index => getVirtualItemKey(items[index], index), + getItemKey, useAnimationFrameWithResizeObserver: true, }); diff --git a/static/app/views/performance/newTraceDetails/traceOurlogs.spec.tsx b/static/app/views/performance/newTraceDetails/traceOurlogs.spec.tsx index f3a4c7446141..d8e28381e4e1 100644 --- a/static/app/views/performance/newTraceDetails/traceOurlogs.spec.tsx +++ b/static/app/views/performance/newTraceDetails/traceOurlogs.spec.tsx @@ -2,7 +2,7 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; import {render, screen, userEvent, within} from 'sentry-test/reactTestingLibrary'; -import {mockGetBoundingClientRect} from 'sentry/utils/fixtures/virtualization'; +import {mockElementSize} from 'sentry/utils/fixtures/virtualization'; import {OurLogKnownFieldKey} from 'sentry/views/explore/logs/types'; import { TraceViewLogsDataProvider, @@ -19,7 +19,9 @@ function Component({traceSlug}: {traceSlug: string}) { ); } -beforeEach(mockGetBoundingClientRect); +beforeEach(() => { + mockElementSize(); +}); describe('TraceViewLogsSection', () => { beforeEach(() => { diff --git a/static/app/views/preprod/snapshots/main/snapshotListView.spec.tsx b/static/app/views/preprod/snapshots/main/snapshotListView.spec.tsx index 610f7e169f70..695183962187 100644 --- a/static/app/views/preprod/snapshots/main/snapshotListView.spec.tsx +++ b/static/app/views/preprod/snapshots/main/snapshotListView.spec.tsx @@ -1,5 +1,6 @@ import {render, screen} from 'sentry-test/reactTestingLibrary'; +import {mockElementSize} from 'sentry/utils/fixtures/virtualization'; import type { SidebarItem, SnapshotDiffPair, @@ -69,17 +70,7 @@ const erroredItem: SidebarItem = { describe('SnapshotListView', () => { beforeEach(() => { jest.clearAllMocks(); - jest.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ - width: 900, - height: 600, - top: 0, - left: 0, - bottom: 600, - right: 900, - x: 0, - y: 0, - toJSON: jest.fn(), - }); + mockElementSize({width: 900, height: 600}); // jsdom returns empty padding strings; parseFloat('') is NaN, which would // propagate into the virtualizer's height math. Force numeric padding. jest diff --git a/static/app/views/preprod/snapshots/main/snapshotListView.tsx b/static/app/views/preprod/snapshots/main/snapshotListView.tsx index 68807669216e..72be351cb59f 100644 --- a/static/app/views/preprod/snapshots/main/snapshotListView.tsx +++ b/static/app/views/preprod/snapshots/main/snapshotListView.tsx @@ -234,12 +234,13 @@ export const SnapshotListView = memo(function SnapshotListView({ }, []); const groups = useMemo(() => buildGroups(items, contentWidth), [items, contentWidth]); + const getItemKey = useCallback((index: number) => groups[index]!.id, [groups]); const virtualizer = useVirtualizer({ count: groups.length, getScrollElement: () => scrollRef.current, estimateSize: i => groups[i]!.estimatedHeight, - getItemKey: i => groups[i]!.id, + getItemKey, overscan: 5, scrollPaddingEnd: 8, }); diff --git a/static/app/views/preprod/snapshots/sidebar/snapshotSidebarContent.spec.tsx b/static/app/views/preprod/snapshots/sidebar/snapshotSidebarContent.spec.tsx index b8ac23da9086..f330f930933d 100644 --- a/static/app/views/preprod/snapshots/sidebar/snapshotSidebarContent.spec.tsx +++ b/static/app/views/preprod/snapshots/sidebar/snapshotSidebarContent.spec.tsx @@ -1,5 +1,6 @@ import {render, screen} from 'sentry-test/reactTestingLibrary'; +import {mockElementSize} from 'sentry/utils/fixtures/virtualization'; import {DiffStatus} from 'sentry/views/preprod/types/snapshotTypes'; import {SnapshotSidebarContent, type SidebarSection} from './snapshotSidebarContent'; @@ -7,17 +8,7 @@ import {SnapshotSidebarContent, type SidebarSection} from './snapshotSidebarCont const noop = () => {}; beforeEach(() => { - jest.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ - width: 350, - height: 600, - top: 0, - left: 0, - bottom: 600, - right: 350, - x: 0, - y: 0, - toJSON: jest.fn(), - }); + mockElementSize({width: 350, height: 600}); }); const statusCounts: Record = { diff --git a/static/app/views/settings/dynamicSampling/projectsTable.spec.tsx b/static/app/views/settings/dynamicSampling/projectsTable.spec.tsx index ff53bd07cb9b..d65175f33000 100644 --- a/static/app/views/settings/dynamicSampling/projectsTable.spec.tsx +++ b/static/app/views/settings/dynamicSampling/projectsTable.spec.tsx @@ -3,21 +3,12 @@ import {ProjectFixture} from 'sentry-fixture/project'; import {render, screen} from 'sentry-test/reactTestingLibrary'; +import {mockElementSize} from 'sentry/utils/fixtures/virtualization'; import type {ProjectionSamplePeriod} from 'sentry/views/settings/dynamicSampling/utils/useProjectSampleCounts'; import {ProjectsTable} from './projectsTable'; -jest.spyOn(window.Element.prototype, 'getBoundingClientRect').mockReturnValue({ - height: 400, - width: 500, - x: 0, - y: 0, - top: 0, - left: 0, - right: 500, - bottom: 400, - toJSON: jest.fn(), -}); +mockElementSize({width: 500, height: 400}); describe('ProjectsTable', () => { const organization = OrganizationFixture({ diff --git a/static/app/views/settings/dynamicSampling/projectsTable.tsx b/static/app/views/settings/dynamicSampling/projectsTable.tsx index 523e71de106f..4de006e54b7d 100644 --- a/static/app/views/settings/dynamicSampling/projectsTable.tsx +++ b/static/app/views/settings/dynamicSampling/projectsTable.tsx @@ -105,6 +105,11 @@ export function ProjectsTable({ return itemsWithExpanded; }, [items, expandedItems, tableSort]); + const getItemKey = useCallback( + (index: number) => sortedItems[index]?.project.id ?? index, + [sortedItems] + ); + const virtualizer = useVirtualizer({ count: sortedItems.length, getScrollElement: () => scrollContainerRef.current, @@ -113,7 +118,7 @@ export function ProjectsTable({ ? BASE_ROW_HEIGHT + (sortedItems[index].subProjects.length + 1) * 21 : BASE_ROW_HEIGHT, overscan: 5, - getItemKey: index => sortedItems[index]?.project.id ?? index, + getItemKey, }); return ( diff --git a/static/app/views/settings/organizationRepositories/components/scmRepositoryTable.spec.tsx b/static/app/views/settings/organizationRepositories/components/scmRepositoryTable.spec.tsx index 1f118d1ed898..d9bcc6922e9e 100644 --- a/static/app/views/settings/organizationRepositories/components/scmRepositoryTable.spec.tsx +++ b/static/app/views/settings/organizationRepositories/components/scmRepositoryTable.spec.tsx @@ -5,29 +5,11 @@ import {RepositoryFixture} from 'sentry-fixture/repository'; import {render, screen, userEvent, within} from 'sentry-test/reactTestingLibrary'; import type {Repository} from 'sentry/types/integrations'; +import {mockElementSize} from 'sentry/utils/fixtures/virtualization'; import type {ScmInstallation} from 'sentry/views/settings/organizationRepositories/types'; import {InstallationOverrideProvider, ScmRepositoryTable} from './scmRepositoryTable'; -// `useVirtualizer` only renders rows whose computed bounding rect overlaps -// the scroll container. Without a stub it sees a 0×0 viewport and renders -// nothing — fake a non-zero box on every element so the rows mount. -function stubBoundingClientRect() { - jest - .spyOn(window.Element.prototype, 'getBoundingClientRect') - .mockImplementation(() => ({ - x: 0, - y: 0, - width: 600, - height: 400, - left: 0, - top: 0, - right: 600, - bottom: 400, - toJSON: jest.fn(), - })); -} - function makeRepo(id: string, name: string): Repository { return RepositoryFixture({ id, @@ -55,7 +37,7 @@ const provider = GitHubIntegrationProviderFixture(); describe('ScmRepositoryTable', () => { beforeEach(() => { - stubBoundingClientRect(); + mockElementSize({width: 600, height: 400}); MockApiClient.addMockResponse({ url: '/organizations/org-slug/projects/', body: [ diff --git a/static/app/views/settings/organizationRepositories/components/scmRepositoryTable.tsx b/static/app/views/settings/organizationRepositories/components/scmRepositoryTable.tsx index 9658b63d5db3..e2bd9b36a2bd 100644 --- a/static/app/views/settings/organizationRepositories/components/scmRepositoryTable.tsx +++ b/static/app/views/settings/organizationRepositories/components/scmRepositoryTable.tsx @@ -527,12 +527,17 @@ function VirtualizedRepoList({ return sortBy(filtered, [r => !hasMapping(r.id), r => r.name]); }, [repositories, repoMatches, mappedProjectSlugsByRepoId]); + const getItemKey = useCallback( + (index: number) => visibleRepos[index]!.id, + [visibleRepos] + ); + const virtualizer = useVirtualizer({ count: visibleRepos.length, getScrollElement: () => scrollRef.current, estimateSize: () => ESTIMATED_REPO_ROW_HEIGHT, overscan: 6, - getItemKey: i => visibleRepos[i]!.id, + getItemKey, }); const renderEmptyMessage = () => { diff --git a/static/app/views/settings/organizationRepositories/index.spec.tsx b/static/app/views/settings/organizationRepositories/index.spec.tsx index 5f4c3e9b4759..3cb824a9241a 100644 --- a/static/app/views/settings/organizationRepositories/index.spec.tsx +++ b/static/app/views/settings/organizationRepositories/index.spec.tsx @@ -11,27 +11,9 @@ import { waitFor, } from 'sentry-test/reactTestingLibrary'; +import {mockElementSize} from 'sentry/utils/fixtures/virtualization'; import OrganizationRepositories from 'sentry/views/settings/organizationRepositories'; -// ScmRepositoryTable uses @tanstack/react-virtual, which only renders rows -// whose bounding rect overlaps the scroll container. Without this stub it -// sees a 0×0 viewport and renders nothing. -function stubBoundingClientRect() { - jest - .spyOn(window.Element.prototype, 'getBoundingClientRect') - .mockImplementation(() => ({ - x: 0, - y: 0, - width: 600, - height: 400, - left: 0, - top: 0, - right: 600, - bottom: 400, - toJSON: jest.fn(), - })); -} - const GITHUB_PROVIDER = GitHubIntegrationProviderFixture(); const GITHUB_INTEGRATION = OrganizationIntegrationsFixture({ id: '1', @@ -72,7 +54,7 @@ function setupDefaultMocks() { describe('OrganizationRepositories', () => { beforeEach(() => { - stubBoundingClientRect(); + mockElementSize({width: 600, height: 400}); }); it('shows a loading indicator while queries are pending', async () => { diff --git a/static/app/views/settings/projectSeer/index.spec.tsx b/static/app/views/settings/projectSeer/index.spec.tsx index 6442a750de80..6985df1ceb4a 100644 --- a/static/app/views/settings/projectSeer/index.spec.tsx +++ b/static/app/views/settings/projectSeer/index.spec.tsx @@ -16,20 +16,10 @@ import type {SeerPreferencesResponse} from 'sentry/components/events/autofix/pre import {CodingAgentProvider} from 'sentry/components/events/autofix/types'; import type {Organization} from 'sentry/types/organization'; import type {DetailedProject} from 'sentry/types/project'; +import {mockElementSize} from 'sentry/utils/fixtures/virtualization'; import {ProjectSeerContainer as ProjectSeer} from 'sentry/views/settings/projectSeer'; -// Needed to mock useVirtualizer lists. -jest.spyOn(window.Element.prototype, 'getBoundingClientRect').mockImplementation(() => ({ - x: 0, - y: 0, - width: 0, - height: 30, - left: 0, - top: 0, - right: 0, - bottom: 0, - toJSON: jest.fn(), -})); +mockElementSize({width: 0, height: 30}); describe('ProjectSeer', () => { let project: DetailedProject; From 78fe266d1f0f5204ed241280616526804de4eed2 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Mon, 13 Jul 2026 20:23:45 -0700 Subject: [PATCH 2/3] add comments --- static/app/utils/fixtures/virtualization.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/static/app/utils/fixtures/virtualization.ts b/static/app/utils/fixtures/virtualization.ts index ac2287739ee3..4d0a41a8c9d3 100644 --- a/static/app/utils/fixtures/virtualization.ts +++ b/static/app/utils/fixtures/virtualization.ts @@ -12,8 +12,11 @@ export function mockElementSize({ width = 500, height = 500, }: MockElementSizeOptions = {}) { + // TanStack Virtual uses offset dimensions to measure its viewport and items. jest.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(width); jest.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(height); + + // Keep component layout reads consistent with the dimensions seen by the virtualizer. jest.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ width, height, From 7b703514fe9e05b63a2497dc8104a96461d6fb97 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Mon, 13 Jul 2026 21:55:31 -0700 Subject: [PATCH 3/3] perf(ui): Speed up large compact selects Avoid repeated selected-option lookups and skip empty search filtering work. Memoize virtualized rows and wire grid measurement refs so fast scrolling does less React work. Co-Authored-By: OpenAI Codex --- .../components/core/compactSelect/control.tsx | 27 +++++++++++++------ .../core/compactSelect/gridList/option.tsx | 19 ++++++++++--- .../core/compactSelect/listBox/option.tsx | 8 ++++-- .../compactSelect/useVirtualizedItems.tsx | 2 ++ .../components/core/compactSelect/utils.tsx | 7 +++++ 5 files changed, 49 insertions(+), 14 deletions(-) diff --git a/static/app/components/core/compactSelect/control.tsx b/static/app/components/core/compactSelect/control.tsx index 305eb5caeee4..22e3a8396639 100644 --- a/static/app/components/core/compactSelect/control.tsx +++ b/static/app/components/core/compactSelect/control.tsx @@ -38,6 +38,7 @@ import type { SearchConfig, SearchMatchResult, SelectKey, + SelectOption, SelectOptionOrSection, SelectOptionWithKey, } from './types'; @@ -435,15 +436,25 @@ export function Control({ * selected, then a count badge will appear. */ const triggerLabel: React.ReactNode = useMemo(() => { - const values = Array.isArray(value) ? value : [value]; - const options = items - .flatMap(item => { - if ('options' in item) { - return item.options; + const values = Array.isArray(value) ? value : value === undefined ? [] : [value]; + if (values.length === 0) { + return {t('None')}; + } + + const selectedValues = new Set(values); + const options: Array> = []; + + findSelectedOptions: for (const item of items) { + const itemOptions = 'options' in item ? item.options : [item]; + for (const option of itemOptions) { + if (selectedValues.has(option.value)) { + options.push(option); + if (options.length === selectedValues.size) { + break findSelectedOptions; + } } - return item; - }) - .filter(item => values.includes(item.value)); + } + } if (options.length === 0) { return {t('None')}; diff --git a/static/app/components/core/compactSelect/gridList/option.tsx b/static/app/components/core/compactSelect/gridList/option.tsx index 84fd1ef8983b..a1d6c85dbc0e 100644 --- a/static/app/components/core/compactSelect/gridList/option.tsx +++ b/static/app/components/core/compactSelect/gridList/option.tsx @@ -1,9 +1,9 @@ -import {Fragment, useContext, useMemo, useRef, useState} from 'react'; +import {Fragment, memo, useContext, useMemo, useRef, useState} from 'react'; import styled from '@emotion/styled'; import type {AriaGridListItemOptions} from '@react-aria/gridlist'; import {useGridListItem, useGridListSelectionCheckbox} from '@react-aria/gridlist'; import {useFocusWithin, useHover} from '@react-aria/interactions'; -import {mergeProps} from '@react-aria/utils'; +import {mergeProps, mergeRefs} from '@react-aria/utils'; import type {ListState} from '@react-stately/list'; import type {Node} from '@react-types/shared'; @@ -25,16 +25,20 @@ export interface GridListOptionProps< listState: ListState; node: Node; size: FormSize; + 'data-index'?: number; + ref?: React.Ref; } /** * A
  • element with accessibile behaviors & attributes. * https://react-spectrum.adobe.com/react-aria/useGridList.html */ -export function GridListOption({ +function GridListOptionComponent({ node, listState, size, + ref: refProp, + 'data-index': dataIndex, }: GridListOptionProps) { const ref = useRef(null); const { @@ -125,7 +129,8 @@ export function GridListOption({ return ( ({ ); } +// Virtualizer range changes re-render the list. Retained rows keep stable props, so +// memoization limits each scroll update to rows entering or leaving the buffer. +export const GridListOption = memo( + GridListOptionComponent +) as typeof GridListOptionComponent; + const StyledMenuListItem = styled(MenuListItem)` > ${InnerWrap} { padding-left: ${p => p.theme.space.md}; diff --git a/static/app/components/core/compactSelect/listBox/option.tsx b/static/app/components/core/compactSelect/listBox/option.tsx index 9c33dabf1369..20c8f79a0fc1 100644 --- a/static/app/components/core/compactSelect/listBox/option.tsx +++ b/static/app/components/core/compactSelect/listBox/option.tsx @@ -1,4 +1,4 @@ -import {Fragment, useContext, useMemo, useRef} from 'react'; +import {Fragment, memo, useContext, useMemo, useRef} from 'react'; import styled from '@emotion/styled'; import type {AriaOptionProps} from '@react-aria/listbox'; import {useOption} from '@react-aria/listbox'; @@ -29,7 +29,7 @@ export interface ListBoxOptionProps extends AriaOptionProps { * A
  • element with accessible behaviors & attributes. * https://react-spectrum.adobe.com/react-aria/useListBox.html */ -export function ListBoxOption({ +function ListBoxOptionComponent({ item, listState, size, @@ -129,6 +129,10 @@ export function ListBoxOption({ ); } +// Virtualizer range changes re-render the list. Retained rows keep stable props, so +// memoization limits each scroll update to rows entering or leaving the buffer. +export const ListBoxOption = memo(ListBoxOptionComponent); + const StyledMenuListItem = styled(MenuListItem)` > ${InnerWrap} { padding-left: ${p => p.theme.space.md}; diff --git a/static/app/components/core/compactSelect/useVirtualizedItems.tsx b/static/app/components/core/compactSelect/useVirtualizedItems.tsx index 0a1e876db060..17c0fcf02f45 100644 --- a/static/app/components/core/compactSelect/useVirtualizedItems.tsx +++ b/static/app/components/core/compactSelect/useVirtualizedItems.tsx @@ -44,6 +44,8 @@ export function useVirtualizedItems({ return heightEstimation.regular; }, enabled: virtualized, + // Keep rich grid rows buffered so fast scrolling does not expose blank space. + overscan: 5, }); if (virtualized) { diff --git a/static/app/components/core/compactSelect/utils.tsx b/static/app/components/core/compactSelect/utils.tsx index aaafd22a4b17..b269021d39b0 100644 --- a/static/app/components/core/compactSelect/utils.tsx +++ b/static/app/components/core/compactSelect/utils.tsx @@ -22,6 +22,9 @@ import type { SelectSectionWithKey, } from './types'; +const EMPTY_HIDDEN_OPTIONS = new Set(); +const EMPTY_SEARCH_SCORES = new Map(); + /** * Normalises the `search` prop into a plain config object (or `undefined` if * search is disabled). Accepts `true` as shorthand for `{}` and treats @@ -163,6 +166,10 @@ export function getHiddenOptions( search: string ) => SearchMatchResult ): {hidden: Set; scores: Map} { + if (!search && limit === Infinity) { + return {hidden: EMPTY_HIDDEN_OPTIONS, scores: EMPTY_SEARCH_SCORES}; + } + const scores = new Map(); const matcher = searchMatcher ?? defaultSearchMatcher;