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:
1 parent
3d9828b847
commit
620151aa41
11 files changed
+538
-73
No files matched your search
@@ -28,6 +28,14 @@ const COVER_SIZE_DEFAULT = 176;
|
||||
/** localStorage key for persisted cover size. */
|
||||
const COVER_SIZE_KEY = 'cover-grid-size';
|
||||
|
||||
/**
|
||||
* How long the small-collection warm-up will wait for idle before
|
||||
* running anyway. It is speculative, but a busy main thread must not
|
||||
* mean Albums is slow to open — the timeout is the promise that it is
|
||||
* only ever deferred, never skipped.
|
||||
*/
|
||||
const WARM_IDLE_TIMEOUT_MS = 3_000;
|
||||
|
||||
class LibraryStore {
|
||||
private tracks: ListTrack[] | null = null;
|
||||
private albums: library.Album[] | null = null;
|
||||
@@ -110,34 +118,82 @@ class LibraryStore {
|
||||
});
|
||||
|
||||
this.loadCoverSize();
|
||||
this.deferEagerFetch();
|
||||
this.warmSmallCollectionsOnIdle();
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules eagerFetch() to run after the DOM is ready.
|
||||
* The LibraryStore singleton is instantiated during ES module
|
||||
* evaluation (import time), so calling eagerFetch() in the
|
||||
* constructor would fire 4 backend roundtrips before the app
|
||||
* shell has rendered. Deferring to the 'DOMContentLoaded'
|
||||
* event (or calling immediately if the DOM is already parsed)
|
||||
* lets the shell paint first, then begins data loading.
|
||||
* Warm the three small collections once the app is idle after first
|
||||
* paint, and deliberately not the tracks (#280).
|
||||
*
|
||||
* All four used to be fetched together at `DOMContentLoaded`,
|
||||
* 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
|
||||
* needs none of it. Measured on 50 000 tracks: 543 MB of backend RSS
|
||||
* at rest before, 296 MB after, and 12.1 MB of binding bytes instead
|
||||
* of 35.9.
|
||||
*
|
||||
* Albums, artists and genres are 1.6 MB together, so they are still
|
||||
* fetched ahead of the click — that is what made those views
|
||||
* instant. Tracks are 12 MB at 50 000 and are fetched by the view
|
||||
* that draws them, or by `prefetch` from a hover.
|
||||
*
|
||||
* Idle rather than immediate: this is speculative, so it must not
|
||||
* compete with the first paint.
|
||||
*/
|
||||
private deferEagerFetch(): void {
|
||||
if (document.readyState === 'loading') {
|
||||
window.addEventListener(
|
||||
'DOMContentLoaded',
|
||||
() => {
|
||||
this.eagerFetch();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
private warmSmallCollectionsOnIdle(): void {
|
||||
const warm = () => {
|
||||
const logged = this.failureReporter();
|
||||
|
||||
void this.getAlbums().catch(logged('albums'));
|
||||
void this.getArtists().catch(logged('artists'));
|
||||
void this.getGenres().catch(logged('genres'));
|
||||
};
|
||||
|
||||
if (typeof requestIdleCallback === 'function') {
|
||||
requestIdleCallback(warm, { timeout: WARM_IDLE_TIMEOUT_MS });
|
||||
} else {
|
||||
// DOM already parsed (shouldn't happen during module
|
||||
// eval, but handles dynamic instantiation safely).
|
||||
this.eagerFetch();
|
||||
setTimeout(warm, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start loading what a view will need, without waiting for it.
|
||||
*
|
||||
* A nav item calls this on hover or focus: that is the ~100 ms
|
||||
* before the click, and it is what #280 trades for not paying for
|
||||
* every collection at startup whether or not anyone goes there.
|
||||
*
|
||||
* A view this store holds nothing for is ignored rather than an
|
||||
* error — it is a hint, and a hint about Home is not a mistake.
|
||||
* The failure is swallowed here because the view that wanted the
|
||||
* data reports it: this is the same request, already deduplicated
|
||||
* by `inFlight`, and it has no caller to reject to.
|
||||
*/
|
||||
prefetch(view: string): void {
|
||||
const started = (() => {
|
||||
switch (view) {
|
||||
case 'tracks':
|
||||
return this.getTracks();
|
||||
case 'albums':
|
||||
return this.getAlbums();
|
||||
case 'artists':
|
||||
return this.getArtists();
|
||||
case 'genres':
|
||||
return this.getGenres();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
void started?.catch(() => undefined);
|
||||
}
|
||||
|
||||
private failureReporter(): (what: string) => (err: unknown) => void {
|
||||
return (what) => (err) =>
|
||||
console.error(`library: could not load ${what}`, err);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// DATA ACCESS
|
||||
// Returns cached data or fetches from backend on first access.
|
||||
@@ -595,6 +651,8 @@ class LibraryStore {
|
||||
}
|
||||
}
|
||||
|
||||
const loaded = this.loadedCollections();
|
||||
|
||||
this.albums = null;
|
||||
this.artists = null;
|
||||
this.genres = null;
|
||||
@@ -610,50 +668,63 @@ class LibraryStore {
|
||||
this.changeGen++;
|
||||
this.notify();
|
||||
|
||||
const logged = (what: string) => (err: unknown) =>
|
||||
console.error(`library: could not reload ${what}`, err);
|
||||
|
||||
void this.getAlbums().catch(logged('albums'));
|
||||
void this.getArtists().catch(logged('artists'));
|
||||
void this.getGenres().catch(logged('genres'));
|
||||
// Only the summaries something was showing (#280): a removal
|
||||
// nobody was looking at does not load a collection to correct it.
|
||||
this.refetchLoaded({
|
||||
albums: loaded.albums,
|
||||
artists: loaded.artists,
|
||||
genres: loaded.genres,
|
||||
});
|
||||
}
|
||||
|
||||
private invalidate(): void {
|
||||
// Which collections something has actually loaded, before they
|
||||
// are dropped. Refetching all four here would undo #280: a scan
|
||||
// finishing would fetch the track list of a library nobody has
|
||||
// opened the Tracks view on.
|
||||
const loaded = this.loadedCollections();
|
||||
|
||||
this.tracks = null;
|
||||
this.albums = null;
|
||||
this.artists = null;
|
||||
this.genres = null;
|
||||
// Anything still in flight was asked for on behalf of a
|
||||
// selection that no longer applies: forget it, so the eager
|
||||
// refetch below starts a request for the current one rather
|
||||
// than adopting the old one's answer.
|
||||
// selection that no longer applies: forget it, so the refetch
|
||||
// below starts a request for the current one rather than
|
||||
// adopting the old one's answer.
|
||||
this.inFlight.clear();
|
||||
this.cacheGen++;
|
||||
this.changeGen++;
|
||||
this.scrollPositions = { tracks: 0, albums: 0, artists: 0, genres: 0 };
|
||||
this.notify();
|
||||
this.eagerFetch();
|
||||
this.refetchLoaded(loaded);
|
||||
}
|
||||
|
||||
/** The collections currently held, keyed by the view that draws them. */
|
||||
private loadedCollections(): Partial<Record<ViewName, boolean>> {
|
||||
return {
|
||||
tracks: this.tracks !== null,
|
||||
albums: this.albums !== null,
|
||||
artists: this.artists !== null,
|
||||
genres: this.genres !== null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all library data. Called after DOM ready
|
||||
* (initial load, via deferEagerFetch) and after cache
|
||||
* invalidation so that controller subscribers receive
|
||||
* fresh data on the next requestUpdate() cycle without
|
||||
* needing their own LibraryScanComplete listener.
|
||||
* Refetch exactly the collections named, and nothing else.
|
||||
*
|
||||
* A failed fetch is reported by whichever view asked for the data
|
||||
* (it is that panel's failure, not the app's), but this refetch has
|
||||
* no caller to reject to — without a catch it is an unhandled
|
||||
* rejection.
|
||||
*/
|
||||
private eagerFetch(): void {
|
||||
// A failed fetch is reported by whichever view asked for the
|
||||
// data (it is that panel's failure, not the app's), but the
|
||||
// eager refetch has no caller to reject to — without a catch it
|
||||
// is an unhandled rejection.
|
||||
const logged = (what: string) => (err: unknown) =>
|
||||
console.error(`library: could not load ${what}`, err);
|
||||
private refetchLoaded(loaded: Partial<Record<ViewName, boolean>>): void {
|
||||
const logged = this.failureReporter();
|
||||
|
||||
void this.getTracks().catch(logged('tracks'));
|
||||
void this.getAlbums().catch(logged('albums'));
|
||||
void this.getArtists().catch(logged('artists'));
|
||||
void this.getGenres().catch(logged('genres'));
|
||||
if (loaded.tracks) void this.getTracks().catch(logged('tracks'));
|
||||
if (loaded.albums) void this.getAlbums().catch(logged('albums'));
|
||||
if (loaded.artists) void this.getArtists().catch(logged('artists'));
|
||||
if (loaded.genres) void this.getGenres().catch(logged('genres'));
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
|
||||
Reference in new issue
Block a user