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

+99
View File
@@ -0,0 +1,99 @@
/**
* #280: the app loads the library when a view needs it, not at startup.
*
* Every collection used to be fetched together at `DOMContentLoaded`
* and refetched on every invalidation, whether or not anything was
* showing it. On a 26 138-track library the track list was 20.5 MB of
* that and cost the backend ~170 MB of transient allocation — for
* someone looking at Home, which draws none of it.
*
* **This file must not load the track list before the first test.** It
* asserts the state the app is actually left in by its own startup, so
* a test added above that asks for tracks would invalidate the
* precondition rather than silently pass.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import { libraryStore } from '@store/library-store';
import { Events } from '../../src/events';
import {
calls,
emit,
flush,
resetHarness,
stub,
} from '@test/support/harness';
const TRACKS = 'library.Library.GetTrackTable';
const SMALL = [
'library.Library.GetAlbums',
'library.Library.GetArtists',
'library.Library.GetGenres',
];
/**
* Wait for the store's own idle warm-up to land.
*
* It asks for the three small collections on `requestIdleCallback`, so
* when it has happened is the browser's decision — waiting for the
* effect rather than for a duration is the only way this is not a race.
*/
function stubReads(): void {
stub(TRACKS, { strings: [''] });
for (const path of SMALL) stub(path, []);
}
async function waitForWarm(): Promise<void> {
for (let i = 0; i < 200 && libraryStore.getCachedAlbums() === null; i++) {
await flush();
}
expect(libraryStore.getCachedAlbums(), 'the warm-up ran').not.toBeNull();
}
describe('what the app loads on its own (#280)', () => {
beforeEach(async () => {
stubReads();
await waitForWarm();
// resetHarness clears the stubs as well as the recorded calls.
resetHarness();
stubReads();
});
it('leaves the track list alone', () => {
expect(libraryStore.getCachedTracks()).toBeNull();
expect(calls(TRACKS)).toEqual([]);
});
it('does not load it to answer a scan', async () => {
emit(Events.LibraryScanComplete);
await flush();
// The small collections are in use, so they are refreshed; the
// track list nobody has opened is not.
expect(calls().map((c) => c.path).sort()).toEqual([...SMALL].sort());
});
it('loads it for the view that draws it, and refreshes it from then on', async () => {
libraryStore.prefetch('tracks');
await flush();
expect(calls(TRACKS)).toHaveLength(1);
expect(libraryStore.getCachedTracks()).not.toBeNull();
resetHarness();
emit(Events.LibraryScanComplete);
await flush();
expect(calls(TRACKS), 'in use now, so a scan refreshes it').toHaveLength(1);
});
it('is asked for nothing by a prefetch for a view it has no data for', async () => {
libraryStore.prefetch('home');
libraryStore.prefetch('settings');
libraryStore.prefetch('explore');
await flush();
expect(calls()).toEqual([]);
});
});
+15 -6
View File
@@ -43,16 +43,25 @@ function stubReads(): void {
}
/**
* Drop the cache and let the eager refetch settle, so each test starts
* from the same place. The store has no reset of its own; a scan
* completing is how the app itself clears it.
* Start each test from a loaded store. The store has no reset of its
* own; a scan completing is how the app itself clears it — and since
* #280 it reloads only what something had loaded, so the four reads
* here are what says "this test is about a loaded store".
*/
async function reload(): Promise<void> {
stubReads();
emit(Events.LibraryScanComplete);
await flush();
// The eager refetch the invalidation kicks off is recorded like any
// other call; clear it, or every count in every test is off by one.
resetHarness();
stubReads();
await Promise.all([
libraryStore.getTracks(),
libraryStore.getAlbums(),
libraryStore.getArtists(),
libraryStore.getGenres(),
]);
// Those reads are recorded like any other call; clear them, or every
// count in every test is off by one.
resetHarness();
stubReads();
}
@@ -91,7 +100,7 @@ describe('library store: caching', () => {
]).toEqual([['/a.mp3', '/b.mp3'], ALBUMS, ARTISTS, GENRES]);
});
it('refetches everything when a scan completes', async () => {
it('refetches everything a view had loaded when a scan completes', async () => {
emit(Events.LibraryScanComplete);
await flush();