diff --git a/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx b/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx index fb291307b..b3832773a 100644 --- a/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx +++ b/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx @@ -611,33 +611,52 @@ export const TableColumnTextContainer = ( : props.rowIndex === props.data.length - 1); useEffect(() => { - if (!isDataRow || !containerRef.current) return; + if (!isDataRow || !containerRef.current || !props.enableRowHoverHighlight) return; const container = containerRef.current; const rowIndex = props.rowIndex; + const rowSelector = `[data-row-index="${props.tableId}-${rowIndex}"]`; + let rafId: null | number = null; + let cachedCells: NodeListOf | null = null; + + const getCells = () => { + if (!cachedCells) { + cachedCells = document.querySelectorAll(rowSelector); + } + return cachedCells; + }; const handleMouseEnter = () => { - // Find all cells in the same row and add hover class - const allCells = document.querySelectorAll( - `[data-row-index="${props.tableId}-${rowIndex}"]`, - ); - allCells.forEach((cell) => cell.classList.add(styles.rowHovered)); + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + rafId = requestAnimationFrame(() => { + const cells = getCells(); + cells.forEach((cell) => cell.classList.add(styles.rowHovered)); + }); }; const handleMouseLeave = () => { - // Remove hover class from all cells in the same row - const allCells = document.querySelectorAll( - `[data-row-index="${props.tableId}-${rowIndex}"]`, - ); - allCells.forEach((cell) => cell.classList.remove(styles.rowHovered)); + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + rafId = requestAnimationFrame(() => { + const cells = getCells(); + cells.forEach((cell) => cell.classList.remove(styles.rowHovered)); + cachedCells = null; + }); }; container.addEventListener('mouseenter', handleMouseEnter); container.addEventListener('mouseleave', handleMouseLeave); return () => { + if (rafId !== null) { + cancelAnimationFrame(rafId); + } container.removeEventListener('mouseenter', handleMouseEnter); container.removeEventListener('mouseleave', handleMouseLeave); + cachedCells = null; }; }, [isDataRow, props.rowIndex, props.enableRowHoverHighlight, props.tableId]); @@ -647,44 +666,64 @@ export const TableColumnTextContainer = ( const rowIndex = props.rowIndex; const draggedOverState = props.isDraggedOver; + const rowSelector = `[data-row-index="${props.tableId}-${rowIndex}"]`; + let rafId: null | number = null; + let cachedCells: NodeListOf | null = null; - if (draggedOverState) { - // Find all cells in the same row and add dragged over class - const allCells = document.querySelectorAll( - `[data-row-index="${props.tableId}-${rowIndex}"]`, - ); - allCells.forEach((cell, index) => { - if (draggedOverState === 'top') { - cell.classList.add(styles.draggedOverTop); - cell.classList.remove(styles.draggedOverBottom); - // Mark first cell so border can span full width - if (index === 0) { - cell.classList.add(styles.draggedOverFirstCell); - } else { - cell.classList.remove(styles.draggedOverFirstCell); - } - } else if (draggedOverState === 'bottom') { - cell.classList.add(styles.draggedOverBottom); - cell.classList.remove(styles.draggedOverTop); - // Mark first cell so border can span full width - if (index === 0) { - cell.classList.add(styles.draggedOverFirstCell); - } else { - cell.classList.remove(styles.draggedOverFirstCell); - } - } - }); - } else { - // Remove dragged over classes from all cells in the same row - const allCells = document.querySelectorAll( - `[data-row-index="${props.tableId}-${rowIndex}"]`, - ); - allCells.forEach((cell) => { - cell.classList.remove(styles.draggedOverTop); - cell.classList.remove(styles.draggedOverBottom); - cell.classList.remove(styles.draggedOverFirstCell); - }); + const getCells = () => { + if (!cachedCells) { + cachedCells = document.querySelectorAll(rowSelector); + } + return cachedCells; + }; + + if (rafId !== null) { + cancelAnimationFrame(rafId); } + + rafId = requestAnimationFrame(() => { + const cells = getCells(); + + if (draggedOverState) { + cells.forEach((cell, index) => { + if (draggedOverState === 'top') { + cell.classList.add(styles.draggedOverTop); + cell.classList.remove(styles.draggedOverBottom); + // Mark first cell so border can span full width + if (index === 0) { + cell.classList.add(styles.draggedOverFirstCell); + } else { + cell.classList.remove(styles.draggedOverFirstCell); + } + } else if (draggedOverState === 'bottom') { + cell.classList.add(styles.draggedOverBottom); + cell.classList.remove(styles.draggedOverTop); + // Mark first cell so border can span full width + if (index === 0) { + cell.classList.add(styles.draggedOverFirstCell); + } else { + cell.classList.remove(styles.draggedOverFirstCell); + } + } + }); + } else { + // Remove dragged over classes from all cells in the same row + cells.forEach((cell) => { + cell.classList.remove(styles.draggedOverTop); + cell.classList.remove(styles.draggedOverBottom); + cell.classList.remove(styles.draggedOverFirstCell); + }); + // Clear cache when state is cleared + cachedCells = null; + } + }); + + return () => { + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + cachedCells = null; + }; }, [isDataRow, props.rowIndex, props.isDraggedOver, props.tableId]); const handleClick = useDoubleClick({ @@ -824,33 +863,52 @@ export const TableColumnContainer = ( : props.rowIndex === props.data.length - 1); useEffect(() => { - if (!isDataRow || !containerRef.current) return; + if (!isDataRow || !containerRef.current || !props.enableRowHoverHighlight) return; const container = containerRef.current; const rowIndex = props.rowIndex; + const rowSelector = `[data-row-index="${props.tableId}-${rowIndex}"]`; + let rafId: null | number = null; + let cachedCells: NodeListOf | null = null; + + const getCells = () => { + if (!cachedCells) { + cachedCells = document.querySelectorAll(rowSelector); + } + return cachedCells; + }; const handleMouseEnter = () => { - // Find all cells in the same row and add hover class - const allCells = document.querySelectorAll( - `[data-row-index="${props.tableId}-${rowIndex}"]`, - ); - allCells.forEach((cell) => cell.classList.add(styles.rowHovered)); + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + rafId = requestAnimationFrame(() => { + const cells = getCells(); + cells.forEach((cell) => cell.classList.add(styles.rowHovered)); + }); }; const handleMouseLeave = () => { - // Remove hover class from all cells in the same row - const allCells = document.querySelectorAll( - `[data-row-index="${props.tableId}-${rowIndex}"]`, - ); - allCells.forEach((cell) => cell.classList.remove(styles.rowHovered)); + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + rafId = requestAnimationFrame(() => { + const cells = getCells(); + cells.forEach((cell) => cell.classList.remove(styles.rowHovered)); + cachedCells = null; + }); }; container.addEventListener('mouseenter', handleMouseEnter); container.addEventListener('mouseleave', handleMouseLeave); return () => { + if (rafId !== null) { + cancelAnimationFrame(rafId); + } container.removeEventListener('mouseenter', handleMouseEnter); container.removeEventListener('mouseleave', handleMouseLeave); + cachedCells = null; }; }, [isDataRow, props.rowIndex, props.enableRowHoverHighlight, props.tableId]); @@ -860,44 +918,64 @@ export const TableColumnContainer = ( const rowIndex = props.rowIndex; const draggedOverState = props.isDraggedOver; + const rowSelector = `[data-row-index="${props.tableId}-${rowIndex}"]`; + let rafId: null | number = null; + let cachedCells: NodeListOf | null = null; - if (draggedOverState) { - // Find all cells in the same row and add dragged over class - const allCells = document.querySelectorAll( - `[data-row-index="${props.tableId}-${rowIndex}"]`, - ); - allCells.forEach((cell, index) => { - if (draggedOverState === 'top') { - cell.classList.add(styles.draggedOverTop); - cell.classList.remove(styles.draggedOverBottom); - // Mark first cell so border can span full width - if (index === 0) { - cell.classList.add(styles.draggedOverFirstCell); - } else { - cell.classList.remove(styles.draggedOverFirstCell); - } - } else if (draggedOverState === 'bottom') { - cell.classList.add(styles.draggedOverBottom); - cell.classList.remove(styles.draggedOverTop); - // Mark first cell so border can span full width - if (index === 0) { - cell.classList.add(styles.draggedOverFirstCell); - } else { - cell.classList.remove(styles.draggedOverFirstCell); - } - } - }); - } else { - // Remove dragged over classes from all cells in the same row - const allCells = document.querySelectorAll( - `[data-row-index="${props.tableId}-${rowIndex}"]`, - ); - allCells.forEach((cell) => { - cell.classList.remove(styles.draggedOverTop); - cell.classList.remove(styles.draggedOverBottom); - cell.classList.remove(styles.draggedOverFirstCell); - }); + const getCells = () => { + if (!cachedCells) { + cachedCells = document.querySelectorAll(rowSelector); + } + return cachedCells; + }; + + if (rafId !== null) { + cancelAnimationFrame(rafId); } + + rafId = requestAnimationFrame(() => { + const cells = getCells(); + + if (draggedOverState) { + cells.forEach((cell, index) => { + if (draggedOverState === 'top') { + cell.classList.add(styles.draggedOverTop); + cell.classList.remove(styles.draggedOverBottom); + // Mark first cell so border can span full width + if (index === 0) { + cell.classList.add(styles.draggedOverFirstCell); + } else { + cell.classList.remove(styles.draggedOverFirstCell); + } + } else if (draggedOverState === 'bottom') { + cell.classList.add(styles.draggedOverBottom); + cell.classList.remove(styles.draggedOverTop); + // Mark first cell so border can span full width + if (index === 0) { + cell.classList.add(styles.draggedOverFirstCell); + } else { + cell.classList.remove(styles.draggedOverFirstCell); + } + } + }); + } else { + // Remove dragged over classes from all cells in the same row + cells.forEach((cell) => { + cell.classList.remove(styles.draggedOverTop); + cell.classList.remove(styles.draggedOverBottom); + cell.classList.remove(styles.draggedOverFirstCell); + }); + // Clear cache when state is cleared + cachedCells = null; + } + }); + + return () => { + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + cachedCells = null; + }; }, [isDataRow, props.rowIndex, props.isDraggedOver, props.tableId]); const handleClick = useDoubleClick({ diff --git a/src/renderer/components/item-list/item-table-list/item-table-list.tsx b/src/renderer/components/item-list/item-table-list/item-table-list.tsx index eca1f6b51..058bb2ef9 100644 --- a/src/renderer/components/item-list/item-table-list/item-table-list.tsx +++ b/src/renderer/components/item-list/item-table-list/item-table-list.tsx @@ -93,6 +93,7 @@ interface VirtualizedTableGridProps { cellPadding: 'lg' | 'md' | 'sm' | 'xl' | 'xs'; controls: ItemControls; data: unknown[]; + dataWithGroups: (null | unknown)[]; enableAlternateRowColors: boolean; enableColumnReorder: boolean; enableColumnResize: boolean; @@ -134,7 +135,8 @@ const VirtualizedTableGrid = ({ CellComponent, cellPadding, controls, - data, + // data, + dataWithGroups, enableAlternateRowColors, enableColumnReorder, enableColumnResize, @@ -185,53 +187,6 @@ const VirtualizedTableGrid = ({ ); }, [pinnedRightColumnCount, pinnedLeftColumnCount, totalColumnCount, columnWidth]); - // Create data array with group headers inserted as null values - // Groups are defined by itemCount, so we calculate indexes based on cumulative item counts - const dataWithGroups = useMemo(() => { - const result: (null | unknown)[] = enableHeader ? [null] : []; - - if (!groups || groups.length === 0) { - // No groups, just add all data - result.push(...data); - return result; - } - - // Calculate group header indexes based on itemCounts - const groupIndexes: number[] = []; - let cumulativeDataIndex = 0; - const headerOffset = enableHeader ? 1 : 0; - - groups.forEach((group, groupIndex) => { - // Group header appears before its items - // Index = header offset + cumulative data index + number of previous group headers - const groupHeaderIndex = headerOffset + cumulativeDataIndex + groupIndex; - groupIndexes.push(groupHeaderIndex); - cumulativeDataIndex += group.itemCount; - }); - - let dataIndex = 0; - const startIndex = enableHeader ? 1 : 0; - let groupHeaderCount = 0; - - // Iterate through the expanded row space (data + group headers) - for ( - let rowIndex = startIndex; - rowIndex < startIndex + data.length + groupIndexes.length; - rowIndex++ - ) { - // Check if this row should have a group header - const expectedGroupIndex = groupIndexes[groupHeaderCount]; - if (expectedGroupIndex !== undefined && rowIndex === expectedGroupIndex) { - result.push(null); // Group header row - groupHeaderCount++; - } else if (dataIndex < data.length) { - result.push(data[dataIndex]); - dataIndex++; - } - } - return result; - }, [data, enableHeader, groups]); - const adjustedRowIndexMap = useMemo(() => { const map = new Map(); @@ -275,71 +230,94 @@ const VirtualizedTableGrid = ({ return map; }, [dataWithGroups, enableHeader, groups]); - const itemProps: TableItemProps = useMemo( + const stableConfigProps = useMemo( + () => ({ + cellPadding, + columns: parsedColumns, + controls, + enableHeader, + getRowHeight, + internalState, + itemType, + playerContext, + size, + tableId, + }), + [ + cellPadding, + parsedColumns, + controls, + enableHeader, + getRowHeight, + internalState, + itemType, + playerContext, + size, + tableId, + ], + ); + + const dynamicDataProps = useMemo( () => ({ activeRowId, adjustedRowIndexMap, calculatedColumnWidths, - cellPadding, - columns: parsedColumns, - controls, data: dataWithGroups, - enableAlternateRowColors, - enableColumnReorder, - enableColumnResize, - enableDrag, - enableExpansion, - enableHeader, - enableHorizontalBorders, - enableRowHoverHighlight, - enableSelection, - enableVerticalBorders, - getRowHeight, - groups, - internalState, - itemType, pinnedLeftColumnCount, pinnedLeftColumnWidths, pinnedRightColumnCount, pinnedRightColumnWidths, - playerContext, - size, startRowIndex, - tableId, }), [ activeRowId, adjustedRowIndexMap, calculatedColumnWidths, - cellPadding, - parsedColumns, - controls, dataWithGroups, + pinnedLeftColumnCount, + pinnedLeftColumnWidths, + pinnedRightColumnCount, + pinnedRightColumnWidths, + startRowIndex, + ], + ); + + const featureFlags = useMemo( + () => ({ + enableAlternateRowColors, + enableColumnReorder, + enableColumnResize, + enableDrag, + enableExpansion, + enableHorizontalBorders, + enableRowHoverHighlight, + enableSelection, + enableVerticalBorders, + groups, + }), + [ enableAlternateRowColors, enableColumnReorder, enableColumnResize, enableDrag, enableExpansion, - enableHeader, enableHorizontalBorders, enableRowHoverHighlight, enableSelection, enableVerticalBorders, - getRowHeight, groups, - internalState, - itemType, - pinnedLeftColumnCount, - pinnedLeftColumnWidths, - pinnedRightColumnCount, - pinnedRightColumnWidths, - playerContext, - size, - startRowIndex, - tableId, ], ); + const itemProps: TableItemProps = useMemo( + () => ({ + ...stableConfigProps, + ...dynamicDataProps, + ...featureFlags, + }), + [stableConfigProps, dynamicDataProps, featureFlags], + ); + const PinnedRowCell = useCallback( (cellProps: CellComponentProps & TableItemProps) => { return ( @@ -612,6 +590,53 @@ const VirtualizedTableGrid = ({ VirtualizedTableGrid.displayName = 'VirtualizedTableGrid'; +const MemoizedVirtualizedTableGrid = memo(VirtualizedTableGrid, (prevProps, nextProps) => { + return ( + prevProps.activeRowId === nextProps.activeRowId && + prevProps.calculatedColumnWidths === nextProps.calculatedColumnWidths && + prevProps.cellPadding === nextProps.cellPadding && + prevProps.controls === nextProps.controls && + prevProps.data === nextProps.data && + prevProps.dataWithGroups === nextProps.dataWithGroups && + prevProps.enableAlternateRowColors === nextProps.enableAlternateRowColors && + prevProps.enableColumnReorder === nextProps.enableColumnReorder && + prevProps.enableColumnResize === nextProps.enableColumnResize && + prevProps.enableDrag === nextProps.enableDrag && + prevProps.enableExpansion === nextProps.enableExpansion && + prevProps.enableHeader === nextProps.enableHeader && + prevProps.enableHorizontalBorders === nextProps.enableHorizontalBorders && + prevProps.enableRowHoverHighlight === nextProps.enableRowHoverHighlight && + prevProps.enableSelection === nextProps.enableSelection && + prevProps.enableVerticalBorders === nextProps.enableVerticalBorders && + prevProps.getRowHeight === nextProps.getRowHeight && + prevProps.groups === nextProps.groups && + prevProps.headerHeight === nextProps.headerHeight && + prevProps.internalState === nextProps.internalState && + prevProps.itemType === nextProps.itemType && + prevProps.mergedRowRef === nextProps.mergedRowRef && + prevProps.onRangeChanged === nextProps.onRangeChanged && + prevProps.parsedColumns === nextProps.parsedColumns && + prevProps.pinnedLeftColumnCount === nextProps.pinnedLeftColumnCount && + prevProps.pinnedLeftColumnRef === nextProps.pinnedLeftColumnRef && + prevProps.pinnedRightColumnCount === nextProps.pinnedRightColumnCount && + prevProps.pinnedRightColumnRef === nextProps.pinnedRightColumnRef && + prevProps.pinnedRowCount === nextProps.pinnedRowCount && + prevProps.pinnedRowRef === nextProps.pinnedRowRef && + prevProps.playerContext === nextProps.playerContext && + prevProps.showLeftShadow === nextProps.showLeftShadow && + prevProps.showRightShadow === nextProps.showRightShadow && + prevProps.showTopShadow === nextProps.showTopShadow && + prevProps.size === nextProps.size && + prevProps.startRowIndex === nextProps.startRowIndex && + prevProps.tableId === nextProps.tableId && + prevProps.totalColumnCount === nextProps.totalColumnCount && + prevProps.totalRowCount === nextProps.totalRowCount && + prevProps.CellComponent === nextProps.CellComponent + ); +}); + +MemoizedVirtualizedTableGrid.displayName = 'MemoizedVirtualizedTableGrid'; + export interface TableGroupHeader { itemCount: number; render: (props: { @@ -738,6 +763,53 @@ const BaseItemTableList = ({ const [centerContainerWidth, setCenterContainerWidth] = useState(0); const [totalContainerWidth, setTotalContainerWidth] = useState(0); + // Compute dataWithGroups once to avoid duplicate computation + // This is used by both VirtualizedTableGrid and getDataFn + const dataWithGroups = useMemo(() => { + const result: (null | unknown)[] = enableHeader ? [null] : []; + + if (!groups || groups.length === 0) { + // No groups, just add all data + result.push(...data); + return result; + } + + // Calculate group header indexes based on itemCounts + const groupIndexes: number[] = []; + let cumulativeDataIndex = 0; + const headerOffset = enableHeader ? 1 : 0; + + groups.forEach((group, groupIndex) => { + // Group header appears before its items + // Index = header offset + cumulative data index + number of previous group headers + const groupHeaderIndex = headerOffset + cumulativeDataIndex + groupIndex; + groupIndexes.push(groupHeaderIndex); + cumulativeDataIndex += group.itemCount; + }); + + let dataIndex = 0; + const startIndex = enableHeader ? 1 : 0; + let groupHeaderCount = 0; + + // Iterate through the expanded row space (data + group headers) + for ( + let rowIndex = startIndex; + rowIndex < startIndex + data.length + groupIndexes.length; + rowIndex++ + ) { + // Check if this row should have a group header + const expectedGroupIndex = groupIndexes[groupHeaderCount]; + if (expectedGroupIndex !== undefined && rowIndex === expectedGroupIndex) { + result.push(null); // Group header row + groupHeaderCount++; + } else if (dataIndex < data.length) { + result.push(data[dataIndex]); + dataIndex++; + } + } + return result; + }, [data, enableHeader, groups]); + // Compute distributed widths: unpinned columns with autoWidth will share any remaining space // When autoSizeColumns is true, all column widths are treated as proportions and scaled to fit the container const calculatedColumnWidths = useMemo(() => { @@ -867,11 +939,20 @@ const BaseItemTableList = ({ const stickyHeader = stickyHeaderRef.current; const container = containerRef.current; + let isMounted = true; const updatePosition = () => { - const containerRect = container.getBoundingClientRect(); - stickyHeader.style.left = `${containerRect.left}px`; - stickyHeader.style.width = `${containerRect.width}px`; + // Guard against updates after unmount + if (!isMounted || !stickyHeader || !container) { + return; + } + try { + const containerRect = container.getBoundingClientRect(); + stickyHeader.style.left = `${containerRect.left}px`; + stickyHeader.style.width = `${containerRect.width}px`; + } catch { + // Silently handle errors if elements are no longer in DOM + } }; updatePosition(); @@ -880,6 +961,7 @@ const BaseItemTableList = ({ window.addEventListener('scroll', updatePosition, true); return () => { + isMounted = false; window.removeEventListener('resize', updatePosition); window.removeEventListener('scroll', updatePosition, true); }; @@ -895,13 +977,22 @@ const BaseItemTableList = ({ updateWidth(); + let debounceTimeout: NodeJS.Timeout | null = null; const resizeObserver = new ResizeObserver(() => { - updateWidth(); + if (debounceTimeout) { + clearTimeout(debounceTimeout); + } + debounceTimeout = setTimeout(() => { + updateWidth(); + }, 100); }); resizeObserver.observe(el); return () => { + if (debounceTimeout) { + clearTimeout(debounceTimeout); + } resizeObserver.disconnect(); }; }, []); @@ -917,13 +1008,22 @@ const BaseItemTableList = ({ updateWidth(); + let debounceTimeout: NodeJS.Timeout | null = null; const resizeObserver = new ResizeObserver(() => { - updateWidth(); + if (debounceTimeout) { + clearTimeout(debounceTimeout); + } + debounceTimeout = setTimeout(() => { + updateWidth(); + }, 100); }); resizeObserver.observe(el); return () => { + if (debounceTimeout) { + clearTimeout(debounceTimeout); + } resizeObserver.disconnect(); }; }, [autoFitColumns]); @@ -1366,7 +1466,6 @@ const BaseItemTableList = ({ const scrollTop = (e.currentTarget as HTMLDivElement).scrollTop; const scrollLeft = (e.currentTarget as HTMLDivElement).scrollLeft; - // Prevent recursive scroll events const isScrolling = { header: false, pinnedLeft: false, @@ -1490,8 +1589,14 @@ const BaseItemTableList = ({ } // Add resize observer to maintain height sync + let heightSyncDebounceTimeout: NodeJS.Timeout | null = null; const resizeObserver = new ResizeObserver(() => { - syncHeights(); + if (heightSyncDebounceTimeout) { + clearTimeout(heightSyncDebounceTimeout); + } + heightSyncDebounceTimeout = setTimeout(() => { + syncHeights(); + }, 100); }); resizeObserver.observe(row); @@ -1526,6 +1631,9 @@ const BaseItemTableList = ({ pinnedRight.removeEventListener('wheel', setActiveElementFromWheel); pinnedRight.removeEventListener('scroll', syncScroll); } + if (heightSyncDebounceTimeout) { + clearTimeout(heightSyncDebounceTimeout); + } resizeObserver.disconnect(); }; } @@ -1546,19 +1654,28 @@ const BaseItemTableList = ({ return () => clearTimeout(timeout); } + let debounceTimeout: NodeJS.Timeout | null = null; const checkScrollPosition = () => { - const scrollLeft = row.scrollLeft; - const maxScrollLeft = row.scrollWidth - row.clientWidth; + if (debounceTimeout) { + clearTimeout(debounceTimeout); + } + debounceTimeout = setTimeout(() => { + const scrollLeft = row.scrollLeft; + const maxScrollLeft = row.scrollWidth - row.clientWidth; - setShowLeftShadow(pinnedLeftColumnCount > 0 && scrollLeft > 0); - setShowRightShadow(pinnedRightColumnCount > 0 && scrollLeft < maxScrollLeft); + setShowLeftShadow(pinnedLeftColumnCount > 0 && scrollLeft > 0); + setShowRightShadow(pinnedRightColumnCount > 0 && scrollLeft < maxScrollLeft); + }, 50); // 50ms debounce for shadow visibility }; checkScrollPosition(); - row.addEventListener('scroll', checkScrollPosition); + row.addEventListener('scroll', checkScrollPosition, { passive: true }); return () => { + if (debounceTimeout) { + clearTimeout(debounceTimeout); + } row.removeEventListener('scroll', checkScrollPosition); }; }, [pinnedLeftColumnCount, pinnedRightColumnCount]); @@ -1579,16 +1696,25 @@ const BaseItemTableList = ({ // When right-pinned columns exist, use right pinned column's scroll position const scrollElement = pinnedRightColumnCount > 0 && pinnedRight ? pinnedRight : row; + let debounceTimeout: NodeJS.Timeout | null = null; const checkScrollPosition = () => { - const currentScrollTop = scrollElement.scrollTop; - setShowTopShadow(currentScrollTop > 0); + if (debounceTimeout) { + clearTimeout(debounceTimeout); + } + debounceTimeout = setTimeout(() => { + const currentScrollTop = scrollElement.scrollTop; + setShowTopShadow(currentScrollTop > 0); + }, 50); }; checkScrollPosition(); - scrollElement.addEventListener('scroll', checkScrollPosition); + scrollElement.addEventListener('scroll', checkScrollPosition, { passive: true }); return () => { + if (debounceTimeout) { + clearTimeout(debounceTimeout); + } scrollElement.removeEventListener('scroll', checkScrollPosition); }; }, [enableHeader, pinnedRightColumnCount]); @@ -1653,11 +1779,20 @@ const BaseItemTableList = ({ const stickyGroupRow = stickyGroupRowRef.current; const container = containerRef.current; + let isMounted = true; const updatePosition = () => { - const containerRect = container.getBoundingClientRect(); - stickyGroupRow.style.left = `${containerRect.left}px`; - stickyGroupRow.style.width = `${containerRect.width}px`; + // Guard against updates after unmount + if (!isMounted || !stickyGroupRow || !container) { + return; + } + try { + const containerRect = container.getBoundingClientRect(); + stickyGroupRow.style.left = `${containerRect.left}px`; + stickyGroupRow.style.width = `${containerRect.width}px`; + } catch { + // Silently handle errors if elements are no longer in DOM + } }; updatePosition(); @@ -1666,52 +1801,15 @@ const BaseItemTableList = ({ window.addEventListener('scroll', updatePosition, true); return () => { + isMounted = false; window.removeEventListener('resize', updatePosition); window.removeEventListener('scroll', updatePosition, true); }; }, [shouldRenderStickyGroupRow]); const getDataFn = useCallback(() => { - const result: (null | unknown)[] = enableHeader ? [null] : []; - - if (!groups || groups.length === 0) { - // No groups, just add all data - result.push(...data); - return result; - } - - // Calculate group header indexes based on itemCounts - const groupIndexes: number[] = []; - let cumulativeDataIndex = 0; - const headerOffset = enableHeader ? 1 : 0; - - groups.forEach((group, groupIndex) => { - // Index = header offset + cumulative data index + number of previous group headers - const groupHeaderIndex = headerOffset + cumulativeDataIndex + groupIndex; - groupIndexes.push(groupHeaderIndex); - cumulativeDataIndex += group.itemCount; - }); - - let dataIndex = 0; - const startIndex = enableHeader ? 1 : 0; - let groupHeaderCount = 0; - - for ( - let rowIndex = startIndex; - rowIndex < startIndex + data.length + groupIndexes.length; - rowIndex++ - ) { - const expectedGroupIndex = groupIndexes[groupHeaderCount]; - if (expectedGroupIndex !== undefined && rowIndex === expectedGroupIndex) { - result.push(null); - groupHeaderCount++; - } else if (dataIndex < data.length) { - result.push(data[dataIndex]); - dataIndex++; - } - } - return result; - }, [data, enableHeader, groups]); + return dataWithGroups; + }, [dataWithGroups]); const extractRowId = useMemo(() => createExtractRowId(getRowId), [getRowId]); @@ -2287,13 +2385,14 @@ const BaseItemTableList = ({ > {StickyHeader} {StickyGroupRow} -