Skip to content

Commit ab41252

Browse files
committed
fix: split render and fetch overscans
1 parent eeb6189 commit ab41252

File tree

3 files changed

+86
-38
lines changed

3 files changed

+86
-38
lines changed

src/components/PaginatedTable/PaginatedTable.tsx

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {
1515
RenderErrorMessage,
1616
} from './types';
1717
import {useScrollBasedChunks} from './useScrollBasedChunks';
18+
import {calculateElementOffsetTop} from './utils';
1819

1920
import './PaginatedTable.scss';
2021

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

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

66-
const activeChunks = useScrollBasedChunks({
68+
const chunkStates = useScrollBasedChunks({
6769
scrollContainerRef,
6870
tableRef,
6971
totalItems: foundEntities,
7072
rowHeight,
7173
chunkSize,
74+
tableOffset,
7275
});
7376

7477
// 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)
@@ -99,6 +102,14 @@ export const PaginatedTable = <T, F>({
99102
[onDataFetched, setFoundEntities, setIsInitialLoad, setTotalEntities],
100103
);
101104

105+
React.useLayoutEffect(() => {
106+
const scrollContainer = scrollContainerRef.current;
107+
const table = tableRef.current;
108+
if (table && scrollContainer) {
109+
setTableOffset(calculateElementOffsetTop(table, scrollContainer));
110+
}
111+
}, [scrollContainerRef.current, tableRef.current, foundEntities]);
112+
102113
// Set will-change: transform on scroll container if not already set
103114
React.useLayoutEffect(() => {
104115
const scrollContainer = scrollContainerRef.current;
@@ -124,16 +135,19 @@ export const PaginatedTable = <T, F>({
124135
const chunks: React.ReactElement[] = [];
125136
let i = 0;
126137

127-
while (i < activeChunks.length) {
128-
const isActive = activeChunks[i];
138+
while (i < chunkStates.length) {
139+
const chunkState = chunkStates[i];
140+
const shouldRender = chunkState.shouldRender;
141+
const shouldFetch = chunkState.shouldFetch;
142+
const isActive = shouldRender || shouldFetch;
129143

130144
if (isActive) {
131145
// Render active chunk normally
132146
chunks.push(
133147
<TableChunk<T, F>
134148
key={i}
135149
id={i}
136-
calculatedCount={i === activeChunks.length - 1 ? lastChunkSize : chunkSize}
150+
calculatedCount={i === chunkStates.length - 1 ? lastChunkSize : chunkSize}
137151
chunkSize={chunkSize}
138152
rowHeight={rowHeight}
139153
columns={columns}
@@ -145,7 +159,8 @@ export const PaginatedTable = <T, F>({
145159
renderErrorMessage={renderErrorMessage}
146160
renderEmptyDataMessage={renderEmptyDataMessage}
147161
onDataFetched={handleDataFetched}
148-
isActive={isActive}
162+
shouldFetch={chunkState.shouldFetch}
163+
shouldRender={chunkState.shouldRender}
149164
keepCache={keepCache}
150165
/>,
151166
);
@@ -155,9 +170,13 @@ export const PaginatedTable = <T, F>({
155170
const startIndex = i;
156171
let totalHeight = 0;
157172

158-
while (i < activeChunks.length && !activeChunks[i]) {
173+
while (
174+
i < chunkStates.length &&
175+
!chunkStates[i].shouldRender &&
176+
!chunkStates[i].shouldFetch
177+
) {
159178
const currentChunkSize =
160-
i === activeChunks.length - 1 ? lastChunkSize : chunkSize;
179+
i === chunkStates.length - 1 ? lastChunkSize : chunkSize;
161180
totalHeight += currentChunkSize * rowHeight;
162181
i++;
163182
}

src/components/PaginatedTable/TableChunk.tsx

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ interface TableChunkProps<T, F> {
2929
columns: Column<T>[];
3030
filters?: F;
3131
sortParams?: SortParams;
32-
isActive: boolean;
32+
shouldFetch: boolean;
33+
shouldRender: boolean;
3334
tableName: string;
3435

3536
fetchData: FetchData<T, F>;
@@ -56,7 +57,8 @@ export const TableChunk = typedMemo(function TableChunk<T, F>({
5657
renderErrorMessage,
5758
renderEmptyDataMessage,
5859
onDataFetched,
59-
isActive,
60+
shouldFetch,
61+
shouldRender,
6062
keepCache,
6163
}: TableChunkProps<T, F>) {
6264
const [isTimeoutActive, setIsTimeoutActive] = React.useState(true);
@@ -75,7 +77,7 @@ export const TableChunk = typedMemo(function TableChunk<T, F>({
7577
};
7678

7779
tableDataApi.useFetchTableChunkQuery(queryParams, {
78-
skip: isTimeoutActive || !isActive,
80+
skip: isTimeoutActive || !shouldFetch,
7981
pollingInterval: autoRefreshInterval,
8082
refetchOnMountOrArgChange: !keepCache,
8183
});
@@ -85,7 +87,7 @@ export const TableChunk = typedMemo(function TableChunk<T, F>({
8587
React.useEffect(() => {
8688
let timeout = 0;
8789

88-
if (isActive && isTimeoutActive) {
90+
if (shouldFetch && isTimeoutActive) {
8991
timeout = window.setTimeout(() => {
9092
setIsTimeoutActive(false);
9193
}, DEBOUNCE_TIMEOUT);
@@ -94,23 +96,23 @@ export const TableChunk = typedMemo(function TableChunk<T, F>({
9496
return () => {
9597
window.clearTimeout(timeout);
9698
};
97-
}, [isActive, isTimeoutActive]);
99+
}, [shouldFetch, isTimeoutActive]);
98100

99101
React.useEffect(() => {
100-
if (currentData && isActive) {
102+
if (currentData) {
101103
onDataFetched({
102104
...currentData,
103105
data: currentData.data as T[],
104106
found: currentData.found || 0,
105107
total: currentData.total || 0,
106108
});
107109
}
108-
}, [currentData, isActive, onDataFetched]);
110+
}, [currentData, onDataFetched]);
109111

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

112114
const renderContent = () => {
113-
if (!isActive) {
115+
if (!shouldRender) {
114116
return null;
115117
}
116118

@@ -161,7 +163,7 @@ export const TableChunk = typedMemo(function TableChunk<T, F>({
161163
// Default display: table-row-group doesn't work in Safari and breaks the table
162164
// display: block works in Safari, but disconnects thead and tbody cell grids
163165
// Hack to make it work in all cases
164-
display: isActive ? 'table-row-group' : 'block',
166+
display: shouldRender ? 'table-row-group' : 'block',
165167
}}
166168
>
167169
{renderContent()}

src/components/PaginatedTable/useScrollBasedChunks.ts

Lines changed: 49 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,46 @@
11
import React from 'react';
22

3-
import {calculateElementOffsetTop, rafThrottle} from './utils';
3+
import {rafThrottle} from './utils';
44

55
interface UseScrollBasedChunksProps {
66
scrollContainerRef: React.RefObject<HTMLElement>;
77
tableRef: React.RefObject<HTMLElement>;
88
totalItems: number;
99
rowHeight: number;
1010
chunkSize: number;
11-
overscanCount?: number;
11+
renderOverscan?: number;
12+
fetchOverscan?: number;
13+
tableOffset: number;
1214
}
1315

14-
const DEFAULT_OVERSCAN_COUNT = 2;
16+
interface ChunkState {
17+
shouldRender: boolean;
18+
shouldFetch: boolean;
19+
}
20+
21+
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
22+
23+
// Bad performance in Safari - reduce overscan counts
24+
const DEFAULT_RENDER_OVERSCAN = isSafari ? 1 : 2;
25+
const DEFAULT_FETCH_OVERSCAN = isSafari ? 2 : 4;
1526

1627
export const useScrollBasedChunks = ({
1728
scrollContainerRef,
1829
tableRef,
1930
totalItems,
2031
rowHeight,
2132
chunkSize,
22-
overscanCount = DEFAULT_OVERSCAN_COUNT,
23-
}: UseScrollBasedChunksProps): boolean[] => {
33+
tableOffset,
34+
renderOverscan = DEFAULT_RENDER_OVERSCAN,
35+
fetchOverscan = DEFAULT_FETCH_OVERSCAN,
36+
}: UseScrollBasedChunksProps): ChunkState[] => {
2437
const chunksCount = React.useMemo(
2538
() => Math.ceil(totalItems / chunkSize),
2639
[chunkSize, totalItems],
2740
);
2841

29-
const [startChunk, setStartChunk] = React.useState(0);
30-
const [endChunk, setEndChunk] = React.useState(
31-
Math.min(overscanCount, Math.max(chunksCount - 1, 0)),
32-
);
42+
const [visibleStartChunk, setVisibleStartChunk] = React.useState(0);
43+
const [visibleEndChunk, setVisibleEndChunk] = React.useState(0);
3344

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

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

46-
const start = Math.max(Math.floor(visibleStart / rowHeight / chunkSize) - overscanCount, 0);
56+
const start = Math.max(Math.floor(visibleStart / rowHeight / chunkSize), 0);
4757
const end = Math.min(
48-
Math.floor(visibleEnd / rowHeight / chunkSize) + overscanCount,
58+
Math.floor(visibleEnd / rowHeight / chunkSize),
4959
Math.max(chunksCount - 1, 0),
5060
);
5161
return {start, end};
52-
}, [scrollContainerRef, tableRef, rowHeight, chunkSize, overscanCount, chunksCount]);
62+
}, [scrollContainerRef, tableRef, tableOffset, rowHeight, chunkSize, chunksCount]);
5363

5464
const updateVisibleChunks = React.useCallback(() => {
5565
const newRange = calculateVisibleRange();
5666
if (newRange) {
57-
setStartChunk(newRange.start);
58-
setEndChunk(newRange.end);
67+
setVisibleStartChunk(newRange.start);
68+
setVisibleEndChunk(newRange.end);
5969
}
6070
}, [calculateVisibleRange]);
6171

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

96106
return React.useMemo(() => {
97-
// boolean array that represents active chunks
98-
const activeChunks = Array(chunksCount).fill(false);
99-
for (let i = startChunk; i <= endChunk; i++) {
100-
activeChunks[i] = true;
101-
}
102-
return activeChunks;
103-
}, [chunksCount, startChunk, endChunk]);
107+
// Calculate render range (visible + render overscan)
108+
const renderStartChunk = Math.max(visibleStartChunk - renderOverscan, 0);
109+
const renderEndChunk = Math.min(
110+
visibleEndChunk + renderOverscan,
111+
Math.max(chunksCount - 1, 0),
112+
);
113+
114+
// Calculate fetch range (visible + fetch overscan)
115+
const fetchStartChunk = Math.max(visibleStartChunk - fetchOverscan, 0);
116+
const fetchEndChunk = Math.min(
117+
visibleEndChunk + fetchOverscan,
118+
Math.max(chunksCount - 1, 0),
119+
);
120+
121+
// Create chunk states array
122+
const chunkStates: ChunkState[] = Array(chunksCount)
123+
.fill(null)
124+
.map((_, index) => ({
125+
shouldRender: index >= renderStartChunk && index <= renderEndChunk,
126+
shouldFetch: index >= fetchStartChunk && index <= fetchEndChunk,
127+
}));
128+
129+
return chunkStates;
130+
}, [chunksCount, visibleStartChunk, visibleEndChunk, renderOverscan, fetchOverscan]);
104131
};

0 commit comments

Comments
 (0)