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:
@@ -139,17 +139,19 @@ function JobRow({
|
||||
runDisabled: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [val, setVal] = useState(String(job.interval_minutes));
|
||||
// The box shows the server's value and holds a draft only while it is being edited, so there is
|
||||
// nothing for an effect to re-sync: `null` means "follow the job". The poll behind this page
|
||||
// refreshes every few seconds, which is exactly when a mirror-plus-effect would have shown the
|
||||
// previous value for a frame.
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const editing = draft !== null;
|
||||
const val = draft ?? String(job.interval_minutes);
|
||||
const next = secsUntil(job.next_run);
|
||||
useEffect(() => {
|
||||
if (!editing) setVal(String(job.interval_minutes));
|
||||
}, [job.interval_minutes, editing]);
|
||||
|
||||
function save() {
|
||||
const m = parseInt(val, 10);
|
||||
if (Number.isFinite(m) && m >= 1 && m <= 1440 && m !== job.interval_minutes) onSave(m);
|
||||
setEditing(false);
|
||||
setDraft(null);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -170,10 +172,10 @@ function JobRow({
|
||||
max={1440}
|
||||
value={val}
|
||||
autoFocus
|
||||
onChange={(e) => setVal(e.target.value)}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") save();
|
||||
if (e.key === "Escape") setEditing(false);
|
||||
if (e.key === "Escape") setDraft(null);
|
||||
}}
|
||||
className="w-16 bg-surface border border-border rounded-sm px-1.5 py-0.5 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
@@ -187,7 +189,7 @@ function JobRow({
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
</FocusSafeButton>
|
||||
<button
|
||||
onClick={() => setEditing(false)}
|
||||
onClick={() => setDraft(null)}
|
||||
className="text-muted hover:text-fg"
|
||||
aria-label={t("common.cancel")}
|
||||
>
|
||||
@@ -197,7 +199,7 @@ function JobRow({
|
||||
) : (
|
||||
<Tooltip hint={t("scheduler.editInterval")}>
|
||||
<button
|
||||
onClick={() => setEditing(true)}
|
||||
onClick={() => setDraft(String(job.interval_minutes))}
|
||||
className="group text-muted font-normal hover:text-accent inline-flex items-center gap-1 transition"
|
||||
>
|
||||
· {t("scheduler.everyMin", { count: job.interval_minutes })}
|
||||
@@ -256,12 +258,16 @@ function MaintenanceSettings({
|
||||
saving: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [val, setVal] = useState(String(m.revalidate_batch));
|
||||
useEffect(() => setVal(String(m.revalidate_batch)), [m.revalidate_batch]);
|
||||
// Same shape as the job row above: a draft while editing, the server's value otherwise.
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const val = draft ?? String(m.revalidate_batch);
|
||||
|
||||
function save() {
|
||||
const n = parseInt(val, 10);
|
||||
if (Number.isFinite(n) && n >= m.min && n <= m.max && n !== m.revalidate_batch) onSave(n);
|
||||
// Dropping the draft is what lets the box follow the server again once the save lands — and what
|
||||
// clears a value that failed validation instead of leaving it stuck in the field.
|
||||
setDraft(null);
|
||||
}
|
||||
const dirty = String(m.revalidate_batch) !== val;
|
||||
|
||||
@@ -281,7 +287,7 @@ function MaintenanceSettings({
|
||||
max={m.max}
|
||||
step={1000}
|
||||
value={val}
|
||||
onChange={(e) => setVal(e.target.value)}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && save()}
|
||||
aria-label={t("scheduler.maintenance.batchLabel")}
|
||||
className="w-28 bg-surface border border-border rounded-sm px-2 py-1 text-sm tabular-nums focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
|
||||
Reference in New Issue
Block a user