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
+150 -7
View File
@@ -1,5 +1,9 @@
import { EventsOn } from '@runtime/runtime';
import { GetAllPlaylistsWithTracks } from '@go/playlist/Service';
import {
GetAllPlaylists,
GetAllPlaylistsWithTracks,
GetPlaylistTracks,
} from '@go/playlist/Service';
import type { playlist } from '@go/models';
import { Events } from '../events';
@@ -10,6 +14,7 @@ class PlaylistStore {
private playlistsLoading = false;
private scrollPosition = 0;
private subscribers = new Set<Subscriber>();
private notifyScheduled = false;
constructor() {
EventsOn(Events.LibraryScanComplete, () => {
@@ -28,15 +33,29 @@ class PlaylistStore {
this.invalidate();
});
EventsOn(Events.PlaylistTracksChanged, () => {
this.invalidate();
});
// `PlaylistTracksChanged` carries the playlist that changed,
// and toggling one heart in the track list is by far its most
// frequent source. Answering it with a full invalidate meant
// `GetAllPlaylistsWithTracks` — every row of every playlist,
// with full track metadata — for a one-row edit: measured at
// 2.61 MB and 172 ms across ten 500-track playlists. Patch the
// one playlist instead; the id is right there.
EventsOn(
Events.PlaylistTracksChanged,
(playlistId?: number | null) => {
void this.patchPlaylist(playlistId);
},
);
EventsOn(Events.PlaylistsRestored, () => {
this.invalidate();
});
void this.getPlaylists();
// Deliberately no eager fetch here. This store is a singleton
// constructed at import time, so warming it put every track of
// every playlist on the path to first paint — for a view the
// user may never open. `getPlaylists()` fetches on first
// access, and `playlist-view` awaits it when it loads.
}
// ===================================================================
@@ -125,7 +144,115 @@ class PlaylistStore {
this.playlists = null;
this.scrollPosition = 0;
this.notify();
void this.getPlaylists();
// Only refetch eagerly if something is actually rendering
// playlists. `playlist-view` is the sole subscriber and is
// created lazily on first navigation, so before it has ever
// been opened this used to fetch every track of every playlist
// in answer to an event nobody was listening for. Once it
// exists it is a cached view and stays subscribed, so the
// refetch-then-rerender path it depends on is unchanged.
if (this.subscribers.size > 0) {
void this.getPlaylists();
}
}
// ===================================================================
// PATCHING
// Refetch one playlist rather than all of them.
// ===================================================================
/**
* Replace a single playlist's tracks in the cache.
*
* Falls back to a full invalidate whenever the patch cannot be
* shown to be equivalent: an event with no id (the bulk restore and
* reorder paths emit one), a cold cache, an id we have never seen,
* or a fetch already in flight — which would otherwise land on top
* of the patch and undo it.
*
* Summaries come along because `updated_at` moves with the edit and
* `playlist-view` sorts on it; `GetAllPlaylists` is summaries only,
* so its cost does not scale with how many tracks a playlist holds.
*/
private async patchPlaylist(
playlistId?: number | null,
): Promise<void> {
const cached = this.playlists;
if (
typeof playlistId !== 'number' ||
cached === null ||
this.playlistsLoading ||
!cached.some((p) => p.Summary?.ID === playlistId)
) {
this.invalidate();
return;
}
try {
const [summaries, tracks] = await Promise.all([
GetAllPlaylists(),
GetPlaylistTracks(playlistId),
]);
// The cache may have been replaced while those were in
// flight, in which case this patch describes a state that
// no longer exists and the newer one is already correct.
if (this.playlists !== cached) return;
const summaryById = new Map(
(summaries ?? []).map((s) => [s.ID, s]),
);
// A new array identity, because `playlist-view` keys its
// reload off it — while sharing the `Tracks` array of every
// playlist that did not change.
//
// `WithTracks` is a generated *class* (it carries
// `convertValues`), so an object spread would produce
// something that no longer is one. Clone through the
// prototype instead.
const withChanges = (
entry: playlist.WithTracks,
changes: Partial<playlist.WithTracks>,
): playlist.WithTracks =>
Object.assign(
Object.create(
Object.getPrototypeOf(entry) as object,
) as playlist.WithTracks,
entry,
changes,
);
this.playlists = cached.map((entry) => {
const summary =
summaryById.get(entry.Summary?.ID) ??
entry.Summary;
if (entry.Summary?.ID !== playlistId) {
return summary === entry.Summary
? entry
: withChanges(entry, {
Summary: summary,
});
}
return withChanges(entry, {
Summary: summary,
Tracks: tracks ?? [],
});
});
this.notify();
} catch (err) {
console.error(
'Failed to patch playlist after track change:',
err,
);
this.invalidate();
}
}
// ===================================================================
@@ -138,8 +265,24 @@ class PlaylistStore {
return () => this.subscribers.delete(callback);
}
/**
* Coalesced to a microtask, the way every other store in this app
* does it (perf.p3). A patch writes the tracks and the summaries in two statements;
* without coalescing that is two synchronous passes over every
* subscriber.
* Lit batches the resulting `requestUpdate()`s anyway, so the win is
* small; the point is that five stores doing this and two not is
* where a real double-notify hides.
*/
private notify(): void {
this.subscribers.forEach((callback) => callback());
if (this.notifyScheduled) return;
this.notifyScheduled = true;
queueMicrotask(() => {
this.notifyScheduled = false;
this.subscribers.forEach((callback) => callback());
});
}
// ===================================================================