refactor(frontend): honest effect deps in the app shell and the ui layer (E27 S2)

Burns the first 14 of the 113 hook suppressions. Three verdicts, applied per site:

A useEffect that should not exist (6):
- Avatar remembered "the image failed" as a boolean an effect reset on src change. The
  component is recycled across items in the virtualized feed, so after one avatar 429s the
  next item's slot committed the fallback initial over a perfectly good image and only the
  post-paint effect cleared it. The failure is now keyed BY URL; derived state cannot be stale.
- Combobox cleared the typed query from an effect on `open`. Moved into setOpenAnd, the single
  funnel every close goes through. That also stops a spurious onQueryChange("") AT MOUNT, which
  told a controlled caller its own initial query had been cleared before the user touched it.
- CommandPalette reset query + picked row from an effect. Closed now means UNMOUNTED, not
  hidden, so a fresh open starts blank because mounting already means that -- and the ranking
  memo over every channel, playlist and saved view stops running while the palette is shut.
- Pager's page box held a copy of the page that an effect re-synced; it now derives it and
  keeps a draft only while being edited.
- App's ?link= / ?oauth= / ?admin= one-shots asserted "once per mount" with an empty dep list.
  The ref guard the codebase already uses in 22 places ENFORCES it, so the honest deps can
  include `t` -- which a language switch legitimately changes, and which would otherwise have
  re-fired the toast.

Honest deps that also fix something (5):
- App's module preload was keyed on the account id, so a permission change that alters the
  module set without moving the id never re-preloaded. `preload()` is idempotent, so depending
  on `me` costs nothing.
- App's access-request nudge now re-issues on a language switch; it used to stay in the old
  language until the pending count changed.
- App's diagnostics effect: applyDiagnostics already no-ops on an unchanged effective level,
  so picking four fields out by hand was guarding nothing and missed any field added later.
- DataTable's two filter effects: a ref holds the latest onFiltersChange so commitFilters can
  be stable, which is what lets the effects name it instead of switching the check off.

Genuine escape hatches (7) moved OUT of eslint-suppressions.json into inline directives that
name the invariant at the site -- the toast handover's render-phase global, the live region's
deliberate arming tick, the docked one-slot component, the popover engine's floating-ui list
state, dnd-kit's per-drag `moved` flag, BackToTop's DOM read and the palette's debounced
announcement. A reader of the code now sees why; a JSON file no one opens does not.

Tests: Avatar (3, new file) and Pager (3) pin the derived-state frames with a layout-phase
probe -- layout effects run before any passive effect, so they read the frame the browser would
paint. Combobox (2) pins the close-clears-query contract and the no-callback-at-mount rule.
All three sets mutation-checked against the effect-based originals.

