diff --git a/.changeset/lit-virtual-reconnect.md b/.changeset/lit-virtual-reconnect.md new file mode 100644 index 000000000..403fab8e3 --- /dev/null +++ b/.changeset/lit-virtual-reconnect.md @@ -0,0 +1,5 @@ +--- +'@tanstack/lit-virtual': patch +--- + +Reattach scroll observers when a Lit virtualizer host is connected again, so scrolling keeps updating the rendered range after the element is moved. diff --git a/packages/lit-virtual/src/index.ts b/packages/lit-virtual/src/index.ts index 569f93ab6..0b619c430 100644 --- a/packages/lit-virtual/src/index.ts +++ b/packages/lit-virtual/src/index.ts @@ -60,6 +60,19 @@ class VirtualizerControllerBase< hostConnected() { this.cleanup = this.virtualizer._didMount() + // `hostDisconnected` runs the cleanup returned by `_didMount`, which + // drops `scrollElement` and its observers. Lit calls `hostUpdated` only + // when an update runs, and reconnecting a host with no pending update + // does not schedule one, so scrolling would stop changing the rendered + // range until a reactive property changed. + // + // Scroll elements that are already readable (the window, or a + // `getScrollElement` that does not use Lit's `ref`) can be observed + // immediately. `ref` clears its value on disconnect and restores it only + // after this callback returns, so also request an update: the following + // `hostUpdated` runs `_willUpdate` once that ref is populated again. + this.virtualizer._willUpdate() + this.host.requestUpdate() } hostUpdated() { diff --git a/packages/lit-virtual/tests/index.test.ts b/packages/lit-virtual/tests/index.test.ts index 8add1b971..26fb2cb33 100644 --- a/packages/lit-virtual/tests/index.test.ts +++ b/packages/lit-virtual/tests/index.test.ts @@ -113,3 +113,76 @@ test('should render virtual items', async () => { 'Element did not render virtual items', ) }) + +@customElement('reconnect-list') +class ReconnectList extends LitElement { + private scrollElementRef: Ref = createRef() + + private virtualizerController: VirtualizerController + + constructor() { + super() + this.virtualizerController = new VirtualizerController(this, { + getScrollElement: () => this.scrollElementRef.value, + count, + estimateSize: () => 50, + observeElementRect: (_, cb) => { + cb({ height, width }) + }, + }) + } + + render() { + const virtualizer = this.virtualizerController.getVirtualizer() + + return html` +
+ ${repeat( + virtualizer.getVirtualItems(), + (virtualRow) => virtualRow.key, + (virtualRow) => + html`
+ Row ${virtualRow.index} +
`, + )} +
+ ` + } +} + +test('keeps updating after the host is removed and reinserted', async () => { + const el = await fixture(html``) + await elementUpdated(el) + await waitUntil( + () => el.shadowRoot.querySelector('[data-index="0"]'), + 'Element did not render the initial rows', + ) + // Let the rect observer's follow-up update finish so the host has no + // pending change when it is removed. A pending update would call + // `hostUpdated` after reinsertion and hide the bug. + await (el as ReconnectList).updateComplete + await (el as ReconnectList).updateComplete + expect(el.shadowRoot.querySelector('[data-index="20"]')).toBeNull() + + const parent = el.parentNode + if (parent == null) { + throw new Error('host has no parent') + } + const next = el.nextSibling + el.remove() + parent.insertBefore(el, next) + await (el as ReconnectList).updateComplete + + const scroller = el.shadowRoot.querySelector('.scroll-container') + if (!(scroller instanceof HTMLElement)) { + throw new Error('scroll container was not rendered') + } + scroller.scrollTop = 1000 + scroller.dispatchEvent(new Event('scroll')) + + await waitUntil( + () => el.shadowRoot.querySelector('[data-index="20"]'), + 'Virtualizer did not update after the host was reattached', + ) + expect(el.shadowRoot.querySelector('[data-index="0"]')).toBeNull() +})