mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
chore(eslint): add ESLint toolchain and clean src to strict 0/0 (#1165)
* chore(eslint): add eslint toolchain and configs * fix(eslint): resolve gradual-config errors (rules-of-hooks, no-empty, prefer-const, …) * chore(eslint): clear unused vars in config, contexts, app and test * chore(eslint): clear unused vars in api and music-network * chore(eslint): clear unused vars in utils * chore(eslint): clear unused vars in store * chore(eslint): clear unused vars in cover and hooks * chore(eslint): clear unused vars in components * chore(eslint): clear unused vars in pages * chore(eslint): remove explicit any in src * chore(eslint): align react-hooks exhaustive-deps * chore(eslint): zero gradual config on src * chore(eslint): strict hook rules in store and utils * chore(eslint): strict hook rules in hooks and cover * chore(eslint): strict hook rules in components * chore(eslint): strict hook rules in pages and contexts * chore(eslint): document scripts ignore in eslint config * chore(eslint): add lint script and zero strict findings * chore(eslint): address review round 1 (gradual 0/0, per-site disable reasons) * chore(eslint): tighten two set-state disable comments (review round 2 LOW) * chore(nix): sync npmDepsHash with package-lock.json * docs(changelog): add Under the Hood entry for ESLint setup (PR #1165) --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
@@ -184,11 +184,15 @@ export function useCliBridge(navigate: NavigateFunction) {
|
||||
}).catch(() => {});
|
||||
}
|
||||
}).then(u => unsubs.push(u));
|
||||
listen<any>('cli:player-command', async e => {
|
||||
listen<Record<string, unknown>>('cli:player-command', async e => {
|
||||
await executeCliPlayerCommand({ payload: e.payload ?? {}, navigate });
|
||||
}).then(u => unsubs.push(u));
|
||||
return () => {
|
||||
unsubs.forEach(u => u());
|
||||
};
|
||||
// Listeners registered once on mount; `navigate` is captured by closure and
|
||||
// stays valid (router navigation is not location-dependent), so the Tauri
|
||||
// event subscriptions are intentionally not torn down on every navigation.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}
|
||||
|
||||
@@ -30,5 +30,9 @@ export function useInAppKeybindings(navigate: NavigateFunction) {
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
// Registered once on mount; `navigate` is captured by closure and stays valid
|
||||
// (router navigation is not location-dependent), so re-binding the global
|
||||
// keydown listener on every navigation is intentionally avoided.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}
|
||||
|
||||
@@ -204,6 +204,8 @@ export function useAlbumBrowseData({
|
||||
const loadMoreRef = useRef<() => void>(() => {});
|
||||
const sentinelIntersectingRef = useRef(false);
|
||||
const browseModeRef = useRef(browseMode);
|
||||
// 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
|
||||
browseModeRef.current = browseMode;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -258,6 +260,10 @@ export function useAlbumBrowseData({
|
||||
setCatalogLoadingMore(false);
|
||||
}
|
||||
}
|
||||
// offlineBrowseActive is an intentional re-create trigger so the catalog
|
||||
// reloads from the right source when offline browse toggles; the loader reads
|
||||
// the active mode internally rather than referencing the flag directly here.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [indexEnabled, offlineBrowseActive, serverId, starredOverrides]);
|
||||
|
||||
const loadBrowse = useCallback(async (
|
||||
@@ -320,6 +326,8 @@ export function useAlbumBrowseData({
|
||||
catalogOffsetRef.current = 0;
|
||||
loadPendingRef.current = false;
|
||||
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
|
||||
setPage(0);
|
||||
setAlbums([]);
|
||||
setHasMore(true);
|
||||
@@ -383,10 +391,15 @@ export function useAlbumBrowseData({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// starredOverrides is read to seed star state during the load, but the browse
|
||||
// list must not reload on every star toggle — it is intentionally excluded.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [browseQuery, indexEnabled, offlineBrowseActive, offlineBrowseReloadTs, serverId, loadBrowse, musicLibraryFilterVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!genreCatalogActive) {
|
||||
// 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
|
||||
setGenreCatalogOptions(null);
|
||||
return;
|
||||
}
|
||||
@@ -452,6 +465,8 @@ export function useAlbumBrowseData({
|
||||
loadMorePage();
|
||||
}, [browseMode, loadMoreGrid, loadMorePage]);
|
||||
|
||||
// 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
|
||||
loadMoreRef.current = loadMore;
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -100,6 +100,8 @@ export function useAlbumBrowseFilters(
|
||||
const filtersRef = useRef<AlbumBrowseReturnFilters>(DEFAULT_ALBUM_BROWSE_RETURN_FILTERS);
|
||||
/** Guards against re-reset when `albumBrowseRestore` is cleared from location state. */
|
||||
const restoredFromStashRef = useRef(false);
|
||||
// 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
|
||||
filtersRef.current = {
|
||||
selectedGenres,
|
||||
yearFrom,
|
||||
@@ -122,6 +124,8 @@ export function useAlbumBrowseFilters(
|
||||
const restored = useAlbumBrowseSessionStore.getState().peekReturnStash(serverId, ALBUMS_SURFACE);
|
||||
if (restored) {
|
||||
useLiveSearchScopeStore.getState().setQuery(restored.searchQuery ?? '');
|
||||
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setSelectedGenres(restored.selectedGenres);
|
||||
setYearFrom(restored.yearFrom);
|
||||
setYearTo(restored.yearTo);
|
||||
@@ -149,6 +153,9 @@ export function useAlbumBrowseFilters(
|
||||
if (!serverId) return;
|
||||
const path = window.location.pathname;
|
||||
if (isAlbumDetailPath(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;
|
||||
const scrollTop = Math.max(
|
||||
readInpageScrollTop(ALBUMS_INPAGE_SCROLL_VIEWPORT_ID),
|
||||
|
||||
@@ -81,8 +81,12 @@ export function useAlbumBrowseScrollRestore({
|
||||
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,
|
||||
surface,
|
||||
@@ -96,6 +100,8 @@ export function useAlbumBrowseScrollRestore({
|
||||
() => readPendingScrollRestore(serverId, surface, genreName, 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;
|
||||
@@ -108,10 +114,14 @@ export function useAlbumBrowseScrollRestore({
|
||||
}
|
||||
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);
|
||||
clearScrollRestoreStash(serverId, surface, genreName);
|
||||
}, [
|
||||
|
||||
@@ -10,6 +10,8 @@ export function useAlbumDetailBack(fallback = '/') {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const locationStateRef = useRef(location.state);
|
||||
// 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
|
||||
locationStateRef.current = location.state;
|
||||
|
||||
const goBack = useCallback(
|
||||
|
||||
@@ -54,6 +54,8 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) 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
|
||||
setLoading(true);
|
||||
setRelatedAlbums([]);
|
||||
|
||||
|
||||
@@ -55,6 +55,8 @@ export function useAlbumGridBrowseFilters(
|
||||
const [selectedGenres, setSelectedGenres] = useState<string[]>(() => initialState.selectedGenres);
|
||||
const restoredFromStashRef = useRef(false);
|
||||
const filtersRef = useRef({ selectedGenres, searchQuery: '' });
|
||||
// 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
|
||||
filtersRef.current = {
|
||||
selectedGenres,
|
||||
searchQuery: useLiveSearchScopeStore.getState().query,
|
||||
@@ -91,7 +93,11 @@ export function useAlbumGridBrowseFilters(
|
||||
if (!serverId) return;
|
||||
const path = window.location.pathname;
|
||||
if (isAlbumDetailPath(path)) {
|
||||
// Read at cleanup time on purpose: we want the snapshots as they are at
|
||||
// navigation-away. Copying them at effect setup would stash stale values.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const scrollSnapshot = scrollSnapshotRef?.current;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const gridSnapshot = gridSnapshotRef?.current;
|
||||
const viewportId = inpageScrollViewportIdForSurface(surface);
|
||||
const scrollTop = Math.max(
|
||||
|
||||
@@ -70,7 +70,8 @@ export function useAlbumTrackListSelection({
|
||||
const to = Math.max(lastSelectedIdxRef.current, globalIdx);
|
||||
songs.slice(from, to + 1).forEach(s => next.add(s.id));
|
||||
} else {
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
}
|
||||
lastSelectedIdxRef.current = globalIdx;
|
||||
return next;
|
||||
|
||||
@@ -66,7 +66,6 @@ export function useAppUpdater() {
|
||||
clearTimeout(timer);
|
||||
window.removeEventListener('psysonic:preview-update', handler);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Clean up download listener when component unmounts
|
||||
|
||||
@@ -28,7 +28,7 @@ const mockArtistInfo = vi.mocked(getArtistInfo) as unknown as {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getTopSongs).mockResolvedValue([]);
|
||||
vi.mocked(search).mockResolvedValue({ songs: [], albums: [], artists: [] } as any);
|
||||
vi.mocked(search).mockResolvedValue({ songs: [], albums: [], artists: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -49,7 +49,7 @@ function deferred<T>() {
|
||||
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: [] } as any
|
||||
{ artist: { id, name: id }, albums: [] }
|
||||
));
|
||||
const a = deferred<SubsonicArtistInfo | null>();
|
||||
const b = deferred<SubsonicArtistInfo | null>();
|
||||
@@ -83,7 +83,7 @@ describe('useArtistDetailData — id-gated info', () => {
|
||||
// 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: [] } as any);
|
||||
vi.mocked(getArtist).mockResolvedValue({ artist: { id: 'A', name: 'A' }, albums: [] });
|
||||
vi.mocked(search).mockResolvedValue({
|
||||
artists: [],
|
||||
albums: [],
|
||||
@@ -99,7 +99,7 @@ describe('useArtistDetailData — id-gated info', () => {
|
||||
displayAlbumArtist: 'Various Artists',
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useArtistDetailData('A'), { wrapper: routerWrapper });
|
||||
|
||||
@@ -112,7 +112,7 @@ describe('useArtistDetailData — id-gated info', () => {
|
||||
|
||||
it('ignores a late-arriving resolve for a stale id', async () => {
|
||||
vi.mocked(getArtist).mockImplementation(async (id) => (
|
||||
{ artist: { id, name: id }, albums: [] } as any
|
||||
{ artist: { id, name: id }, albums: [] }
|
||||
));
|
||||
const a = deferred<SubsonicArtistInfo | null>();
|
||||
mockArtistInfo.mockImplementation(async (id) => {
|
||||
|
||||
@@ -78,6 +78,8 @@ export function useArtistDetailData(
|
||||
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([]);
|
||||
@@ -174,6 +176,8 @@ export function useArtistDetailData(
|
||||
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 => {
|
||||
@@ -191,6 +195,8 @@ export function useArtistDetailData(
|
||||
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: [] }))
|
||||
|
||||
@@ -36,6 +36,8 @@ export function useArtistInfoBatch(
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverId || ids.length === 0) {
|
||||
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setById({});
|
||||
return;
|
||||
}
|
||||
@@ -79,6 +81,9 @@ export function useArtistInfoBatch(
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
@@ -31,6 +31,8 @@ export function useArtistSimilarArtists(
|
||||
|
||||
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 => {
|
||||
@@ -62,6 +64,8 @@ export function useArtistSimilarArtists(
|
||||
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 => {
|
||||
@@ -84,6 +88,9 @@ export function useArtistSimilarArtists(
|
||||
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,
|
||||
@@ -97,6 +104,8 @@ export function useArtistSimilarArtists(
|
||||
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 effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setSimilarArtists([]);
|
||||
setSimilarLoading(false);
|
||||
}
|
||||
|
||||
@@ -102,6 +102,8 @@ export function useArtistsBrowseCatalog({
|
||||
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);
|
||||
|
||||
@@ -53,6 +53,8 @@ export function useArtistsBrowseFilters(
|
||||
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,
|
||||
@@ -73,6 +75,8 @@ export function useArtistsBrowseFilters(
|
||||
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 effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setLetterFilter(restored.letterFilter);
|
||||
setStarredOnly(restored.starredOnly);
|
||||
setViewMode(restored.viewMode);
|
||||
@@ -95,6 +99,9 @@ export function useArtistsBrowseFilters(
|
||||
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,
|
||||
|
||||
@@ -50,8 +50,12 @@ export function useArtistsBrowseScrollRestore({
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -59,6 +63,8 @@ export function useArtistsBrowseScrollRestore({
|
||||
() => 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;
|
||||
@@ -71,10 +77,14 @@ export function useArtistsBrowseScrollRestore({
|
||||
}
|
||||
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);
|
||||
}, [
|
||||
|
||||
@@ -64,6 +64,8 @@ export function useAudioDevicesProbe(t: TFunction): UseAudioDevicesProbeResult {
|
||||
|
||||
useEffect(() => {
|
||||
if (IS_MACOS) 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
|
||||
refreshAudioDevices();
|
||||
}, [refreshAudioDevices]);
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ export function useBrowseAlbumTextSearch(
|
||||
useEffect(() => {
|
||||
const q = debouncedFilter;
|
||||
if (!q || !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
|
||||
setTextSearchAlbums(null);
|
||||
setTextSearchLoading(false);
|
||||
return;
|
||||
|
||||
@@ -39,6 +39,8 @@ export function useBrowseArtistTextSearch(
|
||||
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;
|
||||
|
||||
@@ -41,7 +41,7 @@ export function useCardGridMetrics(
|
||||
const ro = new ResizeObserver(onResize);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [observerEnabled, variant, layoutSignal, maxCols]);
|
||||
}, [observerEnabled, variant, layoutSignal, maxCols, measureRef]);
|
||||
|
||||
return { gridCols, rowHeightEst };
|
||||
}
|
||||
|
||||
@@ -53,10 +53,14 @@ export function useClientSliceInfiniteScroll({
|
||||
|
||||
useEffect(() => {
|
||||
loadPendingRef.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
|
||||
setLoadingMore(false);
|
||||
}, [visibleCount]);
|
||||
|
||||
useEffect(() => {
|
||||
// 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
|
||||
setVisibleCount(sliceVisibleCount(pageSize, restoreDisplayCount));
|
||||
// resetDeps is intentionally spread into the dep array.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -50,6 +50,8 @@ export function useComposersBrowseFilters(
|
||||
const browseStateRef = useRef<ComposerBrowseReturnState>(DEFAULT_COMPOSER_BROWSE_RETURN_STATE);
|
||||
const restoredFromStashRef = useRef(false);
|
||||
|
||||
// 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,
|
||||
@@ -69,6 +71,8 @@ export function useComposersBrowseFilters(
|
||||
const restored = useComposerBrowseSessionStore.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 effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setLetterFilter(restored.letterFilter);
|
||||
setStarredOnly(restored.starredOnly);
|
||||
setViewMode(restored.viewMode);
|
||||
@@ -90,6 +94,9 @@ export function useComposersBrowseFilters(
|
||||
if (!serverId) return;
|
||||
const path = window.location.pathname;
|
||||
if (isComposerDetailPath(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;
|
||||
useComposerBrowseSessionStore.getState().stashReturnState(serverId, {
|
||||
...browseStateRef.current,
|
||||
|
||||
@@ -50,8 +50,12 @@ export function useComposersBrowseScrollRestore({
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -59,6 +63,8 @@ export function useComposersBrowseScrollRestore({
|
||||
() => 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;
|
||||
@@ -71,10 +77,14 @@ export function useComposersBrowseScrollRestore({
|
||||
}
|
||||
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);
|
||||
useComposerBrowseSessionStore.getState().clearReturnStash(serverId);
|
||||
}, [
|
||||
|
||||
@@ -97,6 +97,8 @@ export function useConnectionStatus() {
|
||||
prevDevForceOfflineRef.current = devForceOffline;
|
||||
if (devForceOffline) {
|
||||
setActiveServerReachable(false);
|
||||
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setStatus('disconnected');
|
||||
}
|
||||
return;
|
||||
@@ -123,6 +125,8 @@ export function useConnectionStatus() {
|
||||
}
|
||||
if (isDevOfflineBrowseForced()) {
|
||||
setActiveServerReachable(false);
|
||||
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setStatus('disconnected');
|
||||
} else {
|
||||
setActiveServerReachable(true);
|
||||
|
||||
@@ -54,6 +54,8 @@ export function useDeviceSyncBrowser(
|
||||
|
||||
useEffect(() => {
|
||||
resetSearch();
|
||||
// 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
|
||||
if (activeTab === 'playlists' && playlists.length === 0) loadPlaylists();
|
||||
if (activeTab === 'albums' && randomAlbums.length === 0) loadRandomAlbums();
|
||||
if (activeTab === 'artists' && artists.length === 0) loadArtists();
|
||||
@@ -64,6 +66,8 @@ export function useDeviceSyncBrowser(
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'albums') return;
|
||||
const q = search.trim();
|
||||
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (!q) { setAlbumSearchResults([]); return; }
|
||||
setAlbumSearchLoading(true);
|
||||
const timer = setTimeout(async () => {
|
||||
|
||||
@@ -28,6 +28,8 @@ export function useDeviceSyncDrives(targetDir: string | null): DeviceSyncDrivesR
|
||||
|
||||
// Fetch drives on mount, then poll every 5 seconds
|
||||
useEffect(() => {
|
||||
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
refreshDrives();
|
||||
const interval = setInterval(refreshDrives, 5000);
|
||||
return () => clearInterval(interval);
|
||||
|
||||
@@ -21,6 +21,8 @@ export function useDeviceSyncSourceStatuses(
|
||||
// Compute expected paths for each source (for status comparison)
|
||||
useEffect(() => {
|
||||
if (!targetDir || sources.length === 0) {
|
||||
// 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
|
||||
setSourcePathsMap(new Map());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ export function useFolderBrowserNowPlayingPath({
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentTrack?.id) {
|
||||
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setPlayingPathIds([]);
|
||||
return;
|
||||
}
|
||||
@@ -50,6 +52,8 @@ export function useFolderBrowserNowPlayingPath({
|
||||
const leafItem = leafColumn?.items.find(it => it.id === lastSelectedId);
|
||||
if (!leafItem || leafItem.isDir || leafItem.id !== currentTrack.id) return;
|
||||
|
||||
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setPlayingPathIds(prev => {
|
||||
if (
|
||||
prev.length === selectedChain.length &&
|
||||
|
||||
@@ -23,6 +23,8 @@ export function useFsIdleFade(onEscape: () => void) {
|
||||
}, [resetIdle]);
|
||||
|
||||
useEffect(() => {
|
||||
// React Compiler set-state-in-effect rule: state set from an external subscription/event callback.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
resetIdle();
|
||||
return () => { if (idleTimer.current) clearTimeout(idleTimer.current); };
|
||||
}, [resetIdle]);
|
||||
|
||||
@@ -41,7 +41,11 @@ export function useGenreAlbumBrowse(
|
||||
const loadMoreRef = useRef<() => void>(() => {});
|
||||
const browseSessionRef = useRef({ key: '', restoreDisplayCount: undefined as number | undefined });
|
||||
const browseKey = `${serverId}:${genre}`;
|
||||
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
if (browseSessionRef.current.key !== browseKey) {
|
||||
// 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
|
||||
browseSessionRef.current = {
|
||||
key: browseKey,
|
||||
restoreDisplayCount: restoreDisplayCount,
|
||||
@@ -53,6 +57,8 @@ export function useGenreAlbumBrowse(
|
||||
visibleCount,
|
||||
loadingMore: sliceLoadingMore,
|
||||
loadMore: sliceLoadMore,
|
||||
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
} = useClientSliceInfiniteScroll({
|
||||
pageSize: CLIENT_SLICE_PAGE_SIZE,
|
||||
resetDeps: [
|
||||
@@ -64,6 +70,8 @@ export function useGenreAlbumBrowse(
|
||||
],
|
||||
getScrollRoot,
|
||||
scrollRootEl,
|
||||
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
restoreDisplayCount: sessionRestoreDisplayCount,
|
||||
});
|
||||
|
||||
@@ -115,6 +123,8 @@ export function useGenreAlbumBrowse(
|
||||
|
||||
useEffect(() => {
|
||||
if (!genre) {
|
||||
// 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
|
||||
setAlbums([]);
|
||||
setCatalogHasMore(false);
|
||||
setLoading(false);
|
||||
@@ -144,6 +154,9 @@ export function useGenreAlbumBrowse(
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// sessionRestoreDisplayCount is read once to restore the prior visible count;
|
||||
// the catalog load must not re-run when it later changes, so it is excluded.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [serverId, genre, indexEnabled, sort, musicLibraryFilterVersion, loadCatalogChunk]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
@@ -157,6 +170,8 @@ export function useGenreAlbumBrowse(
|
||||
}
|
||||
}, [genre, visibleCount, albums.length, catalogHasMore, sliceLoadMore, loadCatalogChunk]);
|
||||
|
||||
// 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
|
||||
loadMoreRef.current = loadMore;
|
||||
|
||||
const bindLoadMoreSentinel = useInpageScrollSentinel({
|
||||
|
||||
@@ -28,8 +28,14 @@ export function useGenreDetailBrowse(
|
||||
const restoreKeyRef = useRef('');
|
||||
const restoreDisplayCountRef = useRef<number | undefined>(undefined);
|
||||
const restoreKey = `${serverId}:${genreName}`;
|
||||
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
if (restoreKeyRef.current !== restoreKey) {
|
||||
// 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
|
||||
restoreKeyRef.current = restoreKey;
|
||||
// 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
|
||||
restoreDisplayCountRef.current = peekGenreDetailScrollRestore(serverId, genreName)?.displayCount;
|
||||
}
|
||||
|
||||
@@ -55,6 +61,9 @@ export function useGenreDetailBrowse(
|
||||
if (!serverId || !genreName) return;
|
||||
const path = window.location.pathname;
|
||||
if (isAlbumDetailPath(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;
|
||||
const scrollTop = Math.max(
|
||||
readInpageScrollTop(GENRE_DETAIL_INPAGE_SCROLL_VIEWPORT_ID),
|
||||
@@ -74,6 +83,8 @@ export function useGenreDetailBrowse(
|
||||
|
||||
return {
|
||||
sort,
|
||||
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
restoreDisplayCount: restoreDisplayCountRef.current,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ export function useInpageScrollSentinel({
|
||||
intersectingRef,
|
||||
}: UseInpageScrollSentinelArgs): RefCallback<HTMLDivElement | null> {
|
||||
const onIntersectRef = useRef(onIntersect);
|
||||
// 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
|
||||
onIntersectRef.current = onIntersect;
|
||||
|
||||
const setIntersecting = useCallback((hit: boolean) => {
|
||||
@@ -65,6 +67,10 @@ export function useInpageScrollSentinel({
|
||||
);
|
||||
observer.observe(node);
|
||||
observerInst.current = observer;
|
||||
// scrollRootEl is an intentional re-create trigger: when the resolved scroll
|
||||
// root element changes the sentinel must re-bind its observer to the new root,
|
||||
// even though the body reads it via getScrollRoot() rather than directly.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [active, getScrollRoot, scrollRootEl, rootMargin, setIntersecting]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -66,6 +66,10 @@ export function useLibraryAnalysisBackfill(enabled = true): void {
|
||||
});
|
||||
|
||||
return disable;
|
||||
// Keyed on the server's primitive fields (url/username/password); depending
|
||||
// on the `server` object would restart the backfill on every render when its
|
||||
// identity changes but its connection fields do not.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
enabled,
|
||||
strategy,
|
||||
|
||||
@@ -87,6 +87,10 @@ export function useLibraryCoverBackfill(enabled = true): void {
|
||||
})();
|
||||
|
||||
return disable;
|
||||
// Keyed on the server's primitive fields; depending on the `server` object
|
||||
// would restart the backfill on every render when its identity changes but
|
||||
// its connection fields do not.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled, strategy, activeServerId, server?.url, server?.username, server?.password]);
|
||||
|
||||
// Connect-URL flip: push the new reachable address live. The native worker
|
||||
@@ -104,5 +108,8 @@ export function useLibraryCoverBackfill(enabled = true): void {
|
||||
return;
|
||||
}
|
||||
void libraryCoverBackfillSetBaseUrl(coverCacheRestHost(connectBaseUrl));
|
||||
// Keyed on connectBaseUrl / server?.url; the `server` object guard does not
|
||||
// need to retrigger this base-URL push on every render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [connectBaseUrl, enabled, strategy, activeServerId, server?.url]);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ export function useLibraryIgnoredArticles(
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !serverId) {
|
||||
// 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
|
||||
setIgnoredArticles(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ export function useLibraryIndexSync() {
|
||||
const refreshAllStatuses = useCallback(async () => {
|
||||
if (!masterEnabled || indexedServers.length === 0) return;
|
||||
const entries = await Promise.all(
|
||||
indexedServers.map(async ({ key, server }) => {
|
||||
indexedServers.map(async ({ key }) => {
|
||||
try {
|
||||
const fresh = await libraryGetStatus(key);
|
||||
syncPhaseRef.current[key] = fresh.syncPhase;
|
||||
@@ -127,6 +127,8 @@ export function useLibraryIndexSync() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!masterEnabled || indexedKeys.length === 0) return;
|
||||
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
void runBootstrap();
|
||||
}, [masterEnabled, indexedKeys.join(',')]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -144,6 +146,10 @@ export function useLibraryIndexSync() {
|
||||
if (pollTimer.current) clearTimeout(pollTimer.current);
|
||||
pollTimer.current = null;
|
||||
};
|
||||
// indexedKeys is derived from indexedServers (already a dep); the poll loop is
|
||||
// keyed on the server set, not on the recomputed key array, to avoid
|
||||
// restarting the poll on every render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [masterEnabled, indexedServers, refreshAllStatuses]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -93,6 +93,8 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult {
|
||||
// show nothing — not even embedded/cache (issue #810). LyricsPane surfaces
|
||||
// the "no sources selected" hint.
|
||||
if (!lyricsActive) {
|
||||
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setSyncedLines(null);
|
||||
setWordLines(null);
|
||||
setPlainLyrics(null);
|
||||
|
||||
@@ -30,6 +30,8 @@ export function useMainstageInpageHeaderTight(
|
||||
}, [scrollBodyEl]);
|
||||
|
||||
useEffect(() => {
|
||||
// React Compiler set-state-in-effect rule: state set from an external subscription/event callback.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setTight(false);
|
||||
// Spread values so deps track filter keys, not a new array identity each render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -141,7 +141,7 @@ describe('useMigrationOrchestrator', () => {
|
||||
analysis: { totalLegacyRows: 0, skippedUnknownServerRows: 0, tables: {} },
|
||||
mappings: [{ legacyId: 'legacy-a', indexKey: 'a.test' }],
|
||||
});
|
||||
let resolveGenre: ((value: any) => void) | undefined;
|
||||
let resolveGenre: ((value: unknown) => void) | undefined;
|
||||
libraryGenreTagsInspectMock.mockImplementation(
|
||||
() => new Promise(resolve => { resolveGenre = resolve; }),
|
||||
);
|
||||
@@ -165,7 +165,7 @@ describe('useMigrationOrchestrator', () => {
|
||||
|
||||
it('keeps startup non-blocking while done-flag precheck is pending', async () => {
|
||||
localStorage.setItem(DONE_FLAG, '1');
|
||||
let resolveInspect: ((value: any) => void) | undefined;
|
||||
let resolveInspect: ((value: unknown) => void) | undefined;
|
||||
migrationInspectMock.mockImplementation(
|
||||
() => new Promise(resolve => { resolveInspect = resolve; }),
|
||||
);
|
||||
|
||||
@@ -126,7 +126,7 @@ async function runOrchestrator(force = false): Promise<void> {
|
||||
const after = await migrationInspect(mappings);
|
||||
state.setInspect(after);
|
||||
state.setNeedsMigration(after.needsMigration);
|
||||
skippedLogged = logSkippedUnknownRowsOnce(after, skippedLogged);
|
||||
logSkippedUnknownRowsOnce(after, skippedLogged);
|
||||
if (!after.needsMigration) {
|
||||
localStorage.setItem(MIGRATION_DONE_FLAG, '1');
|
||||
await runGenreTagsPhase();
|
||||
|
||||
@@ -29,6 +29,8 @@ export function useMiniQueueDrag({
|
||||
useEffect(() => {
|
||||
if (!isPsyDragging) {
|
||||
dropTargetRef.current = null;
|
||||
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setDropTarget(null);
|
||||
}
|
||||
}, [isPsyDragging]);
|
||||
@@ -44,7 +46,7 @@ export function useMiniQueueDrag({
|
||||
const onPsyDrop = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (!detail?.data) return;
|
||||
let parsed: any = null;
|
||||
let parsed: { type?: string; index?: number };
|
||||
try { parsed = JSON.parse(detail.data); } catch { return; }
|
||||
const tgt = dropTargetRef.current;
|
||||
dropTargetRef.current = null;
|
||||
@@ -74,7 +76,7 @@ export function useMiniQueueDrag({
|
||||
const cx = d.clientX;
|
||||
const cy = d.clientY;
|
||||
if (typeof cx !== 'number' || typeof cy !== 'number') return;
|
||||
let parsed: { type?: string; index?: number } | null = null;
|
||||
let parsed: { type?: string; index?: number };
|
||||
try {
|
||||
parsed = JSON.parse(d.data);
|
||||
} catch {
|
||||
|
||||
@@ -36,6 +36,8 @@ export function useNavidromeAdminRole(): NavidromeAdminRole {
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn || !server) {
|
||||
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setRole('na');
|
||||
return;
|
||||
}
|
||||
@@ -63,6 +65,10 @@ export function useNavidromeAdminRole(): NavidromeAdminRole {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// Keyed on the server's and identity's primitive fields; depending on the
|
||||
// `server` / `identity` objects would re-probe the admin role on every render
|
||||
// when their identities change but their fields do not.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
isLoggedIn,
|
||||
activeServerId,
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { SubsonicArtistInfo, SubsonicSong, SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import type { SubsonicArtistInfo, SubsonicSong, SubsonicAlbum, SubsonicArtist } from '../api/subsonicTypes';
|
||||
|
||||
vi.mock('../api/subsonicArtists');
|
||||
vi.mock('../api/subsonicLibrary');
|
||||
@@ -26,6 +26,11 @@ import { getAlbumForServer, getSongForServer } from '../api/subsonicLibrary';
|
||||
import { fetchBandsintownEvents } from '../api/bandsintown';
|
||||
import { useNowPlayingFetchers, type NowPlayingFetchersDeps } from './useNowPlayingFetchers';
|
||||
|
||||
// Resolved return shapes of the mocked API calls — used to cast deliberately
|
||||
// partial test fixtures without `any`.
|
||||
type ArtistForServer = Awaited<ReturnType<typeof getArtistForServer>>;
|
||||
type AlbumForServer = Awaited<ReturnType<typeof getAlbumForServer>>;
|
||||
|
||||
// The real getArtistInfo signature returns `Promise<SubsonicArtistInfo>`, but
|
||||
// the hook treats `null` as the "no info available" case and stores it as
|
||||
// such in its tuple. The tests mirror that — cast to a nullable-returning
|
||||
@@ -50,7 +55,7 @@ const baseDeps: NowPlayingFetchersDeps = {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getTopSongsForServer).mockResolvedValue([]);
|
||||
vi.mocked(getArtistForServer).mockResolvedValue({ albums: [] } as any);
|
||||
vi.mocked(getArtistForServer).mockResolvedValue({ albums: [] } as unknown as ArtistForServer);
|
||||
vi.mocked(fetchBandsintownEvents).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
@@ -161,9 +166,9 @@ describe('useNowPlayingFetchers — id-gated songMeta / albumData / discography'
|
||||
const al1 = deferred<{ album: SubsonicAlbum; songs: SubsonicSong[] } | null>();
|
||||
const al2 = deferred<{ album: SubsonicAlbum; songs: SubsonicSong[] } | null>();
|
||||
vi.mocked(getAlbumForServer).mockImplementation(async (_sid, id) => {
|
||||
if (id === 'alb1') return al1.promise as any;
|
||||
if (id === 'alb2') return al2.promise as any;
|
||||
return null as any;
|
||||
if (id === 'alb1') return al1.promise as unknown as AlbumForServer;
|
||||
if (id === 'alb2') return al2.promise as unknown as AlbumForServer;
|
||||
return null as unknown as AlbumForServer;
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
@@ -183,12 +188,12 @@ describe('useNowPlayingFetchers — id-gated songMeta / albumData / discography'
|
||||
});
|
||||
|
||||
it('gates discography on artistId match (empty fallback while gated)', async () => {
|
||||
const d1 = deferred<{ artist: any; albums: SubsonicAlbum[] }>();
|
||||
const d2 = deferred<{ artist: any; albums: SubsonicAlbum[] }>();
|
||||
const d1 = deferred<{ artist: Partial<SubsonicArtist>; albums: SubsonicAlbum[] }>();
|
||||
const d2 = deferred<{ artist: Partial<SubsonicArtist>; albums: SubsonicAlbum[] }>();
|
||||
vi.mocked(getArtistForServer).mockImplementation(async (_sid, id) => {
|
||||
if (id === 'art-D1') return d1.promise as any;
|
||||
if (id === 'art-D2') return d2.promise as any;
|
||||
return { albums: [] } as any;
|
||||
if (id === 'art-D1') return d1.promise as unknown as ArtistForServer;
|
||||
if (id === 'art-D2') return d2.promise as unknown as ArtistForServer;
|
||||
return { albums: [] } as unknown as ArtistForServer;
|
||||
});
|
||||
mockArtistInfo.mockResolvedValue(null);
|
||||
|
||||
@@ -241,10 +246,10 @@ describe('useNowPlayingFetchers — local-playback metadata', () => {
|
||||
);
|
||||
vi.mocked(getSongForServer).mockResolvedValue({ id: 'np-song', title: 'Local Track' } as SubsonicSong);
|
||||
vi.mocked(getAlbumForServer).mockResolvedValue(
|
||||
{ album: { id: 'np-al', name: 'Album' } as SubsonicAlbum, songs: [] } as any,
|
||||
{ album: { id: 'np-al', name: 'Album' } as SubsonicAlbum, songs: [] },
|
||||
);
|
||||
vi.mocked(getArtistForServer).mockResolvedValue({ albums: [{ id: 'np-al' }] } as any);
|
||||
vi.mocked(getTopSongsForServer).mockResolvedValue([{ id: 'np-top' }] as any);
|
||||
vi.mocked(getArtistForServer).mockResolvedValue({ albums: [{ id: 'np-al' } as SubsonicAlbum] } as unknown as ArtistForServer);
|
||||
vi.mocked(getTopSongsForServer).mockResolvedValue([{ id: 'np-top' } as unknown as SubsonicSong]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useNowPlayingFetchers({ ...baseDeps, songId: 'np-song', albumId: 'np-al', artistId: 'np-art', artistName: 'NP Artist' }),
|
||||
|
||||
@@ -216,6 +216,8 @@ export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingF
|
||||
|
||||
// Fetch batch per entity change (not per song switch — same-artist songs share artist/top/tour fetches)
|
||||
useEffect(() => {
|
||||
// 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
|
||||
if (!indexFetchAllowed || !songId) { setSongMetaEntry(null); return; }
|
||||
const cacheKey = subsonicCacheKey(subsonicServerId, songId);
|
||||
const cached = songMetaCache.get(cacheKey);
|
||||
@@ -229,6 +231,8 @@ export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingF
|
||||
}, [indexFetchAllowed, subsonicServerId, songId, connStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
// 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
|
||||
if (!networkOnlyAllowed || !artistId) { setArtistInfoEntry(null); return; }
|
||||
const cacheKey = subsonicCacheKey(subsonicServerId, artistId);
|
||||
const cached = artistInfoCache.get(cacheKey);
|
||||
@@ -242,6 +246,8 @@ export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingF
|
||||
}, [networkOnlyAllowed, subsonicServerId, artistId, audiomuseNavidromeEnabled, connStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
// 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
|
||||
if (!indexFetchAllowed || !albumId) { setAlbumDataEntry(null); return; }
|
||||
const cacheKey = subsonicCacheKey(subsonicServerId, albumId);
|
||||
const cached = albumCache.get(cacheKey);
|
||||
@@ -255,6 +261,8 @@ export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingF
|
||||
}, [indexFetchAllowed, subsonicServerId, albumId, connStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
// 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
|
||||
if (!indexFetchAllowed || !topSongsKey) { setTopSongsEntry(null); return; }
|
||||
const cached = topSongsCache.get(topSongsKey);
|
||||
if (cached !== undefined) { setTopSongsEntry({ key: topSongsKey, value: cached }); return; }
|
||||
@@ -267,6 +275,8 @@ export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingF
|
||||
}, [indexFetchAllowed, topSongsKey, subsonicServerId, artistId, artistName, connStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
// 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
|
||||
if (!tourKey) { setTourEventsEntry(null); setTourLoading(false); return; }
|
||||
const cached = tourCache.get(tourKey);
|
||||
if (cached !== undefined) { setTourEventsEntry({ key: tourKey, value: cached }); setTourLoading(false); return; }
|
||||
@@ -281,6 +291,8 @@ export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingF
|
||||
|
||||
// Discography via getArtist
|
||||
useEffect(() => {
|
||||
// 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
|
||||
if (!indexFetchAllowed || !artistId) { setDiscographyEntry(null); return; }
|
||||
const cacheKey = subsonicCacheKey(subsonicServerId, artistId);
|
||||
const cached = discographyCache.get(cacheKey);
|
||||
@@ -296,6 +308,8 @@ export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingF
|
||||
// Enrichment track stats (per-track, from the enrichment primary)
|
||||
useEffect(() => {
|
||||
const runtime = getMusicNetworkRuntimeOrNull();
|
||||
// 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
|
||||
if (!runtime?.getEnrichmentPrimaryId() || !currentTrack || !networkTrackKey) { setNetworkTrackEntry(null); return; }
|
||||
const cached = networkTrackCache.get(networkTrackKey);
|
||||
if (cached !== undefined) { setNetworkTrackEntry({ key: networkTrackKey, value: cached }); return; }
|
||||
@@ -310,6 +324,8 @@ export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingF
|
||||
// Enrichment artist stats (per-artist — shared across same-artist tracks)
|
||||
useEffect(() => {
|
||||
const runtime = getMusicNetworkRuntimeOrNull();
|
||||
// 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
|
||||
if (!runtime?.getEnrichmentPrimaryId() || !artistName || !networkArtistKey) { setNetworkArtistEntry(null); return; }
|
||||
const cached = networkArtistCache.get(networkArtistKey);
|
||||
if (cached !== undefined) { setNetworkArtistEntry({ key: networkArtistKey, value: cached }); return; }
|
||||
|
||||
@@ -82,6 +82,9 @@ export function useNowPlayingPrewarm(): void {
|
||||
if (ref) void prewarmCoverRef(ref);
|
||||
});
|
||||
}
|
||||
// Keyed on currentTrack?.id; depending on the `currentTrack` object would
|
||||
// re-prewarm on every render when its identity changes but its id does not.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
currentTrack?.id,
|
||||
currentTrack?.artistId,
|
||||
|
||||
@@ -24,6 +24,8 @@ export function useNowPlayingStarLove(deps: NowPlayingStarLoveDeps): NowPlayingS
|
||||
|
||||
// Star
|
||||
const [starred, setStarred] = useState(false);
|
||||
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
useEffect(() => { setStarred(!!songMeta?.starred); }, [songMeta]);
|
||||
const toggleStar = useCallback(async () => {
|
||||
if (!currentTrack) return;
|
||||
@@ -34,6 +36,8 @@ export function useNowPlayingStarLove(deps: NowPlayingStarLoveDeps): NowPlayingS
|
||||
|
||||
// Love (enrichment primary; seeded from track.getInfo, toggle via love/unlove)
|
||||
const [networkLoved, setNetworkLoved] = useState(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
|
||||
useEffect(() => { setNetworkLoved(!!networkTrack?.userLoved); }, [networkTrack]);
|
||||
const toggleNetworkLove = useCallback(async () => {
|
||||
if (!currentTrack || !networkLoveEnabled) return;
|
||||
|
||||
@@ -402,6 +402,10 @@ export function useOrbitGuest(): void {
|
||||
restoreGuestTransitions();
|
||||
resetPendingResendState();
|
||||
};
|
||||
// outboxPlaylistId is read inside the tick loop at call time; the loop is
|
||||
// intentionally (re)started only on session activation / playlist change, not
|
||||
// when the outbox id updates mid-session.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [active, sessionPlaylistId]);
|
||||
|
||||
// Outbox heartbeat — shared with the host hook; the guest's outbox is keyed
|
||||
|
||||
@@ -77,7 +77,7 @@ export function usePlatformShellSetup(): { isTilingWm: boolean } {
|
||||
return () => {
|
||||
cancelHydration?.();
|
||||
};
|
||||
}, [IS_LINUX, waylandTextUi, linuxWaylandTextRenderProfile]);
|
||||
}, [waylandTextUi, linuxWaylandTextRenderProfile]);
|
||||
|
||||
// Sync custom titlebar preference with native decorations on Linux.
|
||||
// On tiling WMs decorations are always off (no native title bar to replace).
|
||||
|
||||
@@ -31,6 +31,9 @@ export function usePlayQueueSyncLedState(status: ConnectionStatus) {
|
||||
|
||||
const playbackServerId = useMemo(
|
||||
() => getPlaybackServerId(),
|
||||
// getPlaybackServerId() reads global queue/auth state; the listed values
|
||||
// are intentional recompute triggers, not direct inputs to the body.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[activeServerId, queueItems, queueIndex, currentTrackId],
|
||||
);
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ export function usePlaybackServerId(): string {
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
return useMemo(
|
||||
() => getPlaybackServerId(),
|
||||
// getPlaybackServerId() reads global queue/auth state; the listed values
|
||||
// are intentional recompute triggers, not direct inputs to the body.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[queueServerId, queueIndex, playingServerId, activeServerId],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
/** Fixed popover anchored above a player-bar trigger (overflow menu / speed btn). */
|
||||
export function usePlayerBarAnchoredPopover(width: number, zIndex = 10050) {
|
||||
@@ -7,7 +7,7 @@ export function usePlayerBarAnchoredPopover(width: number, zIndex = 10050) {
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
const popRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const updatePopStyle = () => {
|
||||
const updatePopStyle = useCallback(() => {
|
||||
const btn = btnRef.current;
|
||||
if (!btn) return;
|
||||
const MARGIN = 8;
|
||||
@@ -23,12 +23,12 @@ export function usePlayerBarAnchoredPopover(width: number, zIndex = 10050) {
|
||||
bottom: window.innerHeight - r.top + MARGIN,
|
||||
zIndex,
|
||||
});
|
||||
};
|
||||
}, [width, zIndex]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
updatePopStyle();
|
||||
}, [open, width]);
|
||||
}, [open, updatePopStyle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -39,7 +39,7 @@ export function usePlayerBarAnchoredPopover(width: number, zIndex = 10050) {
|
||||
window.removeEventListener('resize', onReposition);
|
||||
window.removeEventListener('scroll', onReposition, true);
|
||||
};
|
||||
}, [open, width]);
|
||||
}, [open, updatePopStyle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
@@ -4,6 +4,8 @@ import { onPlaySessionRecorded } from '../store/playSessionRecorded';
|
||||
/** Refresh player stats when a listen is persisted or the tab becomes visible again. */
|
||||
export function usePlayerStatsLiveRefresh(onRefresh: () => void) {
|
||||
const onRefreshRef = useRef(onRefresh);
|
||||
// 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
|
||||
onRefreshRef.current = onRefresh;
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -53,6 +53,8 @@ export function usePlaylistCovers(songs: SubsonicSong[], customCoverId: string |
|
||||
|
||||
useEffect(() => {
|
||||
if (!bgCoverId) {
|
||||
// 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
|
||||
setBgCoverRef(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,8 @@ export function usePlaylistSelection(
|
||||
const to = Math.max(lastSelectedIdx, idx);
|
||||
songs.slice(from, to + 1).forEach(s => next.add(s.id));
|
||||
} else {
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
@@ -19,6 +19,8 @@ export function usePlaylistSongSearch(
|
||||
const searchDebounce = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (!searchOpen || !searchQuery.trim()) { setSearchResults([]); return; }
|
||||
if (searchDebounce.current) clearTimeout(searchDebounce.current);
|
||||
searchDebounce.current = setTimeout(async () => {
|
||||
@@ -27,7 +29,7 @@ export function usePlaylistSongSearch(
|
||||
const res = await search(searchQuery, { songCount: 20, artistCount: 0, albumCount: 0 });
|
||||
const existingIds = new Set(songs.map(s => s.id));
|
||||
setSearchResults(res.songs.filter(s => !existingIds.has(s.id)));
|
||||
} catch {}
|
||||
} catch { /* ignore: best-effort */ }
|
||||
setSearching(false);
|
||||
}, 350);
|
||||
return () => { if (searchDebounce.current) clearTimeout(searchDebounce.current); };
|
||||
|
||||
@@ -30,7 +30,8 @@ export function usePlaylistStarRating(deps: PlaylistStarRatingDeps): PlaylistSta
|
||||
const isStarred = song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id);
|
||||
setStarredSongs(prev => {
|
||||
const next = new Set(prev);
|
||||
isStarred ? next.delete(song.id) : next.add(song.id);
|
||||
if (isStarred) next.delete(song.id);
|
||||
else next.add(song.id);
|
||||
return next;
|
||||
});
|
||||
// F4: optimistic override + retried server sync via the central helper (no rollback).
|
||||
|
||||
@@ -30,11 +30,13 @@ export function usePlaylistSuggestions(songs: SubsonicSong[], playlistId: string
|
||||
try {
|
||||
const random = await getRandomSongs(25, genre);
|
||||
setSuggestions(random.filter(s => !existingIds.has(s.id)).slice(0, 10));
|
||||
} catch {}
|
||||
} catch { /* ignore: best-effort */ }
|
||||
setLoadingSuggestions(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 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
|
||||
if (songs.length > 0) loadSuggestions(songs);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [playlistId]);
|
||||
|
||||
@@ -65,6 +65,8 @@ export function useQueueLufsTgtPopover(expandReplayGain: boolean) {
|
||||
}, [lufsTgtOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
// React Compiler set-state-in-effect rule: state set from an external subscription/event callback.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (!expandReplayGain) setLufsTgtOpen(false);
|
||||
}, [expandReplayGain]);
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@ export function useQueuePanelDrag({
|
||||
useEffect(() => {
|
||||
if (!isPsyDragging) {
|
||||
externalDropTargetRef.current = null;
|
||||
// React Compiler set-state-in-effect rule: state set from a DOM/layout measurement.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setExternalDropTarget(null);
|
||||
}
|
||||
}, [isPsyDragging]);
|
||||
@@ -55,7 +57,14 @@ export function useQueuePanelDrag({
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (!detail?.data) return;
|
||||
|
||||
let parsedData: any = null;
|
||||
let parsedData: {
|
||||
type?: string;
|
||||
index?: number;
|
||||
track?: Track;
|
||||
tracks?: Track[];
|
||||
serverId?: string;
|
||||
id?: string;
|
||||
};
|
||||
try { parsedData = JSON.parse(detail.data); } catch { return; }
|
||||
|
||||
// Radio streams are not tracks — reject silently
|
||||
@@ -70,17 +79,17 @@ export function useQueuePanelDrag({
|
||||
: usePlayerStore.getState().queueItems.length;
|
||||
|
||||
if (parsedData.type === 'queue_reorder') {
|
||||
const fromIdx: number = parsedData.index;
|
||||
const fromIdx = parsedData.index as number;
|
||||
psyDragFromIdxRef.current = null;
|
||||
if (fromIdx !== insertIdx) reorderQueue(fromIdx, insertIdx);
|
||||
} else if (parsedData.type === 'song') {
|
||||
enqueueAt([parsedData.track], insertIdx);
|
||||
enqueueAt([parsedData.track as Track], insertIdx);
|
||||
} else if (parsedData.type === 'songs') {
|
||||
enqueueAt(parsedData.tracks as Track[], insertIdx);
|
||||
} else if (parsedData.type === 'album') {
|
||||
const serverId = resolveMediaServerId(parsedData.serverId);
|
||||
if (!serverId) return;
|
||||
const albumData = await resolveAlbum(serverId, parsedData.id);
|
||||
const albumData = await resolveAlbum(serverId, parsedData.id as string);
|
||||
if (!albumData) return;
|
||||
enqueueAt(albumData.songs.map(songToTrack), insertIdx);
|
||||
}
|
||||
@@ -99,7 +108,7 @@ export function useQueuePanelDrag({
|
||||
const cx = d.clientX;
|
||||
const cy = d.clientY;
|
||||
if (typeof cx !== 'number' || typeof cy !== 'number') return;
|
||||
let parsed: { type?: string; index?: number } | null = null;
|
||||
let parsed: { type?: string; index?: number } | null;
|
||||
try {
|
||||
parsed = JSON.parse(d.data);
|
||||
} catch {
|
||||
|
||||
@@ -81,6 +81,8 @@ export function useQueueResizer({
|
||||
const leftBtn = document.querySelector('.sidebar .collapse-btn') as HTMLElement | null;
|
||||
if (!leftBtn) return;
|
||||
|
||||
// React Compiler set-state-in-effect rule: state set from a DOM/layout measurement.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
syncQueueHandleTop();
|
||||
const raf = requestAnimationFrame(syncQueueHandleTop);
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ export function useQueueTrackEnrichment(trackId: string | undefined): ParsedTrac
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverId || !trackId || !indexEnabled) {
|
||||
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setData(EMPTY);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -127,6 +127,8 @@ export function useRadioMetadata(station: InternetRadioStation | null): RadioMet
|
||||
|
||||
useEffect(() => {
|
||||
if (perfFlags.disableBackgroundPolling) {
|
||||
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setMetadata(EMPTY_METADATA);
|
||||
azuraCastUrlRef.current = null;
|
||||
stopElapsedTick();
|
||||
|
||||
@@ -22,6 +22,8 @@ export function useReleaseNotes(version: string = appVersion): UseReleaseNotesRe
|
||||
|
||||
useEffect(() => {
|
||||
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);
|
||||
|
||||
Promise.all([resolveReleaseNotes(version), resolveChangelogEntry(version)])
|
||||
|
||||
@@ -9,6 +9,8 @@ export function useElementClientHeightById(elementId: string, fallback = 800): n
|
||||
useLayoutEffect(() => {
|
||||
const el = typeof document !== 'undefined' ? document.getElementById(elementId) : null;
|
||||
if (!el) {
|
||||
// React Compiler set-state-in-effect rule: state set from a DOM/layout measurement.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setH(fallback);
|
||||
return;
|
||||
}
|
||||
@@ -46,6 +48,8 @@ export function useElementClientHeightForElement(
|
||||
const [h, setH] = useState(fallback);
|
||||
useLayoutEffect(() => {
|
||||
if (!element) {
|
||||
// React Compiler set-state-in-effect rule: state set from a DOM/layout measurement.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setH(fallback);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ export function useShareQueuePreview(
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !payload) {
|
||||
// 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
|
||||
setState(IDLE);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ export function useShareSearchPreview(shareMatch: ShareSearchMatch | null): Shar
|
||||
|
||||
useEffect(() => {
|
||||
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
|
||||
setPreview(EMPTY_PREVIEW);
|
||||
|
||||
if (shareMatch?.type === 'queueable' && shareMatch.payload.k === 'track') {
|
||||
|
||||
@@ -40,6 +40,8 @@ export function useSidebarNavDnd({
|
||||
const [navDnd, setNavDnd] = useState<NavDndState | null>(null);
|
||||
const [navDropTarget, setNavDropTarget] = useState<SidebarNavDropTarget | null>(null);
|
||||
const navDropTargetRef = useRef<SidebarNavDropTarget | null>(null);
|
||||
// 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
|
||||
navDropTargetRef.current = navDropTarget;
|
||||
/** DOM timers are numeric; avoid NodeJS `Timeout` typing from `setTimeout`. */
|
||||
const longPressTimersRef = useRef<Map<number, number>>(new Map());
|
||||
|
||||
@@ -141,6 +141,10 @@ export function useSongBrowseList({ enabled, searchQuery, initialRestore }: UseS
|
||||
|
||||
return (await runNetworkBrowseSongPage(q, pageOffset, PAGE_SIZE)) ?? [];
|
||||
},
|
||||
// musicLibraryFilterVersion is an intentional re-create trigger: the page
|
||||
// loaders read the active genre/library filter state internally, so the
|
||||
// callback must refresh when that version bumps even though it is unused here.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[indexEnabled, musicLibraryFilterVersion, offlineBrowseActive, serverId],
|
||||
);
|
||||
|
||||
@@ -218,6 +222,8 @@ export function useSongBrowseList({ enabled, searchQuery, initialRestore }: UseS
|
||||
}
|
||||
}, [enabled, loading, hasMore, debouncedQuery, offset, fetchSongPage]);
|
||||
|
||||
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
return {
|
||||
songs,
|
||||
offset,
|
||||
@@ -225,6 +231,8 @@ export function useSongBrowseList({ enabled, searchQuery, initialRestore }: UseS
|
||||
hasMore,
|
||||
browseUnsupported,
|
||||
hasSearched,
|
||||
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
localSearchMode: localSearchModeRef.current,
|
||||
loadMore,
|
||||
};
|
||||
|
||||
@@ -17,6 +17,8 @@ export function useThemeAnimationRisk(): boolean {
|
||||
|
||||
useEffect(() => {
|
||||
if (cached !== null) {
|
||||
// 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
|
||||
setRisk(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -56,6 +56,8 @@ export function useUserMgmtData(serverUrl: string, token: string, t: TFunction):
|
||||
}
|
||||
}, [serverUrl, token, t]);
|
||||
|
||||
// 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
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
return { users, libraries, loading, loadError, load };
|
||||
|
||||
@@ -42,6 +42,8 @@ export function useUtilityOverflowMenu(
|
||||
}, [floatingPlayerBar, playerBarRef]);
|
||||
|
||||
useEffect(() => {
|
||||
// React Compiler set-state-in-effect rule: state set from a DOM/layout measurement.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (!utilityOverflow) setUtilityMenuOpen(false);
|
||||
if (!utilityOverflow && volumeWheelMenuTimerRef.current != null) {
|
||||
window.clearTimeout(volumeWheelMenuTimerRef.current);
|
||||
|
||||
@@ -23,6 +23,8 @@ export function useVirtualizerScrollMargin(
|
||||
): number {
|
||||
const [scrollMargin, setScrollMargin] = useState(0);
|
||||
const getterRef = useRef(getScrollElement);
|
||||
// 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
|
||||
getterRef.current = getScrollElement;
|
||||
useLayoutEffect(() => {
|
||||
if (!options.active) return;
|
||||
@@ -42,6 +44,10 @@ export function useVirtualizerScrollMargin(
|
||||
const scrollContent = scrollEl.firstElementChild as Element | null;
|
||||
if (scrollContent) ro.observe(scrollContent);
|
||||
return () => ro.disconnect();
|
||||
// options.deps is a caller-supplied dependency list spread in on purpose so
|
||||
// this reusable hook re-measures when the caller's layout inputs change;
|
||||
// it cannot be statically verified, which is expected here.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [options.active, wrapRef, ...options.deps]);
|
||||
return scrollMargin;
|
||||
}
|
||||
|
||||
@@ -61,6 +61,9 @@ export function WindowVisibilityProvider({ children }: { children: ReactNode })
|
||||
);
|
||||
}
|
||||
|
||||
// Companion hook intentionally co-located with WindowVisibilityProvider in this
|
||||
// small context module; HMR-only rule does not warrant a separate file.
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useWindowVisibility() {
|
||||
return useContext(WindowVisibilityContext);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user