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:
@@ -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());
|
||||
});
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
|
||||
Reference in New Issue
Block a user