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
Original file line number Diff line number Diff line change
Expand Up @@ -1609,6 +1609,55 @@ describe('AnalyticalTable', () => {
cy.get('[data-column-id="age"]').should('not.exist', { timeout: 100 });
});

it('first virtual row offset matches scrollTop after loading cycle', () => {
// Guards the layout-effect in `AnalyticalTable/index.tsx` that re-syncs the virtualizer' cached `scrollOffset` with the DOM after a data swap clamps `scrollTop` (dispatching scroll event).
// Without it, the first row renders at a stale `translateY` and leaves a whitespace gap at the top.
const filterData = new Array(500).fill('').map((_, index) => ({ name: `Row-${index}`, age: index }));
const TestComp = () => {
const [tableData, setTableData] = useState(filterData);
const [loading, setLoading] = useState(false);
const reactTableOptions = useMemo(() => ({ manualFilters: true }), []);
const triggerFilter = () => {
setTableData([]);
setLoading(true);
setTimeout(() => {
setTableData(filterData.filter((item) => item.age >= 100));
setLoading(false);
}, 100);
};
return (
<>
<Button data-testid="filter" onClick={triggerFilter}>
Filter
</Button>
<AnalyticalTable
data={tableData}
columns={columns}
loading={loading}
reactTableOptions={reactTableOptions}
visibleRows={15}
/>
</>
);
};
cy.mount(<TestComp />);
cy.get('[data-component-name="AnalyticalTableBody"]').as('body');
cy.get('@body').scrollTo(0, 4000);
cy.findByTestId('filter').click();
// `.should(callback)` retries until both assertions pass — implicitly waits for the loading cycle to finish and rows to be rendered.
cy.get('@body').should(($body) => {
const scrollTop = $body[0].scrollTop;
const scrollContainer = $body[0].querySelector('[data-component-name="AnalyticalTableBodyScrollableContainer"]');
const firstRow = scrollContainer?.children?.[0] as HTMLElement | undefined;
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
expect(firstRow, 'first body row should exist').to.exist;
const match = firstRow.style.transform?.match(/translateY\(([\d.]+)px\)/);
const translateY = match ? parseFloat(match[1]) : 0;
// Bounded by the overscan window when in sync; far off (~3000+px) when stale.
expect(Math.abs(translateY - scrollTop), 'first row translateY should be close to scrollTop').to.be.lessThan(500);
});
});

it('InfiniteScroll', () => {
const data = new Array(500).fill('').map((_, index) => ({ name: `Name${index}` }));
const TestComp = (props: Omit<AnalyticalTablePropTypes, 'data' | 'columns'>) => {
Expand Down
14 changes: 14 additions & 0 deletions packages/main/src/components/AnalyticalTable/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -733,6 +733,20 @@ const AnalyticalTable = forwardRef<AnalyticalTableDomRef, AnalyticalTablePropTyp
indexAttribute: 'data-virtual-row-index',
useAnimationFrameWithResizeObserver: true,
});

// Re-sync the virtualizer's cached `scrollOffset` with the DOM after data swaps that clamp `scrollTop` without firing a scroll event in the same React batch.
useIsomorphicLayoutEffect(() => {
const scrollElement = parentRef.current;
if (
scrollElement &&
rowVirtualizer.scrollOffset !== null &&
rowVirtualizer.scrollOffset !== scrollElement.scrollTop
) {
// Defer to a microtask so the scroll listener's `flushSync(rerender)` doesn't run inside this commit.
queueMicrotask(() => scrollElement.dispatchEvent(new Event('scroll')));
}
}, [itemCount]);

// add range to instance for `useAutoResize` plugin hook
tableInstanceRef.current.virtualRowsRange = rowVirtualizer.range;

Expand Down
Loading