From 4eefe58cabf901c1267903ec1f9e764b6ac3c31e Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 12 May 2026 21:37:09 +0200 Subject: [PATCH] feat(data-table): opt-in row virtualization via @tanstack/react-virtual MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: 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 `` 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) --- src/components/shared/data-table.tsx | 85 +++++++++++++++++++++++++++- 1 file changed, 82 insertions(+), 3 deletions(-) diff --git a/src/components/shared/data-table.tsx b/src/components/shared/data-table.tsx index 514b87d3..3d1cea80 100644 --- a/src/components/shared/data-table.tsx +++ b/src/components/shared/data-table.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useMemo, useState } from 'react'; +import { useMemo, useRef, useState } from 'react'; import { flexRender, getCoreRowModel, @@ -10,6 +10,7 @@ import { type RowSelectionState, type VisibilityState, } from '@tanstack/react-table'; +import { useVirtualizer } from '@tanstack/react-virtual'; import { ArrowDown, ArrowUp, ArrowUpDown, Loader2 } from 'lucide-react'; import { @@ -81,6 +82,29 @@ interface DataTableProps { * needing a preferences migration. */ 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 `
` 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({ @@ -101,6 +125,9 @@ export function DataTable({ cardRender, mobileGroupBy, columnVisibility, + virtual, + virtualHeightPx = 600, + virtualRowHeightPx = 48, }: DataTableProps) { const [internalSelection, setInternalSelection] = useState({}); const rowSelectionState = externalSelection ?? internalSelection; @@ -189,9 +216,28 @@ export function DataTable({ const rows = table.getRowModel().rows; + // Virtualization gate — pagination wins over virtual (you can't do both). + const virtualEnabled = Boolean(virtual) && !pagination; + const scrollContainerRef = useRef(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 (
-
+
{table.getHeaderGroups().map((headerGroup) => ( @@ -246,8 +292,41 @@ export function DataTable({ )} + ) : virtualEnabled ? ( + <> + {virtualPaddingTop > 0 ? ( + + + + ) : null} + {virtualRows.map((vRow) => { + const row = rows[vRow.index]!; + return ( + onRowClick?.(row.original)} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ); + })} + {virtualPaddingBottom > 0 ? ( + + + + ) : null} + ) : ( - table.getRowModel().rows.map((row) => ( + rows.map((row) => (