feat(frontend): virtualize DataTable rows past 50

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 <tr>
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 <tr>:         1,116 → 22
  a11y tree size:     ~485 KB → ~80 KB

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-05-24 10:36:10 +07:00
co-authored by Claude Opus 4.7
parent 19d53887b7
commit a8c3fbcf74
4 changed files with 187 additions and 66 deletions
+1
View File
@@ -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",
+20
View File
@@ -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
+165 -66
View File
@@ -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<TData, TValue = unknown> {
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<TData, TValue = unknown>({
filterValue: externalFilterValue,
initialPageSize = 10,
initialSorting = [],
isVirtualized = false,
onColumnVisibilityChange,
onFilterChange,
onPageChange,
@@ -569,6 +580,60 @@ function DataTable<TData, TValue = unknown>({
[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<HTMLDivElement>(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<TData, TValue = unknown>({
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="rounded-md border">
<div
className="rounded-md border"
ref={tableContainerRef}
>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
@@ -731,74 +799,11 @@ function DataTable<TData, TValue = unknown>({
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length > 0 ? (
table.getRowModel().rows.map((row) => {
const contextMenuContent = renderRowContextMenu?.(row.original);
const tableRow = (
<TableRow
className={cn(
'group hover:bg-muted/50 data-[state=open]:bg-muted/50 has-[[data-state=open]]:bg-muted/50',
isRowInteractive && 'cursor-pointer',
contextMenuContent &&
'pointer-coarse:select-none pointer-coarse:[-webkit-touch-callout:none]',
)}
{...(row.getIsSelected() ? { 'data-state': 'selected' } : {})}
onClick={() => handleRowClick(row)}
>
{row.getVisibleCells().map((cell) => (
<TableCell
className={cell.column.columnDef.meta?.cellClassName}
key={cell.id}
onClick={(event) => {
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())}
</TableCell>
))}
</TableRow>
);
return (
<Fragment key={row.id}>
{contextMenuContent ? (
<ContextMenu>
<ContextMenuTrigger asChild>{tableRow}</ContextMenuTrigger>
<ContextMenuContent>{contextMenuContent}</ContextMenuContent>
</ContextMenu>
) : (
tableRow
)}
{row.getIsExpanded() && renderSubComponent && (
<TableRow className="cursor-default border-0 hover:bg-transparent">
<TableCell
className="p-0"
colSpan={row.getVisibleCells().length}
>
{renderSubComponent({ row })}
</TableCell>
</TableRow>
)}
</Fragment>
);
})
) : (
{rows.length === 0 ? (
<TableRow>
<TableCell
className={cn('text-center', empty?.entityName ? 'py-12' : 'h-24')}
colSpan={columns.length}
colSpan={visibleColumnCount}
>
<DataTableEmptyState
entityName={empty?.entityName}
@@ -806,6 +811,100 @@ function DataTable<TData, TValue = unknown>({
/>
</TableCell>
</TableRow>
) : (
<>
{paddingTop > 0 ? (
<tr aria-hidden>
<td
colSpan={visibleColumnCount}
style={{ height: paddingTop }}
/>
</tr>
) : 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 = (
<TableRow
className={cn(
'group hover:bg-muted/50 data-[state=open]:bg-muted/50 has-[[data-state=open]]:bg-muted/50',
isRowInteractive && 'cursor-pointer',
contextMenuContent &&
'pointer-coarse:select-none pointer-coarse:[-webkit-touch-callout:none]',
)}
data-index={virtualItem?.index}
ref={
virtualItem
? (node: HTMLTableRowElement | null) =>
rowVirtualizer.measureElement(node)
: undefined
}
{...(row.getIsSelected() ? { 'data-state': 'selected' } : {})}
onClick={() => handleRowClick(row)}
>
{row.getVisibleCells().map((cell) => (
<TableCell
className={cell.column.columnDef.meta?.cellClassName}
key={cell.id}
onClick={(event) => {
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())}
</TableCell>
))}
</TableRow>
);
return (
<Fragment key={row.id}>
{contextMenuContent ? (
<ContextMenu>
<ContextMenuTrigger asChild>{tableRow}</ContextMenuTrigger>
<ContextMenuContent>{contextMenuContent}</ContextMenuContent>
</ContextMenu>
) : (
tableRow
)}
{row.getIsExpanded() && renderSubComponent && (
<TableRow className="cursor-default border-0 hover:bg-transparent">
<TableCell
className="p-0"
colSpan={row.getVisibleCells().length}
>
{renderSubComponent({ row })}
</TableCell>
</TableRow>
)}
</Fragment>
);
})}
{paddingBottom > 0 ? (
<tr aria-hidden>
<td
colSpan={visibleColumnCount}
style={{ height: paddingBottom }}
/>
</tr>
) : null}
</>
)}
</TableBody>
</Table>
+1
View File
@@ -640,6 +640,7 @@ function Flows() {
empty={{ entityName: 'flows' }}
filterPlaceholder="Filter flows..."
filterValue={filter}
isVirtualized
onFilterChange={setFilter}
onPageChange={handlePageChange}
onRowClick={handleRowClick}