Skip to content

Commit 8228a8e

Browse files
committed
fix: combobox performance issue
1 parent 00e69fd commit 8228a8e

File tree

8 files changed

+408
-12
lines changed

8 files changed

+408
-12
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

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

Lines changed: 42 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,29 @@ interface ComboboxProps
6466
*/
6567
size?: 'S' | 'M';
6668
startIcon?: React.ReactNode;
69+
/**
70+
* Enable virtualization for large lists
71+
* @default false
72+
*/
73+
virtualized?: boolean;
74+
/**
75+
* Estimated size of each item for virtualization
76+
* @default 40
77+
*/
78+
estimatedItemSize?: number;
79+
/**
80+
* Number of items to render outside visible area
81+
* @default 5
82+
*/
83+
overscan?: number;
84+
/**
85+
* For lazy virtualization: total item count
86+
*/
87+
virtualItemCount?: number;
88+
/**
89+
* For lazy virtualization: function to render item by index
90+
*/
91+
renderVirtualItem?: (index: number) => React.ReactNode;
6792
}
6893

6994
type ComboboxInputElement = HTMLInputElement;
@@ -98,6 +123,11 @@ const Combobox = React.forwardRef<ComboboxInputElement, ComboboxProps>(
98123
onChange,
99124
onClear,
100125
onCreateOption,
126+
virtualized = false,
127+
estimatedItemSize = 40,
128+
overscan = 5,
129+
virtualItemCount,
130+
renderVirtualItem,
101131
onFilterValueChange,
102132
onInputChange,
103133
onTextValueChange,
@@ -271,7 +301,18 @@ const Combobox = React.forwardRef<ComboboxInputElement, ComboboxProps>(
271301
<Content sideOffset={4}>
272302
<ComboboxPrimitive.Viewport ref={viewportRef}>
273303
<ScrollAreaCombobox>
274-
{children}
304+
{virtualized ? (
305+
<VirtualizedList
306+
estimatedItemSize={estimatedItemSize}
307+
overscan={overscan}
308+
itemCount={virtualItemCount}
309+
renderItem={renderVirtualItem}
310+
>
311+
{children}
312+
</VirtualizedList>
313+
) : (
314+
children
315+
)}
275316
{creatable !== true && !loading ? (
276317
<ComboboxPrimitive.NoValueFound asChild>
277318
<OptionBox $hasHover={false}>
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
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+
getScrollElement: () => parentRef.current?.parentElement ?? null,
65+
estimateSize: useCallback(() => estimatedItemSize, [estimatedItemSize]),
66+
overscan,
67+
// Optimize scroll performance
68+
scrollMargin: 0,
69+
// Don't measure elements dynamically - use fixed size
70+
measureElement: undefined,
71+
// Use lanes for better performance with large lists
72+
lanes: 1,
73+
});
74+
75+
// Get virtual items - this updates as you scroll
76+
const virtualItems = isReady && isMountedRef.current ? virtualizer.getVirtualItems() : [];
77+
78+
// Show minimal content until ready to prevent blocking
79+
if (!isReady) {
80+
return <Box ref={parentRef} height="200px" width="100%" position="relative" />;
81+
}
82+
83+
return (
84+
<Box
85+
ref={parentRef}
86+
height={`${virtualizer.getTotalSize()}px`}
87+
width="100%"
88+
position="relative"
89+
style={{
90+
willChange: 'transform',
91+
}}
92+
>
93+
{virtualItems.map((virtualItem) => {
94+
// Lazy mode: render on-demand
95+
const child = renderItem ? renderItem(virtualItem.index) : childArray[virtualItem.index];
96+
97+
return (
98+
<Box
99+
key={virtualItem.key}
100+
data-index={virtualItem.index}
101+
style={{
102+
position: 'absolute',
103+
top: 0,
104+
left: 0,
105+
width: '100%',
106+
transform: `translate3d(0, ${virtualItem.start}px, 0)`,
107+
}}
108+
>
109+
{child}
110+
</Box>
111+
);
112+
})}
113+
</Box>
114+
);
115+
};

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

Lines changed: 59 additions & 11 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,13 +74,43 @@ export const TimePicker = React.forwardRef<ComboboxInputElement, TimePickerProps
7474
return separator;
7575
}, [formatter]);
7676

