Skip to content

Commit 1a80ed7

Browse files
feat(component): [worksheet] add row virtualisation via @tanstack/react-virtual (#1843)
* feat(component): [worksheet] add row virtualisation via @tanstack/react-virtual Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: restore original pnpm-lock.yaml format, keep only @TanStack additions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(docs): use named imports for useMemo and useState in worksheet example Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(component): [worksheet] add coverage tests for virtualisation branches Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(component): [worksheet] remove unreachable null guard, fix lint in tests Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(component): [worksheet] type manyItems as Partial<Product> to satisfy typecheck Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(docs): [worksheet] pass useMemo and useState into CodePreview scope Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(component): [worksheet] address PR review feedback on virtualization - Make virtualization opt-in: only active when `height` prop is set - Move `selectedCells` subscription to `VirtualScrollSync` child component to avoid re-rendering InternalWorksheet on every cell selection - Convert `hiddenRows` array lookup to `Set` for O(1) performance - Return no-op cleanup when ResizeObserver is unavailable - Use numeric `height` as JSDOM fallback; default to 0 for string heights - Drop `useMemo` from `renderedRows` (virtualItems changes every render) - Add DOM assertion that virtualized row count < total item count Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(component): [worksheet] add coverage for VirtualScrollSync scroll effect Cover the VirtualScrollSync useEffect and scrollToIndex callback by rendering with a height prop and clicking a cell. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(component): [worksheet] fix prettier formatting in spec Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(component): [worksheet] fix formatting for non-numeric values when copying Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: add changeset for worksheet virtualization minor bump Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 26eb631 commit 1a80ed7

File tree

10 files changed

+602
-253
lines changed

10 files changed

+602
-253
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@bigcommerce/big-design": minor
3+
---
4+
5+
Add opt-in row virtualization to the Worksheet component via `@tanstack/react-virtual`.
6+
7+
- New `height` prop enables virtualization — when provided, only visible rows are rendered in the DOM, significantly improving performance for large datasets.
8+
- Virtualization-aware scroll sync keeps the selected cell in view automatically (`VirtualScrollSync`).
9+
- Collapsed expandable child rows are excluded from the virtual list so the virtualizer doesn't allocate space for hidden rows.
10+
- Fix formatting function to correctly handle non-numeric values when copying (`Number(value)` guard).

packages/big-design/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"react-datepicker": "^7.3.0",
4747
"react-intersection-observer": "^10.0.0",
4848
"react-popper": "^2.3.0",
49+
"@tanstack/react-virtual": "^3.13.0",
4950
"zustand": "^5.0.11"
5051
},
5152
"peerDependencies": {

packages/big-design/src/components/Worksheet/Cell/Cell.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ const InternalCell = <T extends WorksheetItem>({
185185
}, [cell, isShiftPressed, rowIndex, selectedCells, setSelectedCells, setSelectedRows]);
186186

187187
const renderedValue = useMemo(() => {
188-
if (typeof formatting === 'function' && value !== '' && !Number.isNaN(value)) {
188+
if (typeof formatting === 'function' && value !== '' && !Number.isNaN(Number(value))) {
189189
return formatting(value);
190190
}
191191

packages/big-design/src/components/Worksheet/Worksheet.tsx

Lines changed: 140 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { BaselineHelpIcon } from '@bigcommerce/big-design-icons';
2+
import { useVirtualizer } from '@tanstack/react-virtual';
23
import React, {
34
createContext,
45
createRef,
@@ -33,6 +34,35 @@ import {
3334
} from './types';
3435
import { editedRows, invalidRows } from './utils';
3536

37+
// Syncs the virtualizer scroll position to the selected cell.
38+
// Isolated in its own component so selectedCells changes don't re-render InternalWorksheet.
39+
const VirtualScrollSync = ({
40+
scrollToIndex,
41+
visibleRowIndices,
42+
}: {
43+
scrollToIndex: (index: number) => void;
44+
visibleRowIndices: number[];
45+
}) => {
46+
const { store, useStore } = useWorksheetStore();
47+
const selectedCells = useStore(
48+
store,
49+
useShallow((state) => state.selectedCells),
50+
);
51+
52+
useEffect(() => {
53+
if (selectedCells.length > 0) {
54+
const rowIndex = selectedCells[0].rowIndex;
55+
const virtualIndex = visibleRowIndices.indexOf(rowIndex);
56+
57+
if (virtualIndex !== -1) {
58+
scrollToIndex(virtualIndex);
59+
}
60+
}
61+
}, [scrollToIndex, selectedCells, visibleRowIndices]);
62+
63+
return null;
64+
};
65+
3666
const InternalTable = ({
3767
hasExpandableRows,
3868
hasStaticWidth,
@@ -65,12 +95,14 @@ const InternalWorksheet = typedMemo(
6595
expandableRows,
6696
defaultExpandedRows,
6797
disabledRows,
98+
height,
6899
items,
69100
minWidth,
70101
onChange,
71102
onErrors,
72103
}: WorksheetProps<T>): React.ReactElement<WorksheetProps<T>> => {
73104
const tableRef = createRef<HTMLTableElement>();
105+
const scrollContainerRef = useRef<HTMLDivElement>(null);
74106
const shouldBeTriggeredOnChange = useRef(false);
75107
const { store, useStore } = useWorksheetStore();
76108
const tooltipId = useId();
@@ -112,16 +144,83 @@ const InternalWorksheet = typedMemo(
112144
store,
113145
useShallow((state) => state.invalidCells),
114146
);
147+
const hiddenRows = useStore(
148+
store,
149+
useShallow((state) => state.hiddenRows),
150+
);
115151

116152
const { handleKeyDown, handleKeyUp } = useKeyEvents();
117153

154+
// Virtualization is opt-in: only active when `height` is explicitly provided.
155+
const isVirtualized = height !== undefined;
156+
118157
// Add a column for the toggle components
119158
const expandedColumns: Array<InternalWorksheetColumn<T>> = useMemo(() => {
120159
return expandableRows
121160
? [{ hash: '', header: '', type: 'toggle', width: 32 }, ...columns]
122161
: columns;
123162
}, [columns, expandableRows]);
124163

164+
// Compute which rows are children (for virtualization filtering)
165+
const childIds = useMemo(
166+
() => new Set(Object.values(expandableRows || {}).flat()),
167+
[expandableRows],
168+
);
169+
170+
const hiddenRowsSet = useMemo(() => new Set(hiddenRows), [hiddenRows]);
171+
172+
// Only virtualise rows that are visible (non-hidden child rows are excluded)
173+
const visibleRowIndices = useMemo(
174+
() =>
175+
rows.reduce<number[]>((acc, row, index) => {
176+
if (!(childIds.has(row.id) && hiddenRowsSet.has(row.id))) {
177+
acc.push(index);
178+
}
179+
180+
return acc;
181+
}, []),
182+
[rows, childIds, hiddenRowsSet],
183+
);
184+
185+
// When height is a number use it as a JSDOM fallback (getBoundingClientRect returns 0 in tests).
186+
const fallbackHeight = typeof height === 'number' ? height : 0;
187+
188+
const virtualizer = useVirtualizer({
189+
count: isVirtualized ? visibleRowIndices.length : 0,
190+
estimateSize: () => 44,
191+
getScrollElement: () => scrollContainerRef.current,
192+
observeElementRect: (instance, cb) => {
193+
const el = instance.scrollElement!;
194+
195+
const measure = () => {
196+
const rect = el.getBoundingClientRect();
197+
198+
cb({ height: rect.height || fallbackHeight, width: rect.width });
199+
};
200+
201+
measure();
202+
203+
if (typeof ResizeObserver !== 'undefined') {
204+
const ro = new ResizeObserver(measure);
205+
206+
ro.observe(el);
207+
208+
return () => ro.disconnect();
209+
}
210+
211+
// eslint-disable-next-line @typescript-eslint/no-empty-function
212+
return () => {};
213+
},
214+
overscan: 5,
215+
});
216+
217+
const scrollToIndex = useCallback(
218+
(index: number) => virtualizer.scrollToIndex(index, { behavior: 'auto' }),
219+
[virtualizer],
220+
);
221+
222+
const virtualItems = virtualizer.getVirtualItems();
223+
125224
useEffect(() => {
126225
shouldBeTriggeredOnChange.current = editedCells.length > 0;
127226
}, [editedCells]);
@@ -201,15 +300,44 @@ const InternalWorksheet = typedMemo(
201300
[expandedColumns],
202301
);
203302

204-
const renderedRows = useMemo(
205-
() => (
206-
<tbody>
207-
{rows.map((_row, rowIndex) => (
303+
const paddingTop = virtualItems.length > 0 ? virtualItems[0].start : 0;
304+
const paddingBottom =
305+
virtualItems.length > 0
306+
? virtualizer.getTotalSize() - virtualItems[virtualItems.length - 1].end
307+
: 0;
308+
309+
const renderedRows = (
310+
<tbody>
311+
{isVirtualized ? (
312+
<>
313+
{paddingTop > 0 && (
314+
<tr aria-hidden="true">
315+
<td
316+
colSpan={expandedColumns.length + 1}
317+
style={{ border: 'none', height: paddingTop, padding: 0 }}
318+
/>
319+
</tr>
320+
)}
321+
{virtualItems.map((virtualItem) => {
322+
const rowIndex = visibleRowIndices[virtualItem.index];
323+
324+
return <Row columns={expandedColumns} key={rowIndex} rowIndex={rowIndex} />;
325+
})}
326+
{paddingBottom > 0 && (
327+
<tr aria-hidden="true">
328+
<td
329+
colSpan={expandedColumns.length + 1}
330+
style={{ border: 'none', height: paddingBottom, padding: 0 }}
331+
/>
332+
</tr>
333+
)}
334+
</>
335+
) : (
336+
rows.map((_, rowIndex) => (
208337
<Row columns={expandedColumns} key={rowIndex} rowIndex={rowIndex} />
209-
))}
210-
</tbody>
211-
),
212-
[expandedColumns, rows],
338+
))
339+
)}
340+
</tbody>
213341
);
214342

215343
const renderedModals = useMemo(
@@ -222,7 +350,7 @@ const InternalWorksheet = typedMemo(
222350

223351
return (
224352
<UpdateItemsProvider items={rows}>
225-
<StyledBox>
353+
<StyledBox containerHeight={isVirtualized ? height : undefined} ref={scrollContainerRef}>
226354
<InternalTable
227355
hasExpandableRows={Boolean(expandableRows)}
228356
hasStaticWidth={tableHasStaticWidth}
@@ -235,6 +363,9 @@ const InternalWorksheet = typedMemo(
235363
{renderedRows}
236364
</InternalTable>
237365
</StyledBox>
366+
{isVirtualized && (
367+
<VirtualScrollSync scrollToIndex={scrollToIndex} visibleRowIndices={visibleRowIndices} />
368+
)}
238369
{renderedModals}
239370
</UpdateItemsProvider>
240371
);

0 commit comments

Comments
 (0)