Skip to content

Commit 573c5fa

Browse files
committed
fix: combobox performance issue
1 parent 288931c commit 573c5fa

File tree

10 files changed

+436
-3
lines changed

10 files changed

+436
-3
lines changed

.changeset/famous-trees-jog.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@strapi/design-system': major
3+
'@strapi/ui-primitives': major
4+
---
5+
6+
Add virtualization as an option to combobox list

docs/stories/03-inputs/Combobox.stories.tsx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -652,6 +652,51 @@ export const WithField = {
652652
name: 'With Field',
653653
};
654654

655+
export const Virtualization = {
656+
args: {
657+
label: 'Fruits',
658+
error: 'Error',
659+
hint: 'Description line lorem ipsum',
660+
},
661+
render: ({ error, hint, label, ...comboboxProps }) => {
662+
const [value, setValue] = React.useState<string | undefined>('');
663+
664+
return (
665+
<Field.Root id="with_field" error={error} hint={hint}>
666+
<Field.Label>{label}</Field.Label>
667+
<Combobox value={value} onChange={setValue} onClear={() => setValue('')} {...comboboxProps}>
668+
{[...Array(1000)].map((_, i) => (
669+
<ComboboxOption key={i} value={`option-${i}`}>
670+
Option {i}
671+
</ComboboxOption>
672+
))}
673+
</Combobox>
674+
<Field.Error />
675+
<Field.Hint />
676+
</Field.Root>
677+
);
678+
},
679+
parameters: {
680+
docs: {
681+
source: {
682+
code: outdent`
683+
<Field.Root id="with_field" error={error} hint={hint}>
684+
<Field.Label>{label}</Field.Label>
685+
<Combobox {...props}>
686+
{options.map(({ name, value }) => (
687+
<ComboboxOption key={value} value={value}>{name}</ComboboxOption>
688+
))}
689+
</Combobox>
690+
<Field.Error />
691+
<Field.Hint />
692+
</Field.Root>
693+
`,
694+
},
695+
},
696+
},
697+
name: 'Virtualization',
698+
};
699+
655700
export const ComboboxProps = {
656701
/**
657702
* add !dev tag so this story does not appear in the sidebar

packages/design-system/src/components/Combobox/Combobox.test.tsx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,52 @@ describe('Combobox', () => {
172172
});
173173
});
174174

175+
describe('virtualization', () => {
176+
it('should enable virtualization when there are more than 100 items', async () => {
177+
// Create an array of 150 items
178+
const manyOptions = Array.from({ length: 150 }, (_, i) => ({
179+
value: `item-${i}`,
180+
children: `Item ${i}`,
181+
}));
182+
183+
const { getByRole, getByTestId, user } = render({
184+
options: manyOptions,
185+
});
186+
187+
await user.click(getByRole('combobox'));
188+
189+
// VirtualizedList should be present when >100 items and not filtering
190+
expect(getByTestId('virtualized-list')).toBeInTheDocument();
191+
});
192+
193+
it('should disable virtualization when filtering, even with many items', async () => {
194+
// Create an array of 150 items
195+
const manyOptions = Array.from({ length: 150 }, (_, i) => ({
196+
value: `item-${i}`,
197+
children: `Item ${i}`,
198+
}));
199+
200+
const { getByRole, queryByTestId, user } = render({
201+
options: manyOptions,
202+
});
203+
204+
await user.click(getByRole('combobox'));
205+
await user.type(getByRole('combobox'), 'item-1');
206+
207+
// When filtering, VirtualizedList should not be used
208+
expect(queryByTestId('virtualized-list')).not.toBeInTheDocument();
209+
});
210+
211+
it('should not use virtualization with fewer than 100 items', async () => {
212+
const { getByRole, queryByTestId, user } = render(); // Uses defaultOptions which has 4 items
213+
214+
await user.click(getByRole('combobox'));
215+
216+
// VirtualizedList should not be present with small lists
217+
expect(queryByTestId('virtualized-list')).not.toBeInTheDocument();
218+
});
219+
});
220+
175221
describe('clear props', () => {
176222
it('should only show the clear button if the user has started typing an onClear is passed', async () => {
177223
const { getByRole, queryByRole, user } = render({

packages/design-system/src/components/Combobox/Combobox.tsx

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import { Field, useField } from '../Field';
1919
import { IconButton } from '../IconButton';
2020
import { Loader } from '../Loader';
2121

22+
import { VirtualizedList } from './VirtualizedList';
23+
2224
/* -------------------------------------------------------------------------------------------------
2325
* ComboboxInput
2426
* -----------------------------------------------------------------------------------------------*/
@@ -64,6 +66,12 @@ interface ComboboxProps
6466
*/
6567
size?: 'S' | 'M';
6668
startIcon?: React.ReactNode;
69+
/**
70+
* Enable virtualization for large lists
71+
* @default false
72+
*/
73+
// Virtualization is automatic based on the number of options; manual
74+
// control props were removed to simplify the API.
6775
}
6876

