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
+42 -14
View File
@@ -25,7 +25,43 @@ export type CoverArtImageProps = {
ensurePriority?: CoverPrefetchPriority;
} & Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'>;
export function CoverArtImage({
/**
* Guard wrapper: with no `coverRef` we render a provisional placeholder and run
* no hooks. The hook-bearing body lives in `CoverArtImageResolved`, which only
* ever mounts with a non-null `coverRef` — so its hooks are unconditional.
*/
export function CoverArtImage(props: CoverArtImageProps) {
if (!props.coverRef) {
const {
coverRef: _coverRef,
displayCssPx: _displayCssPx,
surface: _surface,
fullRes: _fullRes,
fetchQueueBias: _fetchQueueBias,
observeRootMargin: _observeRootMargin,
observeScrollRootId: _observeScrollRootId,
ensurePriority: _ensurePriority,
onError: _onError,
className,
alt,
...rest
} = props;
return (
<div
className={className}
data-cover-provisional="true"
role="img"
aria-label={alt ?? ''}
{...(rest as React.HTMLAttributes<HTMLDivElement>)}
/>
);
}
return <CoverArtImageResolved {...props} coverRef={props.coverRef} />;
}
type CoverArtImageResolvedProps = Omit<CoverArtImageProps, 'coverRef'> & { coverRef: CoverArtRef };
function CoverArtImageResolved({
coverRef,
displayCssPx,
surface,
@@ -38,19 +74,7 @@ export function CoverArtImage({
ensurePriority: ensurePriorityProp,
onError: restOnError,
...rest
}: CoverArtImageProps) {
if (!coverRef) {
return (
<div
className={className}
data-cover-provisional="true"
role="img"
aria-label={alt ?? ''}
{...(rest as React.HTMLAttributes<HTMLDivElement>)}
/>
);
}
}: CoverArtImageResolvedProps) {
const pinnedHigh = ensurePriorityProp === 'high';
const [ensurePriority, setEnsurePriority] = useState<CoverPrefetchPriority>(
ensurePriorityProp ?? 'middle',
@@ -61,6 +85,8 @@ export function CoverArtImage({
const [imgLoadFailed, setImgLoadFailed] = useState(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 (ensurePriorityProp) setEnsurePriority(ensurePriorityProp);
}, [ensurePriorityProp]);
@@ -69,6 +95,8 @@ export function CoverArtImage({
}, [seenViewport]);
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
setImgLoadFailed(false);
}, [coverRef.cacheEntityId, coverRef.cacheKind, coverRef.fetchCoverArtId, displayCssPx, surface, fullRes]);
+2
View File
@@ -41,6 +41,8 @@ export function ArtistHeroCover({
}, [artistInfo?.largeImageUrl, artistInfo?.mediumImageUrl]);
useEffect(() => {
// 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
setExternalFailed(false);
setExternalUrl('');
if (!candidateUrl) return;
+7
View File
@@ -16,6 +16,8 @@ export function useCoverLightboxSrc(
useEffect(() => {
if (!open || !ref) 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);
void (async () => {
const diskSrc = await ensureCoverTierDiskSrc(ref, 2000);
@@ -30,10 +32,15 @@ export function useCoverLightboxSrc(
return () => {
cancelled = true;
};
// Keyed on the ref's identity fields intentionally; depending on the `ref`
// object itself would re-fetch the lightbox source on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, ref?.cacheEntityId, ref?.cacheKind, ref?.fetchCoverArtId, ref?.serverScope]);
useEffect(() => {
if (open) 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
setSrc('');
setLoading(false);
}, [open]);
-1
View File
@@ -3,7 +3,6 @@ import { getDiskSrc } from './diskSrcCache';
import { getDiskSrcForGrid } from './diskSrcLookup';
import { coverTrafficServerSwitchPaused } from './coverTraffic';
import { rememberGridDiskSrc } from './diskSrcLookup';
import { coverStorageKeyFromRef } from './storageKeys';
import type { CoverArtRef, CoverArtTier } from './types';
function peekMemoryHit(storageKey: string, ref: CoverArtRef, tier: CoverArtTier): boolean {
+2
View File
@@ -37,6 +37,8 @@ function useArtistExternalImage(
useEffect(() => {
if (!enabled || !artistId) {
// Nothing will resolve — not pending, so callers fall back immediately.
// 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
setImage({ src: '', pending: false });
return;
}
+25 -2
View File
@@ -65,7 +65,7 @@ export function useAlbumCoverRef(
const id = albumId?.trim();
if (!id) return null;
return albumCoverRef(id, fallbackCoverArt, { serverScope, distinctDiscCovers });
}, [albumId, fallbackCoverArt, scopeKey, serverScope, distinctDiscCovers]);
}, [albumId, fallbackCoverArt, serverScope, distinctDiscCovers]);
const [ref, setRef] = useState<CoverArtRef | null>(syncRef);
@@ -83,6 +83,10 @@ export function useAlbumCoverRef(
return () => {
cancelled = true;
};
// serverScope is keyed via the stable `scopeKey` string (and via syncRef);
// depending on the object directly would re-resolve from SQLite on every
// render when the scope identity changes but its content does not.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [albumId, fallbackCoverArt, scopeKey, syncRef, libraryResolve]);
return libraryResolve ? ref : syncRef;
@@ -101,7 +105,7 @@ export function useArtistCoverRef(
const id = artistId?.trim();
if (!id) return null;
return artistCoverRef(id, fallbackCoverArt, serverScope);
}, [artistId, fallbackCoverArt, scopeKey, serverScope]);
}, [artistId, fallbackCoverArt, serverScope]);
const [ref, setRef] = useState<CoverArtRef | null>(syncRef);
@@ -119,6 +123,10 @@ export function useArtistCoverRef(
return () => {
cancelled = true;
};
// serverScope is keyed via the stable `scopeKey` string (and via syncRef);
// depending on the object directly would re-resolve from SQLite on every
// render when the scope identity changes but its content does not.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [artistId, fallbackCoverArt, scopeKey, syncRef, libraryResolve]);
return libraryResolve ? ref : syncRef;
@@ -193,6 +201,10 @@ export function useTrackCoverRef(
return () => {
cancelled = true;
};
// serverScope is keyed via the stable `scopeKey` string; depending on the
// object directly would re-resolve from SQLite on every render when the
// scope identity changes but its content does not.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [song, songId, albumId, coverArt, discNumber, scopeKey, syncRef, libraryResolve, distinctDiscCovers]);
return libraryResolve ? ref : syncRef;
@@ -226,6 +238,11 @@ export function usePlaybackTrackCoverRef(
}
}
return resolvePlaybackCoverScope();
// queueServerId/queueLength/activeServerId/serversFingerprint look unused but
// are intentional recompute triggers: resolvePlaybackCoverScope() and
// resolveServerIdForIndexKey() read global server/queue state, so the scope
// must re-derive when those change.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [track, queueItems, queueIndex, queueServerId, queueLength, activeServerId, serversFingerprint]);
const scopeKey = coverScopeKey(scope);
@@ -237,6 +254,9 @@ export function usePlaybackTrackCoverRef(
const syncRef = useMemo(() => {
if (!albumId?.trim() || !track) return undefined;
return albumCoverRefForPlayback(track, scope);
// `scope` is keyed via the stable `scopeKey` string; the primitive track
// fields recompute the ref when the playing track changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [track, trackId, albumId, coverArt, discNumber, scopeKey]);
const [ref, setRef] = useState<CoverArtRef | undefined>(syncRef);
@@ -279,6 +299,9 @@ export function usePlaybackTrackCoverRef(
return () => {
cancelled = true;
};
// `scope` is keyed via the stable `scopeKey` string; depending on the object
// directly would re-resolve from SQLite on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [track, trackId, albumId, coverArt, discNumber, scopeKey, syncRef]);
return ref;
+4
View File
@@ -26,6 +26,10 @@ export function usePlaybackCoverArt(
const scope = useMemo(
() => resolvePlaybackCoverScope(),
// resolvePlaybackCoverScope() reads global server/queue state; the listed
// values look unused but are intentional recompute triggers so the scope
// re-derives whenever the playback server or queue position changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
[queueServerId, queueIndex, playingServerId, queueLength, activeServerId, serversFingerprint],
);
const refWithScope = useMemo(