Files
yellowjacket/frontend/src/utils/lru-map.ts
T
logan 5fb9a0d246 perf(frontend): add the bound, the lookup and the lazy dialog
Four small modules the views below adopt:

- `lru-map.ts` — a Map re-inserted on read and trimmed from the front.
  `explore-view` never unmounts and its two art caches were plain
  Maps: twenty-four searches retained 20.58 MB and were still
  accelerating, a cover thumbnail being ~27 kB of base64 and an artist
  photo ~128 kB.
- `cache-stats.ts` — a bound has to stay checkable, so caches register
  and `window.__yjCacheStats()` reports entries, retained chars and cap
  in one eval, rather than the next session having to rebuild the
  twenty-four-search reproduction first.
- `track-index.ts` — a WeakMap from the tracks array's identity to a
  Map<FilePath, Track>. Five components turned selected file paths back
  into tracks with `filePaths.map(fp => tracks.find(...))`, so "Select
  all -> Edit tags" at 50 000 tracks blocked the main thread for 3.0 to
  6.3 s. 68 ms after. Keying on the array's identity is safe for the
  same reason the memoized filter caches are, and it is collected for
  free when the store drops the array.
- `lazy-track-details.ts` — one memoised dynamic import, because
  `track-details` (42 kB) was imported for side effect by all five
  components that open it and so was evaluated before first paint
  however the routes were split.
2026-08-12 01:18:48 -04:00

99 lines
2.9 KiB
TypeScript

/**
* A `Map` with a ceiling.
*
* `perf.M7`/`M8`: the Explore caches were never evicted, and Explore is
* a cached primary view that never unmounts — so a desktop player left
* open for days grew monotonically. Measured at twelve searches:
* **+8.48 MB of retained heap**, climbing 0.7 MB per search with no
* sign of levelling off, because a cover thumbnail is a ~27 kB base64
* data URL and an artist photo is a ~128 kB one.
*
* JS `Map` already iterates in insertion order, so the whole LRU is:
* re-insert on read, and drop from the front when over the cap. That
* is deliberately all this is — a dependency, or a generic cache with
* TTLs and weak refs, would be more machinery than the two call sites
* justify.
*
* One rule for callers, learned by measuring: **two caches holding the
* same string must have the same cap.** `artistImageCache` and
* `exploreCache.artists` both hold the artist photo's data URL, so
* bounding either one alone frees nothing at all — the other still
* pins every string. A bound is only a bound if it covers every
* reference.
*/
export class LRUMap<K, V> {
private map = new Map<K, V>();
constructor(readonly limit: number) {
if (limit < 1) throw new Error('LRUMap: limit must be at least 1');
}
get size(): number {
return this.map.size;
}
/** Read, and mark the entry most-recently-used. */
get(key: K): V | undefined {
if (!this.map.has(key)) return undefined;
const value = this.map.get(key) as V;
// Re-insertion moves it to the end of the iteration order, which
// is what makes the front of the map the eviction candidate.
this.map.delete(key);
this.map.set(key, value);
return value;
}
/**
* Membership, *without* marking the entry used.
*
* Both Explore caches store `''` to mean "already attempted, no art"
* — a negative marker that also prevents a duplicate in-flight
* fetch. Those probes should not keep a dead entry alive ahead of
* one that is actually being rendered.
*/
has(key: K): boolean {
return this.map.has(key);
}
set(key: K, value: V): this {
this.map.delete(key);
this.map.set(key, value);
while (this.map.size > this.limit) {
const oldest = this.map.keys().next();
if (oldest.done) break;
this.map.delete(oldest.value);
}
return this;
}
delete(key: K): boolean {
return this.map.delete(key);
}
clear(): void {
this.map.clear();
}
values(): IterableIterator<V> {
return this.map.values();
}
keys(): IterableIterator<K> {
return this.map.keys();
}
entries(): IterableIterator<[K, V]> {
return this.map.entries();
}
[Symbol.iterator](): IterableIterator<[K, V]> {
return this.map[Symbol.iterator]();
}
}