feat(search): scoped live search on browse pages (#938)

* feat(artists): scoped live search badge replaces page filter

Move Artists browse text search into the header Live Search with a page
scope badge (Users icon), field-local undo, and double-click/backspace
to clear scope. Block the live-search dropdown while scoped so results
only filter the Artists grid; mobile overlay follows the same rules.

* fix(artists): plain grid for scoped search fixes broken card layout

Route the browse grid through VirtualCardGrid, switch to non-virtual CSS
grid when live search filters the catalog, reset scroll on filter changes,
and skip content-visibility on plain tiles to avoid blank/black cards.

* fix(search): scope badge double-Backspace and single clear control

Require two Backspaces on an empty scoped field after prior text input;
one Backspace still clears the badge when the field was never filled.
Move live-search clear/advanced controls inside the field pill, drop the
extra outer clear button, and use type=text to avoid native search clears.

* fix(search): drop duplicate outer live-search clear button

Keep the native in-field clear on type=search and the original pill layout;
remove only the extra × control outside the search border. Reset dropdown
state when the query is cleared via the native control.

* refactor(search): generic scoped browse query helper, drop dead code

Rename artistsBrowseSearchQuery to scopedBrowseSearchQuery with an
expectedScope argument; wire Artists via useScopedBrowseSearchQuery.
Remove unused liveSearchScoped dropdown helper (scoped mode blocks it).

* feat(search): ghost scope badge and single-click badge remove

After clearing the artists scope on /artists, show a faded ghost chip to
restore page-only search while keeping the global search placeholder.
Active badge removes on one click; tooltips and styles updated.

* feat(search): scoped live search for All Albums and New Releases

Wire albums and newReleases scope badges with debounced album title search
(local index title-only FTS + filtered search3). Plain grid, scroll reset,
and session query restore on album grid browse pages.

* fix(browse): preserve scroll restore after album/artist detail back

Only reset in-page scroll when filter resetKey changes, not when
isScrollRestorePending clears after session restore.

* feat(search): scoped live search for Tracks browse

Wire /tracks to header live search with wide title/artist/album FTS,
hide hero and discovery rails while search is active, and remove the
inline search field from the browse list.

* fix(search): clear header query when leaving scoped browse pages

Prevent global live search from firing on album/detail routes after a
scoped browse query; browse session stashes still restore on back.

* fix(tracks): restore scroll after back from detail during scoped search

Hold stashed song results across fetchSongPage churn, defer leave-stash
teardown past AppShell scroll reset, restore tracks scroll after the list
is ready, and save scroll snapshot when opening artist from song context menu.

* fix(tracks): hide discovery headings during scoped search

Hide the page subtitle and "Browse all tracks" section title when
tracks search is active, matching hero/rails chrome behavior.

* feat(search): scoped live search for Composers browse

Wire /composers to header live search with composers scope badge,
session stash, scroll restore, and plain grid/list during text filter.
Remove the in-page filter input; add i18n and navigation helpers.

* docs: CHANGELOG and credits for scoped browse live search (PR #938)
This commit is contained in:
cucadmuh
2026-06-01 13:04:36 +03:00
committed by GitHub
parent ddf10ee01d
commit 4ac373a65b
59 changed files with 2301 additions and 392 deletions
+4
View File
@@ -16,6 +16,7 @@ import {
} from '../store/albumBrowseSessionStore';
import type { AlbumBrowseSort } from '../utils/library/browseTextSearch';
import { shouldRestoreAlbumBrowseSession } from '../utils/navigation/albumDetailNavigation';
import { useLiveSearchScopeStore } from '../store/liveSearchScopeStore';
const ALBUMS_SURFACE: AlbumBrowseSurface = 'albums';
@@ -106,6 +107,7 @@ export function useAlbumBrowseFilters(
compFilter,
starredOnly,
losslessOnly,
searchQuery: useLiveSearchScopeStore.getState().query,
};
useEffect(() => {
@@ -119,6 +121,7 @@ export function useAlbumBrowseFilters(
restoredFromStashRef.current = true;
const restored = useAlbumBrowseSessionStore.getState().peekReturnStash(serverId, ALBUMS_SURFACE);
if (restored) {
useLiveSearchScopeStore.getState().setQuery(restored.searchQuery ?? '');
setSelectedGenres(restored.selectedGenres);
setYearFrom(restored.yearFrom);
setYearTo(restored.yearTo);
@@ -132,6 +135,7 @@ export function useAlbumBrowseFilters(
if (restoredFromStashRef.current) return;
useAlbumBrowseSessionStore.getState().clearReturnStash(serverId, ALBUMS_SURFACE);
useLiveSearchScopeStore.getState().setQuery('');
setSelectedGenres([]);
setYearFrom('');
setYearTo('');
+34
View File
@@ -0,0 +1,34 @@
import { useLayoutEffect, useRef, type RefObject } from 'react';
import type { AlbumBrowseScrollSnapshot } from './useAlbumBrowseFilters';
type Args = {
scrollSnapshotRef: RefObject<AlbumBrowseScrollSnapshot>;
getScrollRoot: () => HTMLElement | null;
isScrollRestorePending: boolean;
resetKey: string;
};
/** Scroll to top when browse filters shrink the album grid (e.g. scoped text search). */
export function useAlbumBrowseScrollReset({
scrollSnapshotRef,
getScrollRoot,
isScrollRestorePending,
resetKey,
}: 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;
}, [resetKey, isScrollRestorePending, getScrollRoot, scrollSnapshotRef]);
}
+13 -4
View File
@@ -10,6 +10,7 @@ import {
useAlbumBrowseSessionStore,
} from '../store/albumBrowseSessionStore';
import { shouldRestoreAlbumBrowseSession } from '../utils/navigation/albumDetailNavigation';
import { useLiveSearchScopeStore } from '../store/liveSearchScopeStore';
import {
inpageScrollViewportIdForSurface,
readInpageScrollTop,
@@ -53,8 +54,11 @@ export function useAlbumGridBrowseFilters(
const [selectedGenres, setSelectedGenres] = useState<string[]>(() => initialState.selectedGenres);
const restoredFromStashRef = useRef(false);
const filtersRef = useRef({ selectedGenres });
filtersRef.current = { selectedGenres };
const filtersRef = useRef({ selectedGenres, searchQuery: '' });
filtersRef.current = {
selectedGenres,
searchQuery: useLiveSearchScopeStore.getState().query,
};
useEffect(() => {
restoredFromStashRef.current = false;
@@ -66,8 +70,11 @@ export function useAlbumGridBrowseFilters(
if (shouldRestoreAlbumBrowseSession(navigationType, location.state)) {
restoredFromStashRef.current = true;
const restored = useAlbumBrowseSessionStore.getState().peekReturnStash(serverId, surface);
if (restored && !sameGenreSelection(restored.selectedGenres, filtersRef.current.selectedGenres)) {
setSelectedGenres(restored.selectedGenres);
if (restored) {
useLiveSearchScopeStore.getState().setQuery(restored.searchQuery ?? '');
if (!sameGenreSelection(restored.selectedGenres, filtersRef.current.selectedGenres)) {
setSelectedGenres(restored.selectedGenres);
}
}
return;
}
@@ -75,6 +82,7 @@ export function useAlbumGridBrowseFilters(
if (restoredFromStashRef.current) return;
useAlbumBrowseSessionStore.getState().clearReturnStash(serverId, surface);
useLiveSearchScopeStore.getState().setQuery('');
setSelectedGenres([]);
}, [serverId, surface, navigationType, location.state]);
@@ -93,6 +101,7 @@ export function useAlbumGridBrowseFilters(
useAlbumBrowseSessionStore.getState().stashReturnFilters(serverId, surface, {
...DEFAULT_ALBUM_BROWSE_RETURN_FILTERS,
selectedGenres: filtersRef.current.selectedGenres,
searchQuery: filtersRef.current.searchQuery,
scrollTop,
displayCount: gridSnapshot?.albums.length ?? scrollSnapshot?.displayCount,
albums: gridSnapshot?.albums,
+4 -8
View File
@@ -10,6 +10,7 @@ import {
} from '../store/artistBrowseSessionStore';
import { isArtistDetailPath } from '../store/albumBrowseSessionStore';
import { shouldRestoreArtistBrowseSession } from '../utils/navigation/albumDetailNavigation';
import { useLiveSearchScopeStore } from '../store/liveSearchScopeStore';
export type ArtistBrowseScrollSnapshot = {
scrollTop: number;
@@ -38,9 +39,6 @@ export function useArtistsBrowseFilters(
const location = useLocation();
const setShowArtistImages = useAuthStore(s => s.setShowArtistImages);
const [filter, setFilter] = useState(
() => returnStateForNavigation(serverId, navigationType, location.state).filter,
);
const [letterFilter, setLetterFilter] = useState(
() => returnStateForNavigation(serverId, navigationType, location.state).letterFilter,
);
@@ -56,7 +54,7 @@ export function useArtistsBrowseFilters(
const showArtistImages = useAuthStore(s => s.showArtistImages);
browseStateRef.current = {
filter,
filter: useLiveSearchScopeStore.getState().query,
letterFilter,
starredOnly,
viewMode,
@@ -74,7 +72,7 @@ export function useArtistsBrowseFilters(
restoredFromStashRef.current = true;
const restored = useArtistBrowseSessionStore.getState().peekReturnStash(serverId);
if (restored) {
setFilter(restored.filter);
useLiveSearchScopeStore.getState().setQuery(restored.filter);
setLetterFilter(restored.letterFilter);
setStarredOnly(restored.starredOnly);
setViewMode(restored.viewMode);
@@ -86,7 +84,7 @@ export function useArtistsBrowseFilters(
if (restoredFromStashRef.current) return;
useArtistBrowseSessionStore.getState().clearReturnStash(serverId);
setFilter('');
useLiveSearchScopeStore.getState().setQuery('');
setLetterFilter(DEFAULT_ARTIST_BROWSE_RETURN_STATE.letterFilter);
setStarredOnly(false);
setViewMode('grid');
@@ -110,8 +108,6 @@ export function useArtistsBrowseFilters(
}, [serverId, scrollSnapshotRef]);
return {
filter,
setFilter,
letterFilter,
setLetterFilter,
starredOnly,
+54
View File
@@ -0,0 +1,54 @@
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,
]);
}
+73
View File
@@ -0,0 +1,73 @@
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { useEffect, useRef, useState } from 'react';
import {
BROWSE_TEXT_DEBOUNCE_NETWORK_MS,
BROWSE_TEXT_DEBOUNCE_RACE_MS,
browseRaceCountsAlbums,
raceBrowseWithLocalFallback,
runLocalBrowseAlbums,
runNetworkBrowseAlbums,
} from '../utils/library/browseTextSearch';
/**
* Debounced album title search with local-vs-network race when the
* library index is enabled; network-only when it is not.
*/
export function useBrowseAlbumTextSearch(
filter: string,
indexEnabled: boolean,
serverId: string | null | undefined,
losslessOnly = false,
) {
const [debouncedFilter, setDebouncedFilter] = useState('');
const [textSearchAlbums, setTextSearchAlbums] = useState<SubsonicAlbum[] | 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 || !serverId) {
setTextSearchAlbums(null);
setTextSearchLoading(false);
return;
}
const gen = ++searchGenRef.current;
const isStale = () => gen !== searchGenRef.current;
setTextSearchLoading(true);
void (async () => {
if (!indexEnabled) {
const albums = await runNetworkBrowseAlbums(q);
if (isStale()) return;
setTextSearchAlbums(albums);
setTextSearchLoading(false);
return;
}
const outcome = await raceBrowseWithLocalFallback(
isStale,
() => runLocalBrowseAlbums(serverId, q, undefined, losslessOnly),
() => runNetworkBrowseAlbums(q),
{
surface: 'albums_browse',
query: q,
indexEnabled,
counts: browseRaceCountsAlbums,
},
);
if (isStale()) return;
setTextSearchAlbums(outcome?.result ?? null);
setTextSearchLoading(false);
})();
}, [debouncedFilter, indexEnabled, serverId, losslessOnly]);
const effectiveFilter = textSearchAlbums != null ? '' : filter;
return { textSearchAlbums, textSearchLoading, effectiveFilter };
}
+113
View File
@@ -0,0 +1,113 @@
import { useEffect, useRef, useState, type RefObject } from 'react';
import { useLocation, useNavigationType, type NavigationType } from 'react-router-dom';
import { isComposerDetailPath } from '../store/albumBrowseSessionStore';
import {
DEFAULT_COMPOSER_BROWSE_RETURN_STATE,
type ComposerBrowseReturnState,
type ComposerBrowseViewMode,
isComposersBrowsePath,
useComposerBrowseSessionStore,
} from '../store/composerBrowseSessionStore';
import { shouldRestoreComposerBrowseSession } from '../utils/navigation/albumDetailNavigation';
import { useLiveSearchScopeStore } from '../store/liveSearchScopeStore';
export type ComposerBrowseScrollSnapshot = {
scrollTop: number;
visibleCount: number;
};
function returnStateForNavigation(
serverId: string,
navigationType: NavigationType,
locationState: unknown,
): ComposerBrowseReturnState {
if (!shouldRestoreComposerBrowseSession(navigationType, locationState) || !serverId) {
return DEFAULT_COMPOSER_BROWSE_RETURN_STATE;
}
return (
useComposerBrowseSessionStore.getState().peekReturnStash(serverId)
?? DEFAULT_COMPOSER_BROWSE_RETURN_STATE
);
}
export function useComposersBrowseFilters(
serverId: string,
scrollSnapshotRef?: RefObject<ComposerBrowseScrollSnapshot>,
) {
const navigationType = useNavigationType();
const location = useLocation();
const [letterFilter, setLetterFilter] = useState(
() => returnStateForNavigation(serverId, navigationType, location.state).letterFilter,
);
const [starredOnly, setStarredOnly] = useState(
() => returnStateForNavigation(serverId, navigationType, location.state).starredOnly,
);
const [viewMode, setViewMode] = useState<ComposerBrowseViewMode>(
() => returnStateForNavigation(serverId, navigationType, location.state).viewMode,
);
const browseStateRef = useRef<ComposerBrowseReturnState>(DEFAULT_COMPOSER_BROWSE_RETURN_STATE);
const restoredFromStashRef = useRef(false);
browseStateRef.current = {
filter: useLiveSearchScopeStore.getState().query,
letterFilter,
starredOnly,
viewMode,
};
useEffect(() => {
restoredFromStashRef.current = false;
}, [serverId]);
useEffect(() => {
if (!serverId) return;
if (shouldRestoreComposerBrowseSession(navigationType, location.state)) {
restoredFromStashRef.current = true;
const restored = useComposerBrowseSessionStore.getState().peekReturnStash(serverId);
if (restored) {
useLiveSearchScopeStore.getState().setQuery(restored.filter);
setLetterFilter(restored.letterFilter);
setStarredOnly(restored.starredOnly);
setViewMode(restored.viewMode);
}
return;
}
if (restoredFromStashRef.current) return;
useComposerBrowseSessionStore.getState().clearReturnStash(serverId);
useLiveSearchScopeStore.getState().setQuery('');
setLetterFilter(DEFAULT_COMPOSER_BROWSE_RETURN_STATE.letterFilter);
setStarredOnly(false);
setViewMode('grid');
}, [serverId, navigationType, location.state]);
useEffect(() => {
return () => {
if (!serverId) return;
const path = window.location.pathname;
if (isComposerDetailPath(path)) {
const snapshot = scrollSnapshotRef?.current;
useComposerBrowseSessionStore.getState().stashReturnState(serverId, {
...browseStateRef.current,
scrollTop: snapshot?.scrollTop,
visibleCount: snapshot?.visibleCount,
});
} else if (!isComposersBrowsePath(path)) {
useComposerBrowseSessionStore.getState().clearReturnStash(serverId);
}
};
}, [serverId, scrollSnapshotRef]);
return {
letterFilter,
setLetterFilter,
starredOnly,
setStarredOnly,
viewMode,
setViewMode,
};
}
@@ -0,0 +1,91 @@
import { useLayoutEffect, useRef, useState } from 'react';
import { useLocation, useNavigationType, type NavigationType } from 'react-router-dom';
import {
peekComposerBrowseScrollRestore,
useComposerBrowseSessionStore,
} from '../store/composerBrowseSessionStore';
import { shouldRestoreComposerBrowseSession } from '../utils/navigation/albumDetailNavigation';
type PendingScroll = {
scrollTop: number;
visibleCount: number;
};
export type UseComposersBrowseScrollRestoreArgs = {
serverId: string;
scrollBodyEl: HTMLElement | null;
visibleCount: number;
loading: boolean;
loadingMore: boolean;
hasMore: boolean;
loadMore: () => void;
};
export type UseComposersBrowseScrollRestoreResult = {
isScrollRestorePending: boolean;
};
function readPendingScrollRestore(
serverId: string,
navigationType: NavigationType,
locationState: unknown,
): PendingScroll | null {
if (!shouldRestoreComposerBrowseSession(navigationType, locationState) || !serverId) return null;
return peekComposerBrowseScrollRestore(serverId);
}
/** Restore Composers in-page scroll after returning from composer detail. */
export function useComposersBrowseScrollRestore({
serverId,
scrollBodyEl,
visibleCount,
loading,
loadingMore,
hasMore,
loadMore,
}: UseComposersBrowseScrollRestoreArgs): UseComposersBrowseScrollRestoreResult {
const navigationType = useNavigationType();
const location = useLocation();
const initRef = useRef(false);
const pendingRef = useRef<PendingScroll | null>(null);
const doneRef = useRef(false);
if (!initRef.current) {
initRef.current = true;
pendingRef.current = readPendingScrollRestore(serverId, navigationType, location.state);
}
const [isScrollRestorePending, setIsScrollRestorePending] = useState(
() => readPendingScrollRestore(serverId, navigationType, location.state) !== null,
);
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;
scrollBodyEl.scrollTop = pending.scrollTop;
scrollBodyEl.dispatchEvent(new Event('scroll', { bubbles: false }));
pendingRef.current = null;
doneRef.current = true;
setIsScrollRestorePending(false);
useComposerBrowseSessionStore.getState().clearReturnStash(serverId);
}, [
scrollBodyEl,
visibleCount,
loading,
loadingMore,
hasMore,
loadMore,
serverId,
]);
return { isScrollRestorePending };
}
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it, beforeEach } from 'vitest';
import { useLiveSearchScopeStore } from '../store/liveSearchScopeStore';
import { syncLiveSearchRouteScope } from './useLiveSearchRouteScope';
describe('syncLiveSearchRouteScope', () => {
beforeEach(() => {
useLiveSearchScopeStore.setState({ query: '', scope: null, undoStack: [] });
});
it('activates scope on supported browse routes', () => {
syncLiveSearchRouteScope('/albums');
expect(useLiveSearchScopeStore.getState().scope).toBe('albums');
syncLiveSearchRouteScope('/tracks');
expect(useLiveSearchScopeStore.getState().scope).toBe('tracks');
syncLiveSearchRouteScope('/composers');
expect(useLiveSearchScopeStore.getState().scope).toBe('composers');
});
it('clears scope and query when leaving browse routes', () => {
useLiveSearchScopeStore.setState({ query: 'beatles', scope: 'albums' });
syncLiveSearchRouteScope('/album/abc123');
expect(useLiveSearchScopeStore.getState().scope).toBeNull();
expect(useLiveSearchScopeStore.getState().query).toBe('');
});
it('clears query when leaving browse with scope already cleared (ghost mode)', () => {
useLiveSearchScopeStore.setState({ query: 'beatles', scope: null });
syncLiveSearchRouteScope('/album/abc123');
expect(useLiveSearchScopeStore.getState().scope).toBeNull();
expect(useLiveSearchScopeStore.getState().query).toBe('');
});
it('preserves query when switching between browse routes', () => {
useLiveSearchScopeStore.setState({ query: 'jazz', scope: 'albums' });
syncLiveSearchRouteScope('/artists');
expect(useLiveSearchScopeStore.getState().scope).toBe('artists');
expect(useLiveSearchScopeStore.getState().query).toBe('jazz');
});
});
+36
View File
@@ -0,0 +1,36 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { isAlbumsBrowsePath, isNewReleasesBrowsePath } from '../store/albumBrowseSessionStore';
import { isArtistsBrowsePath } from '../store/artistBrowseSessionStore';
import { isTracksBrowsePath } from '../store/advancedSearchSessionStore';
import { isComposersBrowsePath } from '../store/composerBrowseSessionStore';
import { useLiveSearchScopeStore } from '../store/liveSearchScopeStore';
/** Keep scope badge in sync with browse routes; clear field text when leaving browse. */
export function syncLiveSearchRouteScope(pathname: string): void {
const store = useLiveSearchScopeStore.getState();
if (isArtistsBrowsePath(pathname)) {
store.setScope('artists');
} else if (isAlbumsBrowsePath(pathname)) {
store.setScope('albums');
} else if (isNewReleasesBrowsePath(pathname)) {
store.setScope('newReleases');
} else if (isTracksBrowsePath(pathname)) {
store.setScope('tracks');
} else if (isComposersBrowsePath(pathname)) {
store.setScope('composers');
} else {
if (store.scope != null) store.clearScope();
if (store.query !== '') store.setQuery('');
}
}
/** Activate the browse scope badge when a supported route is open; clear on leave. */
export function useLiveSearchRouteScope() {
const location = useLocation();
useEffect(() => {
syncLiveSearchRouteScope(location.pathname);
}, [location.pathname]);
}
+15
View File
@@ -0,0 +1,15 @@
import { useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { navigateToComposerDetail } from '../utils/navigation/albumDetailNavigation';
/** Navigate to composer detail, remembering the current page for the back button. */
export function useNavigateToComposer() {
const navigate = useNavigate();
const location = useLocation();
return useCallback(
(composerId: string, opts?: { search?: string }) => {
navigateToComposerDetail(navigate, location, composerId, opts);
},
[navigate, location],
);
}
+69
View File
@@ -0,0 +1,69 @@
// @vitest-environment jsdom
import { renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { SubsonicSong } from '../api/subsonicTypes';
import { useSongBrowseList } from './useSongBrowseList';
import { useAuthStore } from '../store/authStore';
import { useLibraryIndexStore } from '../store/libraryIndexStore';
vi.mock('../api/subsonicSearch', () => ({
searchSongsPaged: vi.fn(async () => []),
}));
vi.mock('../api/navidromeBrowse', () => ({
ndListSongs: vi.fn(async () => []),
}));
vi.mock('../utils/library/advancedSearchLocal', () => ({
runLocalSongBrowse: vi.fn(async () => []),
}));
vi.mock('../utils/library/browseTextSearch', () => ({
BROWSE_TEXT_DEBOUNCE_NETWORK_MS: 10,
BROWSE_TEXT_DEBOUNCE_RACE_MS: 10,
browseRaceCountsSongs: vi.fn(),
loadMoreLocalBrowseSongs: vi.fn(async () => []),
raceBrowseWithLocalFallback: vi.fn(async () => null),
runLocalBrowseSongPage: vi.fn(async () => []),
runNetworkBrowseSongPage: vi.fn(async () => [{ id: 'fresh' } as SubsonicSong]),
}));
const stashedSong = { id: 'stashed', title: 'Stashed', artist: 'A', duration: 180 } as SubsonicSong;
describe('useSongBrowseList restore hold', () => {
beforeEach(() => {
useAuthStore.setState({ activeServerId: 'srv-1' });
useLibraryIndexStore.setState({ masterEnabled: true });
});
it('keeps stashed songs after fetchSongPage identity changes until query edits', async () => {
const { result, rerender } = renderHook(
({ searchQuery }) => useSongBrowseList({
enabled: true,
searchQuery,
initialRestore: {
query: 'jazz',
songs: [stashedSong],
offset: 1,
hasMore: false,
localSearchMode: true,
browseUnsupported: false,
hasSearched: true,
},
}),
{ initialProps: { searchQuery: 'jazz' } },
);
expect(result.current.songs).toEqual([stashedSong]);
rerender({ searchQuery: 'jazz' });
await waitFor(() => {
expect(result.current.songs).toEqual([stashedSong]);
}, { timeout: 500 });
rerender({ searchQuery: 'jazzx' });
await waitFor(() => {
expect(result.current.songs[0]?.id).toBe('fresh');
}, { timeout: 500 });
});
});
+29 -12
View File
@@ -42,16 +42,19 @@ export type SongBrowseListRestore = {
type UseSongBrowseListArgs = {
enabled: boolean;
/** Header scoped browse query (wide title/artist/album search). */
searchQuery: string;
initialRestore?: SongBrowseListRestore | null;
};
/** Tracks hub song browse — all-library paging or filtered text search. */
export function useSongBrowseList({ enabled, initialRestore }: UseSongBrowseListArgs) {
export function useSongBrowseList({ enabled, searchQuery, initialRestore }: UseSongBrowseListArgs) {
const serverId = useAuthStore(s => s.activeServerId);
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
const [query, setQuery] = useState(() => initialRestore?.query ?? '');
const [debouncedQuery, setDebouncedQuery] = useState(() => initialRestore?.query.trim() ?? '');
const [debouncedQuery, setDebouncedQuery] = useState(
() => initialRestore?.query.trim() ?? searchQuery.trim(),
);
const [songs, setSongs] = useState<SubsonicSong[]>(() => initialRestore?.songs ?? []);
const [offset, setOffset] = useState(() => initialRestore?.offset ?? 0);
const [loading, setLoading] = useState(false);
@@ -63,14 +66,25 @@ export function useSongBrowseList({ enabled, initialRestore }: UseSongBrowseList
const requestSeqRef = useRef(0);
const localSearchModeRef = useRef(initialRestore?.localSearchMode ?? false);
const skipInitialFetchRef = useRef(initialRestore != null);
/** Keep stashed songs until the user edits the scoped query (survives fetchSongPage identity changes). */
const holdRestoredListRef = useRef(initialRestore != null);
const heldRestoredQueryRef = useRef(initialRestore?.query.trim() ?? '');
const restoreQueryHoldRef = useRef(
initialRestore?.query.trim() ? initialRestore.query.trim() : null,
);
useEffect(() => {
if (!enabled) return;
const incoming = searchQuery.trim();
if (incoming !== '') {
restoreQueryHoldRef.current = null;
}
const effectiveQuery = incoming || restoreQueryHoldRef.current || '';
const debounceMs = indexEnabled ? BROWSE_TEXT_DEBOUNCE_RACE_MS : BROWSE_TEXT_DEBOUNCE_NETWORK_MS;
const timer = window.setTimeout(() => setDebouncedQuery(query.trim()), debounceMs);
const timer = window.setTimeout(() => setDebouncedQuery(effectiveQuery), debounceMs);
return () => window.clearTimeout(timer);
}, [query, indexEnabled, enabled]);
}, [searchQuery, indexEnabled, enabled]);
const fetchSongPage = useCallback(
async (q: string, pageOffset: number, isStale: () => boolean): Promise<SubsonicSong[]> => {
@@ -114,9 +128,14 @@ export function useSongBrowseList({ enabled, initialRestore }: UseSongBrowseList
useEffect(() => {
if (!enabled) return;
if (skipInitialFetchRef.current) {
skipInitialFetchRef.current = false;
return;
if (holdRestoredListRef.current) {
const expected = heldRestoredQueryRef.current;
if (searchQuery.trim() !== expected || debouncedQuery !== expected) {
holdRestoredListRef.current = false;
} else {
return;
}
}
let cancelled = false;
@@ -152,7 +171,7 @@ export function useSongBrowseList({ enabled, initialRestore }: UseSongBrowseList
return () => {
cancelled = true;
};
}, [debouncedQuery, fetchSongPage, enabled]);
}, [debouncedQuery, searchQuery, fetchSongPage, enabled]);
const loadMore = useCallback(async () => {
if (!enabled || loading || !hasMore) return;
@@ -182,8 +201,6 @@ export function useSongBrowseList({ enabled, initialRestore }: UseSongBrowseList
}, [enabled, loading, hasMore, debouncedQuery, offset, fetchSongPage]);
return {
query,
setQuery,
songs,
offset,
loading,