mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +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
+23
-15
@@ -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 { useNavigate } from 'react-router-dom';
|
||||||
import { getArtists, SubsonicArtist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
import { getArtists, SubsonicArtist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||||
import { LayoutGrid, List, Images, CheckSquare2, ListMusic, Check } from 'lucide-react';
|
import { LayoutGrid, List, Images, CheckSquare2, ListMusic, Check } from 'lucide-react';
|
||||||
@@ -131,23 +131,27 @@ export default function Artists() {
|
|||||||
setVisibleCount(PAGE_SIZE);
|
setVisibleCount(PAGE_SIZE);
|
||||||
}, [filter, letterFilter, viewMode, PAGE_SIZE]);
|
}, [filter, letterFilter, viewMode, PAGE_SIZE]);
|
||||||
|
|
||||||
// Filter pipeline
|
// Filter pipeline — memoised so unrelated state changes (selection mode,
|
||||||
let filtered = artists;
|
// 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) {
|
if (letterFilter !== ALL_SENTINEL) {
|
||||||
filtered = filtered.filter(a => {
|
out = out.filter(a => {
|
||||||
const first = a.name[0]?.toUpperCase() ?? '#';
|
const first = a.name[0]?.toUpperCase() ?? '#';
|
||||||
const isAlpha = /^[A-Z]$/.test(first);
|
const isAlpha = /^[A-Z]$/.test(first);
|
||||||
if (letterFilter === '#') return !isAlpha;
|
if (letterFilter === '#') return !isAlpha;
|
||||||
return first === letterFilter;
|
return first === letterFilter;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter) {
|
if (filter) {
|
||||||
filtered = filtered.filter(a => a.name.toLowerCase().includes(filter.toLowerCase()));
|
const needle = filter.toLowerCase();
|
||||||
|
out = out.filter(a => a.name.toLowerCase().includes(needle));
|
||||||
}
|
}
|
||||||
|
return out;
|
||||||
|
}, [artists, letterFilter, filter]);
|
||||||
|
|
||||||
const visible = filtered.slice(0, visibleCount);
|
const visible = useMemo(() => filtered.slice(0, visibleCount), [filtered, visibleCount]);
|
||||||
const hasMore = visibleCount < filtered.length;
|
const hasMore = visibleCount < filtered.length;
|
||||||
|
|
||||||
// Intersection Observer for infinite scroll (after hasMore declaration)
|
// Intersection Observer for infinite scroll (after hasMore declaration)
|
||||||
@@ -160,15 +164,19 @@ export default function Artists() {
|
|||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [loadMore, hasMore]);
|
}, [loadMore, hasMore]);
|
||||||
|
|
||||||
// Group by first letter (for list view)
|
// Group by first letter (for list view) — only recompute when the visible
|
||||||
const groups: Record<string, SubsonicArtist[]> = {};
|
// slice or the view mode actually changes. Skipped entirely in grid view.
|
||||||
visible.forEach(a => {
|
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 letter = a.name[0]?.toUpperCase() ?? '#';
|
||||||
const key = /^[A-Z]$/.test(letter) ? letter : '#';
|
const key = /^[A-Z]$/.test(letter) ? letter : '#';
|
||||||
if (!groups[key]) groups[key] = [];
|
if (!g[key]) g[key] = [];
|
||||||
groups[key].push(a);
|
g[key].push(a);
|
||||||
});
|
}
|
||||||
const letters = Object.keys(groups).sort();
|
return { groups: g, letters: Object.keys(g).sort() };
|
||||||
|
}, [visible, viewMode]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-body animate-fade-in">
|
<div className="content-body animate-fade-in">
|
||||||
|
|||||||
Reference in New Issue
Block a user