From a43562de8a565cec03479aa9cf4818e6e2c8b1fd Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sat, 27 Sep 2025 16:31:02 +0200 Subject: [PATCH] refactor(DataTable): enhance virtualization and scrolling performance with dynamic overscan adjustments --- .../Nav/SettingsTabs/Data/SharedLinks.tsx | 5 +- .../SettingsTabs/General/ArchivedChats.tsx | 14 +-- .../src/components/DataTable/DataTable.tsx | 117 +++++++++++++++--- .../components/DataTable/DataTable.types.ts | 3 + .../DataTable/DataTableComponents.tsx | 51 ++++++-- .../DataTable/DataTableErrorBoundary.tsx | 5 +- 6 files changed, 152 insertions(+), 43 deletions(-) diff --git a/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx b/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx index 154dd3954d..995527a1ea 100644 --- a/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx +++ b/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx @@ -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); diff --git a/client/src/components/Nav/SettingsTabs/General/ArchivedChats.tsx b/client/src/components/Nav/SettingsTabs/General/ArchivedChats.tsx index b8a99d424c..77c89652e4 100644 --- a/client/src/components/Nav/SettingsTabs/General/ArchivedChats.tsx +++ b/client/src/components/Nav/SettingsTabs/General/ArchivedChats.tsx @@ -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(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); }, diff --git a/packages/client/src/components/DataTable/DataTable.tsx b/packages/client/src/components/DataTable/DataTable.tsx index 460606b0da..94267c9b7d 100644 --- a/packages/client/src/components/DataTable/DataTable.tsx +++ b/packages/client/src/components/DataTable/DataTable.tsx @@ -52,9 +52,36 @@ function DataTable, 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(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({}); const [optimizedRowSelection, setOptimizedRowSelection] = useOptimizedRowSelection(); const [error, setError] = useState(null); @@ -220,14 +247,12 @@ function DataTable, 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, 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, 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, TValue>({ aria-label={localize('com_ui_data_table_scroll_area')} aria-describedby={showSkeletons ? 'loading-status' : undefined} > - +
{headerGroups.map((headerGroup) => ( @@ -440,7 +517,7 @@ function DataTable, TValue>({ count={skeletonCount} columns={tableColumns as ColumnDef>[]} /> - ) : ( + ) : virtualizationActive ? ( <> {paddingTop > 0 && ( )} + ) : ( + rows.map((row) => ( + } + virtualIndex={row.index} + selected={row.getIsSelected()} + style={{ height: rowHeight }} + /> + )) )} {isFetchingNextPage && ( diff --git a/packages/client/src/components/DataTable/DataTable.types.ts b/packages/client/src/components/DataTable/DataTable.types.ts index bcdcbe6b78..2a5a8ca2ec 100644 --- a/packages/client/src/components/DataTable/DataTable.types.ts +++ b/packages/client/src/components/DataTable/DataTable.types.ts @@ -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; diff --git a/packages/client/src/components/DataTable/DataTableComponents.tsx b/packages/client/src/components/DataTable/DataTableComponents.tsx index d1cd457a6a..9bdeaf7bfd 100644 --- a/packages/client/src/components/DataTable/DataTableComponents.tsx +++ b/packages/client/src/components/DataTable/DataTableComponents.tsx @@ -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 = >({ - row, - virtualIndex, -}: { - row: Row; - virtualIndex?: number; -}) => ( +const TableRowComponent = >( + { + row, + virtualIndex, + style, + selected, + }: { + row: Row; + virtualIndex?: number; + style?: React.CSSProperties; + selected: boolean; + }, + ref: React.Ref, +) => ( {row.getVisibleCells().map((cell) => { const meta = cell.column.columnDef.meta as @@ -74,11 +83,27 @@ const TableRowComponent = >({ ); +type ForwardTableRowComponentType = >( + props: { + row: Row; + virtualIndex?: number; + style?: React.CSSProperties; + } & React.RefAttributes, +) => JSX.Element; + +const ForwardTableRowComponent = forwardRef(TableRowComponent) as ForwardTableRowComponentType; + +interface GenericRowProps { + row: Row>; + 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( diff --git a/packages/client/src/components/DataTable/DataTableErrorBoundary.tsx b/packages/client/src/components/DataTable/DataTableErrorBoundary.tsx index 9633ae7c18..de2f492cc1 100644 --- a/packages/client/src/components/DataTable/DataTableErrorBoundary.tsx +++ b/packages/client/src/components/DataTable/DataTableErrorBoundary.tsx @@ -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< - {/* Optional: Show technical error details in development */} - {process.env.NODE_ENV === 'development' && this.state.error && ( + {import.meta.env.MODE === 'development' && this.state.error && (
Error Details (Dev)