Skip to content

Commit 0c07ace

Browse files
authored
fix: catch rapid size changes missed by ResizeObserver in virtualizer (#10370)
1 parent c26fb9d commit 0c07ace

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
@@ -231,7 +231,16 @@ export class IronListAdapter {
231231
// eslint-disable-next-line @typescript-eslint/no-unused-vars
232232
this._iterateItems((pidx, vidx) => {
233233
oldPhysicalSize += this._physicalSizes[pidx];
234+
const elementOldPhysicalSize = this._physicalSizes[pidx];
234235
this._physicalSizes[pidx] = Math.ceil(this.__getBorderBoxHeight(this._physicalItems[pidx]));
236+
237+
if (this._physicalSizes[pidx] !== elementOldPhysicalSize) {
238+
// Physical size changed, but resize observer may not catch it if the original size is restored quickly.
239+
// See https://github.com/vaadin/web-components/issues/9077
240+
this.__resizeObserver.unobserve(this._physicalItems[pidx]);
241+
this.__resizeObserver.observe(this._physicalItems[pidx], { box: 'border-box' });
242+
}
243+
235244
newPhysicalSize += this._physicalSizes[pidx];
236245
this._physicalAverageCount += this._physicalSizes[pidx] ? 1 : 0;
237246
}, 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, nextResize } from '@vaadin/testing-helpers';
2+
import { aTimeout, fixtureSync, nextFrame, nextResize, oneEvent } from '@vaadin/testing-helpers';
33
import sinon from 'sinon';
44
import { Virtualizer } from '../src/virtualizer.js';
55

@@ -479,3 +479,61 @@ describe('virtualizer - item height - placeholders are disabled', () => {
479479
expect(item.offsetHeight).to.equal(0);
480480
});
481481
});
482+
483+
describe('virtualizer - item height - self-resizing items', () => {
484+
// Create a custom element that resizes itself on slotchange
485+
// (simulating vaadin-card's behavior, see https://github.com/vaadin/web-components/issues/9077)
486+
customElements.define(
487+
'resize-item',
488+
class extends HTMLElement {
489+
constructor() {
490+
super();
491+
this.attachShadow({ mode: 'open' }).innerHTML = `<slot></slot>`;
492+
this.shadowRoot.addEventListener('slotchange', () => {
493+
this.style.display = 'block';
494+
this.style.height = '100px';
495+
});
496+
}
497+
},
498+
);
499+
500+
let virtualizer;
501+
let scrollTarget;
502+
503+
beforeEach(() => {
504+
scrollTarget = fixtureSync(`
505+
<div style="height: 300px;">
506+
<div class="container"></div>
507+
</div>
508+
`);
509+
const scrollContainer = scrollTarget.firstElementChild;
510+
511+
virtualizer = new Virtualizer({
512+
createElements: (count) => Array.from({ length: count }, () => document.createElement('div')),
513+
updateElement: (el, index) => {
514+
el.innerHTML = `<resize-item id="item-${index}">Item ${index}</resize-item>`;
515+
},
516+
scrollTarget,
517+
scrollContainer,
518+
});
519+
520+
virtualizer.size = 100;
521+
});
522+
523+
it('should not overlap items after scrolling', async () => {
524+
await contentUpdate();
525+
// Scroll manually to the end
526+
while (Math.ceil(scrollTarget.scrollTop) < scrollTarget.scrollHeight - scrollTarget.clientHeight) {
527+
scrollTarget.scrollTop += 100;
528+
await oneEvent(scrollTarget, 'scroll');
529+
}
530+
531+
// Ensure that the first two visible items do not overlap
532+
const firstVisibleItem = scrollTarget.querySelector(`#item-${virtualizer.firstVisibleIndex}`);
533+
const secondVisibleItem = scrollTarget.querySelector(`#item-${virtualizer.firstVisibleIndex + 1}`);
534+
535+
expect(firstVisibleItem.getBoundingClientRect().bottom).to.be.at.most(
536+
secondVisibleItem.getBoundingClientRect().top,
537+
);
538+
});
539+
});
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)