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; private dragImageEl: HTMLElement | null = null;
/** Whether delegated event handlers have been attached to the virtualizer. */
private delegationAttached = false;
@query('#add-to-playlist-popup') @query('#add-to-playlist-popup')
private addToPlaylistPopup!: WaPopup; private addToPlaylistPopup!: WaPopup;
@@ -485,6 +488,23 @@ export class QueuePanel
`]; `];
override firstUpdated() { 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 // Monkey-patch lit-virtualizer's scroll error correction to suppress it
// during native scrollbar drag. Without this, the virtualizer calls // during native scrollbar drag. Without this, the virtualizer calls
// scrollTo() to "correct" sub-pixel estimation errors, which fights the // 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 // calls scrollTo() internally. This monkey-patch is still needed
// to suppress that internal correction during scrollbar drag. // to suppress that internal correction during scrollbar drag.
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const virt = (this.virtualizer as any)?.[virtualizerRef]; const virt = (virtEl as any)?.[virtualizerRef];
if (virt) { if (virt) {
const origCorrect = virt._correctScrollError.bind(virt); const origCorrect = virt._correctScrollError.bind(virt);
virt._correctScrollError = () => { virt._correctScrollError = () => {
@@ -511,21 +531,19 @@ export class QueuePanel
origCorrect(); origCorrect();
}; };
} }
this.virtualizer?.addEventListener( virtEl.addEventListener(
'mousedown', 'mousedown',
this.onVirtualizerMouseDown, this.onVirtualizerMouseDown,
); );
// Event delegation: attach stable handlers to the virtualizer // Event delegation: attach stable handlers to the virtualizer
// so renderTrackItem creates zero per-item closures. // so renderTrackItem creates zero per-item closures.
const virtEl = this.virtualizer; virtEl.addEventListener('click', this.onDelegatedClick);
if (virtEl) { virtEl.addEventListener('dblclick', this.onDelegatedDblClick);
virtEl.addEventListener('click', this.onDelegatedClick); virtEl.addEventListener('contextmenu', this.onDelegatedContextMenu);
virtEl.addEventListener('dblclick', this.onDelegatedDblClick); virtEl.addEventListener('dragstart', this.onDelegatedDragStart);
virtEl.addEventListener('contextmenu', this.onDelegatedContextMenu); virtEl.addEventListener('dragend', this.onTrackDragEnd);
virtEl.addEventListener('dragstart', this.onDelegatedDragStart); this.delegationAttached = true;
virtEl.addEventListener('dragend', this.onTrackDragEnd);
}
} }
override connectedCallback() { override connectedCallback() {
@@ -608,9 +626,14 @@ export class QueuePanel
virtEl.removeEventListener('dragstart', this.onDelegatedDragStart); virtEl.removeEventListener('dragstart', this.onDelegatedDragStart);
virtEl.removeEventListener('dragend', this.onTrackDragEnd); virtEl.removeEventListener('dragend', this.onTrackDragEnd);
} }
this.delegationAttached = false;
} }
override updated() { 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; const currentIndex = this.queue.currentIndex;
// Force virtualizer to re-render visible items when the // Force virtualizer to re-render visible items when the
@@ -204,6 +204,9 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
@query('#sort-dropdown') @query('#sort-dropdown')
private sortDropdownPopup!: WaPopup; private sortDropdownPopup!: WaPopup;
/** Whether delegated event handlers have been attached to the virtualizer. */
private delegationAttached = false;
private resizingColumn: number | null = null; private resizingColumn: number | null = null;
private resizeStartX = 0; private resizeStartX = 0;
private resizeStartWidths: number[] = []; private resizeStartWidths: number[] = [];
@@ -1084,6 +1087,7 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
virt.removeEventListener('dragstart', this.onDelegatedDragStart); virt.removeEventListener('dragstart', this.onDelegatedDragStart);
virt.removeEventListener('dragend', this.onTrackDragEnd); virt.removeEventListener('dragend', this.onTrackDragEnd);
} }
this.delegationAttached = false;
this.virtualizer?.removeEventListener( this.virtualizer?.removeEventListener(
'visibilityChanged', 'visibilityChanged',
@@ -1121,20 +1125,35 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
override firstUpdated() { override firstUpdated() {
this.initColumnWidths(); 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; const virt = this.virtualizer;
if (virt) {
virt.addEventListener('click', this.onDelegatedClick); if (!virt) return;
virt.addEventListener('dblclick', this.onDelegatedDblClick);
virt.addEventListener('contextmenu', this.onDelegatedContextMenu); virt.addEventListener('click', this.onDelegatedClick);
virt.addEventListener('dragstart', this.onDelegatedDragStart); virt.addEventListener('dblclick', this.onDelegatedDblClick);
virt.addEventListener('dragend', this.onTrackDragEnd); virt.addEventListener('contextmenu', this.onDelegatedContextMenu);
} virt.addEventListener('dragstart', this.onDelegatedDragStart);
virt.addEventListener('dragend', this.onTrackDragEnd);
this.delegationAttached = true;
} }
override updated(changed: Map<string, unknown>) { 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. // Recompute widths when the column config changes.
const colKey = this.trackListCtrl.columnIds.join( const colKey = this.trackListCtrl.columnIds.join(
',', ',',