Skip to content

fix: very bad performance when scrolling paginated tables #1513

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 29 commits into from
Oct 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
1c88b4e
fix: very bad performance when scrolling paginated tables
astandrik Oct 22, 2024
658d079
Merge branch 'main' into astandrik.very-bad-performance-1472
astandrik Oct 22, 2024
8f9019b
Merge branch 'main' into astandrik.very-bad-performance-1472
astandrik Oct 22, 2024
37e02c4
fix: some performance optimizations
astandrik Oct 22, 2024
cf9d0c4
Merge branch 'main' into astandrik.very-bad-performance-1472
astandrik Oct 23, 2024
05a3271
fix: parentRef problems and microoptimizations
astandrik Oct 23, 2024
cc645d6
Merge branch 'main' into astandrik.very-bad-performance-1472
astandrik Oct 23, 2024
4e2c4dd
feat: test data
astandrik Oct 23, 2024
692aea8
fix: some refinements
astandrik Oct 24, 2024
c41217e
Merge branch 'main' into astandrik.very-bad-performance-1472
astandrik Oct 24, 2024
75774c5
fix: green test
astandrik Oct 24, 2024
798cf0f
fix: Nodes page scrolling
astandrik Oct 24, 2024
44b35cb
fix: throttling
astandrik Oct 24, 2024
4494e98
fix: parentRef cant be null
astandrik Oct 24, 2024
f96ce3e
fix: small refactor
astandrik Oct 24, 2024
f6d75f6
fix: optimize calculation
astandrik Oct 24, 2024
27f7aad
fix: nanofix
astandrik Oct 24, 2024
d7560c0
fix: optimize
astandrik Oct 24, 2024
35d54a3
fix: last chunk calc
astandrik Oct 24, 2024
6fea4b9
fix: scroll to 0
astandrik Oct 25, 2024
ea0394f
Merge branch 'main' into astandrik.very-bad-performance-1472
astandrik Oct 25, 2024
34b05a7
fix: painting optimizations
astandrik Oct 25, 2024
e7da699
fix: turn animation off
astandrik Oct 25, 2024
cfc350d
feat: turn on paginated tables by default
astandrik Oct 28, 2024
3d459a8
fix: review fixes
astandrik Oct 28, 2024
fe13311
Merge branch 'main' into astandrik.very-bad-performance-1472
astandrik Oct 28, 2024
0b47dbc
fix: remove test data
astandrik Oct 28, 2024
6eab133
fix: tests
astandrik Oct 28, 2024
60c185e
Merge branch 'main' into astandrik.very-bad-performance-1472
astandrik Oct 28, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading