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:
Psychotoxical
2026-06-24 01:33:34 +02:00
committed by GitHub
parent c7d76af790
commit 7c724a642f
258 changed files with 2987 additions and 601 deletions
+5 -1
View File
@@ -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
}, []);
}
+15
View File
@@ -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(() => {
+7
View File
@@ -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 effects 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),
+10
View File
@@ -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);
}, [
+2
View File
@@ -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(
+2
View File
@@ -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([]);
+6
View File
@@ -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(
+2 -1
View File
@@ -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;
-1
View File
@@ -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
+5 -5
View File
@@ -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) => {
+6
View File
@@ -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: [] }))
+5
View File
@@ -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 effects 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;
+9
View File
@@ -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 effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
setSimilarArtists([]);
setSimilarLoading(false);
}
+2
View File
@@ -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);
+7
View File
@@ -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 effects 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);
}, [
+2
View File
@@ -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]);
+2
View File
@@ -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;
+2
View File
@@ -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;
+1 -1
View File
@@ -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
+7
View File
@@ -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 effects 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);
}, [
+4
View File
@@ -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 effects 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);
+4
View File
@@ -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 () => {
+2
View File
@@ -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);
+2
View File
@@ -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 effects 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 effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
setPlayingPathIds(prev => {
if (
prev.length === selectedChain.length &&
+2
View File
@@ -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]);
+15
View File
@@ -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({
+11
View File
@@ -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,
};
}
+6
View File
@@ -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(() => {
+4
View File
@@ -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,
+7
View File
@@ -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]);
}
+2
View File
@@ -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;
}
+7 -1
View File
@@ -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(() => {
+2
View File
@@ -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 effects 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
+2 -2
View File
@@ -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; }),
);
+1 -1
View File
@@ -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();
+4 -2
View File
@@ -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 effects 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 {
+6
View File
@@ -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 effects 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,
+18 -13
View File
@@ -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' }),
+16
View File
@@ -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; }
+3
View File
@@ -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,
+4
View File
@@ -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 effects 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;
+4
View File
@@ -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
+1 -1
View File
@@ -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).
+3
View File
@@ -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],
);
+3
View File
@@ -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],
);
}
+5 -5
View File
@@ -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;
+2
View File
@@ -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(() => {
+2
View File
@@ -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;
}
+2 -1
View File
@@ -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;
});
+3 -1
View File
@@ -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); };
+2 -1
View File
@@ -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).
+3 -1
View File
@@ -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]);
+2
View File
@@ -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]);
+14 -5
View File
@@ -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 {
+2
View File
@@ -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);
+2
View File
@@ -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;
}
+2
View File
@@ -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 effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
setMetadata(EMPTY_METADATA);
azuraCastUrlRef.current = null;
stopElapsedTick();
+2
View File
@@ -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)])
+4
View File
@@ -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;
}
+2
View File
@@ -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;
}
+2
View File
@@ -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') {
+2
View File
@@ -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());
+8
View File
@@ -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,
};
+2
View File
@@ -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;
}
+2
View File
@@ -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 };
+2
View File
@@ -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);
+6
View File
@@ -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;
}
+3
View File
@@ -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);
}