build(frontend): close the lint gaps the E32 audit measured

eslint now also enforces:
- the React Compiler diagnostics that `recommended` leaves out (todo,
  memo-dependencies and ten zero-hit tripwires);
- a description on every suppression, and no blanket disables;
- no focused or skipped tests (vitest and playwright);
- the typed pair no-floating-promises and no-deprecated.

Lint also covers e2e/ and the TypeScript build configs, which `eslint .`
had been skipping in silence.

Every hit is fixed or suppressed inline with a named reason:
- The 31 try statements the compiler cannot lower yet carry a suppression
  until S3 rewrites them. Five other compiler skips were plain fixes:
  a module counter bumped inside a hook, a ref read in a default
  parameter, and a local named like the <section> it renders.
- The real defects behind floating promises are fixed. ChatDock threw at
  nobody when the device key store could not be opened. VideoEditor's
  play() rejection went unhandled. loadYouTubeApi never rejected, so a
  blocked IFrame API left the player on an empty stage forever; it now
  fails on a script error or after 15 s, shows its own error with the
  way out, and lets the next open try again.
- MutableRefObject, FormEvent and lucide's deprecated brand icon are
  replaced. The YouTube glyph now lives in lib/brandIcons.
- The lint-probe harness lets its virtual file names into TypeScript's
  default project, so the contract tests still run the real config.
