test(offline): mock useOfflineBrowseReloadToken submodule in useSongBrowseList

The offline move collapsed a single-module mock into a `@/features/offline`
barrel mock, which shadowed the real `useOfflineBrowseContext` the test relied
on. Mock the reload-token submodule directly so the barrel re-exports the stub
while the sibling context hook stays live.
This commit is contained in:
Psychotoxical
2026-06-30 01:08:35 +02:00
parent 321d5ff6ab
commit 17ea96dcdb
45 changed files with 4 additions and 1 deletions
-136
View File
@@ -1,136 +0,0 @@
/**
* Regression tests for the id-gating tuple pattern in `useArtistDetailData`.
*
* `info` (the SubsonicArtistInfo returned by getArtistInfo) is held as a
* `{ id, value }` tuple internally and gated on id-match at the return
* statement. Without this gate, navigating between /artist/A → /artist/B
* would render one frame with A's `largeImageUrl` paired with B's id —
* exactly the cache-mismatch shape that PR #732 fixed for the queue info
* panel and that the shared ArtistCard (now used on this page) would
* otherwise persist into IndexedDB.
*/
import { renderHook, act, waitFor } from '@testing-library/react';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { SubsonicArtistInfo } from '../api/subsonicTypes';
vi.mock('../api/subsonicArtists');
vi.mock('../api/subsonicSearch');
import { getArtist, getArtistInfo, getTopSongs } from '../api/subsonicArtists';
import { search } from '../api/subsonicSearch';
import { useArtistDetailData } from './useArtistDetailData';
const mockArtistInfo = vi.mocked(getArtistInfo) as unknown as {
mockImplementation: (impl: (id: string) => Promise<SubsonicArtistInfo | null>) => void;
};
beforeEach(() => {
vi.mocked(getTopSongs).mockResolvedValue([]);
vi.mocked(search).mockResolvedValue({ songs: [], albums: [], artists: [] });
});
afterEach(() => {
vi.clearAllMocks();
});
function routerWrapper({ children }: { children: React.ReactNode }) {
return React.createElement(MemoryRouter, null, children);
}
function deferred<T>() {
let resolve!: (v: T) => void;
let reject!: (e?: unknown) => void;
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; });
return { promise, resolve, reject };
}
describe('useArtistDetailData — id-gated info', () => {
it('returns null info when id changes before the new fetch resolves', async () => {
vi.mocked(getArtist).mockImplementation(async (id) => (
{ artist: { id, name: id }, albums: [] }
));
const a = deferred<SubsonicArtistInfo | null>();
const b = deferred<SubsonicArtistInfo | null>();
mockArtistInfo.mockImplementation(async (id) => {
if (id === 'A') return a.promise;
if (id === 'B') return b.promise;
return null;
});
const { result, rerender } = renderHook(
({ id }: { id: string }) => useArtistDetailData(id),
{ initialProps: { id: 'A' }, wrapper: routerWrapper },
);
await act(async () => { a.resolve({ largeImageUrl: 'A.jpg' } as SubsonicArtistInfo); });
await waitFor(() => expect(result.current.info).toEqual({ largeImageUrl: 'A.jpg' }));
// Switch to artist B. info must flip to null until B's fetch resolves —
// it must never carry A's largeImageUrl paired with B's id, since the
// shared ArtistCard would build a `coverArtCacheKey(B, 80)` from `id`
// and pair it with A's URL inside CachedImage.
rerender({ id: 'B' });
expect(result.current.info).toBeNull();
await act(async () => { b.resolve({ largeImageUrl: 'B.jpg' } as SubsonicArtistInfo); });
await waitFor(() => expect(result.current.info).toEqual({ largeImageUrl: 'B.jpg' }));
});
it('keeps the album-artist credit on featured compilation albums', async () => {
// "Also featured on" synthesises albums from search3 child songs. A
// compilation has no flat `albumArtist` on the child — the credit lives in
// OpenSubsonic's structured `albumArtists` (and/or `displayAlbumArtist`).
// Dropping it made the card render "—" instead of "Various Artists".
vi.mocked(getArtist).mockResolvedValue({ artist: { id: 'A', name: 'A' }, albums: [] });
vi.mocked(search).mockResolvedValue({
artists: [],
albums: [],
songs: [
{
id: 's1', title: 'Track', artistId: 'A', artist: 'A',
album: 'A Compilation', albumId: 'comp1', coverArt: 'c1', duration: 100,
albumArtists: [{ id: 'va', name: 'Various Artists' }],
},
{
id: 's2', title: 'Other', artistId: 'A', artist: 'A',
album: 'Display Only', albumId: 'comp2', coverArt: 'c2', duration: 90,
displayAlbumArtist: 'Various Artists',
},
],
});
const { result } = renderHook(() => useArtistDetailData('A'), { wrapper: routerWrapper });
await waitFor(() => expect(result.current.featuredAlbums).toHaveLength(2));
const structured = result.current.featuredAlbums.find(a => a.id === 'comp1');
const displayOnly = result.current.featuredAlbums.find(a => a.id === 'comp2');
expect(structured?.artists).toEqual([{ id: 'va', name: 'Various Artists' }]);
expect(displayOnly?.artist).toBe('Various Artists');
});
it('ignores a late-arriving resolve for a stale id', async () => {
vi.mocked(getArtist).mockImplementation(async (id) => (
{ artist: { id, name: id }, albums: [] }
));
const a = deferred<SubsonicArtistInfo | null>();
mockArtistInfo.mockImplementation(async (id) => {
if (id === 'A') return a.promise;
if (id === 'B') return { largeImageUrl: 'B.jpg' } as SubsonicArtistInfo;
return null;
});
const { result, rerender } = renderHook(
({ id }: { id: string }) => useArtistDetailData(id),
{ initialProps: { id: 'A' }, wrapper: routerWrapper },
);
rerender({ id: 'B' });
await waitFor(() => expect(result.current.info).toEqual({ largeImageUrl: 'B.jpg' }));
// A's late resolve must not overwrite B's info.
await act(async () => { a.resolve({ largeImageUrl: 'A.jpg' } as SubsonicArtistInfo); });
expect(result.current.info).toEqual({ largeImageUrl: 'B.jpg' });
});
});
-248
View File
@@ -1,248 +0,0 @@
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { search } from '../api/subsonicSearch';
import { getArtist, getArtistForServer, getArtistInfo, getTopSongs } from '../api/subsonicArtists';
import type {
SubsonicAlbum, SubsonicArtist, SubsonicArtistInfo, SubsonicSong,
} from '../api/subsonicTypes';
import { useAuthStore } from '../store/authStore';
import { useConnectionStatus } from './useConnectionStatus';
import { loadArtistFromLibraryIndex } from '@/features/offline';
import { useOfflineBrowseContext } from '@/features/offline';
import { loadArtistFromLocalPlayback, offlineLocalBrowseEnabled } from '@/features/offline';
import { readDetailServerId } from '../utils/navigation/detailServerScope';
import { runLocalArtistLosslessBrowse } from '../utils/library/browseTextSearch';
import { isLosslessSuffix } from '../utils/library/losslessFormats';
export interface UseArtistDetailDataOptions {
/** When true, albums and top tracks are limited to lossless containers (local index preferred). */
losslessOnly?: boolean;
}
export interface ArtistDetailDataResult {
artist: SubsonicArtist | null;
setArtist: React.Dispatch<React.SetStateAction<SubsonicArtist | null>>;
albums: SubsonicAlbum[];
topSongs: SubsonicSong[];
info: SubsonicArtistInfo | null;
featuredAlbums: SubsonicAlbum[];
loading: boolean;
artistInfoLoading: boolean;
featuredLoading: boolean;
isStarred: boolean;
setIsStarred: React.Dispatch<React.SetStateAction<boolean>>;
losslessOnly: boolean;
}
function filterNetworkArtistToLossless(
albums: SubsonicAlbum[],
songs: SubsonicSong[],
): { albums: SubsonicAlbum[]; songs: SubsonicSong[] } {
const losslessSongs = songs.filter(s => isLosslessSuffix(s.suffix));
const albumIds = new Set(losslessSongs.map(s => s.albumId).filter(Boolean));
return {
albums: albums.filter(a => albumIds.has(a.id)),
songs: losslessSongs,
};
}
export function useArtistDetailData(
id: string | undefined,
options: UseArtistDetailDataOptions = {},
): ArtistDetailDataResult {
const losslessOnly = options.losslessOnly ?? false;
const activeServerId = useAuthStore(s => s.activeServerId);
const [searchParams] = useSearchParams();
const serverId = readDetailServerId(searchParams, activeServerId);
const favoritesOfflineEnabled = useAuthStore(s => s.favoritesOfflineEnabled);
const { status: connStatus } = useConnectionStatus();
const audiomuseNavidromeEnabled = useAuthStore(
s => !!(serverId && s.audiomuseNavidromeByServer[serverId]),
);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const offlineBrowseActive = useOfflineBrowseContext().active && !!serverId;
const preferLocalBytesOnly = offlineBrowseActive && offlineLocalBrowseEnabled(serverId);
const preferLocalArtist = preferLocalBytesOnly
|| (connStatus === 'disconnected' && favoritesOfflineEnabled && !!serverId);
const [artist, setArtist] = useState<SubsonicArtist | null>(null);
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [featuredAlbums, setFeaturedAlbums] = useState<SubsonicAlbum[]>([]);
const [topSongs, setTopSongs] = useState<SubsonicSong[]>([]);
const [infoEntry, setInfoEntry] = useState<{ id: string; value: SubsonicArtistInfo | null } | null>(null);
const [loading, setLoading] = useState(true);
const [isStarred, setIsStarred] = useState(false);
const [artistInfoLoading, setArtistInfoLoading] = useState(false);
const [featuredLoading, setFeaturedLoading] = useState(false);
useEffect(() => {
if (!id) return;
let cancelled = false;
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
setLoading(true);
setInfoEntry(null);
setTopSongs([]);
setFeaturedAlbums([]);
(async () => {
try {
if (offlineBrowseActive && !preferLocalBytesOnly) {
setLoading(false);
return;
}
if (preferLocalArtist && serverId && id) {
const local = preferLocalBytesOnly
? await loadArtistFromLocalPlayback(serverId, id)
: await loadArtistFromLibraryIndex(serverId, id);
if (cancelled) return;
if (local) {
setArtist(local.artist);
setIsStarred(!!local.artist.starred);
setAlbums(local.albums);
setTopSongs([]);
setLoading(false);
return;
}
if (preferLocalBytesOnly) {
setLoading(false);
return;
}
}
if (losslessOnly && serverId) {
const local = await runLocalArtistLosslessBrowse(serverId, id);
if (cancelled) return;
if (local) {
const artistData = serverId
? await getArtistForServer(serverId, id).catch(() => null)
: await getArtist(id).catch(() => null);
if (cancelled) return;
if (artistData) {
setArtist(artistData.artist);
setIsStarred(!!artistData.artist.starred);
}
setAlbums(local.albums);
setTopSongs([...local.songs].sort((a, b) => (b.playCount ?? 0) - (a.playCount ?? 0)));
setLoading(false);
return;
}
}
const artistData = serverId
? await getArtistForServer(serverId, id)
: await getArtist(id);
if (cancelled) return;
setArtist(artistData.artist);
let nextAlbums = artistData.albums;
setIsStarred(!!artistData.artist.starred);
setLoading(false);
const songsData = await getTopSongs(artistData.artist.name).catch(() => [] as SubsonicSong[]);
if (cancelled) return;
let nextSongs = songsData ?? [];
if (losslessOnly) {
({ albums: nextAlbums, songs: nextSongs } = filterNetworkArtistToLossless(nextAlbums, nextSongs));
}
setAlbums(nextAlbums);
setTopSongs(nextSongs);
} catch (err) {
if (!cancelled) {
if (preferLocalArtist && serverId && id) {
try {
const local = preferLocalBytesOnly
? await loadArtistFromLocalPlayback(serverId, id)
: await loadArtistFromLibraryIndex(serverId, id);
if (cancelled) return;
if (local) {
setArtist(local.artist);
setIsStarred(!!local.artist.starred);
setAlbums(local.albums);
setTopSongs([]);
setLoading(false);
return;
}
} catch { /* ignore */ }
}
console.error(err);
setLoading(false);
}
}
})();
return () => { cancelled = true; };
}, [id, losslessOnly, serverId, offlineBrowseActive, preferLocalArtist, preferLocalBytesOnly, searchParams]);
useEffect(() => {
if (!id || preferLocalArtist) return;
let cancelled = false;
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
setArtistInfoLoading(true);
getArtistInfo(id, { similarArtistCount: audiomuseNavidromeEnabled ? 24 : undefined })
.then(artistInfo => {
if (!cancelled) setInfoEntry({ id, value: artistInfo ?? null });
})
.catch(() => {
if (!cancelled) setInfoEntry({ id, value: null });
})
.finally(() => {
if (!cancelled) setArtistInfoLoading(false);
});
return () => { cancelled = true; };
}, [id, audiomuseNavidromeEnabled, preferLocalArtist]);
useEffect(() => {
if (!id || !artist || preferLocalArtist) return;
const ownAlbumIds = new Set(albums.map(a => a.id));
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
setFeaturedLoading(true);
search(artist.name, { songCount: 500, artistCount: 0, albumCount: 0 })
.catch(() => ({ songs: [], albums: [], artists: [] }))
.then(searchResults => {
let featuredSongs = (searchResults.songs ?? []).filter(
song => song.artistId === id && !ownAlbumIds.has(song.albumId),
);
if (losslessOnly) {
featuredSongs = featuredSongs.filter(s => isLosslessSuffix(s.suffix));
}
const albumMap = new Map<string, SubsonicAlbum>();
featuredSongs.forEach(song => {
if (!albumMap.has(song.albumId)) {
albumMap.set(song.albumId, {
id: song.albumId,
name: song.album,
// search3 children carry the album-artist credit in OpenSubsonic's
// structured `albumArtists` / `displayAlbumArtist` (e.g. "Various
// Artists" on compilations), not the flat `albumArtist` field — keep
// all of them so the card resolves a name instead of "—".
artist: song.albumArtist ?? song.displayAlbumArtist ?? '',
artistId: '',
artists: song.albumArtists,
coverArt: song.coverArt,
songCount: 1,
duration: song.duration,
year: song.year,
});
} else {
const a = albumMap.get(song.albumId)!;
a.songCount++;
a.duration += song.duration;
}
});
setFeaturedAlbums([...albumMap.values()]);
setFeaturedLoading(false);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [artist?.id, musicLibraryFilterVersion, losslessOnly, albums, preferLocalArtist]);
const info = infoEntry && infoEntry.id === id ? infoEntry.value : null;
return {
artist, setArtist, albums, topSongs, info, featuredAlbums,
loading, artistInfoLoading, featuredLoading,
isStarred, setIsStarred,
losslessOnly,
};
}
-90
View File
@@ -1,90 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { getArtistInfoForServer } from '../api/subsonicArtists';
import type { SubsonicArtistInfo, SubsonicOpenArtistRef } from '../api/subsonicTypes';
import { makeCache } from '../utils/cache/nowPlayingCache';
const artistInfoCache = makeCache<SubsonicArtistInfo | null>();
function cacheKey(serverId: string, artistId: string): string {
return `${serverId}:${artistId}`;
}
/**
* Fetches `getArtistInfo` for each ref with an id. Returns `undefined` for ids
* still loading, `null` when fetch finished with no info.
*/
export function useArtistInfoBatch(
serverId: string | undefined,
refs: SubsonicOpenArtistRef[],
similarArtistCount?: number,
): Record<string, SubsonicArtistInfo | null | undefined> {
const ids = useMemo(
() => [...new Set(refs.map(r => r.id?.trim()).filter((id): id is string => Boolean(id)))],
[refs],
);
const idsKey = ids.join('\x1e');
const [byId, setById] = useState<Record<string, SubsonicArtistInfo | null | undefined>>(() => {
if (!serverId || ids.length === 0) return {};
const seed: Record<string, SubsonicArtistInfo | null | undefined> = {};
for (const id of ids) {
const cached = artistInfoCache.get(cacheKey(serverId, id));
if (cached !== undefined) seed[id] = cached;
}
return seed;
});
useEffect(() => {
if (!serverId || ids.length === 0) {
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
setById({});
return;
}
const next: Record<string, SubsonicArtistInfo | null | undefined> = {};
const pending: string[] = [];
for (const id of ids) {
const cached = artistInfoCache.get(cacheKey(serverId, id));
if (cached !== undefined) {
next[id] = cached;
} else {
next[id] = undefined;
pending.push(id);
}
}
setById(next);
if (pending.length === 0) return;
let cancelled = false;
void Promise.all(
pending.map(async id => {
try {
const info = await getArtistInfoForServer(serverId, id, {
similarArtistCount: similarArtistCount,
});
artistInfoCache.set(cacheKey(serverId, id), info ?? null);
return [id, info ?? null] as const;
} catch {
artistInfoCache.set(cacheKey(serverId, id), null);
return [id, null] as const;
}
}),
).then(results => {
if (cancelled) return;
setById(prev => {
const merged = { ...prev };
for (const [id, info] of results) merged[id] = info;
return merged;
});
});
return () => { cancelled = true; };
// Keyed on idsKey (the stable string form of `ids`); depending on the ids
// array directly would re-fetch on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [serverId, idsKey, similarArtistCount]);
return byId;
}
-63
View File
@@ -1,63 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useLocalPlaybackStore } from '../store/localPlaybackStore';
import { useOfflineJobStore } from '@/features/offline';
import { useArtistOfflineState } from './useArtistOfflineState';
describe('useArtistOfflineState', () => {
beforeEach(() => {
useOfflineJobStore.setState({ jobs: [], pinQueue: [], bulkProgress: {} });
useLocalPlaybackStore.setState({ entries: {} });
});
it('reports cached when every album is pinned', () => {
useLocalPlaybackStore.setState({
entries: {
'srv:al-1': {
serverIndexKey: 'srv',
trackId: 't1',
localPath: '/x',
layoutFingerprint: 'fp',
sizeBytes: 1,
tier: 'library',
cachedAt: 1,
suffix: 'mp3',
pinSource: { kind: 'artist', sourceId: 'al-1' },
},
'srv:al-2': {
serverIndexKey: 'srv',
trackId: 't2',
localPath: '/y',
layoutFingerprint: 'fp',
sizeBytes: 1,
tier: 'library',
cachedAt: 1,
suffix: 'mp3',
pinSource: { kind: 'artist', sourceId: 'al-2' },
},
},
});
const { result } = renderHook(() =>
useArtistOfflineState('artist-1', 'srv', ['al-1', 'al-2']),
);
expect(result.current.status).toBe('cached');
});
it('reports queued when bulk progress is active but albums only wait in pin queue', () => {
useOfflineJobStore.setState({
bulkProgress: { 'artist-1': { done: 0, total: 2 } },
pinQueue: [
{ albumId: 'al-1', albumName: 'One', pinKind: 'artist', status: 'queued', queuedAt: 1 },
{ albumId: 'al-2', albumName: 'Two', pinKind: 'artist', status: 'queued', queuedAt: 2 },
],
jobs: [],
});
const { result } = renderHook(() =>
useArtistOfflineState('artist-1', 'srv', ['al-1', 'al-2']),
);
expect(result.current.status).toBe('queued');
expect(result.current.progress).toEqual({ done: 0, total: 2 });
});
});
-56
View File
@@ -1,56 +0,0 @@
import { useLocalPlaybackStore } from '../store/localPlaybackStore';
import { useOfflineJobStore } from '@/features/offline';
import { isOfflinePinComplete } from '@/features/offline';
export type ArtistOfflineStatus = 'none' | 'queued' | 'downloading' | 'cached';
interface UseArtistOfflineStateResult {
status: ArtistOfflineStatus;
progress: { done: number; total: number } | null;
}
/**
* Offline discography status for an artist page. Uses persisted library pins
* (not ephemeral bulkProgress) so "Discography cached" survives navigation.
*/
export function useArtistOfflineState(
artistId: string,
serverId: string,
albumIds: string[],
): UseArtistOfflineStateResult {
useLocalPlaybackStore(s => s.entries);
const allPinned = albumIds.length > 0
&& albumIds.every(id => isOfflinePinComplete(id, serverId));
const bulkDone = useOfflineJobStore(s => (artistId ? s.bulkProgress[artistId]?.done : undefined));
const bulkTotal = useOfflineJobStore(s => (artistId ? s.bulkProgress[artistId]?.total : undefined));
const hasQueuedAlbums = useOfflineJobStore(s =>
albumIds.length > 0
&& albumIds.some(id => s.pinQueue.some(p => p.albumId === id && p.status === 'queued')),
);
const hasDownloadingAlbums = useOfflineJobStore(s =>
albumIds.length > 0
&& albumIds.some(id =>
s.pinQueue.some(p => p.albumId === id && p.status === 'downloading')
|| s.jobs.some(j => j.albumId === id && (j.status === 'queued' || j.status === 'downloading')),
),
);
const bulkActive = bulkTotal !== undefined && bulkDone !== undefined && bulkDone < bulkTotal;
const waitingInQueue = bulkActive && hasQueuedAlbums && !hasDownloadingAlbums;
const status: ArtistOfflineStatus = allPinned
? 'cached'
: hasDownloadingAlbums || (bulkActive && !waitingInQueue)
? 'downloading'
: waitingInQueue
? 'queued'
: 'none';
const progress = bulkActive && bulkDone !== undefined && bulkTotal !== undefined
? { done: bulkDone, total: bulkTotal }
: null;
return { status, progress };
}
-115
View File
@@ -1,115 +0,0 @@
import { useEffect, useState } from 'react';
import { getMusicNetworkRuntime } from '../music-network';
import { search } from '../api/subsonicSearch';
import type { SubsonicArtist, SubsonicArtistInfo } from '../api/subsonicTypes';
import { useAuthStore } from '../store/authStore';
export interface ArtistSimilarArtistsResult {
similarArtists: SubsonicArtist[];
similarLoading: boolean;
}
/**
* Resolves the "Similar Artists" list for the current artist:
* - Default: Last.fm getSimilar → server search for each name → keep first exact match.
* - With audiomuseNavidromeEnabled on: prefer info.similarArtist; fall back to Last.fm
* when the server returns nothing and Last.fm is configured.
*/
export function useArtistSimilarArtists(
artist: SubsonicArtist | null,
info: SubsonicArtistInfo | null,
artistInfoLoading: boolean,
): ArtistSimilarArtistsResult {
const audiomuseNavidromeEnabled = useAuthStore(
s => !!(s.activeServerId && s.audiomuseNavidromeByServer[s.activeServerId]),
);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const enrichmentConfigured = useAuthStore(s => s.enrichmentPrimaryId !== null);
const [similarArtists, setSimilarArtists] = useState<SubsonicArtist[]>([]);
const [similarLoading, setSimilarLoading] = useState(false);
useEffect(() => {
if (!artist || audiomuseNavidromeEnabled || !enrichmentConfigured) return;
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
setSimilarArtists([]);
setSimilarLoading(true);
getMusicNetworkRuntime().getSimilarArtists(artist.name).then(async names => {
if (names.length === 0) { setSimilarLoading(false); return; }
const results = await Promise.all(
names.slice(0, 30).map(name =>
search(name, { artistCount: 3, albumCount: 0, songCount: 0 }).catch(() => ({ artists: [], albums: [], songs: [] }))
)
);
const seen = new Set<string>([artist.id]);
const found: SubsonicArtist[] = [];
for (let i = 0; i < results.length; i++) {
const targetName = names[i].toLowerCase();
const match = results[i].artists.find(a => a.name.toLowerCase() === targetName);
if (match && !seen.has(match.id)) {
seen.add(match.id);
found.push(match);
}
}
setSimilarArtists(found);
setSimilarLoading(false);
}).catch(() => setSimilarLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [artist?.id, musicLibraryFilterVersion, audiomuseNavidromeEnabled, enrichmentConfigured]);
/** When AudioMuse is on but the server returns no similar artists, fall back to Last.fm (if configured). */
useEffect(() => {
if (!artist || !audiomuseNavidromeEnabled || !enrichmentConfigured) return;
if (artistInfoLoading) return;
if ((info?.similarArtist?.length ?? 0) > 0) return;
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
setSimilarArtists([]);
setSimilarLoading(true);
getMusicNetworkRuntime().getSimilarArtists(artist.name).then(async names => {
if (names.length === 0) { setSimilarLoading(false); return; }
const results = await Promise.all(
names.slice(0, 30).map(name =>
search(name, { artistCount: 3, albumCount: 0, songCount: 0 }).catch(() => ({ artists: [], albums: [], songs: [] }))
)
);
const seen = new Set<string>([artist.id]);
const found: SubsonicArtist[] = [];
for (let i = 0; i < results.length; i++) {
const targetName = names[i].toLowerCase();
const match = results[i].artists.find(a => a.name.toLowerCase() === targetName);
if (match && !seen.has(match.id)) {
seen.add(match.id);
found.push(match);
}
}
setSimilarArtists(found);
setSimilarLoading(false);
}).catch(() => setSimilarLoading(false));
// Keyed on artist?.id / artist?.name; depending on the `artist` object would
// re-run on every render when its identity changes but its id/name do not.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
artist?.id,
artist?.name,
musicLibraryFilterVersion,
audiomuseNavidromeEnabled,
artistInfoLoading,
info?.similarArtist?.length,
enrichmentConfigured,
]);
useEffect(() => {
if (!audiomuseNavidromeEnabled) return;
if ((info?.similarArtist?.length ?? 0) > 0) {
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
setSimilarArtists([]);
setSimilarLoading(false);
}
}, [artist?.id, audiomuseNavidromeEnabled, info?.similarArtist?.length]);
return { similarArtists, similarLoading };
}
-183
View File
@@ -1,183 +0,0 @@
import { getArtists } from '../api/subsonicArtists';
import type { SubsonicArtist } from '../api/subsonicTypes';
import { useCallback, useEffect, useRef, useState } from 'react';
import { dedupeById } from '../utils/dedupeById';
import {
fetchLocalArtistCatalogChunk,
fetchNetworkStarredArtists,
} from '../utils/library/browseTextSearch';
import { useOfflineBrowseContext } from '@/features/offline';
import { useOfflineBrowseReloadToken } from '@/features/offline';
import {
fetchOfflineLocalArtistCatalogChunk,
fetchOfflineLocalStarredArtists,
offlineLocalBrowseEnabled,
} from '@/features/offline';
/** Local-index artist catalog buffer grows by this many rows per background SQL chunk. */
export const ARTIST_CATALOG_CHUNK_SIZE = 200;
export type ArtistsBrowseMode = 'slice' | 'network';
export type UseArtistsBrowseCatalogArgs = {
serverId: string | null | undefined;
indexEnabled: boolean;
starredOnly: boolean;
musicLibraryFilterVersion: number;
};
export function useArtistsBrowseCatalog({
serverId,
indexEnabled,
starredOnly,
musicLibraryFilterVersion,
}: UseArtistsBrowseCatalogArgs) {
const offlineBrowseActive = useOfflineBrowseContext().active;
const offlineBrowseReloadTs = useOfflineBrowseReloadToken();
const [catalogArtists, setCatalogArtists] = useState<SubsonicArtist[]>([]);
const [loading, setLoading] = useState(true);
const [catalogHasMore, setCatalogHasMore] = useState(false);
const [catalogLoadingMore, setCatalogLoadingMore] = useState(false);
const [browseMode, setBrowseMode] = useState<ArtistsBrowseMode>('network');
const loadGenerationRef = useRef(0);
const catalogOffsetRef = useRef(0);
const catalogLoadingRef = useRef(false);
const loadCatalogChunk = useCallback(async (append: boolean) => {
if (!serverId || catalogLoadingRef.current) return;
const generation = loadGenerationRef.current;
catalogLoadingRef.current = true;
setCatalogLoadingMore(true);
try {
if (offlineBrowseActive) {
if (!offlineLocalBrowseEnabled(serverId)) return;
const chunk = await fetchOfflineLocalArtistCatalogChunk(
serverId,
catalogOffsetRef.current,
ARTIST_CATALOG_CHUNK_SIZE,
);
if (generation !== loadGenerationRef.current || chunk == null) return;
if (append) {
setCatalogArtists(prev => {
const merged = dedupeById([...prev, ...chunk.artists]);
catalogOffsetRef.current = merged.length;
return merged;
});
} else {
setCatalogArtists(chunk.artists);
catalogOffsetRef.current = chunk.artists.length;
}
setCatalogHasMore(chunk.hasMore);
return;
}
const chunk = await fetchLocalArtistCatalogChunk(
serverId,
catalogOffsetRef.current,
ARTIST_CATALOG_CHUNK_SIZE,
);
if (generation !== loadGenerationRef.current || chunk == null) return;
if (append) {
setCatalogArtists(prev => {
const merged = dedupeById([...prev, ...chunk.artists]);
catalogOffsetRef.current = merged.length;
return merged;
});
} else {
setCatalogArtists(chunk.artists);
catalogOffsetRef.current = chunk.artists.length;
}
setCatalogHasMore(chunk.hasMore);
setBrowseMode('slice');
} finally {
catalogLoadingRef.current = false;
if (generation === loadGenerationRef.current) {
setCatalogLoadingMore(false);
}
}
}, [offlineBrowseActive, serverId]);
useEffect(() => {
let cancelled = false;
const generation = ++loadGenerationRef.current;
catalogOffsetRef.current = 0;
catalogLoadingRef.current = false;
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
setCatalogArtists([]);
setCatalogHasMore(false);
setCatalogLoadingMore(false);
setBrowseMode('network');
setLoading(true);
void (async () => {
try {
if (offlineBrowseActive) {
if (!cancelled && generation === loadGenerationRef.current) {
if (serverId && starredOnly && offlineLocalBrowseEnabled(serverId)) {
setCatalogArtists((await fetchOfflineLocalStarredArtists(serverId)) ?? []);
} else if (serverId && !starredOnly && offlineLocalBrowseEnabled(serverId)) {
const first = await fetchOfflineLocalArtistCatalogChunk(
serverId,
0,
ARTIST_CATALOG_CHUNK_SIZE,
);
setCatalogArtists(first?.artists ?? []);
catalogOffsetRef.current = first?.artists.length ?? 0;
setCatalogHasMore(first?.hasMore ?? false);
} else {
setCatalogArtists([]);
setCatalogHasMore(false);
}
setBrowseMode('slice');
}
return;
}
if (starredOnly) {
if (!cancelled && generation === loadGenerationRef.current) {
setCatalogArtists(await fetchNetworkStarredArtists());
}
return;
}
if (indexEnabled && serverId) {
const first = await fetchLocalArtistCatalogChunk(
serverId,
0,
ARTIST_CATALOG_CHUNK_SIZE,
);
if (cancelled || generation !== loadGenerationRef.current) return;
if (first != null) {
setBrowseMode('slice');
setCatalogArtists(first.artists);
catalogOffsetRef.current = first.artists.length;
setCatalogHasMore(first.hasMore);
return;
}
}
if (!cancelled && generation === loadGenerationRef.current) {
setCatalogArtists(await getArtists());
}
} catch {
/* ignore */
} finally {
if (!cancelled && generation === loadGenerationRef.current) {
setLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [musicLibraryFilterVersion, indexEnabled, offlineBrowseActive, offlineBrowseReloadTs, serverId, starredOnly]);
return {
catalogArtists,
loading,
catalogHasMore,
catalogLoadingMore,
browseMode,
loadCatalogChunk,
catalogLoadingRef,
};
}
-125
View File
@@ -1,125 +0,0 @@
import { useEffect, useRef, useState, type RefObject } from 'react';
import { useLocation, useNavigationType, type NavigationType } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';
import {
DEFAULT_ARTIST_BROWSE_RETURN_STATE,
type ArtistBrowseReturnState,
type ArtistBrowseViewMode,
isArtistsBrowsePath,
useArtistBrowseSessionStore,
} from '../store/artistBrowseSessionStore';
import { isArtistDetailPath } from '../store/albumBrowseSessionStore';
import { shouldRestoreArtistBrowseSession } from '../utils/navigation/albumDetailNavigation';
import { useLiveSearchScopeStore } from '../store/liveSearchScopeStore';
export type ArtistBrowseScrollSnapshot = {
scrollTop: number;
visibleCount: number;
};
function returnStateForNavigation(
serverId: string,
navigationType: NavigationType,
locationState: unknown,
): ArtistBrowseReturnState {
if (!shouldRestoreArtistBrowseSession(navigationType, locationState) || !serverId) {
return DEFAULT_ARTIST_BROWSE_RETURN_STATE;
}
return (
useArtistBrowseSessionStore.getState().peekReturnStash(serverId)
?? DEFAULT_ARTIST_BROWSE_RETURN_STATE
);
}
export function useArtistsBrowseFilters(
serverId: string,
scrollSnapshotRef?: RefObject<ArtistBrowseScrollSnapshot>,
) {
const navigationType = useNavigationType();
const location = useLocation();
const setShowArtistImages = useAuthStore(s => s.setShowArtistImages);
const [letterFilter, setLetterFilter] = useState(
() => returnStateForNavigation(serverId, navigationType, location.state).letterFilter,
);
const [starredOnly, setStarredOnly] = useState(
() => returnStateForNavigation(serverId, navigationType, location.state).starredOnly,
);
const [viewMode, setViewMode] = useState<ArtistBrowseViewMode>(
() => returnStateForNavigation(serverId, navigationType, location.state).viewMode,
);
const browseStateRef = useRef<ArtistBrowseReturnState>(DEFAULT_ARTIST_BROWSE_RETURN_STATE);
const restoredFromStashRef = useRef(false);
const showArtistImages = useAuthStore(s => s.showArtistImages);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
browseStateRef.current = {
filter: useLiveSearchScopeStore.getState().query,
letterFilter,
starredOnly,
viewMode,
showArtistImages,
};
useEffect(() => {
restoredFromStashRef.current = false;
}, [serverId]);
useEffect(() => {
if (!serverId) return;
if (shouldRestoreArtistBrowseSession(navigationType, location.state)) {
restoredFromStashRef.current = true;
const restored = useArtistBrowseSessionStore.getState().peekReturnStash(serverId);
if (restored) {
useLiveSearchScopeStore.getState().setQuery(restored.filter);
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
setLetterFilter(restored.letterFilter);
setStarredOnly(restored.starredOnly);
setViewMode(restored.viewMode);
setShowArtistImages(restored.showArtistImages);
}
return;
}
if (restoredFromStashRef.current) return;
useArtistBrowseSessionStore.getState().clearReturnStash(serverId);
useLiveSearchScopeStore.getState().setQuery('');
setLetterFilter(DEFAULT_ARTIST_BROWSE_RETURN_STATE.letterFilter);
setStarredOnly(false);
setViewMode('grid');
}, [serverId, navigationType, location.state, setShowArtistImages]);
useEffect(() => {
return () => {
if (!serverId) return;
const path = window.location.pathname;
if (isArtistDetailPath(path)) {
// Read at cleanup time on purpose: we want the scroll snapshot as it is
// at navigation-away. Copying it at effect setup would stash a stale value.
// eslint-disable-next-line react-hooks/exhaustive-deps
const snapshot = scrollSnapshotRef?.current;
useArtistBrowseSessionStore.getState().stashReturnState(serverId, {
...browseStateRef.current,
scrollTop: snapshot?.scrollTop,
visibleCount: snapshot?.visibleCount,
});
} else if (!isArtistsBrowsePath(path)) {
useArtistBrowseSessionStore.getState().clearReturnStash(serverId);
}
};
}, [serverId, scrollSnapshotRef]);
return {
letterFilter,
setLetterFilter,
starredOnly,
setStarredOnly,
viewMode,
setViewMode,
};
}
-54
View File
@@ -1,54 +0,0 @@
import { useLayoutEffect, useRef, type RefObject } from 'react';
import type { Virtualizer } from '@tanstack/react-virtual';
type BrowseScrollSnapshot = {
scrollTop: number;
visibleCount: number;
};
type Args = {
scrollSnapshotRef: RefObject<BrowseScrollSnapshot>;
getScrollRoot: () => HTMLElement | null;
isScrollRestorePending: boolean;
resetKey: string;
viewMode: 'grid' | 'list';
listVirtualize: boolean;
listVirtualizer: Virtualizer<HTMLElement, Element>;
};
/** Scroll to top when browse filters shrink the list (e.g. scoped text search). */
export function useArtistsBrowseScrollReset({
scrollSnapshotRef,
getScrollRoot,
isScrollRestorePending,
resetKey,
viewMode,
listVirtualize,
listVirtualizer,
}: Args): void {
const prevResetKeyRef = useRef(resetKey);
useLayoutEffect(() => {
if (isScrollRestorePending) return;
if (prevResetKeyRef.current === resetKey) return;
prevResetKeyRef.current = resetKey;
const el = getScrollRoot();
if (!el) return;
if (el.scrollTop !== 0) {
el.scrollTop = 0;
el.dispatchEvent(new Event('scroll', { bubbles: false }));
}
scrollSnapshotRef.current.scrollTop = 0;
if (listVirtualize && viewMode === 'list') listVirtualizer.scrollToOffset(0);
}, [
resetKey,
isScrollRestorePending,
getScrollRoot,
scrollSnapshotRef,
viewMode,
listVirtualize,
listVirtualizer,
]);
}
-101
View File
@@ -1,101 +0,0 @@
import { useLayoutEffect, useRef, useState } from 'react';
import { useLocation, useNavigationType, type NavigationType } from 'react-router-dom';
import {
peekArtistBrowseScrollRestore,
useArtistBrowseSessionStore,
} from '../store/artistBrowseSessionStore';
import { shouldRestoreArtistBrowseSession } from '../utils/navigation/albumDetailNavigation';
type PendingScroll = {
scrollTop: number;
visibleCount: number;
};
export type UseArtistsBrowseScrollRestoreArgs = {
serverId: string;
scrollBodyEl: HTMLElement | null;
visibleCount: number;
loading: boolean;
loadingMore: boolean;
hasMore: boolean;
loadMore: () => void;
};
export type UseArtistsBrowseScrollRestoreResult = {
isScrollRestorePending: boolean;
};
function readPendingScrollRestore(
serverId: string,
navigationType: NavigationType,
locationState: unknown,
): PendingScroll | null {
if (!shouldRestoreArtistBrowseSession(navigationType, locationState) || !serverId) return null;
return peekArtistBrowseScrollRestore(serverId);
}
/** Restore Artists in-page scroll after returning from artist detail. */
export function useArtistsBrowseScrollRestore({
serverId,
scrollBodyEl,
visibleCount,
loading,
loadingMore,
hasMore,
loadMore,
}: UseArtistsBrowseScrollRestoreArgs): UseArtistsBrowseScrollRestoreResult {
const navigationType = useNavigationType();
const location = useLocation();
const initRef = useRef(false);
const pendingRef = useRef<PendingScroll | null>(null);
const doneRef = useRef(false);
// React Compiler refs rule: ref used as a once-only init guard (checked before first assignment); not render data.
// eslint-disable-next-line react-hooks/refs
if (!initRef.current) {
initRef.current = true;
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
pendingRef.current = readPendingScrollRestore(serverId, navigationType, location.state);
}
const [isScrollRestorePending, setIsScrollRestorePending] = useState(
() => readPendingScrollRestore(serverId, navigationType, location.state) !== null,
);
// React Compiler immutability rule: intentional imperative mutation of an external/DOM target inside an effect.
// eslint-disable-next-line react-hooks/immutability
useLayoutEffect(() => {
const pending = pendingRef.current;
if (doneRef.current || !pending) return;
if (!scrollBodyEl || loading) return;
const needsMore = visibleCount < pending.visibleCount && hasMore;
if (needsMore) {
if (!loadingMore) loadMore();
return;
}
if (loadingMore) return;
// React Compiler immutability rule: intentional imperative mutation of an external/DOM target inside an effect.
// eslint-disable-next-line react-hooks/immutability
scrollBodyEl.scrollTop = pending.scrollTop;
scrollBodyEl.dispatchEvent(new Event('scroll', { bubbles: false }));
pendingRef.current = null;
doneRef.current = true;
// React Compiler set-state-in-effect rule: state set from a DOM/layout measurement.
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsScrollRestorePending(false);
useArtistBrowseSessionStore.getState().clearReturnStash(serverId);
}, [
scrollBodyEl,
visibleCount,
loading,
loadingMore,
hasMore,
loadMore,
serverId,
]);
return { isScrollRestorePending };
}
-94
View File
@@ -1,94 +0,0 @@
import { useMemo } from 'react';
import type { SubsonicArtist } from '../api/subsonicTypes';
import { usePlayerStore } from '../store/playerStore';
import { ALL_SENTINEL, artistLetterBucket, compareBuckets, type ArtistListFlatRow } from '../utils/componentHelpers/artistsHelpers';
interface UseArtistsFilteringArgs {
artists: SubsonicArtist[];
filter: string;
letterFilter: string;
starredOnly: boolean;
visibleCount: number;
viewMode: 'grid' | 'list';
/** Server `ignoredArticles` when known (local index); omit for Navidrome default. */
ignoredArticles?: string | null;
}
interface UseArtistsFilteringResult {
filtered: SubsonicArtist[];
visible: SubsonicArtist[];
hasMore: boolean;
groups: Record<string, SubsonicArtist[]>;
letters: string[];
artistListFlatRows: ArtistListFlatRow[];
}
/**
* Memoised filter + group pipeline for the artists page. Reading
* `starredOverrides` here keeps the star-toggle reactive without
* dragging the full player store through Artists.tsx props.
*
* Walking 5000+ artists per render was measurable — every cheap state
* update (selection mode, view mode, page size) used to re-filter the
* whole list. With this hook the three artist arrays
* (filtered → visible → flat-rows) only recompute when their explicit
* deps change.
*
* Group-by-letter and flat-row construction short-circuit when the user
* is on the grid view, since neither output is needed there.
*/
export function useArtistsFiltering({
artists,
filter,
letterFilter,
starredOnly,
visibleCount,
viewMode,
ignoredArticles,
}: UseArtistsFilteringArgs): UseArtistsFilteringResult {
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const filtered = useMemo(() => {
let out = artists;
if (letterFilter !== ALL_SENTINEL) {
out = out.filter(a => artistLetterBucket(a, ignoredArticles) === letterFilter);
}
if (filter) {
const needle = filter.toLowerCase();
out = out.filter(a => a.name.toLowerCase().includes(needle));
}
if (starredOnly) {
out = out.filter(a => a.id in starredOverrides ? starredOverrides[a.id] : !!a.starred);
}
return out;
}, [artists, letterFilter, filter, starredOnly, starredOverrides, ignoredArticles]);
const visible = useMemo(() => filtered.slice(0, visibleCount), [filtered, visibleCount]);
const hasMore = visibleCount < filtered.length;
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 key = artistLetterBucket(a, ignoredArticles);
if (!g[key]) g[key] = [];
g[key].push(a);
}
return { groups: g, letters: Object.keys(g).sort(compareBuckets) };
}, [visible, viewMode, ignoredArticles]);
const artistListFlatRows = useMemo((): ArtistListFlatRow[] => {
if (viewMode !== 'list') return [];
const out: ArtistListFlatRow[] = [];
for (const letter of letters) {
out.push({ kind: 'letter', letter });
const group = groups[letter];
for (let i = 0; i < group.length; i++) {
out.push({ kind: 'artist', artist: group[i], isLastInLetter: i === group.length - 1 });
}
}
return out;
}, [viewMode, letters, groups]);
return { filtered, visible, hasMore, groups, letters, artistListFlatRows };
}
-9
View File
@@ -1,9 +0,0 @@
/**
* @deprecated Import {@link useClientSliceInfiniteScroll} instead.
* Kept as a thin alias for existing call sites.
*/
export {
useClientSliceInfiniteScroll as useArtistsInfiniteScroll,
type UseClientSliceInfiniteScrollArgs as UseArtistsInfiniteScrollArgs,
type UseClientSliceInfiniteScrollResult as UseArtistsInfiniteScrollResult,
} from './useClientSliceInfiniteScroll';
-82
View File
@@ -1,82 +0,0 @@
import type { SubsonicArtist } from '../api/subsonicTypes';
import { useEffect, useRef, useState } from 'react';
import {
BROWSE_TEXT_DEBOUNCE_NETWORK_MS,
BROWSE_TEXT_DEBOUNCE_RACE_MS,
browseRaceCountsArtists,
raceBrowseWithLocalFallback,
runLocalBrowseArtists,
runNetworkBrowseArtists,
type LibrarySearchSurface,
} from '../utils/library/browseTextSearch';
import { useOfflineBrowseContext } from '@/features/offline';
import { offlineLocalBrowseEnabled, searchOfflineLocalArtists } from '@/features/offline';
/**
* Debounced artist/composer name search with local-vs-network race when the
* library index is enabled. Returns `textSearchArtists` when a raced query is
* active; callers should pass `effectiveFilter` (empty while raced) into their
* local filter hook so the query is not applied twice.
*/
export function useBrowseArtistTextSearch(
filter: string,
indexEnabled: boolean,
serverId: string | null | undefined,
surface: LibrarySearchSurface = 'artists_browse',
) {
const offlineBrowseActive = useOfflineBrowseContext().active;
const [debouncedFilter, setDebouncedFilter] = useState('');
const [textSearchArtists, setTextSearchArtists] = useState<SubsonicArtist[] | null>(null);
const [textSearchLoading, setTextSearchLoading] = useState(false);
const searchGenRef = useRef(0);
useEffect(() => {
const ms = indexEnabled ? BROWSE_TEXT_DEBOUNCE_RACE_MS : BROWSE_TEXT_DEBOUNCE_NETWORK_MS;
const timer = window.setTimeout(() => setDebouncedFilter(filter.trim()), ms);
return () => window.clearTimeout(timer);
}, [filter, indexEnabled]);
useEffect(() => {
const q = debouncedFilter;
if (!q || !indexEnabled || !serverId) {
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
// eslint-disable-next-line react-hooks/set-state-in-effect
setTextSearchArtists(null);
setTextSearchLoading(false);
return;
}
const gen = ++searchGenRef.current;
const isStale = () => gen !== searchGenRef.current;
setTextSearchLoading(true);
void (async () => {
if (offlineBrowseActive) {
const artists = offlineLocalBrowseEnabled(serverId)
? await searchOfflineLocalArtists(serverId, q)
: [];
if (isStale()) return;
setTextSearchArtists(artists);
setTextSearchLoading(false);
return;
}
const outcome = await raceBrowseWithLocalFallback(
isStale,
() => runLocalBrowseArtists(serverId, q),
() => runNetworkBrowseArtists(q),
{
surface,
query: q,
indexEnabled,
counts: browseRaceCountsArtists,
},
);
if (isStale()) return;
setTextSearchArtists(outcome?.result ?? null);
setTextSearchLoading(false);
})();
}, [debouncedFilter, indexEnabled, offlineBrowseActive, serverId, surface]);
const effectiveFilter = textSearchArtists != null ? '' : filter;
return { textSearchArtists, textSearchLoading, effectiveFilter };
}
-15
View File
@@ -1,15 +0,0 @@
import { useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { navigateToArtistDetail } from '../utils/navigation/albumDetailNavigation';
/** Navigate to artist detail, remembering the current page for the back button. */
export function useNavigateToArtist() {
const navigate = useNavigate();
const location = useLocation();
return useCallback(
(artistId: string, opts?: { search?: string }) => {
navigateToArtistDetail(navigate, location, artistId, opts);
},
[navigate, location],
);
}
+4 -1
View File
@@ -18,7 +18,10 @@ vi.mock('../utils/library/advancedSearchLocal', () => ({
runLocalSongBrowse: vi.fn(async () => []),
}));
vi.mock('@/features/offline', () => ({
// Only the reload-token hook was stubbed pre-move (its own module); mock that
// submodule directly so the barrel re-exports the stub while the real
// `useOfflineBrowseContext` (a different submodule) stays live.
vi.mock('@/features/offline/hooks/useOfflineBrowseReloadToken', () => ({
useOfflineBrowseReloadToken: () => undefined,
}));