perf(library): load a collection when a view needs it, not at startup

All four collections were fetched at DOMContentLoaded and refetched on
every invalidation, whichever view was showing. On a 26 138-track
library the track list was 20.5 MB of that, and encoding it cost the
backend ~170 MB of transient allocation — paid by someone looking at
Home, which draws none of it.

- The store warms only albums, artists and genres, on idle after first
  paint: 1.6 MB together, and what made those views instant.
- A nav item prefetches on hover and on keyboard focus, which is the
  ~100 ms before the click that a cold open would otherwise wait.
- An invalidation refetches what something had loaded, and nothing
  else — a scan no longer loads the track list of a library whose
  Tracks view nobody has opened.
- `index.html`'s first-paint `<track-list>` is `view-hidden`, and
  `index.ts` no longer activates it: it is markup, not a decision about
  which view the launch lands on, and activating it was what fetched
  the whole list for a landing on Home. The track list itself loads on
  view activation, which the shell drives.

Measured on 50 000 tracks: backend RSS at rest 543 → 296 MB, peak 571 →
296 MB, Go heap held 361 → 125 MB, JS heap 31.8 → 18 MB, binding bytes
at rest 35.9 → 12.1 MB, heap after a browse 36.5 → 22.7 MB. Tracks
first open 26 ms; slowest view open 57 ms.

Closes #280
This commit is contained in:
yonlu committed 2026-10-06 01:43:00 -04:00
1 parent 3d9828b847
commit 620151aa41
11 files changed
+538 -73

No files matched your search

@@ -1553,9 +1553,13 @@ export class QueuePanel
indices: number[],
) {
const queueTracks = this.queue.tracks;
const filePaths = indices
.map((i) => queueTracks[i]?.filePath)
.filter((fp): fp is string => fp != null);
const filePaths: string[] = [];
for (const i of indices) {
const path = queueTracks[i]?.filePath;
if (path != null) filePaths.push(path);
}
await showBatchTrackDetailsForPaths(
() => this.trackDetailsDialog,
@@ -6,6 +6,7 @@ import { designTokens } from '../../styles/tokens.css';
import type { DragActiveDetail } from '@utils/drag-controller';
import { ActiveViewController } from '@store/controllers/active-view-controller';
import { ViewVisibilityController } from '@store/controllers/view-visibility-controller';
import { libraryStore } from '@store/library-store';
import { VIEW_META } from '../../services/view-meta';
import type { View } from '../../services/view-meta';
@@ -352,6 +353,10 @@ export class AppSidebar extends LitElement {
: 'false'}
@click=${() =>
this.navigate(item.id)}
@mouseenter=${() =>
this.prefetch(item.id)}
@focus=${() =>
this.prefetch(item.id)}
@dragover=${(e: DragEvent) =>
this.onNavDragOver(
e,
@@ -503,6 +508,19 @@ export class AppSidebar extends LitElement {
}
}
/**
* Start loading what this view draws, on hover or keyboard focus.
*
* The ~100 ms before the click is the whole point: #280 stopped
* fetching every collection at startup, and this is what keeps the
* view that *is* opened from paying the whole payload after the
* click. Nothing is awaited and nothing is reported here — the view
* itself reports a failure, and this is the same request.
*/
private prefetch(view: View) {
libraryStore.prefetch(view);
}
private navigate(view: View) {
// No optimistic highlight: the shell answers, and it answers
// synchronously in `handleNavigate` before it awaits anything.
@@ -1336,11 +1336,6 @@ export class TrackList
super.connectedCallback();
this.restoreSortPreferences();
if (this.externalTracks) {
this.tracks = this.externalTracks;
} else {
this.loadTracks();
}
this.resizeObserver = new ResizeObserver(
() => {
this.onHostResize();
@@ -1390,10 +1385,33 @@ export class TrackList
this.resizeObserver = null;
}
/**
* Fetch the list when this becomes the view on screen (#280).
*
* Not on connection: `index.html` renders a `<track-list>` as the
* main panel's first-paint content, so a connection-time fetch was
* the whole library loaded at launch for a landing on Home — 12 MB
* at 50 000 tracks, and the backend's peak RSS with it. The shell
* activates this element only when a navigation lands on Tracks.
*
* Called on *every* activation, not just the first: the store may
* have refetched while this view was off screen, and `getTracks()`
* answers from its cache when nothing changed.
*/
protected override onViewActivate(): void {
if (this.externalTracks) {
this.tracks = this.externalTracks;
} else {
void this.loadTracks();
}
this.attachListListeners();
}
/** Document-level listeners belong to the *visible* list. A cached
* list is never disconnected, so this is the only place they can be
* taken down again. */
protected override onViewActivate(): void {
private attachListListeners(): void {
this.listenWhileActive(document, 'click', this.clearSelectionHandler);
this.listenWhileActive(
document,
@@ -1560,9 +1578,10 @@ export class TrackList
this.selection.clear();
}
// Re-fetch when the store delivers fresh
// data after eager refetch on invalidation.
if (!this.externalTracks) {
// Re-fetch when the store delivers fresh data after an
// invalidation. Off screen the list does nothing with it, and
// the next activation re-reads the store (#280).
if (!this.externalTracks && this.viewActive) {
const cached =
this.libraryCtrl.cachedTracks;