mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-24 04:10:15 +01:00
refactor(DataTable): enhance virtualization and scrolling performance with dynamic overscan adjustments
This commit is contained in:
parent
a16744d211
commit
a43562de8a
6 changed files with 152 additions and 43 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue