diff --git a/src/components/PaginatedTable/PaginatedTable.tsx b/src/components/PaginatedTable/PaginatedTable.tsx index 670f907d04..3cd5a3436f 100644 --- a/src/components/PaginatedTable/PaginatedTable.tsx +++ b/src/components/PaginatedTable/PaginatedTable.tsx @@ -14,7 +14,6 @@ import type { RenderEmptyDataMessage, RenderErrorMessage, } from './types'; -import {calculateElementOffsetTop} from './utils'; import './PaginatedTable.scss'; @@ -62,7 +61,6 @@ export const PaginatedTable = ({ const {sortParams, foundEntities} = tableState; const tableRef = React.useRef(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); @@ -83,14 +81,6 @@ export const PaginatedTable = ({ [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; @@ -120,7 +110,6 @@ export const PaginatedTable = ({ scrollContainerRef={scrollContainerRef} tableRef={tableRef} foundEntities={foundEntities} - tableOffset={tableOffset} chunkSize={chunkSize} rowHeight={rowHeight} columns={columns} diff --git a/src/components/PaginatedTable/TableChunksRenderer.tsx b/src/components/PaginatedTable/TableChunksRenderer.tsx index c819fb3483..0a1e7185d5 100644 --- a/src/components/PaginatedTable/TableChunksRenderer.tsx +++ b/src/components/PaginatedTable/TableChunksRenderer.tsx @@ -17,7 +17,6 @@ export interface TableChunksRendererProps { scrollContainerRef: React.RefObject; tableRef: React.RefObject; foundEntities: number; - tableOffset: number; chunkSize: number; rowHeight: number; columns: Column[]; @@ -36,7 +35,6 @@ export const TableChunksRenderer = ({ scrollContainerRef, tableRef, foundEntities, - tableOffset, chunkSize, rowHeight, columns, @@ -56,7 +54,6 @@ export const TableChunksRenderer = ({ totalItems: foundEntities || 1, rowHeight, chunkSize, - tableOffset, }); const lastChunkSize = React.useMemo(() => { diff --git a/src/components/PaginatedTable/useScrollBasedChunks.ts b/src/components/PaginatedTable/useScrollBasedChunks.ts index ea82a61ed2..6ddc823271 100644 --- a/src/components/PaginatedTable/useScrollBasedChunks.ts +++ b/src/components/PaginatedTable/useScrollBasedChunks.ts @@ -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; tableRef: React.RefObject; @@ -12,7 +11,6 @@ interface UseScrollBasedChunksProps { chunkSize: number; renderOverscan?: number; fetchOverscan?: number; - tableOffset: number; } interface ChunkState { @@ -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[] => { @@ -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]); diff --git a/src/components/PaginatedTable/utils.tsx b/src/components/PaginatedTable/utils.tsx index 2d93592214..8e8f58b98c 100644 --- a/src/components/PaginatedTable/utils.tsx +++ b/src/components/PaginatedTable/utils.tsx @@ -29,28 +29,44 @@ export function calculateColumnWidth(newWidth: number, minWidth = 40, maxWidth = export const typedMemo: (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; }