mirror of
https://github.com/vxcontrol/pentagi.git
synced 2026-09-11 04:45:38 +00:00
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:
co-authored by
Claude Opus 4.7
parent
19d53887b7
commit
a8c3fbcf74
@@ -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",
|
||||
|
||||
Generated
+20
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -640,6 +640,7 @@ function Flows() {
|
||||
empty={{ entityName: 'flows' }}
|
||||
filterPlaceholder="Filter flows..."
|
||||
filterValue={filter}
|
||||
isVirtualized
|
||||
onFilterChange={setFilter}
|
||||
onPageChange={handlePageChange}
|
||||
onRowClick={handleRowClick}
|
||||
|
||||
Reference in New Issue
Block a user