Suppressions 113 -> 99; exhaustive-deps 45 -> 35.
This commit is contained in:
2026-09-20 23:57:08 +02:00
parent 7d682c90d5
commit 7e1aeeb9fe
14 changed files with 334 additions and 116 deletions
+70 -32
View File
@@ -1,4 +1,4 @@
import { lazy, Suspense, useEffect, useState } from "react";
import { lazy, Suspense, useEffect, useRef, useState } from "react";
import { qk } from "./lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
@@ -131,6 +131,9 @@ export default function App() {
});
const needsSetup = setupQuery.data?.configured === false;
const meQuery = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
// The signed-in account, or null/undefined while signed out. Hoisted here because half a dozen
// effects below depend on it, and a dep list is only honest if it names the value the body reads.
const me = meQuery.data;
// The `global` key scope: `h` opens the cheat sheet, Mod+K the jump-to palette, Shift+W jumps to
// Home ("mit nézzek?"). Bound only once signed in, because that is where the overlays are mounted
@@ -141,32 +144,40 @@ export default function App() {
// page underneath (owner decision 2026-09-09: the key always ends with the answer on screen).
// The `page` scope rides the same binding: the `g` chords jump to a module, but only to one this
// account can reach (the nav rail's own list, plus Settings, which is not in the rail's order).
useGlobalKeymap(
!!meQuery.data,
setPage,
meQuery.data ? [...moduleOrder(meQuery.data), "settings"] : [],
);
useGlobalKeymap(!!me, setPage, me ? [...moduleOrder(me), "settings"] : []);
// The account's consented diagnostics level (E22 S5), applied to this tab's log: raised, the log is
// kept in more detail and streamed to the server until the level lapses. A debug session that is
// recording (E16) raises it for its own run and tags what it keeps. A lapse (its own timer, or the
// server saying it is off) refetches `me`, so Settings and the recorder show what is in force.
const diagnostics = meQuery.data?.diagnostics;
const debugSession = meQuery.data?.debug_session;
// Re-running this on every `me` refetch is FREE and therefore the deps are the honest ones:
// applyDiagnostics computes an effective-level key and returns early when it has not moved
// (lib/diagnostics.ts), so an unchanged level is a no-op. It does refresh the `lapsed` callback
// before that early return, which is the half we WANT re-run. The old dep list picked out four
// primitive fields to dodge the fresh object identity a refetch produces; that is the checker's
// job to verify, not a comment's to assert, and it silently missed any other field the module
// may come to read.
const diagnostics = me?.diagnostics;
const debugSession = me?.debug_session;
useEffect(() => {
applyDiagnostics(
diagnostics,
debugSession,
() => void queryClient.invalidateQueries({ queryKey: qk.me() }),
);
}, [diagnostics?.level, diagnostics?.until, debugSession?.id, debugSession?.ended_at]); // eslint-disable-line react-hooks/exhaustive-deps
}, [diagnostics, debugSession, queryClient]);
// Fetch the code of every module this account can open — and the channel page — once the first page
// is on screen, so a module's first visit renders at once instead of flashing the chunk fallback.
// Idle-scheduled so it never competes with the first page's own requests (Safari has no
// requestIdleCallback, hence the timer).
// Depending on the whole `me` rather than its id is both honest and MORE correct: the module set
// is derived from `me` through each module's own gate (lib/modules moduleOrder), so a permission
// change — an admin grant, a Plex server appearing — alters it without the id moving, and the old
// `[me.id]` dep never re-preloaded for those. Re-running costs nothing: `preload()` is idempotent
// (lazyWithPreload memoises the pending promise), so a refetch that changes nothing fetches
// nothing; only the idle callback is re-scheduled.
useEffect(() => {
const me = meQuery.data;
if (!me) return;
const run = () => {
preloadPages([...moduleOrder(me), "settings"]);
@@ -179,7 +190,7 @@ export default function App() {
}
const id = window.setTimeout(run, 1000);
return () => window.clearTimeout(id);
}, [meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
}, [me]);
// Measure the scrollbar's gutter width once and publish it as --sbw, so the floating header and the
// toolbar band can pad their right edge to line up with the scrolled cards (the app scroller reserves
@@ -197,9 +208,13 @@ export default function App() {
// Once signed in, drop any leftover ONE-TIME query params from the pre-login flow (?access, ?verify,
// ?reset) — but NOT the nav/filter params, which the logged-in app now reads from and keeps in the
// URL. Gated on auth so the logged-out Welcome page still reads its params first.
// Keyed on "is there a session at all", which is the whole condition the body tests. Re-running
// on an account switch was never the point — by then the params are already gone and a second
// clearParams is a no-op.
const signedIn = !!me;
useEffect(() => {
if (meQuery.data) clearParams("access", "verify", "reset");
}, [meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
if (signedIn) clearParams("access", "verify", "reset");
}, [signedIn]);
// Any request that 401s means the session ended server-side. If we were signed in (cached
// `me` is a user object), reload to the login page at once (also covers any click that hits
@@ -218,12 +233,20 @@ export default function App() {
// Returning from an in-app Google link/upgrade round-trip (auth.py redirects to ?link=…).
// Surface the outcome, refresh `me` (can_read/can_write/has_google may have flipped), land on
// Settings → Account so the new state is visible, then strip the param for a clean URL.
// "Exactly once per mount" is now ENFORCED by the ref rather than asserted by an empty dep list:
// `t` changes identity on a language switch, so the honest dep list would otherwise re-fire the
// toast when the user changes language. (The old `[]` got away with it only because clearParams
// had already removed the param — an invariant held by a side effect two lines further down.)
const linkAnswered = useRef(false);
const { refetch: refetchMe } = meQuery;
useEffect(() => {
if (linkAnswered.current) return;
const result = new URLSearchParams(window.location.search).get("link");
if (!result) return;
linkAnswered.current = true;
if (result === "ok") {
notify({ level: "success", message: t("settings.account.googleLink.linked") });
void meQuery.refetch();
void refetchMe();
setAccountRaw(LS.settingsTab, "account");
setPage("settings");
} else {
@@ -231,42 +254,59 @@ export default function App() {
notify({ level: "error", message: t(`settings.account.googleLink.${key}`) });
}
clearParams("link");
}, []); // eslint-disable-line react-hooks/exhaustive-deps
}, [t, setPage, refetchMe]);
// A Google OAuth flow started while already signed in (add-another-account, link, or upgrade) was
// cancelled at Google's consent screen. The backend only emits this for a live session, so a
// neutral toast is the right feedback — the pre-auth login banner would never render here.
// Same one-shot shape as the `?link=` handler above, same reason for the ref.
const oauthAnswered = useRef(false);
useEffect(() => {
if (oauthAnswered.current) return;
if (new URLSearchParams(window.location.search).get("oauth") !== "cancelled") return;
oauthAnswered.current = true;
notify({ level: "info", message: t("settings.account.googleCancelled") });
clearParams("oauth");
}, []); // eslint-disable-line react-hooks/exhaustive-deps
}, [t]);
// Deep-link from the "new access request" admin email (?admin=access-requests) → jump straight to
// the admin Users → Access requests view. Snapshot the param up front (read once at mount); act once
// `me` is known, and only for an admin (others just land on their default page).
const [adminDeepLink] = useState(() => new URLSearchParams(window.location.search).get("admin"));
// Ref-guarded like the two above, and here it closes a real hole rather than only satisfying the
// checker: the snapshot lives for the whole session, so with honest deps a later role change —
// or any `me` refetch, under the old id-only key — would yank the user to Users long after the
// deep link was answered.
const adminDeepLinkAnswered = useRef(false);
useEffect(() => {
if (adminDeepLink !== "access-requests" || !meQuery.data) return;
if (meQuery.data.role === "admin") {
if (adminDeepLinkAnswered.current) return;
if (adminDeepLink !== "access-requests" || !me) return;
adminDeepLinkAnswered.current = true;
if (me.role === "admin") {
focusAccessRequestsTab();
setPage("users");
}
clearParams("admin");
}, [adminDeepLink, meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
}, [adminDeepLink, me, setPage]);
// Nudge admins when access requests are waiting (in lieu of a server-push bell). Keyed on the
// pending COUNT so it re-resolves the moment an approval/denial changes it — not only on a
// fresh load (which is why the notice used to linger until F5 after an approval).
// Honest deps, and `t` among them is a fix rather than a concession: the nudge is a persisted
// toast whose text is translated at issue time, so a language switch must re-issue it. It did not
// before — the notice stayed in the old language until the count changed.
const role = me?.role;
const pendingInvites = me?.pending_invites ?? 0;
const pendingActivations = me?.pending_activations ?? 0;
useEffect(() => {
if (meQuery.data?.role !== "admin") return;
if (role !== "admin") return;
// Keep a single, current nudge: drop any prior one (persisted copies reload as dismissed
// history, so they'd otherwise pile up one-per-reload) before re-issuing the live notice.
removeByMetaKind("access-requests");
// Both kinds of "someone is waiting on you": a request needing a decision, and a sign-up whose
// address was approved before they registered. The second used to produce NO signal at all —
// nothing lands in the queue for it — which is how the 2026-09-03 registrant went unnoticed.
const pending = (meQuery.data.pending_invites ?? 0) + (meQuery.data.pending_activations ?? 0);
const pending = pendingInvites + pendingActivations;
if (pending > 0) {
notify({
level: "warning",
@@ -285,8 +325,7 @@ export default function App() {
},
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [meQuery.data?.role, meQuery.data?.pending_invites, meQuery.data?.pending_activations]);
}, [role, pendingInvites, pendingActivations, t, setPage]);
// Language is edited outside the Settings page (sidebar/login), so it stays instant.
function changeLanguage(code: LangCode) {
@@ -302,7 +341,7 @@ export default function App() {
if (needsSetup) return <SetupWizard />;
// /api/me answers 200 always (null body = logged out), and BootGate has already handled a failed
// probe — so no data here means "not signed in" → the public landing.
if (!meQuery.data) return <Welcome verify={verifyEntry} />;
if (!me) return <Welcome verify={verifyEntry} />;
// Shown while a page's chunk is still on its way: only if its visit beats the preload above (or the
// preload failed), and like every loading state it stays invisible for a load that is quick anyway.
@@ -323,16 +362,15 @@ export default function App() {
// page the account can't reach (a gated module via stale nav / a deep link) falls back to the feed,
// exactly as the old ternary's fallthrough did. A channel page overlays the content column, so no
// side rail shows behind it.
const me = meQuery.data!;
const pageGate = moduleDef(page).gate;
const activePage: Page = !pageGate || pageGate(me) ? page : "feed";
const activePage: Page = !pageGate || pageGate(me!) ? page : "feed";
const { Component: PageContent, maxWidth } = PAGE_CONTENT[activePage];
const railDef = channelView ? undefined : PAGE_RAIL[activePage];
return (
<div className="h-screen flex text-fg">
<NavSidebar
me={meQuery.data!}
me={me!}
onOpenAbout={() => setAboutOpen(true)}
onChangeLanguage={changeLanguage}
language={i18n.language as LangCode}
@@ -368,7 +406,7 @@ export default function App() {
key={channelView.id}
channelId={channelView.id}
initialName={channelView.name}
me={meQuery.data!}
me={me!}
view={view}
setView={setView}
onShowInFeed={() => {
@@ -393,11 +431,11 @@ export default function App() {
<PageShell
scrollKey={normalScrollKey}
maxWidth={maxWidth}
header={<Header me={meQuery.data!} onYtSearch={enterYtSearch} />}
header={<Header me={me!} onYtSearch={enterYtSearch} />}
banners={
<>
<VerifyBanner entry={verifyEntry} />
{meQuery.data!.is_demo && <DemoBanner />}
{me!.is_demo && <DemoBanner />}
<VersionBanner onOpen={() => openReleaseNotes(FRONTEND_VERSION)} />
</>
}
@@ -417,9 +455,9 @@ export default function App() {
"take me to X" the same way. Their keys are bound by useGlobalKeymap above. */}
<KeyboardHelp />
<AppCommandPalette />
{meQuery.data && !meQuery.data.is_demo && <ChatDock meId={meQuery.data.id} />}
{me && !me.is_demo && <ChatDock meId={me.id} />}
{/* The debug recorder (E16): its start dialog, and its pill docked under the toasts. */}
{meQuery.data && !meQuery.data.is_demo && <DebugRecorder />}
{me && !me.is_demo && <DebugRecorder />}
<Suspense fallback={null}>
{aboutOpen && (
<About