Files
siftlode/frontend/src/components/BootGate.dom.test.tsx
T
peter c5e022f056 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.
2026-09-21 01:44:21 +02:00

230 lines
9.7 KiB
TypeScript

// @vitest-environment jsdom
//
// BootGate settles the tab's identity BEFORE the app tree mounts. The bug it replaced: a new tab (no
// pinned account in sessionStorage) built the app for "no account" first — so with Settings →
// Appearance → Startup turned off, NavigationProvider's initializer could not read the opt-out and
// landed on Home, mirrored ?page=home into the address bar, and then re-read its own mirror when the
// account was pinned, staying on Home. Only a same-tab reload (account already pinned) was right.
//
// Real NavigationProvider, real storage and i18n; only the two boot probes are stubbed. The probe
// records EVERY render, so "never showed the wrong page, not even for one render" is what is asserted.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, cleanup, act, fireEvent, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import BootGate from "./BootGate";
import { NavigationProvider } from "./NavigationProvider";
import { useNavigation } from "./navigationContext";
import { api, type Me } from "../lib/api";
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
import { qk } from "../lib/queryKeys";
import i18n from "../i18n";
const renders: { page: string; lang: string }[] = [];
let mounts = 0;
function Probe() {
const { page } = useNavigation();
const { i18n: inst } = useTranslation();
renders.push({ page, lang: inst.language });
useEffect(() => {
mounts++;
}, []);
return <div data-testid="app">{page}</div>;
}
function mount() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<BootGate>
<NavigationProvider>
<Probe />
</NavigationProvider>
</BootGate>
</QueryClientProvider>,
);
return qc;
}
const account = (over: Partial<Me> = {}) => ({ id: 5, preferences: {}, ...over }) as Me;
const retryButton = () => screen.findByRole("button", { name: i18n.t("common.retry") });
beforeEach(async () => {
localStorage.clear();
sessionStorage.clear(); // a NEW tab: nothing pinned yet
window.history.replaceState(null, "", "/");
renders.length = 0;
mounts = 0;
await i18n.changeLanguage("en");
vi.spyOn(api, "setupStatus").mockResolvedValue({ configured: true });
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("BootGate", () => {
it("a new tab with Startup off opens on the page the account left — never on Home first", async () => {
localStorage.setItem("siftlode.startOnHome.5", "0");
localStorage.setItem("siftlode.page.5", "downloads");
vi.spyOn(api, "me").mockResolvedValue(account());
mount();
expect((await screen.findByTestId("app")).textContent).toBe("downloads");
expect(renders.map((r) => r.page)).toEqual(renders.map(() => "downloads"));
expect(new URLSearchParams(window.location.search).get("page")).toBe("downloads");
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5"); // the tab is pinned
});
it("renders the app in the account's language from its first render", async () => {
vi.spyOn(api, "me").mockResolvedValue(account({ preferences: { language: "hu" } }));
mount();
await screen.findByTestId("app");
expect(renders.map((r) => r.lang)).toEqual(renders.map(() => "hu"));
});
it("mounts nothing of the app while the identity is still unknown", async () => {
vi.spyOn(api, "me").mockReturnValue(new Promise<Me | null>(() => {}));
mount();
await act(async () => {}); // the setup probe resolves; `me` never does
expect(screen.queryByTestId("app")).toBeNull();
expect(renders).toHaveLength(0);
});
it("signed out: mounts the app (App shows the landing) without pinning an account", async () => {
vi.spyOn(api, "me").mockResolvedValue(null);
mount();
await screen.findByTestId("app");
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBeNull();
});
it("setup mode: mounts the app (App shows the wizard) without asking for the account", async () => {
vi.mocked(api.setupStatus).mockResolvedValue({ configured: false });
const me = vi.spyOn(api, "me").mockResolvedValue(account());
mount();
await screen.findByTestId("app");
expect(me).not.toHaveBeenCalled();
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBeNull();
});
it("an unanswered setup probe is an error with a retry — never the signed-out landing", async () => {
vi.mocked(api.setupStatus)
.mockRejectedValueOnce(new Error("502"))
.mockResolvedValue({ configured: true });
vi.spyOn(api, "me").mockResolvedValue(account());
mount();
fireEvent.click(await retryButton());
await screen.findByTestId("app");
expect(renders.length).toBeGreaterThan(0);
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5"); // it mounted for the account
});
it("an unanswered first account probe is an error with a retry", async () => {
vi.spyOn(api, "me").mockRejectedValueOnce(new Error("502")).mockResolvedValue(account());
mount();
fireEvent.click(await retryButton());
await screen.findByTestId("app");
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5");
});
it("a background refetch that fails keeps the app mounted, as it was", async () => {
const me = vi.spyOn(api, "me").mockResolvedValue(account());
const qc = mount();
await screen.findByTestId("app");
me.mockRejectedValue(new Error("502"));
await act(() => qc.refetchQueries({ queryKey: qk.me() }));
// react-query hands the error to its observers on a timer: let that render land before asserting.
await act(() => new Promise((r) => setTimeout(r, 20)));
expect(qc.getQueryState(qk.me())?.status).toBe("error");
expect(screen.getByTestId("app")).toBeTruthy();
expect(mounts).toBe(1);
});
it("a pin naming another account than the server answered as is corrected before the app reads it", async () => {
// Pinned to 9, but 9 was signed out in another tab: the server answers as the browser's default, 5.
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "9");
localStorage.setItem("siftlode.startOnHome.5", "0");
localStorage.setItem("siftlode.page.5", "downloads");
vi.spyOn(api, "me").mockResolvedValue(account());
mount();
expect((await screen.findByTestId("app")).textContent).toBe("downloads");
expect(renders.map((r) => r.page)).toEqual(renders.map(() => "downloads"));
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5");
});
it("a mounted tab whose identity changes reloads rather than keep the previous account's tree", async () => {
const me = vi.spyOn(api, "me").mockResolvedValue(account());
const qc = mount();
await screen.findByTestId("app");
const reload = vi.fn();
vi.stubGlobal("location", { ...window.location, reload });
me.mockResolvedValue(null); // the session ended
await act(() => qc.refetchQueries({ queryKey: qk.me() }));
await waitFor(() => expect(reload).toHaveBeenCalled());
});
it("an identity change reloads without moving the pin first", async () => {
// The tree being torn down still runs its unmount saves before the page goes; with the pin already
// moved they would land under the NEW account's local keys (a player volume still pending, say).
const me = vi.spyOn(api, "me").mockResolvedValue(account());
const qc = mount();
await screen.findByTestId("app");
const reload = vi.fn();
vi.stubGlobal("location", { ...window.location, reload });
me.mockResolvedValue(account({ id: 6 })); // 5 was signed out elsewhere: the server answers as 6
await act(() => qc.refetchQueries({ queryKey: qk.me() }));
await waitFor(() => expect(reload).toHaveBeenCalled());
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();
await screen.findByTestId("app");
const reload = vi.fn();
vi.stubGlobal("location", { ...window.location, reload });
// What every pref save does to the cache (usePref's mergePrefsCache): a new `me` for the same account.
act(() => qc.setQueryData(qk.me(), account({ preferences: { language: "hu" } })));
await act(() => new Promise((r) => setTimeout(r, 20)));
expect(reload).not.toHaveBeenCalled();
expect(mounts).toBe(1);
expect(i18n.language).toBe("en"); // the language is adopted once, never over the user's own choice
});
});