Skip to content

fix: real virtualization #2216

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

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 3 additions & 2 deletions src/components/PaginatedTable/PaginatedTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export interface PaginatedTableProps<T, F> {
containerClassName?: string;
}

const DEFAULT_PAGINATION_LIMIT = 20;
const DEFAULT_PAGINATION_LIMIT = 200;

export const PaginatedTable = <T, F>({
limit: chunkSize = DEFAULT_PAGINATION_LIMIT,
Expand Down Expand Up @@ -67,7 +67,7 @@ export const PaginatedTable = <T, F>({

const tableRef = React.useRef<HTMLDivElement>(null);

const activeChunks = useScrollBasedChunks({
const [activeChunks, visibleRange] = useScrollBasedChunks({
parentRef,
tableRef,
totalItems: foundEntities,
Expand Down Expand Up @@ -118,6 +118,7 @@ export const PaginatedTable = <T, F>({
renderEmptyDataMessage={renderEmptyDataMessage}
onDataFetched={handleDataFetched}
isActive={isActive}
visibleRange={visibleRange}
/>
));
};
Expand Down
49 changes: 29 additions & 20 deletions src/components/PaginatedTable/TableChunk.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {getArray} from '../../utils';
import {useAutoRefreshInterval} from '../../utils/hooks';
import {ResponseError} from '../Errors/ResponseError';

import {EmptyTableRow, LoadingTableRow, TableRow} from './TableRow';
import {EmptyTableRow, TableRow} from './TableRow';
import i18n from './i18n';
import type {
Column,
Expand All @@ -16,6 +16,7 @@ import type {
RenderErrorMessage,
SortParams,
} from './types';
import type {VisibleRange} from './useScrollBasedChunks';
import {typedMemo} from './utils';

const DEBOUNCE_TIMEOUT = 200;
Expand All @@ -30,6 +31,7 @@ interface TableChunkProps<T, F> {
sortParams?: SortParams;
isActive: boolean;
tableName: string;
visibleRange: VisibleRange;

fetchData: FetchData<T, F>;
getRowClassName?: GetRowClassName<T>;
Expand All @@ -54,6 +56,7 @@ export const TableChunk = typedMemo(function TableChunk<T, F>({
renderEmptyDataMessage,
onDataFetched,
isActive,
visibleRange,
}: TableChunkProps<T, F>) {
const [isTimeoutActive, setIsTimeoutActive] = React.useState(true);
const [autoRefreshInterval] = useAutoRefreshInterval();
Expand Down Expand Up @@ -100,46 +103,52 @@ export const TableChunk = typedMemo(function TableChunk<T, F>({

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

// Check if a row is within the visible range
const isRowVisible = (rowIndex: number): boolean => {
// Calculate the absolute row index within the entire table
const absoluteRowIndex = id * chunkSize + rowIndex;

// Check if the row is within the visible range (including overscan)
return absoluteRowIndex >= visibleRange.startRow && absoluteRowIndex <= visibleRange.endRow;
};

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

if (!currentData) {
if (error) {
const errorData = error as IResponseError;
return (
<EmptyTableRow columns={columns}>
{renderErrorMessage ? (
renderErrorMessage(errorData)
) : (
<ResponseError error={errorData} />
)}
</EmptyTableRow>
);
} else {
return getArray(dataLength).map((value) => (
<LoadingTableRow key={value} columns={columns} height={rowHeight} />
));
}
if (!currentData && error) {
const errorData = error as IResponseError;
return (
<EmptyTableRow columns={columns}>
{renderErrorMessage ? (
renderErrorMessage(errorData)
) : (
<ResponseError error={errorData} />
)}
</EmptyTableRow>
);
}

// Data is loaded, but there are no entities in the chunk
if (!currentData.data?.length) {
if (currentData?.data && !currentData.data?.length) {
return (
<EmptyTableRow columns={columns}>
{renderEmptyDataMessage ? renderEmptyDataMessage() : i18n('empty')}
</EmptyTableRow>
);
}

return currentData.data.map((rowData, index) => (
const data = currentData?.data || getArray(dataLength);

return data.map((rowData, index) => (
<TableRow
key={index}
row={rowData as T}
columns={columns}
height={rowHeight}
getRowClassName={getRowClassName}
isVisible={isRowVisible(index)}
/>
));
};
Expand Down
100 changes: 55 additions & 45 deletions src/components/PaginatedTable/TableRow.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import React from 'react';

import {Skeleton} from '@gravity-ui/uikit';

import {DEFAULT_ALIGN, DEFAULT_RESIZEABLE} from './constants';
Expand Down Expand Up @@ -37,65 +39,73 @@ const TableRowCell = ({
);
};

interface LoadingTableRowProps<T> {
columns: Column<T>[];
interface VisibilityProps {
isVisible?: boolean;
}

interface TableRowColumnProps<T> {
column: Column<T>;
row?: T;
height: number;
}

export const LoadingTableRow = typedMemo(function <T>({columns, height}: LoadingTableRowProps<T>) {
return (
<tr className={b('row', {loading: true})}>
{columns.map((column) => {
const resizeable = column.resizeable ?? DEFAULT_RESIZEABLE;
export const TableRowColumn = typedMemo(
<T,>({row, column, height, isVisible = true}: TableRowColumnProps<T> & VisibilityProps) => {
const resizeable = column.resizeable ?? DEFAULT_RESIZEABLE;

return (
<TableRowCell
key={column.name}
height={height}
width={column.width}
align={column.align}
className={column.className}
resizeable={resizeable}
>
<Skeleton
className={b('row-skeleton')}
style={{width: '80%', height: '50%'}}
/>
</TableRowCell>
);
})}
</tr>
);
});
const renderedCell = React.useMemo(() => {
if (row) {
return column.render({row});
}
return null;
}, [column, row]);

return (
<TableRowCell
key={column.name}
height={height}
width={column.width}
align={column.align}
className={column.className}
resizeable={resizeable}
>
{isVisible && row ? (
renderedCell
) : (
<Skeleton className={b('row-skeleton')} style={{width: '80%', height: '50%'}} />
)}
</TableRowCell>
);
},
);

interface TableRowProps<T> {
columns: Column<T>[];
row: T;
row?: T;
height: number;
getRowClassName?: GetRowClassName<T>;
}

export const TableRow = <T,>({row, columns, getRowClassName, height}: TableRowProps<T>) => {
const additionalClassName = getRowClassName?.(row);
export const TableRow = <T,>({
row,
columns,
getRowClassName,
height,
isVisible = true,
}: TableRowProps<T> & VisibilityProps) => {
const additionalClassName = row ? getRowClassName?.(row) : undefined;

return (
<tr className={b('row', additionalClassName)}>
{columns.map((column) => {
const resizeable = column.resizeable ?? DEFAULT_RESIZEABLE;

return (
<TableRowCell
key={column.name}
height={height}
width={column.width}
align={column.align}
className={column.className}
resizeable={resizeable}
>
{column.render({row})}
</TableRowCell>
);
})}
{columns.map((column) => (
<TableRowColumn
key={column.name}
column={column}
row={row}
height={height}
isVisible={isVisible}
/>
))}
</tr>
);
};
Expand Down
59 changes: 51 additions & 8 deletions src/components/PaginatedTable/useScrollBasedChunks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ interface UseScrollBasedChunksProps {
overscanCount?: number;
}

export interface VisibleRange {
startChunk: number;
endChunk: number;
startRow: number;
endRow: number;
}

const DEFAULT_OVERSCAN_COUNT = 1;
const THROTTLE_DELAY = 100;

Expand All @@ -23,7 +30,7 @@ export const useScrollBasedChunks = ({
rowHeight,
chunkSize,
overscanCount = DEFAULT_OVERSCAN_COUNT,
}: UseScrollBasedChunksProps): boolean[] => {
}: UseScrollBasedChunksProps): [boolean[], VisibleRange] => {
const chunksCount = React.useMemo(
() => Math.ceil(totalItems / chunkSize),
[chunkSize, totalItems],
Expand All @@ -34,6 +41,12 @@ export const useScrollBasedChunks = ({
Math.min(overscanCount, Math.max(chunksCount - 1, 0)),
);

// Track exact visible rows (not just chunks)
const [startRow, setStartRow] = React.useState(0);
const [endRow, setEndRow] = React.useState(
Math.min(overscanCount * chunkSize, Math.max(totalItems - 1, 0)),
);

const calculateVisibleRange = React.useCallback(() => {
const container = parentRef?.current;
const table = tableRef.current;
Expand All @@ -46,20 +59,38 @@ export const useScrollBasedChunks = ({
const visibleStart = Math.max(containerScroll - tableOffset, 0);
const visibleEnd = visibleStart + container.clientHeight;

const start = Math.max(Math.floor(visibleStart / rowHeight / chunkSize) - overscanCount, 0);
const end = Math.min(
// Calculate visible chunks (with overscan)
const startChunk = Math.max(
Math.floor(visibleStart / rowHeight / chunkSize) - overscanCount,
0,
);
const endChunk = Math.min(
Math.floor(visibleEnd / rowHeight / chunkSize) + overscanCount,
Math.max(chunksCount - 1, 0),
);

return {start, end};
// Calculate visible rows (more precise)
const startRowIndex = Math.max(Math.floor(visibleStart / rowHeight), 0);
const endRowIndex = Math.min(
Math.floor(visibleEnd / rowHeight),
Math.max(totalItems - 1, 0),
);

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

const handleScroll = React.useCallback(() => {
const newRange = calculateVisibleRange();
if (newRange) {
setStartChunk(newRange.start);
setEndChunk(newRange.end);
setStartRow(newRange.startRow);
setEndRow(newRange.endRow);
}
}, [calculateVisibleRange]);

Expand All @@ -81,12 +112,24 @@ export const useScrollBasedChunks = ({
};
}, [handleScroll, parentRef]);

return React.useMemo(() => {
// Create the visibility information
const activeChunks = React.useMemo(() => {
// boolean array that represents active chunks
const activeChunks = Array(chunksCount).fill(false);
const chunks = Array(chunksCount).fill(false);
for (let i = startChunk; i <= endChunk; i++) {
activeChunks[i] = true;
chunks[i] = true;
}
return activeChunks;
return chunks;
}, [chunksCount, startChunk, endChunk]);

const visibleRange = React.useMemo(() => {
return {
startChunk,
endChunk,
startRow,
endRow,
};
}, [startChunk, endChunk, startRow, endRow]);

return [activeChunks, visibleRange];
};
Loading
Loading