From 61794dc21f6618f9e8a1fa9687f445423ba38a00 Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Thu, 4 Jun 2026 16:15:38 +0200 Subject: [PATCH 1/4] perf(grid): memoize DataGrid rows for fluid scroll with many rows/columns Scrolling tables with ~500 rows and 30-40 columns was very laggy: the whole tbody re-rendered on every scroll tick (no row/cell memoization) and each row was dynamically re-measured. - Extract the per-row render into a React.memo MemoRow (DataGridRow.tsx). Stable per-grid deps are bundled into one memoized rowCtx so the default shallow compare only re-renders rows that actually changed; volatile per-row values are passed as primitives. - Stabilize handleCellDoubleClick/handleEditCommit/handleKeyDown with useCallback (reading live editingCell via a ref) so the memo holds. - Compute formatCellValue once per cell (was twice: title + display). - Fixed row height (height:35) and drop measureElement/data-index from the rows, matching the proven fixed-size pattern in MiniResultGrid. - Move the cell default-render helper to src/utils/dataGridCell.tsx and add unit tests. --- src/components/ui/DataGrid.tsx | 739 +++++++----------------------- src/components/ui/DataGridRow.tsx | 696 ++++++++++++++++++++++++++++ src/utils/dataGridCell.tsx | 21 + tests/utils/dataGridCell.test.tsx | 62 +++ 4 files changed, 936 insertions(+), 582 deletions(-) create mode 100644 src/components/ui/DataGridRow.tsx create mode 100644 src/utils/dataGridCell.tsx create mode 100644 tests/utils/dataGridCell.test.tsx diff --git a/src/components/ui/DataGrid.tsx b/src/components/ui/DataGrid.tsx index 43333d51..49dc0cef 100644 --- a/src/components/ui/DataGrid.tsx +++ b/src/components/ui/DataGrid.tsx @@ -41,16 +41,11 @@ import { getColumnSortState, calculateSelectionRange, toggleSetValue, - resolveInsertionCellDisplay, - resolveExistingCellDisplay, - getCellStateClass, type MergedRow, - type ColumnDisplayInfo, } from "../../utils/dataGrid"; import { isGeometricType, formatGeometricValue } from "../../utils/geometry"; import { isBlobColumn, isBlobWireFormat } from "../../utils/blob"; import { isJsonColumn, isJsonContent } from "../../utils/json"; -import { isLongTextCellTarget, truncateCellPreview } from "../../utils/text"; import { pickPrimaryForeignKeyByColumn, getForeignKeyForPreview, @@ -60,13 +55,7 @@ import { parseDateTime, formatDateTime, } from "../../utils/dateInput"; -import { GeometryInput } from "./GeometryInput"; -import { DateInput } from "./DateInput"; import { RowEditorSidebar } from "./RowEditorSidebar"; -import { JsonCell } from "./JsonCell"; -import { JsonExpansionEditor } from "./JsonExpansionEditor"; -import { TextCell } from "./TextCell"; -import { TextExpansionEditor } from "./TextExpansionEditor"; import { useDatabase } from "../../hooks/useDatabase"; import { rowsToCSV, @@ -80,6 +69,7 @@ import type { TableColumn, ForeignKey, } from "../../types/editor"; +import { MemoRow, type RowCtx } from "./DataGridRow"; interface DataGridProps { columns: string[]; @@ -219,6 +209,13 @@ export const DataGrid = React.memo( colIndex: number; } | null>(null); const editInputRef = useRef(null); + // Mirror of editingCell so the commit/keydown callbacks can read the latest + // value without listing editingCell in their deps — keeps their identity + // stable so the memoized rows don't re-render on every keystroke/scroll. + const editingCellRef = useRef(editingCell); + useEffect(() => { + editingCellRef.current = editingCell; + }, [editingCell]); const pendingJsonSessions = useRef< Map >(new Map()); @@ -442,11 +439,24 @@ export const DataGrid = React.memo( } }, [editingCell]); - const handleCellDoubleClick = ( - rowIndex: number, - colIndex: number, - value: unknown, - ) => { + const buildRowDataWithPending = useCallback( + (rowArray: unknown[], isInsertion: boolean): Record => { + const rowData: Record = {}; + columns.forEach((col, idx) => { + rowData[col] = rowArray[idx]; + }); + if (!isInsertion && pkIndexMap !== null) { + const pkVal = rowArray[pkIndexMap]; + const pending = pendingChanges?.[String(pkVal)]?.changes; + if (pending) Object.assign(rowData, pending); + } + return rowData; + }, + [columns, pkIndexMap, pendingChanges], + ); + + const handleCellDoubleClick = useCallback( + (rowIndex: number, colIndex: number, value: unknown) => { if (!tableName || readonlyProp) return; const mergedRow = mergedRows[rowIndex]; @@ -499,13 +509,26 @@ export const DataGrid = React.memo( } setEditingCell({ rowIndex, colIndex, value: editValue }); - }; + }, + [ + tableName, + readonlyProp, + mergedRows, + pkColumn, + columns, + columnTypeMap, + columnLengthMap, + buildRowDataWithPending, + openJsonViewerWindow, + ], + ); const isCommittingRef = useRef(false); - const handleEditCommit = async () => { + const handleEditCommit = useCallback(async () => { // Prevent multiple concurrent commits (e.g., from rapid blur events) if (isCommittingRef.current) return; + const editingCell = editingCellRef.current; if (!editingCell || !tableName) { setEditingCell(null); return; @@ -596,9 +619,23 @@ export const DataGrid = React.memo( } finally { isCommittingRef.current = false; } - }; + }, [ + tableName, + mergedRows, + columns, + onPendingInsertionChange, + onPendingChange, + pkIndexMap, + pkColumn, + connectionId, + activeSchema, + onRefresh, + showAlert, + t, + ]); - const handleKeyDown = (e: React.KeyboardEvent) => { + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + const editingCell = editingCellRef.current; if (e.key === "Enter") { handleEditCommit(); } else if (e.key === "Escape") { @@ -645,7 +682,7 @@ export const DataGrid = React.memo( }, 0); } } - }; + }, [handleEditCommit, mergedRows, columns]); const columnHelper = useMemo(() => createColumnHelper(), []); @@ -916,22 +953,6 @@ export const DataGrid = React.memo( setContextMenu(null); }, [contextMenu, columns, pendingInsertions, onDuplicateRow]); - const buildRowDataWithPending = useCallback( - (rowArray: unknown[], isInsertion: boolean): Record => { - const rowData: Record = {}; - columns.forEach((col, idx) => { - rowData[col] = rowArray[idx]; - }); - if (!isInsertion && pkIndexMap !== null) { - const pkVal = rowArray[pkIndexMap]; - const pending = pendingChanges?.[String(pkVal)]?.changes; - if (pending) Object.assign(rowData, pending); - } - return rowData; - }, - [columns, pkIndexMap, pendingChanges], - ); - const openSidebarEditor = useCallback(() => { if (!contextMenu) return; const isInsertion = contextMenu.mergedRow?.type === "insertion"; @@ -1135,6 +1156,83 @@ export const DataGrid = React.memo( return () => document.removeEventListener("keydown", handleKeyDown); }, [editingCell, selectedRowIndices, focusedCell, copyCellValue, copySelectedCells, readonlyProp, deleteRowsByIndices]); + // Stable per-row dependency bundle. Memoizing it lets React.memo on MemoRow + // skip re-rendering rows that didn't change during scroll. + const rowCtx: RowCtx = useMemo( + () => ({ + columns, + autoIncrementColumns, + defaultValueColumns, + nullableColumns, + pkColumn, + pendingChanges, + columnTypeMap, + columnLengthMap, + isJsonCellTarget, + fksByColumn, + t, + mergedRows, + pkIndexMap, + parentViewportWidth, + readonly: readonlyProp, + updateSelection, + setFocusedCell, + setExpandedCell, + setEditingCell, + setSidebarRowData, + setSidebarOpen, + handleRowClick, + handleCellDoubleClick, + handleContextMenu, + handleEditCommit, + handleKeyDown, + onForeignKeyShowPanel, + onForeignKeyHidePanel, + onForeignKeyNavigate, + onPendingChange, + onPendingInsertionChange, + openJsonViewerWindow, + buildRowDataWithPending, + editInputRef, + }), + [ + columns, + autoIncrementColumns, + defaultValueColumns, + nullableColumns, + pkColumn, + pendingChanges, + columnTypeMap, + columnLengthMap, + isJsonCellTarget, + fksByColumn, + t, + mergedRows, + pkIndexMap, + parentViewportWidth, + readonlyProp, + updateSelection, + setFocusedCell, + setExpandedCell, + setEditingCell, + setSidebarRowData, + setSidebarOpen, + handleRowClick, + handleCellDoubleClick, + handleContextMenu, + handleEditCommit, + handleKeyDown, + onForeignKeyShowPanel, + onForeignKeyHidePanel, + onForeignKeyNavigate, + onPendingChange, + onPendingInsertionChange, + openJsonViewerWindow, + buildRowDataWithPending, + editInputRef, + ], + ); + // Show "no data" if there are no columns (even with pending insertions, we can't render without column info) // OR if there are columns but no data and no pending insertions if (columns.length === 0) { @@ -1201,563 +1299,40 @@ export const DataGrid = React.memo( {rowVirtualizer.getVirtualItems().map((virtualRow) => { - const row = tableRows[virtualRow.index]; const rowIndex = virtualRow.index; + const row = tableRows[rowIndex]; + const rowOriginal = row.original as unknown[]; const isSelected = selectedRowIndices.has(rowIndex); - - // Check if this is an insertion row const mergedRow = mergedRows[rowIndex]; const isInsertion = mergedRow?.type === "insertion"; - - // Get PK for pending check (using pre-calculated pkIndexMap) const pkVal = pkIndexMap !== null - ? String(row.original[pkIndexMap]) + ? String(rowOriginal[pkIndexMap]) : null; const isPendingDelete = !isInsertion && pkVal ? pendingDeletions?.[pkVal] !== undefined : false; - const expansionMatchesRow = - expandedCell?.rowIndex === rowIndex; - + const isRowEditing = editingCell?.rowIndex === rowIndex; + const isRowFocused = focusedCell?.rowIndex === rowIndex; + const isRowExpanded = expandedCell?.rowIndex === rowIndex; return ( - - - { - setFocusedCell(null); - onForeignKeyHidePanel?.(); - handleRowClick(rowIndex, e); - }} - className={`px-2 py-1.5 text-xs text-center border-b border-r border-default sticky left-0 z-10 cursor-pointer select-none w-[50px] min-w-[50px] ${ - isInsertion - ? isSelected - ? "bg-blue-900/40 text-blue-200 font-bold" - : "bg-green-950/30 text-green-300 font-bold" - : isPendingDelete - ? "bg-red-950/50 text-red-500 line-through" - : isSelected - ? "bg-blue-900/40 text-blue-200 font-bold" - : "bg-base text-muted hover:bg-surface-secondary" - }`} - > - {isInsertion ? "NEW" : rowIndex + 1} - - {row.getVisibleCells().map((cell, colIndex) => { - const isEditing = - editingCell?.rowIndex === rowIndex && - editingCell?.colIndex === colIndex; - - const colName = cell.column.id; - - const columnInfo: ColumnDisplayInfo = { - colName, - autoIncrementColumns, - defaultValueColumns, - nullableColumns, - }; - - const resolved = isInsertion - ? resolveInsertionCellDisplay( - cell.getValue(), - columnInfo, - ) - : resolveExistingCellDisplay( - cell.getValue(), - pkVal, - pkColumn, - pendingChanges, - columnInfo, - ); - - const { - displayValue, - hasPendingChange, - isModified, - isAutoIncrementPlaceholder, - isDefaultValuePlaceholder, - } = resolved; - - const colTypeForCell = columnTypeMap?.get(colName); - const rawCellValue = cell.getValue(); - const isJsonCell = - isJsonCellTarget(colTypeForCell, rawCellValue) && - !isPendingDelete; - const isLongTextCell = - !isJsonCell && - !isPendingDelete && - isLongTextCellTarget( - colTypeForCell, - hasPendingChange ? displayValue : rawCellValue, - ); - - const stateClass = getCellStateClass({ - isPendingDelete, - isSelected, - isInsertion, - isAutoIncrementPlaceholder, - isDefaultValuePlaceholder, - isModified, - isJsonCell, - }); - - const isFocused = - focusedCell?.rowIndex === rowIndex && - focusedCell?.colIndex === colIndex; - - const fkForPreview = getForeignKeyForPreview( - colName, - rawCellValue, - fksByColumn, - { isPendingDelete, isInsertion }, - ); - - return ( - { - // Don't handle row click if clicking on a button - const target = e.target as HTMLElement; - if (target.closest("button")) { - return; - } - setFocusedCell({ rowIndex, colIndex }); - updateSelection(new Set()); - - if (fkForPreview && onForeignKeyShowPanel) { - onForeignKeyShowPanel( - fkForPreview, - rawCellValue, - ); - } else { - onForeignKeyHidePanel?.(); - } - }} - onDoubleClick={() => - !isPendingDelete && - handleCellDoubleClick( - rowIndex, - colIndex, - isAutoIncrementPlaceholder || - isDefaultValuePlaceholder - ? "" - : displayValue, - ) - } - onContextMenu={(e) => - handleContextMenu( - e, - row.original, - rowIndex, - colIndex, - colName, - ) - } - className={`px-4 py-1.5 text-sm border-b border-r border-default last:border-r-0 font-mono ${isEditing ? "relative" : "whitespace-nowrap truncate max-w-[300px]"} ${fkForPreview ? "cursor-pointer" : "cursor-text"} ${stateClass} ${isFocused ? "ring-2 ring-inset ring-blue-400" : ""}`} - title={ - !isEditing - ? truncateCellPreview( - formatCellValue( - displayValue, - t("dataGrid.null"), - colTypeForCell, - columnLengthMap?.get(colName), - ), - ).text - : "" - } - > - {isEditing - ? (() => { - const colType = columnTypeMap?.get(colName); - if (colType && isGeometricType(colType)) { - return ( - - setEditingCell((prev) => - prev - ? { - ...prev, - value: newValue, - isRawSql, - } - : null, - ) - } - onBlur={handleEditCommit} - onKeyDown={handleKeyDown} - onSqlFunctionsClick={() => { - // Close inline editing - setEditingCell(null); - - // Open sidebar with the current row - const mergedRow = - mergedRows[rowIndex]; - if (mergedRow) { - setSidebarRowData({ - data: buildRowDataWithPending( - mergedRow.rowData, - mergedRow.type === "insertion", - ), - rowIndex: rowIndex, - focusField: colName, - }); - setSidebarOpen(true); - } - }} - className="w-full bg-base text-primary border-none outline-none p-0 m-0 font-mono" - /> - ); - } - const dateMode = colType - ? getDateInputMode(colType) - : null; - if (dateMode) { - return ( - - setEditingCell((prev) => - prev - ? { ...prev, value: newValue } - : null, - ) - } - onBlur={handleEditCommit} - onKeyDown={handleKeyDown} - inputRef={editInputRef} - /> - ); - } - const textValue = String( - editingCell.value ?? "", - ); - // Measure the longest line to size the textarea - const lines = textValue.split("\n"); - const canvas = - document.createElement("canvas"); - const ctx = canvas.getContext("2d"); - if (ctx) { - ctx.font = - "14px ui-monospace, SFMono-Regular, monospace"; - } - const longestLineWidth = ctx - ? Math.max( - ...lines.map( - (line) => ctx.measureText(line).width, - ), - ) - : 200; - // padding (p-2 = 8px * 2) + small buffer - const textareaWidth = - Math.ceil(longestLineWidth) + 32; - - return ( - <> - {/* Invisible placeholder to preserve td width */} - - {String(displayValue)} - -