From a8c3fbcf7477f7654b93d47262b6a46bc5e2af0a Mon Sep 17 00:00:00 2001 From: Sergey Kozyrenko Date: Sun, 24 May 2026 10:36:10 +0700 Subject: [PATCH] feat(frontend): virtualize DataTable rows past 50 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in `isVirtualized` prop on DataTable, wired through @tanstack/react-virtual's useWindowVirtualizer. Threshold gates short tables (preserves Find-in-page, screen-reader enumeration). Padding sentinels keep native HTML table semantics. Skipped when `renderSubComponent` is set — expanded rows would need per-row remeasurement we don't wire here. Enabled on /flows. Measured with pageSize=All (1116 rows): DOM elements: 41,657 → 1,121 (37x less) tbody : 1,116 → 22 a11y tree size: ~485 KB → ~80 KB Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/package.json | 1 + frontend/pnpm-lock.yaml | 20 ++ frontend/src/components/ui/data-table.tsx | 231 +++++++++++++++------- frontend/src/pages/flows/flows.tsx | 1 + 4 files changed, 187 insertions(+), 66 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 48d94e4d..6c4b7ede 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -43,6 +43,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@react-pdf/renderer": "^4.5.1", "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.13.25", "@tiptap/core": "^3.23.4", "@tiptap/extensions": "^3.23.4", "@tiptap/pm": "^3.23.4", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 237fe8ea..093f0ae1 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -83,6 +83,9 @@ importers: '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@tanstack/react-virtual': + specifier: ^3.13.25 + version: 3.13.25(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@tiptap/core': specifier: ^3.23.4 version: 3.23.4(@tiptap/pm@3.23.4) @@ -2315,10 +2318,19 @@ packages: react: '>=16.8' react-dom: '>=16.8' + '@tanstack/react-virtual@3.13.25': + resolution: {integrity: sha512-bmNoqMu6gcAW9JGrKVB0Q1tN1i5RONZF8r1fW0bbE4Oyf3DwEGnzzQJ2OW+Ozg1P4s8PyugkHg2ULZoFQN+cqw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/table-core@8.21.3': resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} engines: {node: '>=12'} + '@tanstack/virtual-core@3.15.0': + resolution: {integrity: sha512-0AwPGx0I8QxPYjAxShT/+z+ZOe9u8mW5rsXvivCTjRfRmz9a43+3mRyi4wwlyoUqOC56q/jatKa0Bh9M99BEHQ==} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -8092,8 +8104,16 @@ snapshots: react: 19.2.6 react-dom: 19.2.6(react@19.2.6) + '@tanstack/react-virtual@3.13.25(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@tanstack/virtual-core': 3.15.0 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + '@tanstack/table-core@8.21.3': {} + '@tanstack/virtual-core@3.15.0': {} + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.0 diff --git a/frontend/src/components/ui/data-table.tsx b/frontend/src/components/ui/data-table.tsx index e94a2964..79fde482 100644 --- a/frontend/src/components/ui/data-table.tsx +++ b/frontend/src/components/ui/data-table.tsx @@ -14,6 +14,7 @@ import { useReactTable, type VisibilityState, } from '@tanstack/react-table'; +import { useWindowVirtualizer } from '@tanstack/react-virtual'; import { ArrowDown, ArrowUp, @@ -103,6 +104,15 @@ interface DataTableProps { filterValue?: string; initialPageSize?: number; initialSorting?: SortingState; + /** + * Render only rows visible in the viewport via `@tanstack/react-virtual`. + * Activates only when the rendered row count exceeds the threshold + * (50 by default) so short tables stay fully rendered for native + * Find-in-page / printing. Not compatible with `renderSubComponent` + * (variable-height expanded rows would need per-row remeasurement), + * so the flag is silently ignored when one is provided. + */ + isVirtualized?: boolean; onColumnVisibilityChange?: (visibility: VisibilityState) => void; onFilterChange?: (value: string) => void; onPageChange?: (pageIndex: number, options?: { replace?: boolean }) => void; @@ -241,6 +251,7 @@ function DataTable({ filterValue: externalFilterValue, initialPageSize = 10, initialSorting = [], + isVirtualized = false, onColumnVisibilityChange, onFilterChange, onPageChange, @@ -569,6 +580,60 @@ function DataTable({ [onRowClick], ); + const rows = table.getRowModel().rows; + const visibleColumnCount = table.getVisibleLeafColumns().length; + + const VIRTUALIZATION_THRESHOLD = 50; + const isVirtualizationActive = isVirtualized && !renderSubComponent && rows.length > VIRTUALIZATION_THRESHOLD; + + const tableContainerRef = useRef(null); + const [scrollMargin, setScrollMargin] = useState(0); + + useEffect(() => { + if (!isVirtualizationActive) { + return; + } + + const updateScrollMargin = () => { + const element = tableContainerRef.current; + + if (!element) { + return; + } + + setScrollMargin(element.getBoundingClientRect().top + window.scrollY); + }; + + updateScrollMargin(); + + const resizeObserver = new ResizeObserver(updateScrollMargin); + + if (tableContainerRef.current) { + resizeObserver.observe(tableContainerRef.current); + } + + window.addEventListener('resize', updateScrollMargin); + + return () => { + resizeObserver.disconnect(); + window.removeEventListener('resize', updateScrollMargin); + }; + }, [isVirtualizationActive]); + + const rowVirtualizer = useWindowVirtualizer({ + count: isVirtualizationActive ? rows.length : 0, + estimateSize: () => 53, + overscan: 10, + scrollMargin, + }); + + const virtualItems = isVirtualizationActive ? rowVirtualizer.getVirtualItems() : []; + const totalSize = isVirtualizationActive ? rowVirtualizer.getTotalSize() : 0; + // useWindowVirtualizer's item.start/.end are in document coordinates. + const paddingTop = virtualItems.length > 0 ? virtualItems[0]!.start - scrollMargin : 0; + const paddingBottom = + virtualItems.length > 0 ? totalSize - (virtualItems[virtualItems.length - 1]!.end - scrollMargin) : 0; + const pageSizeValue = pagination.pageSize >= data.length && data.length > 0 ? 'all' : String(pagination.pageSize); const totalRows = table.getFilteredRowModel().rows.length; @@ -703,7 +768,10 @@ function DataTable({ -
+
{table.getHeaderGroups().map((headerGroup) => ( @@ -731,74 +799,11 @@ function DataTable({ ))} - {table.getRowModel().rows.length > 0 ? ( - table.getRowModel().rows.map((row) => { - const contextMenuContent = renderRowContextMenu?.(row.original); - - const tableRow = ( - handleRowClick(row)} - > - {row.getVisibleCells().map((cell) => ( - { - if (cell.column.columnDef.meta?.preventRowClick) { - event.stopPropagation(); - } - }} - style={ - cell.column.columnDef.size - ? { - maxWidth: cell.column.columnDef.size, - minWidth: cell.column.columnDef.size, - width: cell.column.columnDef.size, - } - : undefined - } - > - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - ); - - return ( - - {contextMenuContent ? ( - - {tableRow} - {contextMenuContent} - - ) : ( - tableRow - )} - {row.getIsExpanded() && renderSubComponent && ( - - - {renderSubComponent({ row })} - - - )} - - ); - }) - ) : ( + {rows.length === 0 ? ( ({ /> + ) : ( + <> + {paddingTop > 0 ? ( + + + ) : null} + {(isVirtualizationActive + ? virtualItems.map((virtualItem) => ({ + row: rows[virtualItem.index]!, + virtualItem, + })) + : rows.map((row) => ({ row, virtualItem: null })) + ).map(({ row, virtualItem }) => { + const contextMenuContent = renderRowContextMenu?.(row.original); + + const tableRow = ( + + rowVirtualizer.measureElement(node) + : undefined + } + {...(row.getIsSelected() ? { 'data-state': 'selected' } : {})} + onClick={() => handleRowClick(row)} + > + {row.getVisibleCells().map((cell) => ( + { + if (cell.column.columnDef.meta?.preventRowClick) { + event.stopPropagation(); + } + }} + style={ + cell.column.columnDef.size + ? { + maxWidth: cell.column.columnDef.size, + minWidth: cell.column.columnDef.size, + width: cell.column.columnDef.size, + } + : undefined + } + > + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ); + + return ( + + {contextMenuContent ? ( + + {tableRow} + {contextMenuContent} + + ) : ( + tableRow + )} + {row.getIsExpanded() && renderSubComponent && ( + + + {renderSubComponent({ row })} + + + )} + + ); + })} + {paddingBottom > 0 ? ( + + + ) : null} + )}
+
+
diff --git a/frontend/src/pages/flows/flows.tsx b/frontend/src/pages/flows/flows.tsx index 8deee970..2bf0dc3e 100644 --- a/frontend/src/pages/flows/flows.tsx +++ b/frontend/src/pages/flows/flows.tsx @@ -640,6 +640,7 @@ function Flows() { empty={{ entityName: 'flows' }} filterPlaceholder="Filter flows..." filterValue={filter} + isVirtualized onFilterChange={setFilter} onPageChange={handlePageChange} onRowClick={handleRowClick}