diff --git a/packages/component-base/src/virtualizer-iron-list-adapter.js b/packages/component-base/src/virtualizer-iron-list-adapter.js
index cb87dd800c5..78f0c27e152 100644
--- a/packages/component-base/src/virtualizer-iron-list-adapter.js
+++ b/packages/component-base/src/virtualizer-iron-list-adapter.js
@@ -82,10 +82,24 @@ export class IronListAdapter {
return this.lastVisibleIndex + this._vidxOffset;
}
+ __hasPlaceholders() {
+ return this.__getVisibleElements().some((el) => el.__virtualizerPlaceholder);
+ }
+
scrollToIndex(index) {
if (typeof index !== 'number' || isNaN(index) || this.size === 0 || !this.scrollTarget.offsetHeight) {
return;
}
+ delete this.__pendingScrollToIndex;
+
+ if (this._physicalCount <= 3 /* iron-list-core.DEFAULT_PHYSICAL_COUNT */) {
+ // The condition here is a performance improvement to avoid an unnecessary
+ // re-render when the physical item pool is already covered.
+
+ // Finish rendering at the current scroll position before scrolling
+ this.flush();
+ }
+
index = this._clamp(index, 0, this.size - 1);
const visibleElementCount = this.__getVisibleElements().length;
@@ -113,6 +127,12 @@ export class IronListAdapter {
this._scrollTop -= this.__getIndexScrollOffset(index) || 0;
}
this._scrollHandler();
+
+ if (this.__hasPlaceholders()) {
+ // After rendering synchronously, there are still placeholders in the DOM.
+ // Try again after the next elements update.
+ this.__pendingScrollToIndex = index;
+ }
}
flush() {
@@ -199,8 +219,9 @@ export class IronListAdapter {
__updateElement(el, index, forceSameIndexUpdates) {
// Clean up temporary placeholder sizing
- if (el.style.paddingTop) {
+ if (el.__virtualizerPlaceholder) {
el.style.paddingTop = '';
+ el.__virtualizerPlaceholder = false;
}
if (!this.__preventElementUpdates && (el.__lastUpdatedIndex !== index || forceSameIndexUpdates)) {
@@ -224,6 +245,7 @@ export class IronListAdapter {
// Assign a temporary placeholder sizing to elements that would otherwise end up having
// no height.
el.style.paddingTop = `${this.__placeholderHeight}px`;
+ el.__virtualizerPlaceholder = true;
// Manually schedule the resize handler to make sure the placeholder padding is
// cleared in case the resize observer never triggers.
@@ -241,6 +263,10 @@ export class IronListAdapter {
this.__placeholderHeight = Math.round(filteredHeights.reduce((a, b) => a + b, 0) / filteredHeights.length);
}
});
+
+ if (this.__pendingScrollToIndex !== undefined && !this.__hasPlaceholders()) {
+ this.scrollToIndex(this.__pendingScrollToIndex);
+ }
}
__getIndexScrollOffset(index) {
diff --git a/packages/component-base/test/virtualizer-item-height.test.js b/packages/component-base/test/virtualizer-item-height.test.js
index c8e2b3fe7cd..7642e339c14 100644
--- a/packages/component-base/test/virtualizer-item-height.test.js
+++ b/packages/component-base/test/virtualizer-item-height.test.js
@@ -3,6 +3,11 @@ import { aTimeout, fixtureSync, nextFrame } from '@vaadin/testing-helpers';
import sinon from 'sinon';
import { Virtualizer } from '../src/virtualizer.js';
+async function contentUpdate() {
+ // Wait for the content to update (and resize observer to fire)
+ await aTimeout(200);
+}
+
describe('virtualizer - item height', () => {
let elementsContainer;
let virtualizer;
@@ -76,7 +81,7 @@ describe('virtualizer - item height', () => {
it('should clear the temporary placeholder padding from the item', async () => {
// Wait for the content to update (and resize observer to fire)
- await aTimeout(200);
+ await contentUpdate();
// Cache the height of the first item
const firstItem = elementsContainer.querySelector(`#item-0`);
@@ -241,3 +246,149 @@ describe('virtualizer - item height - initial render', () => {
});
});
});
+
+describe('virtualizer - item height - lazy rendering - scroll to index', () => {
+ let virtualizer;
+ let renderPlaceholders;
+ let scrollTarget;
+
+ beforeEach(() => {
+ scrollTarget = fixtureSync(`
+
+ `);
+ const scrollContainer = scrollTarget.firstElementChild;
+
+ renderPlaceholders = true;
+
+ virtualizer = new Virtualizer({
+ createElements: (count) => Array.from({ length: count }, () => document.createElement('div')),
+ updateElement: (el, index) => {
+ el.dataset.index = index;
+ el.style.width = '100%';
+ el.textContent = renderPlaceholders ? '' : `Item ${index}`;
+ },
+ scrollTarget,
+ scrollContainer,
+ });
+
+ virtualizer.size = 1000;
+ });
+
+ [false, true].forEach((initiallyRendered) => {
+ describe(`initially rendered: ${initiallyRendered}`, () => {
+ beforeEach(async () => {
+ if (initiallyRendered) {
+ // Setup where the virtualizer has initially rendered all the items once
+ renderPlaceholders = false;
+ virtualizer.update();
+ await contentUpdate();
+ renderPlaceholders = true;
+ }
+ });
+
+ it('should have scrolled to the correct index after placeholders are removed', async () => {
+ // Scroll to an index while the virtualizer may still be creating physical items
+ virtualizer.scrollToIndex(500);
+
+ // At this point, only placeholders are rendered.
+ // Enable actual content rendering and update.
+ renderPlaceholders = false;
+ virtualizer.update();
+ // Wait for the content to update (and resize observer to fire)
+ await contentUpdate();
+
+ // The first visible index should be correct
+ expect(virtualizer.firstVisibleIndex).to.equal(500);
+ });
+
+ it('should scroll to the lastly requested index', async () => {
+ virtualizer.scrollToIndex(500);
+ virtualizer.scrollToIndex(600);
+
+ renderPlaceholders = false;
+ virtualizer.update();
+ await contentUpdate();
+
+ expect(virtualizer.firstVisibleIndex).to.equal(600);
+ });
+
+ it('should not scroll to the old index', async () => {
+ virtualizer.scrollToIndex(500);
+
+ renderPlaceholders = false;
+ virtualizer.scrollToIndex(0);
+ await contentUpdate();
+
+ expect(virtualizer.firstVisibleIndex).to.equal(0);
+ });
+
+ it('should not scroll to pending index when there are no placeholders', async () => {
+ virtualizer.scrollToIndex(500);
+
+ renderPlaceholders = false;
+ virtualizer.scrollToIndex(0);
+
+ const scrollToIndexSpy = sinon.spy(virtualizer.__adapter, 'scrollToIndex');
+ await contentUpdate();
+
+ // Expect spy to not have been called
+ expect(scrollToIndexSpy).to.not.have.been.called;
+ });
+
+ it('should not scroll away from manually scrolled position', async () => {
+ virtualizer.scrollToIndex(500);
+ await contentUpdate();
+ renderPlaceholders = false;
+ virtualizer.update();
+ await contentUpdate();
+ scrollTarget.scrollTop += 500;
+ await contentUpdate();
+
+ expect(virtualizer.firstVisibleIndex).not.to.equal(500);
+ });
+
+ it('should not change scroll position after item height change', async () => {
+ renderPlaceholders = false;
+ virtualizer.scrollToIndex(1);
+ await contentUpdate();
+ const scrollTop = scrollTarget.scrollTop;
+
+ // Increase item height
+ fixtureSync(`
+
+ `);
+ await contentUpdate();
+
+ // Expect the scroll position to be the same as before
+ expect(scrollTarget.scrollTop).to.equal(scrollTop);
+ });
+ });
+
+ it('should scroll to end', async () => {
+ renderPlaceholders = false;
+ virtualizer.update();
+ virtualizer.scrollToIndex(Infinity);
+ await contentUpdate();
+
+ expect(virtualizer.lastVisibleIndex).to.equal(999);
+
+ const { top, bottom, left } = scrollTarget.getBoundingClientRect();
+
+ // Expect the first visible item to be at the top of the viewport
+ const topMostItem = document.elementFromPoint(left, top);
+ const firstVisibleItem = document.querySelector(`[data-index="${virtualizer.firstVisibleIndex}"]`);
+ expect(topMostItem).to.equal(firstVisibleItem);
+
+ // Expect the last visible item to be at the bottom of the viewport
+ const bottomMostItem = document.elementFromPoint(left, bottom - 1);
+ const lastVisibleItem = document.querySelector(`[data-index="${virtualizer.lastVisibleIndex}"]`);
+ expect(bottomMostItem).to.equal(lastVisibleItem);
+ });
+ });
+});