From 1b1c5d57c3253d734408fec60a4ed65161fca7ec Mon Sep 17 00:00:00 2001 From: HichamELBSI Date: Thu, 9 Oct 2025 15:14:32 +0200 Subject: [PATCH] fix: combobox performance issue --- .changeset/famous-trees-jog.md | 6 + docs/stories/03-inputs/Combobox.stories.tsx | 45 +++++++ .../src/components/Combobox/Combobox.test.tsx | 46 +++++++ .../src/components/Combobox/Combobox.tsx | 29 ++++- .../components/Combobox/VirtualizedList.tsx | 118 ++++++++++++++++++ .../src/components/TimePicker/TimePicker.tsx | 6 +- packages/primitives/package.json | 1 + .../src/components/Combobox/Combobox.tsx | 54 ++++++++ .../Combobox/VirtualizedViewport.tsx | 111 ++++++++++++++++ yarn.lock | 20 +++ 10 files changed, 433 insertions(+), 3 deletions(-) create mode 100644 .changeset/famous-trees-jog.md create mode 100644 packages/design-system/src/components/Combobox/VirtualizedList.tsx create mode 100644 packages/primitives/src/components/Combobox/VirtualizedViewport.tsx diff --git a/.changeset/famous-trees-jog.md b/.changeset/famous-trees-jog.md new file mode 100644 index 000000000..bb51ce6fb --- /dev/null +++ b/.changeset/famous-trees-jog.md @@ -0,0 +1,6 @@ +--- +'@strapi/design-system': major +'@strapi/ui-primitives': major +--- + +Add virtualization as an option to combobox list diff --git a/docs/stories/03-inputs/Combobox.stories.tsx b/docs/stories/03-inputs/Combobox.stories.tsx index 44582fc04..6244713cc 100644 --- a/docs/stories/03-inputs/Combobox.stories.tsx +++ b/docs/stories/03-inputs/Combobox.stories.tsx @@ -652,6 +652,51 @@ export const WithField = { name: 'With Field', }; +export const Virtualization = { + args: { + label: 'Fruits', + error: 'Error', + hint: 'Description line lorem ipsum', + }, + render: ({ error, hint, label, ...comboboxProps }) => { + const [value, setValue] = React.useState(''); + + return ( + + {label} + setValue('')} {...comboboxProps}> + {[...Array(1000)].map((_, i) => ( + + Option {i} + + ))} + + + + + ); + }, + parameters: { + docs: { + source: { + code: outdent` + + {label} + + {options.map(({ name, value }) => ( + {name} + ))} + + + + + `, + }, + }, + }, + name: 'Virtualization', +}; + export const ComboboxProps = { /** * add !dev tag so this story does not appear in the sidebar diff --git a/packages/design-system/src/components/Combobox/Combobox.test.tsx b/packages/design-system/src/components/Combobox/Combobox.test.tsx index c9d15daba..590183854 100644 --- a/packages/design-system/src/components/Combobox/Combobox.test.tsx +++ b/packages/design-system/src/components/Combobox/Combobox.test.tsx @@ -172,6 +172,52 @@ describe('Combobox', () => { }); }); + describe('virtualization', () => { + it('should enable virtualization when there are more than 100 items', async () => { + // Create an array of 150 items + const manyOptions = Array.from({ length: 150 }, (_, i) => ({ + value: `item-${i}`, + children: `Item ${i}`, + })); + + const { getByRole, getByTestId, user } = render({ + options: manyOptions, + }); + + await user.click(getByRole('combobox')); + + // VirtualizedList should be present when >100 items and not filtering + expect(getByTestId('virtualized-list')).toBeInTheDocument(); + }); + + it('should disable virtualization when filtering, even with many items', async () => { + // Create an array of 150 items + const manyOptions = Array.from({ length: 150 }, (_, i) => ({ + value: `item-${i}`, + children: `Item ${i}`, + })); + + const { getByRole, queryByTestId, user } = render({ + options: manyOptions, + }); + + await user.click(getByRole('combobox')); + await user.type(getByRole('combobox'), 'item-1'); + + // When filtering, VirtualizedList should not be used + expect(queryByTestId('virtualized-list')).not.toBeInTheDocument(); + }); + + it('should not use virtualization with fewer than 100 items', async () => { + const { getByRole, queryByTestId, user } = render(); // Uses defaultOptions which has 4 items + + await user.click(getByRole('combobox')); + + // VirtualizedList should not be present with small lists + expect(queryByTestId('virtualized-list')).not.toBeInTheDocument(); + }); + }); + describe('clear props', () => { it('should only show the clear button if the user has started typing an onClear is passed', async () => { const { getByRole, queryByRole, user } = render({ diff --git a/packages/design-system/src/components/Combobox/Combobox.tsx b/packages/design-system/src/components/Combobox/Combobox.tsx index c5da309a4..c7d7428e1 100644 --- a/packages/design-system/src/components/Combobox/Combobox.tsx +++ b/packages/design-system/src/components/Combobox/Combobox.tsx @@ -19,6 +19,8 @@ import { Field, useField } from '../Field'; import { IconButton } from '../IconButton'; import { Loader } from '../Loader'; +import { VirtualizedList } from './VirtualizedList'; + /* ------------------------------------------------------------------------------------------------- * ComboboxInput * -----------------------------------------------------------------------------------------------*/ @@ -64,6 +66,12 @@ interface ComboboxProps */ size?: 'S' | 'M'; startIcon?: React.ReactNode; + /** + * Enable virtualization for large lists + * @default false + */ + // Virtualization is automatic based on the number of options; manual + // control props were removed to simplify the API. } type ComboboxInputElement = HTMLInputElement; @@ -206,6 +214,21 @@ const Combobox = React.forwardRef( const name = field.name ?? nameProp; const required = field.required || requiredProp; + // Compute children count early so we can decide about virtualization + const childArray = React.Children.toArray(children).filter(Boolean); + const childrenCount = childArray.length; + + // If the user is actively filtering/typing, disable virtualization so + // the list can resize to the filtered results and show the NoValueFound node. + const isFiltering = Boolean( + (internalTextValue && internalTextValue !== '') || (internalFilterValue && internalFilterValue !== ''), + ); + + // Auto-enable virtualization when there are more than 100 items and the + // user is not currently filtering. + const AUTO_VIRTUALIZE_THRESHOLD = 100; + const shouldVirtualizeOptions = !isFiltering && childrenCount > AUTO_VIRTUALIZE_THRESHOLD; + let ariaDescription: string | undefined; if (error) { ariaDescription = `${id}-error`; @@ -271,7 +294,11 @@ const Combobox = React.forwardRef( - {children} + {shouldVirtualizeOptions ? ( + {children} + ) : ( + children + )} {creatable !== true && !loading ? ( diff --git a/packages/design-system/src/components/Combobox/VirtualizedList.tsx b/packages/design-system/src/components/Combobox/VirtualizedList.tsx new file mode 100644 index 000000000..6e1123f06 --- /dev/null +++ b/packages/design-system/src/components/Combobox/VirtualizedList.tsx @@ -0,0 +1,118 @@ +import { ReactNode, FC, useRef, useState, useEffect, startTransition, useMemo, Children, useCallback } from 'react'; + +import { useVirtualizer } from '@tanstack/react-virtual'; + +import { Box } from '../../primitives'; + +interface VirtualizedListProps { + children?: ReactNode; + estimatedItemSize?: number; + overscan?: number; + // Optional: lazy rendering support + itemCount?: number; + renderItem?: (index: number) => ReactNode; +} + +/** + * VirtualizedList - Wraps Combobox children in a virtualizer for performance + * This component should be used inside ScrollArea to virtualize the list + * + * Two modes: + * 1. Children mode (default): Pass children directly + * 2. Lazy mode: Pass itemCount + renderItem for maximum performance + */ +export const VirtualizedList: FC = ({ + children, + estimatedItemSize = 40, + overscan = 10, + itemCount, + renderItem, +}) => { + const parentRef = useRef(null); + const [isReady, setIsReady] = useState(false); + const isMountedRef = useRef(true); + + useEffect(() => { + isMountedRef.current = true; + + if (typeof startTransition === 'function') { + startTransition(() => { + if (isMountedRef.current) { + setIsReady(true); + } + }); + } + + return () => { + isMountedRef.current = false; + }; + }, []); + + // Convert children to array only once and cache it (for children mode) + const childArray = useMemo(() => { + if (renderItem && itemCount !== undefined) { + // Lazy mode: no children array needed + return []; + } + return Children.toArray(children); + }, [children, renderItem, itemCount]); + + const count = itemCount ?? childArray.length; + + const virtualizer = useVirtualizer({ + count, + // parentRef is the inner container; the scroll element is its closest scrollable ancestor + getScrollElement: () => parentRef.current ?? null, + estimateSize: useCallback(() => estimatedItemSize, [estimatedItemSize]), + overscan, + // Optimize scroll performance + scrollMargin: 0, + // Don't measure elements dynamically - use fixed size + measureElement: undefined, + // Use lanes for better performance with large lists + lanes: 1, + }); + + // Get virtual items - this updates as you scroll + const virtualItems = isReady && isMountedRef.current ? virtualizer.getVirtualItems() : []; + + // Show minimal content until ready to prevent blocking + if (!isReady) { + // Small placeholder while React.startTransition finishes to avoid huge blank areas + return ; + } + + return ( + 0 ? virtualizer.getTotalSize() : 0}px`} + width="100%" + position="relative" + data-testid="virtualized-list" + style={{ + willChange: 'transform', + }} + > + {virtualItems.map((virtualItem) => { + // Lazy mode: render on-demand + const child = renderItem ? renderItem(virtualItem.index) : childArray[virtualItem.index]; + + return ( + + {child} + + ); + })} + + ); +}; diff --git a/packages/design-system/src/components/TimePicker/TimePicker.tsx b/packages/design-system/src/components/TimePicker/TimePicker.tsx index 2836761ff..dda7cc7c1 100644 --- a/packages/design-system/src/components/TimePicker/TimePicker.tsx +++ b/packages/design-system/src/components/TimePicker/TimePicker.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { Clock } from '@strapi/icons'; -import styled from 'styled-components'; +import { styled } from 'styled-components'; import { useControllableState } from '../../hooks/useControllableState'; import { useDateFormatter } from '../../hooks/useDateFormatter'; @@ -74,9 +74,11 @@ export const TimePicker = React.forwardRef { const stepCount = 60 / step; - return [...Array(24).keys()].flatMap((hour) => [...Array(stepCount).keys()].map((minuteStep) => formatter.format(new Date(0, 0, 0, hour, minuteStep * step))), ); diff --git a/packages/primitives/package.json b/packages/primitives/package.json index 86f356ccd..9867dd66a 100644 --- a/packages/primitives/package.json +++ b/packages/primitives/package.json @@ -38,6 +38,7 @@ "@radix-ui/react-use-layout-effect": "1.0.1", "@radix-ui/react-use-previous": "1.0.1", "@radix-ui/react-visually-hidden": "1.0.3", + "@tanstack/react-virtual": "^3.10.8", "aria-hidden": "1.2.4", "react-remove-scroll": "2.5.10" }, diff --git a/packages/primitives/src/components/Combobox/Combobox.tsx b/packages/primitives/src/components/Combobox/Combobox.tsx index 32af27dac..e48e4d29f 100644 --- a/packages/primitives/src/components/Combobox/Combobox.tsx +++ b/packages/primitives/src/components/Combobox/Combobox.tsx @@ -20,6 +20,8 @@ import { useFilter } from '../../hooks/useFilter'; import { usePrev } from '../../hooks/usePrev'; import { createCollection } from '../Collection'; +import { VirtualizedViewport } from './VirtualizedViewport'; + import type { ComponentPropsWithoutRef } from '@radix-ui/react-primitive'; const OPEN_KEYS = [' ', 'Enter', 'ArrowUp', 'ArrowDown']; @@ -88,6 +90,9 @@ type ComboboxContextValue = { onVisuallyFocussedItemChange: (item: HTMLDivElement | null) => void; isPrintableCharacter: (str: string) => boolean; visible?: boolean; + virtualized?: boolean | 'auto'; + estimatedItemSize?: number; + overscan?: number; }; const [ComboboxProvider, useComboboxContext] = createContext(COMBOBOX_NAME); @@ -113,6 +118,24 @@ interface RootProps { onFilterValueChange?(value: string): void; isPrintableCharacter?: (str: string) => boolean; visible?: boolean; + /** + * Enable virtualization for large lists + * - true: always virtualize + * - false: never virtualize + * - 'auto': virtualize when item count > 100 (default) + * @default 'auto' + */ + virtualized?: boolean | 'auto'; + /** + * Estimated size of each virtualized item in pixels + * @default 40 + */ + estimatedItemSize?: number; + /** + * Number of items to render outside visible area for smooth scrolling + * @default 5 + */ + overscan?: number; } /** @@ -162,6 +185,9 @@ const Combobox = (props: RootProps) => { onFilterValueChange, isPrintableCharacter = defaultIsPrintableCharacter, visible = false, + virtualized = 'auto', + estimatedItemSize = 40, + overscan = 5, } = props; const [trigger, setTrigger] = React.useState(null); @@ -269,6 +295,9 @@ const Combobox = (props: RootProps) => { onVisuallyFocussedItemChange={setVisuallyFocussedItem} isPrintableCharacter={isPrintableCharacter} visible={visible} + virtualized={virtualized} + estimatedItemSize={estimatedItemSize} + overscan={overscan} > {children} @@ -921,6 +950,31 @@ const ComboboxViewport = React.forwardRef React.Children.toArray(props.children), [props.children]); + const itemCount = childArray.length; + + const shouldVirtualize = + comboboxContext.virtualized === true || (comboboxContext.virtualized === 'auto' && itemCount > 100); + + // If virtualization is enabled, use VirtualizedViewport + if (shouldVirtualize) { + return ( + + itemCount} + estimatedItemSize={comboboxContext.estimatedItemSize} + overscan={comboboxContext.overscan} + onViewportChange={comboboxContext.onViewportChange} + /> + + ); + } + + // Standard non-virtualized viewport return ( <> {/* Hide scrollbars cross-browser and enable momentum scroll for touch devices */} diff --git a/packages/primitives/src/components/Combobox/VirtualizedViewport.tsx b/packages/primitives/src/components/Combobox/VirtualizedViewport.tsx new file mode 100644 index 000000000..19d4de8f7 --- /dev/null +++ b/packages/primitives/src/components/Combobox/VirtualizedViewport.tsx @@ -0,0 +1,111 @@ +import * as React from 'react'; + +import { useComposedRefs } from '@radix-ui/react-compose-refs'; +import { Primitive } from '@radix-ui/react-primitive'; +import { useVirtualizer } from '@tanstack/react-virtual'; + +import type { ComponentPropsWithoutRef } from '@radix-ui/react-primitive'; + +/** + * VirtualizedViewport - Renders only visible items for performance optimization + * Used when Combobox/Select has many items (>100) + */ + +type VirtualizedViewportElement = React.ElementRef; +type PrimitiveDivProps = ComponentPropsWithoutRef; + +export interface VirtualizedViewportProps extends PrimitiveDivProps { + /** + * Estimated size of each item in pixels + * @default 40 + */ + estimatedItemSize?: number; + /** + * Number of items to render outside the visible area + * @default 5 + */ + overscan?: number; + /** + * Callback to get the count of total items + */ + getItemCount: () => number; + /** + * Callback when viewport ref changes + */ + onViewportChange?: (node: VirtualizedViewportElement | null) => void; +} + +export const VirtualizedViewport = React.forwardRef( + ({ children, estimatedItemSize = 40, overscan = 5, getItemCount, onViewportChange, ...props }, forwardedRef) => { + const parentRef = React.useRef(null); + const composedRefs = useComposedRefs(forwardedRef, parentRef, onViewportChange); + + // Get array of children to virtualize + const childArray = React.useMemo(() => React.Children.toArray(children), [children]); + + const virtualizer = useVirtualizer({ + count: getItemCount(), + getScrollElement: () => parentRef.current, + estimateSize: () => estimatedItemSize, + overscan, + }); + + const virtualItems = virtualizer.getVirtualItems(); + + return ( + <> + {/* Hide scrollbars cross-browser and enable momentum scroll for touch devices */} +