From 0dfc3fcfe23361e545af5cd3f1e8fa47b0a84d09 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher Date: Tue, 21 Apr 2026 21:38:11 +0200 Subject: [PATCH] perf(artists): memoise filter pipeline and list-view grouping (#250) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Artists.tsx already paginates the DOM via PAGE_SIZE + IntersectionObserver, so the page never holds more than 50-100 cards at a time. The actual overhead with a 5000-artist library was the filter pipeline running on every render — including renders triggered by selection mode toggles, view-mode switches, image-toggle, etc. — re-walking the full artists array twice per render (letterFilter + search filter), then re-building the list-view groups Record from scratch. Wrap the pipeline in useMemo: - `filtered` recomputes only when artists / letterFilter / filter change - `visible` recomputes only when filtered / visibleCount change (also keeps array identity stable across unrelated re-renders) - `groups` + `letters` recompute only when visible / viewMode change, and skip the loop entirely in grid view (where they're unused) No new dependency. With 5000 artists, unrelated state changes (selection toggle, click on a card, scroll past pagination boundary) are noticeably smoother. Confirmed locally on a ~5000-artist library. Co-authored-by: Psychotoxical Co-authored-by: Claude Opus 4.7 (1M context) --- src/pages/Artists.tsx | 60 ++++++++++++++++++++++++------------------- 1 file changed, 34 insertions(+), 26 deletions(-) diff --git a/src/pages/Artists.tsx b/src/pages/Artists.tsx index 0778e598..c92c4039 100644 --- a/src/pages/Artists.tsx +++ b/src/pages/Artists.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useCallback, useRef } from 'react'; +import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { getArtists, SubsonicArtist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic'; import { LayoutGrid, List, Images, CheckSquare2, ListMusic, Check } from 'lucide-react'; @@ -131,23 +131,27 @@ export default function Artists() { setVisibleCount(PAGE_SIZE); }, [filter, letterFilter, viewMode, PAGE_SIZE]); - // Filter pipeline - let filtered = artists; + // Filter pipeline — memoised so unrelated state changes (selection mode, + // viewMode, etc.) don't re-iterate the full artists array. With 5000+ + // artists each re-render walked the list twice without this. + const filtered = useMemo(() => { + let out = artists; + if (letterFilter !== ALL_SENTINEL) { + out = out.filter(a => { + const first = a.name[0]?.toUpperCase() ?? '#'; + const isAlpha = /^[A-Z]$/.test(first); + if (letterFilter === '#') return !isAlpha; + return first === letterFilter; + }); + } + if (filter) { + const needle = filter.toLowerCase(); + out = out.filter(a => a.name.toLowerCase().includes(needle)); + } + return out; + }, [artists, letterFilter, filter]); - if (letterFilter !== ALL_SENTINEL) { - filtered = filtered.filter(a => { - const first = a.name[0]?.toUpperCase() ?? '#'; - const isAlpha = /^[A-Z]$/.test(first); - if (letterFilter === '#') return !isAlpha; - return first === letterFilter; - }); - } - - if (filter) { - filtered = filtered.filter(a => a.name.toLowerCase().includes(filter.toLowerCase())); - } - - const visible = filtered.slice(0, visibleCount); + const visible = useMemo(() => filtered.slice(0, visibleCount), [filtered, visibleCount]); const hasMore = visibleCount < filtered.length; // Intersection Observer for infinite scroll (after hasMore declaration) @@ -160,15 +164,19 @@ export default function Artists() { return () => observer.disconnect(); }, [loadMore, hasMore]); - // Group by first letter (for list view) - const groups: Record = {}; - visible.forEach(a => { - const letter = a.name[0]?.toUpperCase() ?? '#'; - const key = /^[A-Z]$/.test(letter) ? letter : '#'; - if (!groups[key]) groups[key] = []; - groups[key].push(a); - }); - const letters = Object.keys(groups).sort(); + // Group by first letter (for list view) — only recompute when the visible + // slice or the view mode actually changes. Skipped entirely in grid view. + const { groups, letters } = useMemo(() => { + if (viewMode !== 'list') return { groups: {} as Record, letters: [] as string[] }; + const g: Record = {}; + for (const a of visible) { + const letter = a.name[0]?.toUpperCase() ?? '#'; + const key = /^[A-Z]$/.test(letter) ? letter : '#'; + if (!g[key]) g[key] = []; + g[key].push(a); + } + return { groups: g, letters: Object.keys(g).sort() }; + }, [visible, viewMode]); return (