diff --git a/frontend/src/components/ui/data-table.test.tsx b/frontend/src/components/ui/data-table.test.tsx index 2b82bc5d..dd2219d5 100644 --- a/frontend/src/components/ui/data-table.test.tsx +++ b/frontend/src/components/ui/data-table.test.tsx @@ -1068,3 +1068,143 @@ describe('cycleColumnSort', () => { expect(column.getIsSorted).toHaveBeenCalledTimes(1); }); }); + +describe('DataTable — virtualization', () => { + // 60 rows on a single page (pageSize 100) so the rendered set clears the + // 50-row threshold and `isVirtualized` actually activates. + const VIRTUAL_ROWS: Row[] = Array.from({ length: 60 }, (_, index) => ({ + id: String(index + 1), + name: `Row ${index + 1}`, + })); + + let rectSpy: ReturnType; + + beforeEach(() => { + // jsdom reports a zero rect for everything. Pin a realistic row height + // so the window virtualizer culls a deterministic subset instead of + // collapsing zero-height rows and rendering all of them. + rectSpy = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ + bottom: 53, + height: 53, + left: 0, + right: 800, + toJSON: () => ({}), + top: 0, + width: 800, + x: 0, + y: 0, + } as DOMRect); + }); + + afterEach(() => { + rectSpy.mockRestore(); + }); + + // Body rows carry `data-index`; padding spacers and the header row don't. + const dataRows = () => document.querySelectorAll('tbody tr[data-index]'); + + it('renders only a subset of rows once past the threshold', async () => { + render( + + columns={COLUMNS} + data={VIRTUAL_ROWS} + initialPageSize={100} + isVirtualized + />, + { wrapper: Wrapper }, + ); + + await waitFor(() => expect(dataRows().length).toBeGreaterThan(0)); + // The viewport can't hold all 60 estimated rows, so the window virtualizer + // culls the rest — far fewer rows reach the DOM than exist in the data. + expect(dataRows().length).toBeLessThan(VIRTUAL_ROWS.length); + }); + + it('exposes the true row total via aria-rowcount while virtualized', async () => { + render( + + columns={COLUMNS} + data={VIRTUAL_ROWS} + initialPageSize={100} + isVirtualized + />, + { wrapper: Wrapper }, + ); + + await waitFor(() => expect(dataRows().length).toBeGreaterThan(0)); + // 60 data rows + 1 header row — what a screen reader announces as the total. + expect(document.querySelector('table')).toHaveAttribute('aria-rowcount', '61'); + // Header occupies aria-rowindex 1; each data row is its data index + 2 + // (1-based, past the header). Assert the relationship rather than a fixed + // index so the check is independent of which window the virtualizer renders. + expect(document.querySelector('thead tr')).toHaveAttribute('aria-rowindex', '1'); + const firstDataRow = dataRows()[0]; + const dataIndex = Number(firstDataRow.getAttribute('data-index')); + expect(firstDataRow).toHaveAttribute('aria-rowindex', String(dataIndex + 2)); + }); + + it('keeps data-index on rows wrapped in a context menu (Radix asChild path)', async () => { + // Production /flows pairs `isVirtualized` with `renderRowContextMenu`, so + // every virtual row is wrapped in . The + // measureElement ref and `data-index` must survive Radix's prop/ref + // merge or dynamic measurement silently breaks. + render( + + columns={COLUMNS} + data={VIRTUAL_ROWS} + initialPageSize={100} + isVirtualized + renderRowContextMenu={(row) =>
menu for {row.name}
} + />, + { wrapper: Wrapper }, + ); + + await waitFor(() => expect(dataRows().length).toBeGreaterThan(0)); + dataRows().forEach((row) => expect(row).toHaveAttribute('data-index')); + }); + + it('does not virtualize at or below the threshold (full DOM, no aria-rowcount)', () => { + const fewRows: Row[] = Array.from({ length: 30 }, (_, index) => ({ + id: String(index + 1), + name: `Row ${index + 1}`, + })); + + render( + + columns={COLUMNS} + data={fewRows} + initialPageSize={100} + isVirtualized + />, + { wrapper: Wrapper }, + ); + + // Every row is in the DOM, so native enumeration works — no spacer, no + // virtualization wiring, no aria-rowcount override. + expect(screen.getByText('Row 1')).toBeInTheDocument(); + expect(screen.getByText('Row 30')).toBeInTheDocument(); + expect(dataRows().length).toBe(0); + expect(document.querySelector('tbody tr[aria-hidden]')).not.toBeInTheDocument(); + expect(document.querySelector('table')).not.toHaveAttribute('aria-rowcount'); + }); + + it('disables virtualization when renderSubComponent is set, even past the threshold', () => { + render( + + columns={COLUMNS} + data={VIRTUAL_ROWS} + initialPageSize={100} + isVirtualized + renderSubComponent={({ row }) =>
expanded {row.original.name}
} + />, + { wrapper: Wrapper }, + ); + + // Expanded-row support wins over virtualization: the full set renders + // and no measurement wiring is attached. + expect(dataRows().length).toBe(0); + expect(screen.getByText('Row 1')).toBeInTheDocument(); + expect(screen.getByText('Row 60')).toBeInTheDocument(); + expect(document.querySelector('table')).not.toHaveAttribute('aria-rowcount'); + }); +}); diff --git a/frontend/src/components/ui/data-table.tsx b/frontend/src/components/ui/data-table.tsx index 79fde482..ddce6007 100644 --- a/frontend/src/components/ui/data-table.tsx +++ b/frontend/src/components/ui/data-table.tsx @@ -14,7 +14,6 @@ import { useReactTable, type VisibilityState, } from '@tanstack/react-table'; -import { useWindowVirtualizer } from '@tanstack/react-virtual'; import { ArrowDown, ArrowUp, @@ -30,7 +29,6 @@ import { } from 'lucide-react'; import { type ChangeEvent, - Fragment, type ReactElement, type ReactNode, useCallback, @@ -59,6 +57,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@ import { useEffectAfterMount } from '@/hooks/use-effect-after-mount'; import { useLatestRef } from '@/hooks/use-latest-ref'; import { usePageStorageKeys } from '@/hooks/use-page-storage-keys'; +import { useWindowVirtualList } from '@/hooks/use-window-virtual-list'; import { migrateLegacyTableState, updateTableState } from '@/lib/table-state'; import { cn } from '@/lib/utils'; @@ -105,12 +104,18 @@ interface DataTableProps { 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. + * Render only the viewport-visible rows via window-scrolled + * virtualization (`@tanstack/react-virtual`). Activates only when the + * rendered row count exceeds `VIRTUALIZATION_THRESHOLD` so short tables + * stay fully rendered for native Find-in-page and printing. + * + * Constraints: + * - Silently ignored when `renderSubComponent` is set — variable-height + * expanded rows would need per-row remeasurement this component + * doesn't wire. + * - Assumes the page scrolls via `window`. Layouts with an inner + * `overflow-auto` container (e.g. settings-layout's `
`) are not + * supported and will misposition rows. */ isVirtualized?: boolean; onColumnVisibilityChange?: (visibility: VisibilityState) => void; @@ -143,6 +148,17 @@ interface DataTableProps { const PAGE_SIZE_OPTIONS = [10, 15, 20, 50, 100] as const; +// Row count above which `isVirtualized` actually activates. Short tables +// stay fully rendered so native Find-in-page and printing keep working. +const VIRTUALIZATION_THRESHOLD = 50; + +// Pixel estimate handed to the virtualizer until the first real measurement. +// Matches the rendered height of a single-line `TableCell` with `p-4` padding +// plus the `border-b`; rows still resize correctly once `measureItem` +// observes them — this only affects the initial scroll-anchor math. +const ROW_HEIGHT_ESTIMATE = 53; +const estimateRowSize = () => ROW_HEIGHT_ESTIMATE; + const columnPickerLabel = (column: Column): string => column.columnDef.meta?.columnMenuLabel ?? column.id; @@ -216,6 +232,32 @@ interface DataTableColumnHeaderProps { title: ReactNode; } +interface DataTableRowProps { + isRowInteractive: boolean; + /** + * Virtualization wiring, present only when the row is rendered inside + * `VirtualizedTableBody`. Bundling `index` and `ref` into one object keeps + * the "both or neither" invariant in the type: `index` feeds both + * `data-index` (read back by `measureElement` to attribute a measured rect) + * and `aria-rowindex` (1-based, offset past the header row); `ref` is the + * virtualizer's `measureElement`. + */ + measurement?: { index: number; ref: (node: Element | null) => void }; + onRowClick: (row: Row) => void; + renderRowContextMenu?: (row: TData) => ReactNode; + renderSubComponent?: (props: { row: Row }) => ReactElement; + row: Row; +} + +interface VirtualizedTableBodyProps { + isRowInteractive: boolean; + onRowClick: (row: Row) => void; + renderRowContextMenu?: (row: TData) => ReactNode; + renderSubComponent?: (props: { row: Row }) => ReactElement; + rows: Row[]; + visibleColumnCount: number; +} + /** * Cycle a TanStack column through `none → asc → desc → none`. Pure with * respect to React (no hooks called) so a header `onClick` can invoke it @@ -582,58 +624,8 @@ function DataTable({ 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; @@ -768,14 +760,14 @@ function DataTable({ -
- +
+
{table.getHeaderGroups().map((headerGroup) => ( - + {headerGroup.headers.map((header) => ( ({ ))} - - {rows.length === 0 ? ( + {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} - - )} - + + ) : isVirtualizationActive ? ( + + ) : ( + + {rows.map((row) => ( + + ))} + + )}
-
-
@@ -1137,4 +1057,161 @@ function DataTableFilter({ onQueryChange, placeholder, query }: DataTableFilterP ); } +/** + * Single row in the `DataTable` body. Renders the visible cells, wraps with + * a context-menu trigger when one is supplied, and emits the expanded + * sub-component row when the row is expanded. Both `VirtualizedTableBody` + * and the plain branch render the same markup by reusing this component; + * the only difference is whether `measurement` is supplied. + */ +function DataTableRow({ + isRowInteractive, + measurement, + onRowClick, + renderRowContextMenu, + renderSubComponent, + row, +}: DataTableRowProps) { + const contextMenuContent = renderRowContextMenu?.(row.original); + + const tableRow = ( + onRowClick(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 })} + + + ) : null} + + ); +} + +/** + * Empty `` spacer that pushes the virtualized window into the correct + * scroll position. Raw `` / `` (not `TableRow` / `TableCell`) so the + * spacer doesn't inherit row border and hover-background styles. + * `aria-hidden` keeps it out of the accessibility tree. + */ +function PaddingRow({ colSpan, height }: { colSpan: number; height: number }) { + return ( + + + + ); +} + +/** + * Window-scrolled virtualization for `DataTable`'s ``. Mounted only + * when `DataTable` decides virtualization should activate, so the underlying + * `useWindowVirtualList` (and its window/body listeners) never run on tables + * that don't need them. Padding `` spacers preserve `` / `` + * / `` semantics — no absolute positioning, no `display: grid` override. + */ +function VirtualizedTableBody({ + isRowInteractive, + onRowClick, + renderRowContextMenu, + renderSubComponent, + rows, + visibleColumnCount, +}: VirtualizedTableBodyProps) { + // Key the measurement cache by TanStack's row id, not the bare index, so it + // follows row identity once the table opts into `getRowId` (today's default + // ids are positional, making this equivalent to the index). Recreated on + // every `rows` change so the cache tracks the current row set. + const getItemKey = useCallback((index: number) => rows[index].id, [rows]); + + const { anchorRef, measureItem, paddingEnd, paddingStart, virtualItems } = + useWindowVirtualList({ + count: rows.length, + estimateSize: estimateRowSize, + getItemKey, + }); + + return ( + + {paddingStart > 0 ? ( + + ) : null} + {virtualItems.map((virtualItem) => { + const row = rows[virtualItem.index]; + + return ( + + ); + })} + {paddingEnd > 0 ? ( + + ) : null} + + ); +} + export { DataTable, DataTableColumnHeader }; diff --git a/frontend/src/hooks/use-window-virtual-list.ts b/frontend/src/hooks/use-window-virtual-list.ts new file mode 100644 index 00000000..dcc15736 --- /dev/null +++ b/frontend/src/hooks/use-window-virtual-list.ts @@ -0,0 +1,166 @@ +import { useWindowVirtualizer, type VirtualItem, type Virtualizer } from '@tanstack/react-virtual'; +import { type RefObject, useLayoutEffect, useRef, useState } from 'react'; + +/** + * Headless window-scrolled list virtualization. + * + * Wraps `@tanstack/react-virtual`'s `useWindowVirtualizer` and supplies the + * three pieces its raw API leaves to the caller: + * + * - keeps `scrollMargin` in sync with the anchor element's document-Y + * position, including when content above the anchor shifts size (page + * header growing, banner appearing, sidebar variant flipping); + * - exposes the stable `measureElement` reference so dynamic row heights + * work without recreating the ref callback on every render; + * - returns ready-to-render spacer pixels (`paddingStart` / `paddingEnd`) + * so consumers don't reimplement the arithmetic. + * + * Render structure the consumer is responsible for: + * + * ``` + * + * + * {virtualItems.map((vi) => ( + * + * ))} + * + * + * ``` + * + * Anchor placement — its top edge defines item 0's document position. For + * tables that's `` (the header sits above it, so attaching the ref to + * the table wrapper would offset all items by the header height); for + * div-based lists it's the list container itself. + * + * Anchor must be present when the hook mounts. The `scrollMargin` sync runs + * once in `useLayoutEffect` and does not re-attach if the anchor renders + * later. If the anchor is conditional in the consumer, mount/unmount the + * hook owner together with it (the recommended pattern below already does + * this). + * + * Mount the hook owner conditionally. The underlying virtualizer attaches + * window scroll/resize listeners on every mount — there is no `enabled` + * option that skips them. Pattern: render `` only when + * `count > threshold`, so non-virtualized consumers of the same parent never + * pay the listener cost. + * + * Scroll container — assumes `window`. Layouts with an inner `overflow-auto` + * container (e.g. settings-layout's `
`) need `useVirtualizer` with an + * explicit scroll element instead; this hook will misposition items there. + * + * `overscan` defaults to 5 (not react-virtual's default of 1) so the user + * sees rows materialize ahead of the viewport edge during fast scrolling. + */ +type UseWindowVirtualListOptions = { + /** Number of items in the underlying collection. */ + count: number; + /** + * Estimated item size in pixels. Pass a memoized reference (`useCallback` + * or module-scope constant) so the virtualizer doesn't treat each render + * as a measurement-affecting change. + */ + estimateSize: () => number; + /** + * Stable identifier per index. TanStack uses this as the key for the + * internal measurement cache; without it the cache is keyed by index, + * which mis-attributes measurements when rows reorder (sort, filter) and + * heights vary. + */ + getItemKey?: (index: number) => number | string; + /** Items rendered above + below the viewport. */ + overscan?: number; +}; + +type UseWindowVirtualListResult = { + /** + * Attach to the element whose top edge marks item 0's document-Y + * position. See the structural example in the module JSDoc. + */ + anchorRef: RefObject; + /** + * Stable ref to forward to each rendered item. Pair with + * `data-index={virtualItem.index}` on the same node — TanStack reads the + * attribute to know which item the measurement belongs to. + */ + measureItem: (node: Element | null) => void; + /** Pixels of spacer to render after the last visible item. */ + paddingEnd: number; + /** Pixels of spacer to render before the first visible item. */ + paddingStart: number; + /** Sum of all item sizes — the list's full scrollable height. */ + totalSize: number; + /** Items currently in the render window (visible + overscan). */ + virtualItems: VirtualItem[]; + /** Escape hatch for callers needing `scrollToIndex`, `scrollToOffset`, … */ + virtualizer: Virtualizer; +}; + +export function useWindowVirtualList({ + count, + estimateSize, + getItemKey, + overscan = 5, +}: UseWindowVirtualListOptions): UseWindowVirtualListResult { + const anchorRef = useRef(null); + const [scrollMargin, setScrollMargin] = useState(0); + + useLayoutEffect(() => { + if (!anchorRef.current) { + return; + } + + const syncScrollMargin = () => { + const node = anchorRef.current; + + if (!node) { + return; + } + + // Anchor's document-Y (scroll-invariant): rect.top + scrollY. + setScrollMargin(node.getBoundingClientRect().top + window.scrollY); + }; + + syncScrollMargin(); + + // Re-sync when content *above* the anchor changes height (header + // growth, banner mount, sidebar variant flip) and shifts its + // document-Y. The anchor's own resize can't move its own top edge, so + // observing `document.body` is both necessary and sufficient. + const observer = new ResizeObserver(syncScrollMargin); + + observer.observe(document.body); + + window.addEventListener('resize', syncScrollMargin); + + return () => { + observer.disconnect(); + window.removeEventListener('resize', syncScrollMargin); + }; + }, []); + + const virtualizer = useWindowVirtualizer({ + count, + estimateSize, + getItemKey, + overscan, + scrollMargin, + }); + + const virtualItems = virtualizer.getVirtualItems(); + const totalSize = virtualizer.getTotalSize(); + + const firstItem = virtualItems[0]; + const lastItem = virtualItems.at(-1); + const paddingStart = firstItem ? firstItem.start - scrollMargin : 0; + const paddingEnd = lastItem ? totalSize - (lastItem.end - scrollMargin) : 0; + + return { + anchorRef, + measureItem: virtualizer.measureElement, + paddingEnd, + paddingStart, + totalSize, + virtualItems, + virtualizer, + }; +}