From a253aa5d22b375b67cf302afb6a3118962c3a940 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Mon, 15 Sep 2025 22:51:34 +0200 Subject: [PATCH] refactor: DataTable and ArchivedChats; fix: sorting ArchivedChats API --- api/models/Conversation.js | 70 +- api/server/routes/convos.js | 6 +- .../SettingsTabs/General/ArchivedChats.tsx | 72 +- packages/client/src/components/DataTable.tsx | 2696 ++++++++++++++--- packages/client/src/components/index.ts | 1 + 5 files changed, 2296 insertions(+), 549 deletions(-) diff --git a/api/models/Conversation.js b/api/models/Conversation.js index 9f7aa9001..b7d4bf2ad 100644 --- a/api/models/Conversation.js +++ b/api/models/Conversation.js @@ -28,7 +28,7 @@ const getConvo = async (user, conversationId) => { return await Conversation.findOne({ user, conversationId }).lean(); } catch (error) { logger.error('[getConvo] Error getting single conversation', error); - return { message: 'Error getting single conversation' }; + throw new Error('Error getting single conversation'); } }; @@ -151,13 +151,21 @@ module.exports = { const result = await Conversation.bulkWrite(bulkOps); return result; } catch (error) { - logger.error('[saveBulkConversations] Error saving conversations in bulk', error); + logger.error('[bulkSaveConvos] Error saving conversations in bulk', error); throw new Error('Failed to save conversations in bulk.'); } }, getConvosByCursor: async ( user, - { cursor, limit = 25, isArchived = false, tags, search, order = 'desc' } = {}, + { + cursor, + limit = 25, + isArchived = false, + tags, + search, + sortBy = 'createdAt', + sortDirection = 'desc', + } = {}, ) => { const filters = [{ user }]; if (isArchived) { @@ -184,35 +192,77 @@ module.exports = { filters.push({ conversationId: { $in: matchingIds } }); } catch (error) { logger.error('[getConvosByCursor] Error during meiliSearch', error); - return { message: 'Error during meiliSearch' }; + throw new Error('Error during meiliSearch'); } } + const validSortFields = ['title', 'createdAt', 'updatedAt']; + if (!validSortFields.includes(sortBy)) { + throw new Error( + `Invalid sortBy field: ${sortBy}. Must be one of ${validSortFields.join(', ')}`, + ); + } + const finalSortBy = sortBy; + const finalSortDirection = sortDirection === 'asc' ? 'asc' : 'desc'; + + let cursorFilter = null; if (cursor) { - filters.push({ updatedAt: { $lt: new Date(cursor) } }); + try { + const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString()); + const { primary, secondary } = decoded; + const primaryValue = finalSortBy === 'title' ? primary : new Date(primary); + const secondaryValue = new Date(secondary); + const op = finalSortDirection === 'asc' ? '$gt' : '$lt'; + + cursorFilter = { + $or: [ + { [finalSortBy]: { [op]: primaryValue } }, + { + [finalSortBy]: primaryValue, + updatedAt: { [op]: secondaryValue }, + }, + ], + }; + } catch (err) { + logger.warn('[getConvosByCursor] Invalid cursor format, starting from beginning'); + } + if (cursorFilter) { + filters.push(cursorFilter); + } } const query = filters.length === 1 ? filters[0] : { $and: filters }; try { + const sortOrder = finalSortDirection === 'asc' ? 1 : -1; + const sortObj = { [finalSortBy]: sortOrder }; + + if (finalSortBy !== 'updatedAt') { + sortObj.updatedAt = sortOrder; + } + const convos = await Conversation.find(query) .select( 'conversationId endpoint title createdAt updatedAt user model agent_id assistant_id spec iconURL', ) - .sort({ updatedAt: order === 'asc' ? 1 : -1 }) + .sort(sortObj) .limit(limit + 1) .lean(); let nextCursor = null; if (convos.length > limit) { const lastConvo = convos.pop(); - nextCursor = lastConvo.updatedAt.toISOString(); + const primaryValue = lastConvo[finalSortBy]; + const primaryStr = finalSortBy === 'title' ? primaryValue : primaryValue.toISOString(); + const secondaryStr = lastConvo.updatedAt.toISOString(); + const composite = { primary: primaryStr, secondary: secondaryStr }; + nextCursor = Buffer.from(JSON.stringify(composite)).toString('base64'); } return { conversations: convos, nextCursor }; } catch (error) { logger.error('[getConvosByCursor] Error getting conversations', error); - return { message: 'Error getting conversations' }; + throw new Error('Error getting conversations'); } }, getConvosQueried: async (user, convoIds, cursor = null, limit = 25) => { @@ -252,7 +302,7 @@ module.exports = { return { conversations: limited, nextCursor, convoMap }; } catch (error) { logger.error('[getConvosQueried] Error getting conversations', error); - return { message: 'Error fetching conversations' }; + throw new Error('Error fetching conversations'); } }, getConvo, @@ -269,7 +319,7 @@ module.exports = { } } catch (error) { logger.error('[getConvoTitle] Error getting conversation title', error); - return { message: 'Error getting conversation title' }; + throw new Error('Error getting conversation title'); } }, /** diff --git a/api/server/routes/convos.js b/api/server/routes/convos.js index 704b12751..afc3572c4 100644 --- a/api/server/routes/convos.js +++ b/api/server/routes/convos.js @@ -30,7 +30,8 @@ router.get('/', async (req, res) => { const cursor = req.query.cursor; const isArchived = isEnabled(req.query.isArchived); const search = req.query.search ? decodeURIComponent(req.query.search) : undefined; - const order = req.query.order || 'desc'; + const sortBy = req.query.sortBy || 'createdAt'; + const sortDirection = req.query.sortDirection || 'desc'; let tags; if (req.query.tags) { @@ -44,7 +45,8 @@ router.get('/', async (req, res) => { isArchived, tags, search, - order, + sortBy, + sortDirection, }); res.status(200).json(result); } catch (error) { diff --git a/client/src/components/Nav/SettingsTabs/General/ArchivedChats.tsx b/client/src/components/Nav/SettingsTabs/General/ArchivedChats.tsx index df7c1709a..9cb38775e 100644 --- a/client/src/components/Nav/SettingsTabs/General/ArchivedChats.tsx +++ b/client/src/components/Nav/SettingsTabs/General/ArchivedChats.tsx @@ -12,7 +12,6 @@ import { Label, TooltipAnchor, Spinner, - DataTable, useToastContext, useMediaQuery, } from '@librechat/client'; @@ -26,6 +25,7 @@ import { MinimalIcon } from '~/components/Endpoints'; import { NotificationSeverity } from '~/common'; import { useLocalize } from '~/hooks'; import { formatDate } from '~/utils'; +import DataTable from './DataTable'; const DEFAULT_PARAMS = { isArchived: true, @@ -44,13 +44,12 @@ const defaultSort: SortingState = [ }, ]; -// Define the table column type for better type safety -// (kept from your original code) type TableColumn = ColumnDef & { meta?: { size?: string | number; mobileSize?: string | number; minWidth?: string | number; + priority?: number; }; }; @@ -86,36 +85,41 @@ export default function ArchivedChatsTable() { })); }, []); - // Robust against stale state; keeps UI sort in sync with backend defaults const handleSortingChange = useCallback( (updater: SortingState | ((old: SortingState) => SortingState)) => { setSorting((prev) => { const next = typeof updater === 'function' ? updater(prev) : updater; - // If user clears sorting, fall back to default both in UI and query - const coerced = next.length === 0 ? defaultSort : next; + const coerced = next; const primary = coerced[0]; setQueryParams((p) => { - if (primary && isSortKey(primary.id)) { + const newParams = (() => { + if (primary && isSortKey(primary.id)) { + return { + ...p, + sortBy: primary.id, + sortDirection: primary.desc ? 'desc' : 'asc', + }; + } return { ...p, - sortBy: primary.id, - sortDirection: primary.desc ? 'desc' : 'asc', + sortBy: 'createdAt', + sortDirection: 'desc', }; - } - // Fallback if id isn't one of the permitted keys - return { - ...p, - sortBy: 'createdAt', - sortDirection: 'desc', - }; + })(); + + setTimeout(() => { + refetch(); + }, 0); + + return newParams; }); return coerced; }); }, - [setQueryParams, setSorting], + [setQueryParams, setSorting, refetch], ); const handleError = useCallback( @@ -189,13 +193,7 @@ export default function ArchivedChatsTable() { cell: ({ row }) => { const { conversationId, title } = row.original; return ( - +
+ + {title} + +
); }, meta: { - size: isSmallScreen ? '70%' : '50%', - mobileSize: '70%', + priority: 3, + minWidth: 'min-content', }, enableSorting: true, }, @@ -220,8 +226,8 @@ export default function ArchivedChatsTable() { ), cell: ({ row }) => formatDate(row.original.createdAt?.toString() ?? '', isSmallScreen), meta: { - size: isSmallScreen ? '30%' : '35%', - mobileSize: '30%', + priority: 2, + minWidth: '80px', }, enableSorting: true, }, @@ -265,7 +271,7 @@ export default function ArchivedChatsTable() { description={localize('com_ui_delete')} render={ @@ -370,44 +422,73 @@ const DeleteButton = memo( DeleteButton.displayName = 'DeleteButton'; -// Memoized skeleton rows with static precomputed widths for optimal performance +/** + * Dynamic skeleton rows that match final column proportions to prevent CLS + * Uses same priority-based width calculation as real content + */ const SkeletonRows = memo(function ({ count = 10, columns, columnStyles, + containerRef, }: { count?: number; columns: TableColumn[]; columnStyles: Record; + containerRef: React.RefObject; }) { - // Use static shuffled widths to avoid random computation; slice based on count - const skeletonWidths = useMemo(() => { - // Pre-shuffle once at component level for consistency across renders - const shuffled = [...STATIC_SKELETON_WIDTHS].sort(() => Math.random() - 0.5); - return shuffled - .slice(0, Math.min(count, shuffled.length)) - .map((w) => w + DATA_TABLE_CONSTANTS.SKELETON_OFFSET); - }, [count]); + if (!columns.length || !containerRef.current) { + return null; + } - if (!columns.length || !columns[0]?.id) return null; - const firstDataColumnIndex = columns[0].id === 'select' ? 1 : 0; + const containerWidth = containerRef.current.clientWidth; + + // Calculate total priority for skeleton width distribution + const totalPriority = columns.reduce((sum, column) => { + const explicitPriority = column.meta?.priority; + const priority = explicitPriority ?? 1; // uniform default + return sum + priority; + }, 0); + + if (totalPriority === 0) { + return null; + } + + // Helper function to get column key - same as in useDynamicColumnWidths + const keyFor = (column: TableColumn): string => + String(column.id ?? column.accessorKey ?? ''); return ( <> {Array.from({ length: count }, (_, index) => ( - - {columns.map((column, columnIndex) => { - const style = columnStyles[column.id!] || {}; - const isFirstDataColumn = columnIndex === firstDataColumnIndex; - const width = isFirstDataColumn - ? (skeletonWidths[index % skeletonWidths.length] ?? undefined) - : undefined; + + {columns.map((column, colIndex) => { + const columnKey = keyFor(column); + const baseStyle = columnStyles[columnKey] || {}; + const explicitPriority = column.meta?.priority; + const priority = explicitPriority ?? 1; // uniform default + // Use priority-based width for all skeleton cells + const skeletonWidth = getSkeletonWidth(priority, containerWidth, totalPriority); + const skeletonStyle: React.CSSProperties = { + ...baseStyle, + width: skeletonWidth, + minWidth: skeletonWidth, + height: '48px', + }; return ( - + ); @@ -612,7 +693,7 @@ export default function DataTable({ onSortingChange, }: DataTableProps) { const renderCountRef = useRef(0); - const prevPropsRef = useRef(null); + // const prevPropsRef = useRef(null); // Disabled with debug logging const tableConfig = useMemo(() => { return { @@ -623,7 +704,6 @@ export default function DataTable({ skeletonCount: config?.skeleton?.count ?? 10, overscan: config?.virtualization?.overscan ?? DATA_TABLE_CONSTANTS.OVERS_CAN, debounceDelay: config?.search?.debounce ?? DATA_TABLE_CONSTANTS.SEARCH_DEBOUNCE_MS, - enableColumnPinning: config?.pinning?.enableColumnPinning ?? false, }; }, [config]); @@ -631,36 +711,41 @@ export default function DataTable({ enableRowSelection, showCheckboxes, enableSearch, - filterColumn, + // filterColumn, // Disabled with debug logging skeletonCount, overscan, debounceDelay, - enableColumnPinning, } = tableConfig; const localize = useLocalize(); const isSmallScreen = useMediaQuery('(max-width: 768px)'); const tableContainerRef = useRef(null); - // State management - const [columnFilters, setColumnFilters] = useState([]); const [columnVisibility, setColumnVisibility] = useState({}); - const [columnPinning, setColumnPinning] = useState({}); const [optimizedRowSelection, setOptimizedRowSelection] = useOptimizedRowSelection(); - const [term, setTerm] = useState(filterValue ?? ''); + const [rawTerm, setRawTerm] = useState(filterValue ?? ''); + const [isImmediateSearch, setIsImmediateSearch] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const [error, setError] = useState(null); const [isSearching, setIsSearching] = useState(false); - const debouncedTerm = useDebounced(term, debounceDelay); + const debouncedTerm = useDebounced(rawTerm, debounceDelay); + + const isTransitioning = isFetching || isImmediateSearch; // Track when we're waiting for search results const isWaitingForSearchResults = isSearching && isFetching; const isFirstLoad = isLoading && data.length === 0; const isRefreshing = isFetching && !isFirstLoad && !isFetchingNextPage && !isSearching; - // Show skeletons during initial load, refresh, or search - const showSkeletons = isFirstLoad || isRefreshing || isWaitingForSearchResults; + // Show skeletons during initial load, refresh, search, or transitioning + // Exclude isFetchingNextPage to prevent skeletons during infinite scroll + const showSkeletons = + isFirstLoad || + isRefreshing || + isWaitingForSearchResults || + (isTransitioning && !isFetchingNextPage) || // Don't show skeletons during infinite scroll + rawTerm !== debouncedTerm; // Show during typing // External sorting support: use provided sorting state, fall back to internal const [internalSorting, setInternalSorting] = useState(defaultSort); @@ -676,7 +761,7 @@ export default function DataTable({ // Keep search input in sync with external filterValue useEffect(() => { - setTerm(filterValue ?? ''); + setRawTerm(filterValue ?? ''); }, [filterValue]); // Sorting handler: call external callback if provided, otherwise use internal state @@ -692,50 +777,8 @@ export default function DataTable({ ); const handleSortingChange = onSortingChange ?? handleSortingChangeInternal; - // Diagnostic logging renderCountRef.current += 1; - if (process.env.NODE_ENV === 'development') { - console.log('DataTable: Render #', renderCountRef.current); - console.log('DataTable: Props summary:', { - dataLength: data?.length, - columnsLength: columns?.length, - defaultSort, - sorting: sorting?.length, - filterValue, - isLoading, - configKeys: config ? Object.keys(config) : null, - }); - - // Prop stability check - if (prevPropsRef.current) { - const prev = prevPropsRef.current; - if ( - !Object.is(defaultSort, prev.defaultSort) || - data.length !== prev.dataLength || - columns.length !== prev.columnsLength || - filterValue !== prev.filterValue || - sorting?.length !== prev.sortingLength - ) { - console.log('DataTable: Key props changed since last render'); - } - } - prevPropsRef.current = { - defaultSort, - dataLength: data?.length || 0, - columnsLength: columns?.length || 0, - filterValue, - sortingLength: sorting?.length || 0, - }; - - // Search UX warning for missing filterColumn - if (enableSearch && !filterColumn && onFilterChange) { - console.warn( - 'DataTable: enableSearch is true but filterColumn is missing. Search will be hidden.', - ); - } - } - const sanitizeError = useCallback((err: Error): string => { const message = err.message; if (message?.includes('auth') || message?.includes('token')) { @@ -746,7 +789,6 @@ export default function DataTable({ : 'An error occurred. Please try again.'; }, []); - // Memoized table columns with selection const tableColumns = useMemo(() => { if (!enableRowSelection || !showCheckboxes) return columns; @@ -783,32 +825,47 @@ export default function DataTable({ return [selectColumn, ...columns]; }, [columns, enableRowSelection, showCheckboxes]); - // Memoized column styles for performance - const columnStyles = useColumnStyles(tableColumns as TableColumn[], isSmallScreen); + // Dynamic column styles with priority-based sizing + const columnStyles = useColumnStyles( + tableColumns as TableColumn[], + isSmallScreen, + tableContainerRef, + ); - // Set CSS variables for column sizing with hash optimization + // Set CSS variables for column sizing with hash optimization - prevent re-render loops const columnSizesHashRef = useRef(''); + const prevTableColumnsRef = useRef([]); + useLayoutEffect(() => { if (typeof window === 'undefined' || !tableContainerRef.current) return; + // Only update if columns or styles actually changed + const columnsChanged = prevTableColumnsRef.current.length !== tableColumns.length; + // Calculate hash of column sizes to avoid unnecessary DOM writes const sizesHash = tableColumns .map((col, index) => `${index}:${columnStyles[col.id!]?.width || 'auto'}`) .join('|'); - if (sizesHash !== columnSizesHashRef.current) { + if (columnsChanged || sizesHash !== columnSizesHashRef.current) { columnSizesHashRef.current = sizesHash; + prevTableColumnsRef.current = tableColumns; - tableColumns.forEach((column, index) => { - if (column.id) { - const size = columnStyles[column.id]?.width || 'auto'; - tableContainerRef.current!.style.setProperty(`--col-${index}-size`, `${size}`); + // Batch DOM updates to prevent layout thrashing + requestAnimationFrame(() => { + if (tableContainerRef.current) { + tableColumns.forEach((column, index) => { + if (column.id) { + const size = columnStyles[column.id]?.width || 'auto'; + tableContainerRef.current!.style.setProperty(`--col-${index}-size`, `${size}`); + } + }); } }); } }, [tableColumns, columnStyles]); - // Memoized row data with stable references + // Memoized row data with stable references - deep comparison to prevent unnecessary re-renders const memoizedRowData = useMemo(() => { return data.map((item, index) => ({ ...item, @@ -818,26 +875,23 @@ export default function DataTable({ }, [data]); // React Table instance + const tableData = memoizedRowData; + const table = useReactTable({ - data: memoizedRowData, + data: tableData, columns: tableColumns, getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - getFilteredRowModel: getFilteredRowModel(), enableRowSelection, enableMultiRowSelection: true, - enableColumnPinning, + manualSorting: true, // Use manual sorting for server-side sorting + manualFiltering: true, state: { sorting: finalSorting, - columnFilters, columnVisibility, - columnPinning, rowSelection: optimizedRowSelection, }, onSortingChange: handleSortingChange, - onColumnFiltersChange: setColumnFilters, onColumnVisibilityChange: setColumnVisibility, - onColumnPinningChange: setColumnPinning, onRowSelectionChange: setOptimizedRowSelection, }); @@ -848,29 +902,14 @@ export default function DataTable({ // Virtual scrolling setup with optimized height measurement const measuredHeightsRef = useRef([]); - const rowVirtualizer = useVirtualizer({ - count: rows.length, - getScrollElement: () => { - // SSR safety: return null during SSR - if (typeof window === 'undefined') return null; - return tableContainerRef.current; - }, - estimateSize: useCallback(() => { - const heights = measuredHeightsRef.current; - if (heights.length > 0) { - const avg = heights.reduce((a, b) => a + b, 0) / heights.length; - return Math.max(avg, DATA_TABLE_CONSTANTS.ROW_HEIGHT_ESTIMATE); - } - return DATA_TABLE_CONSTANTS.ROW_HEIGHT_ESTIMATE; - }, []), - overscan, - // Fixed: Optimize measureElement to avoid duplicate getBoundingClientRect calls - measureElement: (el) => { - if (!el) return DATA_TABLE_CONSTANTS.ROW_HEIGHT_ESTIMATE; + const measureElementCallback = useCallback((el: Element | null) => { + if (!el) return DATA_TABLE_CONSTANTS.ROW_HEIGHT_ESTIMATE; - const height = el.getBoundingClientRect().height; + const height = el.getBoundingClientRect().height; - // Memory management for measured heights + // Memory management for measured heights - only update if significantly different + const lastHeight = measuredHeightsRef.current[measuredHeightsRef.current.length - 1]; + if (!lastHeight || Math.abs(height - lastHeight) > 1) { measuredHeightsRef.current = [ ...measuredHeightsRef.current.slice(-DATA_TABLE_CONSTANTS.MEASURED_HEIGHTS_TRIM + 1), height, @@ -882,9 +921,29 @@ export default function DataTable({ -DATA_TABLE_CONSTANTS.MEASURED_HEIGHTS_TRIM, ); } + } - return height; - }, + return height; + }, []); + + const rowVirtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: useCallback(() => { + // SSR safety: return null during SSR + if (typeof window === 'undefined') return null; + return tableContainerRef.current; + }, []), + estimateSize: useCallback(() => { + const heights = measuredHeightsRef.current; + if (heights.length > 0) { + const avg = heights.reduce((a, b) => a + b, 0) / heights.length; + return Math.max(avg, DATA_TABLE_CONSTANTS.ROW_HEIGHT_ESTIMATE); + } + return DATA_TABLE_CONSTANTS.ROW_HEIGHT_ESTIMATE; + }, []), + overscan, + // Fixed: Optimize measureElement to avoid duplicate getBoundingClientRect calls + measureElement: measureElementCallback, }); const virtualRows = rowVirtualizer.getVirtualItems(); @@ -903,25 +962,37 @@ export default function DataTable({ [virtualRows, rows], ); - // Fixed: Infinite scrolling with early return pattern and always attached listener + // Store scroll position before fetching to prevent jumping + const scrollPositionRef = useRef<{ top: number; timestamp: number } | null>(null); + const isRestoringScrollRef = useRef(false); + + // Fixed: Infinite scrolling with scroll position preservation const handleScrollInternal = useCallback(async () => { if (!mountedRef.current || !tableContainerRef.current) return; // Early return if conditions not met - if (!hasNextPage || isFetchingNextPage) return; + if (!hasNextPage || isFetchingNextPage || isRestoringScrollRef.current) return; const scrollElement = tableContainerRef.current; - const clientHeight = scrollElement.clientHeight; - const virtualEnd = virtualRows.length > 0 ? virtualRows[virtualRows.length - 1].end : 0; + const { scrollTop, scrollHeight, clientHeight } = scrollElement; - // Simplified condition: check distance to virtual end - const nearEnd = - totalSize - virtualEnd <= clientHeight * DATA_TABLE_CONSTANTS.INFINITE_SCROLL_THRESHOLD; + // More precise threshold calculation + const scrollThreshold = + scrollHeight - clientHeight * DATA_TABLE_CONSTANTS.INFINITE_SCROLL_THRESHOLD; + const nearEnd = scrollTop >= scrollThreshold; if (nearEnd) { try { + // Store current scroll position before fetching + scrollPositionRef.current = { + top: scrollTop, + timestamp: Date.now(), + }; + await fetchNextPage?.(); } catch (err) { + // Clear stored position on error + scrollPositionRef.current = null; const rawError = err instanceof Error ? err : new Error('Failed to fetch next page'); const sanitizedMessage = sanitizeError(rawError); const sanitizedError = new Error(sanitizedMessage); @@ -929,55 +1000,129 @@ export default function DataTable({ onError?.(sanitizedError); } } - }, [ - fetchNextPage, - totalSize, - virtualRows, - onError, - sanitizeError, - hasNextPage, - isFetchingNextPage, - ]); + }, [fetchNextPage, onError, sanitizeError, hasNextPage, isFetchingNextPage]); const throttledHandleScroll = useMemo( () => throttle(handleScrollInternal, DATA_TABLE_CONSTANTS.SCROLL_THROTTLE_MS), [handleScrollInternal], ); - // Always attach scroll listener, early return in handler + // Scroll position restoration effect - prevents jumping when new data is added + useEffect(() => { + if (!isFetchingNextPage && scrollPositionRef.current && tableContainerRef.current) { + const { top, timestamp } = scrollPositionRef.current; + const isRecent = Date.now() - timestamp < 1000; // Only restore if within 1 second + + if (isRecent) { + isRestoringScrollRef.current = true; + + // Use requestAnimationFrame to ensure DOM has updated + requestAnimationFrame(() => { + if (tableContainerRef.current && mountedRef.current) { + const scrollElement = tableContainerRef.current; + const maxScroll = scrollElement.scrollHeight - scrollElement.clientHeight; + const targetScroll = Math.min(top, maxScroll); + + scrollElement.scrollTo({ + top: targetScroll, + behavior: 'auto', // Instant restoration + }); + + // Clear restoration flag after a brief delay + setTimeout(() => { + isRestoringScrollRef.current = false; + }, 100); + } + }); + } + + // Clear stored position + scrollPositionRef.current = null; + } + }, [isFetchingNextPage, data.length]); // Trigger when fetch completes or data changes + + // Always attach scroll listener with optimized event handling to reduce GC pressure useEffect(() => { const scrollElement = tableContainerRef.current; if (!scrollElement) return; - scrollElement.addEventListener('scroll', throttledHandleScroll, { passive: true }); - return () => scrollElement.removeEventListener('scroll', throttledHandleScroll); + // Pre-bind the handler to avoid creating new functions on each scroll + const scrollHandler = throttledHandleScroll; + + // Use passive listener for better performance + const options = { passive: true }; + scrollElement.addEventListener('scroll', scrollHandler, options); + + return () => { + scrollElement.removeEventListener('scroll', scrollHandler); + }; }, [throttledHandleScroll]); - // Resize observer for virtualizer revalidation + // Resize observer for virtualizer revalidation - heavily throttled to prevent rapid re-renders const handleWindowResize = useCallback(() => { - rowVirtualizer.measure(); + // Debounce resize to prevent rapid re-renders + if (resizeTimeoutRef.current) { + clearTimeout(resizeTimeoutRef.current); + } + resizeTimeoutRef.current = setTimeout(() => { + if (mountedRef.current) { + rowVirtualizer.measure(); + } + }, 250); // Increased delay significantly }, [rowVirtualizer]); + const resizeTimeoutRef = useRef | null>(null); + + // Optimized resize observer with reduced layout thrashing useEffect(() => { if (typeof window === 'undefined') return; const scrollElement = tableContainerRef.current; if (!scrollElement) return; + // Increased throttling to reduce excessive re-renders and GC pressure + let resizeTimeout: ReturnType | null = null; + let lastResizeTime = 0; + const MIN_RESIZE_INTERVAL = 500; // Increased from 250ms to reduce GC pressure + const resizeObserver = new ResizeObserver(() => { - rowVirtualizer.measure(); - if (scrollElement) { - throttledHandleScroll(); - } + const now = Date.now(); + if (now - lastResizeTime < MIN_RESIZE_INTERVAL) return; + + lastResizeTime = now; + if (resizeTimeout) clearTimeout(resizeTimeout); + + resizeTimeout = setTimeout(() => { + if (mountedRef.current && !isRestoringScrollRef.current) { + // Only measure if not currently restoring scroll position + rowVirtualizer.measure(); + } + }, MIN_RESIZE_INTERVAL); }); resizeObserver.observe(scrollElement); - window.addEventListener('resize', handleWindowResize); - return () => { - resizeObserver.disconnect(); - window.removeEventListener('resize', handleWindowResize); + + // Optimized window resize handler with increased debouncing + const windowResizeHandler = () => { + if (resizeTimeoutRef.current) { + clearTimeout(resizeTimeoutRef.current); + } + resizeTimeoutRef.current = setTimeout(() => { + if (mountedRef.current && !isRestoringScrollRef.current) { + rowVirtualizer.measure(); + } + }, MIN_RESIZE_INTERVAL); }; - }, [rowVirtualizer, handleWindowResize, throttledHandleScroll]); + + window.addEventListener('resize', windowResizeHandler, { passive: true }); + + return () => { + if (resizeTimeout) clearTimeout(resizeTimeout); + if (resizeTimeoutRef.current) clearTimeout(resizeTimeoutRef.current); + resizeObserver.disconnect(); + window.removeEventListener('resize', windowResizeHandler); + }; + }, [rowVirtualizer, handleWindowResize]); useLayoutEffect(() => { mountedRef.current = true; @@ -986,36 +1131,71 @@ export default function DataTable({ }; }, []); - // Dynamic measurement optimization + // Optimized dynamic measurement - prevent infinite loops with better guards and reduced frequency + const lastMeasurementRef = useRef<{ + dataLength: number; + isSmallScreen: boolean; + virtualRowsLength: number; + isTransitioning: boolean; + timestamp: number; + }>({ + dataLength: 0, + isSmallScreen: false, + virtualRowsLength: 0, + isTransitioning: false, + timestamp: 0, + }); + useLayoutEffect(() => { - if (typeof window === 'undefined') return; + if (typeof window === 'undefined' || isTransitioning || isRestoringScrollRef.current) return; + + const current = { + dataLength: data.length, + isSmallScreen, + virtualRowsLength: virtualRowsWithStableKeys.length, + isTransitioning, + timestamp: Date.now(), + }; + + const timeSinceLastMeasurement = current.timestamp - lastMeasurementRef.current.timestamp; + + // Only measure if something meaningful changed AND enough time has passed + const hasChanged = + current.dataLength !== lastMeasurementRef.current.dataLength || + current.isSmallScreen !== lastMeasurementRef.current.isSmallScreen || + current.virtualRowsLength !== lastMeasurementRef.current.virtualRowsLength; + + // Increased throttle to 200ms to reduce GC pressure and improve performance + if (!hasChanged || timeSinceLastMeasurement < 200) return; + + lastMeasurementRef.current = current; if (mountedRef.current && tableContainerRef.current && virtualRows.length > 0) { - requestAnimationFrame(() => { - if (mountedRef.current) { + // Increased debounce delay to reduce re-renders + const measurementTimeout = setTimeout(() => { + if (mountedRef.current && !isRestoringScrollRef.current) { rowVirtualizer.measure(); - virtualRowsWithStableKeys.forEach((virtualRow) => { - const rowElement = tableContainerRef.current?.querySelector( - `[data-index="${virtualRow.index}"]`, - ); - if (rowElement) { - const height = rowElement.getBoundingClientRect().height; - measuredHeightsRef.current = [...measuredHeightsRef.current.slice(-9), height]; - } - }); } - }); + }, 100); + + return () => clearTimeout(measurementTimeout); } }, [ data.length, rowVirtualizer, isSmallScreen, virtualRowsWithStableKeys.length, - virtualRowsWithStableKeys, virtualRows.length, + isTransitioning, ]); // Search effect with optimized state updates + useEffect(() => { + if (rawTerm !== filterValue) { + setIsImmediateSearch(true); + } + }, [rawTerm, filterValue]); + useEffect(() => { if (debouncedTerm !== filterValue) { setIsSearching(true); @@ -1025,20 +1205,18 @@ export default function DataTable({ } }, [debouncedTerm, onFilterChange, filterValue]); + useEffect(() => { + if (!isFetching && isImmediateSearch) { + setIsImmediateSearch(false); + } + }, [isFetching, isImmediateSearch]); + useEffect(() => { if (!isFetching && isSearching) { setIsSearching(false); } }, [isFetching, isSearching]); - // Internal filtering when no external filter handler - useEffect(() => { - if (filterColumn && !onFilterChange) { - const newFilters = debouncedTerm ? [{ id: filterColumn, value: debouncedTerm }] : []; - setColumnFilters(newFilters); - } - }, [debouncedTerm, filterColumn, onFilterChange]); - // Optimized delete handler with batch operations const handleDelete = useCallback(async () => { if (!onDelete || isDeleting) return; @@ -1047,8 +1225,9 @@ export default function DataTable({ setError(null); try { - const selectedRowsLength = table.getFilteredSelectedRowModel().rows.length; - let selectedRows = table.getFilteredSelectedRowModel().rows.map((r) => r.original); + // Use getSelectedRowModel instead of getFilteredSelectedRowModel + const selectedRowsLength = table.getSelectedRowModel().rows.length; + let selectedRows = table.getSelectedRowModel().rows.map((r) => r.original); // Validation if (selectedRows.length === 0) { @@ -1061,7 +1240,8 @@ export default function DataTable({ (row): row is TData => typeof row === 'object' && row !== null, ); - if (selectedRows.length !== selectedRowsLength && process.env.NODE_ENV === 'development') { + // TODO: Remove this - only for development + if (selectedRows.length !== selectedRowsLength && true) { console.warn('DataTable: Invalid row data detected and filtered out during deletion.'); } @@ -1093,22 +1273,14 @@ export default function DataTable({ } }, [onReset, rowVirtualizer]); - // Fixed: Derive selected count from stable table state instead of re-calling getFilteredSelectedRowModel const selectedCount = useMemo(() => { - const selection = table.getState().rowSelection; + const selection = optimizedRowSelection; return Object.keys(selection).length; - }, [table.getState().rowSelection]); + }, [optimizedRowSelection]); const shouldShowSearch = useMemo( - () => enableSearch && filterColumn && table.getColumn(filterColumn), - [ - enableSearch, - filterColumn, - table - .getAllColumns() - .map((c) => c.id) - .join(','), - ], + () => enableSearch && !!onFilterChange, + [enableSearch, onFilterChange], ); return ( @@ -1119,269 +1291,1785 @@ export default function DataTable({ > {/* Accessible live region for loading announcements */}
- {isSearching - ? 'Searching...' - : isFetchingNextPage - ? 'Loading more rows' - : hasNextPage - ? 'More rows available' - : 'All rows loaded'} + {(() => { + if (isSearching) return 'Searching...'; + if (isFetchingNextPage) return 'Loading more rows'; + if (hasNextPage) return 'More rows available'; + return 'All rows loaded'; + })()}
{/* Error display - kept outside boundary for non-rendering errors */} {error && (
{sanitizeError(error)} - +
)} - {/* Controls - kept outside boundary as they're stable */} -
- {enableRowSelection && showCheckboxes && ( - - )} - - {shouldShowSearch && ( -
- { - startTransition(() => setTerm(e.target.value)); - }} - isSearching={isWaitingForSearchResults} - placeholder="Search..." - aria-label="Search table data" + {/* Isolated Toolbar - Outside scroll container */} +
+
+ {enableRowSelection && showCheckboxes && ( + -
- )} + )} - {selectedCount > 0 && ( -
- {selectedCount} row{selectedCount === 1 ? '' : 's'} selected -
- )} + {shouldShowSearch && ( +
+ { + startTransition(() => setRawTerm(e.target.value)); + }} + isSearching={isWaitingForSearchResults} + placeholder="Search..." + aria-label="Search table data" + /> +
+ )} + + {selectedCount > 0 && ( +
{`${selectedCount} selected`}
+ )} +
- {/* Error boundary wraps only the table container to catch rendering errors */} + {/* Error boundary wraps the scrollable table container */}
{ + if (isFetchingNextPage) { + e.preventDefault(); + return; + } + if (isTransitioning) { + e.deltaY *= 0.5; + } + }} role="grid" aria-label="Data grid" aria-rowcount={data.length} aria-busy={isLoading || isFetching || isFetchingNextPage} > - - - {headerGroups.map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - const sortDir = header.column.getIsSorted(); - const canSort = header.column.getCanSort(); - - let ariaSort: 'ascending' | 'descending' | 'none' | undefined; - if (!canSort) { - ariaSort = undefined; - } else if (sortDir === 'asc') { - ariaSort = 'ascending'; - } else if (sortDir === 'desc') { - ariaSort = 'descending'; - } else { - ariaSort = 'none'; - } - - return ( - { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - header.column.toggleSorting(); - } - } - : undefined - } - className={cn( - 'group relative whitespace-nowrap bg-surface-secondary px-2 py-2 text-left text-sm font-medium text-text-secondary transition-colors duration-200 sm:px-4', - canSort && - 'cursor-pointer hover:bg-surface-hover focus-visible:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary', - )} - style={{ - ...columnStyles[header.column.id], - ...getCommonPinningStyles(header.column, table), - }} - role="columnheader" - scope="col" - tabIndex={canSort ? 0 : -1} - aria-sort={ariaSort} - > -
- - {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} - - {canSort && ( - - {!sortDir && ( - - )} - {sortDir === 'asc' && } - {sortDir === 'desc' && ( - - )} - - )} -
- {/* Column pinning controls */} - {enableColumnPinning && - !header.isPlaceholder && - header.column.getCanPin() && ( -
- {header.column.getIsPinned() !== 'left' && ( - - )} - {header.column.getIsPinned() && ( - - )} - {header.column.getIsPinned() !== 'right' && ( - - )} -
- )} -
- ); - })} -
- ))} -
- - - {paddingTop > 0 && ( - - - )} - - {showSkeletons ? ( - []} - columnStyles={columnStyles} - /> - ) : ( - virtualRowsWithStableKeys.map((virtualRow) => { - const row = rows[virtualRow.index]; - return ( - - ); - }) - )} - - {data.length === 0 && !isLoading && ( - - +
-
+ {/* Sticky Table Header - Fixed z-index and positioning */} + + {headerGroups.map((headerGroup) => ( + - {debouncedTerm - ? localize('com_ui_no_results_found') - : localize('com_ui_no_data_available')} - - - )} + {headerGroup.headers.map((header) => { + const sortDir = header.column.getIsSorted(); + const canSort = header.column.getCanSort(); - {paddingBottom > 0 && ( - - - )} + let ariaSort: 'ascending' | 'descending' | 'none' | undefined; + if (!canSort) { + ariaSort = undefined; + } else if (sortDir === 'asc') { + ariaSort = 'ascending'; + } else if (sortDir === 'desc') { + ariaSort = 'descending'; + } else { + ariaSort = 'none'; + } - {/* Loading indicator for infinite scroll */} - {(isFetchingNextPage || hasNextPage) && !isLoading && ( - - -
- {isFetchingNextPage ? ( - - ) : ( - hasNextPage &&
- )} -
- - - )} - -
-
+ return ( + { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + header.column.toggleSorting(); + } + } + : undefined + } + className={cn( + 'group relative box-border whitespace-nowrap bg-surface-secondary px-2 py-3 text-left text-sm font-medium text-text-secondary transition-colors duration-200 sm:px-4', + canSort && + 'cursor-pointer hover:bg-surface-hover focus-visible:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary', + )} + style={{ + ...columnStyles[header.column.id], + backgroundColor: 'var(--surface-secondary)', + position: 'sticky', + top: 0, + boxSizing: 'border-box', + maxWidth: 'none', + }} + role="columnheader" + scope="col" + tabIndex={canSort ? 0 : -1} + aria-sort={ariaSort} + > +
+ + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + {canSort && ( + + {!sortDir && ( + + )} + {sortDir === 'asc' && } + {sortDir === 'desc' && ( + + )} + + )} +
+
+ ); + })} + + ))} + + + + {paddingTop > 0 && ( + + + + )} + + {showSkeletons ? ( + []} + columnStyles={columnStyles} + containerRef={tableContainerRef} + /> + ) : ( + virtualRowsWithStableKeys.map((virtualRow) => { + const row = rows[virtualRow.index]; + return ( + + ); + }) + )} + + {data.length === 0 && !isLoading && ( + + + {debouncedTerm ? localize('com_ui_no_results_found') : 'No data available'} + + + )} + + {paddingBottom > 0 && ( + + + + )} + + {/* Loading indicator for infinite scroll */} + {(isFetchingNextPage || hasNextPage) && !isLoading && ( + + +
+ {isFetchingNextPage ? ( + + ) : ( + hasNextPage &&
+ )} +
+ + + )} + + +
+
+
+
+ ); +} + +// interface PrevProps { +// defaultSort: SortingState; +// dataLength: number; +// columnsLength: number; +// filterValue: string; +// sortingLength?: number; +// } + +// Constants extracted for maintainability +export const DATA_TABLE_CONSTANTS = { + // Selection and checkbox dimensions + CHECKBOX_WIDTH: '30px' as const, + SELECT_COLUMN_SIZE: '50px' as const, + SELECT_COLUMN_MIN_WIDTH: '50px' as const, + DELETE_BUTTON_MIN_WIDTH: '40px' as const, + + // Animation and timing + ANIMATION_DELAY_BASE: 15 as const, // ms per row + ANIMATION_DELAY_MAX: 300 as const, // ms cap + SEARCH_DEBOUNCE_MS: 300 as const, + ROW_TRANSITION_DURATION: '200ms' as const, + + // Virtual scrolling and sizing + OVERS_CAN: 10 as const, + ROW_HEIGHT_ESTIMATE: 50 as const, // px fallback + INFINITE_SCROLL_THRESHOLD: 2.5 as const, // multiplier of clientHeight - increased for earlier trigger + SCROLL_THROTTLE_MS: 100 as const, // throttle scroll events + + // Skeleton and layout + SKELETON_OFFSET: 150 as const, // px added to base widths + SEARCH_INPUT_MIN_WIDTH: '200px' as const, + TABLE_MIN_WIDTH: '300px' as const, + LOADING_INDICATOR_SIZE: 4 as const, // rem for spinner + TRASH_ICON_SIZE: 3.5 as const, // rem base, 4 for sm + + // Memory management + MAX_MEASURED_HEIGHTS: 100 as const, + MEASURED_HEIGHTS_TRIM: 50 as const, +} as const; + +/** + * Dynamic skeleton width calculator based on priorities + * Returns pixel value for skeleton sizing that matches final content proportions + */ +const getSkeletonWidth = ( + priority: number, + containerWidth: number, + totalPriority: number, +): string => { + const ratio = priority / totalPriority; + const baseWidth = Math.max(50, containerWidth * ratio * 0.8); // 80% of allocated space for visual balance + return `${Math.min(baseWidth, containerWidth * 0.3)}px`; // Cap at 30% of container to prevent overflow +}; + +type TableColumn = ColumnDef & { + accessorKey?: string | number; + meta?: { + size?: string | number; + mobileSize?: string | number; + minWidth?: string | number; + priority?: number; // 1-5 scale, higher = more width priority + }; +}; + +// Throttle utility for performance optimization - Fixed: Use DOM-safe timeout type +const throttle = unknown>(fn: T, delay: number): T => { + let timeoutId: ReturnType | null = null; + let lastExecTime = 0; + + return ((...args: Parameters) => { + const currentTime = Date.now(); + + if (currentTime - lastExecTime > delay) { + fn(...args); + lastExecTime = currentTime; + } else { + if (timeoutId) clearTimeout(timeoutId); + timeoutId = setTimeout( + () => { + fn(...args); + lastExecTime = Date.now(); + }, + delay - (currentTime - lastExecTime), + ); + } + }) as T; +}; + +// Deep comparison utility for objects +const deepEqual = (a: unknown, b: unknown): boolean => { + if (a === b) return true; + if (a == null || b == null) return false; + if (typeof a !== typeof b) return false; + + if (typeof a === 'object') { + // Handle arrays + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (!deepEqual(a[i], b[i])) return false; + } + return true; + } + + // Handle non-array objects + if (!Array.isArray(a) && !Array.isArray(b)) { + const keysA = Object.keys(a as Record); + const keysB = Object.keys(b as Record); + if (keysA.length !== keysB.length) return false; + + for (const key of keysA) { + if ( + !keysB.includes(key) || + !deepEqual((a as Record)[key], (b as Record)[key]) + ) { + return false; + } + } + return true; + } + + return false; + } + + return false; +}; + +const SelectionCheckbox = memo( + ({ + checked, + onChange, + ariaLabel, + }: { + checked: boolean; + onChange: (value: boolean) => void; + ariaLabel: string; + }) => ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onChange(!checked); + } + e.stopPropagation(); + }} + className={`flex h-full items-center justify-center`} + style={{ width: DATA_TABLE_CONSTANTS.CHECKBOX_WIDTH }} + onClick={(e) => { + e.stopPropagation(); + onChange(!checked); + }} + > + +
+ ), +); + +SelectionCheckbox.displayName = 'SelectionCheckbox'; + +/** + * Dynamic column width calculator using priority-based distribution + * Computes relative widths based on semantic priorities and container size + */ +const useDynamicColumnWidths = ( + columns: TableColumn[], + containerRef: React.RefObject, + isSmallScreen: boolean, +) => { + // Stabilize columns array to prevent infinite re-renders + const stableColumns = useMemo(() => { + return columns.map((c) => ({ + id: c.id, + accessorKey: c.accessorKey, + meta: c.meta, + })); + }, [columns]); + + return useMemo(() => { + // Get container width directly without state to prevent re-render loops + const containerWidth = containerRef.current?.clientWidth || 0; + + if (containerWidth === 0) { + return {}; + } + + // Calculate total priority + const totalPriority = stableColumns.reduce((sum, column) => { + const explicitPriority = column.meta?.priority; + const priority = explicitPriority ?? 1; // uniform default + return sum + priority; + }, 0); + + if (totalPriority === 0) { + return {}; + } + + const keyFor = (column: (typeof stableColumns)[0]): string => + String(column.id ?? column.accessorKey ?? ''); + + const widths: number[] = []; + const columnDetails: Array<{ + key: string; + id: string; + finalWidth: number; + source: string; + metaSize: string | number | undefined; + }> = []; + const result = stableColumns.reduce( + (acc, column) => { + const key = keyFor(column); + if (!key) { + return acc; + } + + const explicitPriority = column.meta?.priority; + const priority = explicitPriority ?? 1; // uniform default + + // Check for fixed size first + let finalWidth: number; + let source = 'calculated'; + const metaSize = column.meta?.size; + + if (metaSize && typeof metaSize === 'string' && metaSize.includes('px')) { + finalWidth = (parseFloat(metaSize) / containerWidth) * 100; // Convert px to % + source = 'fixed px'; + } else if (isSmallScreen && column.meta?.mobileSize) { + const mobileSize = column.meta.mobileSize; + if (typeof mobileSize === 'string' && mobileSize.includes('px')) { + finalWidth = (parseFloat(mobileSize) / containerWidth) * 100; + source = 'mobile px'; + } else { + finalWidth = parseFloat(mobileSize as string) || (priority / totalPriority) * 100; + source = 'mobile %'; + } + } else { + // Compute relative width as percentage + const ratio = priority / totalPriority; + finalWidth = Math.max(5, Math.min(60, ratio * 100)); // 5-60% range + source = 'calculated %'; + } + + // Determine minWidth - prefer explicit, fallback to standard + let minWidth: string | number | undefined; + if (column.meta?.minWidth) { + minWidth = column.meta.minWidth; + } else { + minWidth = 'min-content'; + } + + widths.push(finalWidth); + columnDetails.push({ key, id: column.id || '', finalWidth, source, metaSize }); + + acc[key] = { + width: `${finalWidth}%`, + minWidth, + maxWidth: column.meta?.size || 'none', + flex: `0 0 ${finalWidth}%`, // For flex fallback + // Ensure content doesn't break layout + overflow: 'hidden', + whiteSpace: 'nowrap', + } as React.CSSProperties; + + return acc; + }, + {} as Record, + ); + + // Diagnostic log: Check total width allocation + const totalWidth = widths.reduce((sum, w) => sum + w, 0); + + // Only log in development and when there are issues + if (process.env.NODE_ENV === 'development' && totalWidth > 100) { + console.warn( + '[DataTable Debug] WARNING: Column widths exceed 100% - potential horizontal overflow!', + { containerWidth, columnDetails, totalWidth }, + ); + } + + return result; + }, [stableColumns, isSmallScreen, containerRef]); +}; + +// Legacy support hook - wraps dynamic widths with backward compatibility +const useColumnStyles = ( + columns: TableColumn[], + isSmallScreen: boolean, + containerRef: React.RefObject, +): ReturnType => { + return useDynamicColumnWidths(columns, containerRef, isSmallScreen); +}; + +const TableRowComponent = ({ + row, + columnStyles, + index, + virtualIndex, +}: { + row: Row; + columnStyles: Record; + index: number; + virtualIndex?: number; +}) => { + const handleSelection = useCallback( + (value: boolean) => { + startTransition(() => { + row.toggleSelected(value); + }); + }, + [row], + ); + + return ( + + {row.getVisibleCells().map((cell) => { + if (cell.column.id === 'select') { + return ( + + + + ); + } + + return ( + +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ ); + })} +
+ ); +}; + +const MemoizedTableRow = memo(TableRowComponent) as typeof TableRowComponent; + +const DeleteButton = memo( + ({ + onDelete, + isDeleting, + disabled, + isSmallScreen, + }: { + onDelete?: () => Promise; + isDeleting: boolean; + disabled: boolean; + isSmallScreen: boolean; + }) => { + if (!onDelete) return null; + + return ( + + ); + }, +); + +DeleteButton.displayName = 'DeleteButton'; + +/** + * Dynamic skeleton rows that match final column proportions to prevent CLS + * Uses same priority-based width calculation as real content + */ +const SkeletonRows = memo(function ({ + count = 10, + columns, + columnStyles, + containerRef, +}: { + count?: number; + columns: TableColumn[]; + columnStyles: Record; + containerRef: React.RefObject; +}) { + if (!columns.length || !containerRef.current) { + return null; + } + + const containerWidth = containerRef.current.clientWidth; + + // Calculate total priority for skeleton width distribution + const totalPriority = columns.reduce((sum, column) => { + const explicitPriority = column.meta?.priority; + const priority = explicitPriority ?? 1; // uniform default + return sum + priority; + }, 0); + + if (totalPriority === 0) { + return null; + } + + // Helper function to get column key - same as in useDynamicColumnWidths + const keyFor = (column: TableColumn): string => + String(column.id ?? column.accessorKey ?? ''); + + return ( + <> + {Array.from({ length: count }, (_, index) => ( + + {columns.map((column, colIndex) => { + const columnKey = keyFor(column); + const baseStyle = columnStyles[columnKey] || {}; + const explicitPriority = column.meta?.priority; + const priority = explicitPriority ?? 1; // uniform default + // Use priority-based width for all skeleton cells + const skeletonWidth = getSkeletonWidth(priority, containerWidth, totalPriority); + const skeletonStyle: React.CSSProperties = { + ...baseStyle, + width: skeletonWidth, + minWidth: skeletonWidth, + height: '48px', + }; + + return ( + + + + ); + })} + + ))} + + ); +}); + +SkeletonRows.displayName = 'SkeletonRows'; + +/** + * Comprehensive configuration object for DataTable features. + * Consolidates all individual props into nested sections for better maintainability and type safety. + * + * @example + * const config = { + * selection: { enableRowSelection: true, showCheckboxes: false }, + * search: { enableSearch: true, debounce: 500, filterColumn: 'name' }, + * skeleton: { count: 5 }, + * virtualization: { overscan: 15 }, + * pinning: { enableColumnPinning: true } + * }; + * + * Defaults: enableRowSelection: true, showCheckboxes: true, enableSearch: true, + * skeleton.count: 10, virtualization.overscan: 10, search.debounce: 300, + * pinning.enableColumnPinning: false + */ +interface DataTableConfig { + /** + * Selection configuration for row selection features. + * Controls checkbox visibility and row selection behavior. + */ + selection?: { + /** + * Enable row selection functionality across the table. + * When true, rows can be selected via checkboxes or keyboard navigation. + * @default true + */ + enableRowSelection?: boolean; + + /** + * Show checkboxes in the first column for row selection. + * Requires enableRowSelection to be true. Affects both header (select all) and row-level checkboxes. + * @default true + */ + showCheckboxes?: boolean; + }; + + /** + * Search configuration for filtering functionality. + * Enables search input with debounced filtering on specified column. + */ + search?: { + /** + * Enable search input and filtering capabilities. + * When true, displays AnimatedSearchInput above the table for filtering data. + * Requires filterColumn to be specified for column-specific filtering. + * @default true + */ + enableSearch?: boolean; + + /** + * Debounce delay for search input in milliseconds. + * Controls how long to wait after user stops typing before filtering the table. + * Higher values reduce re-renders but may feel less responsive. + * @default 300 + */ + debounce?: number; + + /** + * Column key to filter search results on. + * Must match a column accessorKey. Search will filter rows where this column's value contains the search term. + * Required when enableSearch is true. + * @example 'name', 'email', 'id' + */ + filterColumn?: string; + }; + + /** + * Skeleton configuration for loading states. + * Controls the number of skeleton rows shown while data is loading. + */ + skeleton?: { + /** + * Number of skeleton rows to display during initial loading or data fetching. + * Skeleton rows provide visual feedback and maintain table layout consistency. + * @default 10 + */ + count?: number; + }; + + /** + * Virtualization configuration for scroll performance. + * Controls virtual scrolling behavior for large datasets. + */ + virtualization?: { + /** + * Number of additional rows to render outside the visible viewport. + * Higher values improve scroll smoothness but increase memory usage. + * Recommended range: 5-20 depending on row complexity and dataset size. + * @default 10 + */ + overscan?: number; + }; + + /** + * Column pinning configuration for sticky column behavior. + * Controls whether columns can be pinned to left or right side of the table. + */ + pinning?: { + /** + * Enable column pinning functionality. + * When true, columns can be pinned to the left or right side of the table. + * Pinned columns remain visible during horizontal scrolling. + * @default false + */ + enableColumnPinning?: boolean; + }; +} + +function useDebounced(value: T, delay: number) { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const id = setTimeout(() => setDebounced(value), delay); + return () => clearTimeout(id); + }, [value, delay]); + return debounced; +} + +// Optimized row selection state with deep comparison +const useOptimizedRowSelection = (initialSelection: Record = {}) => { + const [selection, setSelection] = useState(initialSelection); + + const optimizedSelection = useMemo(() => { + return Object.keys(selection).length > 0 ? selection : {}; + }, [selection]); + + const setOptimizedSelection = useCallback( + ( + newSelection: + | Record + | ((prev: Record) => Record), + ) => { + setSelection((prev) => { + const next = typeof newSelection === 'function' ? newSelection(prev) : newSelection; + return deepEqual(prev, next) ? prev : next; + }); + }, + [], + ); + + return [optimizedSelection, setOptimizedSelection] as const; +}; + +interface DataTableProps { + columns: TableColumn[]; + data: TData[]; + className?: string; + isLoading?: boolean; + isFetching?: boolean; + /** + * Configuration object consolidating all feature props. + * See DataTableConfig for detailed structure and defaults. + */ + config?: DataTableConfig; + onDelete?: (selectedRows: TData[]) => Promise; + filterValue?: string; + onFilterChange?: (value: string) => void; + defaultSort?: SortingState; + isFetchingNextPage?: boolean; + hasNextPage?: boolean; + fetchNextPage?: () => Promise; + onError?: (error: Error) => void; + onReset?: () => void; + sorting?: SortingState; + onSortingChange?: (updater: SortingState | ((old: SortingState) => SortingState)) => void; +} + +/** + * DataTable renders a virtualized, searchable table with selection and infinite loading. + * Optimized for performance with memoization, virtual scrolling, and efficient state management. + */ +export default function DataTable({ + columns, + data, + className = '', + isLoading = false, + isFetching = false, + config, + onDelete, + filterValue = '', + onFilterChange, + defaultSort = [], + isFetchingNextPage = false, + hasNextPage = false, + fetchNextPage, + onError, + onReset, + sorting, + onSortingChange, +}: DataTableProps) { + const renderCountRef = useRef(0); + // const prevPropsRef = useRef(null); // Disabled with debug logging + + const tableConfig = useMemo(() => { + return { + enableRowSelection: config?.selection?.enableRowSelection ?? true, + showCheckboxes: config?.selection?.showCheckboxes ?? true, + enableSearch: config?.search?.enableSearch ?? true, + filterColumn: config?.search?.filterColumn, + skeletonCount: config?.skeleton?.count ?? 10, + overscan: config?.virtualization?.overscan ?? DATA_TABLE_CONSTANTS.OVERS_CAN, + debounceDelay: config?.search?.debounce ?? DATA_TABLE_CONSTANTS.SEARCH_DEBOUNCE_MS, + }; + }, [config]); + + const { + enableRowSelection, + showCheckboxes, + enableSearch, + // filterColumn, // Disabled with debug logging + skeletonCount, + overscan, + debounceDelay, + } = tableConfig; + + const localize = useLocalize(); + const isSmallScreen = useMediaQuery('(max-width: 768px)'); + const tableContainerRef = useRef(null); + + const [columnVisibility, setColumnVisibility] = useState({}); + const [optimizedRowSelection, setOptimizedRowSelection] = useOptimizedRowSelection(); + const [rawTerm, setRawTerm] = useState(filterValue ?? ''); + const [isImmediateSearch, setIsImmediateSearch] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [error, setError] = useState(null); + const [isSearching, setIsSearching] = useState(false); + + const debouncedTerm = useDebounced(rawTerm, debounceDelay); + + const isTransitioning = isFetching || isImmediateSearch; + + // Track when we're waiting for search results + const isWaitingForSearchResults = isSearching && isFetching; + const isFirstLoad = isLoading && data.length === 0; + const isRefreshing = isFetching && !isFirstLoad && !isFetchingNextPage && !isSearching; + + // Show skeletons during initial load, refresh, search, or transitioning + // Exclude isFetchingNextPage to prevent skeletons during infinite scroll + const showSkeletons = + isFirstLoad || + isRefreshing || + isWaitingForSearchResults || + (isTransitioning && !isFetchingNextPage) || // Don't show skeletons during infinite scroll + rawTerm !== debouncedTerm; // Show during typing + + // External sorting support: use provided sorting state, fall back to internal + const [internalSorting, setInternalSorting] = useState(defaultSort); + const finalSorting = sorting ?? internalSorting; + + // Mount tracking for cleanup - Fixed: Declare mountedRef before any callback that uses it + const mountedRef = useRef(true); + + // Sync internal sorting with defaultSort changes + useEffect(() => { + if (!sorting) setInternalSorting(defaultSort); + }, [defaultSort, sorting]); + + // Keep search input in sync with external filterValue + useEffect(() => { + setRawTerm(filterValue ?? ''); + }, [filterValue]); + + // Sorting handler: call external callback if provided, otherwise use internal state + const handleSortingChangeInternal = useCallback( + (updater: SortingState | ((old: SortingState) => SortingState)) => { + startTransition(() => { + const newSorting = typeof updater === 'function' ? updater(finalSorting) : updater; + setInternalSorting(newSorting); + onSortingChange?.(newSorting); + }); + }, + [finalSorting, onSortingChange], + ); + const handleSortingChange = onSortingChange ?? handleSortingChangeInternal; + + renderCountRef.current += 1; + + const sanitizeError = useCallback((err: Error): string => { + const message = err.message; + if (message?.includes('auth') || message?.includes('token')) { + return 'Authentication failed. Please log in again.'; + } + return process.env.NODE_ENV === 'development' + ? message + : 'An error occurred. Please try again.'; + }, []); + + const tableColumns = useMemo(() => { + if (!enableRowSelection || !showCheckboxes) return columns; + + const selectColumn: TableColumn = { + id: 'select', + header: ({ table }: { table: TTable }) => ( +
+ table.toggleAllPageRowsSelected(Boolean(value))} + aria-label="Select all rows" + /> +
+ ), + cell: ({ row }) => ( + row.toggleSelected(value)} + ariaLabel={`Select row ${row.index + 1}`} + /> + ), + meta: { + size: DATA_TABLE_CONSTANTS.SELECT_COLUMN_SIZE, + minWidth: DATA_TABLE_CONSTANTS.SELECT_COLUMN_MIN_WIDTH, + }, + }; + + return [selectColumn, ...columns]; + }, [columns, enableRowSelection, showCheckboxes]); + + // Dynamic column styles with priority-based sizing + const columnStyles = useColumnStyles( + tableColumns as TableColumn[], + isSmallScreen, + tableContainerRef, + ); + + // Set CSS variables for column sizing with hash optimization - prevent re-render loops + const columnSizesHashRef = useRef(''); + const prevTableColumnsRef = useRef([]); + + useLayoutEffect(() => { + if (typeof window === 'undefined' || !tableContainerRef.current) return; + + // Only update if columns or styles actually changed + const columnsChanged = prevTableColumnsRef.current.length !== tableColumns.length; + + // Calculate hash of column sizes to avoid unnecessary DOM writes + const sizesHash = tableColumns + .map((col, index) => `${index}:${columnStyles[col.id!]?.width || 'auto'}`) + .join('|'); + + if (columnsChanged || sizesHash !== columnSizesHashRef.current) { + columnSizesHashRef.current = sizesHash; + prevTableColumnsRef.current = tableColumns; + + // Batch DOM updates to prevent layout thrashing + requestAnimationFrame(() => { + if (tableContainerRef.current) { + tableColumns.forEach((column, index) => { + if (column.id) { + const size = columnStyles[column.id]?.width || 'auto'; + tableContainerRef.current!.style.setProperty(`--col-${index}-size`, `${size}`); + } + }); + } + }); + } + }, [tableColumns, columnStyles]); + + // Memoized row data with stable references - deep comparison to prevent unnecessary re-renders + const memoizedRowData = useMemo(() => { + return data.map((item, index) => ({ + ...item, + _index: index, + _id: (item as Record)?.id || index, + })); + }, [data]); + + // React Table instance + const tableData = memoizedRowData; + + const table = useReactTable({ + data: tableData, + columns: tableColumns, + getCoreRowModel: getCoreRowModel(), + enableRowSelection, + enableMultiRowSelection: true, + manualSorting: true, // Use manual sorting for server-side sorting + manualFiltering: true, + state: { + sorting: finalSorting, + columnVisibility, + rowSelection: optimizedRowSelection, + }, + onSortingChange: handleSortingChange, + onColumnVisibilityChange: setColumnVisibility, + onRowSelectionChange: setOptimizedRowSelection, + }); + + const { rows } = table.getRowModel(); + + // Fixed: Simplify header groups - React Table already memoizes internally + const headerGroups = table.getHeaderGroups(); + + // Virtual scrolling setup with optimized height measurement + const measuredHeightsRef = useRef([]); + const measureElementCallback = useCallback((el: Element | null) => { + if (!el) return DATA_TABLE_CONSTANTS.ROW_HEIGHT_ESTIMATE; + + const height = el.getBoundingClientRect().height; + + // Memory management for measured heights - only update if significantly different + const lastHeight = measuredHeightsRef.current[measuredHeightsRef.current.length - 1]; + if (!lastHeight || Math.abs(height - lastHeight) > 1) { + measuredHeightsRef.current = [ + ...measuredHeightsRef.current.slice(-DATA_TABLE_CONSTANTS.MEASURED_HEIGHTS_TRIM + 1), + height, + ]; + + // Trim if exceeds max + if (measuredHeightsRef.current.length > DATA_TABLE_CONSTANTS.MAX_MEASURED_HEIGHTS) { + measuredHeightsRef.current = measuredHeightsRef.current.slice( + -DATA_TABLE_CONSTANTS.MEASURED_HEIGHTS_TRIM, + ); + } + } + + return height; + }, []); + + const rowVirtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: useCallback(() => { + // SSR safety: return null during SSR + if (typeof window === 'undefined') return null; + return tableContainerRef.current; + }, []), + estimateSize: useCallback(() => { + const heights = measuredHeightsRef.current; + if (heights.length > 0) { + const avg = heights.reduce((a, b) => a + b, 0) / heights.length; + return Math.max(avg, DATA_TABLE_CONSTANTS.ROW_HEIGHT_ESTIMATE); + } + return DATA_TABLE_CONSTANTS.ROW_HEIGHT_ESTIMATE; + }, []), + overscan, + // Fixed: Optimize measureElement to avoid duplicate getBoundingClientRect calls + measureElement: measureElementCallback, + }); + + const virtualRows = rowVirtualizer.getVirtualItems(); + const totalSize = rowVirtualizer.getTotalSize(); + const paddingTop = virtualRows.length > 0 ? virtualRows[0].start : 0; + const paddingBottom = + virtualRows.length > 0 ? totalSize - virtualRows[virtualRows.length - 1].end : 0; + + // Virtual rows with stable keys for better performance + const virtualRowsWithStableKeys = useMemo( + () => + virtualRows.map((vRow) => ({ + ...vRow, + stableKey: `${vRow.index}-${rows[vRow.index]?.id || vRow.index}`, + })), + [virtualRows, rows], + ); + + // Store scroll position before fetching to prevent jumping + const scrollPositionRef = useRef<{ top: number; timestamp: number } | null>(null); + const isRestoringScrollRef = useRef(false); + + // Fixed: Infinite scrolling with scroll position preservation + const handleScrollInternal = useCallback(async () => { + if (!mountedRef.current || !tableContainerRef.current) return; + + // Early return if conditions not met + if (!hasNextPage || isFetchingNextPage || isRestoringScrollRef.current) return; + + const scrollElement = tableContainerRef.current; + const { scrollTop, scrollHeight, clientHeight } = scrollElement; + + // More precise threshold calculation + const scrollThreshold = + scrollHeight - clientHeight * DATA_TABLE_CONSTANTS.INFINITE_SCROLL_THRESHOLD; + const nearEnd = scrollTop >= scrollThreshold; + + if (nearEnd) { + try { + // Store current scroll position before fetching + scrollPositionRef.current = { + top: scrollTop, + timestamp: Date.now(), + }; + + await fetchNextPage?.(); + } catch (err) { + // Clear stored position on error + scrollPositionRef.current = null; + const rawError = err instanceof Error ? err : new Error('Failed to fetch next page'); + const sanitizedMessage = sanitizeError(rawError); + const sanitizedError = new Error(sanitizedMessage); + setError(sanitizedError); + onError?.(sanitizedError); + } + } + }, [fetchNextPage, onError, sanitizeError, hasNextPage, isFetchingNextPage]); + + const throttledHandleScroll = useMemo( + () => throttle(handleScrollInternal, DATA_TABLE_CONSTANTS.SCROLL_THROTTLE_MS), + [handleScrollInternal], + ); + + // Scroll position restoration effect - prevents jumping when new data is added + useEffect(() => { + if (!isFetchingNextPage && scrollPositionRef.current && tableContainerRef.current) { + const { top, timestamp } = scrollPositionRef.current; + const isRecent = Date.now() - timestamp < 1000; // Only restore if within 1 second + + if (isRecent) { + isRestoringScrollRef.current = true; + + // Use requestAnimationFrame to ensure DOM has updated + requestAnimationFrame(() => { + if (tableContainerRef.current && mountedRef.current) { + const scrollElement = tableContainerRef.current; + const maxScroll = scrollElement.scrollHeight - scrollElement.clientHeight; + const targetScroll = Math.min(top, maxScroll); + + scrollElement.scrollTo({ + top: targetScroll, + behavior: 'auto', // Instant restoration + }); + + // Clear restoration flag after a brief delay + setTimeout(() => { + isRestoringScrollRef.current = false; + }, 100); + } + }); + } + + // Clear stored position + scrollPositionRef.current = null; + } + }, [isFetchingNextPage, data.length]); // Trigger when fetch completes or data changes + + // Always attach scroll listener with optimized event handling to reduce GC pressure + useEffect(() => { + const scrollElement = tableContainerRef.current; + if (!scrollElement) return; + + // Pre-bind the handler to avoid creating new functions on each scroll + const scrollHandler = throttledHandleScroll; + + // Use passive listener for better performance + const options = { passive: true }; + scrollElement.addEventListener('scroll', scrollHandler, options); + + return () => { + scrollElement.removeEventListener('scroll', scrollHandler); + }; + }, [throttledHandleScroll]); + + // Resize observer for virtualizer revalidation - heavily throttled to prevent rapid re-renders + const handleWindowResize = useCallback(() => { + // Debounce resize to prevent rapid re-renders + if (resizeTimeoutRef.current) { + clearTimeout(resizeTimeoutRef.current); + } + resizeTimeoutRef.current = setTimeout(() => { + if (mountedRef.current) { + rowVirtualizer.measure(); + } + }, 250); // Increased delay significantly + }, [rowVirtualizer]); + + const resizeTimeoutRef = useRef | null>(null); + + // Optimized resize observer with reduced layout thrashing + useEffect(() => { + if (typeof window === 'undefined') return; + + const scrollElement = tableContainerRef.current; + if (!scrollElement) return; + + // Increased throttling to reduce excessive re-renders and GC pressure + let resizeTimeout: ReturnType | null = null; + let lastResizeTime = 0; + const MIN_RESIZE_INTERVAL = 500; // Increased from 250ms to reduce GC pressure + + const resizeObserver = new ResizeObserver(() => { + const now = Date.now(); + if (now - lastResizeTime < MIN_RESIZE_INTERVAL) return; + + lastResizeTime = now; + if (resizeTimeout) clearTimeout(resizeTimeout); + + resizeTimeout = setTimeout(() => { + if (mountedRef.current && !isRestoringScrollRef.current) { + // Only measure if not currently restoring scroll position + rowVirtualizer.measure(); + } + }, MIN_RESIZE_INTERVAL); + }); + + resizeObserver.observe(scrollElement); + + // Optimized window resize handler with increased debouncing + const windowResizeHandler = () => { + if (resizeTimeoutRef.current) { + clearTimeout(resizeTimeoutRef.current); + } + resizeTimeoutRef.current = setTimeout(() => { + if (mountedRef.current && !isRestoringScrollRef.current) { + rowVirtualizer.measure(); + } + }, MIN_RESIZE_INTERVAL); + }; + + window.addEventListener('resize', windowResizeHandler, { passive: true }); + + return () => { + if (resizeTimeout) clearTimeout(resizeTimeout); + if (resizeTimeoutRef.current) clearTimeout(resizeTimeoutRef.current); + resizeObserver.disconnect(); + window.removeEventListener('resize', windowResizeHandler); + }; + }, [rowVirtualizer, handleWindowResize]); + + useLayoutEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + // Optimized dynamic measurement - prevent infinite loops with better guards and reduced frequency + const lastMeasurementRef = useRef<{ + dataLength: number; + isSmallScreen: boolean; + virtualRowsLength: number; + isTransitioning: boolean; + timestamp: number; + }>({ + dataLength: 0, + isSmallScreen: false, + virtualRowsLength: 0, + isTransitioning: false, + timestamp: 0, + }); + + useLayoutEffect(() => { + if (typeof window === 'undefined' || isTransitioning || isRestoringScrollRef.current) return; + + const current = { + dataLength: data.length, + isSmallScreen, + virtualRowsLength: virtualRowsWithStableKeys.length, + isTransitioning, + timestamp: Date.now(), + }; + + const timeSinceLastMeasurement = current.timestamp - lastMeasurementRef.current.timestamp; + + // Only measure if something meaningful changed AND enough time has passed + const hasChanged = + current.dataLength !== lastMeasurementRef.current.dataLength || + current.isSmallScreen !== lastMeasurementRef.current.isSmallScreen || + current.virtualRowsLength !== lastMeasurementRef.current.virtualRowsLength; + + // Increased throttle to 200ms to reduce GC pressure and improve performance + if (!hasChanged || timeSinceLastMeasurement < 200) return; + + lastMeasurementRef.current = current; + + if (mountedRef.current && tableContainerRef.current && virtualRows.length > 0) { + // Increased debounce delay to reduce re-renders + const measurementTimeout = setTimeout(() => { + if (mountedRef.current && !isRestoringScrollRef.current) { + rowVirtualizer.measure(); + } + }, 100); + + return () => clearTimeout(measurementTimeout); + } + }, [ + data.length, + rowVirtualizer, + isSmallScreen, + virtualRowsWithStableKeys.length, + virtualRows.length, + isTransitioning, + ]); + + // Search effect with optimized state updates + useEffect(() => { + if (rawTerm !== filterValue) { + setIsImmediateSearch(true); + } + }, [rawTerm, filterValue]); + + useEffect(() => { + if (debouncedTerm !== filterValue) { + setIsSearching(true); + startTransition(() => { + onFilterChange?.(debouncedTerm); + }); + } + }, [debouncedTerm, onFilterChange, filterValue]); + + useEffect(() => { + if (!isFetching && isImmediateSearch) { + setIsImmediateSearch(false); + } + }, [isFetching, isImmediateSearch]); + + useEffect(() => { + if (!isFetching && isSearching) { + setIsSearching(false); + } + }, [isFetching, isSearching]); + + // Optimized delete handler with batch operations + const handleDelete = useCallback(async () => { + if (!onDelete || isDeleting) return; + + setIsDeleting(true); + setError(null); + + try { + // Use getSelectedRowModel instead of getFilteredSelectedRowModel + const selectedRowsLength = table.getSelectedRowModel().rows.length; + let selectedRows = table.getSelectedRowModel().rows.map((r) => r.original); + + // Validation + if (selectedRows.length === 0) { + setIsDeleting(false); + return; + } + + // Filter out non-object entries + selectedRows = selectedRows.filter( + (row): row is TData => typeof row === 'object' && row !== null, + ); + + // TODO: Remove this - only for development + if (selectedRows.length !== selectedRowsLength && true) { + console.warn('DataTable: Invalid row data detected and filtered out during deletion.'); + } + + await onDelete(selectedRows); + + // Batch state updates + startTransition(() => { + table.resetRowSelection(); + setOptimizedRowSelection({}); + }); + } catch (err) { + const rawError = err instanceof Error ? err : new Error('Failed to delete items'); + const sanitizedMessage = sanitizeError(rawError); + const sanitizedError = new Error(sanitizedMessage); + setError(sanitizedError); + onError?.(sanitizedError); + } finally { + setIsDeleting(false); + } + }, [onDelete, table, isDeleting, onError, sanitizeError, setOptimizedRowSelection]); + + // Reset handler for error boundary retry + const handleBoundaryReset = useCallback(() => { + setError(null); + onReset?.(); + // Re-measure virtualizer after reset + if (tableContainerRef.current && rowVirtualizer) { + rowVirtualizer.measure(); + } + }, [onReset, rowVirtualizer]); + + const selectedCount = useMemo(() => { + const selection = optimizedRowSelection; + return Object.keys(selection).length; + }, [optimizedRowSelection]); + + const shouldShowSearch = useMemo( + () => enableSearch && !!onFilterChange, + [enableSearch, onFilterChange], + ); + + return ( +
+ {/* Accessible live region for loading announcements */} +
+ {(() => { + if (isSearching) return 'Searching...'; + if (isFetchingNextPage) return 'Loading more rows'; + if (hasNextPage) return 'More rows available'; + return 'All rows loaded'; + })()} +
+ + {/* Error display - kept outside boundary for non-rendering errors */} + {error && ( +
+ {sanitizeError(error)} + +
+ )} + + {/* Isolated Toolbar - Outside scroll container */} +
+
+ {enableRowSelection && showCheckboxes && ( + + )} + + {shouldShowSearch && ( +
+ { + startTransition(() => setRawTerm(e.target.value)); + }} + isSearching={isWaitingForSearchResults} + placeholder="Search..." + aria-label="Search table data" + /> +
+ )} + + {selectedCount > 0 && ( +
{`${selectedCount} selected`}
+ )} +
+
+ + {/* Error boundary wraps the scrollable table container */} + +
{ + if (isFetchingNextPage) { + e.preventDefault(); + return; + } + if (isTransitioning) { + e.deltaY *= 0.5; + } + }} + role="grid" + aria-label="Data grid" + aria-rowcount={data.length} + aria-busy={isLoading || isFetching || isFetchingNextPage} + > +
+ + {/* Sticky Table Header - Fixed z-index and positioning */} + + {headerGroups.map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const sortDir = header.column.getIsSorted(); + const canSort = header.column.getCanSort(); + + let ariaSort: 'ascending' | 'descending' | 'none' | undefined; + if (!canSort) { + ariaSort = undefined; + } else if (sortDir === 'asc') { + ariaSort = 'ascending'; + } else if (sortDir === 'desc') { + ariaSort = 'descending'; + } else { + ariaSort = 'none'; + } + + return ( + { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + header.column.toggleSorting(); + } + } + : undefined + } + className={cn( + 'group relative box-border whitespace-nowrap bg-surface-secondary px-2 py-3 text-left text-sm font-medium text-text-secondary transition-colors duration-200 sm:px-4', + canSort && + 'cursor-pointer hover:bg-surface-hover focus-visible:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary', + )} + style={{ + ...columnStyles[header.column.id], + backgroundColor: 'var(--surface-secondary)', + position: 'sticky', + top: 0, + boxSizing: 'border-box', + maxWidth: 'none', + }} + role="columnheader" + scope="col" + tabIndex={canSort ? 0 : -1} + aria-sort={ariaSort} + > +
+ + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + {canSort && ( + + {!sortDir && ( + + )} + {sortDir === 'asc' && } + {sortDir === 'desc' && ( + + )} + + )} +
+
+ ); + })} +
+ ))} +
+ + + {paddingTop > 0 && ( + + + )} + + {showSkeletons ? ( + []} + columnStyles={columnStyles} + containerRef={tableContainerRef} + /> + ) : ( + virtualRowsWithStableKeys.map((virtualRow) => { + const row = rows[virtualRow.index]; + return ( + + ); + }) + )} + + {data.length === 0 && !isLoading && ( + + + {debouncedTerm ? localize('com_ui_no_results_found') : 'No data available'} + + + )} + + {paddingBottom > 0 && ( + + + )} + + {/* Loading indicator for infinite scroll */} + {(isFetchingNextPage || hasNextPage) && !isLoading && ( + + +
+ {isFetchingNextPage ? ( + + ) : ( + hasNextPage &&
+ )} +
+ + + )} + +
+
+
+
diff --git a/packages/client/src/components/index.ts b/packages/client/src/components/index.ts index d1de8a853..23a49117c 100644 --- a/packages/client/src/components/index.ts +++ b/packages/client/src/components/index.ts @@ -31,6 +31,7 @@ export * from './InputOTP'; export * from './MultiSearch'; export * from './Resizable'; export * from './Select'; +export { default as DataTableErrorBoundary } from './DataTable/DataTableErrorBoundary'; export { default as Radio } from './Radio'; export { default as Badge } from './Badge'; export { default as Avatar } from './Avatar';