Skip to content
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

fix: very bad performance when scrolling paginated tables #1513

Merged
merged 29 commits into from
Oct 28, 2024
Merged
Show file tree
Hide file tree
Changes from 11 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
49 changes: 26 additions & 23 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 All @@ -20,9 +19,13 @@ import type {
SortParams,
} from './types';
import {useScrollBasedChunks} from './useScrollBasedChunks';
import {calculateElementOffsetTop} from './utils';

import './PaginatedTable.scss';

const HEADER_HEIGHT = 40;
artemmufazalov marked this conversation as resolved.
Show resolved Hide resolved
const CONTROLS_HEIGHT = 50;
artemmufazalov marked this conversation as resolved.
Show resolved Hide resolved

export interface PaginatedTableProps<T, F> {
limit: number;
initialEntitiesCount?: number;
Expand All @@ -32,7 +35,7 @@ export interface PaginatedTableProps<T, F> {
columns: Column<T>[];
getRowClassName?: GetRowClassName<T>;
rowHeight?: number;
parentRef?: React.RefObject<HTMLElement>;
parentRef: React.RefObject<HTMLElement> | null;
artemmufazalov marked this conversation as resolved.
Show resolved Hide resolved
initialSortParams?: SortParams;
onColumnsResize?: HandleTableColumnsResize;
renderControls?: RenderControls;
Expand All @@ -42,7 +45,7 @@ export interface PaginatedTableProps<T, F> {
}

export const PaginatedTable = <T, F>({
limit,
limit: chunkSize,
initialEntitiesCount,
fetchData,
filters,
Expand All @@ -58,8 +61,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,10 +72,11 @@ 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 handleDataFetched = React.useCallback((total: number, found: number) => {
Expand All @@ -83,15 +87,16 @@ export const PaginatedTable = <T, F>({

// reset table on filters change
React.useLayoutEffect(() => {
if (parentRef?.current && tableRef.current && !initialTotal) {
artemmufazalov marked this conversation as resolved.
Show resolved Hide resolved
parentRef.current.scrollTo({
left: 0,
top: calculateElementOffsetTop(tableRef.current) - HEADER_HEIGHT - CONTROLS_HEIGHT,
});
}
setTotalEntities(initialTotal);
setFoundEntities(initialFound);
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 +109,13 @@ export const PaginatedTable = <T, F>({
);
}

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

return getArray(chunksCount).map((value) => (
const totalItemsCount = foundEntities || chunkSize;
return activeChunks.map((isActive, index) => (
<TableChunk<T, F>
key={value}
id={value}
limit={limit}
totalLength={totalLength}
key={index}
id={index}
totalItemsCount={totalItemsCount}
chunkSize={chunkSize}
rowHeight={rowHeight}
columns={columns}
fetchData={fetchData}
Expand All @@ -122,7 +125,7 @@ export const PaginatedTable = <T, F>({
getRowClassName={getRowClassName}
renderErrorMessage={renderErrorMessage}
onDataFetched={handleDataFetched}
isActive={activeChunks.includes(value)}
isActive={isActive}
/>
));
};
Expand Down
28 changes: 15 additions & 13 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;
totalItemsCount: 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,
totalItemsCount,
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,9 +88,10 @@ export const TableChunk = <T, F>({
}
}, [currentData, isActive, onDataFetched]);

const chunkOffset = id * limit;
const remainingLength = totalLength - chunkOffset;
const calculatedChunkLength = remainingLength < limit ? remainingLength : limit;
const chunkOffset = id * chunkSize;
const remainingLength = totalItemsCount - chunkOffset;
const calculatedChunkLength =
remainingLength < chunkSize && remainingLength > 0 ? remainingLength : chunkSize;
artemmufazalov marked this conversation as resolved.
Show resolved Hide resolved

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

Expand Down Expand Up @@ -134,7 +136,7 @@ export const TableChunk = <T, F>({
));
};

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

return (
<tbody
Expand All @@ -150,4 +152,4 @@ export const TableChunk = <T, F>({
{renderContent()}
</tbody>
);
};
});
109 changes: 74 additions & 35 deletions src/components/PaginatedTable/useScrollBasedChunks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,68 +2,107 @@ 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> | null;
tableRef: React.RefObject<HTMLElement>;
totalItems: number;
itemHeight: number;
rowHeight: number;
chunkSize: number;
overscanCount?: number;
}

interface ChunksRange {
start: number;
end: 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 [chunksRange, setChunksRange] = React.useState<ChunksRange>({
start: 0,
end: Math.min(overscanCount, chunksCount - 1),
});

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 startChunk = Math.max(
Math.floor(visibleStart / rowHeight / chunkSize) - overscanCount,
0,
);
const endChunk = Math.min(
Math.floor(visibleEnd / rowHeight / chunkSize) + overscanCount,
chunksCount - 1,
);

return {start: startChunk, end: endChunk};
}, [parentRef, tableRef, rowHeight, chunkSize, chunksCount, overscanCount]);

setActiveChunks(newActiveChunks);
}, [chunkSize, containerRef, itemHeight, totalItems]);
const handleScroll = React.useCallback(() => {
const newRange = calculateVisibleRange();
if (
newRange &&
(newRange.start !== chunksRange.start || newRange.end !== chunksRange.end)
) {
setChunksRange(newRange);
}
}, [calculateVisibleRange, chunksRange.end, chunksRange.start]);

const throttledCalculateActiveChunks = React.useMemo(
() => throttle(calculateActiveChunks, THROTTLE_DELAY),
[calculateActiveChunks],
const throttledHandleScroll = React.useMemo(
() => throttle(handleScroll, THROTTLE_DELAY, {leading: true, trailing: true}),
[handleScroll],
);

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

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

return React.useMemo(() => {
const activeChunkIds = Array.from(
{length: chunksRange.end - chunksRange.start + 1},
(_, i) => chunksRange.start + i,
);

// Create boolean array where true represents active chunks
const activeChunks = Array(chunksCount).fill(false);
activeChunkIds.forEach((id) => {
activeChunks[id] = true;
});

return activeChunks;
return activeChunks;
}, [chunksRange.start, chunksRange.end, chunksCount]);
};
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import React from 'react';

// invoke passed function at most once per animation frame
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function rafThrottle<Fn extends (...args: any[]) => any>(fn: Fn) {
Expand All @@ -23,3 +25,17 @@ export function rafThrottle<Fn extends (...args: any[]) => any>(fn: Fn) {
export function calculateColumnWidth(newWidth: number, minWidth = 40, maxWidth = Infinity) {
return Math.max(minWidth, Math.min(newWidth, maxWidth));
}

export const typedMemo: <T>(Component: T) => T = React.memo;

export function calculateElementOffsetTop(element: HTMLElement, container?: HTMLElement): number {
let currentElement = element;
let offsetTop = 0;

while (currentElement && currentElement !== container) {
offsetTop += currentElement.offsetTop;
currentElement = currentElement.offsetParent as HTMLElement;
artemmufazalov marked this conversation as resolved.
Show resolved Hide resolved
}

return offsetTop;
}
2 changes: 1 addition & 1 deletion src/containers/Nodes/NodesWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {PaginatedNodes} from './PaginatedNodes';
interface NodesWrapperProps {
path?: string;
database?: string;
parentRef?: React.RefObject<HTMLElement>;
parentRef: React.RefObject<HTMLElement> | null;
additionalNodesProps?: AdditionalNodesProps;
}

Expand Down
2 changes: 1 addition & 1 deletion src/containers/Nodes/PaginatedNodes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ const b = cn('ydb-nodes');
interface NodesProps {
path?: string;
database?: string;
parentRef?: React.RefObject<HTMLElement>;
parentRef: React.RefObject<HTMLElement> | null;
additionalNodesProps?: AdditionalNodesProps;
}

Expand Down
Loading
Loading