fix(13-02): defer virtualizer event delegation until element exists

- Event delegation handlers were attached in firstUpdated(), but the
  virtualizer is conditionally rendered (hidden when tracks empty/loading)
- On first render, tracks are [] so virtualizer doesn't exist, and
  firstUpdated() never fires again — delegation was never attached
- Move delegation to a guarded helper called from both firstUpdated()
  and updated(), so it attaches as soon as the virtualizer appears
- Fixes click, multi-select, context menu, double-click, and drag in
  both track-list and queue-panel components
This commit is contained in:
2026-03-16 11:23:35 -04:00
parent 70e3814985
commit f05d2bb603
2 changed files with 61 additions and 19 deletions
@@ -78,6 +78,9 @@ export class QueuePanel
private dragImageEl: HTMLElement | null = null;
/** Whether delegated event handlers have been attached to the virtualizer. */
private delegationAttached = false;
@query('#add-to-playlist-popup')
private addToPlaylistPopup!: WaPopup;
@@ -485,6 +488,23 @@ export class QueuePanel
`];
override firstUpdated() {
this.attachVirtualizerHooks();
}
/**
* Attach scroll-error monkey-patch, mousedown listener,
* and delegated event handlers to the virtualizer.
* The virtualizer may not exist on first render (queue
* empty), so this is called from both firstUpdated()
* and updated() — guarded by a flag.
*/
private attachVirtualizerHooks() {
if (this.delegationAttached) return;
const virtEl = this.virtualizer;
if (!virtEl) return;
// 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
@@ -497,7 +517,7 @@ export class QueuePanel
// calls scrollTo() internally. This monkey-patch is still needed
// to suppress that internal correction during scrollbar drag.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const virt = (this.virtualizer as any)?.[virtualizerRef];
const virt = (virtEl as any)?.[virtualizerRef];
if (virt) {
const origCorrect = virt._correctScrollError.bind(virt);
virt._correctScrollError = () => {
@@ -511,21 +531,19 @@ export class QueuePanel
origCorrect();
};
}
this.virtualizer?.addEventListener(
virtEl.addEventListener(
'mousedown',
this.onVirtualizerMouseDown,
);
// Event delegation: attach stable handlers to the virtualizer
// so renderTrackItem creates zero per-item closures.
const virtEl = this.virtualizer;
if (virtEl) {
virtEl.addEventListener('click', this.onDelegatedClick);
virtEl.addEventListener('dblclick', this.onDelegatedDblClick);
virtEl.addEventListener('contextmenu', this.onDelegatedContextMenu);
virtEl.addEventListener('dragstart', this.onDelegatedDragStart);
virtEl.addEventListener('dragend', this.onTrackDragEnd);
}
virtEl.addEventListener('click', this.onDelegatedClick);
virtEl.addEventListener('dblclick', this.onDelegatedDblClick);
virtEl.addEventListener('contextmenu', this.onDelegatedContextMenu);
virtEl.addEventListener('dragstart', this.onDelegatedDragStart);
virtEl.addEventListener('dragend', this.onTrackDragEnd);
this.delegationAttached = true;
}
override connectedCallback() {
@@ -608,9 +626,14 @@ export class QueuePanel
virtEl.removeEventListener('dragstart', this.onDelegatedDragStart);
virtEl.removeEventListener('dragend', this.onTrackDragEnd);
}
this.delegationAttached = false;
}
override updated() {
// The virtualizer may not exist on first render
// (queue empty). Retry hooks here when it appears.
this.attachVirtualizerHooks();
const currentIndex = this.queue.currentIndex;
// Force virtualizer to re-render visible items when the
@@ -204,6 +204,9 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
@query('#sort-dropdown')
private sortDropdownPopup!: WaPopup;
/** Whether delegated event handlers have been attached to the virtualizer. */
private delegationAttached = false;
private resizingColumn: number | null = null;
private resizeStartX = 0;
private resizeStartWidths: number[] = [];
@@ -1084,6 +1087,7 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
virt.removeEventListener('dragstart', this.onDelegatedDragStart);
virt.removeEventListener('dragend', this.onTrackDragEnd);
}
this.delegationAttached = false;
this.virtualizer?.removeEventListener(
'visibilityChanged',
@@ -1121,20 +1125,35 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
override firstUpdated() {
this.initColumnWidths();
this.attachDelegation();
}
/**
* Attach delegated event handlers to the virtualizer.
* The virtualizer may not exist on first render (tracks
* still loading), so this is called from both
* firstUpdated() and updated() — guarded by a flag.
*/
private attachDelegation() {
if (this.delegationAttached) return;
// Event delegation: attach stable handlers to the virtualizer
// so renderTrackRow creates zero per-item closures.
const virt = this.virtualizer;
if (virt) {
virt.addEventListener('click', this.onDelegatedClick);
virt.addEventListener('dblclick', this.onDelegatedDblClick);
virt.addEventListener('contextmenu', this.onDelegatedContextMenu);
virt.addEventListener('dragstart', this.onDelegatedDragStart);
virt.addEventListener('dragend', this.onTrackDragEnd);
}
if (!virt) return;
virt.addEventListener('click', this.onDelegatedClick);
virt.addEventListener('dblclick', this.onDelegatedDblClick);
virt.addEventListener('contextmenu', this.onDelegatedContextMenu);
virt.addEventListener('dragstart', this.onDelegatedDragStart);
virt.addEventListener('dragend', this.onTrackDragEnd);
this.delegationAttached = true;
}
override updated(changed: Map<string, unknown>) {
// The virtualizer may not exist on first render
// (tracks still loading). Retry delegation here.
this.attachDelegation();
// Recompute widths when the column config changes.
const colKey = this.trackListCtrl.columnIds.join(
',',