From 9da1d643f7b6fccd083f07656dbed6a4b8511be2 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher Date: Tue, 21 Apr 2026 21:37:47 +0200 Subject: [PATCH] perf(search): use CachedImage for result thumbs to stop re-render download storms (#245) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar LiveSearch and mobile search overlay rendered cover thumbs via raw 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 tags. Confirmed locally: typing in the search field is noticeably smoother. Co-authored-by: Psychotoxical Co-authored-by: Claude Opus 4.7 (1M context) --- src/components/LiveSearch.tsx | 10 ++++++++-- src/components/MobileSearchOverlay.tsx | 17 ++++++++++++++--- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/components/LiveSearch.tsx b/src/components/LiveSearch.tsx index bb47e776..69c4b1c5 100644 --- a/src/components/LiveSearch.tsx +++ b/src/components/LiveSearch.tsx @@ -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; @@ -166,7 +167,12 @@ export default function LiveSearch() { onClick={() => { navigate(`/album/${a.id}`); setOpen(false); setQuery(''); }} role="option" aria-selected={activeIndex === i}> {a.coverArt ? ( - + ) : (
)} diff --git a/src/components/MobileSearchOverlay.tsx b/src/components/MobileSearchOverlay.tsx index df35e539..908e5cee 100644 --- a/src/components/MobileSearchOverlay.tsx +++ b/src/components/MobileSearchOverlay.tsx @@ -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 => (