77+
// Enable virtualization for small steps that generate many options
78+
// step=1 -> 1440 options, step=5 -> 288 options
79+
const shouldVirtualize = step <= 5;
80+
81+
// Increase overscan for smoother scrolling with many items
82+
const overscanCount = step === 1 ? 15 : 10;
83+
84+
// For virtualized lists, generate options lazily to avoid creating 1440 React elements upfront
85+
// For non-virtualized lists, generate all options as before
7786
const timeOptions = React.useMemo(() => {
78-
const stepCount = 60 / step;
87+
if (!shouldVirtualize) {
88+
// Non-virtualized: generate all options as before
89+
const stepCount = 60 / step;
90+
return [...Array(24).keys()].flatMap((hour) =>
91+
[...Array(stepCount).keys()].map((minuteStep) =>
92+
formatter.format(new Date(0, 0, 0, hour, minuteStep * step)),
93+
),
94+
);
95+
}
7996

80-
return [...Array(24).keys()].flatMap((hour) =>
81-
[...Array(stepCount).keys()].map((minuteStep) => formatter.format(new Date(0, 0, 0, hour, minuteStep * step))),
82-
);
83-
}, [step, formatter]);
97+
// Virtualized: return empty array and generate on-demand
98+
return [];
99+
}, [step, formatter, shouldVirtualize]);
100+
101+
// Generate time option on demand for virtualized lists
102+
const getTimeOption = React.useCallback(
103+
(index: number) => {
104+
const stepCount = 60 / step;
105+
const hour = Math.floor(index / stepCount);
106+
const minuteStep = index % stepCount;
107+
return formatter.format(new Date(0, 0, 0, hour, minuteStep * step));
108+
},
109+
[step, formatter],
110+
);
111+
112+
// Calculate total count for virtualized lists
113+
const totalTimeCount = (60 / step) * 24;
84114

85115
const handleTextValueChange = (string?: string) => {
86116
if (!string || isNotAlphabeticalCharacter(string)) {
@@ -137,6 +167,19 @@ export const TimePicker = React.forwardRef<ComboboxInputElement, TimePickerProps
137167
const escapedSeparator = escapeForRegex(separator);
138168
const pattern = `\\d{2}${escapedSeparator}\\d{2}`;
139169

170+
// Render function for lazy virtualization
171+
const renderTimeOption = React.useCallback(
172+
(index: number) => {
173+
const time = getTimeOption(index);
174+
return (
175+
<ComboboxOption key={time} value={time}>
176+
{time}
177+
</ComboboxOption>
178+
);
179+
},
180+
[getTimeOption],
181+
);
182+
140183
return (
141184
<TimePickerCombobox
142185
{...restProps}
@@ -153,12 +196,17 @@ export const TimePicker = React.forwardRef<ComboboxInputElement, TimePickerProps
153196
textValue={textValue}
154197
onTextValueChange={handleTextValueChange}
155198
onBlur={handleBlur}
199+
virtualized={shouldVirtualize}
200+
overscan={overscanCount}
201+
virtualItemCount={shouldVirtualize ? totalTimeCount : undefined}
202+
renderVirtualItem={shouldVirtualize ? renderTimeOption : undefined}
156203
>
157-
{timeOptions.map((time) => (
158-
<ComboboxOption key={time} value={time}>
159-
{time}
160-
</ComboboxOption>
161-
))}
204+
{!shouldVirtualize &&
205+
timeOptions.map((time) => (
206+
<ComboboxOption key={time} value={time}>
207+
{time}
208+
</ComboboxOption>
209+
))}
162210
</TimePickerCombobox>
163211
);
164212
},

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
},

packages/primitives/src/components/Combobox/Combobox.tsx

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import { useFilter } from '../../hooks/useFilter';
2020
import { usePrev } from '../../hooks/usePrev';
2121
import { createCollection } from '../Collection';
2222

