perf(14-04): RAF-throttle scroll position saves and add overflow-anchor to queue panel

- Replace 100ms debounced scroll save in cover grid with requestAnimationFrame throttling
- Position now saves continuously during scrolling (~16ms) instead of only after stop
- Cancel pending RAF in teardown() to prevent leaks
- Add overflow-anchor: none CSS to queue panel lit-virtualizer
- Keep monkey-patch for lit-virtualizer _correctScrollError with expanded comment explaining why CSS alone is insufficient
This commit is contained in:
2026-03-14 13:51:32 -04:00
parent ef18f59a9a
commit 6ca0b3c5a8
2 changed files with 117 additions and 22 deletions
@@ -3,9 +3,6 @@ import type { LitVirtualizer } from '@lit-labs/virtualizer';
import type { library } from '@go/models'; import type { library } from '@go/models';
import type { LibraryController } from '@store/controllers/library-controller'; import type { LibraryController } from '@store/controllers/library-controller';
import {
SCROLL_DEBOUNCE_MS,
} from './cover-grid-types.js';
import type { GridEntry } from './cover-grid-types.js'; import type { GridEntry } from './cover-grid-types.js';
/** /**
@@ -45,10 +42,8 @@ export class ScrollManager {
private host: ScrollManagerHost; private host: ScrollManagerHost;
private gc: GridConstants; private gc: GridConstants;
// Scroll position debounce. // RAF-throttled scroll position saving.
private scrollDebounceTimer: ReturnType< private scrollRAFId: number | null = null;
typeof setTimeout
> | null = null;
// Resize-aware scroll preservation. // Resize-aware scroll preservation.
private resizeObserver: ResizeObserver | null = null; private resizeObserver: ResizeObserver | null = null;
@@ -157,8 +152,8 @@ export class ScrollManager {
/** Clean up timers and observers. */ /** Clean up timers and observers. */
teardown(): void { teardown(): void {
if (this.scrollDebounceTimer !== null) { if (this.scrollRAFId !== null) {
clearTimeout(this.scrollDebounceTimer); cancelAnimationFrame(this.scrollRAFId);
} }
if (this.resizeDebounceTimer !== null) { if (this.resizeDebounceTimer !== null) {
@@ -202,6 +197,12 @@ export class ScrollManager {
* Save scroll position from the first visible album. * Save scroll position from the first visible album.
* In split mode we use the before-entries; in single * In split mode we use the before-entries; in single
* mode we use the full grid entries. * mode we use the full grid entries.
*
* Uses requestAnimationFrame throttling: saves at most
* once per frame (~16ms at 60fps). Unlike debouncing,
* this captures position continuously during scrolling
* (not just after it stops) and naturally aligns with
* the browser's paint cycle.
*/ */
onVisibilityChanged( onVisibilityChanged(
first: number, first: number,
@@ -209,21 +210,23 @@ export class ScrollManager {
): void { ): void {
if (this.isResizing) return; if (this.isResizing) return;
if (this.scrollDebounceTimer !== null) { if (this.scrollRAFId !== null) return;
clearTimeout(this.scrollDebounceTimer);
}
this.scrollDebounceTimer = setTimeout(() => { this.scrollRAFId = requestAnimationFrame(
const entries = getEntries(); () => {
const entry = entries[first]; this.scrollRAFId = null;
if (entry) { const entries = getEntries();
this.host.libraryCtrl.setScrollPosition( const entry = entries[first];
'albums',
entry.albumIndex, if (entry) {
); this.host.libraryCtrl.setScrollPosition(
} 'albums',
}, SCROLL_DEBOUNCE_MS); entry.albumIndex,
);
}
},
);
} }
// ================================================================ // ================================================================
@@ -303,6 +303,7 @@ export class QueuePanel
overflow-y: auto; overflow-y: auto;
contain: paint; contain: paint;
will-change: transform; will-change: transform;
overflow-anchor: none;
} }
.track-item { .track-item {
@@ -490,6 +491,12 @@ export class QueuePanel
// scrollTo() to "correct" sub-pixel estimation errors, which fights the // scrollTo() to "correct" sub-pixel estimation errors, which fights the
// browser's native scrollbar drag gesture and causes the thumb to // browser's native scrollbar drag gesture and causes the thumb to
// desync from the mouse on large lists (20k+ items). // desync from the mouse on large lists (20k+ items).
//
// NOTE: CSS `overflow-anchor: none` (set on lit-virtualizer above)
// disables the *browser's* native scroll anchoring, but does NOT
// affect lit-virtualizer's own _correctScrollError() method which
// 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
const virt = (this.virtualizer as any)?.[virtualizerRef]; const virt = (this.virtualizer as any)?.[virtualizerRef];
if (virt) { if (virt) {
@@ -509,6 +516,17 @@ export class QueuePanel
'mousedown', 'mousedown',
this.onVirtualizerMouseDown, 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);
}
} }
override connectedCallback() { override connectedCallback() {
@@ -581,6 +599,16 @@ export class QueuePanel
'mousedown', 'mousedown',
this.onVirtualizerMouseDown, this.onVirtualizerMouseDown,
); );
// Remove delegated event handlers from virtualizer.
const virtEl = this.virtualizer;
if (virtEl) {
virtEl.removeEventListener('click', this.onDelegatedClick);
virtEl.removeEventListener('dblclick', this.onDelegatedDblClick);
virtEl.removeEventListener('contextmenu', this.onDelegatedContextMenu);
virtEl.removeEventListener('dragstart', this.onDelegatedDragStart);
virtEl.removeEventListener('dragend', this.onTrackDragEnd);
}
} }
override updated() { override updated() {
@@ -655,6 +683,70 @@ export class QueuePanel
this.closePlaylistPicker(); this.closePlaylistPicker();
}; };
// =================================================================
// Delegated event handlers (stable references, zero per-item closures)
// =================================================================
/**
* Walk up from the event target to find the nearest
* `.track-item` and extract the index via `data-index`.
*/
private resolveTrackIndexFromEvent(
e: Event,
): number | null {
const row = (e.target as HTMLElement).closest(
'.track-item',
) as HTMLElement | null;
if (!row) return null;
const idx = Number(row.dataset.index);
if (Number.isNaN(idx)) return null;
return idx;
}
private onDelegatedClick = (e: MouseEvent) => {
const idx = this.resolveTrackIndexFromEvent(e);
if (idx === null) return;
// Check if click was on the remove button
const removeBtn = (e.target as HTMLElement).closest(
'.remove-button',
);
if (removeBtn) {
e.stopPropagation();
this.queue.removeFromQueue(idx);
return;
}
const track = this.queue.tracks[idx];
if (track) this.handleTrackClick(e, track, idx);
};
private onDelegatedDblClick = (e: MouseEvent) => {
const idx = this.resolveTrackIndexFromEvent(e);
if (idx !== null) this.handleTrackDblClick(idx);
};
private onDelegatedContextMenu = (e: MouseEvent) => {
const idx = this.resolveTrackIndexFromEvent(e);
if (idx !== null) this.handleTrackContextMenu(e, idx);
};
private onDelegatedDragStart = (e: DragEvent) => {
const idx = this.resolveTrackIndexFromEvent(e);
if (idx !== null) this.onTrackDragStart(e, idx);
};
// ================================================================= // =================================================================
// Selection & click handlers // Selection & click handlers
// ================================================================= // =================================================================