diff --git a/package.json b/package.json index 69866f9628..f0e41b11bf 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "test:watch": "vitest watch --project browser --project node", "visual": "vitest run --project visual --coverage.reportsDirectory='./coverage/visual'", "visual:update": "vitest run --project visual --update", + "bench": "vitest bench --project bench", "format": "oxfmt", "format:check": "oxfmt --check", "eslint": "eslint --max-warnings 0 --cache --cache-location .cache/eslint --cache-strategy content", diff --git a/src/HeaderCell.tsx b/src/HeaderCell.tsx index c677186e10..d5a83315c0 100644 --- a/src/HeaderCell.tsx +++ b/src/HeaderCell.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from 'react'; +import { memo, useRef, useState } from 'react'; import { flushSync } from 'react-dom'; import { css } from 'ecij'; @@ -13,8 +13,7 @@ import { isCtrlKeyHeldDown, stopPropagation } from './utils'; -import type { CalculatedColumn, SortColumn } from './types'; -import type { HeaderRowProps } from './HeaderRow'; +import type { CalculatedColumn, Direction, Position, ResizedWidth, SortDirection } from './types'; const cellSortableClassname = css` @layer rdg.HeaderCell { @@ -64,37 +63,35 @@ const dragImageClassname = css` } `; -type SharedHeaderRowProps = Pick< - HeaderRowProps, - | 'sortColumns' - | 'onSortColumnsChange' - | 'setPosition' - | 'onColumnResize' - | 'onColumnResizeEnd' - | 'shouldFocusGrid' - | 'direction' - | 'onColumnsReorder' ->; - -export interface HeaderCellProps extends SharedHeaderRowProps { +export interface HeaderCellProps { column: CalculatedColumn; colSpan: number | undefined; rowIdx: number; isCellActive: boolean; + sortDirection: SortDirection | undefined; + priority: number | undefined; + onSort: (column: CalculatedColumn, ctrlClick: boolean) => void; + onColumnResize: (column: CalculatedColumn, width: ResizedWidth) => void; + onColumnResizeEnd: () => void; + onColumnsReorder: ((sourceColumnKey: string, targetColumnKey: string) => void) | undefined | null; + setPosition: (position: Position) => void; + shouldFocusGrid: boolean; + direction: Direction; draggedColumnKey: string | undefined; setDraggedColumnKey: (draggedColumnKey: string | undefined) => void; } -export default function HeaderCell({ +function HeaderCell({ column, colSpan, rowIdx, isCellActive, + sortDirection, + priority, + onSort, onColumnResize, onColumnResizeEnd, onColumnsReorder, - sortColumns, - onSortColumnsChange, setPosition, shouldFocusGrid, direction, @@ -107,11 +104,6 @@ export default function HeaderCell({ const rowSpan = getHeaderCellRowSpan(column, rowIdx); // set the tabIndex to 0 when there is no active cell so grid can receive focus const { tabIndex, childTabIndex, onFocus } = useRovingTabIndex(shouldFocusGrid || isCellActive); - const sortIndex = sortColumns?.findIndex((sort) => sort.columnKey === column.key); - const sortColumn = - sortIndex !== undefined && sortIndex > -1 ? sortColumns![sortIndex] : undefined; - const sortDirection = sortColumn?.direction; - const priority = sortColumn !== undefined && sortColumns!.length > 1 ? sortIndex! + 1 : undefined; const ariaSort = sortDirection && !priority ? (sortDirection === 'ASC' ? 'ascending' : 'descending') : undefined; const { sortable, resizable, draggable } = column; @@ -126,43 +118,6 @@ export default function HeaderCell({ isOver && cellOverClassname ); - function onSort(ctrlClick: boolean) { - if (onSortColumnsChange == null) return; - const { sortDescendingFirst } = column; - if (sortColumn === undefined) { - // not currently sorted - const nextSort: SortColumn = { - columnKey: column.key, - direction: sortDescendingFirst ? 'DESC' : 'ASC' - }; - onSortColumnsChange(sortColumns && ctrlClick ? [...sortColumns, nextSort] : [nextSort]); - } else { - let nextSortColumn: SortColumn | undefined; - if ( - (sortDescendingFirst === true && sortDirection === 'DESC') || - (sortDescendingFirst !== true && sortDirection === 'ASC') - ) { - nextSortColumn = { - columnKey: column.key, - direction: sortDirection === 'ASC' ? 'DESC' : 'ASC' - }; - } - if (ctrlClick) { - const nextSortColumns = [...sortColumns!]; - if (nextSortColumn) { - // swap direction - nextSortColumns[sortIndex!] = nextSortColumn; - } else { - // remove sort - nextSortColumns.splice(sortIndex!, 1); - } - onSortColumnsChange(nextSortColumns); - } else { - onSortColumnsChange(nextSortColumn ? [nextSortColumn] : []); - } - } - } - function handleFocus(event: React.FocusEvent) { onFocus?.(event); if (shouldFocusGrid) { @@ -177,7 +132,7 @@ export default function HeaderCell({ function onClick(event: React.MouseEvent) { if (sortable) { - onSort(event.ctrlKey || event.metaKey); + onSort(column, event.ctrlKey || event.metaKey); } } @@ -186,7 +141,7 @@ export default function HeaderCell({ if (sortable && (key === ' ' || key === 'Enter')) { // prevent scrolling event.preventDefault(); - onSort(event.ctrlKey || event.metaKey); + onSort(column, event.ctrlKey || event.metaKey); } else if ( resizable && isCtrlKeyHeldDown(event) && @@ -317,10 +272,14 @@ export default function HeaderCell({ ); } -type ResizeHandleProps = Pick< - HeaderCellProps, - 'direction' | 'column' | 'onColumnResize' | 'onColumnResizeEnd' ->; +export default memo(HeaderCell) as (props: HeaderCellProps) => React.JSX.Element; + +interface ResizeHandleProps { + direction: Direction; + column: CalculatedColumn; + onColumnResize: (column: CalculatedColumn, width: ResizedWidth) => void; + onColumnResizeEnd: () => void; +} function ResizeHandle({ direction, diff --git a/src/HeaderRow.tsx b/src/HeaderRow.tsx index 8c0f140706..b54ce80939 100644 --- a/src/HeaderRow.tsx +++ b/src/HeaderRow.tsx @@ -1,6 +1,7 @@ -import { memo, useState } from 'react'; +import { memo, useMemo, useState } from 'react'; import { css } from 'ecij'; +import { useLatestFunc } from './hooks'; import { classnames } from './utils'; import type { CalculatedColumn, @@ -8,7 +9,9 @@ import type { IterateOverViewportColumnsForRow, Maybe, Position, - ResizedWidth + ResizedWidth, + SortColumn, + SortDirection } from './types'; import type { DataGridProps } from './DataGrid'; import HeaderCell from './HeaderCell'; @@ -52,6 +55,12 @@ const headerRow = css` export const headerRowClassname = `rdg-header-row ${headerRow}`; +interface SortInfo { + direction: SortDirection; + priority: number | undefined; + index: number; +} + function HeaderRow({ headerRowClass, rowIdx, @@ -69,26 +78,86 @@ function HeaderRow({ const [draggedColumnKey, setDraggedColumnKey] = useState(); const isPositionOnRow = activeCellIdx === -1; + const sortMap = useMemo(() => { + if (!sortColumns?.length) return undefined; + const map = new Map(); + for (let i = 0; i < sortColumns.length; i++) { + const sc = sortColumns[i]; + map.set(sc.columnKey, { + direction: sc.direction, + priority: sortColumns.length > 1 ? i + 1 : undefined, + index: i + }); + } + return map; + }, [sortColumns]); + + function handleSort(column: CalculatedColumn, ctrlClick: boolean) { + if (onSortColumnsChange == null) return; + const { sortDescendingFirst } = column; + const sortInfo = sortMap?.get(column.key); + const currentSortDirection = sortInfo?.direction; + + if (currentSortDirection === undefined) { + // not currently sorted + const nextSort: SortColumn = { + columnKey: column.key, + direction: sortDescendingFirst ? 'DESC' : 'ASC' + }; + onSortColumnsChange(sortColumns && ctrlClick ? [...sortColumns, nextSort] : [nextSort]); + } else { + let nextSortColumn: SortColumn | undefined; + if ( + (sortDescendingFirst === true && currentSortDirection === 'DESC') || + (sortDescendingFirst !== true && currentSortDirection === 'ASC') + ) { + nextSortColumn = { + columnKey: column.key, + direction: currentSortDirection === 'ASC' ? 'DESC' : 'ASC' + }; + } + if (ctrlClick) { + const nextSortColumns = [...sortColumns!]; + if (nextSortColumn) { + // swap direction + nextSortColumns[sortInfo!.index] = nextSortColumn; + } else { + // remove sort + nextSortColumns.splice(sortInfo!.index, 1); + } + onSortColumnsChange(nextSortColumns); + } else { + onSortColumnsChange(nextSortColumn ? [nextSortColumn] : []); + } + } + } + + const handleSortLatest = useLatestFunc(handleSort); + const cells = iterateOverViewportColumnsForRow(activeCellIdx, { type: 'HEADER' }) - .map(([column, isCellActive, colSpan], index) => ( - - key={column.key} - column={column} - colSpan={colSpan} - rowIdx={rowIdx} - isCellActive={isCellActive} - onColumnResize={onColumnResize} - onColumnResizeEnd={onColumnResizeEnd} - onColumnsReorder={onColumnsReorder} - onSortColumnsChange={onSortColumnsChange} - sortColumns={sortColumns} - setPosition={setPosition} - shouldFocusGrid={shouldFocusGrid && index === 0} - direction={direction} - draggedColumnKey={draggedColumnKey} - setDraggedColumnKey={setDraggedColumnKey} - /> - )) + .map(([column, isCellActive, colSpan], index) => { + const sortInfo = sortMap?.get(column.key); + return ( + + key={column.key} + column={column} + colSpan={colSpan} + rowIdx={rowIdx} + isCellActive={isCellActive} + onColumnResize={onColumnResize} + onColumnResizeEnd={onColumnResizeEnd} + onColumnsReorder={onColumnsReorder} + sortDirection={sortInfo?.direction} + priority={sortInfo?.priority} + onSort={handleSortLatest} + setPosition={setPosition} + shouldFocusGrid={shouldFocusGrid && index === 0} + direction={direction} + draggedColumnKey={draggedColumnKey} + setDraggedColumnKey={setDraggedColumnKey} + /> + ); + }) .toArray(); return ( diff --git a/src/hooks/useCalculatedColumns.ts b/src/hooks/useCalculatedColumns.ts index 9929d91a17..c870d0a2b0 100644 --- a/src/hooks/useCalculatedColumns.ts +++ b/src/hooks/useCalculatedColumns.ts @@ -166,9 +166,9 @@ export function useCalculatedColumns({ templateColumns: readonly string[]; layoutCssVars: Readonly>; totalFrozenColumnWidth: number; - columnMetrics: ReadonlyMap, ColumnMetric>; + columnMetrics: readonly ColumnMetric[]; } => { - const columnMetrics = new Map, ColumnMetric>(); + const columnMetrics: ColumnMetric[] = []; let left = 0; let totalFrozenColumnWidth = 0; const templateColumns: string[] = []; @@ -184,20 +184,19 @@ export function useCalculatedColumns({ width = column.minWidth; } templateColumns.push(`${width}px`); - columnMetrics.set(column, { width, left }); + columnMetrics.push({ width, left }); left += width; } if (lastFrozenColumnIndex !== -1) { - const columnMetric = columnMetrics.get(columns[lastFrozenColumnIndex])!; + const columnMetric = columnMetrics[lastFrozenColumnIndex]; totalFrozenColumnWidth = columnMetric.left + columnMetric.width; } const layoutCssVars: Record = {}; for (let i = 0; i <= lastFrozenColumnIndex; i++) { - const column = columns[i]; - layoutCssVars[`--rdg-frozen-left-${column.idx}`] = `${columnMetrics.get(column)!.left}px`; + layoutCssVars[`--rdg-frozen-left-${columns[i].idx}`] = `${columnMetrics[i].left}px`; } return { templateColumns, layoutCssVars, totalFrozenColumnWidth, columnMetrics }; @@ -222,7 +221,7 @@ export function useCalculatedColumns({ // get the first visible non-frozen column index let colVisibleStartIdx = firstUnfrozenColumnIdx; while (colVisibleStartIdx < lastColIdx) { - const { left, width } = columnMetrics.get(columns[colVisibleStartIdx])!; + const { left, width } = columnMetrics[colVisibleStartIdx]; // if the right side of the columnn is beyond the left side of the available viewport, // then it is the first column that's at least partially visible if (left + width > viewportLeft) { @@ -234,7 +233,7 @@ export function useCalculatedColumns({ // get the last visible non-frozen column index let colVisibleEndIdx = colVisibleStartIdx; while (colVisibleEndIdx < lastColIdx) { - const { left, width } = columnMetrics.get(columns[colVisibleEndIdx])!; + const { left, width } = columnMetrics[colVisibleEndIdx]; // if the right side of the column is beyond or equal to the right side of the available viewport, // then it the last column that's at least partially visible, as the previous column's right side is not beyond the viewport. if (left + width >= viewportRight) { diff --git a/src/hooks/useColumnWidths.ts b/src/hooks/useColumnWidths.ts index 5a2388504d..88639a141f 100644 --- a/src/hooks/useColumnWidths.ts +++ b/src/hooks/useColumnWidths.ts @@ -28,12 +28,13 @@ export function useColumnWidths( columnsCanFlex && // there is enough space for columns to flex and the grid was resized gridWidth !== prevGridWidth; - const newTemplateColumns = [...templateColumns]; + let newTemplateColumns: string[] | undefined; const columnsToMeasure: string[] = []; for (const { key, idx, width } of viewportColumns) { const columnWidth = columnWidths.get(key); if (key === columnToAutoResize?.key) { + newTemplateColumns ??= [...templateColumns]; newTemplateColumns[idx] = columnToAutoResize.width === 'max-content' ? columnToAutoResize.width @@ -47,12 +48,13 @@ export function useColumnWidths( columnsToMeasureOnResize?.has(key) === true || columnWidth === undefined) ) { + newTemplateColumns ??= [...templateColumns]; newTemplateColumns[idx] = width; columnsToMeasure.push(key); } } - const gridTemplateColumns = newTemplateColumns.join(' '); + const gridTemplateColumns = (newTemplateColumns ?? templateColumns).join(' '); useLayoutEffect(updateMeasuredAndResizedWidths); diff --git a/test/bench/renderGrid.bench.tsx b/test/bench/renderGrid.bench.tsx new file mode 100644 index 0000000000..f57477da2b --- /dev/null +++ b/test/bench/renderGrid.bench.tsx @@ -0,0 +1,135 @@ +import { renderToString } from 'react-dom/server'; +import { bench } from 'vitest'; +import { render } from 'vitest-browser-react'; + +import { DataGrid } from '../../src'; +import type { Column } from '../../src'; + +// --- Data generation --- + +interface Row { + id: number; + [key: string]: unknown; +} + +function generateRows(count: number, columnCount: number): Row[] { + const rows: Row[] = []; + for (let i = 0; i < count; i++) { + const row: Row = { id: i }; + for (let j = 0; j < columnCount; j++) { + row[`col${j}`] = `cell-${i}-${j}`; + } + rows.push(row); + } + return rows; +} + +function generateColumns(count: number, options?: { frozen?: number }): Column[] { + const frozenCount = options?.frozen ?? 0; + const columns: Column[] = []; + for (let i = 0; i < count; i++) { + columns.push({ + key: `col${i}`, + name: `Column ${i}`, + width: 120, + frozen: i < frozenCount + }); + } + return columns; +} + +// Pre-generate datasets +const COLS_5 = generateColumns(5); +const COLS_20 = generateColumns(20); +const COLS_50 = generateColumns(50); +const COLS_FROZEN = generateColumns(20, { frozen: 3 }); + +const ROWS_100_5C = generateRows(100, 5); +const ROWS_1K_5C = generateRows(1_000, 5); +const ROWS_10K_5C = generateRows(10_000, 5); +const ROWS_100K_5C = generateRows(100_000, 5); +const ROWS_100_20C = generateRows(100, 20); +const ROWS_1K_20C = generateRows(1_000, 20); +const ROWS_10K_20C = generateRows(10_000, 20); +const ROWS_100_50C = generateRows(100, 50); +const ROWS_1K_50C = generateRows(1_000, 50); + +// --- Full grid render benchmarks --- + +describe('DataGrid render - row count scaling (5 columns)', () => { + bench('100 rows', async () => { + await render(); + }); + + bench('1,000 rows', async () => { + await render(); + }); + + bench('10,000 rows', async () => { + await render(); + }); + + bench('100,000 rows', async () => { + await render(); + }); +}); + +describe('DataGrid render - column count scaling (100 rows)', () => { + bench('5 columns', async () => { + await render(); + }); + + bench('20 columns', async () => { + await render(); + }); + + bench('50 columns', async () => { + await render(); + }); +}); + +describe('DataGrid render - large grids', () => { + bench('1,000 rows × 20 columns', async () => { + await render(); + }); + + bench('10,000 rows × 20 columns', async () => { + await render(); + }); + + bench('1,000 rows × 50 columns', async () => { + await render(); + }); +}); + +describe('DataGrid render - frozen columns', () => { + bench('20 columns, 3 frozen, 1,000 rows', async () => { + await render(); + }); +}); + +describe('DataGrid render - virtualization disabled', () => { + bench('100 rows × 5 columns - no virtualization', async () => { + await render(); + }); + + bench('1,000 rows × 5 columns - no virtualization', async () => { + await render(); + }); +}); + +// --- SSR benchmarks --- + +describe('DataGrid SSR (renderToString)', () => { + bench('100 rows × 5 columns', () => { + renderToString(); + }); + + bench('1,000 rows × 5 columns', () => { + renderToString(); + }); + + bench('1,000 rows × 20 columns', () => { + renderToString(); + }); +}); diff --git a/test/bench/setup.ts b/test/bench/setup.ts new file mode 100644 index 0000000000..807f8e59cb --- /dev/null +++ b/test/bench/setup.ts @@ -0,0 +1,7 @@ +import 'vitest-browser-react'; + +import { configure } from 'vitest-browser-react/pure'; + +configure({ + reactStrictMode: false +}); diff --git a/test/bench/useCalculatedColumns.bench.ts b/test/bench/useCalculatedColumns.bench.ts new file mode 100644 index 0000000000..a0a230de1c --- /dev/null +++ b/test/bench/useCalculatedColumns.bench.ts @@ -0,0 +1,171 @@ +import { bench } from 'vitest'; +import { renderHook } from 'vitest-browser-react'; + +import { useCalculatedColumns } from '../../src/hooks/useCalculatedColumns'; +import type { Column } from '../../src/types'; + +// --- Data generation --- + +interface Row { + id: number; + [key: string]: unknown; +} + +function generateColumns(count: number, options?: { frozen?: number }): Column[] { + const frozenCount = options?.frozen ?? 0; + const columns: Column[] = []; + for (let i = 0; i < count; i++) { + columns.push({ + key: `col${i}`, + name: `Column ${i}`, + width: 100 + (i % 50), + frozen: i < frozenCount + }); + } + return columns; +} + +function generateColumnsWithGroups(count: number) { + const columns: Column[] = []; + for (let i = 0; i < count; i++) { + columns.push({ + key: `col${i}`, + name: `Column ${i}`, + width: 120 + }); + } + // Return as column groups of 5 + const groups = []; + for (let i = 0; i < columns.length; i += 5) { + groups.push({ + name: `Group ${Math.floor(i / 5)}`, + children: columns.slice(i, i + 5) + }); + } + return groups; +} + +const COLS_10 = generateColumns(10); +const COLS_50 = generateColumns(50); +const COLS_100 = generateColumns(100); +const COLS_500 = generateColumns(500); +const COLS_FROZEN = generateColumns(50, { frozen: 5 }); +const COLS_GROUPED = generateColumnsWithGroups(50); + +const DEFAULT_COLUMN_OPTIONS = undefined; +const VIEWPORT_WIDTH = 1920; +const GET_COLUMN_WIDTH = () => 120; + +// --- Benchmarks --- + +describe('useCalculatedColumns - column count scaling', () => { + bench('10 columns', async () => { + await renderHook(() => + useCalculatedColumns({ + rawColumns: COLS_10, + defaultColumnOptions: DEFAULT_COLUMN_OPTIONS, + viewportWidth: VIEWPORT_WIDTH, + scrollLeft: 0, + getColumnWidth: GET_COLUMN_WIDTH, + enableVirtualization: true + }) + ); + }); + + bench('50 columns', async () => { + await renderHook(() => + useCalculatedColumns({ + rawColumns: COLS_50, + defaultColumnOptions: DEFAULT_COLUMN_OPTIONS, + viewportWidth: VIEWPORT_WIDTH, + scrollLeft: 0, + getColumnWidth: GET_COLUMN_WIDTH, + enableVirtualization: true + }) + ); + }); + + bench('100 columns', async () => { + await renderHook(() => + useCalculatedColumns({ + rawColumns: COLS_100, + defaultColumnOptions: DEFAULT_COLUMN_OPTIONS, + viewportWidth: VIEWPORT_WIDTH, + scrollLeft: 0, + getColumnWidth: GET_COLUMN_WIDTH, + enableVirtualization: true + }) + ); + }); + + bench('500 columns', async () => { + await renderHook(() => + useCalculatedColumns({ + rawColumns: COLS_500, + defaultColumnOptions: DEFAULT_COLUMN_OPTIONS, + viewportWidth: VIEWPORT_WIDTH, + scrollLeft: 0, + getColumnWidth: GET_COLUMN_WIDTH, + enableVirtualization: true + }) + ); + }); +}); + +describe('useCalculatedColumns - frozen columns', () => { + bench('50 columns with 5 frozen', async () => { + await renderHook(() => + useCalculatedColumns({ + rawColumns: COLS_FROZEN, + defaultColumnOptions: DEFAULT_COLUMN_OPTIONS, + viewportWidth: VIEWPORT_WIDTH, + scrollLeft: 0, + getColumnWidth: GET_COLUMN_WIDTH, + enableVirtualization: true + }) + ); + }); + + bench('50 columns with 5 frozen - scrolled', async () => { + await renderHook(() => + useCalculatedColumns({ + rawColumns: COLS_FROZEN, + defaultColumnOptions: DEFAULT_COLUMN_OPTIONS, + viewportWidth: VIEWPORT_WIDTH, + scrollLeft: 3000, + getColumnWidth: GET_COLUMN_WIDTH, + enableVirtualization: true + }) + ); + }); +}); + +describe('useCalculatedColumns - column groups', () => { + bench('50 columns in 10 groups', async () => { + await renderHook(() => + useCalculatedColumns({ + rawColumns: COLS_GROUPED, + defaultColumnOptions: DEFAULT_COLUMN_OPTIONS, + viewportWidth: VIEWPORT_WIDTH, + scrollLeft: 0, + getColumnWidth: GET_COLUMN_WIDTH, + enableVirtualization: true + }) + ); + }); +}); + +describe('useCalculatedColumns - virtualization off', () => { + bench('100 columns - virtualization disabled', async () => { + await renderHook(() => + useCalculatedColumns({ + rawColumns: COLS_100, + defaultColumnOptions: DEFAULT_COLUMN_OPTIONS, + viewportWidth: VIEWPORT_WIDTH, + scrollLeft: 0, + getColumnWidth: GET_COLUMN_WIDTH, + enableVirtualization: false + }) + ); + }); +}); diff --git a/test/bench/useViewportRows.bench.ts b/test/bench/useViewportRows.bench.ts new file mode 100644 index 0000000000..0c571f3427 --- /dev/null +++ b/test/bench/useViewportRows.bench.ts @@ -0,0 +1,159 @@ +import { bench } from 'vitest'; +import { renderHook } from 'vitest-browser-react'; + +import { useViewportRows } from '../../src/hooks/useViewportRows'; + +// --- Data generation --- + +interface Row { + id: number; + value: string; +} + +function generateRows(count: number): Row[] { + const rows: Row[] = []; + for (let i = 0; i < count; i++) { + rows.push({ id: i, value: `row-${i}` }); + } + return rows; +} + +const ROWS_100 = generateRows(100); +const ROWS_1K = generateRows(1_000); +const ROWS_10K = generateRows(10_000); +const ROWS_100K = generateRows(100_000); + +const FIXED_ROW_HEIGHT = 35; +const VARIABLE_ROW_HEIGHT = (row: Row) => (row.id % 3 === 0 ? 50 : 35); +const CLIENT_HEIGHT = 800; + +// --- Fixed row height benchmarks --- + +describe('useViewportRows - fixed row height', () => { + bench('100 rows', async () => { + await renderHook(() => + useViewportRows({ + rows: ROWS_100, + rowHeight: FIXED_ROW_HEIGHT, + clientHeight: CLIENT_HEIGHT, + scrollTop: 0, + enableVirtualization: true + }) + ); + }); + + bench('1,000 rows', async () => { + await renderHook(() => + useViewportRows({ + rows: ROWS_1K, + rowHeight: FIXED_ROW_HEIGHT, + clientHeight: CLIENT_HEIGHT, + scrollTop: 0, + enableVirtualization: true + }) + ); + }); + + bench('10,000 rows', async () => { + await renderHook(() => + useViewportRows({ + rows: ROWS_10K, + rowHeight: FIXED_ROW_HEIGHT, + clientHeight: CLIENT_HEIGHT, + scrollTop: 0, + enableVirtualization: true + }) + ); + }); + + bench('100,000 rows', async () => { + await renderHook(() => + useViewportRows({ + rows: ROWS_100K, + rowHeight: FIXED_ROW_HEIGHT, + clientHeight: CLIENT_HEIGHT, + scrollTop: 0, + enableVirtualization: true + }) + ); + }); + + bench('10,000 rows - scrolled to middle', async () => { + const scrollTop = (10_000 / 2) * FIXED_ROW_HEIGHT; + await renderHook(() => + useViewportRows({ + rows: ROWS_10K, + rowHeight: FIXED_ROW_HEIGHT, + clientHeight: CLIENT_HEIGHT, + scrollTop, + enableVirtualization: true + }) + ); + }); + + bench('10,000 rows - virtualization disabled', async () => { + await renderHook(() => + useViewportRows({ + rows: ROWS_10K, + rowHeight: FIXED_ROW_HEIGHT, + clientHeight: CLIENT_HEIGHT, + scrollTop: 0, + enableVirtualization: false + }) + ); + }); +}); + +// --- Variable row height benchmarks --- + +describe('useViewportRows - variable row height', () => { + bench('100 rows', async () => { + await renderHook(() => + useViewportRows({ + rows: ROWS_100, + rowHeight: VARIABLE_ROW_HEIGHT, + clientHeight: CLIENT_HEIGHT, + scrollTop: 0, + enableVirtualization: true + }) + ); + }); + + bench('1,000 rows', async () => { + await renderHook(() => + useViewportRows({ + rows: ROWS_1K, + rowHeight: VARIABLE_ROW_HEIGHT, + clientHeight: CLIENT_HEIGHT, + scrollTop: 0, + enableVirtualization: true + }) + ); + }); + + bench('10,000 rows', async () => { + await renderHook(() => + useViewportRows({ + rows: ROWS_10K, + rowHeight: VARIABLE_ROW_HEIGHT, + clientHeight: CLIENT_HEIGHT, + scrollTop: 0, + enableVirtualization: true + }) + ); + }); + + bench('10,000 rows - scrolled to middle', async () => { + // Approximate scroll position for middle of variable height rows + const scrollTop = 5_000 * 40; + await renderHook(() => + useViewportRows({ + rows: ROWS_10K, + rowHeight: VARIABLE_ROW_HEIGHT, + clientHeight: CLIENT_HEIGHT, + scrollTop, + enableVirtualization: true + }) + ); + }); +}); diff --git a/test/bench/utils.bench.ts b/test/bench/utils.bench.ts new file mode 100644 index 0000000000..07dbf6a626 --- /dev/null +++ b/test/bench/utils.bench.ts @@ -0,0 +1,105 @@ +import { bench } from 'vitest'; + +import type { CalculatedColumn, CalculatedColumnParent } from '../../src/types'; +import { classnames, getCellClassname, getCellStyle, getHeaderCellStyle } from '../../src/utils'; + +// --- Helpers --- + +function createColumn( + idx: number, + frozen = false, + parent?: CalculatedColumnParent +): CalculatedColumn { + return { + key: `col${idx}`, + name: `Column ${idx}`, + idx, + level: 0, + width: 100, + minWidth: 50, + maxWidth: undefined, + resizable: true, + sortable: true, + draggable: false, + frozen, + parent, + renderCell: () => null, + renderHeaderCell: () => null + }; +} + +// --- Benchmarks --- + +describe('classnames', () => { + bench('no args', () => { + classnames(); + }); + + bench('single string', () => { + classnames('rdg-cell'); + }); + + bench('multiple strings', () => { + classnames('rdg-cell', 'rdg-cell-frozen', 'rdg-cell-selected', 'custom-class'); + }); + + bench('mixed with falsy values', () => { + classnames('rdg-cell', false, undefined, 'rdg-cell-frozen', null, 'custom'); + }); +}); + +describe('getCellClassname', () => { + const column = createColumn(0); + const frozenColumn = createColumn(0, true); + + bench('non-frozen column', () => { + getCellClassname(column); + }); + + bench('frozen column', () => { + getCellClassname(frozenColumn); + }); + + bench('with extra classes', () => { + const isEditing = false as boolean; + getCellClassname(column, 'selected', 'active', isEditing && 'editing'); + }); +}); + +describe('getCellStyle', () => { + const column = createColumn(3); + const frozenColumn = createColumn(1, true); + + bench('non-frozen column', () => { + getCellStyle(column); + }); + + bench('frozen column', () => { + getCellStyle(frozenColumn); + }); + + bench('with colSpan', () => { + getCellStyle(column, 3); + }); +}); + +describe('getHeaderCellStyle', () => { + const column = createColumn(0); + const parent: CalculatedColumnParent = { + name: 'Parent', + parent: undefined, + idx: 0, + colSpan: 3, + level: 0, + headerCellClass: undefined + }; + const childColumn = createColumn(1, false, parent); + + bench('top-level column', () => { + getHeaderCellStyle(column, 2, 2); + }); + + bench('nested column with parent', () => { + getHeaderCellStyle(childColumn, 3, 1); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 5c4afce666..c343ecc0ea 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -159,6 +159,25 @@ export default defineConfig( environment: 'node', setupFiles: ['test/failOnConsole.ts'] } + }, + { + extends: true, + test: { + name: 'bench', + include: ['bench/**/*.bench.*'], + benchmark: { + include: ['bench/**/*.bench.*'] + }, + browser: { + enabled: true, + instances: getInstances().slice(0, 1), + viewport, + headless: true, + ui: false, + screenshotFailures: false + }, + setupFiles: ['test/browser/styles.css', 'test/bench/setup.ts'] + } } ] }