Skip to content

fix: scrolling performance optimizations for table #2335

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 21 commits into from
May 30, 2025
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
107 changes: 86 additions & 21 deletions src/components/PaginatedTable/PaginatedTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
RenderErrorMessage,
} from './types';
import {useScrollBasedChunks} from './useScrollBasedChunks';
import {calculateElementOffsetTop} from './utils';

import './PaginatedTable.scss';

Expand Down Expand Up @@ -62,13 +63,15 @@ export const PaginatedTable = <T, F>({
const {sortParams, foundEntities} = tableState;

const tableRef = React.useRef<HTMLDivElement>(null);
const [tableOffset, setTableOffset] = React.useState(0);

const activeChunks = useScrollBasedChunks({
const chunkStates = useScrollBasedChunks({
scrollContainerRef,
tableRef,
totalItems: foundEntities,
rowHeight,
chunkSize,
tableOffset,
});

// this prevent situation when filters are new, but active chunks is not yet recalculated (it will be done to the next rendrer, so we bring filters change on the next render too)
Expand Down Expand Up @@ -99,6 +102,25 @@ export const PaginatedTable = <T, F>({
[onDataFetched, setFoundEntities, setIsInitialLoad, setTotalEntities],
);

React.useLayoutEffect(() => {
const scrollContainer = scrollContainerRef.current;
const table = tableRef.current;
if (table && scrollContainer) {
setTableOffset(calculateElementOffsetTop(table, scrollContainer));
}
}, [scrollContainerRef.current, tableRef.current, foundEntities]);

// Set will-change: transform on scroll container if not already set
React.useLayoutEffect(() => {
const scrollContainer = scrollContainerRef.current;
if (scrollContainer) {
const computedStyle = window.getComputedStyle(scrollContainer);
if (computedStyle.willChange !== 'transform') {
scrollContainer.style.willChange = 'transform';
}
}
}, [scrollContainerRef.current]);

// Reset table on initialization and filters change
React.useLayoutEffect(() => {
const defaultTotal = initialEntitiesCount || 0;
Expand All @@ -110,26 +132,69 @@ export const PaginatedTable = <T, F>({
}, [initialEntitiesCount, setTotalEntities, setFoundEntities, setIsInitialLoad]);

const renderChunks = () => {
return activeChunks.map((isActive, index) => (
<TableChunk<T, F>
key={index}
id={index}
calculatedCount={index === activeChunks.length - 1 ? lastChunkSize : chunkSize}
chunkSize={chunkSize}
rowHeight={rowHeight}
columns={columns}
fetchData={fetchData}
filters={filters}
tableName={tableName}
sortParams={sortParams}
getRowClassName={getRowClassName}
renderErrorMessage={renderErrorMessage}
renderEmptyDataMessage={renderEmptyDataMessage}
onDataFetched={handleDataFetched}
isActive={isActive}
keepCache={keepCache}
/>
));
const chunks: React.ReactElement[] = [];
let i = 0;

while (i < chunkStates.length) {
const chunkState = chunkStates[i];
const shouldRender = chunkState.shouldRender;
const shouldFetch = chunkState.shouldFetch;
const isActive = shouldRender || shouldFetch;

if (isActive) {
// Render active chunk normally
chunks.push(
<TableChunk<T, F>
key={i}
id={i}
calculatedCount={i === chunkStates.length - 1 ? lastChunkSize : chunkSize}
chunkSize={chunkSize}
rowHeight={rowHeight}
columns={columns}
fetchData={fetchData}
filters={filters}
tableName={tableName}
sortParams={sortParams}
getRowClassName={getRowClassName}
renderErrorMessage={renderErrorMessage}
renderEmptyDataMessage={renderEmptyDataMessage}
onDataFetched={handleDataFetched}
shouldFetch={chunkState.shouldFetch}
shouldRender={chunkState.shouldRender}
keepCache={keepCache}
/>,
);
i++;
} else {
// Find consecutive inactive chunks and merge them
const startIndex = i;
let totalHeight = 0;

while (
i < chunkStates.length &&
!chunkStates[i].shouldRender &&
!chunkStates[i].shouldFetch
) {
const currentChunkSize =
i === chunkStates.length - 1 ? lastChunkSize : chunkSize;
totalHeight += currentChunkSize * rowHeight;
i++;
}

// Render merged empty tbody for consecutive inactive chunks
chunks.push(
<tbody
key={`merged-${startIndex}-${i - 1}`}
style={{
height: `${totalHeight}px`,
display: 'block',
}}
/>,
);
}
}

return chunks;
};

const renderTable = () => (
Expand Down
20 changes: 11 additions & 9 deletions src/components/PaginatedTable/TableChunk.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ interface TableChunkProps<T, F> {
columns: Column<T>[];
filters?: F;
sortParams?: SortParams;
isActive: boolean;
shouldFetch: boolean;
shouldRender: boolean;
tableName: string;

fetchData: FetchData<T, F>;
Expand All @@ -56,7 +57,8 @@ export const TableChunk = typedMemo(function TableChunk<T, F>({
renderErrorMessage,
renderEmptyDataMessage,
onDataFetched,
isActive,
shouldFetch,
shouldRender,
keepCache,
}: TableChunkProps<T, F>) {
const [isTimeoutActive, setIsTimeoutActive] = React.useState(true);
Expand All @@ -75,7 +77,7 @@ export const TableChunk = typedMemo(function TableChunk<T, F>({
};

tableDataApi.useFetchTableChunkQuery(queryParams, {
skip: isTimeoutActive || !isActive,
skip: isTimeoutActive || !shouldFetch,
pollingInterval: autoRefreshInterval,
refetchOnMountOrArgChange: !keepCache,
});
Expand All @@ -85,7 +87,7 @@ export const TableChunk = typedMemo(function TableChunk<T, F>({
React.useEffect(() => {
let timeout = 0;

if (isActive && isTimeoutActive) {
if (shouldFetch && isTimeoutActive) {
timeout = window.setTimeout(() => {
setIsTimeoutActive(false);
}, DEBOUNCE_TIMEOUT);
Expand All @@ -94,23 +96,23 @@ export const TableChunk = typedMemo(function TableChunk<T, F>({
return () => {
window.clearTimeout(timeout);
};
}, [isActive, isTimeoutActive]);
}, [shouldFetch, isTimeoutActive]);

React.useEffect(() => {
if (currentData && isActive) {
if (currentData) {
onDataFetched({
...currentData,
data: currentData.data as T[],
found: currentData.found || 0,
total: currentData.total || 0,
});
}
}, [currentData, isActive, onDataFetched]);
}, [currentData, onDataFetched]);

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

const renderContent = () => {
if (!isActive) {
if (!shouldRender) {
return null;
}

Expand Down Expand Up @@ -161,7 +163,7 @@ export const TableChunk = typedMemo(function TableChunk<T, F>({
// 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
display: isActive ? 'table-row-group' : 'block',
display: shouldRender ? 'table-row-group' : 'block',
}}
>
{renderContent()}
Expand Down
71 changes: 49 additions & 22 deletions src/components/PaginatedTable/useScrollBasedChunks.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,46 @@
import React from 'react';

import {calculateElementOffsetTop, rafThrottle} from './utils';
import {rafThrottle} from './utils';

interface UseScrollBasedChunksProps {
scrollContainerRef: React.RefObject<HTMLElement>;
tableRef: React.RefObject<HTMLElement>;
totalItems: number;
rowHeight: number;
chunkSize: number;
overscanCount?: number;
renderOverscan?: number;
fetchOverscan?: number;
tableOffset: number;
}

const DEFAULT_OVERSCAN_COUNT = 1;
interface ChunkState {
shouldRender: boolean;
shouldFetch: boolean;
}

const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);

// Bad performance in Safari - reduce overscan counts
const DEFAULT_RENDER_OVERSCAN = isSafari ? 1 : 2;
const DEFAULT_FETCH_OVERSCAN = isSafari ? 2 : 4;

export const useScrollBasedChunks = ({
scrollContainerRef,
tableRef,
totalItems,
rowHeight,
chunkSize,
overscanCount = DEFAULT_OVERSCAN_COUNT,
}: UseScrollBasedChunksProps): boolean[] => {
tableOffset,
renderOverscan = DEFAULT_RENDER_OVERSCAN,
fetchOverscan = DEFAULT_FETCH_OVERSCAN,
}: UseScrollBasedChunksProps): ChunkState[] => {
const chunksCount = React.useMemo(
() => Math.ceil(totalItems / chunkSize),
[chunkSize, totalItems],
);

const [startChunk, setStartChunk] = React.useState(0);
const [endChunk, setEndChunk] = React.useState(
Math.min(overscanCount, Math.max(chunksCount - 1, 0)),
);
const [visibleStartChunk, setVisibleStartChunk] = React.useState(0);
const [visibleEndChunk, setVisibleEndChunk] = React.useState(0);

const calculateVisibleRange = React.useCallback(() => {
const container = scrollContainerRef?.current;
Expand All @@ -38,24 +49,23 @@ export const useScrollBasedChunks = ({
return null;
}

const tableOffset = calculateElementOffsetTop(table, container);
const containerScroll = container.scrollTop;
const visibleStart = Math.max(containerScroll - tableOffset, 0);
const visibleEnd = visibleStart + container.clientHeight;

const start = Math.max(Math.floor(visibleStart / rowHeight / chunkSize) - overscanCount, 0);
const start = Math.max(Math.floor(visibleStart / rowHeight / chunkSize), 0);
const end = Math.min(
Math.floor(visibleEnd / rowHeight / chunkSize) + overscanCount,
Math.floor(visibleEnd / rowHeight / chunkSize),
Math.max(chunksCount - 1, 0),
);
return {start, end};
}, [scrollContainerRef, tableRef, rowHeight, chunkSize, overscanCount, chunksCount]);
}, [scrollContainerRef, tableRef, tableOffset, rowHeight, chunkSize, chunksCount]);

const updateVisibleChunks = React.useCallback(() => {
const newRange = calculateVisibleRange();
if (newRange) {
setStartChunk(newRange.start);
setEndChunk(newRange.end);
setVisibleStartChunk(newRange.start);
setVisibleEndChunk(newRange.end);
}
}, [calculateVisibleRange]);

Expand Down Expand Up @@ -94,11 +104,28 @@ export const useScrollBasedChunks = ({
}, [handleScroll, scrollContainerRef]);

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]);
// Calculate render range (visible + render overscan)
const renderStartChunk = Math.max(visibleStartChunk - renderOverscan, 0);
const renderEndChunk = Math.min(
visibleEndChunk + renderOverscan,
Math.max(chunksCount - 1, 0),
);

// Calculate fetch range (visible + fetch overscan)
const fetchStartChunk = Math.max(visibleStartChunk - fetchOverscan, 0);
const fetchEndChunk = Math.min(
visibleEndChunk + fetchOverscan,
Math.max(chunksCount - 1, 0),
);

// Create chunk states array
const chunkStates: ChunkState[] = Array(chunksCount)
.fill(null)
.map((_, index) => ({
shouldRender: index >= renderStartChunk && index <= renderEndChunk,
shouldFetch: index >= fetchStartChunk && index <= fetchEndChunk,
}));

return chunkStates;
}, [chunksCount, visibleStartChunk, visibleEndChunk, renderOverscan, fetchOverscan]);
};
Loading