fix(queue-panel): suppress virtualizer scroll corrections during scrollbar drag

lit-virtualizer's flow layout has a scroll error correction mechanism that
calls scrollTo() to fix sub-pixel estimation errors. On large lists (20k+),
even with fixed-height items, floating-point differences from
getBoundingClientRect() (e.g. 49.000003px vs 49px) accumulate across items
and trigger corrections that fight the native scrollbar drag gesture,
causing the thumb to desync from the mouse.

Detect scrollbar drag by checking if mousedown occurs in the scrollbar
gutter (clientX > element clientWidth), then monkey-patch the virtualizer's
_correctScrollError method to discard accumulated errors during drag
instead of calling scrollTo(). Items continue to render/recycle normally
since layout updates are not suppressed -- only the scroll position
corrections are skipped.
This commit is contained in:
2026-03-07 11:14:54 -05:00
parent 288d9deae2
commit 0bd8cefa00
@@ -15,6 +15,7 @@ import '@components/playlist-picker/playlist-picker.js';
import type { PlaylistPicker } from '@components/playlist-picker/playlist-picker.js';
import '@lit-labs/virtualizer';
import type { LitVirtualizer } from '@lit-labs/virtualizer';
import { virtualizerRef } from '@lit-labs/virtualizer/virtualize.js';
import { flow } from '@lit-labs/virtualizer/layouts/flow.js';
import { classMap } from 'lit/directives/class-map.js';
import type { QueueTrack } from '@store/queue-store';
@@ -96,6 +97,23 @@ export class QueuePanel
this.selection.selectAll();
};
// Detect native scrollbar thumb drag to suppress lit-virtualizer's
// scroll error corrections that fight the browser's drag gesture.
private onVirtualizerMouseDown = (e: MouseEvent) => {
const el = this.virtualizer;
if (!el) return;
const rect = el.getBoundingClientRect();
// Click is in the scrollbar gutter if it's beyond the content area.
// clientWidth excludes scrollbar; offsetWidth includes it.
if (e.clientX > rect.left + el.clientWidth) {
this.scrollbarDragging = true;
}
};
private onScrollbarDragEnd = () => {
this.scrollbarDragging = false;
};
private closePickerHandler = (e: MouseEvent) => {
const path = e.composedPath();
const popup = this.addToPlaylistPopup;
@@ -127,6 +145,8 @@ export class QueuePanel
};
private panelWidth = DEFAULT_WIDTH;
private scrollbarDragging = false;
// _itemSize is an internal property applied via Object.assign in BaseLayout's
// config setter. Setting it to match the actual fixed .track-item height (49px)
// prevents lit-virtualizer's scroll error correction from fighting the native
@@ -461,6 +481,33 @@ export class QueuePanel
`];
override firstUpdated() {
// Monkey-patch lit-virtualizer's scroll error correction to suppress it
// during native scrollbar drag. Without this, the virtualizer calls
// scrollTo() to "correct" sub-pixel estimation errors, which fights the
// browser's native scrollbar drag gesture and causes the thumb to
// desync from the mouse on large lists (20k+ items).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const virt = (this.virtualizer as any)?.[virtualizerRef];
if (virt) {
const origCorrect = virt._correctScrollError.bind(virt);
virt._correctScrollError = () => {
if (this.scrollbarDragging) {
// Discard the error instead of applying it via scrollTo().
// This prevents stale corrections from accumulating and
// being applied in a burst when the drag ends.
virt._scrollError = null;
return;
}
origCorrect();
};
}
this.virtualizer?.addEventListener(
'mousedown',
this.onVirtualizerMouseDown,
);
}
override connectedCallback() {
super.connectedCallback();
this.style.setProperty(
@@ -491,6 +538,10 @@ export class QueuePanel
'shortcut:select-all',
this.handleSelectAll,
);
document.addEventListener(
'mouseup',
this.onScrollbarDragEnd,
);
}
override disconnectedCallback() {
@@ -519,6 +570,14 @@ export class QueuePanel
'shortcut:select-all',
this.handleSelectAll,
);
document.removeEventListener(
'mouseup',
this.onScrollbarDragEnd,
);
this.virtualizer?.removeEventListener(
'mousedown',
this.onVirtualizerMouseDown,
);
}
override updated() {