🔧 fix: Ariakit Combobox Virtualization (#5851)

Ariakit Combobox was not working well with several virtualization libraries as automated focus management was conflicting with scrolling/styling required of other virtualization methods. The entire strategy was replaced using experimental ariakit virtualization component `SelectRenderer`

Performance of component was also improved as a result of latest ariakit lib changes
This commit is contained in:
Danny Avila 2025-02-13 10:07:40 -05:00 committed by GitHub
parent e402979cc5
commit 28fe1218c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 119 additions and 128 deletions

View file

@ -28,7 +28,8 @@
}, },
"homepage": "https://librechat.ai", "homepage": "https://librechat.ai",
"dependencies": { "dependencies": {
"@ariakit/react": "^0.4.11", "@ariakit/react": "^0.4.15",
"@ariakit/react-core": "^0.4.15",
"@codesandbox/sandpack-react": "^2.19.10", "@codesandbox/sandpack-react": "^2.19.10",
"@dicebear/collection": "^7.0.4", "@dicebear/collection": "^7.0.4",
"@dicebear/core": "^7.0.4", "@dicebear/core": "^7.0.4",

View file

@ -1,10 +1,10 @@
import { Search } from 'lucide-react';
import * as Ariakit from '@ariakit/react'; import * as Ariakit from '@ariakit/react';
import { matchSorter } from 'match-sorter'; import { matchSorter } from 'match-sorter';
import { AutoSizer, List } from 'react-virtualized'; import { useMemo, useState, useRef, memo, useEffect } from 'react';
import { startTransition, useMemo, useState, useEffect, useRef, memo } from 'react'; import { SelectRenderer } from '@ariakit/react-core/select/select-renderer';
import { cn } from '~/utils';
import type { OptionWithIcon } from '~/common'; import type { OptionWithIcon } from '~/common';
import { Search } from 'lucide-react'; import { cn } from '~/utils';
interface ControlComboboxProps { interface ControlComboboxProps {
selectedValue: string; selectedValue: string;
@ -35,11 +35,33 @@ function ControlCombobox({
const buttonRef = useRef<HTMLButtonElement>(null); const buttonRef = useRef<HTMLButtonElement>(null);
const [buttonWidth, setButtonWidth] = useState<number | null>(null); const [buttonWidth, setButtonWidth] = useState<number | null>(null);
const getItem = (option: OptionWithIcon) => ({
id: `item-${option.value}`,
value: option.value as string | undefined,
label: option.label,
icon: option.icon,
});
const combobox = Ariakit.useComboboxStore({
defaultItems: items.map(getItem),
resetValueOnHide: true,
value: searchValue,
setValue: setSearchValue,
});
const select = Ariakit.useSelectStore({
combobox,
defaultItems: items.map(getItem),
value: selectedValue,
setValue,
});
const matches = useMemo(() => { const matches = useMemo(() => {
return matchSorter(items, searchValue, { const filteredItems = matchSorter(items, searchValue, {
keys: ['value', 'label'], keys: ['value', 'label'],
baseSort: (a, b) => (a.index < b.index ? -1 : 1), baseSort: (a, b) => (a.index < b.index ? -1 : 1),
}); });
return filteredItems.map(getItem);
}, [searchValue, items]); }, [searchValue, items]);
useEffect(() => { useEffect(() => {
@ -48,104 +70,74 @@ function ControlCombobox({
} }
}, [isCollapsed]); }, [isCollapsed]);
const rowRenderer = ({
index,
key,
style,
}: {
index: number;
key: string;
style: React.CSSProperties;
}) => {
const item = matches[index];
return (
<Ariakit.SelectItem
key={key}
value={`${item.value ?? ''}`}
aria-label={`${item.label ?? item.value ?? ''}`}
className={cn(
'flex cursor-pointer items-center px-3 text-sm',
'text-text-primary hover:bg-surface-tertiary',
'data-[active-item]:bg-surface-tertiary',
)}
render={<Ariakit.ComboboxItem />}
style={style}
>
{item.icon != null && (
<div className="assistant-item mr-2 flex h-5 w-5 items-center justify-center overflow-hidden rounded-full">
{item.icon}
</div>
)}
<span className="flex-grow truncate text-left">{item.label}</span>
</Ariakit.SelectItem>
);
};
return ( return (
<div className="flex w-full items-center justify-center px-1"> <div className="flex w-full items-center justify-center px-1">
<Ariakit.ComboboxProvider <Ariakit.SelectLabel store={select} className="sr-only">
resetValueOnHide {ariaLabel}
setValue={(value) => { </Ariakit.SelectLabel>
startTransition(() => { <Ariakit.Select
setSearchValue(value); ref={buttonRef}
}); store={select}
}} className={cn(
'flex items-center justify-center gap-2 rounded-full bg-surface-secondary',
'text-text-primary hover:bg-surface-tertiary',
'border border-border-light',
isCollapsed ? 'h-10 w-10' : 'h-10 w-full rounded-md px-3 py-2 text-sm',
)}
> >
<Ariakit.SelectProvider value={selectedValue} setValue={setValue}> {SelectIcon != null && (
<Ariakit.SelectLabel className="sr-only">{ariaLabel}</Ariakit.SelectLabel> <div className="assistant-item flex h-5 w-5 items-center justify-center overflow-hidden rounded-full">
<Ariakit.Select {SelectIcon}
ref={buttonRef} </div>
className={cn( )}
'flex items-center justify-center gap-2 rounded-full bg-surface-secondary', {!isCollapsed && (
'text-text-primary hover:bg-surface-tertiary', <span className="flex-grow truncate text-left">{displayValue ?? selectPlaceholder}</span>
'border border-border-light', )}
isCollapsed ? 'h-10 w-10' : 'h-10 w-full rounded-md px-3 py-2 text-sm', </Ariakit.Select>
)} <Ariakit.SelectPopover
> store={select}
{SelectIcon != null && ( gutter={4}
<div className="assistant-item flex h-5 w-5 items-center justify-center overflow-hidden rounded-full"> portal
{SelectIcon} className="z-50 overflow-hidden rounded-md border border-border-light bg-surface-secondary shadow-lg"
</div> style={{ width: isCollapsed ? '300px' : (buttonWidth ?? '300px') }}
)} >
{!isCollapsed && ( <div className="p-2">
<span className="flex-grow truncate text-left"> <div className="relative">
{displayValue ?? selectPlaceholder} <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-primary" />
</span> <Ariakit.Combobox
)} store={combobox}
</Ariakit.Select> autoSelect
<Ariakit.SelectPopover placeholder={searchPlaceholder}
gutter={4} className="w-full rounded-md border border-border-light bg-surface-tertiary py-2 pl-9 pr-3 text-sm text-text-primary focus:outline-none"
portal />
className="z-50 overflow-hidden rounded-md border border-border-light bg-surface-secondary shadow-lg" </div>
style={{ width: isCollapsed ? '300px' : buttonWidth ?? '300px' }} </div>
> <div className="max-h-[300px] overflow-auto">
<div className="p-2"> <Ariakit.ComboboxList store={combobox}>
<div className="relative"> <SelectRenderer store={select} items={matches} itemSize={ROW_HEIGHT} overscan={5}>
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-primary" /> {({ value, icon, label, ...item }) => (
<Ariakit.Combobox <Ariakit.ComboboxItem
autoSelect key={item.id}
placeholder={searchPlaceholder} {...item}
className="w-full rounded-md border border-border-light bg-surface-tertiary py-2 pl-9 pr-3 text-sm text-text-primary focus:outline-none" className={cn(
/> 'flex w-full cursor-pointer items-center px-3 text-sm',
</div> 'text-text-primary hover:bg-surface-tertiary',
</div> 'data-[active-item]:bg-surface-tertiary',
<div className="max-h-[50vh]"> )}
<AutoSizer disableHeight> render={<Ariakit.SelectItem value={value} />}
{({ width }) => ( >
<List {icon != null && (
width={width} <div className="assistant-item mr-2 flex h-5 w-5 items-center justify-center overflow-hidden rounded-full">
height={Math.min(matches.length * ROW_HEIGHT, 300)} {icon}
rowCount={matches.length} </div>
rowHeight={ROW_HEIGHT} )}
rowRenderer={rowRenderer} <span className="flex-grow truncate text-left">{label}</span>
overscanRowCount={5} </Ariakit.ComboboxItem>
/> )}
)} </SelectRenderer>
</AutoSizer> </Ariakit.ComboboxList>
</div> </div>
</Ariakit.SelectPopover> </Ariakit.SelectPopover>
</Ariakit.SelectProvider>
</Ariakit.ComboboxProvider>
</div> </div>
); );
} }

52
package-lock.json generated
View file

@ -1598,7 +1598,8 @@
"version": "v0.7.7-rc1", "version": "v0.7.7-rc1",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@ariakit/react": "^0.4.11", "@ariakit/react": "^0.4.15",
"@ariakit/react-core": "^0.4.15",
"@codesandbox/sandpack-react": "^2.19.10", "@codesandbox/sandpack-react": "^2.19.10",
"@dicebear/collection": "^7.0.4", "@dicebear/collection": "^7.0.4",
"@dicebear/core": "^7.0.4", "@dicebear/core": "^7.0.4",
@ -1711,6 +1712,22 @@
"vite-plugin-pwa": "^0.21.1" "vite-plugin-pwa": "^0.21.1"
} }
}, },
"client/node_modules/@ariakit/react": {
"version": "0.4.15",
"resolved": "https://registry.npmjs.org/@ariakit/react/-/react-0.4.15.tgz",
"integrity": "sha512-0V2LkNPFrGRT+SEIiObx/LQjR6v3rR+mKEDUu/3tq7jfCZ+7+6Q6EMR1rFaK+XMkaRY1RWUcj/rRDWAUWnsDww==",
"dependencies": {
"@ariakit/react-core": "0.4.15"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/ariakit"
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"client/node_modules/@babel/code-frame": { "client/node_modules/@babel/code-frame": {
"version": "7.26.2", "version": "7.26.2",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
@ -2818,35 +2835,16 @@
} }
}, },
"node_modules/@ariakit/core": { "node_modules/@ariakit/core": {
"version": "0.4.10", "version": "0.4.14",
"resolved": "https://registry.npmjs.org/@ariakit/core/-/core-0.4.10.tgz", "resolved": "https://registry.npmjs.org/@ariakit/core/-/core-0.4.14.tgz",
"integrity": "sha512-mX3EabQbfVh5uTjsTJ3+gjj7KGdTNhIN0qZHJd5Z2iPUnKl9NBy23Lgu6PEskpVsKAZ3proirjguD7U9fKMs/A==", "integrity": "sha512-hpzZvyYzGhP09S9jW1XGsU/FD5K3BKsH1eG/QJ8rfgEeUdPS7BvHPt5lHbOeJ2cMrRzBEvsEzLi1ivfDifHsVA=="
"license": "MIT"
},
"node_modules/@ariakit/react": {
"version": "0.4.11",
"resolved": "https://registry.npmjs.org/@ariakit/react/-/react-0.4.11.tgz",
"integrity": "sha512-nLpPrmNcspqNhk4o+epsgeZfP1+Fkh4uIzNe5yrFkXolRkqHGKAxl4Hi82e0yxIBUbYbZIEwsZQQVceF1L6xrw==",
"license": "MIT",
"dependencies": {
"@ariakit/react-core": "0.4.11"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/ariakit"
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
}, },
"node_modules/@ariakit/react-core": { "node_modules/@ariakit/react-core": {
"version": "0.4.11", "version": "0.4.15",
"resolved": "https://registry.npmjs.org/@ariakit/react-core/-/react-core-0.4.11.tgz", "resolved": "https://registry.npmjs.org/@ariakit/react-core/-/react-core-0.4.15.tgz",
"integrity": "sha512-i6KedWhjZkNC7tMEKO0eNjjq2HRPiHyGaBS2x2VaWwzBepoYtjyvxRXyqLJ3gaiNdlwckN1TZsRDfD+viy13IQ==", "integrity": "sha512-Up8+U97nAPJdyUh9E8BCEhJYTA+eVztWpHoo1R9zZfHd4cnBWAg5RHxEmMH+MamlvuRxBQA71hFKY/735fDg+A==",
"license": "MIT",
"dependencies": { "dependencies": {
"@ariakit/core": "0.4.10", "@ariakit/core": "0.4.14",
"@floating-ui/dom": "^1.0.0", "@floating-ui/dom": "^1.0.0",
"use-sync-external-store": "^1.2.0" "use-sync-external-store": "^1.2.0"
}, },