Skip to content
Open
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
2 changes: 1 addition & 1 deletion packages/main/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -864,7 +864,7 @@
"watch:css": "yarn build:css --watch"
},
"dependencies": {
"@tanstack/react-virtual": "3.14.23",
"@tanstack/react-virtual": "3.14.26",
"@ui5/webcomponents-react-base": "workspace:~",
"clsx": "2.1.1"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1849,57 +1849,6 @@ describe('AnalyticalTable', () => {
});
});

it('first virtual row offset matches scrollTop after loading cycle', () => {
// Guards the layout-effect in `AnalyticalTable/index.tsx` that re-syncs the virtualizer's cached `scrollOffset` with the DOM after a data swap clamps `scrollTop` (dispatching a scroll event).
// Without it, the first row renders at a stale `translateY` and leaves a whitespace gap at the top.
// `minRows={20}` + a filter result smaller than `minRows` keeps `itemCount = Math.max(minRows, rows.length, ...)` constant across the empty-then-refilled cycle, so the effect can only re-fire via the `rows.length` dep — guarding both deps in one shot.
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 < 5));
setLoading(false);
}, 100);
};
return (
<>
<Button data-testid="filter" onClick={triggerFilter}>
Filter
</Button>
<AnalyticalTable
data={tableData}
columns={columns}
loading={loading}
reactTableOptions={reactTableOptions}
visibleRows={15}
minRows={20}
/>
</>
);
};
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
1 change: 0 additions & 1 deletion packages/main/src/components/AnalyticalTable/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -811,7 +811,6 @@ const AnalyticalTable = forwardRef<AnalyticalTableDomRef, AnalyticalTablePropTyp
overscan,
measureElement,
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.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { AnalyticalTableSelectionMode } from '../../../enums/AnalyticalTableSelectionMode.js';
import { AnalyticalTableSubComponentsBehavior } from '../../../enums/AnalyticalTableSubComponentsBehavior.js';
import { Button } from '../../../webComponents/Button/index.js';
import { Panel } from '../../../webComponents/Panel/index.js';
import { Tab } from '../../../webComponents/Tab/index.js';
import { TabContainer } from '../../../webComponents/TabContainer/index.js';
Expand Down Expand Up @@ -272,3 +274,94 @@ export const BorderlessHarness = () => (
</div>
</>
);

// Virtualized, scrollable table with variable-height row subcomponents — drives the #6923 RO-loop regression spec.
const subComponentData = Array.from({ length: 200 }, (_, i) => ({
name: `Name-${i}`,
age: i,
friend: { name: `Friend-${i}`, age: i + 100 },
}));

const subComponentColumns: AnalyticalTableColumnDefinition[] = [
{ Header: 'Name', accessor: 'name' },
{ Header: 'Age', accessor: 'age' },
{ Header: 'Friend Name', accessor: 'friend.name' },
];

export const SubComponentResizeHarness = ({
subComponentsBehavior = AnalyticalTableSubComponentsBehavior.Visible,
}: {
subComponentsBehavior?: AnalyticalTablePropTypes['subComponentsBehavior'];
}) => {
const renderRowSubComponent = useCallback((row) => {
// Vary heights so re-measurement moves rows; one row has no subcomponent.
if (row.index % 7 === 2) {
return null;
}
const height = row.index % 3 === 0 ? 240 : row.index % 3 === 1 ? 120 : 60;
return (
<div
data-testid={`subcomponent-${row.id}`}
style={{ height: `${height}px`, background: 'var(--sapList_Background)' }}
>
{`subcomponent ${row.id} — ${height}px`}
</div>
);
}, []);
return (
<AnalyticalTable
data={subComponentData}
columns={subComponentColumns}
renderRowSubComponent={renderRowSubComponent}
subComponentsBehavior={subComponentsBehavior}
visibleRows={5}
/>
);
};

