Skip to content

Commit 2e3a46c

Browse files
authored
fix: catch rapid size changes missed by ResizeObserver in virtualizer (#10370) (#10389)
1 parent d6e30b1 commit 2e3a46c

File tree

3 files changed

+131
-1
lines changed

3 files changed

+131
-1
lines changed

packages/component-base/src/virtualizer-iron-list-adapter.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,16 @@ export class IronListAdapter {
218218
// eslint-disable-next-line @typescript-eslint/no-unused-vars
219219
this._iterateItems((pidx, vidx) => {
220220
oldPhysicalSize += this._physicalSizes[pidx];
221+
const elementOldPhysicalSize = this._physicalSizes[pidx];
221222
this._physicalSizes[pidx] = Math.ceil(this.__getBorderBoxHeight(this._physicalItems[pidx]));
223+
224+
if (this._physicalSizes[pidx] !== elementOldPhysicalSize) {
225+
// Physical size changed, but resize observer may not catch it if the original size is restored quickly.
226+
// See https://github.com/vaadin/web-components/issues/9077
227+
this.__resizeObserver.unobserve(this._physicalItems[pidx]);
228+
this.__resizeObserver.observe(this._physicalItems[pidx]);
229+
}
230+
222231
newPhysicalSize += this._physicalSizes[pidx];
223232
this._physicalAverageCount += this._physicalSizes[pidx] ? 1 : 0;
224233
}, itemSet);

packages/component-base/test/virtualizer-item-height.test.js

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect } from '@vaadin/chai-plugins';
2-
import { aTimeout, fixtureSync, nextFrame } from '@vaadin/testing-helpers';
2+
import { aTimeout, fixtureSync, nextFrame, oneEvent } from '@vaadin/testing-helpers';
33
import sinon from 'sinon';
44
import { Virtualizer } from '../src/virtualizer.js';
55

@@ -412,3 +412,61 @@ describe('virtualizer - item height - lazy rendering', () => {
412412
});
413413
});
414414
});
415+
416+
describe('virtualizer - item height - self-resizing items', () => {
417+
// Create a custom element that resizes itself on slotchange
418+
// (simulating vaadin-card's behavior, see https://github.com/vaadin/web-components/issues/9077)
419+
customElements.define(
420+
'resize-item',
421+
class extends HTMLElement {
422+
constructor() {
423+
super();
424+
this.attachShadow({ mode: 'open' }).innerHTML = `<slot></slot>`;
425+
this.shadowRoot.addEventListener('slotchange', () => {
426+
this.style.display = 'block';
427+
this.style.height = '100px';
428+
});
429+
}
430+
},
431+
);
432+
433+
let virtualizer;
434+
let scrollTarget;
435+
436+
beforeEach(() => {
437+
scrollTarget = fixtureSync(`
438+
<div style="height: 300px;">
439+
<div class="container"></div>
440+
</div>
441+
`);
442+
const scrollContainer = scrollTarget.firstElementChild;
443+
444+
virtualizer = new Virtualizer({
445+
createElements: (count) => Array.from({ length: count }, () => document.createElement('div')),
446+
updateElement: (el, index) => {
447+
el.innerHTML = `<resize-item id="item-${index}">Item ${index}</resize-item>`;
448+
},
449+
scrollTarget,
450+
scrollContainer,
451+
});
452+
453+
virtualizer.size = 100;
454+
});
455+
456+
it('should not overlap items after scrolling', async () => {
457+
await contentUpdate();
458+
// Scroll manually to the end
459+
while (Math.ceil(scrollTarget.scrollTop) < scrollTarget.scrollHeight - scrollTarget.clientHeight) {
460+
scrollTarget.scrollTop += 100;
461+
await oneEvent(scrollTarget, 'scroll');
462+
}
463+
464+
// Ensure that the first two visible items do not overlap
465+
const firstVisibleItem = scrollTarget.querySelector(`#item-${virtualizer.firstVisibleIndex}`);
466+
const secondVisibleItem = scrollTarget.querySelector(`#item-${virtualizer.firstVisibleIndex + 1}`);
467+
468+
expect(firstVisibleItem.getBoundingClientRect().bottom).to.be.at.most(
469+
secondVisibleItem.getBoundingClientRect().top,
470+
);
471+
});
472+
});
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { expect } from '@vaadin/chai-plugins';
2+
import { aTimeout, fixtureSync, nextFrame, oneEvent } from '@vaadin/testing-helpers';
3+
import '@vaadin/virtual-list';
4+
import '@vaadin/card';
5+
6+
describe('virtual-list with card items', () => {
7+
let virtualList;
8+
9+
async function contentUpdate() {
10+
// Wait for the content to update (and resize observer to fire)
11+
await aTimeout(200);
12+
}
13+
14+
beforeEach(async () => {
15+
virtualList = fixtureSync(`
16+
<vaadin-virtual-list style="height: 300px;"></vaadin-virtual-list>
17+
`);
18+
19+
// Create a renderer that creates a new vaadin-card on each render
20+
// See https://github.com/vaadin/web-components/issues/9077
21+
virtualList.renderer = (root, _, model) => {
22+
root.innerHTML = `
23+
<vaadin-card id="card-${model.index}">
24+
<div slot="title">Title ${model.index}</div>
25+
<div slot="subtitle">Subtitle ${model.index}</div>
26+
</vaadin-card>
27+
`;
28+
};
29+
30+
virtualList.items = Array.from({ length: 100 }, (_, i) => ({ index: i }));
31+
await nextFrame();
32+
});
33+
34+
it('should not overlap items after scrolling', async () => {
35+
// Scroll manually to the end
36+
while (Math.ceil(virtualList.scrollTop) < virtualList.scrollHeight - virtualList.clientHeight) {
37+
virtualList.scrollTop += 100;
38+
await oneEvent(virtualList, 'scroll');
39+
}
40+
41+
await contentUpdate();
42+
43+
// Ensure that the first two visible items do not overlap
44+
const firstVisibleItem = virtualList.querySelector(`#card-${virtualList.firstVisibleIndex}`);
45+
const secondVisibleItem = virtualList.querySelector(`#card-${virtualList.firstVisibleIndex + 1}`);
46+
expect(firstVisibleItem.getBoundingClientRect().bottom).to.be.at.most(
47+
secondVisibleItem.getBoundingClientRect().top,
48+
);
49+
});
50+
51+
it('should not overlap items after changing scroll position', async () => {
52+
virtualList.scrollTop = virtualList.scrollHeight;
53+
54+
await contentUpdate();
55+
56+
// Ensure that the first two visible items do not overlap
57+
const firstVisibleItem = virtualList.querySelector(`#card-${virtualList.firstVisibleIndex}`);
58+
const secondVisibleItem = virtualList.querySelector(`#card-${virtualList.firstVisibleIndex + 1}`);
59+
expect(firstVisibleItem.getBoundingClientRect().bottom).to.be.at.most(
60+
secondVisibleItem.getBoundingClientRect().top,
61+
);
62+
});
63+
});

0 commit comments

Comments
 (0)