23+
import { VirtualizedViewport } from './VirtualizedViewport';
24+
2325
import type { ComponentPropsWithoutRef } from '@radix-ui/react-primitive';
2426

2527
const OPEN_KEYS = [' ', 'Enter', 'ArrowUp', 'ArrowDown'];
@@ -88,6 +90,9 @@ type ComboboxContextValue = {
8890
onVisuallyFocussedItemChange: (item: HTMLDivElement | null) => void;
8991
isPrintableCharacter: (str: string) => boolean;
9092
visible?: boolean;
93+
virtualized?: boolean | 'auto';
94+
estimatedItemSize?: number;
95+
overscan?: number;
9196
};
9297

9398
const [ComboboxProvider, useComboboxContext] = createContext<ComboboxContextValue>(COMBOBOX_NAME);
@@ -113,6 +118,24 @@ interface RootProps {
113118
onFilterValueChange?(value: string): void;
114119
isPrintableCharacter?: (str: string) => boolean;
115120
visible?: boolean;
121+
/**
122+
* Enable virtualization for large lists
123+
* - true: always virtualize
124+
* - false: never virtualize
125+
* - 'auto': virtualize when item count > 100 (default)
126+
* @default 'auto'
127+
*/
128+
virtualized?: boolean | 'auto';
129+
/**
130+
* Estimated size of each virtualized item in pixels
131+
* @default 40
132+
*/
133+
estimatedItemSize?: number;
134+
/**
135+
* Number of items to render outside visible area for smooth scrolling
136+
* @default 5
137+
*/
138+
overscan?: number;
116139
}
117140

118141
/**
@@ -162,6 +185,9 @@ const Combobox = (props: RootProps) => {
162185
onFilterValueChange,
163186
isPrintableCharacter = defaultIsPrintableCharacter,
164187
visible = false,
188+
virtualized = 'auto',
189+
estimatedItemSize = 40,
190+
overscan = 5,
165191
} = props;
166192

167193
const [trigger, setTrigger] = React.useState<ComboboxInputElement | null>(null);
@@ -269,6 +295,9 @@ const Combobox = (props: RootProps) => {
269295
onVisuallyFocussedItemChange={setVisuallyFocussedItem}
270296
isPrintableCharacter={isPrintableCharacter}
271297
visible={visible}
298+
virtualized={virtualized}
299+
estimatedItemSize={estimatedItemSize}
300+
overscan={overscan}
272301
>
273302
{children}
274303
</ComboboxProvider>
@@ -921,6 +950,31 @@ const ComboboxViewport = React.forwardRef<ComboboxViewportElement, ViewportProps
921950
const comboboxContext = useComboboxContext(VIEWPORT_NAME);
922951
const composedRefs = useComposedRefs(forwardedRef, comboboxContext.onViewportChange);
923952

953+
// Count children to determine if virtualization should be enabled
954+
// Use React.Children.count to avoid calling getItems() which causes infinite loops
955+
const childArray = React.useMemo(() => React.Children.toArray(props.children), [props.children]);
956+
const itemCount = childArray.length;
957+
958+
const shouldVirtualize =
959+
comboboxContext.virtualized === true || (comboboxContext.virtualized === 'auto' && itemCount > 100);
960+
961+
// If virtualization is enabled, use VirtualizedViewport
962+
if (shouldVirtualize) {
963+
return (
964+
<Collection.Slot scope={undefined}>
965+
<VirtualizedViewport
966+
{...props}
967+
ref={composedRefs}
968+
getItemCount={() => itemCount}
969+
estimatedItemSize={comboboxContext.estimatedItemSize}
970+
overscan={comboboxContext.overscan}
971+
onViewportChange={comboboxContext.onViewportChange}
972+
/>
973+
</Collection.Slot>
974+
);
975+
}
976+
977+
// Standard non-virtualized viewport
924978
return (
925979
<>
926980
{/* Hide scrollbars cross-browser and enable momentum scroll for touch devices */}

0 commit comments

Comments
 (0)