refactor(frontend): the long tail — and eslint-suppressions.json is gone (E27 S2)
Closes the burn-down. Hook suppressions 113 -> 34, `exhaustive-deps` 45 -> 2 (the target was <10), and every one of the 34 survivors is an inline directive NAMING the invariant it protects. The suppressions file is deleted, because a bulk file hides the next violation instead of answering it — and it cannot hold a warning-level rule at all. The sprint plan's three verdicts missed the answer that turned out to be the most common one: adjust the state DURING RENDER. React re-runs the component and discards that pass rather than committing it, so the stale frame an effect-based reset paints does not exist. Applied to the Plex player's up-next card, the playlist view reset, the feed's optimistic overrides, the channel banner's retry count, the download center's tab guard, the Plex search highlight, the comfort-pin latch, Welcome's per-view outcome and the card pager's page clamp. It is legal only for a component's own state — PlexBrowse's language commit resets PlexProvider's filters, so that one stays an effect and says why. Mirrors of a prop or query became derived values or `draft ?? server`: - `downloadRow` had ui/Avatar's defect exactly — a boolean "poster failed" that an effect reset, in rows recycled down a virtualized list, so one job's missing poster painted over the next one's for a frame. Keyed by URL now. - Scheduler's two number editors held a mirror an effect re-synced, on a page that POLLS — the shape a poll can overwrite mid-edit. A draft while editing, the server's value otherwise; six tests where there were none, the one behavioural change (the save button stops being dirty at commit rather than at the echo) mutation-checked. - SetupWizard's "no token" and WatchPage's "gone" are facts about the URL, so they are seeded and derived rather than pushed into state by an effect that had to commit first. Elsewhere: PlexInfo's rating chip was a component declared during render — a new component type every render, so React remounted the subtree and dropped the focus on its filter button. Hoisted. Feed's undo action and PlexPlayer's session reclaim both re-entered their own useCallback, which the checker reads as a forward reference; both go through the ref idiom this file already uses. BootGate's dep list was load-bearing for correctness — a re-run meant `location.reload()` — so the "already adopted THIS identity" comparison now enforces it in code, with a test that fails without it (the existing reload tests all install their spy too late to catch it). One correction to the batch before this: keying ChatThread's and Messages' decrypt passes on `q.data` instead of `q.dataUpdatedAt` was wrong. react-query's structural sharing makes it safe in production, but the component must not melt down when something hands back a fresh object per render — and Messages.keyboard.dom.test does exactly that, so the effect span the suite. Both are back on the fetch as the trigger with the payload behind a ref, which is honest AND loop-proof; the replaced comment had warned about precisely this. CLAUDE.md gains four canonical-patterns rows for the above, and its "React 18" line is now true.
This commit is contained in:
@@ -43,6 +43,38 @@ const money = (n: number) =>
|
||||
maximumFractionDigits: 1,
|
||||
}).format(n);
|
||||
|
||||
// One rating chip's number: a filter button when this is the score the backend's ratingMin column
|
||||
// tracks, plain text otherwise. It lives at MODULE level, not inside RatingsRow, because a component
|
||||
// declared during render is a NEW component type on every render — React unmounts and remounts the
|
||||
// subtree rather than updating it, which throws away DOM state and, for this one, the keyboard focus
|
||||
// on the button while the info page re-renders around it.
|
||||
function Score({
|
||||
score,
|
||||
filterable,
|
||||
onFilterRating,
|
||||
}: {
|
||||
score: number;
|
||||
filterable: boolean;
|
||||
onFilterRating?: (score: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return filterable && onFilterRating ? (
|
||||
<button
|
||||
onClick={() => onFilterRating(Math.floor(score))}
|
||||
className="inline-flex items-center gap-1 hover:opacity-80"
|
||||
title={t("plex.info.filterRating", { n: Math.floor(score) })}
|
||||
>
|
||||
<Star className="h-3.5 w-3.5 fill-current" />
|
||||
{score.toFixed(1)}
|
||||
</button>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Star className="h-3.5 w-3.5 fill-current" />
|
||||
{score.toFixed(1)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Multi-source ratings cluster (E5 S5): IMDb (links out) · TMDb (with vote count) · Plex audience —
|
||||
// whichever are present. The "primary" score (IMDb, else audience) mirrors the backend rating column
|
||||
// (coalesce(imdb, audience)), so only it carries the ratingMin filter; the others are informational.
|
||||
@@ -65,29 +97,17 @@ function RatingsRow({
|
||||
const primary = imdbRating ?? audience ?? null; // the filterable score
|
||||
const chipCls =
|
||||
"inline-flex items-center gap-1 rounded-md bg-amber-400/15 px-1.5 py-0.5 font-semibold text-amber-500";
|
||||
const Score = ({ score, filterable }: { score: number; filterable: boolean }) =>
|
||||
filterable && onFilterRating ? (
|
||||
<button
|
||||
onClick={() => onFilterRating(Math.floor(score))}
|
||||
className="inline-flex items-center gap-1 hover:opacity-80"
|
||||
title={t("plex.info.filterRating", { n: Math.floor(score) })}
|
||||
>
|
||||
<Star className="h-3.5 w-3.5 fill-current" />
|
||||
{score.toFixed(1)}
|
||||
</button>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Star className="h-3.5 w-3.5 fill-current" />
|
||||
{score.toFixed(1)}
|
||||
</span>
|
||||
);
|
||||
const has = (x?: number | null): x is number => x != null;
|
||||
if (!has(imdbRating) && !has(audience) && !has(tmdbRating)) return null;
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
{has(imdbRating) && (
|
||||
<span className={chipCls}>
|
||||
<Score score={imdbRating} filterable={imdbRating === primary} />
|
||||
<Score
|
||||
score={imdbRating}
|
||||
filterable={imdbRating === primary}
|
||||
onFilterRating={onFilterRating}
|
||||
/>
|
||||
<span className="text-2xs font-normal opacity-70">IMDb</span>
|
||||
{imdbUrl && (
|
||||
<a
|
||||
@@ -111,7 +131,7 @@ function RatingsRow({
|
||||
)}
|
||||
{has(audience) && imdbRating !== audience && (
|
||||
<span className={chipCls}>
|
||||
<Score score={audience} filterable={!has(imdbRating)} />
|
||||
<Score score={audience} filterable={!has(imdbRating)} onFilterRating={onFilterRating} />
|
||||
<span className="text-2xs font-normal opacity-70">{t("plex.info.audience")}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user