mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
perf(artists): memoise filter pipeline and list-view grouping (#250)
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 <dev@psysonic.app> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
aa0776e811
commit
0dfc3fcfe2
+34
-26
@@ -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<string, SubsonicArtist[]> = {};
|
||||
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<string, SubsonicArtist[]>, letters: [] as string[] };
|
||||
const g: Record<string, SubsonicArtist[]> = {};
|
||||
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 (
|
||||
<div className="content-body animate-fade-in">
|
||||
|
||||
Reference in New Issue
Block a user