Skip to content

Commit fc7c60c

Browse files
perf(data-grid): optimize rerenders (#1104)
* perf: optimize data-grid following Vercel React best practices - Add optimizePackageImports for lucide-react tree-shaking - Use functional setState in MultiSelectCell for stable callbacks - Add searchMatchSet for O(1) search lookups instead of O(n) .some() - Extract serializeCellsToTsv helper to combine iterations and DRY copy/cut - Add content-visibility CSS to virtualized rows Co-authored-by: Cursor <cursoragent@cursor.com> * perf: convert constant lookup arrays to Sets for O(1) checks - NON_NAVIGABLE_COLUMN_IDS in use-data-grid.ts - REMOVE_FILTER_SHORTCUTS, OPERATORS_WITHOUT_VALUE in data-grid-filter-menu.tsx - REMOVE_SORT_SHORTCUTS in data-grid-sort-menu.tsx - Add seenColumnIds Set for O(1) deduplication in serializeCellsToTsv Co-authored-by: Cursor <cursoragent@cursor.com> * perf: optimize column lookups and extract shared helper - Add getEmptyValueForVariant helper to DRY empty value logic - Build columnById Map for O(1) lookups in paste/delete handlers - Build cellById Map for O(1) lookups in search function - Convert tableColumns.find() and getVisibleCells().find() to Map.get() Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: move getEmptyCellValue to lib and remove redundant comments - Rename getEmptyValueForVariant to getEmptyCellValue - Move helper to src/lib/data-grid.ts for reusability - Remove comments that just describe what code does Co-authored-by: Cursor <cursoragent@cursor.com> * chore: rebuild registry * perf: optimize cell variants with Map/Set lookups and rebuild registry - Add optionByValue Map for O(1) label lookups in SelectCell and MultiSelectCell - Add selectedValuesSet for O(1) selection checks in MultiSelectCell - Fix missing @ prefix in tanstack packages in optimizePackageImports - Rebuild registry Co-authored-by: Cursor <cursoragent@cursor.com> * chore: rebuild registry again * fix: address Copilot review feedback - Move event.preventDefault() outside setState callback for synchronous execution - Cache cellMap per row in serializeCellsToTsv to avoid redundant Map creation - Fix TypeScript type for rowCellMaps Co-authored-by: Cursor <cursoragent@cursor.com> * chore: rebuild registry again again * chore: update demo * chore: update callcabks --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 34b05c6 commit fc7c60c

File tree

11 files changed

+153
-186
lines changed

11 files changed

+153
-186
lines changed

next.config.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@ const nextConfig: NextConfig = {
1111
cacheComponents: true,
1212
// Already doing typechecking as separate task in CI
1313
typescript: { ignoreBuildErrors: true },
14+
experimental: {
15+
optimizePackageImports: [
16+
"@tanstack/query-db-collection",
17+
"@tanstack/react-db",
18+
"@tanstack/react-query",
19+
"@tanstack/react-table",
20+
"@tanstack/react-virtual",
21+
],
22+
},
1423
};
1524

1625
export default nextConfig;

public/r/data-grid-filter-menu.json

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

public/r/data-grid-sort-menu.json

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

public/r/data-grid.json

Lines changed: 4 additions & 4 deletions
Large diffs are not rendered by default.

src/app/data-grid/components/data-grid-demo.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,7 @@ export function DataGridDemo() {
9999

100100
const columns = React.useMemo<ColumnDef<Person>[]>(
101101
() => [
102-
getDataGridSelectColumn<Person>({
103-
enableRowMarkers: true,
104-
readOnly: true,
105-
}),
102+
getDataGridSelectColumn<Person>({ enableRowMarkers: true }),
106103
{
107104
id: "name",
108105
accessorKey: "name",

src/components/data-grid/data-grid-cell-variants.tsx

Lines changed: 53 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -874,6 +874,10 @@ export function SelectCell<TData>({
874874
const containerRef = React.useRef<HTMLDivElement>(null);
875875
const cellOpts = cell.column.columnDef.meta?.cell;
876876
const options = cellOpts?.variant === "select" ? cellOpts.options : [];
877+
const optionByValue = React.useMemo(
878+
() => new Map(options.map((option) => [option.value, option])),
879+
[options],
880+
);
877881

878882
const prevInitialValueRef = React.useRef(initialValue);
879883
if (initialValue !== prevInitialValueRef.current) {
@@ -918,8 +922,7 @@ export function SelectCell<TData>({
918922
[isEditing, isFocused, initialValue, tableMeta],
919923
);
920924

921-
const displayLabel =
922-
options.find((opt) => opt.value === value)?.label ?? value;
925+
const displayLabel = optionByValue.get(value)?.label ?? value;
923926

924927
return (
925928
<DataGridCellWrapper<TData>
@@ -1015,6 +1018,10 @@ export function MultiSelectCell<TData>({
10151018
const inputRef = React.useRef<HTMLInputElement>(null);
10161019
const cellOpts = cell.column.columnDef.meta?.cell;
10171020
const options = cellOpts?.variant === "multi-select" ? cellOpts.options : [];
1021+
const optionByValue = React.useMemo(
1022+
() => new Map(options.map((option) => [option.value, option])),
1023+
[options],
1024+
);
10181025
const sideOffset = -(containerRef.current?.clientHeight ?? 0);
10191026

10201027
const prevCellValueRef = React.useRef(cellValue);
@@ -1031,30 +1038,38 @@ export function MultiSelectCell<TData>({
10311038
const onValueChange = React.useCallback(
10321039
(value: string) => {
10331040
if (readOnly) return;
1034-
const newValues = selectedValues.includes(value)
1035-
? selectedValues.filter((v) => v !== value)
1036-
: [...selectedValues, value];
1037-
1038-
setSelectedValues(newValues);
1039-
tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: newValues });
1041+
let newValues: string[] = [];
1042+
setSelectedValues((curr) => {
1043+
newValues = curr.includes(value)
1044+
? curr.filter((v) => v !== value)
1045+
: [...curr, value];
1046+
return newValues;
1047+
});
1048+
queueMicrotask(() => {
1049+
tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: newValues });
1050+
inputRef.current?.focus();
1051+
});
10401052
setSearchValue("");
1041-
queueMicrotask(() => inputRef.current?.focus());
10421053
},
1043-
[selectedValues, tableMeta, rowIndex, columnId, readOnly],
1054+
[tableMeta, rowIndex, columnId, readOnly],
10441055
);
10451056

10461057
const removeValue = React.useCallback(
10471058
(valueToRemove: string, event?: React.MouseEvent) => {
10481059
if (readOnly) return;
10491060
event?.stopPropagation();
10501061
event?.preventDefault();
1051-
const newValues = selectedValues.filter((v) => v !== valueToRemove);
1052-
setSelectedValues(newValues);
1053-
tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: newValues });
1054-
// Focus back on input after removing
1055-
setTimeout(() => inputRef.current?.focus(), 0);
1062+
let newValues: string[] = [];
1063+
setSelectedValues((curr) => {
1064+
newValues = curr.filter((v) => v !== valueToRemove);
1065+
return newValues;
1066+
});
1067+
queueMicrotask(() => {
1068+
tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: newValues });
1069+
inputRef.current?.focus();
1070+
});
10561071
},
1057-
[selectedValues, tableMeta, rowIndex, columnId, readOnly],
1072+
[tableMeta, rowIndex, columnId, readOnly],
10581073
);
10591074

10601075
const clearAll = React.useCallback(() => {
@@ -1103,31 +1118,37 @@ export function MultiSelectCell<TData>({
11031118

11041119
const onInputKeyDown = React.useCallback(
11051120
(event: React.KeyboardEvent<HTMLInputElement>) => {
1106-
// Handle backspace when input is empty - remove last selected item
1107-
if (
1108-
event.key === "Backspace" &&
1109-
searchValue === "" &&
1110-
selectedValues.length > 0
1111-
) {
1121+
if (event.key === "Backspace" && searchValue === "") {
11121122
event.preventDefault();
1113-
const lastValue = selectedValues[selectedValues.length - 1];
1114-
if (lastValue) {
1115-
removeValue(lastValue);
1116-
}
1123+
let newValues: string[] | null = null;
1124+
setSelectedValues((curr) => {
1125+
if (curr.length === 0) return curr;
1126+
newValues = curr.slice(0, -1);
1127+
return newValues;
1128+
});
1129+
queueMicrotask(() => {
1130+
if (newValues !== null) {
1131+
tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: newValues });
1132+
}
1133+
inputRef.current?.focus();
1134+
});
11171135
}
1118-
// Prevent escape from propagating to close the popover immediately
1119-
// Let the command handle it first
11201136
if (event.key === "Escape") {
11211137
event.stopPropagation();
11221138
}
11231139
},
1124-
[searchValue, selectedValues, removeValue],
1140+
[searchValue, tableMeta, rowIndex, columnId],
11251141
);
11261142

11271143
const displayLabels = selectedValues
1128-
.map((val) => options.find((opt) => opt.value === val)?.label ?? val)
1144+
.map((val) => optionByValue.get(val)?.label ?? val)
11291145
.filter(Boolean);
11301146

1147+
const selectedValuesSet = React.useMemo(
1148+
() => new Set(selectedValues),
1149+
[selectedValues],
1150+
);
1151+
11311152
const lineCount = getLineCount(rowHeight);
11321153

11331154
const { visibleItems: visibleLabels, hiddenCount: hiddenBadgeCount } =
@@ -1169,8 +1190,7 @@ export function MultiSelectCell<TData>({
11691190
<Command className="**:data-[slot=command-input-wrapper]:h-auto **:data-[slot=command-input-wrapper]:border-none **:data-[slot=command-input-wrapper]:p-0 [&_[data-slot=command-input-wrapper]_svg]:hidden">
11701191
<div className="flex min-h-9 flex-wrap items-center gap-1 border-b px-3 py-1.5">
11711192
{selectedValues.map((value) => {
1172-
const option = options.find((opt) => opt.value === value);
1173-
const label = option?.label ?? value;
1193+
const label = optionByValue.get(value)?.label ?? value;
11741194

11751195
return (
11761196
<Badge
@@ -1205,7 +1225,7 @@ export function MultiSelectCell<TData>({
12051225
<CommandEmpty>No options found.</CommandEmpty>
12061226
<CommandGroup className="max-h-[300px] scroll-py-1 overflow-y-auto overflow-x-hidden">
12071227
{options.map((option) => {
1208-
const isSelected = selectedValues.includes(option.value);
1228+
const isSelected = selectedValuesSet.has(option.value);
12091229

12101230
return (
12111231
<CommandItem

src/components/data-grid/data-grid-filter-menu.tsx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,14 @@ import { cn } from "@/lib/utils";
5353
import type { FilterOperator, FilterValue } from "@/types/data-grid";
5454

5555
const FILTER_SHORTCUT_KEY = "f";
56-
const REMOVE_FILTER_SHORTCUTS = ["backspace", "delete"];
56+
const REMOVE_FILTER_SHORTCUTS = new Set(["backspace", "delete"]);
5757
const FILTER_DEBOUNCE_MS = 300;
58-
const OPERATORS_WITHOUT_VALUE = ["isEmpty", "isNotEmpty", "isTrue", "isFalse"];
58+
const OPERATORS_WITHOUT_VALUE = new Set([
59+
"isEmpty",
60+
"isNotEmpty",
61+
"isTrue",
62+
"isFalse",
63+
]);
5964

6065
interface DataGridFilterMenuProps<TData>
6166
extends React.ComponentProps<typeof PopoverContent> {
@@ -177,7 +182,7 @@ export function DataGridFilterMenu<TData>({
177182
const onTriggerKeyDown = React.useCallback(
178183
(event: React.KeyboardEvent<HTMLButtonElement>) => {
179184
if (
180-
REMOVE_FILTER_SHORTCUTS.includes(event.key.toLowerCase()) &&
185+
REMOVE_FILTER_SHORTCUTS.has(event.key.toLowerCase()) &&
181186
columnFilters.length > 0
182187
) {
183188
event.preventDefault();
@@ -340,7 +345,7 @@ function DataGridFilterItem<TData>({
340345
const operator = filterValue?.operator ?? getDefaultOperator(variant);
341346

342347
const operators = getOperatorsForVariant(variant);
343-
const needsValue = !OPERATORS_WITHOUT_VALUE.includes(operator);
348+
const needsValue = !OPERATORS_WITHOUT_VALUE.has(operator);
344349

345350
const column = table.getColumn(filter.id);
346351

@@ -357,7 +362,7 @@ function DataGridFilterItem<TData>({
357362
return;
358363
}
359364

360-
if (REMOVE_FILTER_SHORTCUTS.includes(event.key.toLowerCase())) {
365+
if (REMOVE_FILTER_SHORTCUTS.has(event.key.toLowerCase())) {
361366
event.preventDefault();
362367
onFilterRemove(filter.id);
363368
}

src/components/data-grid/data-grid-row.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ function DataGridRowImpl<TData>({
211211
{...props}
212212
ref={rowRef}
213213
className={cn(
214-
"absolute flex w-full border-b",
214+
"absolute flex w-full border-b [content-visibility:auto]",
215215
!adjustLayout && "will-change-transform",
216216
className,
217217
)}

src/components/data-grid/data-grid-sort-menu.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ import {
4242
import { cn } from "@/lib/utils";
4343

4444
const SORT_SHORTCUT_KEY = "s";
45-
const REMOVE_SORT_SHORTCUTS = ["backspace", "delete"];
45+
const REMOVE_SORT_SHORTCUTS = new Set(["backspace", "delete"]);
4646

4747
const SORT_ORDERS = [
4848
{ label: "Asc", value: "asc" },
@@ -156,7 +156,7 @@ export function DataGridSortMenu<TData>({
156156
const onTriggerKeyDown = React.useCallback(
157157
(event: React.KeyboardEvent<HTMLButtonElement>) => {
158158
if (
159-
REMOVE_SORT_SHORTCUTS.includes(event.key.toLowerCase()) &&
159+
REMOVE_SORT_SHORTCUTS.has(event.key.toLowerCase()) &&
160160
sorting.length > 0
161161
) {
162162
event.preventDefault();
@@ -313,7 +313,7 @@ function DataTableSortItem({
313313
return;
314314
}
315315

316-
if (REMOVE_SORT_SHORTCUTS.includes(event.key.toLowerCase())) {
316+
if (REMOVE_SORT_SHORTCUTS.has(event.key.toLowerCase())) {
317317
event.preventDefault();
318318
onSortRemove(sort.id);
319319
}

0 commit comments

Comments
 (0)