6977
type ComboboxInputElement = HTMLInputElement;
@@ -98,6 +106,7 @@ const Combobox = React.forwardRef<ComboboxInputElement, ComboboxProps>(
98106
onChange,
99107
onClear,
100108
onCreateOption,
109+
// virtualization props removed; virtualization is automatic
101110
onFilterValueChange,
102111
onInputChange,
103112
onTextValueChange,
@@ -206,6 +215,21 @@ const Combobox = React.forwardRef<ComboboxInputElement, ComboboxProps>(
206215
const name = field.name ?? nameProp;
207216
const required = field.required || requiredProp;
208217

218+
// Compute children count early so we can decide about virtualization
219+
const childArray = React.Children.toArray(children).filter(Boolean);
220+
const childrenCount = childArray.length;
221+
222+
// If the user is actively filtering/typing, disable virtualization so
223+
// the list can resize to the filtered results and show the NoValueFound node.
224+
const isFiltering = Boolean(
225+
(internalTextValue && internalTextValue !== '') || (internalFilterValue && internalFilterValue !== ''),
226+
);
227+
228+
// Auto-enable virtualization when there are more than 100 items and the
229+
// user is not currently filtering.
230+
const AUTO_VIRTUALIZE_THRESHOLD = 100;
231+
const shouldVirtualizeOptions = !isFiltering && childrenCount > AUTO_VIRTUALIZE_THRESHOLD;
232+
209233
let ariaDescription: string | undefined;
210234
if (error) {
211235
ariaDescription = `${id}-error`;
@@ -271,7 +295,11 @@ const Combobox = React.forwardRef<ComboboxInputElement, ComboboxProps>(
271295
<Content sideOffset={4}>
272296
<ComboboxPrimitive.Viewport ref={viewportRef}>
273297
<ScrollAreaCombobox>
274-
{children}
298+
{shouldVirtualizeOptions ? (
299+
<VirtualizedList itemCount={childrenCount}>{children}</VirtualizedList>
300+
) : (
301+
children
302+
)}
275303
{creatable !== true && !loading ? (
276304
<ComboboxPrimitive.NoValueFound asChild>
277305
<OptionBox $hasHover={false}>
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { ReactNode, FC, useRef, useState, useEffect, startTransition, useMemo, Children, useCallback } from 'react';
2+
3+
import { useVirtualizer } from '@tanstack/react-virtual';
4+
5+
import { Box } from '../../primitives';
6+
7+
interface VirtualizedListProps {
8+
children?: ReactNode;
9+
estimatedItemSize?: number;
10+
overscan?: number;
11+
// Optional: lazy rendering support
12+
itemCount?: number;
13+
renderItem?: (index: number) => ReactNode;
14+
}
15+
16+
/**
17+
* VirtualizedList - Wraps Combobox children in a virtualizer for performance
18+
* This component should be used inside ScrollArea to virtualize the list
19+
*
20+
* Two modes:
21+
* 1. Children mode (default): Pass children directly
22+
* 2. Lazy mode: Pass itemCount + renderItem for maximum performance
23+
*/
24+
export const VirtualizedList: FC<VirtualizedListProps> = ({
25+
children,
26+
estimatedItemSize = 40,
27+
overscan = 10,
28+
itemCount,
29+
renderItem,
30+
}) => {
31+
const parentRef = useRef<HTMLDivElement>(null);
32+
const [isReady, setIsReady] = useState(false);
33+
const isMountedRef = useRef(true);
34+
35+
useEffect(() => {
36+
isMountedRef.current = true;
37+
38+
if (typeof startTransition === 'function') {
39+
startTransition(() => {
40+
if (isMountedRef.current) {
41+
setIsReady(true);
42+
}
43+
});
44+
}
45+
46+
return () => {
47+
isMountedRef.current = false;
48+
};
49+
}, []);
50+
51+
// Convert children to array only once and cache it (for children mode)
52+
const childArray = useMemo(() => {
53+
if (renderItem && itemCount !== undefined) {
54+
// Lazy mode: no children array needed
55+
return [];
56+
}
57+
return Children.toArray(children);
58+
}, [children, renderItem, itemCount]);
59+
60+
const count = itemCount ?? childArray.length;
61+
62+
const virtualizer = useVirtualizer({
63+
count,
64+
// parentRef is the inner container; the scroll element is its closest scrollable ancestor
65+
getScrollElement: () => parentRef.current ?? null,
66+
estimateSize: useCallback(() => estimatedItemSize, [estimatedItemSize]),
67+
overscan,
68+
// Optimize scroll performance
69+
scrollMargin: 0,
70+
// Don't measure elements dynamically - use fixed size
71+
measureElement: undefined,
72+
// Use lanes for better performance with large lists
73+
lanes: 1,
74+
});
75+
76+
// Get virtual items - this updates as you scroll
77+
const virtualItems = isReady && isMountedRef.current ? virtualizer.getVirtualItems() : [];
78+
79+
// Show minimal content until ready to prevent blocking
80+
if (!isReady) {
81+
// Small placeholder while React.startTransition finishes to avoid huge blank areas
82+
return <Box ref={parentRef} height="40px" width="100%" position="relative" />;
83+
}
84+
85+
return (
86+
<Box
87+
ref={parentRef}
88+
height={`${virtualizer.getTotalSize() > 0 ? virtualizer.getTotalSize() : 0}px`}
89+
width="100%"
90+
position="relative"
91+
data-testid="virtualized-list"
92+
style={{
93+
willChange: 'transform',
94+
}}
95+
>
96+
{virtualItems.map((virtualItem) => {
97+
// Lazy mode: render on-demand
98+
const child = renderItem ? renderItem(virtualItem.index) : childArray[virtualItem.index];
99+
100+
return (
101+
<Box
102+
key={virtualItem.key}
103+
data-index={virtualItem.index}
104+
style={{
105+
position: 'absolute',
106+
top: 0,
107+
left: 0,
108+
width: '100%',
109+
transform: `translate3d(0, ${virtualItem.start}px, 0)`,
110+
}}
111+
>
112+
{child}
113+
</Box>
114+
);
115+
})}
116+
</Box>
117+
);
118+
};

packages/design-system/src/components/TimePicker/TimePicker.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as React from 'react';
22

33
import { Clock } from '@strapi/icons';
4-
import styled from 'styled-components';
4+
import { styled } from 'styled-components';
55

66
import { useControllableState } from '../../hooks/useControllableState';
77
import { useDateFormatter } from '../../hooks/useDateFormatter';
@@ -74,9 +74,11 @@ export const TimePicker = React.forwardRef<ComboboxInputElement, TimePickerProps
7474
return separator;
7575
}, [formatter]);
7676

77+
// Always generate the full set of time options. The Combobox will
78+
// automatically enable virtualization when the number of children
79+
// exceeds the threshold (AUTO_VIRTUALIZE_THRESHOLD).
7780
const timeOptions = React.useMemo(() => {
7881
const stepCount = 60 / step;
79-
8082
return [...Array(24).keys()].flatMap((hour) =>
8183
[...Array(stepCount).keys()].map((minuteStep) => formatter.format(new Date(0, 0, 0, hour, minuteStep * step))),
8284
);
@@ -137,6 +139,8 @@ export const TimePicker = React.forwardRef<ComboboxInputElement, TimePickerProps
137139
const escapedSeparator = escapeForRegex(separator);
138140
const pattern = `\\d{2}${escapedSeparator}\\d{2}`;
139141

142+
// (no lazy render function required anymore)
143+
140144
return (
141145
<TimePickerCombobox
142146
{...restProps}

packages/primitives/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"@radix-ui/react-use-layout-effect": "1.0.1",
3939
"@radix-ui/react-use-previous": "1.0.1",
4040
"@radix-ui/react-visually-hidden": "1.0.3",
41+
"@tanstack/react-virtual": "^3.10.8",
4142
"aria-hidden": "1.2.4",
4243
"react-remove-scroll": "2.5.10"
4344
},

0 commit comments

Comments
 (0)