Skip to content

Commit

Permalink
fix: very bad performance when scrolling paginated tables (#1513)
Browse files Browse the repository at this point in the history
  • Loading branch information
astandrik authored Oct 28, 2024
1 parent 255b210 commit e2f7a25
Show file tree
Hide file tree
Showing 22 changed files with 178 additions and 119 deletions.
6 changes: 5 additions & 1 deletion src/components/PaginatedTable/PaginatedTable.scss
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@
position: relative;
z-index: 1;

// Performance optimization for row hovering.
// it actually works.
transform: translateZ(0);

&:hover {
background: var(--paginated-table-hover-color);
}
Expand Down Expand Up @@ -177,6 +181,6 @@
}

&__row-skeleton::after {
animation-delay: 200ms;
animation: none !important;
}
}
40 changes: 20 additions & 20 deletions src/components/PaginatedTable/PaginatedTable.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import React from 'react';

import {getArray} from '../../utils';
import {TableWithControlsLayout} from '../TableWithControlsLayout/TableWithControlsLayout';

import {TableChunk} from './TableChunk';
Expand Down Expand Up @@ -32,7 +31,7 @@ export interface PaginatedTableProps<T, F> {
columns: Column<T>[];
getRowClassName?: GetRowClassName<T>;
rowHeight?: number;
parentRef?: React.RefObject<HTMLElement>;
parentRef: React.RefObject<HTMLElement>;
initialSortParams?: SortParams;
onColumnsResize?: HandleTableColumnsResize;
renderControls?: RenderControls;
Expand All @@ -42,7 +41,7 @@ export interface PaginatedTableProps<T, F> {
}

export const PaginatedTable = <T, F>({
limit,
limit: chunkSize,
initialEntitiesCount,
fetchData,
filters,
Expand All @@ -58,8 +57,8 @@ export const PaginatedTable = <T, F>({
renderEmptyDataMessage,
containerClassName,
}: PaginatedTableProps<T, F>) => {
const initialTotal = initialEntitiesCount || limit;
const initialFound = initialEntitiesCount || 0;
const initialTotal = initialEntitiesCount || 0;
const initialFound = initialEntitiesCount || 1;

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

const activeChunks = useScrollBasedChunks({
containerRef: parentRef ?? tableRef,
parentRef,
tableRef,
totalItems: foundEntities,
itemHeight: rowHeight,
chunkSize: limit,
rowHeight,
chunkSize,
});

const lastChunkSize = React.useMemo(
() => foundEntities % chunkSize || chunkSize,
[foundEntities, chunkSize],
);

const handleDataFetched = React.useCallback((total: number, found: number) => {
setTotalEntities(total);
setFoundEntities(found);
Expand All @@ -88,10 +93,8 @@ export const PaginatedTable = <T, F>({
setIsInitialLoad(true);
if (parentRef?.current) {
parentRef.current.scrollTo(0, 0);
} else {
tableRef.current?.scrollTo(0, 0);
}
}, [filters, initialFound, initialTotal, limit, parentRef]);
}, [filters, initialFound, initialTotal, parentRef]);

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

const totalLength = foundEntities || limit;
const chunksCount = Math.ceil(totalLength / limit);

return getArray(chunksCount).map((value) => (
return activeChunks.map((isActive, index) => (
<TableChunk<T, F>
key={value}
id={value}
limit={limit}
totalLength={totalLength}
key={index}
id={index}
calculatedCount={index === activeChunks.length - 1 ? lastChunkSize : chunkSize}
chunkSize={chunkSize}
rowHeight={rowHeight}
columns={columns}
fetchData={fetchData}
Expand All @@ -122,7 +122,7 @@ export const PaginatedTable = <T, F>({
getRowClassName={getRowClassName}
renderErrorMessage={renderErrorMessage}
onDataFetched={handleDataFetched}
isActive={activeChunks.includes(value)}
isActive={isActive}
/>
));
};
Expand Down
29 changes: 12 additions & 17 deletions src/components/PaginatedTable/TableChunk.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@ import {ResponseError} from '../Errors/ResponseError';

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

const DEBOUNCE_TIMEOUT = 200;

interface TableChunkProps<T, F> {
id: number;
limit: number;
totalLength: number;
chunkSize: number;
rowHeight: number;
calculatedCount: number;
columns: Column<T>[];
filters?: F;
sortParams?: SortParams;
Expand All @@ -29,10 +30,10 @@ interface TableChunkProps<T, F> {
}

// Memoisation prevents chunks rerenders that could cause perfomance issues on big tables
export const TableChunk = <T, F>({
export const TableChunk = typedMemo(function TableChunk<T, F>({
id,
limit,
totalLength,
chunkSize,
calculatedCount,
rowHeight,
columns,
fetchData,
Expand All @@ -43,15 +44,15 @@ export const TableChunk = <T, F>({
renderErrorMessage,
onDataFetched,
isActive,
}: TableChunkProps<T, F>) => {
}: TableChunkProps<T, F>) {
const [isTimeoutActive, setIsTimeoutActive] = React.useState(true);
const [autoRefreshInterval] = useAutoRefreshInterval();

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

const queryParams = {
offset: id * limit,
limit,
offset: id * chunkSize,
limit: chunkSize,
fetchData: fetchData as FetchData<T, unknown>,
filters,
sortParams,
Expand Down Expand Up @@ -87,11 +88,7 @@ export const TableChunk = <T, F>({
}
}, [currentData, isActive, onDataFetched]);

const chunkOffset = id * limit;
const remainingLength = totalLength - chunkOffset;
const calculatedChunkLength = remainingLength < limit ? remainingLength : limit;

const dataLength = currentData?.data?.length || calculatedChunkLength;
const dataLength = currentData?.data?.length || calculatedCount;

const renderContent = () => {
if (!isActive) {
Expand Down Expand Up @@ -134,13 +131,11 @@ export const TableChunk = <T, F>({
));
};

const chunkHeight = dataLength ? dataLength * rowHeight : limit * rowHeight;

return (
<tbody
id={id.toString()}
style={{
height: `${chunkHeight}px`,
height: `${dataLength * rowHeight}px`,
// Default display: table-row-group doesn't work in Safari and breaks the table
// display: block works in Safari, but disconnects thead and tbody cell grids
// Hack to make it work in all cases
Expand All @@ -150,4 +145,4 @@ export const TableChunk = <T, F>({
{renderContent()}
</tbody>
);
};
});
2 changes: 1 addition & 1 deletion src/components/PaginatedTable/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,6 @@ export const DEFAULT_SORT_ORDER = DESCENDING;
// Time in ms after which request will be sent
export const DEFAULT_REQUEST_TIMEOUT = 200;

export const DEFAULT_TABLE_ROW_HEIGHT = 40;
export const DEFAULT_TABLE_ROW_HEIGHT = 41;

export const DEFAULT_INTERSECTION_OBSERVER_MARGIN = '100%';
95 changes: 59 additions & 36 deletions src/components/PaginatedTable/useScrollBasedChunks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,68 +2,91 @@ import React from 'react';

import {throttle} from 'lodash';

import {getArray} from '../../utils';
import {calculateElementOffsetTop} from './utils';

interface UseScrollBasedChunksProps {
containerRef: React.RefObject<HTMLElement>;
parentRef: React.RefObject<HTMLElement>;
tableRef: React.RefObject<HTMLElement>;
totalItems: number;
itemHeight: number;
rowHeight: number;
chunkSize: number;
overscanCount?: number;
}

const DEFAULT_OVERSCAN_COUNT = 1;
const THROTTLE_DELAY = 100;
const CHUNKS_AHEAD_COUNT = 1;

export const useScrollBasedChunks = ({
containerRef,
parentRef,
tableRef,
totalItems,
itemHeight,
rowHeight,
chunkSize,
}: UseScrollBasedChunksProps): number[] => {
const [activeChunks, setActiveChunks] = React.useState<number[]>(
getArray(1 + CHUNKS_AHEAD_COUNT).map((index) => index),
overscanCount = DEFAULT_OVERSCAN_COUNT,
}: UseScrollBasedChunksProps): boolean[] => {
const chunksCount = React.useMemo(
() => Math.ceil(totalItems / chunkSize),
[chunkSize, totalItems],
);

const calculateActiveChunks = React.useCallback(() => {
const container = containerRef.current;
if (!container) {
return;
}
const [startChunk, setStartChunk] = React.useState(0);
const [endChunk, setEndChunk] = React.useState(
Math.min(overscanCount, Math.max(chunksCount - 1, 0)),
);

const {scrollTop, clientHeight} = container;
const visibleStartIndex = Math.floor(scrollTop / itemHeight);
const visibleEndIndex = Math.min(
Math.ceil((scrollTop + clientHeight) / itemHeight),
totalItems - 1,
);
const calculateVisibleRange = React.useCallback(() => {
const container = parentRef?.current;
const table = tableRef.current;
if (!container || !table) {
return null;
}

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

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

setActiveChunks(newActiveChunks);
}, [chunkSize, containerRef, itemHeight, totalItems]);
return {start, end};
}, [parentRef, tableRef, rowHeight, chunkSize, overscanCount, chunksCount]);

const throttledCalculateActiveChunks = React.useMemo(
() => throttle(calculateActiveChunks, THROTTLE_DELAY),
[calculateActiveChunks],
);
const handleScroll = React.useCallback(() => {
const newRange = calculateVisibleRange();
if (newRange) {
setStartChunk(newRange.start);
setEndChunk(newRange.end);
}
}, [calculateVisibleRange]);

React.useEffect(() => {
const container = containerRef.current;
const container = parentRef?.current;
if (!container) {
return undefined;
}

container.addEventListener('scroll', throttledCalculateActiveChunks);
const throttledHandleScroll = throttle(handleScroll, THROTTLE_DELAY, {
leading: true,
trailing: true,
});

container.addEventListener('scroll', throttledHandleScroll);
return () => {
container.removeEventListener('scroll', throttledCalculateActiveChunks);
throttledCalculateActiveChunks.cancel();
container.removeEventListener('scroll', throttledHandleScroll);
throttledHandleScroll.cancel();
};
}, [containerRef, throttledCalculateActiveChunks]);
}, [handleScroll, parentRef]);

return activeChunks;
return React.useMemo(() => {
// boolean array that represents active chunks
const activeChunks = Array(chunksCount).fill(false);
for (let i = startChunk; i <= endChunk; i++) {
activeChunks[i] = true;
}
return activeChunks;
}, [chunksCount, startChunk, endChunk]);
};
25 changes: 0 additions & 25 deletions src/components/PaginatedTable/utils.ts

This file was deleted.

Loading

0 comments on commit e2f7a25

Please sign in to comment.