// Scrolled near the end, then an async filter empties and refills with fewer rows than minRows: the DOM clamps
// scrollTop with no scroll event, and minRows (20) > result keeps itemCount constant so the re-sync effect can only
// re-fire via its rows.length dep. Drives the scrollOffset regression test (origin PR #8631).
export const ScrollOffsetAfterFilterHarness = () => {
const full = useMemo(() => Array.from({ length: 500 }, (_, i) => ({ name: `Row-${i}`, age: i })), []);
const [tableData, setTableData] = useState(full);
const [loading, setLoading] = useState(false);
const columns = useMemo<AnalyticalTableColumnDefinition[]>(
() => [
{ Header: 'Name', accessor: 'name' },
{ Header: 'Age', accessor: 'age' },
],
[],
);
const reactTableOptions = useMemo(() => ({ manualFilters: true }), []);
const onTableScroll = useCallback(() => {
const w = window as unknown as { __tableScrollCount?: number };
w.__tableScrollCount = (w.__tableScrollCount ?? 0) + 1;
}, []);
return (
<>
<Button
data-testid="filter"
onClick={() => {
setTableData([]);
setLoading(true);
setTimeout(() => {
setTableData(full.filter((row) => row.age < 5));
setLoading(false);
}, 100);
}}
>
Filter
</Button>
<AnalyticalTable
data={tableData}
columns={columns}
loading={loading}
reactTableOptions={reactTableOptions}
onTableScroll={onTableScroll}
visibleRows={15}
minRows={20}
/>
</>
);
};
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import type { Locator, Page } from '@playwright/test';
import type { ConsoleMessage, Locator, Page } from '@playwright/test';
import { expect, test } from '../../../../../../playwright/fixtures/gallery-fixtures.js';
import { AnalyticalTableScaleWidthMode } from '../../../enums/AnalyticalTableScaleWidthMode.js';
import { AnalyticalTableSelectionMode } from '../../../enums/AnalyticalTableSelectionMode.js';
import { AnalyticalTableSubComponentsBehavior } from '../../../enums/AnalyticalTableSubComponentsBehavior.js';
import type { AnalyticalTableColumnDefinition } from '../index.js';
import type { StickyHarness } from './AnalyticalTable.gallery.js';
import type {
ScrollOffsetAfterFilterHarness,
StickyHarness,
SubComponentResizeHarness,
} from './AnalyticalTable.gallery.js';

// 5×150 = 750px total; container 500px → guaranteed horizontal overflow.
const wideCols: AnalyticalTableColumnDefinition[] = [
Expand Down Expand Up @@ -496,3 +501,112 @@ test.describe('AnalyticalTable Smart scaleWidthMode — shrink to fit', () => {
expect(await boxWidth(columnHeader(page, 'friend.name'))).toBeGreaterThanOrEqual(100);
});
});

test.describe('AnalyticalTable — scroll offset', () => {
// Guards the scrollOffset re-sync effect (index.tsx) after a filter clamps scrollTop. onTableScroll must fire:
// on current virtual-core the native range-clamp already fixes the render, so the translateY check alone no longer
// detects removal — keep both. translateY vs scrollTop: <500 is within the overscan window, stale is ≈3000px.
test('re-syncs scroll offset after an async filter shrinks the rows', async ({ mount, page }) => {
await mount<typeof ScrollOffsetAfterFilterHarness>('AnalyticalTable/ScrollOffsetAfterFilterHarness', {});
const body = page.locator('[data-component-name="AnalyticalTableBody"]');
await expect(body).toBeVisible();

await body.evaluate((el) => el.scrollTo(0, 4000));
await expect.poll(() => body.evaluate((el) => el.scrollTop)).toBeGreaterThan(0);
await page.waitForTimeout(250);
await page.evaluate(() => ((window as unknown as { __tableScrollCount: number }).__tableScrollCount = 0));

await page.getByTestId('filter').click();

await expect
.poll(() => page.evaluate(() => (window as unknown as { __tableScrollCount?: number }).__tableScrollCount ?? 0))
.toBeGreaterThan(0);

await expect
.poll(() =>
body.evaluate((el) => {
const container = el.querySelector('[data-component-name="AnalyticalTableBodyScrollableContainer"]');
const firstRow = container?.children?.[0] as HTMLElement | undefined;
if (!firstRow) {
return Number.POSITIVE_INFINITY;
}
const match = firstRow.style.transform?.match(/translateY\(([\d.]+)px\)/);
const translateY = match ? parseFloat(match[1]) : 0;
return Math.abs(translateY - el.scrollTop);
}),
)
.toBeLessThan(500);
});
});

const RO_LOOP = /ResizeObserver loop/i;

// Collect RO-loop reports from every channel: the window `error` event (where Chromium dispatches it), console, and pageerror.
async function trackResizeObserverLoop(page: Page): Promise<() => Promise<string[]>> {
const fromListeners: string[] = [];
page.on('console', (msg: ConsoleMessage) => {
if (RO_LOOP.test(msg.text())) {
fromListeners.push(msg.text());
}
});
page.on('pageerror', (err) => {
if (RO_LOOP.test(err.message)) {
fromListeners.push(err.message);
}
});
await page.addInitScript(() => {
(window as unknown as { __roErrors: string[] }).__roErrors = [];
window.addEventListener('error', (e: ErrorEvent) => {
if (/ResizeObserver loop/i.test(e?.message ?? '')) {
(window as unknown as { __roErrors: string[] }).__roErrors.push(e.message);
}
});
});
return async () => {
const fromWindow = await page.evaluate(() => (window as unknown as { __roErrors?: string[] }).__roErrors ?? []);
return [...fromWindow, ...fromListeners];
};
}

