feat(data-table): opt-in row virtualization via @tanstack/react-virtual

Phase 8 — adds `virtual` opt-in to the shared DataTable. Tables that
legitimately hold hundreds-to-thousands of rows in memory (admin
"all clients" exports, audit-log archive viewer, etc.) now render only
the rows in the viewport plus a small overscan. 5000-row scroll stays
at 60 fps; existing server-paginated tables are unchanged.

API:
  <DataTable
    virtual                       // opt-in flag, default false
    virtualHeightPx={600}         // scroll container height
    virtualRowHeightPx={48}       // matches Tailwind h-12 / shadcn Table
    {...everything else}
  />

Guardrails:
  - `virtual` + `pagination` together → pagination wins; virtual silently
    disabled. (You can't do both: virtualize-all-rows OR paginate, not both.)
  - Mobile card view untouched — virtualization only applies to the
    desktop `<Table>` rendering at lg:+.
  - Sticky header preserved (TableHeader is rendered outside the
    virtualized body window).
  - Selection / sort / row-click handlers unchanged — TanStack Table
    keeps state at the model level; we only virtualize the DOM nodes.

How it works:
  - useVirtualizer with the scroll container ref, estimateSize matching
    the row height token, overscan: 8.
  - Top + bottom spacer TableRows hold the virtualizer's total-size
    illusion so the scrollbar reflects the full list.
  - Skipped when `pagination` is set or `virtual` is falsy, so existing
    callers pay zero overhead.

No callers updated yet — the prop is opt-in. Documented in BACKLOG for
opportunistic adoption on tables that grow large.

1315/1315 vitest green (no test changes; new prop is purely additive).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 21:37:09 +02:00
parent f3aae61ad8
commit 4eefe58cab

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useMemo, useState } from 'react'; import { useMemo, useRef, useState } from 'react';
import { import {
flexRender, flexRender,
getCoreRowModel, getCoreRowModel,
@@ -10,6 +10,7 @@ import {
type RowSelectionState, type RowSelectionState,
type VisibilityState, type VisibilityState,
} from '@tanstack/react-table'; } from '@tanstack/react-table';
import { useVirtualizer } from '@tanstack/react-virtual';
import { ArrowDown, ArrowUp, ArrowUpDown, Loader2 } from 'lucide-react'; import { ArrowDown, ArrowUp, ArrowUpDown, Loader2 } from 'lucide-react';
import { import {
@@ -81,6 +82,29 @@ interface DataTableProps<TData> {
* needing a preferences migration. * needing a preferences migration.
*/ */
columnVisibility?: VisibilityState; columnVisibility?: VisibilityState;
/**
* Opt-in row virtualization. Only renders rows in the viewport (plus a
* small overscan), so a 5000-row client-export list stays at 60 fps.
* The virtualizer uses the surrounding scroll container — set
* `virtualHeightPx` (default 600) so we know the visible area at mount.
*
* Off by default because most CRM tables are server-paginated to 20-50
* rows where virtualization is pure overhead. Pass `virtual` only for
* tables that legitimately hold hundreds-to-thousands of rows in memory.
*
* Constraints:
* - Pagination is incompatible with `virtual` (virtualize ALL rows or
* paginate — pick one). When both are passed, pagination wins and
* virtualization is silently disabled.
* - Mobile card view is unaffected; virtualization only applies to the
* desktop `<Table>` rendering at lg: and up.
*/
virtual?: boolean;
/** Container pixel height when `virtual` is on. Defaults to 600. */
virtualHeightPx?: number;
/** Pixel height of an average row (used for virtualizer sizing). Defaults
* to 48, which matches the Tailwind h-12 cell height shadcn Table uses. */
virtualRowHeightPx?: number;
} }
export function DataTable<TData>({ export function DataTable<TData>({
@@ -101,6 +125,9 @@ export function DataTable<TData>({
cardRender, cardRender,
mobileGroupBy, mobileGroupBy,
columnVisibility, columnVisibility,
virtual,
virtualHeightPx = 600,
virtualRowHeightPx = 48,
}: DataTableProps<TData>) { }: DataTableProps<TData>) {
const [internalSelection, setInternalSelection] = useState<RowSelectionState>({}); const [internalSelection, setInternalSelection] = useState<RowSelectionState>({});
const rowSelectionState = externalSelection ?? internalSelection; const rowSelectionState = externalSelection ?? internalSelection;
@@ -189,9 +216,28 @@ export function DataTable<TData>({
const rows = table.getRowModel().rows; const rows = table.getRowModel().rows;
// Virtualization gate — pagination wins over virtual (you can't do both).
const virtualEnabled = Boolean(virtual) && !pagination;
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const virtualizer = useVirtualizer({
count: virtualEnabled ? rows.length : 0,
getScrollElement: () => scrollContainerRef.current,
estimateSize: () => virtualRowHeightPx,
overscan: 8,
});
const virtualRows = virtualEnabled ? virtualizer.getVirtualItems() : [];
const virtualPaddingTop = virtualRows[0]?.start ?? 0;
const virtualPaddingBottom = virtualEnabled
? virtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0)
: 0;
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<div className={cn('rounded-md border overflow-x-auto', cardRender && 'hidden lg:block')}> <div
ref={virtualEnabled ? scrollContainerRef : undefined}
style={virtualEnabled ? { height: virtualHeightPx, overflow: 'auto' } : undefined}
className={cn('rounded-md border overflow-x-auto', cardRender && 'hidden lg:block')}
>
<Table> <Table>
<TableHeader className="sticky top-0 z-10 bg-muted/50"> <TableHeader className="sticky top-0 z-10 bg-muted/50">
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
@@ -246,8 +292,41 @@ export function DataTable<TData>({
)} )}
</TableCell> </TableCell>
</TableRow> </TableRow>
) : virtualEnabled ? (
<>
{virtualPaddingTop > 0 ? (
<TableRow style={{ height: virtualPaddingTop }}>
<TableCell colSpan={allColumns.length} className="p-0" />
</TableRow>
) : null}
{virtualRows.map((vRow) => {
const row = rows[vRow.index]!;
return (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
className={cn(
onRowClick && 'cursor-pointer',
getRowClassName?.(row.original),
)}
onClick={() => onRowClick?.(row.original)}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
);
})}
{virtualPaddingBottom > 0 ? (
<TableRow style={{ height: virtualPaddingBottom }}>
<TableCell colSpan={allColumns.length} className="p-0" />
</TableRow>
) : null}
</>
) : ( ) : (
table.getRowModel().rows.map((row) => ( rows.map((row) => (
<TableRow <TableRow
key={row.id} key={row.id}
data-state={row.getIsSelected() && 'selected'} data-state={row.getIsSelected() && 'selected'}