Skip to content

Commit 83d0b10

Browse files
committed
fix: rack groups disco dance
1 parent 548ce2f commit 83d0b10

File tree

4 files changed

+92
-52
lines changed

4 files changed

+92
-52
lines changed

src/components/PaginatedTable/PaginatedTable.tsx

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import type {
1414
RenderEmptyDataMessage,
1515
RenderErrorMessage,
1616
} from './types';
17-
import {calculateElementOffsetTop} from './utils';
1817

1918
import './PaginatedTable.scss';
2019

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

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

6765
// 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)
6866
const [filters, setFilters] = React.useState(rawFilters);
@@ -83,14 +81,6 @@ export const PaginatedTable = <T, F>({
8381
[onDataFetched, setFoundEntities, setIsInitialLoad, setTotalEntities],
8482
);
8583

86-
React.useLayoutEffect(() => {
87-
const scrollContainer = scrollContainerRef.current;
88-
const table = tableRef.current;
89-
if (table && scrollContainer) {
90-
setTableOffset(calculateElementOffsetTop(table, scrollContainer));
91-
}
92-
}, [scrollContainerRef.current, tableRef.current, foundEntities]);
93-
9484
// Set will-change: transform on scroll container if not already set
9585
React.useLayoutEffect(() => {
9686
const scrollContainer = scrollContainerRef.current;
@@ -100,7 +90,7 @@ export const PaginatedTable = <T, F>({
10090
scrollContainer.style.willChange = 'transform';
10191
}
10292
}
103-
}, [scrollContainerRef.current]);
93+
}, [scrollContainerRef]);
10494

