From c40243dfea0aaa74f63372083d37458309f05e6a Mon Sep 17 00:00:00 2001 From: Frank Stellmacher Date: Tue, 21 Apr 2026 21:38:16 +0200 Subject: [PATCH] perf(genres): paginate the genre grid + memoise sort to fix 30s cold-start freeze (#251) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported: opening the Genres page after a cold start froze the UI for up to 30 seconds with a 500-genre Subsonic library. The page itself was structurally trivial (one getGenres() call, sort, render) but each card mounted a Lucide watermark SVG at size=80 — 500 complex SVGs reconciled in one batch was the actual blocker, especially when other cold-start useEffects were still draining the main thread. Two changes: - Pagination via IntersectionObserver, PAGE_SIZE 60. First paint mounts ~88% fewer cards; the observer pulls the next batch in 1500px ahead so the user never sees the end. Same pattern Albums.tsx uses. - useMemo for the sort. Without it, every unrelated re-render (theme change, sidebar toggle) re-sorted all 500 entries. Scroll-restore extended to also persist visibleCount in sessionStorage so coming back from a GenreDetail page lands the user on a grid that still contains the row they scrolled to. Confirmed locally on a 500-genre library: page now renders instantly on cold start. Co-authored-by: Psychotoxical Co-authored-by: Claude Opus 4.7 (1M context) --- src/pages/Genres.tsx | 98 +++++++++++++++++++++++++++++--------------- 1 file changed, 66 insertions(+), 32 deletions(-) diff --git a/src/pages/Genres.tsx b/src/pages/Genres.tsx index cd153c20..bb058229 100644 --- a/src/pages/Genres.tsx +++ b/src/pages/Genres.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { @@ -45,23 +45,52 @@ function genreColor(name: string): string { } const SCROLL_KEY = 'genres-scroll'; +const VISIBLE_KEY = 'genres-visible'; +const PAGE_SIZE = 60; export default function Genres() { const { t } = useTranslation(); const navigate = useNavigate(); - const [genres, setGenres] = useState([]); + const [rawGenres, setRawGenres] = useState([]); const [loading, setLoading] = useState(true); + const [visibleCount, setVisibleCount] = useState(() => { + // Restore the previous visibleCount when navigating back from a detail + // page so scroll position lines up with rendered cards. + const saved = sessionStorage.getItem(VISIBLE_KEY); + return saved ? Math.max(PAGE_SIZE, parseInt(saved, 10)) : PAGE_SIZE; + }); const containerRef = useRef(null); + const observerTarget = useRef(null); useEffect(() => { getGenres() - .then(data => { - const sorted = [...data].sort((a, b) => b.albumCount - a.albumCount); - setGenres(sorted); - }) + .then(data => setRawGenres(data)) .finally(() => setLoading(false)); }, []); // getGenres is not folder-scoped — no dep on musicLibraryFilterVersion + // Memoised sort — without this the page re-sorted 500+ entries on every + // unrelated re-render (e.g. theme change, sidebar toggle). + const genres = useMemo( + () => [...rawGenres].sort((a, b) => b.albumCount - a.albumCount), + [rawGenres], + ); + + const visible = useMemo(() => genres.slice(0, visibleCount), [genres, visibleCount]); + const hasMore = visibleCount < genres.length; + + // Infinite scroll — render the next batch when the user is ~1.5 screens + // away from the sentinel, so the rest of the watermarks never block first + // paint of the page. + useEffect(() => { + if (!hasMore) return; + const observer = new IntersectionObserver( + entries => { if (entries[0].isIntersecting) setVisibleCount(c => c + PAGE_SIZE); }, + { rootMargin: '1500px' }, + ); + if (observerTarget.current) observer.observe(observerTarget.current); + return () => observer.disconnect(); + }, [hasMore]); + // Restore scroll position after genres are rendered useEffect(() => { if (loading || genres.length === 0) return; @@ -69,6 +98,7 @@ export default function Genres() { if (!saved) return; const pos = parseInt(saved, 10); sessionStorage.removeItem(SCROLL_KEY); + sessionStorage.removeItem(VISIBLE_KEY); requestAnimationFrame(() => { if (containerRef.current) containerRef.current.scrollTop = pos; }); @@ -77,6 +107,7 @@ export default function Genres() { const handleGenreClick = (genreValue: string) => { if (containerRef.current) { sessionStorage.setItem(SCROLL_KEY, String(containerRef.current.scrollTop)); + sessionStorage.setItem(VISIBLE_KEY, String(visibleCount)); } navigate(`/genres/${encodeURIComponent(genreValue)}`); }; @@ -96,33 +127,36 @@ export default function Genres() { {loading &&

{t('genres.loading')}

} {!loading && genres.length === 0 &&

{t('genres.empty')}

} - {!loading && genres.length > 0 && ( -
- {genres.map(genre => { - const Icon = getGenreIcon(genre.value); - const color = genreColor(genre.value); - return ( -
handleGenreClick(genre.value)} - role="button" - tabIndex={0} - onKeyDown={e => e.key === 'Enter' && handleGenreClick(genre.value)} - data-tooltip={genre.value} - > -
- + {!loading && visible.length > 0 && ( + <> +
+ {visible.map(genre => { + const Icon = getGenreIcon(genre.value); + const color = genreColor(genre.value); + return ( +
handleGenreClick(genre.value)} + role="button" + tabIndex={0} + onKeyDown={e => e.key === 'Enter' && handleGenreClick(genre.value)} + data-tooltip={genre.value} + > +
+ +
+

{genre.value}

+

+ {t('genres.albumCount', { count: genre.albumCount })} +

-

{genre.value}

-

- {t('genres.albumCount', { count: genre.albumCount })} -

-
- ); - })} -
+ ); + })} +
+ {hasMore &&
} + )}
);