🩹 fix: Stop Archived and Shared Chats Dialogs Crashing on Open (#14886)

* fix: stop the virtualized data table looping on render

Opening Archived chats or Shared chats with 50 or more rows threw "Too many
re-renders". DataTable passed an inline getItemKey to useVirtualizer, and
virtual-core lists that option among the deps of its getMeasurementOptions memo,
whose onChange notifies. getVirtualItems() is read during render, so every
render built a new closure, notified, and dispatched a render-phase update on
the component that was still rendering, until React gave up at 25 passes. It
only fired past the 50-row virtualization threshold, which is why both dialogs
looked fine while empty.

Memoize getItemKey and estimateSize so their identity tracks their inputs.

DataTable.spec had mocked @tanstack/react-virtual away, attributing the same
error to jsdom, which hid this from CI. Keep that mock, since its row
assertions need every row rendered, and add a spec that drives the real
virtualizer and fails without the fix.

Also restyle both dialogs, which is what made them look unfinished:

- add the 19 keys these components pull from @librechat/client but the app
  locale never defined, so the empty state rendered com_ui_no_data verbatim
- rename Shared links to Shared chats, matching the sibling Archived chats
- transparent table with a rounded hover highlight painted on the cells, since
  border-radius does not apply to a table row, which needs separated borders
- row height 56 to 40, dividers dropped, skeletons follow the same height
- row hover uses surface-secondary-alt: plain surface-secondary is 247 against a
  255 dialog in light mode and reads as nothing
- row action buttons use surface-hover-alt, because surface-hover is also 227 in
  light and would vanish against the row highlight
- drop the focus ring from the dialog containers and stop Shared chats seating
  focus in its search field, so neither flashes an outline on open
- narrow both dialogs and let the table height follow its content

* Fix compact row actions and selection count

* fix: update selected count translation test to match interpolated output
This commit is contained in:
Marco Beretta 2026-08-16 06:02:44 +02:00 committed by GitHub
parent 0a2f59ab86
commit edc6cf5936
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 312 additions and 114 deletions

View file

@ -408,7 +408,7 @@
},
"packages/client": {
"name": "@librechat/client",
"version": "0.4.75",
"version": "0.4.76",
"devDependencies": {
"@babel/core": "^7.28.5",
"@babel/preset-env": "^7.29.5",

View file

@ -1,4 +1,4 @@
import { useCallback, useState, useMemo } from 'react';
import { useCallback, useState, useMemo, useRef } from 'react';
import { Trans } from 'react-i18next';
import { useRecoilValue } from 'recoil';
import { Link } from 'react-router-dom';
@ -42,6 +42,7 @@ export default function SharedLinks() {
const localize = useLocalize();
const { showToast } = useToastContext();
const [isOpen, setIsOpen] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const searchStore = useRecoilValue(store.search);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const isSmallScreen = useMediaQuery('(max-width: 768px)');
@ -64,6 +65,17 @@ export default function SharedLinks() {
}));
}, []);
const getRowId = useCallback((row: SharedLinkRow) => row.shareId, []);
/** Radix would otherwise seat focus on the search field, flashing its ring every
* time the dialog opens. Anchor focus to the content instead: it is a landing
* spot rather than a tab stop, so it shows no ring and the first Tab reaches a
* real control that does. */
const handleOpenAutoFocus = useCallback((event: Event) => {
event.preventDefault();
contentRef.current?.focus();
}, []);
const allLinks = useMemo<SharedLinkRow[]>(() => {
if (!data?.pages) {
return [];
@ -184,11 +196,11 @@ export default function SharedLinks() {
to={`/share/${shareId}`}
target="_blank"
rel="noopener noreferrer"
className="group flex items-center gap-1 truncate rounded-sm text-link underline decoration-1 underline-offset-2 hover:decoration-2 focus:outline-none focus:ring-2 focus:ring-text-primary"
className="group flex items-center gap-1.5 truncate rounded-sm font-medium text-text-primary underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-primary"
>
<span className="truncate">{title}</span>
<ExternalLink
className="size-3 flex-shrink-0 opacity-70 group-hover:opacity-100"
className="size-3.5 flex-shrink-0 text-text-tertiary transition-colors group-hover:text-text-secondary"
aria-hidden="true"
/>
</Link>
@ -225,7 +237,7 @@ export default function SharedLinks() {
<TooltipAnchor
description={localize('com_ui_open_source_chat_new_tab')}
render={
<Button asChild variant="ghost" className="h-8 w-8 p-0 hover:bg-surface-hover">
<Button asChild variant="row-action" size="icon-sm">
<a
href={`/c/${row.original.conversationId}`}
target="_blank"
@ -243,8 +255,8 @@ export default function SharedLinks() {
description={localize('com_ui_delete_shared_link_heading')}
render={
<Button
variant="ghost"
className="h-8 w-8 p-0 hover:bg-surface-hover"
variant="row-action"
size="icon-sm"
onClick={() => {
setDeleteRow(row.original);
setIsDeleteOpen(true);
@ -278,8 +290,10 @@ export default function SharedLinks() {
</OGDialogTrigger>
<OGDialogContent
title={localize('com_nav_shared_links')}
className="w-11/12 max-w-5xl bg-surface-dialog text-text-primary shadow-2xl"
ref={contentRef}
tabIndex={-1}
onOpenAutoFocus={handleOpenAutoFocus}
className="w-11/12 max-w-3xl shadow-2xl focus:outline-none"
>
<OGDialogHeader>
<OGDialogTitle>{localize('com_nav_shared_links')}</OGDialogTitle>
@ -287,8 +301,8 @@ export default function SharedLinks() {
<VirtualizedDataTable
columns={columns}
data={allLinks}
getRowId={(row) => row.shareId}
className="scrollbar-gutter-stable h-[60vh]"
getRowId={getRowId}
className="scrollbar-gutter-stable max-h-[60vh] min-h-80"
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
isFetching={isFetching}
@ -300,6 +314,7 @@ export default function SharedLinks() {
isLoading={isLoading}
config={{
selection: { enableRowSelection: false, showCheckboxes: false },
skeleton: { count: 6 },
search: { enableSearch: searchStore.enabled === true, debounce: 300 },
}}
/>

View file

@ -18,7 +18,9 @@ export function ArchivedChatsModal({
/** The virtualized table has no stable focusable on mount, so Radix's default
* autofocus lands on a row that the virtualizer tears out, dropping focus to
* the page's top focus guard; anchor focus to the dialog content instead. */
* the page's top focus guard; anchor focus to the dialog content instead.
* The container is only a landing spot for focus, never a tab stop, so it
* draws no focus ring of its own; the first Tab reveals one on a real control. */
const handleOpenAutoFocus = (event: Event) => {
event.preventDefault();
contentRef.current?.focus();
@ -30,8 +32,7 @@ export function ArchivedChatsModal({
ref={contentRef}
tabIndex={-1}
onOpenAutoFocus={handleOpenAutoFocus}
title={localize('com_nav_archived_chats')}
className="w-11/12 max-w-[1000px] bg-surface-dialog text-text-primary shadow-2xl focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-text-primary"
className="w-11/12 max-w-3xl shadow-2xl focus:outline-none"
>
<OGDialogHeader>
<OGDialogTitle>{localize('com_nav_archived_chats')}</OGDialogTitle>

View file

@ -62,6 +62,11 @@ export default function ArchivedChatsTable() {
}));
}, []);
const getRowId = useCallback(
(row: ArchivedConversationRow, index: number) => row.conversationId ?? `archived-${index}`,
[],
);
const allConversations = useMemo<ArchivedConversationRow[]>(() => {
if (!data?.pages) {
return [];
@ -151,30 +156,32 @@ export default function ArchivedChatsTable() {
header: localize('com_nav_archive_name'),
cell: ({ row }) => {
const { conversationId, title } = row.original;
const link = (
<Link
to={`/c/${conversationId ?? ''}`}
target="_blank"
rel="noopener noreferrer"
className="group flex items-center gap-1.5 truncate rounded-sm font-medium text-text-primary underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-primary"
aria-label={localize('com_ui_open_archived_chat_new_tab_title', {
title: title ?? localize('com_ui_untitled'),
})}
>
<span className="truncate">{title}</span>
<ExternalLink
className="size-3.5 flex-shrink-0 text-text-tertiary transition-colors group-hover:text-text-secondary"
aria-hidden="true"
/>
</Link>
);
return (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2.5">
<MinimalIcon
endpoint={row.original.endpoint}
size={28}
isCreatedByUser={false}
iconClassName="size-4"
/>
<Link
to={`/c/${conversationId ?? ''}`}
target="_blank"
rel="noopener noreferrer"
className="group flex items-center gap-1 truncate rounded-sm text-link underline decoration-1 underline-offset-2 hover:decoration-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-primary"
title={title ?? undefined}
aria-label={localize('com_ui_open_archived_chat_new_tab_title', {
title: title ?? localize('com_ui_untitled'),
})}
>
<span className="truncate">{title}</span>
<ExternalLink
className="size-3 flex-shrink-0 opacity-70 group-hover:opacity-100"
aria-hidden="true"
/>
</Link>
{title ? <TooltipAnchor description={title} render={link} /> : link}
</div>
);
},
@ -204,8 +211,8 @@ export default function ArchivedChatsTable() {
description={localize('com_ui_unarchive_conversation')}
render={
<Button
variant="ghost"
className="h-8 w-8 p-0 hover:bg-surface-hover"
variant="row-action"
size="icon-sm"
onClick={() =>
unarchiveConversation({
conversationId: conversation.conversationId ?? '',
@ -223,8 +230,8 @@ export default function ArchivedChatsTable() {
description={localize('com_ui_delete_conversation_tooltip')}
render={
<Button
variant="ghost"
className="h-8 w-8 p-0 hover:bg-surface-hover"
variant="row-action"
size="icon-sm"
onClick={() => {
setDeleteConversation(row.original);
setIsDeleteOpen(true);
@ -248,37 +255,31 @@ export default function ArchivedChatsTable() {
return (
<>
{/* Fixed height keeps the loading (skeleton) and loaded states the same
size, so the virtualized table can't reflow the dialog on load. */}
<div className="h-[60vh]">
<VirtualizedDataTable
columns={columns}
data={allConversations}
getRowId={(row, index) => row.conversationId ?? `archived-${index}`}
className="scrollbar-gutter-stable h-full max-h-none"
onFilterChange={handleFilterChange}
filterValue={queryParams.search}
fetchNextPage={handleFetchNextPage}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
isFetching={isFetching}
isLoading={isLoading}
sorting={sorting}
onSortingChange={handleSortingChange}
config={{
selection: { enableRowSelection: false, showCheckboxes: false },
search: { enableSearch: searchState.enabled === true, debounce: 300 },
}}
/>
</div>
{/* The skeleton count matches the minimum height so the loading and loaded
states are close in size, while a short list still collapses the box. */}
<VirtualizedDataTable
columns={columns}
data={allConversations}
getRowId={getRowId}
className="scrollbar-gutter-stable max-h-[60vh] min-h-80"
onFilterChange={handleFilterChange}
filterValue={queryParams.search}
fetchNextPage={handleFetchNextPage}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
isFetching={isFetching}
isLoading={isLoading}
sorting={sorting}
onSortingChange={handleSortingChange}
config={{
selection: { enableRowSelection: false, showCheckboxes: false },
skeleton: { count: 6 },
search: { enableSearch: searchState.enabled === true, debounce: 300 },
}}
/>
<OGDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
<OGDialogContent
title={localize('com_ui_delete_confirm', {
title: deleteConversation?.title ?? localize('com_ui_untitled'),
})}
className="w-11/12 max-w-md"
>
<OGDialogContent showCloseButton={false} className="w-11/12 max-w-md">
<OGDialogHeader>
<OGDialogTitle>
<Trans

View file

@ -621,7 +621,7 @@
"com_nav_setting_mcp": "MCP Settings",
"com_nav_setting_speech": "Speech",
"com_nav_settings": "Settings",
"com_nav_shared_links": "Shared links",
"com_nav_shared_links": "Shared chats",
"com_nav_show_thinking": "Open Thinking Dropdowns by Default",
"com_nav_slash_command": "/-Command",
"com_nav_slash_command_description": "Toggle command \"/\" for selecting a prompt via keyboard",
@ -966,6 +966,7 @@
"com_ui_cache_write": "Cache write",
"com_ui_callback_url": "Callback URL",
"com_ui_cancel": "Cancel",
"com_ui_cancel_dialog": "Cancel dialog",
"com_ui_cancelled": "Cancelled",
"com_ui_capabilities_count": "{{count}} capabilities",
"com_ui_capabilities_count_one": "{{count}} capability",
@ -1009,6 +1010,7 @@
"com_ui_confirm": "Confirm",
"com_ui_confirm_action": "Confirm Action",
"com_ui_confirm_admin_use_change": "Changing this setting will block access for admins, including yourself. Are you sure you want to proceed?",
"com_ui_confirm_bulk_delete": "Are you sure you want to delete the selected items? This action cannot be undone.",
"com_ui_confirm_change": "Confirm Change",
"com_ui_connecting": "Connecting",
"com_ui_contact_admin_if_issue_persists": "Contact the Admin if the issue persists",
@ -1087,6 +1089,8 @@
"com_ui_custom_header_name": "Custom Header Name",
"com_ui_custom_prompt_mode": "Custom Prompt Mode",
"com_ui_dark_theme_enabled": "Dark theme enabled",
"com_ui_data_table": "Data table",
"com_ui_data_table_scroll_area": "Scrollable data table area",
"com_ui_date": "Date",
"com_ui_date_april": "April",
"com_ui_date_august": "August",
@ -1130,6 +1134,8 @@
"com_ui_delete_project": "Delete project?",
"com_ui_delete_project_confirm": "Delete \"{{name}}\"? The chats inside won't be deleted.",
"com_ui_delete_prompt": "Delete Prompt?",
"com_ui_delete_selected": "Delete selected",
"com_ui_delete_selected_items": "Delete selected items",
"com_ui_delete_shared_link": "Delete shared link?",
"com_ui_delete_shared_link_heading": "Delete Shared Link",
"com_ui_delete_success": "Successfully deleted",
@ -1182,6 +1188,7 @@
"com_ui_editing_file": "Editing {{0}}",
"com_ui_editor_instructions": "Drag the image to reposition • Use zoom slider or buttons to adjust size",
"com_ui_empty_category": "-",
"com_ui_enabled": "Enabled",
"com_ui_endpoint": "Endpoint",
"com_ui_endpoint_menu": "LLM Endpoint Menu",
"com_ui_enter": "Enter",
@ -1194,6 +1201,7 @@
"com_ui_enter_value": "Enter value",
"com_ui_error": "Error",
"com_ui_error_connection": "Error connecting to server, try refreshing the page.",
"com_ui_error_details": "Error details",
"com_ui_error_message_prefix": "Error Message:",
"com_ui_error_save_admin_settings": "There was an error saving your admin settings.",
"com_ui_error_try_following_prefix": "Please try one of the following",
@ -1252,6 +1260,7 @@
"com_ui_file_token_limit_desc": "Set maximum token limit for file processing to control costs and resource usage",
"com_ui_files": "Files",
"com_ui_files_count_size": "{{0}} Files ({{1}}KB)",
"com_ui_filter_by": "Filter by {{title}}",
"com_ui_filter_mcp_servers": "Filter MCP servers by name",
"com_ui_filter_prompts": "Filter Prompts",
"com_ui_filter_prompts_name": "Filter prompts by name",
@ -1427,6 +1436,7 @@
"com_ui_live": "live",
"com_ui_load_more": "Load more",
"com_ui_loading": "Loading...",
"com_ui_loading_more_data": "Loading more results",
"com_ui_locked": "Locked",
"com_ui_logo": "{{0}} Logo",
"com_ui_low": "Low",
@ -1577,6 +1587,7 @@
"com_ui_no_categories": "No categories available",
"com_ui_no_category": "No category",
"com_ui_no_changes": "No changes were made",
"com_ui_no_data": "Nothing here yet",
"com_ui_no_individual_resource_access": "No individual users or groups have access",
"com_ui_no_labels": "No Labels",
"com_ui_no_mcp_servers": "No MCP servers yet",
@ -1584,11 +1595,14 @@
"com_ui_no_memories": "No memories. Create them manually or prompt the AI to remember something",
"com_ui_no_memories_match": "No memories match your search",
"com_ui_no_memories_title": "No memories yet",
"com_ui_no_options": "No options available",
"com_ui_no_project_chats": "No chats yet",
"com_ui_no_projects": "No projects yet",
"com_ui_no_prompts_title": "No prompts yet",
"com_ui_no_read_access": "You don't have permission to view memories",
"com_ui_no_results_found": "No results found",
"com_ui_no_search_results": "No results match your search",
"com_ui_no_selection": "No selection",
"com_ui_no_skills_found": "No skills found",
"com_ui_no_terms_content": "No terms and conditions content to display",
"com_ui_no_valid_items": "No valid items were selected",
@ -1816,6 +1830,8 @@
"com_ui_search_result_count": "{{count}} result found",
"com_ui_search_results_count": "{{count}} results found",
"com_ui_search_skills": "Search skills...",
"com_ui_search_table": "Search table",
"com_ui_search_table_description": "Type to filter results",
"com_ui_searching_files": "Searching your files",
"com_ui_seconds": "seconds",
"com_ui_secret_key": "Secret Key",
@ -1834,6 +1850,7 @@
"com_ui_select_search_provider": "Search provider by name",
"com_ui_select_search_region": "Search region by name",
"com_ui_select_var": "Select {{0}}",
"com_ui_selected_count": "{{count}} selected",
"com_ui_send_now": "Send now",
"com_ui_set": "Set",
"com_ui_settings_label_2fa": "Two-factor authentication",
@ -1854,7 +1871,7 @@
"com_ui_settings_label_playback_rate": "Playback rate",
"com_ui_settings_label_provider_api_keys": "Provider API keys",
"com_ui_settings_label_revoke_keys": "Revoke keys",
"com_ui_settings_label_shared_links": "Shared links",
"com_ui_settings_label_shared_links": "Shared chats",
"com_ui_settings_label_voice": "Voice",
"com_ui_settings_no_results": "No settings match your search",
"com_ui_settings_results_aria": "Search results",
@ -2064,6 +2081,8 @@
"com_ui_support_contact_name": "Name",
"com_ui_support_contact_name_min_length": "Name must be at least {{minLength}} characters",
"com_ui_support_contact_name_placeholder": "Support contact name",
"com_ui_table_error": "Table error",
"com_ui_table_error_description": "The table failed to load. Refresh the page or try again.",
"com_ui_teach_or_explain": "Learning",
"com_ui_temporary": "Temporary Chat",
"com_ui_terms_and_conditions": "Terms and Conditions",

2
package-lock.json generated
View file

@ -43494,7 +43494,7 @@
},
"packages/client": {
"name": "@librechat/client",
"version": "0.4.75",
"version": "0.4.76",
"devDependencies": {
"@babel/core": "^7.28.5",
"@babel/preset-env": "^7.29.5",

View file

@ -1,6 +1,6 @@
{
"name": "@librechat/client",
"version": "0.4.75",
"version": "0.4.76",
"description": "React components for LibreChat",
"repository": {
"type": "git",

View file

@ -42,4 +42,19 @@ describe('Button', () => {
expect(themedSubtle).toContain('rounded-theme-control');
expect(themedSubtle).not.toContain('rounded-xl');
});
it('provides compact row actions with a distinct hover surface', () => {
render(
<Button variant="row-action" size="icon-sm">
Open
</Button>,
);
expect(screen.getByRole('button', { name: 'Open' })).toHaveClass(
'size-8',
'p-0',
'rounded-lg',
'hover:bg-surface-hover-alt',
);
});
});

View file

@ -15,9 +15,10 @@ type ButtonVariantOptions =
| 'destructive'
| 'secondary'
| 'ghost'
| 'row-action'
| null
| undefined;
size?: 'default' | 'icon' | 'sm' | 'lg' | 'theme' | null | undefined;
size?: 'default' | 'icon' | 'icon-sm' | 'sm' | 'lg' | 'theme' | null | undefined;
shape?: 'default' | 'theme' | null | undefined;
} & ClassProp)
| undefined;
@ -36,6 +37,7 @@ const buttonVariantRecipe = cva(
'border border-border-light bg-transparent text-text-primary hover:bg-surface-secondary focus-visible:ring-text-primary focus-visible:ring-offset-0',
secondary: 'bg-surface-secondary text-text-primary hover:bg-surface-hover',
ghost: 'hover:bg-surface-hover hover:text-text-primary',
'row-action': 'hover:bg-surface-hover-alt hover:text-text-primary',
link: 'text-text-primary underline-offset-4 hover:underline',
submit: 'bg-surface-submit text-text-on-status hover:bg-surface-submit-hover',
},
@ -44,6 +46,7 @@ const buttonVariantRecipe = cva(
sm: 'h-9 rounded-lg px-3',
lg: 'h-11 rounded-lg px-8',
icon: 'size-10',
'icon-sm': 'size-8 p-0',
theme: 'h-theme-control gap-theme-compact px-theme-normal',
},
shape: {

View file

@ -30,8 +30,9 @@ jest.mock('~/hooks', () => ({
useMediaQuery: jest.fn(() => false),
}));
// jsdom can't measure layout, so @tanstack/react-virtual's measurement-driven re-render loop
// never converges (infinite "Too many re-renders"). Stub it to render every row deterministically.
// jsdom reports a zero-height scroll container, so the real virtualizer resolves an empty
// range and no row assertion below would find its row. Stub it to render every row
// deterministically; DataTable.virtualization.spec.tsx covers the real virtualizer.
jest.mock('@tanstack/react-virtual', () => ({
useVirtualizer: ({
count,
@ -77,6 +78,12 @@ jest.mock('lucide-react', () => ({
ArrowDownUp: ({ className }: { className?: string }) => (
<span data-testid="arrow-down-up" className={className} />
),
Inbox: ({ className }: { className?: string }) => (
<span data-testid="inbox-icon" className={className} />
),
SearchX: ({ className }: { className?: string }) => (
<span data-testid="search-x-icon" className={className} />
),
}));
// Mock Table components

View file

@ -1,7 +1,7 @@
import React, { useRef, useState, useEffect, useMemo, useCallback } from 'react';
import { JSX } from 'react/jsx-runtime';
import { useVirtualizer } from '@tanstack/react-virtual';
import { ArrowUp, ArrowDown, ArrowDownUp } from 'lucide-react';
import { ArrowUp, ArrowDown, ArrowDownUp, Inbox, SearchX } from 'lucide-react';
import {
useReactTable,
getCoreRowModel,
@ -62,7 +62,7 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
virtualization: {
overscan = 10,
minRows = 50,
rowHeight = 56,
rowHeight = 40,
fastOverscanMultiplier = 4,
} = {},
} = config || {};
@ -290,12 +290,22 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
onRowSelectionChange: setOptimizedRowSelection,
});
/* The virtualizer rebuilds its measurement options whenever one of them changes
identity, and that notify re-renders this component. Both options below are
read during render, so defining them inline would notify on every render and
loop until React aborts with "Too many re-renders". */
const getItemKey = useCallback(
(index: number) => getRowId(data[index] as TData, index),
[data, getRowId],
);
const estimateSize = useCallback(() => rowHeight, [rowHeight]);
const rowVirtualizer = useVirtualizer({
enabled: virtualizationActive,
count: data.length,
getScrollElement: () => tableContainerRef.current,
getItemKey: (index) => getRowId(data[index] as TData, index),
estimateSize: useCallback(() => rowHeight, [rowHeight]),
getItemKey,
estimateSize,
overscan: dynamicOverscan,
});
@ -312,6 +322,7 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
const showSkeletons = isLoading || (isFetching && !isFetchingNextPage);
const shouldShowSearch = enableSearch && onFilterChange;
const showToolbar = Boolean(shouldShowSearch || customActionsRenderer);
// Render table body based on loading state and virtualization
let tableBodyContent: React.ReactNode;
@ -319,6 +330,7 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
tableBodyContent = (
<SkeletonRows
count={skeletonCount}
rowHeight={rowHeight}
columns={tableColumns as ColumnDef<Record<string, unknown>>[]}
/>
);
@ -548,26 +560,27 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
return (
<div
className={cn(
'relative flex w-full flex-col overflow-hidden rounded-lg border border-border-light bg-surface-primary',
'h-[calc(100vh-8rem)] max-h-[80vh]',
className,
)}
/* Transparent so the rows read as a list on whatever surface hosts them, and
the height follows the rows up to the cap so a short list doesn't leave a
tall empty box below it. */
className={cn('relative flex w-full flex-col overflow-hidden', 'max-h-[80vh]', className)}
role="region"
aria-label={localize('com_ui_data_table')}
>
<div className="flex w-full shrink-0 items-center gap-2 border-b border-border-light md:gap-3">
{shouldShowSearch && <DataTableSearch value={searchTerm} onChange={setSearchTerm} />}
{customActionsRenderer &&
customActionsRenderer({
selectedCount,
selectedRows,
table: table as unknown as TTable<ProcessedDataRow<TData>>,
})}
</div>
{showToolbar && (
<div className="flex w-full shrink-0 items-center gap-2 border-b border-border-light pr-2 md:gap-3">
{shouldShowSearch && <DataTableSearch value={searchTerm} onChange={setSearchTerm} />}
{customActionsRenderer &&
customActionsRenderer({
selectedCount,
selectedRows,
table: table as unknown as TTable<ProcessedDataRow<TData>>,
})}
</div>
)}
<div
ref={tableContainerRef}
className="overflow-anchor-none relative min-h-0 flex-1 overflow-auto will-change-scroll"
className="overflow-anchor-none relative flex min-h-0 flex-1 flex-col overflow-auto will-change-scroll"
style={
{
WebkitOverflowScrolling: 'touch',
@ -582,12 +595,14 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
role="table"
aria-label={localize('com_ui_data_table')}
aria-rowcount={data.length}
className="table-auto"
/* Separated borders let the row cells carry a rounded hover highlight;
collapsed borders drop `border-radius` on table cells entirely. */
className="shrink-0 table-auto border-separate border-spacing-0"
unwrapped={true}
>
<TableHeader className="sticky top-0 z-10 bg-surface-secondary">
<TableHeader>
{headerGroups.map((headerGroup) => (
<TableRow key={headerGroup.id}>
<TableRow key={headerGroup.id} className="border-0 hover:bg-transparent">
{headerGroup.headers.map((header) => {
const isDesktopOnly =
(header.column.columnDef.meta as { desktopOnly?: boolean } | undefined)
@ -635,22 +650,28 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
<Button
type="button"
variant="ghost"
className="h-auto w-full justify-start gap-1 px-0 py-0 font-medium hover:bg-transparent md:gap-2"
className="group h-auto w-full justify-start gap-1 px-0 py-0 text-xs font-medium uppercase tracking-wide text-text-secondary hover:bg-transparent hover:text-text-primary md:gap-1.5"
onClick={header.column.getToggleSortingHandler()}
>
{renderedHeader}
<span className="text-text-primary" aria-hidden="true">
<span aria-hidden="true">
{{
asc: <ArrowUp className="size-4 text-text-primary" />,
desc: <ArrowDown className="size-4 text-text-primary" />,
asc: <ArrowUp className="size-3.5" />,
desc: <ArrowDown className="size-3.5" />,
}[header.column.getIsSorted() as string] ?? (
<ArrowDownUp className="size-4 text-text-primary" />
/* The neutral marker is noise on every unsorted column, so it
only surfaces once the header is a pointer or keyboard target. */
<ArrowDownUp className="size-3.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100" />
)}
</span>
</Button>
);
} else {
headerContent = <div className="flex items-center">{renderedHeader}</div>;
headerContent = (
<div className="flex items-center text-xs font-medium uppercase tracking-wide text-text-secondary">
{renderedHeader}
</div>
);
}
return (
@ -658,9 +679,12 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
key={header.id}
scope="col"
className={cn(
'border-b border-border-light px-2 py-2 md:px-3 md:py-2',
/* Stuck per cell rather than on <thead>, which does not stay
put once the table uses separated borders. The fill has to
be opaque or virtualized rows show through it. */
'sticky top-0 z-10 h-9 border-b border-border-light bg-surface-dialog px-3 py-2 md:px-4',
isSelectHeader && 'px-0 text-center',
canSort && 'cursor-pointer hover:bg-surface-tertiary',
canSort && 'cursor-pointer',
meta?.className,
header.column.getIsResizing() && 'bg-surface-tertiary/60',
isDesktopOnly && 'hidden md:table-cell',
@ -699,11 +723,18 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
{!isLoading && !showSkeletons && rows.length === 0 && (
<div
className="flex flex-col items-center justify-center py-12"
className="flex flex-1 flex-col items-center justify-center gap-3 px-6 py-12"
role="status"
aria-live="polite"
>
<Label className="text-center text-text-secondary">
<span className="flex size-11 items-center justify-center rounded-full bg-surface-tertiary text-text-tertiary">
{searchTerm ? (
<SearchX className="size-5" aria-hidden="true" />
) : (
<Inbox className="size-5" aria-hidden="true" />
)}
</span>
<Label className="text-center text-sm text-text-secondary">
{searchTerm ? localize('com_ui_no_search_results') : localize('com_ui_no_data')}
</Label>
</div>

View file

@ -0,0 +1,83 @@
import React from 'react';
import { Provider as JotaiProvider } from 'jotai';
import { render, screen } from '@testing-library/react';
import type { TableColumn } from './DataTable.types';
import DataTable from './DataTable';
jest.mock('~/utils', () => ({
cn: (...classes: (string | undefined | boolean)[]) => classes.filter(Boolean).join(' '),
logger: {
log: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
}));
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
useMediaQuery: jest.fn(() => false),
}));
interface TestRow extends Record<string, unknown> {
id: string;
name: string;
}
const columns: TableColumn<TestRow, unknown>[] = [
{
accessorKey: 'name',
header: 'Name',
cell: ({ row }) => <span>{row.original.name}</span>,
meta: { width: 100, isRowHeader: true },
},
];
/** `minRows` defaults to 50, so this is the smallest set that turns virtualization on. */
const makeRows = (count: number): TestRow[] =>
Array.from({ length: count }, (_, index) => ({ id: `row-${index}`, name: `Row ${index}` }));
const renderTable = (data: TestRow[]) =>
render(
<JotaiProvider>
<DataTable
columns={columns}
data={data}
getRowId={(row) => row.id}
config={{ selection: { enableRowSelection: false, showCheckboxes: false } }}
/>
</JotaiProvider>,
);
/**
* Exercises the real @tanstack/react-virtual rather than a stub: the virtualizer
* notifies (and therefore re-renders) whenever its measurement options change
* identity, so an option rebuilt on every render loops until React gives up.
*/
describe('DataTable virtualization', () => {
it('renders a virtualized table without looping on re-renders', () => {
expect(() => renderTable(makeRows(60))).not.toThrow();
expect(screen.getByRole('table')).toBeInTheDocument();
});
it('survives a re-render with the same data', () => {
const data = makeRows(60);
const { rerender } = renderTable(data);
expect(() =>
rerender(
<JotaiProvider>
<DataTable
columns={columns}
data={data}
getRowId={(row) => row.id}
config={{ selection: { enableRowSelection: false, showCheckboxes: false } }}
/>
</JotaiProvider>,
),
).not.toThrow();
});
it('stays below the virtualization threshold without looping', () => {
expect(() => renderTable(makeRows(10))).not.toThrow();
});
});

View file

@ -57,7 +57,10 @@ const TableRowComponent = <TData extends Record<string, unknown>>(
ref={ref}
data-state={selected ? 'selected' : undefined}
data-index={virtualIndex}
className="border-none hover:bg-surface-secondary"
/* The highlight is painted by the cells, not the row: `border-radius` has no
effect on a table row, so rounding the outer cells is what gives the hover
its pill shape. */
className="group border-0 hover:bg-transparent [&>*:first-child]:rounded-l-lg [&>*:last-child]:rounded-r-lg"
style={style}
>
{row.getVisibleCells().map((cell) => {
@ -84,7 +87,8 @@ const TableRowComponent = <TData extends Record<string, unknown>>(
<CellComponent
key={cell.id}
className={cn(
'max-w-0 truncate px-2 py-2 md:px-3 md:py-3',
'max-w-0 truncate px-3 py-1 text-sm transition-colors',
'group-hover:bg-surface-secondary-alt group-data-[state=selected]:bg-surface-active',
cell.column.id === 'select' && 'w-8 p-1',
meta?.className,
isDesktopOnly && 'hidden md:table-cell',
@ -130,21 +134,29 @@ export const SkeletonRows: React.MemoExoticComponent<
<TData extends Record<string, unknown>, TValue>({
count,
columns,
rowHeight,
}: {
count?: number;
columns: TableColumn<TData, TValue>[];
rowHeight?: number;
}) => JSX.Element
> = memo(
<TData extends Record<string, unknown>, TValue>({
count = 10,
columns,
rowHeight = 40,
}: {
count?: number;
columns: TableColumn<TData, TValue>[];
rowHeight?: number;
}): JSX.Element => (
<>
{Array.from({ length: count }, (_, index) => (
<TableRow key={`skeleton-${index}`} className="h-[56px] border-b border-border-light">
<TableRow
key={`skeleton-${index}`}
className="border-0 hover:bg-transparent"
style={{ height: rowHeight }}
>
{columns.map((column) => {
const columnKey = String(
column.id ?? ('accessorKey' in column && column.accessorKey) ?? '',
@ -154,7 +166,7 @@ export const SkeletonRows: React.MemoExoticComponent<
<TableCell
key={columnKey}
className={cn(
'px-2 py-2 md:px-3',
'px-3 py-1',
meta?.className,
meta?.desktopOnly && 'hidden md:table-cell',
)}

View file

@ -126,8 +126,12 @@ describe('DataTableSearch', () => {
render(<DataTableSearch value="" onChange={jest.fn()} />);
const input = screen.getByTestId('search-input');
expect(input.className).toContain('h-10');
expect(input.className).toContain('bg-surface-secondary');
expect(input.className).toContain('h-11');
/* The field sits flush inside the table's toolbar, so it carries no chrome of
its own and leaves room for the leading search icon. */
expect(input.className).toContain('bg-transparent');
expect(input.className).toContain('border-0');
expect(input.className).toContain('pl-9');
});
it('should have correct id for label association', () => {

View file

@ -1,4 +1,5 @@
import { memo, startTransition, useId, type MemoExoticComponent } from 'react';
import { Search } from 'lucide-react';
import { JSX } from 'react/jsx-runtime';
import type { DataTableSearchProps } from './DataTable.types';
import { useLocalize } from '~/hooks';
@ -24,6 +25,10 @@ export const DataTableSearch: MemoExoticComponent<
<label htmlFor={searchId} className="sr-only">
{localize('com_ui_search_table')}
</label>
<Search
className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-text-tertiary"
aria-hidden="true"
/>
<Input
id={searchId}
value={value}
@ -34,7 +39,10 @@ export const DataTableSearch: MemoExoticComponent<
aria-label={localize('com_ui_search_table')}
aria-describedby={descriptionId}
placeholder={placeholder || localize('com_ui_search')}
className={cn('h-10 rounded-b-none border-0 bg-surface-secondary md:h-12', className)}
className={cn(
'h-11 rounded-none border-0 bg-transparent pl-9 text-sm placeholder:text-text-tertiary focus-visible:ring-inset',
className,
)}
/>
<span id={descriptionId} className="sr-only">
{localize('com_ui_search_table_description')}

View file

@ -42,8 +42,7 @@ describe('i18next translation tests', () => {
it('should correctly format placeholders in the translation', () => {
i18n.changeLanguage('en');
// The translation uses {count} syntax (not standard i18next {{count}})
// Verify i18next returns the template string with the placeholder
expect(i18n.t('com_ui_selected_count', { count: 5 })).toBe('{count} selected');
// The key uses standard i18next {{count}} interpolation, so t() substitutes the value
expect(i18n.t('com_ui_selected_count', { count: 5 })).toBe('5 selected');
});
});

View file

@ -10,7 +10,7 @@
"com_ui_confirm_bulk_delete": "Are you sure you want to delete the selected items? This action cannot be undone.",
"com_ui_delete_success": "Items deleted successfully",
"com_ui_retry": "Retry",
"com_ui_selected_count": "{count} selected",
"com_ui_selected_count": "{{count}} selected",
"com_ui_data_table": "Data Table",
"com_ui_no_data": "No data",
"com_ui_delete_selected": "Delete Selected",