10595
// Reset table on initialization and filters change
10696
React.useLayoutEffect(() => {
@@ -120,7 +110,6 @@ export const PaginatedTable = <T, F>({
120110
scrollContainerRef={scrollContainerRef}
121111
tableRef={tableRef}
122112
foundEntities={foundEntities}
123-
tableOffset={tableOffset}
124113
chunkSize={chunkSize}
125114
rowHeight={rowHeight}
126115
columns={columns}

src/components/PaginatedTable/TableChunksRenderer.tsx

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ export interface TableChunksRendererProps<T, F> {
1717
scrollContainerRef: React.RefObject<HTMLElement>;
1818
tableRef: React.RefObject<HTMLElement>;
1919
foundEntities: number;
20-
tableOffset: number;
2120
chunkSize: number;
2221
rowHeight: number;
2322
columns: Column<T>[];
@@ -36,7 +35,6 @@ export const TableChunksRenderer = <T, F>({
3635
scrollContainerRef,
3736
tableRef,
3837
foundEntities,
39-
tableOffset,
4038
chunkSize,
4139
rowHeight,
4240
columns,
@@ -56,7 +54,6 @@ export const TableChunksRenderer = <T, F>({
5654
totalItems: foundEntities || 1,
5755
rowHeight,
5856
chunkSize,
59-
tableOffset,
6057
});
6158

6259
const lastChunkSize = React.useMemo(() => {

src/components/PaginatedTable/useScrollBasedChunks.ts

Lines changed: 53 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ import React from 'react';
22

33
import {throttle} from 'lodash';
44

5-
import {rafThrottle} from './utils';
6-
5+
import {getCurrentTableOffset, isTableOffscreen, rafThrottle} from './utils';
76
interface UseScrollBasedChunksProps {
87
scrollContainerRef: React.RefObject<HTMLElement>;
98
tableRef: React.RefObject<HTMLElement>;
@@ -12,7 +11,6 @@ interface UseScrollBasedChunksProps {
1211
chunkSize: number;
1312
renderOverscan?: number;
1413
fetchOverscan?: number;
15-
tableOffset: number;
1614
}
1715

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

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

55-
const containerScroll = container.scrollTop;
56-
const visibleStart = Math.max(containerScroll - tableOffset, 0);
71+
// Compute current table offset relative to the scroll container using DOM rects.
72+
// This accounts for dynamic layout changes as groups above expand/collapse.
73+
const currentTableOffset = getCurrentTableOffset(container, table);
74+
75+
const visibleStart = Math.max(container.scrollTop - currentTableOffset, 0);
5776
const visibleEnd = visibleStart + container.clientHeight;
5877

59-
const start = Math.max(Math.floor(visibleStart / rowHeight / chunkSize), 0);
60-
const end = Math.min(
61-
Math.floor(visibleEnd / rowHeight / chunkSize),
62-
Math.max(chunksCount - 1, 0),
63-
);
64-
return {start, end};
65-
}, [scrollContainerRef, tableRef, tableOffset, rowHeight, chunkSize, chunksCount]);
78+
// Determine if this table is far outside of the viewport; if so, freeze updates
79+
const isOffscreen = isTableOffscreen({
80+
container,
81+
currentTableOffset,
82+
totalItems,
83+
rowHeight,
84+
});
85+
86+
return {
87+
start: Math.max(Math.floor(visibleStart / rowHeight / chunkSize), 0),
88+
end: Math.min(
89+
Math.floor(visibleEnd / rowHeight / chunkSize),
90+
Math.max(chunksCount - 1, 0),
91+
),
92+
skipUpdate: isOffscreen,
93+
};
94+
}, [scrollContainerRef, tableRef, rowHeight, chunkSize, chunksCount, totalItems]);
6695

6796
const updateVisibleChunks = React.useCallback(() => {
6897
const newRange = calculateVisibleRange();
6998
if (newRange) {
70-
setVisibleStartChunk(newRange.start);
71-
setVisibleEndChunk(newRange.end);
99+
const {start, end, skipUpdate} = newRange as unknown as {
100+
start: number;
101+
end: number;
102+
skipUpdate?: boolean;
103+
};
104+
if (skipUpdate) {
105+
return;
106+
}
107+
108+
setVisibleStartChunk(start);
109+
setVisibleEndChunk(end);
72110
}
73111
}, [calculateVisibleRange]);
74112

src/components/PaginatedTable/utils.tsx

Lines changed: 38 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -29,28 +29,44 @@ export function calculateColumnWidth(newWidth: number, minWidth = 40, maxWidth =
2929
export const typedMemo: <T>(Component: T) => T = React.memo;
3030

3131
/**
32-
* Calculates the total vertical offset (distance from top) of an element relative to its container
33-
* or the document body if no container is specified.
34-
*
35-
* This function traverses up through the DOM tree, accumulating offsetTop values
36-
* from each parent element until it reaches either the specified container or
37-
* the top of the document.
38-
* @param element - The HTML element to calculate the offset for
39-
* @param container - Optional container element to stop the calculation at
40-
* @returns The total vertical offset in pixels
41-
*
42-
* Example:
43-
* const offset = calculateElementOffsetTop(myElement, myContainer);
44-
* // Returns the distance in pixels from myElement to the top of myContainer
32+
* Computes the current vertical offset of a table element relative to a scrollable container.
33+
* Uses DOMRects to calculate the distance from the table's top edge to the container's top edge
34+
* in container scroll coordinates: tableRect.top - containerRect.top + container.scrollTop.
35+
* @param container The scrollable container element
36+
* @param table The table (or table wrapper) element whose offset is calculated
37+
* @returns The vertical offset in pixels
4538
*/
46-
export function calculateElementOffsetTop(element: HTMLElement, container?: HTMLElement): number {
47-
let currentElement = element;
48-
let offsetTop = 0;
49-
50-
while (currentElement && currentElement !== container) {
51-
offsetTop += currentElement.offsetTop;
52-
currentElement = currentElement.offsetParent as HTMLElement;
53-
}
39+
export function getCurrentTableOffset(container: HTMLElement, table: HTMLElement): number {
40+
const tableRect = table.getBoundingClientRect();
41+
const containerRect = container.getBoundingClientRect();
42+
return tableRect.top - containerRect.top + container.scrollTop;
43+
}
5444

55-
return offsetTop;
45+
/**
46+
* Returns whether a table is considered offscreen relative to the container's viewport
47+
* with an additional safety margin (freeze margin).
48+
* A table is offscreen if its vertical span [tableStartY, tableEndY] lies farther than
49+
* the specified margin outside the viewport [scrollTop, scrollTop + clientHeight].
50+
* @param params The parameters for the offscreen check
51+
* @param params.container The scrollable container element
52+
* @param params.currentTableOffset The current vertical offset of the table within the container
53+
* @param params.totalItems Total number of rows in the table
54+
* @param params.rowHeight Fixed row height in pixels
55+
* @param params.freezeMarginPx Optional additional margin in pixels; defaults to container.clientHeight
56+
* @returns True if the table is offscreen beyond the margin; otherwise false
57+
*/
58+
export function isTableOffscreen(params: {
59+
container: HTMLElement;
60+
currentTableOffset: number;
61+
totalItems: number;
62+
rowHeight: number;
63+
freezeMarginPx?: number;
64+
}): boolean {
65+
const {container, currentTableOffset, totalItems, rowHeight, freezeMarginPx} = params;
66+
const tableStartY = currentTableOffset;
67+
const tableEndY = tableStartY + totalItems * rowHeight;
68+
const viewportMin = container.scrollTop;
69+
const viewportMax = viewportMin + container.clientHeight;
70+
const margin = typeof freezeMarginPx === 'number' ? freezeMarginPx : container.clientHeight;
71+
return viewportMax < tableStartY - margin || viewportMin > tableEndY + margin;
5672
}

0 commit comments

Comments
 (0)