refactor(DataTable): enhance virtualization and scrolling performance with dynamic overscan adjustments

This commit is contained in:
Marco Beretta 2025-09-27 16:31:02 +02:00
parent a16744d211
commit a43562de8a
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
6 changed files with 152 additions and 43 deletions

View file

@ -1,8 +1,6 @@
import { useCallback, useState, useMemo, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { TrashIcon, MessageSquare } from 'lucide-react';
import type { ColumnDef, SortingState } from '@tanstack/react-table';
import type { SharedLinkItem, SharedLinksListParams } from 'librechat-data-provider';
import {
OGDialog,
useToastContext,
@ -18,6 +16,8 @@ import {
Button,
Label,
} from '@librechat/client';
import type { SharedLinkItem, SharedLinksListParams } from 'librechat-data-provider';
import type { ColumnDef, SortingState } from '@tanstack/react-table';
import { useDeleteSharedLinkMutation, useSharedLinksQuery } from '~/data-provider';
import { NotificationSeverity } from '~/common';
import { formatDate, cn } from '~/utils';
@ -90,7 +90,6 @@ export default function SharedLinks() {
const coerced = next;
const primary = coerced[0];
// Seed allKnown with current data before changing params
if (data?.pages) {
const currentFlattened = data.pages.flatMap((page) => page?.links?.filter(Boolean) ?? []);
setAllKnownLinks(currentFlattened);

View file

@ -1,6 +1,7 @@
import { useState, useCallback, useMemo } from 'react';
import { QueryKeys } from 'librechat-data-provider';
import { TrashIcon, ArchiveRestore } from 'lucide-react';
import type { SortingState } from '@tanstack/react-table';
import { useQueryClient, InfiniteData } from '@tanstack/react-query';
import {
Button,
OGDialog,
@ -18,7 +19,7 @@ import {
type TableColumn,
} from '@librechat/client';
import type { ConversationListParams, TConversation } from 'librechat-data-provider';
import { QueryKeys } from 'librechat-data-provider';
import type { SortingState } from '@tanstack/react-table';
import {
useArchiveConvoMutation,
useConversationsInfiniteQuery,
@ -28,7 +29,6 @@ import { MinimalIcon } from '~/components/Endpoints';
import { NotificationSeverity } from '~/common';
import { formatDate, cn } from '~/utils';
import { useLocalize } from '~/hooks';
import { useQueryClient, InfiniteData } from '@tanstack/react-query';
const DEFAULT_PARAMS = {
isArchived: true,
@ -87,13 +87,9 @@ export default function ArchivedChatsTable() {
const [sorting, setSorting] = useState<SortingState>(defaultSort);
const [searchValue, setSearchValue] = useState('');
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isFetching, isLoading } =
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } =
useConversationsInfiniteQuery(queryParams, {
enabled: isOpen,
// Critical for "server-only sorting" UX: we disable keepPreviousData so that
// a sort (or search) change clears the table immediately and skeletons show
// while the new order is fetched from the server. This prevents any client-side
// reordering of stale rows.
keepPreviousData: false,
staleTime: 30 * 1000,
refetchOnWindowFocus: false,
@ -144,7 +140,6 @@ export default function ArchivedChatsTable() {
[showToast, localize],
);
// Flatten server-provided pages directly; no client sorting or aggregation cache
const flattenedConversations = useMemo(
() => data?.pages?.flatMap((page) => page?.conversations?.filter(Boolean) ?? []) ?? [],
[data?.pages],
@ -160,7 +155,6 @@ export default function ArchivedChatsTable() {
conversationId,
);
}
// Ensure appearance in non-archived queries is refreshed
queryClient.invalidateQueries([QueryKeys.allConversations]);
setUnarchivingId(null);
},

View file

@ -52,9 +52,36 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
selection: { enableRowSelection = true, showCheckboxes = true } = {},
search: { enableSearch = true, debounce: debounceDelay = 300 } = {},
skeleton: { count: skeletonCount = 10 } = {},
virtualization: { overscan = 10 } = {},
virtualization: {
overscan = 10,
minRows = 50,
rowHeight = 56,
fastOverscanMultiplier = 4,
} = {},
} = config || {};
const virtualizationActive = data.length >= minRows;
// Dynamic overscan adjustment for fast scroll bursts (state kept stable, minimal updates)
const [dynamicOverscan, setDynamicOverscan] = useState(overscan);
const lastScrollTopRef = useRef(0);
const lastScrollTimeRef = useRef(performance.now());
const fastScrollTimeoutRef = useRef<number | null>(null);
// Sync overscan prop changes
useEffect(() => {
setDynamicOverscan(overscan);
}, [overscan]);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (fastScrollTimeoutRef.current) {
clearTimeout(fastScrollTimeoutRef.current);
}
};
}, []);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [optimizedRowSelection, setOptimizedRowSelection] = useOptimizedRowSelection();
const [error, setError] = useState<Error | null>(null);
@ -220,14 +247,12 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
});
const rowVirtualizer = useVirtualizer({
enabled: virtualizationActive,
count: data.length,
getScrollElement: () => tableContainerRef.current,
estimateSize: useCallback(() => 60, []),
overscan,
measureElement:
typeof window !== 'undefined'
? (element) => element?.getBoundingClientRect().height ?? 60
: undefined,
getItemKey: (index) => getRowId(data[index] as TData, index),
estimateSize: useCallback(() => rowHeight, [rowHeight]),
overscan: dynamicOverscan,
});
const virtualRows = rowVirtualizer.getVirtualItems();
@ -263,6 +288,25 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
}
}, [debouncedTerm, filterValue, onFilterChange, setOptimizedRowSelection]);
// Re-measure on key state changes that can affect layout
useEffect(() => {
if (!virtualizationActive) return;
// With fixed rowHeight, just ensure the range recalculates
rowVirtualizer.calculateRange();
}, [data.length, finalSorting, columnVisibility, virtualizationActive, rowVirtualizer]);
// ResizeObserver to re-measure when container size changes
useEffect(() => {
if (!virtualizationActive) return;
const container = tableContainerRef.current;
if (!container) return;
const ro = new ResizeObserver(() => {
rowVirtualizer.calculateRange();
});
ro.observe(container);
return () => ro.disconnect();
}, [virtualizationActive, rowVirtualizer]);
const handleScroll = useMemo(() => {
let rafId: number | null = null;
let timeoutId: number | null = null;
@ -271,20 +315,53 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
const container = tableContainerRef.current;
if (container) {
const now = performance.now();
const delta = Math.abs(container.scrollTop - lastScrollTopRef.current);
const dt = now - lastScrollTimeRef.current;
if (dt > 0) {
const velocity = delta / dt; // px per ms
if (
velocity > 2 &&
virtualizationActive &&
dynamicOverscan === overscan /* only expand if not already expanded */
) {
if (fastScrollTimeoutRef.current) {
window.clearTimeout(fastScrollTimeoutRef.current);
}
setDynamicOverscan(Math.min(overscan * fastOverscanMultiplier, overscan * 8));
fastScrollTimeoutRef.current = window.setTimeout(() => {
setDynamicOverscan((current) => (current !== overscan ? overscan : current));
}, 160);
}
}
lastScrollTopRef.current = container.scrollTop;
lastScrollTimeRef.current = now;
}
if (timeoutId) clearTimeout(timeoutId);
timeoutId = window.setTimeout(() => {
const container = tableContainerRef.current;
if (!container || !fetchNextPage || !hasNextPage || isFetchingNextPage) return;
const loaderContainer = tableContainerRef.current;
if (!loaderContainer || !fetchNextPage || !hasNextPage || isFetchingNextPage) return;
const { scrollTop, scrollHeight, clientHeight } = container;
const { scrollTop, scrollHeight, clientHeight } = loaderContainer;
if (scrollTop + clientHeight >= scrollHeight - 200) {
fetchNextPage().finally();
}
}, 100);
});
};
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
}, [
fetchNextPage,
hasNextPage,
isFetchingNextPage,
overscan,
fastOverscanMultiplier,
virtualizationActive,
dynamicOverscan,
]);
useEffect(() => {
const scrollElement = tableContainerRef.current;
@ -347,7 +424,7 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
aria-label={localize('com_ui_data_table_scroll_area')}
aria-describedby={showSkeletons ? 'loading-status' : undefined}
>
<Table role="table" aria-label={localize('com_ui_data_table')}>
<Table role="table" aria-label={localize('com_ui_data_table')} aria-rowcount={data.length}>
<TableHeader className="sticky top-0 z-10 bg-surface-secondary">
{headerGroups.map((headerGroup) => (
<TableRow key={headerGroup.id}>
@ -440,7 +517,7 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
count={skeletonCount}
columns={tableColumns as ColumnDef<Record<string, unknown>>[]}
/>
) : (
) : virtualizationActive ? (
<>
{paddingTop > 0 && (
<TableRow aria-hidden="true">
@ -455,9 +532,11 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
if (!row) return null;
return (
<MemoizedTableRow
key={`${virtualRow.key}-${row.getIsSelected() ? 'selected' : 'unselected'}`}
key={virtualRow.key}
row={row as unknown as Row<TData>}
virtualIndex={virtualRow.index}
selected={row.getIsSelected()}
style={{ height: rowHeight }}
/>
);
})}
@ -470,6 +549,16 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
</TableRow>
)}
</>
) : (
rows.map((row) => (
<MemoizedTableRow
key={getRowId(row.original as TData, row.index)}
row={row as unknown as Row<TData>}
virtualIndex={row.index}
selected={row.getIsSelected()}
style={{ height: rowHeight }}
/>
))
)}
{isFetchingNextPage && (
<TableRow>

View file

@ -32,6 +32,9 @@ export interface DataTableConfig {
};
virtualization?: {
overscan?: number;
minRows?: number;
rowHeight?: number; // fixed row height to disable costly dynamic measurements
fastOverscanMultiplier?: number; // multiplier applied during fast scroll bursts
};
pinning?: {
enableColumnPinning?: boolean;

View file

@ -1,4 +1,4 @@
import { memo } from 'react';
import React, { memo, forwardRef } from 'react';
import { flexRender } from '@tanstack/react-table';
import type { TableColumn } from './DataTable.types';
import type { Row } from '@tanstack/react-table';
@ -40,17 +40,26 @@ export const SelectionCheckbox = memo(
SelectionCheckbox.displayName = 'SelectionCheckbox';
const TableRowComponent = <TData extends Record<string, unknown>>({
row,
virtualIndex,
}: {
row: Row<TData>;
virtualIndex?: number;
}) => (
const TableRowComponent = <TData extends Record<string, unknown>>(
{
row,
virtualIndex,
style,
selected,
}: {
row: Row<TData>;
virtualIndex?: number;
style?: React.CSSProperties;
selected: boolean;
},
ref: React.Ref<HTMLTableRowElement>,
) => (
<TableRow
data-state={row.getIsSelected() ? 'selected' : undefined}
ref={ref}
data-state={selected ? 'selected' : undefined}
data-index={virtualIndex}
className="border-none hover:bg-surface-secondary"
style={style}
>
{row.getVisibleCells().map((cell) => {
const meta = cell.column.columnDef.meta as
@ -74,11 +83,27 @@ const TableRowComponent = <TData extends Record<string, unknown>>({
</TableRow>
);
type ForwardTableRowComponentType = <TData extends Record<string, unknown>>(
props: {
row: Row<TData>;
virtualIndex?: number;
style?: React.CSSProperties;
} & React.RefAttributes<HTMLTableRowElement>,
) => JSX.Element;
const ForwardTableRowComponent = forwardRef(TableRowComponent) as ForwardTableRowComponentType;
interface GenericRowProps {
row: Row<Record<string, unknown>>;
virtualIndex?: number;
style?: React.CSSProperties;
selected: boolean;
}
export const MemoizedTableRow = memo(
TableRowComponent,
(prev, next) =>
prev.row.original === next.row.original &&
prev.row.getIsSelected() === next.row.getIsSelected(),
ForwardTableRowComponent as (props: GenericRowProps) => JSX.Element,
(prev: GenericRowProps, next: GenericRowProps) =>
prev.row.original === next.row.original && prev.selected === next.selected,
);
export const SkeletonRows = memo(

View file

@ -1,4 +1,4 @@
import React, { Component, ErrorInfo, ReactNode } from 'react';
import { Component, ErrorInfo, ReactNode } from 'react';
import { Button } from '../Button';
import { RefreshCw } from 'lucide-react';
@ -75,8 +75,7 @@ export class DataTableErrorBoundary extends Component<
</div>
</div>
{/* Optional: Show technical error details in development */}
{process.env.NODE_ENV === 'development' && this.state.error && (
{import.meta.env.MODE === 'development' && this.state.error && (
<details className="mt-4 max-w-md rounded-md bg-gray-100 p-3 text-xs dark:bg-gray-800">
<summary className="cursor-pointer font-medium text-gray-900 dark:text-gray-100">
Error Details (Dev)