// Scroll so virtualized rows and their subcomponents mount/unmount while being measured.
async function scrollStress(page: Page) {
const body = page.locator('[data-component-name="AnalyticalTableBody"]');
await expect(body).toBeVisible();
for (const offset of [600, 1800, 400, 2400, 0, 1200]) {
await body.evaluate((el, top) => el.scrollTo(0, top), offset);
await page.waitForTimeout(80);
}
}

// Regression guard for #6923: dynamic subcomponent heights must not trigger a ResizeObserver loop during scroll.
test.describe('AnalyticalTable subcomponents — ResizeObserver loop', () => {
test('no RO loop while scrolling with always-visible variable-height subcomponents', async ({ mount, page }) => {
const getErrors = await trackResizeObserverLoop(page);
await mount<typeof SubComponentResizeHarness>('AnalyticalTable/SubComponentResizeHarness', {
subComponentsBehavior: AnalyticalTableSubComponentsBehavior.Visible,
});
await expect(page.getByRole('grid')).toBeVisible();
await expect(page.getByTestId('subcomponent-0')).toBeVisible();

await scrollStress(page);

expect(await getErrors()).toEqual([]);
});

test('no RO loop while expanding/collapsing subcomponents during scroll', async ({ mount, page }) => {
const getErrors = await trackResizeObserverLoop(page);
await mount<typeof SubComponentResizeHarness>('AnalyticalTable/SubComponentResizeHarness', {
subComponentsBehavior: AnalyticalTableSubComponentsBehavior.IncludeHeightExpandable,
});
await expect(page.getByRole('grid')).toBeVisible();

const expander = () => page.locator('[title="Expand Node"]').first().locator('[ui5-button]');
for (let i = 0; i < 4; i++) {
await expander().click();
await scrollStress(page);
await expander().click();
}

expect(await getErrors()).toEqual([]);
});
});
20 changes: 10 additions & 10 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4408,22 +4408,22 @@ __metadata:
languageName: node
linkType: hard

"@tanstack/react-virtual@npm:3.14.23":
version: 3.14.23
resolution: "@tanstack/react-virtual@npm:3.14.23"
"@tanstack/react-virtual@npm:3.14.26":
version: 3.14.26
resolution: "@tanstack/react-virtual@npm:3.14.26"
dependencies:
"@tanstack/virtual-core": "npm:3.19.8"
"@tanstack/virtual-core": "npm:3.19.11"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 10c0/cd39e337ba5d450be5cade885dd0a043ef9149abd95331c533b588a626573af4a48b0d4e78d2bcf36b4ffa1267921dfae7203dec8aa8aa7f128014accd2e4ff5
checksum: 10c0/71291177ac4c4b067a40351d46c69af0d5fc9dfcc6cb328cb87717b29209501a7193e92e75e599273ee423fff6c55c88157bd238684846b0d926dfa73b5d6dac
languageName: node
linkType: hard

"@tanstack/virtual-core@npm:3.19.8":
version: 3.19.8
resolution: "@tanstack/virtual-core@npm:3.19.8"
checksum: 10c0/eac7a1edd843d8c0f1b07343a7804ac393fb152cb046581331c1c912b7dc518f484c5bca69cae2637739f447cffc86f779b3f4ca7b227872ed7cd5dc8fe5d28b
"@tanstack/virtual-core@npm:3.19.11":
version: 3.19.11
resolution: "@tanstack/virtual-core@npm:3.19.11"
checksum: 10c0/61558882fd09f64020104ecd94bacaa2a6ccbb0745247610ede7683d709cf8d134cc3f6d1ee2d22203cb881e531bc4a3bb92e4657c08edf923ab6d5c39a958ec
languageName: node
linkType: hard

Expand Down Expand Up @@ -5786,7 +5786,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "@ui5/webcomponents-react@workspace:packages/main"
dependencies:
"@tanstack/react-virtual": "npm:3.14.23"
"@tanstack/react-virtual": "npm:3.14.26"
"@ui5/webcomponents-react-base": "workspace:~"
clsx: "npm:2.1.1"
peerDependencies:
Expand Down
Loading