Skip to content

Commit 46ffd70

Browse files
authored
fix(AnalyticalTable): prevent subcomponent re-render loop at browser zoom (#8645)
Fixes #8641
1 parent 42c5d86 commit 46ffd70

2 files changed

Lines changed: 159 additions & 111 deletions

File tree

packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx

Lines changed: 154 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -2486,131 +2486,176 @@ describe('AnalyticalTable', () => {
24862486
cy.findByText('A').shouldNotBeClickable(done);
24872487
});
24882488

2489-
it('render subcomponents', () => {
2490-
const renderRowSubComponentLarge = (row) => {
2491-
return (
2492-
<div title="subcomponent" style={{ height: '200px', width: '100%', display: 'flex', alignItems: 'end' }}>
2493-
{`SubComponent ${row.index}`}
2494-
</div>
2495-
);
2496-
};
2497-
const renderRowSubComponent = () => {
2498-
return <div title="subcomponent">SubComponent</div>;
2499-
};
2489+
// `describe` is used to clean-up zoom level after test
2490+
describe('render subcomponents', () => {
2491+
let originalZoom: string;
2492+
before(() => {
2493+
originalZoom = document.documentElement.style.zoom;
2494+
});
2495+
after(() => {
2496+
document.documentElement.style.zoom = originalZoom;
2497+
});
25002498

2501-
const onlyFirstRowWithSubcomponent = (row) => {
2502-
if (row.id === '0') {
2503-
return <div title="subcomponent">SingleSubComponent</div>;
2504-
}
2505-
};
2506-
cy.mount(<AnalyticalTable data={data} columns={columns} renderRowSubComponent={renderRowSubComponent} />);
2499+
// ~700 callback invocations per mount at default zoom; broken fractional-zoom loop produces 5000+
2500+
const LOOP_BUDGET_PER_MOUNT = 2000;
25072501

2508-
cy.findAllByTitle('Expand Node').should('have.length', 4);
2509-
cy.findAllByTitle('Collapse Node').should('not.exist');
2502+
[
2503+
{ zoom: '1', label: 'default zoom' },
2504+
{ zoom: '1.1', label: 'fractional zoom (1.1)' },
2505+
].forEach(({ zoom, label }) => {
2506+
it(label, () => {
2507+
document.documentElement.style.zoom = zoom;
2508+
2509+
let renderCallCount = 0;
2510+
const countCalls = (fn) => (row) => {
2511+
renderCallCount++;
2512+
return fn(row);
2513+
};
2514+
const expectBoundedAndReset = () => {
2515+
cy.then(() => {
2516+
expect(renderCallCount).to.be.lessThan(LOOP_BUDGET_PER_MOUNT);
2517+
renderCallCount = 0;
2518+
});
2519+
};
25102520

2511-
cy.get('[aria-rowindex="2"] > [aria-colindex="1"] > [title="Expand Node"] > [ui5-button]').click();
2521+
const renderRowSubComponentLarge = countCalls((row) => {
2522+
return (
2523+
<div title="subcomponent" style={{ height: '200px', width: '100%', display: 'flex', alignItems: 'end' }}>
2524+
{`SubComponent ${row.index}`}
2525+
</div>
2526+
);
2527+
});
2528+
const renderRowSubComponent = countCalls(() => {
2529+
return <div title="subcomponent">SubComponent</div>;
2530+
});
2531+
const onlyFirstRowWithSubcomponent = countCalls((row) => {
2532+
if (row.id === '0') {
2533+
return <div title="subcomponent">SingleSubComponent</div>;
2534+
}
2535+
});
25122536

2513-
cy.findAllByTitle('Expand Node').should('have.length', 3);
2514-
cy.findAllByTitle('Collapse Node').should('have.length', 1);
2515-
cy.findByText('SubComponent').should('be.visible');
2537+
cy.mount(<AnalyticalTable data={data} columns={columns} renderRowSubComponent={renderRowSubComponent} />);
25162538

2517-
cy.get('[aria-rowindex="3"] > [aria-colindex="1"] > [title="Expand Node"] > [ui5-button]').click();
2539+
cy.findAllByTitle('Expand Node').should('have.length', 4);
2540+
cy.findAllByTitle('Collapse Node').should('not.exist');
25182541

2519-
cy.findAllByTitle('Expand Node').should('have.length', 2);
2520-
cy.findAllByTitle('Collapse Node').should('have.length', 2);
2521-
cy.findAllByText('SubComponent').should('be.visible').should('have.length', 2);
2542+
cy.get('[aria-rowindex="2"] > [aria-colindex="1"] > [title="Expand Node"] > [ui5-button]').click();
25222543

2523-
cy.mount(<AnalyticalTable data={data} columns={columns} renderRowSubComponent={onlyFirstRowWithSubcomponent} />);
2544+
cy.findAllByTitle('Expand Node').should('have.length', 3);
2545+
cy.findAllByTitle('Collapse Node').should('have.length', 1);
2546+
cy.findByText('SubComponent').should('be.visible');
25242547

2525-
cy.findAllByTitle('Expand Node').should('have.length', 1);
2526-
cy.findAllByTitle('Collapse Node').should('not.exist');
2548+
cy.get('[aria-rowindex="3"] > [aria-colindex="1"] > [title="Expand Node"] > [ui5-button]').click();
25272549

2528-
cy.get('[aria-rowindex="2"] > [aria-colindex="1"] > [title="Expand Node"] > [ui5-button]').click();
2550+
cy.findAllByTitle('Expand Node').should('have.length', 2);
2551+
cy.findAllByTitle('Collapse Node').should('have.length', 2);
2552+
cy.findAllByText('SubComponent').should('be.visible').should('have.length', 2);
2553+
expectBoundedAndReset();
25292554

2530-
cy.findAllByTitle('Expand Node').should('not.exist');
2531-
cy.findAllByTitle('Collapse Node').should('have.length', 1);
2532-
cy.findByText('SingleSubComponent').should('be.visible');
2533-
cy.get('[aria-rowindex="3"] > [aria-colindex="1"] > [title="Expand Node"] > [ui5-button]').should('not.exist');
2555+
cy.mount(
2556+
<AnalyticalTable data={data} columns={columns} renderRowSubComponent={onlyFirstRowWithSubcomponent} />,
2557+
);
25342558

2535-
cy.mount(
2536-
<AnalyticalTable
2537-
data={data}
2538-
columns={columns}
2539-
renderRowSubComponent={renderRowSubComponent}
2540-
subComponentsBehavior={AnalyticalTableSubComponentsBehavior.Visible}
2541-
/>,
2542-
);
2543-
cy.findAllByText('SubComponent').should('be.visible').should('have.length', 4);
2544-
cy.findByTitle('Expand Node').should('not.exist');
2545-
cy.findByTitle('Collapse Node').should('not.exist');
2559+
cy.findAllByTitle('Expand Node').should('have.length', 1);
2560+
cy.findAllByTitle('Collapse Node').should('not.exist');
25462561

2547-
cy.mount(
2548-
<AnalyticalTable
2549-
data={data}
2550-
columns={columns}
2551-
renderRowSubComponent={onlyFirstRowWithSubcomponent}
2552-
subComponentsBehavior={AnalyticalTableSubComponentsBehavior.Visible}
2553-
/>,
2554-
);
2555-
cy.findByText('SingleSubComponent').should('be.visible').should('have.length', 1);
2556-
cy.findByTitle('Expand Node').should('not.exist');
2557-
cy.findByTitle('Collapse Node').should('not.exist');
2562+
cy.get('[aria-rowindex="2"] > [aria-colindex="1"] > [title="Expand Node"] > [ui5-button]').click();
25582563

2559-
cy.mount(
2560-
<AnalyticalTable
2561-
data={data}
2562-
columns={columns}
2563-
renderRowSubComponent={renderRowSubComponentLarge}
2564-
visibleRows={3}
2565-
subComponentsBehavior={AnalyticalTableSubComponentsBehavior.Visible}
2566-
/>,
2567-
);
2568-
cy.wait(300);
2564+
cy.findAllByTitle('Expand Node').should('not.exist');
2565+
cy.findAllByTitle('Collapse Node').should('have.length', 1);
2566+
cy.findByText('SingleSubComponent').should('be.visible');
2567+
cy.get('[aria-rowindex="3"] > [aria-colindex="1"] > [title="Expand Node"] > [ui5-button]').should('not.exist');
2568+
expectBoundedAndReset();
25692569

2570-
cy.findByText('SubComponent 1').should('exist').and('not.be.visible');
2571-
cy.findByTitle('Expand Node').should('not.exist');
2572-
cy.findByTitle('Collapse Node').should('not.exist');
2570+
cy.mount(
2571+
<AnalyticalTable
2572+
data={data}
2573+
columns={columns}
2574+
renderRowSubComponent={renderRowSubComponent}
2575+
subComponentsBehavior={AnalyticalTableSubComponentsBehavior.Visible}
2576+
/>,
2577+
);
2578+
cy.findAllByText('SubComponent').should('be.visible').should('have.length', 4);
2579+
cy.findByTitle('Expand Node').should('not.exist');
2580+
cy.findByTitle('Collapse Node').should('not.exist');
2581+
expectBoundedAndReset();
25732582

2574-
cy.mount(
2575-
<AnalyticalTable
2576-
data={data}
2577-
columns={columns}
2578-
renderRowSubComponent={renderRowSubComponentLarge}
2579-
visibleRows={3}
2580-
subComponentsBehavior={AnalyticalTableSubComponentsBehavior.IncludeHeight}
2581-
/>,
2582-
);
2583-
cy.findByText('SubComponent 1').should('be.visible');
2584-
cy.findByText('SubComponent 2').should('be.visible');
2585-
cy.findByTitle('Expand Node').should('not.exist');
2586-
cy.findByTitle('Collapse Node').should('not.exist');
2583+
cy.mount(
2584+
<AnalyticalTable
2585+
data={data}
2586+
columns={columns}
2587+
renderRowSubComponent={onlyFirstRowWithSubcomponent}
2588+
subComponentsBehavior={AnalyticalTableSubComponentsBehavior.Visible}
2589+
/>,
2590+
);
2591+
cy.findByText('SingleSubComponent').should('be.visible').should('have.length', 1);
2592+
cy.findByTitle('Expand Node').should('not.exist');
2593+
cy.findByTitle('Collapse Node').should('not.exist');
2594+
expectBoundedAndReset();
25872595

2588-
const loadMore = cy.spy().as('more');
2589-
cy.mount(
2590-
<AnalyticalTable
2591-
onLoadMore={loadMore}
2592-
infiniteScroll={true}
2593-
infiniteScrollThreshold={0}
2594-
data={data}
2595-
columns={columns}
2596-
renderRowSubComponent={renderRowSubComponentLarge}
2597-
visibleRows={3}
2598-
subComponentsBehavior={AnalyticalTableSubComponentsBehavior.IncludeHeightExpandable}
2599-
/>,
2600-
);
2601-
cy.findByText('A').should('be.visible');
2602-
cy.findByText('X').should('be.visible');
2603-
cy.findByText('C').should('not.be.visible');
2604-
cy.get('[aria-rowindex="2"] > [aria-colindex="1"] > [title="Expand Node"] > [ui5-button]').click();
2605-
cy.findByText('A').should('be.visible');
2606-
cy.findByText('X').should('be.visible');
2607-
cy.findByText('C').should('not.be.visible');
2608-
cy.get('[aria-rowindex="3"] > [aria-colindex="1"] > [title="Expand Node"] > [ui5-button]').click();
2609-
cy.findByText('A').should('be.visible');
2610-
cy.findByText('X').should('be.visible');
2611-
cy.findByText('C').should('not.be.visible');
2612-
cy.get('[data-component-name="AnalyticalTableBody"]').scrollTo('bottom');
2613-
cy.get('@more').should('have.been.calledOnce');
2596+
cy.mount(
2597+
<AnalyticalTable
2598+
data={data}
2599+
columns={columns}
2600+
renderRowSubComponent={renderRowSubComponentLarge}
2601+
visibleRows={3}
2602+
subComponentsBehavior={AnalyticalTableSubComponentsBehavior.Visible}
2603+
/>,
2604+
);
2605+
cy.wait(300);
2606+
2607+
cy.findByText('SubComponent 1').should('exist').and('not.be.visible');
2608+
cy.findByTitle('Expand Node').should('not.exist');
2609+
cy.findByTitle('Collapse Node').should('not.exist');
2610+
expectBoundedAndReset();
2611+
2612+
cy.mount(
2613+
<AnalyticalTable
2614+
data={data}
2615+
columns={columns}
2616+
renderRowSubComponent={renderRowSubComponentLarge}
2617+
visibleRows={3}
2618+
subComponentsBehavior={AnalyticalTableSubComponentsBehavior.IncludeHeight}
2619+
/>,
2620+
);
2621+
cy.findByText('SubComponent 1').should('be.visible');
2622+
cy.findByText('SubComponent 2').should('be.visible');
2623+
cy.findByTitle('Expand Node').should('not.exist');
2624+
cy.findByTitle('Collapse Node').should('not.exist');
2625+
expectBoundedAndReset();
2626+
2627+
const loadMore = cy.spy().as('more');
2628+
cy.mount(
2629+
<AnalyticalTable
2630+
onLoadMore={loadMore}
2631+
infiniteScroll={true}
2632+
infiniteScrollThreshold={0}
2633+
data={data}
2634+
columns={columns}
2635+
renderRowSubComponent={renderRowSubComponentLarge}
2636+
visibleRows={3}
2637+
subComponentsBehavior={AnalyticalTableSubComponentsBehavior.IncludeHeightExpandable}
2638+
/>,
2639+
);
2640+
cy.findByText('A').should('be.visible');
2641+
cy.findByText('X').should('be.visible');
2642+
cy.findByText('C').should('not.be.visible');
2643+
cy.get('[aria-rowindex="2"] > [aria-colindex="1"] > [title="Expand Node"] > [ui5-button]').click();
2644+
cy.findByText('A').should('be.visible');
2645+
cy.findByText('X').should('be.visible');
2646+
cy.findByText('C').should('not.be.visible');
2647+
cy.get('[aria-rowindex="3"] > [aria-colindex="1"] > [title="Expand Node"] > [ui5-button]').click();
2648+
cy.findByText('A').should('be.visible');
2649+
cy.findByText('X').should('be.visible');
2650+
cy.findByText('C').should('not.be.visible');
2651+
if (zoom === '1') {
2652+
// Cypress' scrollTo('bottom') uses BCR-style coords and doesn't fire onLoadMore reliably under CSS zoom; verified at zoom=1 only
2653+
cy.get('[data-component-name="AnalyticalTableBody"]').scrollTo('bottom');
2654+
cy.get('@more').should('have.been.calledOnce');
2655+
}
2656+
expectBoundedAndReset();
2657+
});
2658+
});
26142659
});
26152660

26162661
if (reactVersion.startsWith('19')) {

packages/main/src/components/AnalyticalTable/TableBody/RowSubComponent.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@ export function RowSubComponent(props: RowSubComponentProps) {
4040
return;
4141
}
4242

43-
const measureAndDispatch = (height: number) => {
43+
const measureAndDispatch = (rawHeight: number) => {
44+
// ceil: avoid under-estimation (would overlap next row) and absorb sub-px precision diff between getComputedStyle and borderBoxSize
45+
const height = Math.ceil(rawHeight);
4446
const prev: { rowId?: string; subComponentHeight?: number } = subComponentsHeight?.[virtualRow.index] ?? {};
4547
if (height === 0 || (prev.subComponentHeight === height && prev.rowId === row.id)) {
4648
return;
@@ -72,7 +74,8 @@ export function RowSubComponent(props: RowSubComponentProps) {
7274
}
7375
};
7476

75-
measureAndDispatch(subComponentElement.getBoundingClientRect().height);
77+
// sync initial read for first-paint correctness; blockSize matches borderBoxSize coords (getBoundingClientRect returns zoom-applied)
78+
measureAndDispatch(parseFloat(getComputedStyle(subComponentElement).blockSize));
7679

7780
const observer = new ResizeObserver(([entry]) => {
7881
measureAndDispatch(entry.borderBoxSize[0].blockSize);

0 commit comments

Comments
 (0)