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
+14 -3
View File
@@ -2,10 +2,11 @@ import React, { useState, useEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useNavigate } from 'react-router-dom';
import { X, Search, Disc3, Users, Music, Music2, Clock, ChevronRight } 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';
const STORAGE_KEY = 'psysonic_recent_searches';
const MAX_RECENT = 6;
@@ -203,7 +204,12 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
{results!.albums.map(a => (
<button key={a.id} className="mobile-search-item" onClick={() => goTo(`/album/${a.id}`)}>
{a.coverArt ? (
<img className="mobile-search-thumb" src={buildCoverArtUrl(a.coverArt, 80)} alt="" loading="lazy" />
<CachedImage
className="mobile-search-thumb"
src={buildCoverArtUrl(a.coverArt, 80)}
cacheKey={coverArtCacheKey(a.coverArt, 80)}
alt=""
/>
) : (
<div className="mobile-search-avatar">
<Disc3 size={20} />
@@ -225,7 +231,12 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
{results!.songs.map(s => (
<button key={s.id} className="mobile-search-item" onClick={() => playSong(s)}>
{s.coverArt ? (
<img className="mobile-search-thumb" src={buildCoverArtUrl(s.coverArt, 80)} alt="" loading="lazy" />
<CachedImage
className="mobile-search-thumb"
src={buildCoverArtUrl(s.coverArt, 80)}
cacheKey={coverArtCacheKey(s.coverArt, 80)}
alt=""
/>
) : (
<div className="mobile-search-avatar">
<Music size={20} />