perf(frontend): patch the stores instead of invalidating them

An event carries what a consumer needs so it never has to invalidate.

- `library-store` answers `TrackPlayCountChanged` by patching one
  track, replacing the tracks array (consumers key memoized caches on
  its identity) while sharing every unchanged Track — instead of
  discarding four collections and refetching 25 MB per song.
- `playlist-store` answers `PlaylistTracksChanged` by refetching the
  one playlist the event names, plus the summaries, since `UpdatedAt`
  is a sort key. 2 668 kB and 172 ms for one heart, against 2.0 kB. It
  falls back to a full invalidate only where a patch cannot be shown
  to be equivalent: no id, a cold cache, an unknown id, or a fetch
  already in flight. And a store with no subscriber fetches nothing —
  the singleton's constructor used to put every track of every
  playlist on the path to first paint for a view the user might never
  open.
- `library-store` guards every fetch with a cache generation and holds
  the request itself instead of deriving a promise from subscriber
  notifications, which fixes the library-filter race and the
  never-settling waiter together: they are the same bug seen from
  either end.
- `explore-cache`'s two art caches are bounded, sharing one exported
  cap constant — the artist photo's data URL is held by both, so
  capping either alone frees nothing at all and reads as a fix that
  did not work.
- `search-store` deliberately does *not* coalesce its notify: deferring
  makes a subscriber that unsubscribes synchronously after a `setTerm`
  miss the notification entirely, which is a semantic change rather
  than an optimisation, and this is the store on the keystroke path.
- `selection-controller` retains its keys across a refetch rather than
  clearing them, since they are file paths and those survive one, and
  `getSelectedKeysOrdered()` gains an early exit. It stays a walk of
  the list: an index goes stale on any re-sort, re-filter or refetch
  while a file path survives all three, and 3 ms does not buy a
  silently mis-ordered queue insert.
This commit is contained in:
2026-08-12 01:19:04 -04:00
parent 795f40acee
commit 7d9e0bf2fb
10 changed files with 677 additions and 188 deletions
+51 -27
View File
@@ -10,9 +10,27 @@
* album detail page → check cache before API calls
*/
import type { explore } from '@go/models';
type MBReleaseGroup = explore.MBReleaseGroup;
type LBTopRecording = explore.LBTopRecording;
import { registerCacheProbe } from '../utils/cache-stats';
import { LRUMap } from '../utils/lru-map';
/**
* Cap for artist entries (`perf.M8`).
*
* An entry's `imageURL` is the artist photo's base64 data URL — ~128 kB
* measured — and it is the *same string* `explore-view`'s own
* `artistImageCache` holds. Two unbounded maps referencing one string
* means bounding either alone frees nothing, so this constant is
* exported and both use it. Changing it here changes both.
*/
export const ARTIST_IMAGE_CACHE_LIMIT = 32;
/**
* Cap for album entries. These are small — measured at 61 chars each,
* since `coverArt` is a local `/coverart/…` path rather than a data URL
* — so the cap is generous and exists to make the map bounded at all
* rather than because it costs anything today.
*/
const ALBUM_CACHE_LIMIT = 512;
/** Cached artist data from search results. */
export interface CachedArtist {
@@ -33,10 +51,8 @@ export interface CachedAlbum {
}
class ExploreCacheStore {
private artists = new Map<string, CachedArtist>();
private albums = new Map<string, CachedAlbum>();
private artistAlbums = new Map<string, MBReleaseGroup[]>();
private artistTopTracks = new Map<string, LBTopRecording[]>();
private artists = new LRUMap<string, CachedArtist>(ARTIST_IMAGE_CACHE_LIMIT);
private albums = new LRUMap<string, CachedAlbum>(ALBUM_CACHE_LIMIT);
// -- Artists --
@@ -58,26 +74,6 @@ class ExploreCacheStore {
return this.albums.get(mbid);
}
// -- Artist → Albums (release groups) --
setArtistAlbums(artistMBID: string, albums: MBReleaseGroup[]) {
if (artistMBID) this.artistAlbums.set(artistMBID, albums);
}
getArtistAlbums(artistMBID: string): MBReleaseGroup[] | undefined {
return this.artistAlbums.get(artistMBID);
}
// -- Artist → Top tracks --
setArtistTopTracks(artistMBID: string, tracks: LBTopRecording[]) {
if (artistMBID) this.artistTopTracks.set(artistMBID, tracks);
}
getArtistTopTracks(artistMBID: string): LBTopRecording[] | undefined {
return this.artistTopTracks.get(artistMBID);
}
// -- Bulk populate from search results --
populateFromSearch(artists: any[], releaseGroups: any[]) {
@@ -104,6 +100,34 @@ class ExploreCacheStore {
}
}
}
/** Entries and retained string length, for `window.__yjCacheStats()`. */
stats() {
const size = (o: object) => {
let n = 0;
for (const v of Object.values(o)) {
if (typeof v === 'string') n += v.length;
}
return n;
};
const of = (m: LRUMap<string, object>) => {
let chars = 0;
for (const v of m.values()) chars += size(v);
return { entries: m.size, chars, limit: m.limit };
};
return {
artists: of(this.artists as LRUMap<string, object>),
albums: of(this.albums as LRUMap<string, object>),
};
}
}
export const exploreCache = new ExploreCacheStore();
registerCacheProbe('exploreCache.artists', () => exploreCache.stats().artists);
registerCacheProbe('exploreCache.albums', () => exploreCache.stats().albums);