Skip to content

Commit e2f7a25

Browse files
authored
fix: very bad performance when scrolling paginated tables (#1513)
1 parent 255b210 commit e2f7a25

22 files changed

+178
-119
lines changed

src/components/PaginatedTable/PaginatedTable.scss

+5-1
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@
2929
position: relative;
3030
z-index: 1;
3131

32+
// Performance optimization for row hovering.
33+
// it actually works.
34+
transform: translateZ(0);
35+
3236
&:hover {
3337
background: var(--paginated-table-hover-color);
3438
}
@@ -177,6 +181,6 @@
177181
}
178182

179183
&__row-skeleton::after {
180-
animation-delay: 200ms;
184+
animation: none !important;
181185
}
182186
}

src/components/PaginatedTable/PaginatedTable.tsx

+20-20
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import React from 'react';
22

3-
import {getArray} from '../../utils';
43
import {TableWithControlsLayout} from '../TableWithControlsLayout/TableWithControlsLayout';
54

65
import {TableChunk} from './TableChunk';
@@ -32,7 +31,7 @@ export interface PaginatedTableProps<T, F> {
3231
columns: Column<T>[];
3332
getRowClassName?: GetRowClassName<T>;
3433
rowHeight?: number;
35-
parentRef?: React.RefObject<HTMLElement>;
34+
parentRef: React.RefObject<HTMLElement>;
3635
initialSortParams?: SortParams;
3736
onColumnsResize?: HandleTableColumnsResize;
3837
renderControls?: RenderControls;
@@ -42,7 +41,7 @@ export interface PaginatedTableProps<T, F> {
4241
}
4342

4443
export const PaginatedTable = <T, F>({
45-
limit,
44+
limit: chunkSize,
4645
initialEntitiesCount,
4746
fetchData,
4847
filters,
@@ -58,8 +57,8 @@ export const PaginatedTable = <T, F>({
5857
renderEmptyDataMessage,
5958
containerClassName,
6059
}: PaginatedTableProps<T, F>) => {
61-
const initialTotal = initialEntitiesCount || limit;
62-
const initialFound = initialEntitiesCount || 0;
60+
const initialTotal = initialEntitiesCount || 0;
61+
const initialFound = initialEntitiesCount || 1;
6362

6463
const [sortParams, setSortParams] = React.useState<SortParams | undefined>(initialSortParams);
6564
const [totalEntities, setTotalEntities] = React.useState(initialTotal);
@@ -69,12 +68,18 @@ export const PaginatedTable = <T, F>({
6968
const tableRef = React.useRef<HTMLDivElement>(null);
7069

7170
const activeChunks = useScrollBasedChunks({
72-
containerRef: parentRef ?? tableRef,
71+
parentRef,
72+
tableRef,
7373
totalItems: foundEntities,
74-
itemHeight: rowHeight,
75-
chunkSize: limit,
74+
rowHeight,
75+
chunkSize,
7676
});
7777

78+
const lastChunkSize = React.useMemo(
79+
() => foundEntities % chunkSize || chunkSize,
80+
[foundEntities, chunkSize],
81+
);
82+
7883
const handleDataFetched = React.useCallback((total: number, found: number) => {
7984
setTotalEntities(total);
8085
setFoundEntities(found);
@@ -88,10 +93,8 @@ export const PaginatedTable = <T, F>({
8893
setIsInitialLoad(true);
8994
if (parentRef?.current) {
9095
parentRef.current.scrollTo(0, 0);
91-
} else {
92-
tableRef.current?.scrollTo(0, 0);
9396
}
94-
}, [filters, initialFound, initialTotal, limit, parentRef]);
97+
}, [filters, initialFound, initialTotal, parentRef]);
9598

9699
const renderChunks = () => {
97100
if (!isInitialLoad && foundEntities === 0) {
@@ -104,15 +107,12 @@ export const PaginatedTable = <T, F>({
104107
);
105108
}
106109

107-
const totalLength = foundEntities || limit;
108-
const chunksCount = Math.ceil(totalLength / limit);
109-
110-
return getArray(chunksCount).map((value) => (
110+
return activeChunks.map((isActive, index) => (
111111
<TableChunk<T, F>
112-
key={value}
113-
id={value}
114-
limit={limit}
115-
totalLength={totalLength}
112+
key={index}
113+
id={index}
114+
calculatedCount={index === activeChunks.length - 1 ? lastChunkSize : chunkSize}
115+
chunkSize={chunkSize}
116116
rowHeight={rowHeight}
117117
columns={columns}
118118
fetchData={fetchData}
@@ -122,7 +122,7 @@ export const PaginatedTable = <T, F>({
122122
getRowClassName={getRowClassName}
123123
renderErrorMessage={renderErrorMessage}
124124
onDataFetched={handleDataFetched}
125-
isActive={activeChunks.includes(value)}
125+
isActive={isActive}
126126
/>
127127
));
128128
};

src/components/PaginatedTable/TableChunk.tsx

+12-17
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@ import {ResponseError} from '../Errors/ResponseError';
88

99
import {EmptyTableRow, LoadingTableRow, TableRow} from './TableRow';
1010
import type {Column, FetchData, GetRowClassName, SortParams} from './types';
11+
import {typedMemo} from './utils';
1112

1213
const DEBOUNCE_TIMEOUT = 200;
1314

1415
interface TableChunkProps<T, F> {
1516
id: number;
16-
limit: number;
17-
totalLength: number;
17+
chunkSize: number;
1818
rowHeight: number;
19+
calculatedCount: number;
1920
columns: Column<T>[];
2021
filters?: F;
2122
sortParams?: SortParams;
@@ -29,10 +30,10 @@ interface TableChunkProps<T, F> {
2930
}
3031

3132
// Memoisation prevents chunks rerenders that could cause perfomance issues on big tables
32-
export const TableChunk = <T, F>({
33+
export const TableChunk = typedMemo(function TableChunk<T, F>({
3334
id,
34-
limit,
35-
totalLength,
35+
chunkSize,
36+
calculatedCount,
3637
rowHeight,
3738
columns,
3839
fetchData,
@@ -43,15 +44,15 @@ export const TableChunk = <T, F>({
4344
renderErrorMessage,
4445
onDataFetched,
4546
isActive,
46-
}: TableChunkProps<T, F>) => {
47+
}: TableChunkProps<T, F>) {
4748
const [isTimeoutActive, setIsTimeoutActive] = React.useState(true);
4849
const [autoRefreshInterval] = useAutoRefreshInterval();
4950

5051
const columnsIds = columns.map((column) => column.name);
5152

5253
const queryParams = {
53-
offset: id * limit,
54-
limit,
54+
offset: id * chunkSize,
55+
limit: chunkSize,
5556
fetchData: fetchData as FetchData<T, unknown>,
5657
filters,
5758
sortParams,
@@ -87,11 +88,7 @@ export const TableChunk = <T, F>({
8788
}
8889
}, [currentData, isActive, onDataFetched]);
8990

90-
const chunkOffset = id * limit;
91-
const remainingLength = totalLength - chunkOffset;
92-
const calculatedChunkLength = remainingLength < limit ? remainingLength : limit;
93-
94-
const dataLength = currentData?.data?.length || calculatedChunkLength;
91+
const dataLength = currentData?.data?.length || calculatedCount;
9592

9693
const renderContent = () => {
9794
if (!isActive) {
@@ -134,13 +131,11 @@ export const TableChunk = <T, F>({
134131
));
135132
};
136133

137-
const chunkHeight = dataLength ? dataLength * rowHeight : limit * rowHeight;
138-
139134
return (
140135
<tbody
141136
id={id.toString()}
142137
style={{
143-
height: `${chunkHeight}px`,
138+
height: `${dataLength * rowHeight}px`,
144139
// Default display: table-row-group doesn't work in Safari and breaks the table
145140
// display: block works in Safari, but disconnects thead and tbody cell grids
146141
// Hack to make it work in all cases
@@ -150,4 +145,4 @@ export const TableChunk = <T, F>({
150145
{renderContent()}
151146
</tbody>
152147
);
153-
};
148+
});

src/components/PaginatedTable/constants.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,6 @@ export const DEFAULT_SORT_ORDER = DESCENDING;
1313
// Time in ms after which request will be sent
1414
export const DEFAULT_REQUEST_TIMEOUT = 200;
1515

16-
export const DEFAULT_TABLE_ROW_HEIGHT = 40;
16+
export const DEFAULT_TABLE_ROW_HEIGHT = 41;
1717

1818
export const DEFAULT_INTERSECTION_OBSERVER_MARGIN = '100%';

src/components/PaginatedTable/useScrollBasedChunks.ts

+59-36
Original file line numberDiff line numberDiff line change
@@ -2,68 +2,91 @@ import React from 'react';
22

33
import {throttle} from 'lodash';
44

5-
import {getArray} from '../../utils';
5+
import {calculateElementOffsetTop} from './utils';
66

77
interface UseScrollBasedChunksProps {
8-
containerRef: React.RefObject<HTMLElement>;
8+
parentRef: React.RefObject<HTMLElement>;
9+
tableRef: React.RefObject<HTMLElement>;
910
totalItems: number;
10-
itemHeight: number;
11+
rowHeight: number;
1112
chunkSize: number;
13+
overscanCount?: number;
1214
}
1315

16+
const DEFAULT_OVERSCAN_COUNT = 1;
1417
const THROTTLE_DELAY = 100;
15-
const CHUNKS_AHEAD_COUNT = 1;
1618

1719
export const useScrollBasedChunks = ({
18-
containerRef,
20+
parentRef,
21+
tableRef,
1922
totalItems,
20-
itemHeight,
23+
rowHeight,
2124
chunkSize,
22-
}: UseScrollBasedChunksProps): number[] => {
23-
const [activeChunks, setActiveChunks] = React.useState<number[]>(
24-
getArray(1 + CHUNKS_AHEAD_COUNT).map((index) => index),
25+
overscanCount = DEFAULT_OVERSCAN_COUNT,
26+
}: UseScrollBasedChunksProps): boolean[] => {
27+
const chunksCount = React.useMemo(
28+
() => Math.ceil(totalItems / chunkSize),
29+
[chunkSize, totalItems],
2530
);
2631

27-
const calculateActiveChunks = React.useCallback(() => {
28-
const container = containerRef.current;
29-
if (!container) {
30-
return;
31-
}
32+
const [startChunk, setStartChunk] = React.useState(0);
33+
const [endChunk, setEndChunk] = React.useState(
34+
Math.min(overscanCount, Math.max(chunksCount - 1, 0)),
35+
);
3236

33-
const {scrollTop, clientHeight} = container;
34-
const visibleStartIndex = Math.floor(scrollTop / itemHeight);
35-
const visibleEndIndex = Math.min(
36-
Math.ceil((scrollTop + clientHeight) / itemHeight),
37-
totalItems - 1,
38-
);
37+
const calculateVisibleRange = React.useCallback(() => {
38+
const container = parentRef?.current;
39+
const table = tableRef.current;
40+
if (!container || !table) {
41+
return null;
42+
}
3943

40-
const startChunk = Math.floor(visibleStartIndex / chunkSize);
41-
const endChunk = Math.floor(visibleEndIndex / chunkSize);
44+
const tableOffset = calculateElementOffsetTop(table, container);
45+
const containerScroll = container.scrollTop;
46+
const visibleStart = Math.max(containerScroll - tableOffset, 0);
47+
const visibleEnd = visibleStart + container.clientHeight;
4248

43-
const newActiveChunks = getArray(endChunk - startChunk + 1 + CHUNKS_AHEAD_COUNT).map(
44-
(index) => startChunk + index,
49+
const start = Math.max(Math.floor(visibleStart / rowHeight / chunkSize) - overscanCount, 0);
50+
const end = Math.min(
51+
Math.floor(visibleEnd / rowHeight / chunkSize) + overscanCount,
52+
Math.max(chunksCount - 1, 0),
4553
);
4654

47-
setActiveChunks(newActiveChunks);
48-
}, [chunkSize, containerRef, itemHeight, totalItems]);
55+
return {start, end};
56+
}, [parentRef, tableRef, rowHeight, chunkSize, overscanCount, chunksCount]);
4957

50-
const throttledCalculateActiveChunks = React.useMemo(
51-
() => throttle(calculateActiveChunks, THROTTLE_DELAY),
52-
[calculateActiveChunks],
53-
);
58+
const handleScroll = React.useCallback(() => {
59+
const newRange = calculateVisibleRange();
60+
if (newRange) {
61+
setStartChunk(newRange.start);
62+
setEndChunk(newRange.end);
63+
}
64+
}, [calculateVisibleRange]);
5465

5566
React.useEffect(() => {
56-
const container = containerRef.current;
67+
const container = parentRef?.current;
5768
if (!container) {
5869
return undefined;
5970
}
6071

61-
container.addEventListener('scroll', throttledCalculateActiveChunks);
72+
const throttledHandleScroll = throttle(handleScroll, THROTTLE_DELAY, {
73+
leading: true,
74+
trailing: true,
75+
});
76+
77+
container.addEventListener('scroll', throttledHandleScroll);
6278
return () => {
63-
container.removeEventListener('scroll', throttledCalculateActiveChunks);
64-
throttledCalculateActiveChunks.cancel();
79+
container.removeEventListener('scroll', throttledHandleScroll);
80+
throttledHandleScroll.cancel();
6581
};
66-
}, [containerRef, throttledCalculateActiveChunks]);
82+
}, [handleScroll, parentRef]);
6783

68-
return activeChunks;
84+
return React.useMemo(() => {
85+
// boolean array that represents active chunks
86+
const activeChunks = Array(chunksCount).fill(false);
87+
for (let i = startChunk; i <= endChunk; i++) {
88+
activeChunks[i] = true;
89+
}
90+
return activeChunks;
91+
}, [chunksCount, startChunk, endChunk]);
6992
};

src/components/PaginatedTable/utils.ts

-25
This file was deleted.

0 commit comments

Comments
 (0)