Skip to content
Merged
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
11 changes: 0 additions & 11 deletions src/components/PaginatedTable/PaginatedTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import type {
RenderEmptyDataMessage,
RenderErrorMessage,
} from './types';
import {calculateElementOffsetTop} from './utils';

import './PaginatedTable.scss';

Expand Down Expand Up @@ -62,7 +61,6 @@ export const PaginatedTable = <T, F>({
const {sortParams, foundEntities} = tableState;

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

// 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)
const [filters, setFilters] = React.useState(rawFilters);
Expand All @@ -83,14 +81,6 @@ 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;
Expand Down Expand Up @@ -120,7 +110,6 @@ export const PaginatedTable = <T, F>({
scrollContainerRef={scrollContainerRef}
tableRef={tableRef}
foundEntities={foundEntities}
tableOffset={tableOffset}
chunkSize={chunkSize}
rowHeight={rowHeight}
columns={columns}
Expand Down
3 changes: 0 additions & 3 deletions src/components/PaginatedTable/TableChunksRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ export interface TableChunksRendererProps<T, F> {
scrollContainerRef: React.RefObject<HTMLElement>;
tableRef: React.RefObject<HTMLElement>;
foundEntities: number;
tableOffset: number;
chunkSize: number;
rowHeight: number;
columns: Column<T>[];
Expand All @@ -36,7 +35,6 @@ export const TableChunksRenderer = <T, F>({
scrollContainerRef,
tableRef,
foundEntities,
tableOffset,
chunkSize,
rowHeight,
columns,
Expand All @@ -56,7 +54,6 @@ export const TableChunksRenderer = <T, F>({
totalItems: foundEntities || 1,
rowHeight,
chunkSize,
tableOffset,
});

const lastChunkSize = React.useMemo(() => {
Expand Down
65 changes: 50 additions & 15 deletions src/components/PaginatedTable/useScrollBasedChunks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ import React from 'react';

import {throttle} from 'lodash';

import {rafThrottle} from './utils';

import {getCurrentTableOffset, isTableOffscreen, rafThrottle} from './utils';
interface UseScrollBasedChunksProps {
scrollContainerRef: React.RefObject<HTMLElement>;
tableRef: React.RefObject<HTMLElement>;
Expand All @@ -12,7 +11,6 @@ interface UseScrollBasedChunksProps {
chunkSize: number;
renderOverscan?: number;
fetchOverscan?: number;
tableOffset: number;
}

interface ChunkState {
Expand All @@ -27,13 +25,31 @@ const DEFAULT_RENDER_OVERSCAN = isSafari ? 1 : 2;
const DEFAULT_FETCH_OVERSCAN = 4;
const THROTTLE_DELAY = 200;

/**
* Virtualized chunking for tables within a shared scroll container.
*
* Behavior:
* - Dynamic offset: On scroll/resize, compute the table's current offset relative to the
* scroll container using DOM rects. This stays correct as surrounding layout changes.
* - Visible range: Convert the container viewport [scrollTop, scrollTop+clientHeight]
* into table coordinates and derive visible chunk indices from rowHeight and chunkSize.
* - Offscreen freeze: If the table's [tableStartY, tableEndY] is farther than one viewport
* away (freeze margin = container.clientHeight), skip updating the visible chunk range.
* This keeps offscreen groups stable and prevents scroll jumps when many groups are open.
* - Overscan: renderOverscan/fetchOverscan buffer around the visible range to reduce
* thrashing (Safari uses smaller render overscan).
* - Throttling: Scroll updates are throttled (THROTTLE_DELAY), and resize is raf-throttled.
*
* Notes:
* - totalItems/rowHeight changes re-evaluate bounds.
* - When offscreen, the hook returns skipUpdate to preserve the previous range.
*/
export const useScrollBasedChunks = ({
scrollContainerRef,
tableRef,
totalItems,
rowHeight,
chunkSize,
tableOffset,
renderOverscan = DEFAULT_RENDER_OVERSCAN,
fetchOverscan = DEFAULT_FETCH_OVERSCAN,
}: UseScrollBasedChunksProps): ChunkState[] => {
Expand All @@ -52,23 +68,42 @@ export const useScrollBasedChunks = ({
return null;
}

const containerScroll = container.scrollTop;
const visibleStart = Math.max(containerScroll - tableOffset, 0);
// Compute current table offset relative to the scroll container using DOM rects.
// This accounts for dynamic layout changes as groups above expand/collapse.
const currentTableOffset = getCurrentTableOffset(container, table);

const visibleStart = Math.max(container.scrollTop - currentTableOffset, 0);
const visibleEnd = visibleStart + container.clientHeight;

const start = Math.max(Math.floor(visibleStart / rowHeight / chunkSize), 0);
const end = Math.min(
Math.floor(visibleEnd / rowHeight / chunkSize),
Math.max(chunksCount - 1, 0),
);
return {start, end};
}, [scrollContainerRef, tableRef, tableOffset, rowHeight, chunkSize, chunksCount]);
// Determine if this table is far outside of the viewport; if so, freeze updates
const isOffscreen = isTableOffscreen({
container,
currentTableOffset,
totalItems,
rowHeight,
});

return {
start: Math.max(Math.floor(visibleStart / rowHeight / chunkSize), 0),
end: Math.min(
Math.floor(visibleEnd / rowHeight / chunkSize),
Math.max(chunksCount - 1, 0),
),
skipUpdate: isOffscreen,
};
}, [scrollContainerRef, tableRef, rowHeight, chunkSize, chunksCount, totalItems]);

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

if (skipUpdate) {
return;
}

setVisibleStartChunk(start);
setVisibleEndChunk(end);
}
}, [calculateVisibleRange]);

Expand Down
60 changes: 38 additions & 22 deletions src/components/PaginatedTable/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,28 +29,44 @@ export function calculateColumnWidth(newWidth: number, minWidth = 40, maxWidth =
export const typedMemo: <T>(Component: T) => T = React.memo;

/**
* Calculates the total vertical offset (distance from top) of an element relative to its container
* or the document body if no container is specified.
*
* This function traverses up through the DOM tree, accumulating offsetTop values
* from each parent element until it reaches either the specified container or
* the top of the document.
* @param element - The HTML element to calculate the offset for
* @param container - Optional container element to stop the calculation at
* @returns The total vertical offset in pixels
*
* Example:
* const offset = calculateElementOffsetTop(myElement, myContainer);
* // Returns the distance in pixels from myElement to the top of myContainer
* Computes the current vertical offset of a table element relative to a scrollable container.
* Uses DOMRects to calculate the distance from the table's top edge to the container's top edge
* in container scroll coordinates: tableRect.top - containerRect.top + container.scrollTop.
* @param container The scrollable container element
* @param table The table (or table wrapper) element whose offset is calculated
* @returns The vertical offset in pixels
*/
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;
}
export function getCurrentTableOffset(container: HTMLElement, table: HTMLElement): number {
const tableRect = table.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
return tableRect.top - containerRect.top + container.scrollTop;
}

return offsetTop;
/**
* Returns whether a table is considered offscreen relative to the container's viewport
* with an additional safety margin (freeze margin).
* A table is offscreen if its vertical span [tableStartY, tableEndY] lies farther than
* the specified margin outside the viewport [scrollTop, scrollTop + clientHeight].
* @param params The parameters for the offscreen check
* @param params.container The scrollable container element
* @param params.currentTableOffset The current vertical offset of the table within the container
* @param params.totalItems Total number of rows in the table
* @param params.rowHeight Fixed row height in pixels
* @param params.freezeMarginPx Optional additional margin in pixels; defaults to container.clientHeight
* @returns True if the table is offscreen beyond the margin; otherwise false
*/
export function isTableOffscreen(params: {
container: HTMLElement;
currentTableOffset: number;
totalItems: number;
rowHeight: number;
freezeMarginPx?: number;
}): boolean {
const {container, currentTableOffset, totalItems, rowHeight, freezeMarginPx} = params;
const tableStartY = currentTableOffset;
const tableEndY = tableStartY + totalItems * rowHeight;
const viewportMin = container.scrollTop;
const viewportMax = viewportMin + container.clientHeight;
const margin = typeof freezeMarginPx === 'number' ? freezeMarginPx : container.clientHeight;
return viewportMax < tableStartY - margin || viewportMin > tableEndY + margin;
}
Loading