refactor(search): extract LiveSearch query effect into a hook

This commit is contained in:
Psychotoxical
2026-06-30 23:53:23 +02:00
parent 0c1a9e249e
commit 97bd7afed1
2 changed files with 282 additions and 230 deletions
+12 -230
View File
@@ -1,34 +1,13 @@
import { subscribeLibrarySyncIdle, subscribeLibrarySyncProgress } from '@/lib/api/library';
import type { SearchResults } from '@/lib/api/subsonicTypes';
import { songToTrack } from '@/lib/media/songToTrack';
import {
LIVE_SEARCH_DEBOUNCE_NETWORK_MS,
LIVE_SEARCH_DEBOUNCE_RACE_MS,
EMPTY_SEARCH_RESULTS,
liveSearchQueryRejected,
mergeLiveSearchResults,
runLocalLiveSearch,
runNetworkLiveSearch,
} from '@/lib/library/liveSearchLocal';
import { raceLiveSearch } from '@/lib/library/searchRace';
import { libraryIsReady } from '@/lib/library/libraryReady';
import {
emitLiveSearchDebug,
searchHitCounts,
searchResultSamples,
} from '@/lib/library/liveSearchDebug';
import {
logLibrarySearch,
} from '@/lib/library/libraryDevLog';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useNavigateToAlbum } from '@/features/album';
import { Search, TextSearch } from 'lucide-react';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { useAuthStore } from '@/store/authStore';
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
import { useTranslation } from 'react-i18next';
import { useLiveSearchHeaderCollapse } from '@/features/search/hooks/useLiveSearchHeaderCollapse';
import { useLiveSearchQuery } from '@/features/search/hooks/useLiveSearchQuery';
import LiveSearchDropdown, { type LiveSearchSource } from '@/features/search/components/LiveSearchDropdown';
import { showToast } from '@/lib/dom/toast';
import { useShareSearch } from '@/features/search/hooks/useShareSearch';
@@ -47,7 +26,6 @@ import {
resolveLiveSearchScopeGhost,
} from '@/features/search/components/liveSearchScope';
import { useLiveSearchScopeStore } from '@/store/liveSearchScopeStore';
import { resolveIndexKey } from '@/lib/server/serverIndexKey';
export default function LiveSearch() {
const { t } = useTranslation();
@@ -66,7 +44,6 @@ export default function LiveSearch() {
const [activeIndex, setActiveIndex] = useState(-1);
const [isFocused, setIsFocused] = useState(false);
const [searchSource, setSearchSource] = useState<LiveSearchSource | null>(null);
const localReadyRef = useRef(false);
const liveSearchGenRef = useRef(0);
const navigate = useNavigate();
const navigateToAlbum = useNavigateToAlbum();
@@ -76,21 +53,6 @@ export default function LiveSearch() {
const dropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const isCollapsed = useLiveSearchHeaderCollapse(ref);
const serverId = useAuthStore(s => s.activeServerId);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
const refreshLocalReady = useCallback(async () => {
if (!serverId || !indexEnabled) {
localReadyRef.current = false;
return;
}
localReadyRef.current = await libraryIsReady(serverId);
}, [serverId, indexEnabled]);
useEffect(() => {
void refreshLocalReady();
}, [refreshLocalReady, musicLibraryFilterVersion]);
useEffect(() => {
resetLiveSearchScopeBackspaceState(scopeBackspaceRef.current);
@@ -100,27 +62,6 @@ export default function LiveSearch() {
noteLiveSearchScopeQueryInput(scopeBackspaceRef.current, query);
}, [query]);
useEffect(() => {
if (!indexEnabled || !serverId) return;
let unlistenProgress: (() => void) | undefined;
let unlistenIdle: (() => void) | undefined;
const indexKey = resolveIndexKey(serverId);
void subscribeLibrarySyncIdle(payload => {
if (payload.serverId === indexKey) void refreshLocalReady();
}).then(fn => {
unlistenIdle = fn;
});
void subscribeLibrarySyncProgress(p => {
if (p.serverId === indexKey && p.kind === 'phase_changed') void refreshLocalReady();
}).then(fn => {
unlistenProgress = fn;
});
return () => {
unlistenIdle?.();
unlistenProgress?.();
};
}, [indexEnabled, serverId, refreshLocalReady]);
const closeSearch = useCallback(() => {
setOpen(false);
setQuery('');
@@ -152,176 +93,17 @@ export default function LiveSearch() {
const share = useShareSearch(query, closeSearch);
useEffect(() => {
if (isLiveSearchDropdownBlocked(scope)) {
// 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
setResults(null);
setOpen(false);
setSearchSource(null);
setLoading(false);
return;
}
if (share.shareMatch) {
setResults(null);
setLoading(false);
setSearchSource(null);
setOpen(true);
setActiveIndex(-1);
return;
}
const q = query.trim();
if (!q) {
setResults(null);
setOpen(false);
setSearchSource(null);
setLoading(false);
return;
}
setSearchSource(null);
setActiveIndex(-1);
const abort = new AbortController();
const debounceMs = indexEnabled ? LIVE_SEARCH_DEBOUNCE_RACE_MS : LIVE_SEARCH_DEBOUNCE_NETWORK_MS;
const timer = window.setTimeout(() => {
void (async () => {
const gen = liveSearchGenRef.current;
const isStale = () =>
gen !== liveSearchGenRef.current || abort.signal.aborted;
if (isStale()) return;
setLoading(true);
const searchT0 = performance.now();
try {
if (liveSearchQueryRejected(q)) {
if (!isStale()) {
setResults(EMPTY_SEARCH_RESULTS);
setSearchSource(null);
setOpen(true);
}
return;
}
const raceCtx = { epoch: gen, isStale, suppressLog: indexEnabled && !!serverId };
if (indexEnabled && serverId) {
const winner = await raceLiveSearch(
() => runLocalLiveSearch(serverId, q, raceCtx),
() => runNetworkLiveSearch(q, abort.signal),
isStale,
meta => {
emitLiveSearchDebug('race_settled', {
query: q,
winner: meta.winner,
localMs: meta.localMs,
networkMs: meta.networkMs,
localHits: meta.localHits,
networkHits: meta.networkHits,
});
if (isStale()) return;
if (meta.localResult && meta.networkResult) {
const primary =
meta.winner === 'local' ? meta.localResult : meta.networkResult;
const supplement =
meta.winner === 'local' ? meta.networkResult : meta.localResult;
const merged = mergeLiveSearchResults(primary, supplement);
const primaryHits = searchHitCounts(primary);
const mergedHits = searchHitCounts(merged);
if (mergedHits !== primaryHits) {
setResults(merged);
setSearchSource(meta.winner);
emitLiveSearchDebug('race_merged', {
query: q,
winner: meta.winner,
before: primaryHits,
after: mergedHits,
samples: searchResultSamples(merged),
});
}
}
},
);
if (isStale()) return;
if (winner) {
setResults(winner.result);
setSearchSource(winner.source);
setOpen(true);
const samples = searchResultSamples(winner.result);
emitLiveSearchDebug('race_winner', {
query: q,
winner: winner.source,
raceMs: winner.durationMs,
hits: searchHitCounts(winner.result),
samples,
path: 'search_race',
localReady: localReadyRef.current,
});
logLibrarySearch({
at: new Date().toISOString(),
query: q,
path: 'search_race',
surface: 'live_search',
durationMs: Math.round(performance.now() - searchT0),
debounceMs,
indexEnabled,
localReadyCached: localReadyRef.current,
raceWinner: winner.source,
raceWinnerMs: winner.durationMs,
counts: {
artists: winner.result.artists.length,
albums: winner.result.albums.length,
songs: winner.result.songs.length,
},
});
return;
}
showToast(t('search.liveSearchFailed'), 3200, 'error');
} else if (serverId) {
const network = await runNetworkLiveSearch(q, abort.signal);
if (isStale()) return;
if (network) {
setResults(network);
setSearchSource('network');
setOpen(true);
logLibrarySearch({
at: new Date().toISOString(),
query: q,
path: 'search3',
surface: 'live_search',
source: 'network',
durationMs: Math.round(performance.now() - searchT0),
debounceMs,
indexEnabled,
counts: {
artists: network.artists.length,
albums: network.albums.length,
songs: network.songs.length,
},
});
}
}
} catch (err) {
if (isStale()) return;
const name = err instanceof Error ? err.name : '';
if (name === 'CanceledError' || name === 'AbortError') return;
showToast(t('search.liveSearchFailed'), 3200, 'error');
} finally {
if (!isStale()) setLoading(false);
}
})();
}, debounceMs);
return () => {
window.clearTimeout(timer);
abort.abort();
liveSearchGenRef.current += 1;
};
}, [query, scope, share.shareMatch, serverId, indexEnabled, musicLibraryFilterVersion, t]);
useLiveSearchQuery({
query,
scope,
shareMatch: share.shareMatch,
liveSearchGenRef,
setResults,
setOpen,
setLoading,
setSearchSource,
setActiveIndex,
});
const isSearchActive = isFocused || open || query.trim().length > 0 || scope != null;
@@ -0,0 +1,270 @@
import { useRef, useEffect, useCallback, type Dispatch, type SetStateAction, type MutableRefObject } from 'react';
import { useTranslation } from 'react-i18next';
import { subscribeLibrarySyncIdle, subscribeLibrarySyncProgress } from '@/lib/api/library';
import type { SearchResults } from '@/lib/api/subsonicTypes';
import {
LIVE_SEARCH_DEBOUNCE_NETWORK_MS,
LIVE_SEARCH_DEBOUNCE_RACE_MS,
EMPTY_SEARCH_RESULTS,
liveSearchQueryRejected,
mergeLiveSearchResults,
runLocalLiveSearch,
runNetworkLiveSearch,
} from '@/lib/library/liveSearchLocal';
import { raceLiveSearch } from '@/lib/library/searchRace';
import { libraryIsReady } from '@/lib/library/libraryReady';
import {
emitLiveSearchDebug,
searchHitCounts,
searchResultSamples,
} from '@/lib/library/liveSearchDebug';
import { logLibrarySearch } from '@/lib/library/libraryDevLog';
import { resolveIndexKey } from '@/lib/server/serverIndexKey';
import { showToast } from '@/lib/dom/toast';
import { useAuthStore } from '@/store/authStore';
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
import type { LiveSearchScope } from '@/store/liveSearchScopeStore';
import { isLiveSearchDropdownBlocked } from '@/features/search/components/liveSearchScope';
import type { useShareSearch } from '@/features/search/hooks/useShareSearch';
import type { LiveSearchSource } from '@/features/search/components/LiveSearchDropdown';
interface UseLiveSearchQueryParams {
query: string;
scope: LiveSearchScope | null;
shareMatch: ReturnType<typeof useShareSearch>['shareMatch'];
/** Generation counter for in-flight cancellation; the component also bumps it on route leave. */
liveSearchGenRef: MutableRefObject<number>;
setResults: Dispatch<SetStateAction<SearchResults | null>>;
setOpen: Dispatch<SetStateAction<boolean>>;
setLoading: Dispatch<SetStateAction<boolean>>;
setSearchSource: Dispatch<SetStateAction<LiveSearchSource | null>>;
setActiveIndex: Dispatch<SetStateAction<number>>;
}
/**
* Runs the debounced live-search query (local-index/network race, merge, logging) and
* pushes the results into the caller's state setters. Owns the local-ready cache that the
* race logging reads; the overlay/keyboard/render state stays in the component.
*/
export function useLiveSearchQuery({
query,
scope,
shareMatch,
liveSearchGenRef,
setResults,
setOpen,
setLoading,
setSearchSource,
setActiveIndex,
}: UseLiveSearchQueryParams) {
const { t } = useTranslation();
const serverId = useAuthStore(s => s.activeServerId);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
const localReadyRef = useRef(false);
const refreshLocalReady = useCallback(async () => {
if (!serverId || !indexEnabled) {
localReadyRef.current = false;
return;
}
localReadyRef.current = await libraryIsReady(serverId);
}, [serverId, indexEnabled]);
useEffect(() => {
void refreshLocalReady();
}, [refreshLocalReady, musicLibraryFilterVersion]);
useEffect(() => {
if (!indexEnabled || !serverId) return;
let unlistenProgress: (() => void) | undefined;
let unlistenIdle: (() => void) | undefined;
const indexKey = resolveIndexKey(serverId);
void subscribeLibrarySyncIdle(payload => {
if (payload.serverId === indexKey) void refreshLocalReady();
}).then(fn => {
unlistenIdle = fn;
});
void subscribeLibrarySyncProgress(p => {
if (p.serverId === indexKey && p.kind === 'phase_changed') void refreshLocalReady();
}).then(fn => {
unlistenProgress = fn;
});
return () => {
unlistenIdle?.();
unlistenProgress?.();
};
}, [indexEnabled, serverId, refreshLocalReady]);
useEffect(() => {
if (isLiveSearchDropdownBlocked(scope)) {
setResults(null);
setOpen(false);
setSearchSource(null);
setLoading(false);
return;
}
if (shareMatch) {
setResults(null);
setLoading(false);
setSearchSource(null);
setOpen(true);
setActiveIndex(-1);
return;
}
const q = query.trim();
if (!q) {
setResults(null);
setOpen(false);
setSearchSource(null);
setLoading(false);
return;
}
setSearchSource(null);
setActiveIndex(-1);
const abort = new AbortController();
const debounceMs = indexEnabled ? LIVE_SEARCH_DEBOUNCE_RACE_MS : LIVE_SEARCH_DEBOUNCE_NETWORK_MS;
const timer = window.setTimeout(() => {
void (async () => {
const gen = liveSearchGenRef.current;
const isStale = () =>
gen !== liveSearchGenRef.current || abort.signal.aborted;
if (isStale()) return;
setLoading(true);
const searchT0 = performance.now();
try {
if (liveSearchQueryRejected(q)) {
if (!isStale()) {
setResults(EMPTY_SEARCH_RESULTS);
setSearchSource(null);
setOpen(true);
}
return;
}
const raceCtx = { epoch: gen, isStale, suppressLog: indexEnabled && !!serverId };
if (indexEnabled && serverId) {
const winner = await raceLiveSearch(
() => runLocalLiveSearch(serverId, q, raceCtx),
() => runNetworkLiveSearch(q, abort.signal),
isStale,
meta => {
emitLiveSearchDebug('race_settled', {
query: q,
winner: meta.winner,
localMs: meta.localMs,
networkMs: meta.networkMs,
localHits: meta.localHits,
networkHits: meta.networkHits,
});
if (isStale()) return;
if (meta.localResult && meta.networkResult) {
const primary =
meta.winner === 'local' ? meta.localResult : meta.networkResult;
const supplement =
meta.winner === 'local' ? meta.networkResult : meta.localResult;
const merged = mergeLiveSearchResults(primary, supplement);
const primaryHits = searchHitCounts(primary);
const mergedHits = searchHitCounts(merged);
if (mergedHits !== primaryHits) {
setResults(merged);
setSearchSource(meta.winner);
emitLiveSearchDebug('race_merged', {
query: q,
winner: meta.winner,
before: primaryHits,
after: mergedHits,
samples: searchResultSamples(merged),
});
}
}
},
);
if (isStale()) return;
if (winner) {
setResults(winner.result);
setSearchSource(winner.source);
setOpen(true);
const samples = searchResultSamples(winner.result);
emitLiveSearchDebug('race_winner', {
query: q,
winner: winner.source,
raceMs: winner.durationMs,
hits: searchHitCounts(winner.result),
samples,
path: 'search_race',
localReady: localReadyRef.current,
});
logLibrarySearch({
at: new Date().toISOString(),
query: q,
path: 'search_race',
surface: 'live_search',
durationMs: Math.round(performance.now() - searchT0),
debounceMs,
indexEnabled,
localReadyCached: localReadyRef.current,
raceWinner: winner.source,
raceWinnerMs: winner.durationMs,
counts: {
artists: winner.result.artists.length,
albums: winner.result.albums.length,
songs: winner.result.songs.length,
},
});
return;
}
showToast(t('search.liveSearchFailed'), 3200, 'error');
} else if (serverId) {
const network = await runNetworkLiveSearch(q, abort.signal);
if (isStale()) return;
if (network) {
setResults(network);
setSearchSource('network');
setOpen(true);
logLibrarySearch({
at: new Date().toISOString(),
query: q,
path: 'search3',
surface: 'live_search',
source: 'network',
durationMs: Math.round(performance.now() - searchT0),
debounceMs,
indexEnabled,
counts: {
artists: network.artists.length,
albums: network.albums.length,
songs: network.songs.length,
},
});
}
}
} catch (err) {
if (isStale()) return;
const name = err instanceof Error ? err.name : '';
if (name === 'CanceledError' || name === 'AbortError') return;
showToast(t('search.liveSearchFailed'), 3200, 'error');
} finally {
if (!isStale()) setLoading(false);
}
})();
}, debounceMs);
return () => {
window.clearTimeout(timer);
abort.abort();
liveSearchGenRef.current += 1;
};
}, [
query, scope, shareMatch, serverId, indexEnabled, musicLibraryFilterVersion, t,
liveSearchGenRef, setResults, setOpen, setLoading, setSearchSource, setActiveIndex,
]);
}