perf(search): use CachedImage for result thumbs to stop re-render download storms (#245)

The sidebar LiveSearch and mobile search overlay rendered cover thumbs via
raw <img src={buildCoverArtUrl(...)}> instead of going through the
IndexedDB-backed CachedImage path used everywhere else in the app.

buildCoverArtUrl() mints a fresh URL on every call (random salt + a new
MD5 token per Subsonic spec), so the browser cache was permanently
defeated for these thumbs — every re-render of the dropdown produced
new URLs and the browser dispatched HTTP requests for every "new"
image.

While typing in the search field this multiplied: each keystroke
triggered a re-render with fresh URLs for every visible cover, and
after the 300 ms debounce a fresh search() result triggered another
wave. Five visible album thumbs × five keystrokes ≈ 50 redundant cover
fetches in 1.5 s, manifesting as periodic typing delays.

Switch all three call sites to CachedImage with the stable
coverArtCacheKey() — same pattern CLAUDE.md explicitly recommends:

    buildCoverArtUrl() generates a new ephemeral URL every call —
    browser cache is useless. Use coverArtCacheKey(id, size) +
    CachedImage for <img> tags.

Confirmed locally: typing in the search field is noticeably smoother.

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Frank Stellmacher
2026-04-21 21:37:47 +02:00
committed by GitHub
parent 06140a490b
commit 9da1d643f7
2 changed files with 22 additions and 5 deletions
+8 -2
View File
@@ -1,10 +1,11 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Search, Disc3, Users, Music, SlidersVertical, TextSearch } from 'lucide-react';
import { search, SearchResults, buildCoverArtUrl } from '../api/subsonic';
import { search, SearchResults, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { useTranslation } from 'react-i18next';
import CachedImage from './CachedImage';
function debounce(fn: (q: string) => void, ms: number): (q: string) => void {
let timer: ReturnType<typeof setTimeout>;
@@ -166,7 +167,12 @@ export default function LiveSearch() {
onClick={() => { navigate(`/album/${a.id}`); setOpen(false); setQuery(''); }}
role="option" aria-selected={activeIndex === i}>
{a.coverArt ? (
<img className="search-result-thumb" src={buildCoverArtUrl(a.coverArt, 40)} alt="" loading="lazy" />
<CachedImage
className="search-result-thumb"
src={buildCoverArtUrl(a.coverArt, 40)}
cacheKey={coverArtCacheKey(a.coverArt, 40)}
alt=""
/>
) : (
<div className="search-result-icon"><Disc3 size={14} /></div>
)}