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;