This commit is contained in:
2026-10-03 16:34:20 +02:00
parent 3c16012524
commit 471846d784
72 changed files with 938 additions and 216 deletions
+5 -2
View File
@@ -2,7 +2,8 @@ import { useLayoutEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { ListPlus, Plus, Youtube } from "lucide-react";
import { ListPlus, Plus } from "lucide-react";
import { YouTubeIcon } from "../lib/brandIcons";
import { MenuCheck } from "./ui/MenuCheck";
import { api, type Playlist } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
@@ -117,6 +118,7 @@ function MembershipPanel({
async function toggle(pl: Playlist) {
if (busy) return;
setBusy(true);
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
if (pl.has_video) await api.removeFromPlaylist(pl.id, videoId);
else await api.addToPlaylist(pl.id, videoId);
@@ -132,6 +134,7 @@ function MembershipPanel({
const name = newName.trim();
if (!name || busy) return;
setBusy(true);
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
const pl = await api.createPlaylist(name);
await api.addToPlaylist(pl.id, videoId);
@@ -183,7 +186,7 @@ function MembershipPanel({
<MenuCheck kind="many" on={!!pl.has_video} />
<span className="truncate flex-1 text-left">{playlistName(pl, t)}</span>
{(pl.source === "youtube" || pl.yt_playlist_id) && (
<Youtube className="w-3 h-3 shrink-0 text-muted" />
<YouTubeIcon className="w-3 h-3 shrink-0 text-muted" />
)}
</PopoverItem>
))}
+10 -5
View File
@@ -23,13 +23,14 @@ import i18n from "../i18n";
const renders: { page: string; lang: string }[] = [];
let mounts = 0;
const countMount = () => {
mounts++; // outside the component: the compiler cannot lower `++` on a module variable in one
};
function Probe() {
const { page } = useNavigation();
const { i18n: inst } = useTranslation();
renders.push({ page, lang: inst.language });
useEffect(() => {
mounts++;
}, []);
useEffect(countMount, []);
return <div data-testid="app">{page}</div>;
}
@@ -226,7 +227,9 @@ describe("BootGate", () => {
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" } })));
act(() => {
qc.setQueryData(qk.me(), account({ preferences: { startup: "last" } }));
});
await act(() => new Promise((r) => setTimeout(r, 20)));
expect(reload).not.toHaveBeenCalled();
@@ -241,7 +244,9 @@ describe("BootGate", () => {
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" } })));
act(() => {
qc.setQueryData(qk.me(), account({ preferences: { language: "hu" } }));
});
await act(() => new Promise((r) => setTimeout(r, 20)));
expect(reload).not.toHaveBeenCalled();
@@ -82,7 +82,9 @@ describe("ChannelPage — the grid follows a running backfill", () => {
expect(qc.getQueryState(feedKey)?.isInvalidated).toBe(false);
// The next poll tick: the background job has stored 60 more.
act(() => qc.setQueryData(qk.channel(CH), reading(79)));
act(() => {
qc.setQueryData(qk.channel(CH), reading(79));
});
await waitFor(() => expect(qc.getQueryState(feedKey)?.isInvalidated).toBe(true));
});
@@ -60,9 +60,9 @@ describe("ChannelsProvider tab history", () => {
expect(result.current.view).toBe("blocked");
// Browser Back to an entry stamped with a different tab.
act(() =>
window.dispatchEvent(new PopStateEvent("popstate", { state: { _chTab: "discovery" } })),
);
act(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state: { _chTab: "discovery" } }));
});
expect(result.current.view).toBe("discovery");
expect(localStorage.getItem(VIEW_KEY)).toBe("discovery");
@@ -71,7 +71,9 @@ describe("ChannelsProvider tab history", () => {
it("ignores a popstate for an entry outside the manager (no _chTab)", () => {
const { result } = render();
act(() => result.current.setView("hidden"));
act(() => window.dispatchEvent(new PopStateEvent("popstate", { state: { sfPage: "feed" } })));
act(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state: { sfPage: "feed" } }));
});
expect(result.current.view).toBe("hidden"); // unchanged
});
});
+12 -1
View File
@@ -13,6 +13,7 @@ import {
} from "../lib/chatDock";
import { onMessage, onUnread } from "../lib/messagesSocket";
import * as e2ee from "../lib/e2ee";
import { errorAttrs, log } from "../lib/log";
import Avatar from "./ui/Avatar";
import ChatThread from "./ChatThread";
import Overlay from "./ui/Overlay";
@@ -30,7 +31,17 @@ export default function ChatDock({ meId }: { meId: number }) {
// Restore the per-device unlocked key and the persisted dock windows once, app-wide.
useEffect(() => {
initDock(meId);
e2ee.loadFromDevice(meId);
// The device key store can be unopenable (storage disabled, some private windows): messages then
// stay locked behind the passphrase, as on a new device — said in the log, not thrown at nobody.
e2ee
.loadFromDevice(meId)
.catch((err: unknown) =>
log.warn(
"ui.e2ee_device_key_unreadable",
"the device key store could not be read",
errorAttrs(err),
),
);
}, [meId]);
// Live delivery: refresh the conversation list, unread badge, and the affected thread; and
+2 -1
View File
@@ -54,7 +54,8 @@ export default function ChatThread({
useEffect(() => {
if (isSystem || !ready) return;
let cancelled = false;
(async () => {
// Every await inside is caught per message (a failure reads "can't decrypt"), so this never rejects.
void (async () => {
const out: Record<number, string> = {};
// Fetch the partner key lazily inside the try (only when there's a ciphertext message) and
// once — so an empty thread never fetches, and a fetch failure (e.g. a partner who reset and
+1 -1
View File
@@ -104,7 +104,7 @@ export default function DownloadDialog({
<ProfileEditor
onClose={() => {
setManage(false);
profilesQ.refetch();
void profilesQ.refetch();
}}
/>
</Suspense>
+4 -4
View File
@@ -19,9 +19,9 @@ import {
RefreshCw,
Rows3,
Trash2,
Youtube,
type LucideIcon,
} from "lucide-react";
import { YouTubeIcon } from "../lib/brandIcons";
import { api, HttpError, type FeedFilters, type Video, type VideoStatus } from "../lib/api";
import i18n from "../i18n";
import { notifyActionError } from "../lib/actionErrors";
@@ -352,7 +352,7 @@ export default function Feed({
// 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
// eslint-disable-next-line react-hooks/immutability -- a latest-value ref for a forward reference: the Undo re-enters onState before its binding exists
onStateRef.current = onState;
const onResetState = useCallback(
@@ -473,7 +473,7 @@ export default function Feed({
{t("feed.yt.back")}
</Button>
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
<Youtube className="w-4 h-4 text-accent shrink-0" />
<YouTubeIcon className="w-4 h-4 text-accent shrink-0" />
<span className="text-sm font-semibold truncate">
{t("feed.yt.resultsFor", { query: ytSearch })}
</span>
@@ -882,7 +882,7 @@ export default function Feed({
{filters.q.trim() && !isDemo && (
<Button
className="mt-4"
icon={<Youtube />}
icon={<YouTubeIcon />}
onClick={() => onYtSearch(filters.q.trim())}
>
{t("feed.yt.searchFor", { query: filters.q.trim() })}
+2 -2
View File
@@ -1,6 +1,6 @@
import { forwardRef, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Youtube } from "lucide-react";
import { YouTubeIcon } from "../lib/brandIcons";
import type { Me } from "../lib/api";
import SearchBar from "./SearchBar";
import PlexSearchBox from "./PlexSearchBox";
@@ -61,7 +61,7 @@ const Header = forwardRef<
onGo={() => onYtSearch(trimmed)}
goLabel={t("header.searchYoutube")}
goTitle={t("header.searchYoutubeTip")}
goIcon={<Youtube className="w-4 h-4" />}
goIcon={<YouTubeIcon className="w-4 h-4" />}
goDisabled={!trimmed || me.is_demo}
/>
);
+3 -3
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState, type DragEvent, type MutableRefObject } from "react";
import { useEffect, useMemo, useRef, useState, type DragEvent, type RefObject } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ExternalLink, FileText, Plus, Server, X } from "lucide-react";
@@ -606,7 +606,7 @@ function Attachments({
adding: boolean;
onAdd: (files: Iterable<File>) => void;
onReview: (source: Source) => void;
reviewed: MutableRefObject<Source | null>;
reviewed: RefObject<Source | null>;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
@@ -615,7 +615,7 @@ 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
// eslint-disable-next-line react-hooks/immutability -- the effect that consumes the caller's one-shot focus token (see the write below)
useEffect(() => {
const source = reviewed.current;
if (!source) return;
+2
View File
@@ -44,6 +44,7 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
if (pass.length < 6) return setErr(t("messages.passTooShort"));
if (!configured && pass !== confirm) return setErr(t("messages.passMismatch"));
setBusy(true);
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
if (configured) {
await e2ee.unlock(meId, pass, keyQ.data!);
@@ -76,6 +77,7 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
setErr(null);
if (hasPassword && !curPass) return setErr(t("messages.resetNeedPass"));
setBusy(true);
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
await api.resetMessageKey(hasPassword ? curPass : undefined);
// Server key + dead history are gone; wipe this device's identity too so the gate re-appears
+2 -1
View File
@@ -100,7 +100,8 @@ function ConversationList({
useEffect(() => {
if (!ready) return;
let cancelled = false;
(async () => {
// Each decrypt is caught (a failure previews as "⚠"), so this never rejects.
void (async () => {
const out: Record<number, string> = {};
for (const c of conversationsRef.current?.items ?? []) {
const m = c.last_message;
@@ -84,7 +84,9 @@ describe("NavigationProvider · the tab title", () => {
await settle();
expect(document.title).toBe("Siftlode");
act(() => qc.setQueryData(qk.me(), { id: 1 }));
act(() => {
qc.setQueryData(qk.me(), { id: 1 });
});
await settle();
expect(document.title).not.toBe("Siftlode");
expect(document.title.endsWith(" · Siftlode")).toBe(true);
+4 -3
View File
@@ -2,7 +2,8 @@ import { useEffect, useId, useRef } from "react";
import { Trans, useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { AlertTriangle, Check, Loader2, RefreshCw, ShieldCheck, X, Youtube } from "lucide-react";
import { AlertTriangle, Check, Loader2, RefreshCw, ShieldCheck, X } from "lucide-react";
import { YouTubeIcon } from "../lib/brandIcons";
import { api, type Me } from "../lib/api";
import { beginGrant, endOnboarding } from "../lib/onboarding";
import { Button } from "./ui/Button";
@@ -186,7 +187,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
{step === "read" && (
<>
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
<Youtube className="w-6 h-6" />
<YouTubeIcon className="w-6 h-6" />
</div>
<h2 id={titleId} className="text-lg font-semibold">
{t("onboarding.read.title")}
@@ -199,7 +200,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
</p>
<ConsentHeadsUp />
<div className="mt-5 flex flex-col gap-2">
<Button size="lg" icon={<Youtube />} onClick={() => beginGrant("read")}>
<Button size="lg" icon={<YouTubeIcon />} onClick={() => beginGrant("read")}>
{t("onboarding.read.connect")}
</Button>
<Button variant="ghost" size="md" onClick={close}>
+7 -3
View File
@@ -70,6 +70,7 @@ import {
useYtBigView,
useYtProgress,
useYtVolume,
YT_API_LOAD_FAILED,
} from "../lib/playerModalHooks";
import { useScrollFade } from "../lib/useScrollFade";
import { Button } from "./ui/Button";
@@ -372,6 +373,7 @@ export default function PlayerModal({
const victim = active;
removingRef.current = true;
let done = false;
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try without catch yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
done = await onRemoveFromList(victim);
} finally {
@@ -831,9 +833,11 @@ export default function PlayerModal({
<AlertTriangle className="w-8 h-8 text-warn mx-auto mb-3" />
<h3 className="text-base font-semibold">{t("player.unavailableTitle")}</h3>
<p className="text-sm text-muted mt-1.5 mb-4">
{playerError === 101 || playerError === 150
? t("player.embedDisabledBody")
: t("player.unavailableBody")}
{playerError === YT_API_LOAD_FAILED
? t("player.apiLoadFailedBody")
: playerError === 101 || playerError === 150
? t("player.embedDisabledBody")
: t("player.unavailableBody")}
</p>
<LinkButton
href={`https://www.youtube.com/watch?v=${currentVideoId}`}
+8 -7
View File
@@ -18,7 +18,8 @@ import {
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Check, FolderInput, History, ListPlus, Play, X, Youtube } from "lucide-react";
import { Check, FolderInput, History, ListPlus, Play, X } from "lucide-react";
import { YouTubeIcon } from "../lib/brandIcons";
import { api, type Playlist, type PlaylistDetail, type Video } from "../lib/api";
import { notify } from "../lib/notifications";
import { notifyActionError } from "../lib/actionErrors";
@@ -460,10 +461,10 @@ export default function Playlists() {
* the X column became the Delete key + this toast; no confirm, because nothing is lost).
* @returns whether the item was actually removed — the player needs to know, because it must
* step to the next item (or close) only when the removal really happened. */
async function removeItem(
video: Video,
listId: number | null = selectedRef.current,
): Promise<boolean> {
async function removeItem(video: Video, listIdArg?: number | null): Promise<boolean> {
// Defaulted here, not as `listId = selectedRef.current` in the signature: the React Compiler
// cannot reorder a ref read in a default parameter and skipped the component for it.
const listId = listIdArg === undefined ? selectedRef.current : listIdArg;
if (listId == null) return false;
// Only the list ON SCREEN has rows to take the item out of optimistically — the open player can
// be removing from the list it was launched from while another one is selected.
@@ -685,7 +686,7 @@ export default function Playlists() {
label: plName(pl),
icon:
pl.source === "youtube" || pl.yt_playlist_id ? (
<Youtube className="w-4 h-4" />
<YouTubeIcon className="w-4 h-4" />
) : (
<ListPlus className="w-4 h-4" />
),
@@ -866,7 +867,7 @@ export default function Playlists() {
onKeyDown={(e) => {
if (e.key === "Enter") {
renameFocus.arm(e);
saveRename();
void saveRename(); // plActions.rename reports its own failure
}
if (e.key === "Escape") {
renameFocus.arm(e);
+4 -2
View File
@@ -2,7 +2,8 @@ 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";
import { ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react";
import { ListVideo, Pin, Plus, RefreshCw } from "lucide-react";
import { YouTubeIcon } from "../lib/brandIcons";
import { api, type Playlist } from "../lib/api";
import { compareText } from "../lib/collate";
import { formatRuntime } from "../lib/format";
@@ -155,6 +156,7 @@ export default function PlaylistsRail({
async function syncYoutube() {
if (syncing) return;
setSyncing(true);
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
const r = await api.syncYoutubePlaylists();
qc.invalidateQueries({ queryKey: qk.playlists() });
@@ -360,7 +362,7 @@ export default function PlaylistsRail({
<div className="flex items-center gap-1 text-[0.8125rem] text-fg">
<span className="truncate">{plName(pl)}</span>
{(pl.source === "youtube" || pl.yt_playlist_id) && (
<Youtube
<YouTubeIcon
className={`w-3 h-3 shrink-0 ${pl.dirty ? "text-accent" : "text-muted"}`}
/>
)}
+2 -2
View File
@@ -201,7 +201,7 @@ export default function PlexBrowse() {
// needs — the navigation handlers record where the page was, and the restore layout effect above
// reads it on the way back. React's own guarantee (a ref write is never part of rendering) is what
// makes it safe, and nothing here reads the value during render.
// eslint-disable-next-line react-hooks/immutability
// eslint-disable-next-line react-hooks/immutability -- a scroll memory: handlers record, the restore layout effect reads, nothing reads it in render
const rememberInfoScroll = (px: number) => void (infoScrollRef.current = px);
// One menu for the titles grid and one for the search's episodes (see ui/ContextMenu).
const menu = useContextMenu<PlexCard>();
@@ -289,7 +289,7 @@ export default function PlexBrowse() {
// belong: `setFilters` belongs to PlexProvider, and React only permits a render-phase setState
// on the component's OWN state. The cascade the rule warns about is bounded to one extra pass
// — the guard above is false immediately afterwards.
// eslint-disable-next-line react-hooks/set-state-in-effect
// eslint-disable-next-line react-hooks/set-state-in-effect -- setFilters is PlexProvider's state, so it cannot reset during render; the cascade is one bounded pass
setCommittedLang(lang);
if (filters.genres.length) setFilters({ ...filters, genres: [] });
}
@@ -71,6 +71,7 @@ export default function PlexCollectionEditor({
if (busy) return;
setBusy(id);
const inIt = members.has(id);
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
if (inIt) await api.plexCollectionRemoveItem(id, item.id);
else await api.plexCollectionAddItem(id, item.id);
@@ -94,6 +95,7 @@ export default function PlexCollectionEditor({
async function markEditable(id: string) {
if (busy) return;
setBusy(id);
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
await api.plexSetCollectionEditable(id, true);
refresh();
@@ -108,6 +110,7 @@ export default function PlexCollectionEditor({
const title = newName.trim();
if (!title || busy) return;
setBusy("__new__");
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
const col = await api.plexCreateCollection(library, title, item.id);
setNewName("");
@@ -126,6 +129,7 @@ export default function PlexCollectionEditor({
if (!(await confirm({ message: t("plex.collEditor.deleteConfirm", { title }), danger: true })))
return;
setBusy(id);
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
await api.plexDeleteCollection(id);
setMembers((prev) => {
@@ -148,6 +148,7 @@ export default function PlexCollectionView({
async function playAll(shuffle: boolean) {
setQueueBusy(true);
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
let queue = await buildQueue();
if (shuffle) queue = shuffled(queue);
@@ -206,6 +207,7 @@ export default function PlexCollectionView({
return;
}
setBusy(true);
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
await api.plexRenameCollection(collectionId, title);
invalidate();
@@ -238,6 +240,7 @@ export default function PlexCollectionView({
if (!(await confirm({ message: t("plex.collection.removeConfirm", { title: card.title }) })))
return;
setBusy(true);
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
await api.plexCollectionRemoveItem(collectionId, card.id);
invalidate();
@@ -250,6 +253,7 @@ export default function PlexCollectionView({
async function enableEdit() {
setBusy(true);
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
await api.plexSetCollectionEditable(collectionId, true);
invalidate();
@@ -302,7 +306,7 @@ export default function PlexCollectionView({
onKeyDown={(e) => {
if (e.key === "Enter") {
renameFocus.arm(e);
doRename();
void doRename(); // reports its own failure
}
if (e.key === "Escape") {
renameFocus.arm(e);
@@ -219,7 +219,9 @@ describe("a tap on the picture (C3)", () => {
video()._paused = true;
act(() => void video().play());
expect(bandHidden()).toBe(false);
act(() => vi.advanceTimersByTime(3000));
act(() => {
vi.advanceTimersByTime(3000);
});
expect(bandHidden()).toBe(true);
});
});
@@ -247,10 +249,14 @@ describe("keyboard focus in the bands", () => {
fireEvent.keyDown(document.body, { key: "Tab" }); // the keyboard is in use: focus shows
act(() => vol.focus());
fireEvent.keyDown(vol, { key: "ArrowUp" });
act(() => vi.advanceTimersByTime(5000));
act(() => {
vi.advanceTimersByTime(5000);
});
expect(bandHidden()).toBe(false);
act(() => vol.blur());
act(() => vi.advanceTimersByTime(3000));
act(() => {
vi.advanceTimersByTime(3000);
});
expect(bandHidden()).toBe(true);
});
@@ -264,9 +270,13 @@ describe("keyboard focus in the bands", () => {
fireEvent.keyDown(document.body, { key: "Tab" });
act(() => vol.focus());
fireEvent.keyDown(vol, { key: "ArrowUp" });
act(() => vi.advanceTimersByTime(9000));
act(() => {
vi.advanceTimersByTime(9000);
});
expect(bandHidden()).toBe(false);
act(() => vi.advanceTimersByTime(1500));
act(() => {
vi.advanceTimersByTime(1500);
});
expect(bandHidden()).toBe(true);
expect(document.activeElement).toBe(vol);
fireEvent.keyDown(vol, { key: "ArrowUp" });
@@ -283,7 +293,9 @@ describe("keyboard focus in the bands", () => {
fireEvent.keyDown(document.body, { key: "Tab" });
act(() => mute.focus());
fireEvent.keyDown(mute, { key: "ArrowUp" });
act(() => vi.advanceTimersByTime(10_500));
act(() => {
vi.advanceTimersByTime(10_500);
});
expect(bandHidden()).toBe(true);
const pauses = pause.mock.calls.length;
// prevented: the unseen Mute is not pressed on keyup
@@ -304,7 +316,9 @@ describe("keyboard focus in the bands", () => {
fireEvent.keyDown(document.body, { key: "Tab" });
act(() => mute.focus());
fireEvent.keyDown(mute, { key: "ArrowUp" });
act(() => vi.advanceTimersByTime(10_500));
act(() => {
vi.advanceTimersByTime(10_500);
});
expect(bandHidden()).toBe(true);
const pauses = pause.mock.calls.length;
expect(fireEvent.keyDown(mute, { key: "Enter" })).toBe(false);
@@ -325,7 +339,9 @@ describe("keyboard focus in the bands", () => {
fireEvent.keyDown(document.body, { key: "Tab" });
act(() => mute.focus());
fireEvent.keyDown(mute, { key: "ArrowUp" });
act(() => vi.advanceTimersByTime(10_500));
act(() => {
vi.advanceTimersByTime(10_500);
});
expect(bandHidden()).toBe(true);
expect(fireEvent.keyDown(mute, { key })).toBe(false);
const toggles = pause.mock.calls.length + play.mock.calls.length;
@@ -352,7 +368,9 @@ describe("keyboard focus in the bands", () => {
const skip = screen.getByRole("button", { name: /plex\.player\.skipIntro/ });
fireEvent.keyDown(document.body, { key: "Tab" });
act(() => skip.focus());
act(() => vi.advanceTimersByTime(10_500));
act(() => {
vi.advanceTimersByTime(10_500);
});
expect(bandHidden()).toBe(true);
const pauses = pause.mock.calls.length;
// not prevented: the button's own activation runs, and Space does not pause the film instead
@@ -377,7 +395,9 @@ describe("keyboard focus in the bands", () => {
return sel === ":focus-visible" ? false : matches.call(this, sel);
});
act(() => btn.focus());
act(() => vi.advanceTimersByTime(3000));
act(() => {
vi.advanceTimersByTime(3000);
});
expect(bandHidden()).toBe(true);
act(() => btn.blur()); // the tap on the picture takes the focus first…
pressPicture("touch"); // …then toggles
@@ -428,7 +448,9 @@ describe("a double-tap on a side third skips 10 s (C5)", () => {
pressPicture("touch", 450); // reveal
expect(bandHidden()).toBe(false);
expect(band()).toContain("pointer-events-none");
act(() => vi.advanceTimersByTime(300));
act(() => {
vi.advanceTimersByTime(300);
});
expect(band()).not.toContain("pointer-events-none");
});
@@ -270,7 +270,9 @@ describe("the subtitle poll and notice", () => {
it("shows a toast raised while it is open inside itself, not under its scrim", async () => {
mount({ details: { A: item("A") }, sessions: { A: () => new Promise(() => {}) } });
await screen.findByText("Title A");
act(() => notify({ level: "error", message: "Couldn't mark it watched." }));
act(() => {
notify({ level: "error", message: "Couldn't mark it watched." });
});
const toast = await screen.findByText("Couldn't mark it watched.");
let host: Element | null = video();
while (host && !host.contains(toast)) host = host.parentElement;
+6 -4
View File
@@ -27,7 +27,7 @@ import {
Volume2,
VolumeX,
} from "lucide-react";
import { api, HttpError, type PlexTrackNaming } from "../lib/api";
import { api, HttpError, type PlexPlaySession, type PlexTrackNaming } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import {
audioOrdForLang,
@@ -418,7 +418,9 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
// "Preparing stream…" for a not-yet-known session while the plexSession request is in flight —
// it's re-set honestly from the response below.
setSessionIsTranscode(false);
let sess;
// Initialised, not a bare `let sess;`: the compiler's analysis read an unassigned local as a
// value from OUTSIDE this callback and asked for `sess.hw` as a dependency (memo-dependencies).
let sess: PlexPlaySession | null = null;
try {
// Multi-audio → request the multi-rendition session (no single `audio` ordinal); the track is
// selected client-side after the manifest loads.
@@ -724,7 +726,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
// sessGenRef is a LIVE counter: bumping it is how the unmount tells every in-flight session
// that its response no longer belongs to anything, and a value captured when the effect ran
// would invalidate a generation that has since been superseded instead of the current one.
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps -- sessGenRef is a LIVE counter: unmount must bump the current generation, not a copy
sessGenRef.current++;
if (hlsRef.current) {
hlsRef.current.destroy();
@@ -837,7 +839,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
video.currentTime = rel; // native seek within the loaded region keeps playing on its own
if (play) video.play().catch(() => {});
} else {
loadSession(T, wasPlaying); // session restart: only auto-resume if we were playing
void loadSession(T, wasPlaying); // session restart: only auto-resume if we were playing
}
},
[loadSession],
+3 -1
View File
@@ -50,7 +50,7 @@ export default function PlexPlaylistAdd({
: api.plexPlaylists(target.ratingKey),
});
const playlists = listQ.data?.playlists ?? [];
const refresh = () => qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
const refresh = () => void qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
// How many of the target a playlist currently holds (0..size).
function inCount(p: PlexPlaylist): number {
@@ -68,6 +68,7 @@ export default function PlexPlaylistAdd({
if (busy) return;
setBusy(true);
const allIn = inCount(p) >= size;
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
if (isGroup) {
if (allIn) await api.plexPlaylistRemoveBulk(p.id, target.ratingKeys);
@@ -92,6 +93,7 @@ export default function PlexPlaylistAdd({
const title = newName.trim();
if (!title || busy) return;
setBusy(true);
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
if (isGroup) {
const pl = await api.plexCreatePlaylist(title);
+1 -1
View File
@@ -742,7 +742,7 @@ export default function PlexPlaylistView({
onKeyDown={(e) => {
if (e.key === "Enter") {
renameFocus.arm(e);
rename();
void rename(); // reports its own failure
}
// Unmounting the field fires no blur, so Escape really does cancel.
if (e.key === "Escape") {
+2 -1
View File
@@ -401,7 +401,8 @@ export function PlaylistPicker() {
onChange={(next) => {
const v = next[0];
if (!v) return;
if (v === CREATE_PLAYLIST) createPlaylist(q);
if (v === CREATE_PLAYLIST)
void createPlaylist(q); // reports its own failure
else onOpenPlaylist(Number(v.slice(3)));
}}
/>
@@ -49,7 +49,10 @@ describe("PrefsProvider seed", () => {
);
expect(seen[0]).toEqual({ perf: true, hints: false });
act(() => vi.advanceTimersByTime(1000)); // past the auto-save debounce
// past the auto-save debounce
act(() => {
vi.advanceTimersByTime(1000);
});
expect(save).not.toHaveBeenCalled();
});
});
+1 -1
View File
@@ -60,7 +60,7 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
const [spec, setSpec] = useState<DownloadSpec>(BLANK);
const [formOpen, setFormOpen] = useState(false);
const invalidate = () => qc.invalidateQueries({ queryKey: qk.downloadProfiles() });
const invalidate = () => void qc.invalidateQueries({ queryKey: qk.downloadProfiles() });
const startNew = () => {
setEditing(null);
setName("");
@@ -201,7 +201,9 @@ describe("SavedViewsProvider — signed-out tab", () => {
});
expect(spy).not.toHaveBeenCalled();
// …and it asks as soon as an account signs in.
act(() => qc.setQueryData(qk.me(), { id: ID, preferences: {} }));
act(() => {
qc.setQueryData(qk.me(), { id: ID, preferences: {} });
});
await waitFor(() => expect(spy).toHaveBeenCalledWith("feed"));
});
});
@@ -188,8 +188,9 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
const displayView = activeView ?? stickyView;
const isDirty = displayView != null && activeView == null;
const refresh = () => qc.invalidateQueries({ queryKey: qk.savedViews(source) });
const refresh = () => void qc.invalidateQueries({ queryKey: qk.savedViews(source) });
async function runViewMutation(fn: () => Promise<unknown>, failMsg: string): Promise<boolean> {
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
await fn();
return true;
@@ -74,7 +74,9 @@ afterEach(() => {
const menuRows = () =>
screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent ?? "");
async function openMenu() {
act(() => fireEvent.click(screen.getByRole("button", { name: "scheduler.menu" })));
act(() => {
fireEvent.click(screen.getByRole("button", { name: "scheduler.menu" }));
});
await waitFor(() =>
expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0),
);
+3 -1
View File
@@ -367,6 +367,7 @@ function SignInMethods({ me }: { me: Me }) {
)
return;
setSessBusy(true);
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
await api.logoutOthers();
notify({ level: "success", message: t("settings.account.sessions.done") });
@@ -377,10 +378,11 @@ function SignInMethods({ me }: { me: Me }) {
}
};
const submit = async (e: React.FormEvent) => {
const submit = async (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setErr(null);
setBusy(true);
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
await api.setPassword(next, me.has_password ? current : undefined);
setOpen(false);
+1
View File
@@ -84,6 +84,7 @@ export default function SetupWizard() {
setAttempt((n) => n + 1);
setError("");
setBusy(true);
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
if (step === "admin") {
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email))
+1 -1
View File
@@ -240,7 +240,7 @@ function LinkShare({ job }: { job: DownloadJob }) {
const [allowDownload, setAllowDownload] = useState(true);
const [expiryDays, setExpiryDays] = useState<number | null>(null);
const [password, setPassword] = useState("");
const invalidate = () => qc.invalidateQueries({ queryKey: qk.downloadLinks(job.id) });
const invalidate = () => void qc.invalidateQueries({ queryKey: qk.downloadLinks(job.id) });
const create = useMutation({
mutationFn: () =>
+4 -1
View File
@@ -141,7 +141,10 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
const playPause = () => {
const v = videoRef.current;
if (!v) return;
v.paused ? v.play() : v.pause();
// play() rejects when a pause or a source change interrupts it; the button state follows the
// element's own events, so there is nothing to undo.
if (v.paused) v.play().catch(() => {});
else v.pause();
};
// --- cut-list mutations ---
+2
View File
@@ -200,6 +200,7 @@ export default function WatchPage() {
const unlock = async () => {
setUnlocking(true);
setError(null);
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
const r = await send(`/api/public/watch/${encodeURIComponent(token)}/unlock`, {
method: "POST",
@@ -219,6 +220,7 @@ export default function WatchPage() {
setStatus("gone");
return;
}
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower a throw inside try/catch yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
if (!r.ok) throw new Error(`HTTP ${r.status}`);
setMeta(await r.json());
setStatus("ready");
+4 -2
View File
@@ -478,7 +478,7 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
setMode("forgot");
};
async function onSubmit(e: React.FormEvent) {
async function onSubmit(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault();
setAttempt((n) => n + 1);
reset();
@@ -495,6 +495,7 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
return;
}
setBusy(true);
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
if (mode === "signin") {
await api.passwordLogin(mail, password);
@@ -538,7 +539,7 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
}
}
async function onDemo(e: React.FormEvent) {
async function onDemo(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault();
setDemoError("");
const mail = demoEmail.trim().toLowerCase();
@@ -547,6 +548,7 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
return;
}
setDemoBusy(true);
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
const r = await api.demoLogin(mail);
if (r.authenticated) window.location.reload();
@@ -67,8 +67,8 @@ function mount(u: typeof user = user) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<AccountPanel user={u as any} onClose={() => {}} />
{/* a partial user fixture: `never` stands in for the full type without an `any` */}
<AccountPanel user={u as never} onClose={() => {}} />
</QueryClientProvider>,
);
}
@@ -35,7 +35,7 @@ function useOperatorContact(): string | null {
const [contact, setContact] = useState<string | null>(null);
useEffect(() => {
let alive = true;
fetchOperatorContact().then((c) => alive && setContact(c));
void fetchOperatorContact().then((c) => alive && setContact(c)); // never rejects: null on failure
return () => {
alive = false;
};
@@ -112,9 +112,13 @@ describe("PlexPosterCard preview", () => {
vi.useFakeTimers();
render(<PlexPosterCard card={card} onOpen={vi.fn()} />);
fireEvent.mouseEnter(root());
act(() => vi.advanceTimersByTime(600));
act(() => {
vi.advanceTimersByTime(600);
});
expect(screen.queryByText("A quiet synopsis.")).toBeNull(); // a sweep opens nothing
act(() => vi.advanceTimersByTime(400));
act(() => {
vi.advanceTimersByTime(400);
});
expect(screen.getByText("A quiet synopsis.")).toBeTruthy();
expect(screen.getAllByText("A long title")).toHaveLength(2); // the card's and the preview's
});
@@ -123,7 +127,9 @@ describe("PlexPosterCard preview", () => {
vi.useFakeTimers();
render(<PlexPosterCard card={card} onOpen={vi.fn()} />);
fireEvent.focus(screen.getByRole("button", { name: "A long title" }));
act(() => vi.advanceTimersByTime(2000));
act(() => {
vi.advanceTimersByTime(2000);
});
expect(screen.queryByText("A quiet synopsis.")).toBeNull();
});
});
+7 -3
View File
@@ -225,7 +225,9 @@ export function PlexPersonView({
menuOpen: menu.open && menu.target === c,
});
const section = (titleKey: string, cards: PlexCard[]) =>
// Not `section`: a local named like the <section> it renders confused the React Compiler's scope
// analysis ("[hoisting] expected identifier to be defined") and it skipped the component.
const renderSection = (titleKey: string, cards: PlexCard[]) =>
cards.length > 0 ? (
<section className="mb-5">
<h2 className="text-sm font-semibold mb-3">{t(titleKey, { count: cards.length })}</h2>
@@ -385,8 +387,8 @@ export function PlexPersonView({
<p className="text-sm text-muted">{t("plex.person.empty")}</p>
) : (
<>
{section("plex.person.movies", d.movies)}
{section("plex.person.shows", d.shows)}
{renderSection("plex.person.movies", d.movies)}
{renderSection("plex.person.shows", d.shows)}
</>
)}
</div>
@@ -464,6 +466,7 @@ export function PlexShowView({
);
async function markAll(w: boolean) {
setBusy(true);
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
await api.plexShowState(showId, w);
} catch (e) {
@@ -797,6 +800,7 @@ export function PlexSeasonView({
);
async function markAll(w: boolean) {
setBusy(true);
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
await api.plexSeasonState(seasonId, w);
} catch (e) {
@@ -231,7 +231,7 @@ interface PopoverSurfaceProps {
* take its name from. */
ariaLabel?: string;
focusManaged?: boolean;
initialFocus?: number | React.MutableRefObject<HTMLElement | null>;
initialFocus?: number | React.RefObject<HTMLElement | null>;
/** The surface never receives pointer events — for a passive caption that must not swallow a click
* aimed at whatever it happens to cover during its close delay. Interactive hover cards (scrollable
* content, links) must NOT set this. */
+1 -1
View File
@@ -205,7 +205,7 @@ export default function Toaster({
{/* lib/toastDock is a one-slot registry: this identity changes only when a surface docks
or undocks, and remounting is then the correct behaviour, not the hazard the rule
warns about. */}
{/* eslint-disable-next-line react-hooks/static-components */}
{/* eslint-disable-next-line react-hooks/static-components -- a one-slot dock registry: the identity changes only on dock/undock, where a remount is right */}
<Docked />
</div>
)}
+3 -1
View File
@@ -3,9 +3,11 @@ import { useState } from "react";
// An overlay's toast-host rank (see the overlay-hosts note in Toaster). Its own module so Toaster exports
// only components (Fast Refresh); the counter is module state, so there is exactly one sequence.
let overlaySeq = 0;
// Outside the hook: the React Compiler cannot lower `++` on a module variable inside one (react-hooks/todo).
const nextOverlaySeq = () => ++overlaySeq;
/** This overlay's rank among open overlays — taken once, when it mounts. Call it in the overlay
* itself, never in a component that can remount while the overlay stays open. */
export function useOverlayOrder(): number {
const [order] = useState(() => ++overlaySeq);
const [order] = useState(nextOverlaySeq);
return order;
}
+1 -1
View File
@@ -65,7 +65,7 @@ export function useSortableA11y(describe: (id: UniqueIdentifier) => SortableSubj
// dnd-kit reports the item over ITSELF in the pick-up tick (see above), and this flag is
// the only thing separating that from a real move. A ref would be the wrong lifetime —
// one drag, not the component.
// eslint-disable-next-line react-hooks/immutability
// eslint-disable-next-line react-hooks/immutability -- per-DRAG state the announcement closures share; a ref would live as long as the component
moved = true;
return t("common.dnd.movedOver", landing(active.id, over.id));
},
@@ -106,6 +106,7 @@ export function useDebugRecording() {
if (!session) return;
hide(true);
let image: Blob | null = null;
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
// Two frames: the pill's `invisible` must be painted before the capture reads the tab.
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
@@ -89,6 +89,7 @@ export function useDownloadActions({
const quickShare = async (job: DownloadJob) => {
if (sharingId !== null) return;
setSharingId(job.id);
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try without catch yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
await quickShareLink(qc, job, t);
} finally {
@@ -1,6 +1,7 @@
import { useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { ExternalLink, Pencil, RotateCcw, Trash2, Upload, Youtube } from "lucide-react";
import { ExternalLink, Pencil, RotateCcw, Trash2, Upload } from "lucide-react";
import { YouTubeIcon } from "../lib/brandIcons";
import { qk } from "../lib/queryKeys";
import { api, type Playlist } from "../lib/api";
import { notify } from "../lib/notifications";
@@ -130,6 +131,7 @@ export function usePlaylistActions() {
// Re-read after the dialog: another menu may have started one while this one asked.
if (!ok || syncing(pl.id)) return;
mark("reverting", pl.id, true);
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
await api.revertPlaylist(pl.id);
refresh(pl.id);
@@ -148,6 +150,7 @@ export function usePlaylistActions() {
if (syncing(pl.id)) return;
const name = playlistName(pl, t);
mark("pushing", pl.id, true);
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
const plan = await api.playlistPushPlan(pl.id);
const nothingToDo =
@@ -266,7 +269,7 @@ export function usePlaylistActions() {
icon: pushing.has(pl.id) ? (
<Upload className="w-4 h-4 animate-pulse" />
) : linked ? (
<Youtube className="w-4 h-4" />
<YouTubeIcon className="w-4 h-4" />
) : (
<Upload className="w-4 h-4" />
),
@@ -99,13 +99,13 @@ describe("usePlexPlaylistEdits · the reorder", () => {
// answers the same from now on — so the rollback's own refetch comes back deep-equal, keeps
// the same `q.data`, and could never correct a stale rollback.
detail.mockResolvedValue({ id: 5, title: "F", items: [movie("1"), movie("3")] } as never);
act(() =>
act(() => {
qc.setQueryData(qk.plexPlaylist(5, "en"), {
id: 5,
title: "F",
items: [movie("1"), movie("3")],
}),
);
});
});
// The view has taken the refreshed list in BEFORE the reorder fails — the race as it happens.
// (react-query batches its notification, so without this wait the refresh landed only after the
// rollback and quietly corrected a stale one, which proved nothing.)
@@ -96,6 +96,7 @@ export function usePlexPlaylistEdits(playlistId: number) {
const before = items.map((i) => i.id);
const set = new Set(keys);
setItems((prev) => prev.filter((i) => !set.has(i.id)));
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
if (keys.length === 1) await api.plexPlaylistRemoveItem(playlistId, keys[0]!);
else await api.plexPlaylistRemoveBulk(playlistId, keys);