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:
2026-09-21 01:44:21 +02:00
parent 4065ac7e6a
commit c5e022f056
39 changed files with 475 additions and 327 deletions
+30 -26
View File
@@ -7,8 +7,8 @@ worse than no rule — if you change how the code works, change this file in the
Siftlode is a self-hosted YouTube reader (+ a local Plex browser/player and a download center). It is a
**modular monolith**: one Postgres, one FastAPI process (uvicorn + APScheduler), one worker process
(yt-dlp/ffmpeg), one PO-token sidecar. React 18 + react-query frontend with a router-free navigation
provider.
(yt-dlp/ffmpeg), one PO-token sidecar. React 19 (with the React Compiler) + react-query frontend, with a
router-free navigation provider.
## The gate — never merge or ship red
@@ -75,30 +75,34 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
## Canonical patterns — when you have a choice, pick THIS
| Area | Use | Not |
| ----------------------- | ------------------------------------------------------------ | ------------------------------------------------------ |
| ORM | SQLAlchemy 2.0 `select()` | `db.query(...)` |
| Request body | a Pydantic model | `payload: dict` |
| Request SIZE | nothing — `bodylimit` caps everything (SEC-20) | `capped_body`, except for a LARGER per-route cap |
| Ownership check | `owned_or_404` / `get_or_404` | ad-hoc `_own_playlist`/`_owned`/`_own_job` |
| Admin gate import | `app.auth` | the `app.routes.admin` re-export |
| Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) |
| User-facing error | structured `detail` (`{code, reason}`) | a bare English string under a bilingual UI |
| Mutation response | `{"ok": true, …}` (`{"deleted": id}`), test-held | a bare `{"unsubscribed"}`/`{"cleared": n}`, no `ok` |
| Transaction commit | the handler commits; helpers `flush` (test-held list) | helpers that commit (only `quota` owns its session) |
| Admin knob storage | `sysconfig` (values) | env / `app_state` / `scheduler_settings` scatter |
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks |
| FE action button | `ui/Button` — pick a `SIZES` rung | a hand-rolled `<button className="px-3 py-2 …">` |
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
| FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler |
| FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `<body>` |
| FE control | `SegmentedToggle`; a long or user's list: `FilterSelect` | a native `<select>` (lint-enforced) |
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)` | a bare `catch {}` or `notify()` with no error attached |
| FE network call | `req`/`uploadReq`/`beacon`, or `lib/http.ts` `send` (public pages) | a bare `fetch`/`XMLHttpRequest`/`WebSocket` (lint-enforced) |
| FE diagnostics | `lib/log.ts` (`log.info("event.code", msg, attrs)`); a filter change → `lib/filterLog.ts`, a player → `lib/mediaLog.ts` | `console.*` (lint-enforced); a value only a higher level may carry, logged unguarded |
| Area | Use | Not |
| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| ORM | SQLAlchemy 2.0 `select()` | `db.query(...)` |
| Request body | a Pydantic model | `payload: dict` |
| Request SIZE | nothing — `bodylimit` caps everything (SEC-20) | `capped_body`, except for a LARGER per-route cap |
| Ownership check | `owned_or_404` / `get_or_404` | ad-hoc `_own_playlist`/`_owned`/`_own_job` |
| Admin gate import | `app.auth` | the `app.routes.admin` re-export |
| Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) |
| User-facing error | structured `detail` (`{code, reason}`) | a bare English string under a bilingual UI |
| Mutation response | `{"ok": true, …}` (`{"deleted": id}`), test-held | a bare `{"unsubscribed"}`/`{"cleared": n}`, no `ok` |
| Transaction commit | the handler commits; helpers `flush` (test-held list) | helpers that commit (only `quota` owns its session) |
| Admin knob storage | `sysconfig` (values) | env / `app_state` / `scheduler_settings` scatter |
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks |
| FE action button | `ui/Button` — pick a `SIZES` rung | a hand-rolled `<button className="px-3 py-2 …">` |
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
| FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler |
| FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `<body>` |
| FE control | `SegmentedToggle`; a long or user's list: `FilterSelect` | a native `<select>` (lint-enforced) |
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)` | a bare `catch {}` or `notify()` with no error attached |
| FE network call | `req`/`uploadReq`/`beacon`, or `lib/http.ts` `send` (public pages) | a bare `fetch`/`XMLHttpRequest`/`WebSocket` (lint-enforced) |
| FE diagnostics | `lib/log.ts` (`log.info("event.code", msg, attrs)`); a filter change → `lib/filterLog.ts`, a player → `lib/mediaLog.ts` | `console.*` (lint-enforced); a value only a higher level may carry, logged unguarded |
| FE state that MIRRORS a prop/query | derive it, or hold a draft (`draft ?? server`) | a copy an effect re-syncs — that paints the stale value first |
| FE state reset on a key change | adjust it DURING render against a remembered key | `useEffect(() => setX(init), [key])` |
| FE effect that must not re-subscribe | `useEffectEvent` (React 19.2+), else a latest-value ref | an excused dep list asserting the same thing in prose |
| FE hook suppression | an inline `eslint-disable-next-line` NAMING the invariant | `eslint-suppressions.json` (deliberately absent — E27 S2) |
## House rules that bite (learned from real bugs)
-151
View File
@@ -1,151 +0,0 @@
{
"src/components/BootGate.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/ChannelPage.tsx": {
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/components/ChannelTags.tsx": {
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/components/ConfigPanel.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/DateFilterSelect.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/DebugRecorder.dom.test.tsx": {
"react-hooks/globals": {
"count": 1
}
},
"src/components/DebugRecorder.tsx": {
"react-hooks/purity": {
"count": 1
}
},
"src/components/DiagnosticsSettings.tsx": {
"react-hooks/purity": {
"count": 1
}
},
"src/components/DownloadCenter.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/Feed.tsx": {
"react-hooks/immutability": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/FeedFiltersProvider.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/GlassTuner.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
},
"react-hooks/use-memo": {
"count": 1
}
},
"src/components/Home.tsx": {
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/components/IssueComposer.tsx": {
"react-hooks/immutability": {
"count": 2
}
},
"src/components/MyLogViewer.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/PersonalViewsModal.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/Playlists.tsx": {
"react-hooks/immutability": {
"count": 2
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/PlexInfo.tsx": {
"react-hooks/static-components": {
"count": 3
}
},
"src/components/PlexPlayer.tsx": {
"react-hooks/immutability": {
"count": 1
}
},
"src/components/PlexSearchBox.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/Scheduler.tsx": {
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/components/SetupWizard.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/TagFilterChips.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/WatchPage.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/Welcome.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/comfortPin.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/downloadRow.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/usePlexPlaylistEdits.ts": {
"react-hooks/set-state-in-effect": {
"count": 1
}
}
}
+8 -5
View File
@@ -188,13 +188,16 @@ export default tseslint.config(
rules: {
...reactHooks.configs.recommended.rules,
// eslint-plugin-react-hooks 7's `recommended` is no longer two rules — it carries the whole
// React Compiler rule set (E27 S1). On this codebase that lands 283 findings, and they were
// React Compiler rule set (E27 S1). On this codebase that landed 283 findings, and they were
// triaged rather than bulk-suppressed, because the two answers are different:
//
// react-hooks/refs 217 — OFF, see below.
// the other 66 — real, and suppressed into eslint-suppressions.json as a RATCHET:
// new violations fail the gate, the existing list burns down in E27 S2
// (45 of them are `set-state-in-effect`, which IS that sprint's subject).
// the other 66 — real. They went into eslint-suppressions.json as a ratchet for one
// sprint and E27 S2 burned the file down to nothing: what survives is
// an inline directive at the site, each naming the invariant it
// protects. There is DELIBERATELY no suppressions file any more — a
// bulk `--suppress-all` would hide the next violation instead of
// answering it, and it cannot hold a warning-level rule anyway.
//
// `refs` is off because it fires on this app's popover architecture, not on its history, so
// suppressing it would not help the next popover anyone writes. Measured over the 217:
@@ -203,7 +206,7 @@ export default tseslint.config(
// down as a prop (`popover={pop}`, `ref={fade.ref}`), which is what `components/ui`'s popover
// engine is FOR. That is 153 of 217 flagged for having the word `refs` in an object that is not
// a React ref. The remaining ~64 are genuine render-phase `someRef.current = x` writes and are
// real; they are E27 S2's work, not this rule's, because the rule cannot tell them apart here.
// real (E27 S2 answered the ones the other rules could see, at their sites).
// Re-evaluate when the rule can: it shipped in 1.0.0 and this looks like over-approximation.
"react-hooks/refs": "off",
"local/jsx-comment-text": "error",
@@ -191,6 +191,26 @@ describe("BootGate", () => {
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5");
});
// The adoption effect is idempotent for the identity it already adopted, and that is load-bearing
// rather than tidy: a re-run reloads the page, so an effect that cannot tell "already adopted this
// account" from "adopted a different one" turns an ordinary boot into a reload loop. The spy is
// installed BEFORE mounting on purpose — the other reload tests stub it after the app is up, which
// is exactly late enough to miss the re-run that setting `readyFor` triggers.
it("a plain boot never reloads, however many times the adoption effect re-runs", async () => {
const reload = vi.fn();
vi.stubGlobal("location", { ...window.location, reload });
vi.spyOn(api, "me").mockResolvedValue(account());
const qc = mount();
await screen.findByTestId("app");
// Anything that re-renders the gate must leave it alone: a pref save, a background refetch.
act(() => qc.setQueryData(qk.me(), account({ preferences: { startup: "last" } })));
await act(() => new Promise((r) => setTimeout(r, 20)));
expect(reload).not.toHaveBeenCalled();
expect(mounts).toBe(1);
});
it("a change to the account's data, not its identity, leaves the app as it is", async () => {
vi.spyOn(api, "me").mockResolvedValue(account());
const qc = mount();
+19 -11
View File
@@ -1,4 +1,4 @@
import { useLayoutEffect, useState, type ReactNode } from "react";
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { api, getActiveAccount, setActiveAccount } from "../lib/api";
@@ -83,23 +83,31 @@ export default function BootGate({ children }: { children: ReactNode }) {
// • Adopt the account's language, which follows the account across devices — the local copy only
// knows what this browser last showed.
const [readyFor, setReadyFor] = useState<number | null | undefined>(undefined);
// `me` is read for the identity it produced, so it rides a ref: a background refetch of the same
// account must not re-run the adoption over a language the user has just changed. That rule used
// to live in a dep list that excluded `me`, which made the dep list load-bearing for correctness —
// and the failure mode of getting it wrong was a page-reload loop, not a stale value.
const meRef = useRef(me);
meRef.current = me;
useLayoutEffect(() => {
if (identity === undefined) return;
if (readyFor !== undefined) {
// The tree was built for another identity — see the header. The pin stays as it is: the tree
// being torn down still runs its unmount saves before the page goes, and with the pin moved they
// would land under the new account's local keys. The reloaded page's first probe corrects it.
location.reload();
// Already adopted. Only a DIFFERENT identity reloads: the tree was built for another account —
// see the header. The pin stays as it is: the tree being torn down still runs its unmount saves
// before the page goes, and with the pin moved they would land under the new account's local
// keys. The reloaded page's first probe corrects it. Comparing rather than merely testing for
// "set" is what makes this effect idempotent, so a re-run for any other reason is a no-op
// instead of a reload.
if (readyFor !== identity) location.reload();
return;
}
if (me && getActiveAccount() !== me.id) setActiveAccount(me.id);
const lang = me?.preferences?.language;
const account = meRef.current;
if (account && getActiveAccount() !== account.id) setActiveAccount(account.id);
const lang = account?.preferences?.language;
if (typeof lang === "string" && isSupported(lang) && lang !== i18n.language) setLanguage(lang);
// eslint-disable-next-line react-hooks/set-state-in-effect -- the adoption latch. It cannot be derived: the whole point is that the tree mounts only AFTER the pin and the language have been applied, and both are writes to things outside React (sessionStorage, i18n). A layout effect so the frame that first shows the app already has them.
setReadyFor(identity);
// `me` is read for the identity it produced; a background refetch of the same account must not
// re-run the adoption over a language the user has just changed.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [identity]);
}, [identity, readyFor]);
// No answer at all (a network or 5xx failure on the very first setup or account probe — past the
// one retry `req` already makes): say so, with a retry. An unanswered setup probe is NOT "signed
+8 -1
View File
@@ -95,7 +95,13 @@ export default function ChannelPage({
intervalMs: channelDetailPollMs,
});
const ch = detail.data;
useEffect(() => setBannerAttempt(0), [ch?.banner_url]); // fresh channel/banner → try again
// A fresh channel/banner gets the retries back, in the render that changes it: as an effect the
// new banner was committed with the previous one's exhausted attempt count still in place.
const [bannerFor, setBannerFor] = useState(ch?.banner_url);
if (bannerFor !== ch?.banner_url) {
setBannerFor(ch?.banner_url);
setBannerAttempt(0);
}
// The poll above is also the ONLY signal that the running backfill has written videos, so it
// drives the video grid's refresh too (the "Get full history" mutation below can't: it fires on
// the PATCH response, before the background job has stored anything).
@@ -137,6 +143,7 @@ export default function ChannelPage({
if (ch.subscribed || ch.blocked) return;
if (ch.explored && ch.stored_videos > 0) return;
autoTried.current = channelId;
// eslint-disable-next-line react-hooks/set-state-in-effect -- a one-off INGEST of an unsubscribed channel's uploads on first visit — a network side effect, not state. The autoTried ref keyed on the channel id is what makes it once per channel.
void runExplore();
}, [ch, channelId, me.is_demo, runExplore]);
+10 -11
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useRef, useState } from "react";
import { Check, Plus, Sparkles, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { type Tag } from "../lib/api";
@@ -119,16 +119,17 @@ function SuggestedChipsLane({
});
const hidden = suggestions.length - visible;
const [overflowOpen, setOverflowOpen] = useState(false);
// Open only while there is a "+N" trigger to anchor it — derived, not closed by an effect, so the
// frame in which the last collapsed chip fits again cannot also hold a menu pointing at a
// reference that has gone. Same shape as TagFilterChips and the second row below.
const [overflowOpenRaw, setOverflowOpen] = useState(false);
const overflowOpen = overflowOpenRaw && hidden > 0;
const overflowPop = useAnchoredPopover({
open: overflowOpen,
onOpenChange: setOverflowOpen,
mode: "hover",
placement: "bottom",
});
useEffect(() => {
if (hidden === 0 && overflowOpen) setOverflowOpen(false);
}, [hidden, overflowOpen]);
return (
<div className={`${chipLaneClass} flex items-center gap-1 min-w-0 flex-1`}>
@@ -353,18 +354,16 @@ export function ChannelTagsControl({
hostsInput: true,
});
const [overflowOpen, setOverflowOpen] = useState(false);
// Derived for the same reason as the suggestion row above: if tags change so nothing is collapsed
// anymore, the "+N" trigger unmounts, and the popover must already be shut in that same render.
const [overflowOpenRaw, setOverflowOpen] = useState(false);
const overflowOpen = overflowOpenRaw && hidden > 0;
const overflowPop = useAnchoredPopover({
open: overflowOpen,
onOpenChange: setOverflowOpen,
mode: "hover",
placement: "bottom",
});
// If tags change so nothing is collapsed anymore, the "+N" trigger unmounts — force the reveal-all
// popover's state closed so it can't linger open against a gone reference.
useEffect(() => {
if (hidden === 0 && overflowOpen) setOverflowOpen(false);
}, [hidden, overflowOpen]);
const pillClass =
"shrink-0 text-3xs px-1.5 py-0.5 rounded-full bg-card text-muted border border-border";
+8 -3
View File
@@ -281,10 +281,15 @@ export default function Channels() {
},
];
// A focus-channel intent (header "without full history" / tag manager) seeds the search box.
// The TOKEN is the intent (it advances even when the same channel is re-focused); the name is the
// payload, so it rides a ref. Naming it would re-seed the box on any render that changed it,
// overwriting whatever the user had since typed.
const focusChannelNameRef = useRef(focusChannelName);
focusChannelNameRef.current = focusChannelName;
useEffect(() => {
if (focusChannelName) setSearch(focusChannelName);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [focusChannelToken]);
const name = focusChannelNameRef.current;
if (name) setSearch(name);
}, [focusChannelToken, setSearch]);
// The header's reset intent clears the search box (skip the initial mount); the tag chips ride in
// `filters.tags`, which goToFullHistory already clears by setting a fresh map.
const resetRef = useRef(filtersResetToken);
+8 -4
View File
@@ -42,6 +42,10 @@ export default function ChatThread({
{ intervalMs: POLL_MS, enabled: !needsKey },
);
const items = q.data?.items ?? [];
// The two effects below take the FETCH as their trigger and read the payload through this ref, so
// their dep lists name everything they touch without making the identity of `q.data` a trigger.
const threadRef = useRef(q.data);
threadRef.current = q.data;
// Decrypt the thread when fresh server data arrives. Keyed on dataUpdatedAt (not `items`): the
// `?? []` fallback is a new array reference every render, which would otherwise re-run this
@@ -56,7 +60,7 @@ export default function ChatThread({
// hasn't re-set-up, so has no key) degrades to "can't decrypt" instead of rejecting the pass.
let pub: string | null = null;
let refreshed = false;
for (const m of q.data?.items ?? []) {
for (const m of threadRef.current?.items ?? []) {
if (!m.ciphertext || !m.iv) continue;
try {
if (!pub) pub = await partnerPub(partnerId);
@@ -84,7 +88,8 @@ export default function ChatThread({
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
// `dataUpdatedAt` is the trigger and the payload is read through the ref above — see Messages,
// where keying this family on `q.data` itself produced a render loop.
}, [q.dataUpdatedAt, ready, isSystem, partnerId, t]);
// Opening the thread (or a new incoming message arriving) marks messages read server-side, so
@@ -101,13 +106,12 @@ export default function ChatThread({
lastPartner.current = partnerId;
lastIncoming.current = -1;
}
const incoming = (q.data?.items ?? []).filter((m) => m.recipient_id === meId).length;
const incoming = (threadRef.current?.items ?? []).filter((m) => m.recipient_id === meId).length;
if (incoming !== lastIncoming.current) {
lastIncoming.current = incoming;
qc.invalidateQueries({ queryKey: qk.conversations() });
qc.invalidateQueries({ queryKey: qk.messageUnread() });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [q.dataUpdatedAt, partnerId, meId, qc]);
// Scroll to the newest message when the count changes (open / new message) — NOT on `plain`,
+7 -3
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -59,10 +59,14 @@ export default function ConfigPanel() {
// load, registry change) or an explicit post-save bump — so a refetch that resolves mid-edit
// (e.g. an invalidation) can no longer clobber the admin's in-progress draft (CB2).
const dataVersion = useMemo(() => items.map((i) => i.key).join("|"), [items]);
// The baseline is read through a ref, not named as a dependency: an unchanged refetch produces a
// new object, and re-seeding on that is exactly the clobbering of an in-progress draft that CB2
// was about. `dataVersion` (the config SHAPE) and the post-save token are the real triggers.
const baselineRef = useRef(baseline);
baselineRef.current = baseline;
useEffect(() => {
setDraft(baseline);
setDraft(baselineRef.current);
setSecretReset({});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataVersion, reseedToken]);
const isDirty = (it: ConfigItem): boolean => {
@@ -40,6 +40,7 @@ export default function DateFilterSelect({
// useLayoutEffect (not useEffect) so the reset commits BEFORE paint — otherwise the trigger flashes
// one stale "Custom range" frame between the external clear and this reset.
useLayoutEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- the reveal is NOT derivable from the range — the user opens it on an empty range and fills it in afterwards. This undoes it only when the range is cleared from OUTSIDE while the popover is shut, and it is a layout effect so the trigger never paints a stale "Custom range".
if (!open && customOpen && !filters.dateFrom && !filters.dateTo) setCustomOpen(false);
}, [open, customOpen, filters.dateFrom, filters.dateTo]);
@@ -108,6 +108,7 @@ afterEach(() => {
let composing: string | null = null;
function ComposerProbe() {
// eslint-disable-next-line react-hooks/globals -- a TEST probe: a module-level handle is how the test reads a hook's value from outside the tree without a wrapper that would change what is being measured.
composing = useComposerSession();
return null;
}
@@ -44,6 +44,7 @@ function useElapsed(since: string): string {
const id = window.setInterval(() => tick((n) => n + 1), 1000);
return () => window.clearInterval(id);
}, []);
// eslint-disable-next-line react-hooks/purity -- the clock is READ at render and the interval above only re-renders — a `now` held in state is the moment the component mounted, which is the defect DiagnosticsSettings records from prod UAT.
return formatDuration(Math.max(0, Math.floor((Date.now() - Date.parse(since)) / 1000)));
}
@@ -26,6 +26,7 @@ function useRemaining(until: string | null): { h: number; m: number } | null {
return () => window.clearInterval(id);
}, [until]);
if (!until) return null;
// eslint-disable-next-line react-hooks/purity -- same as DebugRecorder's elapsed clock, and the comment above records the prod UAT finding that a state-held `now` produced: a level raised two minutes after opening Settings read "24 h 2 m" until the first tick.
const minutes = Math.max(0, Math.ceil((Date.parse(until) - Date.now()) / 60_000));
return { h: Math.floor(minutes / 60), m: minutes % 60 };
}
+5 -5
View File
@@ -1,4 +1,4 @@
import { lazy, Suspense, useEffect, useState } from "react";
import { lazy, Suspense, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -152,10 +152,10 @@ export default function DownloadCenter() {
];
// Guard against the active tab going away under us (e.g. admin on "system" gets demoted mid-session)
// — that would render a blank page with no active tab. Fall back to the default.
useEffect(() => {
if (!tabs.some((tb) => tb.id === tab)) setTab("queue");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab, me.role]);
// Adjusted during render rather than from an effect: as an effect the blank page it exists to
// prevent was committed and painted first. `setTab` is this component's own state (persisted per
// account), so a render-phase write is allowed, and it converges — "queue" is always in the list.
if (!tabs.some((tb) => tb.id === tab)) setTab("queue");
const saveBtn = (job: DownloadJob) => (
<a
+18 -6
View File
@@ -220,17 +220,21 @@ export default function Feed({
if (qEmpty && parseSort(filters.sort).key === "relevance") {
setFilters({ ...filters, sort: buildSort("date", "desc") });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [qEmpty]);
// Naming `filters` is safe precisely because the write below falsifies the condition above: the
// sort it sets is "date", so the one re-run it causes does nothing. The old `[qEmpty]` was
// guarding against a loop that the guard already prevents.
}, [qEmpty, filters, setFilters]);
// Drop optimistic status overrides when filters change OR fresh server data
// arrives — after a refetch the server is authoritative, so a stale override
// (e.g. a video reverted to "new" from the notification center) won't keep it
// filtered out of the current view.
useEffect(() => {
const [overridesFor, setOverridesFor] = useState(filters);
if (overridesFor !== filters) {
setOverridesFor(filters);
setOverrides({});
setSavedOverrides({});
}, [filters]);
}
// ...but NOT on infinite-scroll appends: fetchNextPage also bumps dataUpdatedAt while page 1's
// rows are unchanged, so clearing overrides there would make a just-hidden card flash back.
// Only a real refetch (page count doesn't grow) is authoritative.
@@ -284,7 +288,7 @@ export default function Feed({
message: v?.title
? i18n.t("feed.hiddenNamed", { title: v.title })
: i18n.t("feed.hidden"),
action: { label: i18n.t("feed.undo"), onClick: () => onState(id, "new") },
action: { label: i18n.t("feed.undo"), onClick: () => onStateRef.current(id, "new") },
meta: {
kind: "video-hidden",
videoId: id,
@@ -299,7 +303,10 @@ export default function Feed({
message: v?.title
? i18n.t("feed.markedWatchedNamed", { title: v.title })
: i18n.t("feed.markedWatched"),
action: { label: i18n.t("feed.unwatch"), onClick: () => onState(id, "new") },
action: {
label: i18n.t("feed.unwatch"),
onClick: () => onStateRef.current(id, "new"),
},
meta: {
kind: "video-watched",
videoId: id,
@@ -328,6 +335,11 @@ export default function Feed({
},
[qc],
);
// The toast's Undo action re-enters onState, which is a forward reference to the binding still
// being defined — the same shape PlexPlayer's session reclaim has, and answered the same way.
const onStateRef = useRef(onState);
// eslint-disable-next-line react-hooks/immutability
onStateRef.current = onState;
const onResetState = useCallback(
(id: string) => {
@@ -74,6 +74,7 @@ export function FeedFiltersProvider({ children }: { children: ReactNode }) {
// means exactly what it says: write what the URL brought, once.
const [urlSeededFilters] = useState(filters);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- setFilters also WRITES to per-account localStorage, which is the point here — persisting what a share link brought. That is a side effect, not a value to derive, and the frozen seed above makes it happen exactly once.
if (initialHadUrlFilters) setFilters(urlSeededFilters);
}, [initialHadUrlFilters, urlSeededFilters, setFilters]);
+5 -1
View File
@@ -141,7 +141,10 @@ export default function GlassTuner() {
// Opt-in via Settings → Appearance (theme.showTuner, off by default); the flag lives in PrefsProvider.
const enabled = usePrefs().theme.showTuner;
const { t } = useTranslation();
const initial = useMemo(loadSaved, []);
// Read once at mount. useState's lazy initializer rather than a useMemo with an empty dep list:
// a memo is a caching hint React may discard and recompute, while "the saved values as they were
// when this mounted" is state — and the effect below applies them exactly once.
const [initial] = useState(loadSaved);
const [vars, setVars] = useState<Record<string, number>>(() => {
const base: Record<string, number> = {};
ALL.forEach((s) => (base[s.k] = s.def));
@@ -167,6 +170,7 @@ export default function GlassTuner() {
const v = (initial.palette?.[p.k] || readVar(p.k)).trim();
sw[p.k] = /^#[0-9a-fA-F]{6}$/.test(v) ? v : "#000000";
});
// eslint-disable-next-line react-hooks/set-state-in-effect -- the swatches are READ FROM THE DOM (readVar on the computed style) after the saved overrides have been applied to it, so they cannot exist before a commit.
setSwatches(sw);
}, [initial]);
+2
View File
@@ -474,6 +474,7 @@ export default function Home() {
hero: DiscoverPayload["hero"];
} | null>(null);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- deliberately OUTLIVES the query: a failed reroll is terminal (retry:false) and must leave the previous answer on screen with the budget tag the SERVER echoed for it. Derived from pickQ it would vanish with the query.
if (pickQ.data !== undefined) setLastPick({ budget: pickQ.data.budget, hero: pickQ.data.hero });
}, [pickQ.data]);
@@ -695,6 +696,7 @@ export default function Home() {
// `reroll`'s changing identity causes — and to a token minted for another history entry.
const surpriseRequest = useSurpriseRequest();
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- consuming a cross-page token (lib/homeIntent) that was minted before this module's chunk existed. takeSurprise() clears it and answers false to every later pass, including StrictMode's second mount.
if (surpriseRequest !== null && takeSurprise()) reroll("surprise");
}, [surpriseRequest, reroll]);
@@ -603,9 +603,11 @@ function Attachments({
// A removed screenshot's ✕ goes with it: the focus moves to the file picker, which stays.
const pick = useRef<HTMLInputElement>(null);
const reviewButtons = useRef<Partial<Record<Source, HTMLButtonElement | null>>>({});
// eslint-disable-next-line react-hooks/immutability
useEffect(() => {
const source = reviewed.current;
if (!source) return;
// eslint-disable-next-line react-hooks/immutability -- a ONE-SHOT token, consumed: the parent sets it to say "give this source's review button the focus back when the list re-renders", and clearing it here is what stops the focus being stolen on every later render. The ref is the caller's, which is why it is a dependency rather than a local.
reviewed.current = null;
reviewButtons.current[source]?.focus();
}, [reviewed]);
+10 -6
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
@@ -85,15 +85,20 @@ function ConversationList({
const items = q.data?.items ?? [];
const [previews, setPreviews] = useState<Record<number, string>>({});
// Decrypt conversation previews when fresh data arrives. Keyed on dataUpdatedAt (not `items`):
// the `?? []` fallback is a new array each render, which would otherwise spin this effect
// (and setPreviews) every render while the query is loading.
// Decrypt conversation previews when fresh data arrives. The TRIGGER is `dataUpdatedAt` and the
// payload is read through a ref, which is what makes the dep list below complete without making
// the identity of `q.data` a trigger. That distinction is load-bearing: the `?? []` fallback is a
// new array every render, and anything handing back a fresh object per render (a stub, a caller
// that rebuilds it) turns "re-run when the data changes" into a render loop through setPreviews.
// Measured the hard way — E27 S2 keyed this on `q.data`, and Messages.keyboard.dom.test hung.
const conversationsRef = useRef(q.data);
conversationsRef.current = q.data;
useEffect(() => {
if (!ready) return;
let cancelled = false;
(async () => {
const out: Record<number, string> = {};
for (const c of q.data?.items ?? []) {
for (const c of conversationsRef.current?.items ?? []) {
const m = c.last_message;
if (m.kind === "user" && m.ciphertext && m.iv) {
try {
@@ -109,7 +114,6 @@ function ConversationList({
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [q.dataUpdatedAt, ready]);
function previewText(c: Conversation): string {
+1
View File
@@ -33,6 +33,7 @@ function useLogEvents(): LogEvent[] {
setList(events());
});
});
// eslint-disable-next-line react-hooks/set-state-in-effect -- this IS the subscribe-to-an-external-store shape the rule's own text allows: the ring fills outside React, and a sync at subscribe time is what closes the gap between the first render and the listener. useSyncExternalStore would drop the rAF coalescing, which is the point at a few hundred events a second.
setList(events()); // whatever arrived between the first render and the subscription
return () => {
unsubscribe();
+5 -3
View File
@@ -87,6 +87,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
meta: { errorShownInline: true },
});
const { mutate: startImport } = importSubs;
// First time we see read access with zero imported channels, pull the user's
// subscriptions automatically so they don't have to hunt for a button.
useEffect(() => {
@@ -97,10 +98,11 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
!importTriggered.current
) {
importTriggered.current = true;
importSubs.mutate();
startImport();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [me.can_read, myStatus.data?.channels_total]);
// `mutate` is stable (react-query memoises it) while the mutation object around it is not, so
// the effect can name what it calls. The ref keeps it to once regardless.
}, [me.can_read, myStatus.data, startImport]);
// Closing without finishing the essential read step suppresses future auto-popups
// (it stays reachable from Settings). Once read is granted, closing is just "done".
+10 -7
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { BookmarkPlus, Check, GripVertical, Play, Save, Star, Trash2, X } from "lucide-react";
import {
@@ -107,13 +107,16 @@ export default function PersonalViewsModal() {
},
});
const resetDraft = filtersDraft.reset;
// Re-seed only when the SELECTION changes, not on every same-view refetch — a refetch returns the
// value we just wrote, and re-seeding on it would throw away the prune's undo history. The chosen
// view is therefore read through a ref: `selectedId` is the trigger, `selected` is only the data.
const selectedRef = useRef(selected);
selectedRef.current = selected;
useEffect(() => {
resetDraft(selected?.filters ?? {});
setNameDraft(selected?.name ?? "");
// Re-seed only when the selection changes (not on every same-view refetch), so pruning keeps its
// undo history. Save-current re-seeds explicitly in its own handler.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedId]);
const view = selectedRef.current;
resetDraft(view?.filters ?? {});
setNameDraft(view?.name ?? "");
}, [selectedId, resetDraft]);
// The selected view's captured filters, as the same human labels its module's chip row shows, read
// from the undoable draft. The feed hook runs unconditionally (fed empty filters off-feed); Plex
+18 -7
View File
@@ -290,6 +290,9 @@ export default function Playlists() {
});
const detail = detailQuery.data;
// Destructured so the effect below can name what it calls: both are stable useCallbacks, while
// the object around them is rebuilt every render (lib/useUndoable).
const { reset: resetOrder, rebase: rebaseOrder } = order;
useEffect(() => {
if (!detail) return;
// Scoped to the playlist: two lists holding the SAME videos would otherwise look identical to
@@ -297,7 +300,7 @@ export default function Playlists() {
const key = `${selectedId}:${idsKey(detail.items)}`;
if (key !== lastSetRef.current) {
lastSetRef.current = key;
order.reset(detail.items);
resetOrder(detail.items);
} else {
// Same membership, fresher ROWS — a watch position the player just stored, a status. The
// guard above looks at the id set only, so without this the list kept showing, and handing the
@@ -305,17 +308,23 @@ export default function Playlists() {
// reopen it, and it resumed at the old spot and the next checkpoint wrote that over the 830.
// `rebase` takes the fresh rows into every snapshot and keeps the order and its undo history.
const fresh = new Map(detail.items.map((v) => [v.id, v]));
order.rebase((list) => list.map((v) => fresh.get(v.id) ?? v));
rebaseOrder((list) => list.map((v) => fresh.get(v.id) ?? v));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [detail]);
// `reset` and `rebase` are stable useCallbacks (lib/useUndoable), so naming them costs nothing;
// `selectedId` moves only together with `detail` anyway, since detail IS the selected playlist's.
}, [detail, selectedId, resetOrder, rebaseOrder]);
// Reset the view when switching playlists (the stored order itself is per-playlist).
useEffect(() => {
// Reset the view when switching playlists (the stored order itself is per-playlist) — during the
// render that switches, so the incoming playlist is never drawn once through the previous one's
// sort, filters and grouping. All three are this component's own state, which is what makes a
// render-phase write legal here.
const [viewFor, setViewFor] = useState(selectedId);
if (viewFor !== selectedId) {
setViewFor(selectedId);
setViewSort(STORED_SORT);
setFilters({});
setGroupBy(false);
}, [selectedId]);
}
const builtin = detail?.kind === "watch_later";
// YouTube-mirrored playlists can be edited locally and synced back (the read-sync skips a
@@ -385,6 +394,7 @@ export default function Playlists() {
// Read by the drag announcements, which must report the CURRENT positions without making the
// announcement closures a render dependency of DndContext.
const shownRef = useRef(shown);
// eslint-disable-next-line react-hooks/immutability -- the house latest-value ref: the announcement closures must read the CURRENT list without the list becoming a render dependency of DndContext. A render-phase ref write is never part of the rendered output, which is what makes it safe; the checker is conservative about refs an effect also reads.
shownRef.current = shown;
const viewActive = !isStoredSort(viewSort) || groupBy || filterActive;
@@ -595,6 +605,7 @@ export default function Playlists() {
}
// Kept in a ref so the memoized column callbacks don't rebuild on every state change.
const removeRef = useRef(removeItem);
// eslint-disable-next-line react-hooks/immutability -- same latest-value ref as shownRef above — it keeps the memoised column callbacks from rebuilding on every state change.
removeRef.current = removeItem;
async function moveItemTo(video: Video, target: Playlist) {
+11 -9
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
@@ -91,7 +91,9 @@ export default function PlaylistsRail({
queryFn: () => api.playlists(),
refetchOnWindowFocus: true,
});
const playlists = listQuery.data ?? [];
// Memoised so the effect and the sort below depend on a value that only changes with the query —
// `?? []` would otherwise mint a new empty array on every render while the list is loading.
const playlists = useMemo(() => listQuery.data ?? [], [listQuery.data]);
// Keep the selection valid: default to the first playlist, and if the selected one disappears
// drop to the next available (or clear when none remain). Waits for the first load.
@@ -104,10 +106,12 @@ export default function PlaylistsRail({
if (selectedId == null || !playlists.some((p) => p.id === selectedId)) {
setSelectedId(playlists[0]!.id); // playlists is non-empty (the length guard above returns)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [playlists, selectedId, listQuery.data]);
}, [playlists, selectedId, listQuery.data, setSelectedId]);
const plName = (p: { kind: string; name: string }) => playlistName(p, t);
// useCallback because the two memos below sort by it: as a fresh closure each render it could not
// be named in their dep lists, and `t` — which is what actually changes it, on a language switch —
// was therefore invisible to them. A rename in the other language now re-sorts.
const plName = useCallback((p: { kind: string; name: string }) => playlistName(p, t), [t]);
const sortedPlaylists = useMemo(() => {
const sign = plSort.dir === "asc" ? 1 : -1;
@@ -126,15 +130,13 @@ export default function PlaylistsRail({
return 0;
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [playlists, plSort]);
}, [playlists, plSort, plName]);
const q = search.trim().toLowerCase();
const visiblePlaylists = useMemo(
() =>
q ? sortedPlaylists.filter((p) => plName(p).toLowerCase().includes(q)) : sortedPlaylists,
// eslint-disable-next-line react-hooks/exhaustive-deps
[sortedPlaylists, q],
[sortedPlaylists, q, plName],
);
useEffect(() => {
+38 -18
View File
@@ -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>
)}
+1
View File
@@ -511,6 +511,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// The self-restart path above reaches the CURRENT loadSession through this, so the recursion is
// not a forward reference to a binding that is still being defined.
const loadSessionRef = useRef(loadSession);
// eslint-disable-next-line react-hooks/immutability -- the latest-value ref the self-restart path above reads; see the comment there for why the recursion cannot name loadSession directly.
loadSessionRef.current = loadSession;
// Audio/subtitle track selection + subtitle rendering. Owns the selected-ordinal state; the shared
+8 -2
View File
@@ -48,8 +48,14 @@ export default function PlexSearchBox() {
const suggestions = suggestEnabled ? (suggestQ.data?.items ?? []) : [];
const open = suggestEnabled && (suggestQ.isFetching || suggestions.length > 0);
// A fresh query resets the highlight.
useEffect(() => setHi(-1), [dq, activeField, scope]);
// A fresh query resets the highlight — during THAT render, so no frame ever draws the new
// suggestion list with the old row highlighted.
const suggestKey = `${scope}|${activeField}|${dq}`;
const [hiFor, setHiFor] = useState(suggestKey);
if (hiFor !== suggestKey) {
setHiFor(suggestKey);
setHi(-1);
}
// Keep the highlighted option scrolled into view during arrow-key navigation.
useEffect(() => {
if (hi < 0) return;
@@ -0,0 +1,157 @@
// @vitest-environment jsdom
//
// The scheduler's two number editors — a job's interval and the maintenance batch size. Both used to
// hold a MIRROR of the server value that an effect re-synced; both now show the server value and keep
// a draft only while the field is being edited (E27 S2). That matters here more than elsewhere
// because this page polls: every few seconds a fresh status arrives, and a mirror is exactly the
// thing a poll can overwrite at the wrong moment.
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
});
import Scheduler from "./Scheduler";
import { api, type SchedulerStatus } from "../lib/api";
import { qk } from "../lib/queryKeys";
const status = (over: Partial<SchedulerStatus> = {}): SchedulerStatus => ({
running_here: true,
enabled: true,
paused: false,
jobs: [
{
id: "sync",
interval_minutes: 30,
next_run: null,
running: false,
status: "ok",
last_started: null,
last_finished: null,
last_result: null,
last_error: null,
progress: null,
},
],
queue: {
channels_recent_pending: 0,
channels_deep_pending: 0,
deep_eta_seconds: 0,
videos_pending_enrich: 0,
videos_pending_shorts: 0,
videos_live_refresh: 0,
},
quota: { used_today: 0, remaining_today: 100, daily_budget: 100, backfill_reserve: 0 },
maintenance: { revalidate_batch: 5000, revalidate_batch_default: 5000, min: 1000, max: 50000 },
...over,
});
let qc: QueryClient;
let current: SchedulerStatus;
async function mount(initial = status()) {
current = initial;
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<Scheduler />
</QueryClientProvider>,
);
await screen.findByText("scheduler.maintenance.batchLabel");
}
/** A poll landing, driven through the query the page actually makes. */
async function poll(next: SchedulerStatus) {
current = next;
await act(() => qc.refetchQueries({ queryKey: qk.scheduler() }));
}
beforeEach(() => {
vi.spyOn(api, "schedulerStatus").mockImplementation(() => Promise.resolve(current));
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const intervalBox = () => screen.queryByRole("spinbutton", { name: "" });
const batchBox = () => screen.getByLabelText("scheduler.maintenance.batchLabel");
describe("Scheduler · the job interval editor", () => {
it("opens on the current interval and commits a new one", async () => {
const update = vi.spyOn(api, "updateSchedulerJob").mockResolvedValue(undefined as never);
await mount();
fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ }));
const box = intervalBox()!;
expect(box).toHaveProperty("value", "30");
fireEvent.change(box, { target: { value: "45" } });
fireEvent.keyDown(box, { key: "Enter" });
expect(intervalBox()).toBeNull(); // committing closes the editor at once
await waitFor(() => expect(update).toHaveBeenCalledWith("sync", 45));
});
it("drops what you typed on Escape and shows the server's value again", async () => {
const update = vi.spyOn(api, "updateSchedulerJob").mockResolvedValue(undefined as never);
await mount();
fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ }));
fireEvent.change(intervalBox()!, { target: { value: "999" } });
fireEvent.keyDown(intervalBox()!, { key: "Escape" });
expect(update).not.toHaveBeenCalled();
expect(intervalBox()).toBeNull();
fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ }));
expect(intervalBox()).toHaveProperty("value", "30");
});
it("keeps what you are typing when a poll lands underneath", async () => {
await mount();
fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ }));
fireEvent.change(intervalBox()!, { target: { value: "45" } });
await poll(status({ jobs: [{ ...status().jobs[0]!, interval_minutes: 30, running: true }] }));
expect(intervalBox()).toHaveProperty("value", "45");
});
it("follows the server while the editor is closed", async () => {
await mount();
await poll(status({ jobs: [{ ...status().jobs[0]!, interval_minutes: 90 }] }));
await waitFor(() =>
expect(screen.getByRole("button", { name: /scheduler.everyMin/ })).toBeTruthy(),
);
fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ }));
expect(intervalBox()).toHaveProperty("value", "90");
});
});
describe("Scheduler · the maintenance batch editor", () => {
it("is not dirty until edited, and stops being dirty once saved", async () => {
const save = vi
.spyOn(api, "updateMaintenanceBatch")
.mockResolvedValue({ revalidate_batch: 9000 });
await mount();
const saveBtn = () => screen.getByRole("button", { name: "common.save" });
expect(saveBtn()).toHaveProperty("disabled", true);
fireEvent.change(batchBox(), { target: { value: "9000" } });
expect(saveBtn()).toHaveProperty("disabled", false);
fireEvent.keyDown(batchBox(), { key: "Enter" });
expect(saveBtn()).toHaveProperty("disabled", true); // the draft is dropped at once
await waitFor(() => expect(save).toHaveBeenCalledWith(9000));
});
it("follows a poll that changes the batch while nothing is being typed", async () => {
await mount();
expect(batchBox()).toHaveProperty("value", "5000");
await poll(status({ maintenance: { ...status().maintenance, revalidate_batch: 12000 } }));
await waitFor(() => expect(batchBox()).toHaveProperty("value", "12000"));
});
});
+19 -13
View File
@@ -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"
+6 -6
View File
@@ -20,17 +20,17 @@ export default function SetupWizard() {
const { t, i18n } = useTranslation();
const [token] = useState(() => new URLSearchParams(window.location.search).get("token") ?? "");
const [info, setInfo] = useState<{ secrets_manageable: boolean } | null>(null);
const [tokenError, setTokenError] = useState(false);
// "No token at all" is not a fetch outcome, it is a fact about the URL — so it is derived rather
// than pushed into state by an effect, which had to run and commit before the error could show.
const [infoFailed, setInfoFailed] = useState(false);
const tokenError = !token || infoFailed;
useEffect(() => {
if (!token) {
setTokenError(true);
return;
}
if (!token) return;
api
.setupInfo(token)
.then(setInfo)
.catch(() => setTokenError(true));
.catch(() => setInfoFailed(true));
}, [token]);
// Step state.
+7 -6
View File
@@ -299,15 +299,16 @@ function LinkShare({ job }: { job: DownloadJob }) {
// user had pressed "New link" → "Create". All further controls (password, download toggle, expiry,
// extra links, revoke) stay available on the created link and via the "New link" button.
const autoTried = useRef(false);
// The mutation OBJECT is rebuilt every render; `mutate` is not (react-query memoises it), so the
// two halves the effect actually uses can be named while `links.data` stays the only real trigger.
// The autoTried ref is what makes it fire exactly once.
const { mutate: createLink, isPending: createPending } = create;
useEffect(() => {
if (links.data && links.data.length === 0 && !autoTried.current && !create.isPending) {
if (links.data && links.data.length === 0 && !autoTried.current && !createPending) {
autoTried.current = true;
create.mutate();
createLink();
}
// Only links.data (empty → auto-create once) should drive this; `create` is a fresh object every
// render and would re-run the effect needlessly. The autoTried ref makes it fire exactly once.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [links.data]);
}, [links.data, createLink, createPending]);
return (
<div>
+6 -8
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { fitChipsWithPill } from "../lib/chipOverflow";
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
@@ -59,19 +59,17 @@ export default function TagFilterChips({
});
const hidden = chips.length - visible;
const [open, setOpen] = useState(false);
// The popover can only be open while there is a "+N" trigger to anchor it. Derived rather than
// closed by an effect, so the frame in which the last overflowing chip fits again cannot also
// contain a menu hanging off a reference that is already gone.
const [openRaw, setOpen] = useState(false);
const open = openRaw && hidden > 0;
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
mode: "menu",
placement: "bottom-end",
});
// If nothing overflows anymore, the "+N" trigger unmounts — force the popover closed so it can't
// linger against a gone reference.
useEffect(() => {
if (hidden === 0 && open) setOpen(false);
}, [hidden, open]);
const moreLabel = t("channels.tags.moreCount", { count: hidden });
return (
+4 -5
View File
@@ -138,8 +138,10 @@ const T = STR[lang];
export default function WatchPage() {
const token = window.location.pathname.split("/watch/")[1]?.split(/[/?#]/)[0] ?? "";
// Seeded from the URL rather than corrected by the effect below: with no token there is nothing
// to load, and "loading" was committed and painted before the effect said so.
const [status, setStatus] = useState<"loading" | "ready" | "password" | "gone" | "error">(
"loading",
token ? "loading" : "gone",
);
const [meta, setMeta] = useState<Meta | null>(null);
const [password, setPassword] = useState("");
@@ -166,10 +168,7 @@ export default function WatchPage() {
// Bumped by Retry, which asks again.
const [attempt, setAttempt] = useState(0);
useEffect(() => {
if (!token) {
setStatus("gone");
return;
}
if (!token) return; // the seed above already says "gone"
send(`/api/public/watch/${encodeURIComponent(token)}`)
.then((r) => {
// Only a 404 says the link is gone (unknown, expired, revoked, or its file removed). Anything
+7 -2
View File
@@ -432,12 +432,17 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
// `done` and `error` belong to the view that produced them, and the render is `done ? msg : form`
// — without this, Back out of a submitted form would show the old success message under the
// previous view's title. Clearing on view change covers both Back/Forward and the mode links.
useEffect(() => {
// Cleared during the render that changes the view, not a commit later: the render is
// `done ? msg : form`, so an effect meant Back out of a submitted form drew the old success
// message under the new view's title for one frame — which is the exact symptom this guards.
const [outcomeFor, setOutcomeFor] = useState(mode);
if (outcomeFor !== mode) {
setOutcomeFor(mode);
setDone("");
setError("");
setBlocked(null);
setDeadReset(false);
}, [mode]);
}
// Explicit demo entry (replaces the old hidden probe): a labelled email field, still gated
// by the demo whitelist server-side.
+4 -4
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, Pin, PinOff } from "lucide-react";
@@ -100,9 +100,9 @@ export function useComfortPin(open: boolean): ComfortPin {
// Latch: react-query keeps the data cached when `enabled` goes false again, so this only decides
// WHEN the first fetch happens, not whether the answer survives the menu closing.
const [everOpen, setEverOpen] = useState(false);
useEffect(() => {
if (open) setEverOpen(true);
}, [open]);
// Latched during render: the query below is gated on it, so an effect delayed the first fetch by
// a commit for no reason. Convergent — the write falsifies its own condition.
if (open && !everOpen) setEverOpen(true);
const shelf = useQuery({
queryKey: qk.comfortPins(),
+7 -4
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, type ReactNode } from "react";
import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { CalendarClock, Copy, File, Link2, Scissors } from "lucide-react";
import clsx from "clsx";
@@ -77,8 +77,11 @@ function Thumb({ job }: { job: DownloadJob }) {
const poster = job.poster_url ? api.posterImageUrl(job.id) : null;
const primary = poster || remote;
// On load failure (a genuinely missing poster file) fall back to the remote source thumbnail.
const [src, setSrc] = useState(primary);
useEffect(() => setSrc(primary), [primary]);
// Remembered BY the URL that failed, not as a value an effect re-syncs: these rows are recycled
// down a long virtualized list, so a boolean-style mirror painted the previous job's fallback
// over the next one's poster for a frame. See ui/Avatar, which had the same defect.
const [failedPrimary, setFailedPrimary] = useState<string | null>(null);
const src = failedPrimary && failedPrimary === primary ? remote : primary;
// A generic uploaded file (media_kind='file') has no video frame to poster — it's a document/
// archive, not playable media. Show a neutral file glyph instead of an (always-empty) thumbnail.
if (job.asset?.media_kind === "file") {
@@ -97,7 +100,7 @@ function Thumb({ job }: { job: DownloadJob }) {
loading="lazy"
className="w-full h-full object-cover"
onError={() => {
if (remote && src !== remote) setSrc(remote);
if (remote && src !== remote) setFailedPrimary(primary);
}}
/>
) : null}
@@ -20,6 +20,7 @@ export function usePlexPlaylistEdits(playlistId: number) {
// Local optimistic copy so drag/remove are instant; re-synced whenever the query returns.
const [items, setItems] = useState<PlexCard[]>([]);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- the local copy is OPTIMISTIC — drag and remove write it before the server answers — so it cannot be derived from the query. This re-syncs it to the server's answer, and the rollback path below deliberately writes it too.
if (q.data) setItems(q.data.items);
}, [q.data]);