fix(ui): a write that fails says what failed and why, wherever it fails

`req` opens its blocking dialog for 400/409/422/5xx only. A 403/404/429 was left to the caller and a
502-504 or lost connection got just the self-clearing connection notice, so 34 mutations without a
reporting onError and 15 direct api writes with a bare or missing catch failed in silence: a hidden
channel that snapped back, a share link that never revoked, a confirmed account delete that ended
with nothing said.

- lib/queryClient: one MutationCache reporter. react-query awaits it BEFORE the mutation's own
  onError, so it waits a task and skips a failure the site already reported; the toast title comes
  from `meta.failTitle`, `meta.errorShownInline` opts out.
- notifyActionError: title names WHAT failed, the line under it says WHY (`failureReason`: a
  structured localized detail, else the status). It marks the error reported (`markReported`), so
  nothing reports one failure twice; `notifyKnownFailure` for a site's own specific sentence.
- Every silent site named (45 new HU/EN titles, 4 reasons). Stale "the global dialog shows it"
  comments corrected on the job actions, channel block, Plex sync, cookies, download enqueue,
  account delete and usePref.
- Channels passed the tag hook's channel id to the reporter as the error; the tag and priority hooks
  now report themselves, with the real error.
- A contract test keeps every useMutation naming its failure; CLAUDE.md gains the pattern row.
- history.dom.test: the "navigates only once the entries are back" assertion raced an async act's
  macrotask flush (1 in 5 runs); it now asserts from a later popstate listener.
This commit is contained in:
2026-09-13 01:45:28 +02:00
parent b4aea7fa5b
commit 8a50538275
45 changed files with 851 additions and 132 deletions
+1
View File
@@ -91,6 +91,7 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
| FE control | the chip/segmented primitives (`Select` only for >6 options) | a native `<select>` |
| 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 |
## House rules that bite (learned from real bugs)
+9 -2
View File
@@ -3,6 +3,7 @@ import { qk } from "./lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api, getActiveAccount, setActiveAccount, setUnauthorizedHandler } from "./lib/api";
import { notifyActionError } from "./lib/actionErrors";
import { setLanguage, isSupported, type LangCode } from "./i18n";
import { clearParams } from "./lib/urlState";
import type { Page } from "./lib/urlState";
@@ -97,7 +98,9 @@ export default function App() {
function setPanelLayout(panel: PanelId, next: PanelLayout) {
setPanelLayouts((prev) => ({ ...prev, [panel]: next }));
saveLayoutLocal(panel, next);
api.savePrefs({ [PREF_KEY[panel]]: next }).catch(() => {});
api
.savePrefs({ [PREF_KEY[panel]]: next })
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.prefs")));
}
// Collapse state for the two full-height panels (left nav + filter sidebar). Persisted to the
@@ -287,7 +290,11 @@ export default function App() {
// Language is edited outside the Settings page (sidebar/login), so it stays instant.
function changeLanguage(code: LangCode) {
setLanguage(code);
api.savePrefs({ language: code }).catch(() => {});
// Also from the signed-out login page, where the save 401s — `req` marks that as handled, so
// the reporter stays quiet there.
api
.savePrefs({ language: code })
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.prefs")));
}
// First-run: a fresh instance is in setup mode (all other routes are locked) → show the wizard.
+6 -8
View File
@@ -7,6 +7,7 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
import { api } from "../lib/api";
import { qk } from "../lib/queryKeys";
import { notify } from "../lib/notifications";
import { notifyActionError, notifyKnownFailure } from "../lib/actionErrors";
import { copyText } from "../lib/clipboard";
import { invalidateUserAccess, isEmailOff } from "../lib/adminUsers";
import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin";
@@ -66,25 +67,22 @@ export default function AccountPanel({
const issue = useMutation({
mutationFn: () => api.createSetPasswordLink(user.id),
onSuccess: (r) => setLink({ url: r.url, expires_at: r.expires_at }),
onError: () => notify({ level: "error", message: t("users.account.linkFailed") }),
onError: (err) => notifyActionError(err, t("users.account.linkFailed")),
});
const resend = useMutation({
mutationFn: () => api.resendUserVerification(user.id),
onSuccess: () => notify({ level: "success", message: t("users.account.resendSent") }),
onError: (err) =>
notify({
level: "error",
message: isEmailOff(err)
? t("users.pendingAccess.resendEmailOff")
: t("users.pendingAccess.resendFailed"),
}),
isEmailOff(err)
? notifyKnownFailure(err, t("users.pendingAccess.resendEmailOff"))
: notifyActionError(err, t("users.pendingAccess.resendFailed")),
});
const activate = useMutation({
mutationFn: () => api.activateUser(user.id),
onSuccess: () => invalidateUserAccess(qc, user.id),
onError: () => notify({ level: "error", message: t("users.pendingAccess.activateFailed") }),
onError: (err) => notifyActionError(err, t("users.pendingAccess.activateFailed")),
});
const copy = async () => {
@@ -86,7 +86,8 @@ describe("a user action that fails", () => {
await waitFor(() =>
expect(notify).toHaveBeenCalledWith({
level: "error",
message: i18n.t("errors.notFound.user"), // the request layer localizes a structured detail
title: "users.roles.failed", // what failed…
message: i18n.t("errors.notFound.user"), // …and why: the request layer localizes a structured detail
}),
);
await waitFor(() => expect(users).toHaveBeenCalledTimes(2));
+10 -21
View File
@@ -38,7 +38,7 @@ import { useConfirm } from "./ConfirmProvider";
import Tabs, { usePersistedTab } from "./ui/Tabs";
import { PageToolbar } from "./ui/PageShell";
import { useMe } from "../lib/useMe";
import { notifyActionError } from "../lib/actionErrors";
import { notifyActionError, notifyKnownFailure } from "../lib/actionErrors";
// Admin-only user & access management: roles, access requests (the Invite whitelist), and the
// demo whitelist + reset. Each section is its own tab (persisted across reloads); the Access tab
@@ -351,7 +351,7 @@ function PendingAccess() {
refresh();
notify({ level: "success", message: t("users.pendingAccess.activated", { email: u.email }) });
},
onError: () => notify({ level: "error", message: t("users.pendingAccess.activateFailed") }),
onError: (err) => notifyActionError(err, t("users.pendingAccess.activateFailed")),
});
const resend = useMutation({
@@ -363,12 +363,9 @@ function PendingAccess() {
// The public resend is deliberately silent; this one is not, so say WHY it failed — an instance
// with no working SMTP would otherwise show a success toast for a mail that was never sent.
onError: (err) =>
notify({
level: "error",
message: isEmailOff(err)
? t("users.pendingAccess.resendEmailOff")
: t("users.pendingAccess.resendFailed"),
}),
isEmailOff(err)
? notifyKnownFailure(err, t("users.pendingAccess.resendEmailOff"))
: notifyActionError(err, t("users.pendingAccess.resendFailed")),
});
const list = rows.data ?? [];
@@ -563,12 +560,8 @@ function AdminInvites() {
// A 409 means the list was stale — someone registered on that address since it loaded. Say
// what to do instead of a generic failure, and refetch so the row re-renders as read-only.
const conflict = e instanceof HttpError && e.status === 409;
notify({
level: "error",
message: t(
conflict ? "settings.invites.deleteHasAccount" : "settings.invites.deleteFailed",
),
});
if (conflict) notifyKnownFailure(e, t("settings.invites.deleteHasAccount"));
else notifyActionError(e, t("settings.invites.deleteFailed"));
if (conflict) refresh();
},
});
@@ -599,13 +592,9 @@ function AdminInvites() {
notify({ level: "success", message: t("settings.invites.invited", { email: v.inv.email }) });
},
onError: (err) =>
notify({
level: "error",
message:
err instanceof HttpError && inviteEmailOff(err)
? t("settings.invites.inviteEmailOff")
: t("settings.invites.inviteFailed"),
}),
err instanceof HttpError && inviteEmailOff(err)
? notifyKnownFailure(err, t("settings.invites.inviteEmailOff"))
: notifyActionError(err, t("settings.invites.inviteFailed")),
});
// The one recipient the app has no language signal about: the admin typed this address in, so
// nobody ever told us how to write to its owner. The dialog is where that gets decided — and it
+22 -15
View File
@@ -29,6 +29,7 @@ import { useFeedFilters } from "./FeedFiltersProvider";
import { useConfirm } from "./ConfirmProvider";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { notifyActionError } from "../lib/actionErrors";
import { api, DEFAULT_TAG_MODES, type FeedFilters, type Me, type ChannelDetail } from "../lib/api";
import type { FeedView } from "../lib/feedView";
import { channelYouTubeUrl, formatViews } from "../lib/format";
@@ -106,18 +107,23 @@ export default function ChannelPage({
const runExplore = useCallback(
(token?: string | null) => {
setExploring(true);
return api
.exploreChannel(channelId, token ?? undefined)
.then((r) => {
setNextToken(r.next_page_token);
qc.invalidateQueries({ queryKey: qk.feed() });
// Refetch the detail so `explored` flips true → the "Exploring" badge shows on the
// first visit (the GET that drove this render predated the explore that just ran).
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
})
.finally(() => setExploring(false));
return (
api
.exploreChannel(channelId, token ?? undefined)
.then((r) => {
setNextToken(r.next_page_token);
qc.invalidateQueries({ queryKey: qk.feed() });
// Refetch the detail so `explored` flips true → the "Exploring" badge shows on the
// first visit (the GET that drove this render predated the explore that just ran).
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
})
// Both the automatic first-visit explore and "load more" used to fail in silence: an empty
// channel page that looked like a channel with no videos (E11 S2b).
.catch((err: unknown) => notifyActionError(err, t("errors.writeFailed.exploreChannel")))
.finally(() => setExploring(false))
);
},
[channelId, qc],
[channelId, qc, t],
);
// First visit of an un-subscribed channel → ingest its recent uploads. Skipped for the demo
@@ -129,7 +135,7 @@ export default function ChannelPage({
if (ch.subscribed || ch.blocked) return;
if (ch.explored && ch.stored_videos > 0) return;
autoTried.current = channelId;
runExplore().catch(() => {});
void runExplore();
}, [ch, channelId, me.is_demo, runExplore]);
const block = useMutation({
@@ -139,15 +145,16 @@ export default function ChannelPage({
qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: qk.blockedChannels() });
},
// No onError: block/unblock is a local DB op (not a YouTube write, so no 403 "connect" case),
// and its 400/409/422/500 are already surfaced by the global error modal (see api.ts req) —
// a caller toast here would just double it.
// No onError: a local DB op (not a YouTube write, so no 403 "connect" case). The app-wide
// reporter names the failure the error dialog does not cover (a 404 for a channel gone).
meta: { failTitle: "errors.writeFailed.blockChannel" },
});
// "Hide from feed": the same per-subscription flag the manager toggles (updateChannel {hidden}).
// Local DB op — keeps the subscription, only drops the channel's videos from the feed/explore.
const toggleHidden = useMutation({
mutationFn: () => api.updateChannel(channelId, { hidden: !ch?.hidden }),
meta: { failTitle: "errors.writeFailed.hideChannel" },
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
qc.invalidateQueries({ queryKey: qk.feed() });
+4 -4
View File
@@ -6,7 +6,6 @@ import { Ban, Eye, EyeOff, History, LayoutGrid, Pencil, RefreshCw, Table, X } fr
import ExcludedChannelList from "./ExcludedChannelList";
import { api, NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
import { Sparkles } from "lucide-react";
import { notifyActionError } from "../lib/actionErrors";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { accountKey, LS } from "../lib/storage";
import { buildChannelColumns, applySyncFilter } from "./channelColumns";
@@ -110,6 +109,7 @@ export default function Channels() {
const [tagManagerOpen, setTagManagerOpen] = useState(false);
const unblock = useMutation({
mutationFn: (id: string) => api.unblockChannel(id),
meta: { failTitle: "errors.writeFailed.unblockChannel" },
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.blockedChannels() });
qc.invalidateQueries({ queryKey: qk.feed() });
@@ -133,6 +133,7 @@ export default function Channels() {
id: string;
body: { priority?: number; hidden?: boolean; deep_requested?: boolean };
}) => api.updateChannel(v.id, v.body),
meta: { failTitle: "errors.writeFailed.channelSettings" },
// Requesting full history may have just pulled in a channel's recent uploads, so
// refresh the per-user status and feed too — not only the channel list.
onSuccess: () => {
@@ -182,11 +183,10 @@ export default function Channels() {
// A failed attach/detach reverts the optimistic change by refetching the list. Also refetch
// suggestions: acceptSuggestion optimistically dropped the channel from that cache, and on
// failure the tag never actually attached — so the server still suggests it and the ghost chips
// must come back rather than leaving an empty cell (honest states). Surface the write failure too.
onError: (e) => {
// must come back rather than leaving an empty cell (honest states). The hook reports the failure.
onError: () => {
qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: qk.tagSuggestions() });
notifyActionError(e, t("channels.tags.updateFailed"));
},
});
// --- Suggested tags (E8·S4): learned from the user's own manual-tag pattern, shown as accept/dismiss
+5 -2
View File
@@ -34,6 +34,7 @@ import { useConfirm } from "./ConfirmProvider";
import { useLiveQuery } from "../lib/useLiveQuery";
import { useMe } from "../lib/useMe";
import { api, type DownloadJob } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { invalidateDownloads, quickShareLink } from "../lib/downloads";
import { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
import { inputCls } from "./ui/form";
@@ -95,14 +96,16 @@ export default function DownloadCenter() {
? api.redownloadDownload(id)
: api.deleteDownload(id),
onSuccess: invalidate,
// No onError: a failed job action returns 400/409/422/500, already surfaced by the global
// error modal (api.ts req) with the backend's specific message — a caller toast would double it.
// A 400/409/422/500 opens the global error dialog with the backend's own message (and is then
// skipped here); a job that is gone (404) or a lost connection is said here, naming the action.
onError: (e, v) => notifyActionError(e, t(`errors.writeFailed.job.${v.op}`)),
});
// Remove a shared-with-me item from your list (only your grant; the owner's file is untouched).
const removeShared = useMutation({
mutationFn: (id: number) => api.removeSharedDownload(id),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.downloadsShared() }),
meta: { failTitle: "errors.writeFailed.removeShared" },
});
const confirmThen = async (title: string, body: string, fn: () => void) => {
+6 -2
View File
@@ -10,6 +10,7 @@ import { invalidateDownloads } from "../lib/downloads";
// Lazy: the full profile editor only opens from the dialog's "manage presets" affordance.
const ProfileEditor = lazy(() => import("./ProfileEditor"));
import { notify } from "../lib/notifications";
import { notifyActionError } from "../lib/actionErrors";
import { navigateTo } from "../lib/nav";
import { inputCls } from "./ui/form";
@@ -51,8 +52,11 @@ export default function DownloadDialog({
action: { label: t("downloads.dialog.view"), onClick: () => navigateTo("downloads") },
});
onClose();
} catch {
// Non-quiet request: the global error dialog already surfaced the reason (e.g. quota).
} catch (e) {
// A quota or validation refusal (422/409) is already in the global error dialog, which the
// reporter skips; a 403/404/429 or a lost connection is not, and used to leave the dialog
// open with nothing said (E11 S2b).
notifyActionError(e, t("errors.writeFailed.enqueueDownload"));
setBusy(false);
}
};
+23 -9
View File
@@ -23,6 +23,7 @@ import {
} from "lucide-react";
import { api, HttpError, type FeedFilters, type Video, type VideoStatus } from "../lib/api";
import i18n from "../i18n";
import { notifyActionError } from "../lib/actionErrors";
import { notify, resolveVideo } from "../lib/notifications";
import { useDebounced } from "../lib/useDebounced";
import {
@@ -308,15 +309,17 @@ export default function Feed({
resolveVideo(id);
}
})
.catch(() =>
.catch((e: unknown) => {
// Server rejected the change — drop the optimistic override so the card
// reverts to the real (unchanged) state instead of staying phantom-hidden.
// reverts to the real (unchanged) state instead of staying phantom-hidden, and say so:
// a card that silently snaps back reads as a misclick.
notifyActionError(e, i18n.t("errors.writeFailed.videoState"));
setOverrides((o) => {
const next = { ...o };
delete next[id];
return next;
}),
);
});
});
},
[qc],
);
@@ -330,13 +333,14 @@ export default function Feed({
.clearState(id)
// Stale-mark only (see setState above): the optimistic override already reset the card.
.then(() => qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" }))
.catch(() =>
.catch((e: unknown) => {
notifyActionError(e, i18n.t("errors.writeFailed.videoReset"));
setOverrides((o) => {
const next = { ...o };
delete next[id];
return next;
}),
);
});
});
},
[qc],
);
@@ -349,7 +353,15 @@ export default function Feed({
qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: qk.playlist() });
})
.catch(() => setSavedOverrides((o) => ({ ...o, [id]: !saved })));
.catch((e: unknown) => {
notifyActionError(
e,
i18n.t(
saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove",
),
);
setSavedOverrides((o) => ({ ...o, [id]: !saved }));
});
},
[qc],
);
@@ -466,7 +478,9 @@ export default function Feed({
.then((r) =>
notify({ message: i18n.t("feed.yt.cleared", { count: r.removed }) }),
)
.catch(() => {});
.catch((e: unknown) =>
notifyActionError(e, i18n.t("errors.writeFailed.clearSearch")),
);
setFilters({ ...filters, librarySource: "organic", sort: "newest" });
onExitYtSearch();
dropFeedCaches();
+20 -4
View File
@@ -79,10 +79,26 @@ export default function NotificationsPanel() {
qc.invalidateQueries({ queryKey: qk.notifications() });
qc.invalidateQueries({ queryKey: qk.notifUnread() });
};
const readMut = useMutation({ mutationFn: api.markNotificationRead, onSuccess: refresh });
const readAllMut = useMutation({ mutationFn: api.markAllNotificationsRead, onSuccess: refresh });
const dismissMut = useMutation({ mutationFn: api.dismissNotification, onSuccess: refresh });
const clearMut = useMutation({ mutationFn: api.clearNotifications, onSuccess: refresh });
const readMut = useMutation({
mutationFn: api.markNotificationRead,
onSuccess: refresh,
meta: { failTitle: "errors.writeFailed.notifRead" },
});
const readAllMut = useMutation({
mutationFn: api.markAllNotificationsRead,
onSuccess: refresh,
meta: { failTitle: "errors.writeFailed.notifReadAll" },
});
const dismissMut = useMutation({
mutationFn: api.dismissNotification,
onSuccess: refresh,
meta: { failTitle: "errors.writeFailed.notifDismiss" },
});
const clearMut = useMutation({
mutationFn: api.clearNotifications,
onSuccess: refresh,
meta: { failTitle: "errors.writeFailed.notifClear" },
});
const serverUnread = serverItems.some((n) => !n.read);
const hasAny = serverItems.length > 0 || clientItems.length > 0;
@@ -82,6 +82,8 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: qk.myStatus() });
},
// The step itself says the import failed and offers a retry button — no toast on top.
meta: { errorShownInline: true },
});
// First time we see read access with zero imported channels, pull the user's
@@ -380,7 +380,7 @@ describe("Playlists — a change the server refused is taken back on screen", ()
await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
expect(rowsOnScreen()).toEqual(["Video V", "Video W"]);
expect(h.notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "error", message: "playlists.removeFailed" }),
expect.objectContaining({ level: "error", title: "playlists.removeFailed" }),
);
});
});
+11 -7
View File
@@ -676,13 +676,17 @@ export default function Playlists() {
}
function playerState(id: string, status: string) {
api.setState(id, status).then(() => {
// Every cached list, not `qk.playlist(selectedId)`: the player may have been launched from a
// list that is no longer the selected one (see `playing`), and that list holds the row whose
// status just changed. Only lists actually on screen refetch.
qc.invalidateQueries({ queryKey: qk.playlist() });
qc.invalidateQueries({ queryKey: qk.feed() });
});
api
.setState(id, status)
.then(() => {
// Every cached list, not `qk.playlist(selectedId)`: the player may have been launched from a
// list that is no longer the selected one (see `playing`), and that list holds the row whose
// status just changed. Only lists actually on screen refetch.
qc.invalidateQueries({ queryKey: qk.playlist() });
qc.invalidateQueries({ queryKey: qk.feed() });
})
// No catch used to mean an unhandled rejection and nothing said (E11 S2b).
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState")));
}
// Stable identity: PlayerModal's keydown/scroll-lock effect depends on it, so a fresh closure
@@ -160,6 +160,7 @@ export default function PlaylistsRail({
const createMut = useMutation({
mutationFn: (name: string) => api.createPlaylist(name),
meta: { failTitle: "errors.writeFailed.createPlaylist" },
onSuccess: (pl: Playlist) => {
setNewName("");
qc.invalidateQueries({ queryKey: qk.playlists() });
+9 -3
View File
@@ -6,6 +6,7 @@ import { plexLang } from "../i18n";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ListMusic, Plus, SlidersHorizontal, X } from "lucide-react";
import { api, plexFilterCount, type PlexFilters } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { type PanelLayout } from "../lib/panelLayout";
import { type MediaSourceKind } from "../lib/mediaSource";
import { useDebounced } from "../lib/useDebounced";
@@ -377,9 +378,14 @@ function PlaylistPicker() {
];
async function createPlaylist(title: string) {
const pl = await api.plexCreatePlaylist(title);
qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
onOpenPlaylist(pl.id);
try {
const pl = await api.plexCreatePlaylist(title);
qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
onOpenPlaylist(pl.id);
} catch (e) {
// Called without an await from the picker, so a refusal was an unhandled rejection (E11 S2b).
notifyActionError(e, t("plex.playlistAdd.createFailed"));
}
}
return (
@@ -77,10 +77,12 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
invalidate();
setFormOpen(false);
},
meta: { failTitle: "errors.writeFailed.downloadProfileSave" },
});
const del = useMutation({
mutationFn: (id: number) => api.deleteDownloadProfile(id),
onSuccess: invalidate,
meta: { failTitle: "errors.writeFailed.downloadProfileDelete" },
});
const onDelete = async (p: DownloadProfile) => {
+4
View File
@@ -390,6 +390,7 @@ export default function Scheduler() {
// live poll surfaces the "running" state within a tick, so we just nudge a refetch.
const runMut = useMutation({
mutationFn: (jobId: string) => api.runSchedulerJob(jobId),
meta: { failTitle: "errors.writeFailed.schedulerRun" },
onSuccess: (_d, jobId) => {
notify({
level: "success",
@@ -400,6 +401,7 @@ export default function Scheduler() {
});
const runAllMut = useMutation({
mutationFn: () => api.runAllSchedulerJobs(),
meta: { failTitle: "errors.writeFailed.schedulerRunAll" },
onSuccess: (res) => {
notify({
level: "success",
@@ -410,10 +412,12 @@ export default function Scheduler() {
});
const batchMut = useMutation({
mutationFn: (v: number) => api.updateMaintenanceBatch(v),
meta: { failTitle: "errors.writeFailed.maintenanceBatch" },
onSuccess: () => qc.invalidateQueries({ queryKey: qk.scheduler() }),
});
const purgeMut = useMutation({
mutationFn: () => api.purgeDiscovery(),
meta: { failTitle: "errors.writeFailed.purgeDiscovery" },
onSuccess: (res) => {
const removed =
(res.search_videos_deleted ?? 0) + (res.videos_deleted ?? 0) + (res.channels_deleted ?? 0);
+13 -5
View File
@@ -6,6 +6,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-react";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import { api, HttpError, type Me } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { LS, useAccountPersistedState } from "../lib/storage";
import Avatar from "./ui/Avatar";
import { notify, type NotifSettings } from "../lib/notifications";
@@ -553,10 +554,11 @@ function PlexWatchSync() {
};
// No onError on these: they're Plex, not YouTube (notifyYouTubeActionError would wrongly say
// "connect YouTube"), and their 409/500 are already surfaced by the global error modal — a caller
// toast would double it. Actual import failures come back as 200 {error}, handled by announce.
// "connect YouTube"). Their 409/500 open the global error dialog and the rest go to the app-wide
// reporter (lib/queryClient). Actual import failures come back as 200 {error}, handled by announce.
const toggle = useMutation({
mutationFn: (next: boolean) => api.plexWatchSetLink(next),
meta: { failTitle: "errors.writeFailed.plexWatchLink" },
onSuccess: (res) => {
qc.setQueryData(qk.plexWatchLink(), res);
announce(res.import);
@@ -564,6 +566,7 @@ function PlexWatchSync() {
});
const reimport = useMutation({
mutationFn: () => api.plexWatchImport(),
meta: { failTitle: "errors.writeFailed.plexWatchImport" },
onSuccess: (res) => {
qc.setQueryData(qk.plexWatchLink(), res);
announce(res.import);
@@ -637,10 +640,13 @@ export function DownloadAuth() {
notify({ level: "success", message: t("settings.downloadAuth.saved") });
},
// Validation errors (empty / not-a-cookies.txt) surface via the global error modal, which
// localizes the structured { code: "cookies" } detail (see lib/api core localizeDetail).
// localizes the structured { code: "cookies" } detail (see lib/api core localizeDetail); the
// statuses it does not cover go to the app-wide reporter.
meta: { failTitle: "settings.downloadAuth.failed" },
});
const remove = useMutation({
mutationFn: (host: string) => api.deleteDownloadCredential(host),
meta: { failTitle: "errors.writeFailed.downloadCookiesRemove" },
onSuccess: (res) => {
qc.setQueryData(qk.downloadCredentials(), res);
notify({ level: "success", message: t("settings.downloadAuth.removed") });
@@ -781,8 +787,10 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
await api.deleteAccount();
// Session cleared server-side → /api/me 401s → Welcome. The flag shows the confirmation banner.
window.location.href = "/?deleted=1";
} catch {
/* the global error dialog surfaces the reason (e.g. last admin) */
} catch (e) {
// A refusal with a reason (the last admin, a 409) is already in the global error dialog, which
// the reporter skips; anything else used to end the confirmed delete with nothing said.
notifyActionError(e, t("errors.writeFailed.deleteAccount"));
}
};
return (
+4
View File
@@ -106,15 +106,18 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
const toggle = useMutation({
mutationFn: () => api.updateDownloadLink(link.id, { allow_download: !link.allow_download }),
onSuccess: onChanged,
meta: { failTitle: "errors.writeFailed.shareLinkDownload" },
});
const revoke = useMutation({
mutationFn: () => api.revokeDownloadLink(link.id),
onSuccess: onChanged,
meta: { failTitle: "errors.writeFailed.shareLinkRevoke" },
});
// Set / change / clear the link password. Rotating (or clearing) it bumps the link's
// password_version server-side, which invalidates any watch grant issued under the old password.
const savePass = useMutation({
mutationFn: (pw: string) => api.updateDownloadLink(link.id, { password: pw }),
meta: { failTitle: "errors.writeFailed.shareLinkPassword" },
onSuccess: () => {
setEditPass(false);
setNewPass("");
@@ -282,6 +285,7 @@ function LinkShare({ job }: { job: DownloadJob }) {
setExpiryDays(null);
setPassword("");
},
meta: { failTitle: "downloads.share.linkFailed" },
});
// Open with a link already generated: sharing is almost always the goal, so mint a default
+5
View File
@@ -210,6 +210,11 @@ function SystemStats() {
const pauseResume = useMutation({
mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.myStatus() }),
onError: (e, paused) =>
notifyActionError(
e,
t(paused ? "errors.writeFailed.syncResume" : "errors.writeFailed.syncPause"),
),
});
const data = q.data;
const maxDaily = Math.max(1, ...(data?.daily ?? []).map((d) => d.total));
+6
View File
@@ -4,6 +4,7 @@ import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { CheckCircle2, Clock, Database, History, Loader2, Pause, Play } from "lucide-react";
import { api, type MyStatus } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { formatViews } from "../lib/format";
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
@@ -53,6 +54,11 @@ export default function SyncStatus({
const toggle = useMutation({
mutationFn: () => (data?.paused ? api.resumeSync() : api.pauseSync()),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.myStatus() }),
onError: (e) =>
notifyActionError(
e,
t(data?.paused ? "errors.writeFailed.syncResume" : "errors.writeFailed.syncPause"),
),
});
if (!data) return null;
+1
View File
@@ -129,6 +129,7 @@ export default function TagManager({
};
const create = useMutation({
mutationFn: (name: string) => api.createTag({ name, category: "other" }),
meta: { failTitle: "channels.tags.createFailed" },
onSuccess: () => {
setNewName("");
invalidate();
@@ -19,7 +19,7 @@ vi.mock("react-i18next", async (importOriginal) => {
});
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
const { notify } = vi.hoisted(() => ({
notify: vi.fn((_n: { level: string; message: string }) => 0),
notify: vi.fn((_n: { level: string; title?: string; message: string }) => 0),
}));
vi.mock("../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/notifications")>()),
@@ -28,6 +28,7 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
import PlexCollectionEditor from "./PlexCollectionEditor";
import UploadDialog from "./UploadDialog";
import DownloadDialog from "./DownloadDialog";
import { api, HttpError } from "../lib/api";
beforeAll(() => {
@@ -48,7 +49,9 @@ afterEach(() => {
vi.restoreAllMocks();
});
const errors = () => notify.mock.calls.filter(([n]) => n.level === "error").map(([n]) => n.message);
// What failed, as the toast names it: the title (E11 S2b — the title says WHAT, the line under it WHY).
const errors = () =>
notify.mock.calls.filter(([n]) => n.level === "error").map(([n]) => n.title ?? n.message);
const client = () => new QueryClient({ defaultOptions: { queries: { retry: false } } });
describe("the Plex collection editor", () => {
@@ -144,3 +147,20 @@ describe("the upload dialog", () => {
expect(errors()).toEqual([]);
});
});
describe("the download dialog", () => {
it("names a queueing the server refused with no dialog of its own", async () => {
// Its catch said "the global error dialog already surfaced the reason" — true for a quota 422,
// not for a 403/404/429 or a lost connection, where the dialog just stayed open (E11 S2b).
vi.spyOn(api, "downloadProfiles").mockResolvedValue([{ id: 1, name: "Best" }] as never);
vi.spyOn(api, "enqueueDownload").mockRejectedValue(new HttpError(403, "nope"));
render(
<QueryClientProvider client={client()}>
<DownloadDialog source="https://example.com/v" onClose={() => {}} />
</QueryClientProvider>,
);
await screen.findByText("Best");
fireEvent.click(screen.getByText("downloads.dialog.submit"));
await waitFor(() => expect(errors()).toEqual(["errors.writeFailed.enqueueDownload"]));
});
});
@@ -87,11 +87,13 @@ beforeEach(() => {
});
afterEach(cleanup);
// What each error toast names: its title when it has one (a failure reported with its reason under
// it — E11 S2b), else the specific sentence (the no-mail case).
const errorMessages = () =>
notifyMock.mock.calls
.map(([a]) => a as { level?: string; message?: string })
.map(([a]) => a as { level?: string; title?: string; message?: string })
.filter((a) => a.level === "error")
.map((a) => a.message);
.map((a) => a.title ?? a.message);
describe("the account panel reports a refused recovery action", () => {
it("says the link could not be minted instead of leaving the button inert", async () => {
@@ -62,10 +62,12 @@ function QuotaModal({
unlimited: state!.unlimited,
}),
onSuccess: done,
meta: { failTitle: "errors.writeFailed.downloadQuota" },
});
const reset = useMutation({
mutationFn: () => api.adminResetDownloadQuota(userId),
onSuccess: done,
meta: { failTitle: "errors.writeFailed.downloadQuotaReset" },
});
if (!state) return null;
const upd = (p: Partial<typeof state>) => setForm({ ...state, ...p });
@@ -41,6 +41,7 @@ export function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: ()
qc.invalidateQueries({ queryKey: qk.downloads() });
onClose();
},
meta: { failTitle: "errors.writeFailed.downloadDetails" },
});
return (
@@ -61,13 +61,16 @@ describe("usePlaylistActions — deleting a list", () => {
});
it("does say one it was left to handle — in the reader's language", async () => {
// A plain-string `detail` is whatever English the route hand-wrote; the caller's own translated
// fallback is the one a Hungarian reader can read (deep-review round 5).
// A plain-string `detail` is whatever English the route hand-wrote, so it is never shown: the
// caller's translated title says what failed, and the status says why (deep-review round 5, S2b).
vi.spyOn(api, "deletePlaylist").mockRejectedValue(new HttpError(404, "Unknown playlist"));
const { result } = mount();
await act(() => result.current.remove(linked));
expect(h.notify).toHaveBeenCalledWith(
expect.objectContaining({ message: "playlists.deleteYtFailed" }),
expect.objectContaining({ title: "playlists.deleteYtFailed" }),
);
expect(h.notify).not.toHaveBeenCalledWith(
expect.objectContaining({ message: "Unknown playlist" }),
);
});
+52
View File
@@ -20,6 +20,58 @@
"recipient": "There's no user with that email address.",
"job": "This job doesn't exist any more."
},
"reason": {
"unreachable": "The server couldn't be reached, so the change didn't go through. Try again.",
"forbidden": "You don't have permission to do that.",
"rateLimited": "Too many requests at once — wait a moment and try again.",
"unexpected": "Something went wrong in the app. Try again."
},
"writeFailed": {
"hideChannel": "Couldn't change whether the channel is hidden",
"blockChannel": "Couldn't change the channel's block",
"unblockChannel": "Couldn't unblock the channel",
"channelSettings": "Couldn't save the channel's settings",
"channelPriority": "Couldn't save the channel's priority",
"exploreChannel": "Couldn't load the channel's videos",
"downloadQuota": "Couldn't save the download quota",
"downloadQuotaReset": "Couldn't reset the download quota",
"job": {
"pause": "Couldn't pause the download",
"resume": "Couldn't resume the download",
"cancel": "Couldn't cancel the download",
"delete": "Couldn't delete the download",
"redownload": "Couldn't start the download again"
},
"removeShared": "Couldn't remove the shared item",
"downloadDetails": "Couldn't save the download's details",
"enqueueDownload": "Couldn't queue the download",
"downloadProfileSave": "Couldn't save the download profile",
"downloadProfileDelete": "Couldn't delete the download profile",
"downloadCookiesRemove": "Couldn't remove those cookies",
"notifRead": "Couldn't mark the notification as read",
"notifReadAll": "Couldn't mark the notifications as read",
"notifDismiss": "Couldn't dismiss the notification",
"notifClear": "Couldn't clear the notifications",
"createPlaylist": "Couldn't create the playlist",
"schedulerRun": "Couldn't start the job",
"schedulerRunAll": "Couldn't start the jobs",
"maintenanceBatch": "Couldn't save the maintenance batch size",
"purgeDiscovery": "Couldn't clear the discovery data",
"plexWatchLink": "Couldn't change the Plex watch-state sync",
"plexWatchImport": "Couldn't import the watch state from Plex",
"shareLinkDownload": "Couldn't change whether the link allows downloading",
"shareLinkRevoke": "Couldn't revoke the link",
"shareLinkPassword": "Couldn't change the link's password",
"syncPause": "Couldn't pause syncing",
"syncResume": "Couldn't resume syncing",
"prefs": "Couldn't save your settings",
"deleteAccount": "Couldn't delete your account",
"videoState": "Couldn't change the video's state",
"videoReset": "Couldn't reset the video",
"watchLaterAdd": "Couldn't add it to Watch later",
"watchLaterRemove": "Couldn't remove it from Watch later",
"clearSearch": "Couldn't clear the search results"
},
"ok": "OK",
"offline": {
"title": "Connection lost",
+52
View File
@@ -20,6 +20,58 @@
"recipient": "Nincs felhasználó ezzel az e-mail címmel.",
"job": "Ez a feladat már nem létezik."
},
"reason": {
"unreachable": "A művelet nem ment végbe: a szerver nem volt elérhető. Próbáld újra.",
"forbidden": "Ehhez nincs jogosultságod.",
"rateLimited": "Túl sok kérés egyszerre — várj egy kicsit, és próbáld újra.",
"unexpected": "Váratlan hiba történt az alkalmazásban. Próbáld újra."
},
"writeFailed": {
"hideChannel": "Nem sikerült módosítani a csatorna elrejtését",
"blockChannel": "Nem sikerült módosítani a csatorna tiltását",
"unblockChannel": "Nem sikerült feloldani a csatorna tiltását",
"channelSettings": "Nem sikerült menteni a csatorna beállításait",
"channelPriority": "Nem sikerült menteni a csatorna prioritását",
"exploreChannel": "Nem sikerült betölteni a csatorna videóit",
"downloadQuota": "Nem sikerült menteni a letöltési kvótát",
"downloadQuotaReset": "Nem sikerült visszaállítani a letöltési kvótát",
"job": {
"pause": "Nem sikerült szüneteltetni a letöltést",
"resume": "Nem sikerült folytatni a letöltést",
"cancel": "Nem sikerült megszakítani a letöltést",
"delete": "Nem sikerült törölni a letöltést",
"redownload": "Nem sikerült újraindítani a letöltést"
},
"removeShared": "Nem sikerült eltávolítani a megosztott elemet",
"downloadDetails": "Nem sikerült menteni a letöltés adatait",
"enqueueDownload": "Nem sikerült sorba állítani a letöltést",
"downloadProfileSave": "Nem sikerült menteni a letöltési profilt",
"downloadProfileDelete": "Nem sikerült törölni a letöltési profilt",
"downloadCookiesRemove": "Nem sikerült eltávolítani ezeket a sütiket",
"notifRead": "Nem sikerült olvasottnak jelölni az értesítést",
"notifReadAll": "Nem sikerült olvasottnak jelölni az értesítéseket",
"notifDismiss": "Nem sikerült elvetni az értesítést",
"notifClear": "Nem sikerült törölni az értesítéseket",
"createPlaylist": "Nem sikerült létrehozni a lejátszási listát",
"schedulerRun": "Nem sikerült elindítani a feladatot",
"schedulerRunAll": "Nem sikerült elindítani a feladatokat",
"maintenanceBatch": "Nem sikerült menteni a karbantartási köteg méretét",
"purgeDiscovery": "Nem sikerült törölni a felfedezési adatokat",
"plexWatchLink": "Nem sikerült módosítani a Plex megtekintésiállapot-szinkront",
"plexWatchImport": "Nem sikerült importálni a megtekintési állapotot a Plexből",
"shareLinkDownload": "Nem sikerült módosítani, hogy a link engedi-e a letöltést",
"shareLinkRevoke": "Nem sikerült visszavonni a linket",
"shareLinkPassword": "Nem sikerült módosítani a link jelszavát",
"syncPause": "Nem sikerült szüneteltetni a szinkronizálást",
"syncResume": "Nem sikerült folytatni a szinkronizálást",
"prefs": "Nem sikerült menteni a beállításaidat",
"deleteAccount": "Nem sikerült törölni a fiókodat",
"videoState": "Nem sikerült módosítani a videó állapotát",
"videoReset": "Nem sikerült alaphelyzetbe állítani a videót",
"watchLaterAdd": "Nem sikerült hozzáadni a Megnézem később listához",
"watchLaterRemove": "Nem sikerült eltávolítani a Megnézem később listából",
"clearSearch": "Nem sikerült törölni a keresési találatokat"
},
"ok": "OK",
"offline": {
"title": "Megszakadt a kapcsolat",
+71 -4
View File
@@ -8,9 +8,10 @@ vi.mock("./notifications", () => ({ notify: vi.fn(() => 0) }));
vi.mock("./errorDialog", () => ({ reportError: vi.fn() }));
import { HttpError, api } from "./api";
import { alreadyReported } from "./api/core";
import { notify } from "./notifications";
import { reportError } from "./errorDialog";
import { notifyActionError } from "./actionErrors";
import { failureReason, notifyActionError, notifyKnownFailure } from "./actionErrors";
import i18n from "../i18n";
const notifyMock = vi.mocked(notify);
@@ -33,7 +34,7 @@ describe("a Plex collection name that is already taken", () => {
notifyMock.mockClear();
notifyActionError(err, "EDIT_FAILED");
expect(notifyMock.mock.calls).toEqual([
[{ level: "error", message: i18n.t("plex.collection.nameTaken") }],
[{ level: "error", title: "EDIT_FAILED", message: i18n.t("plex.collection.nameTaken") }],
]);
expect(reportError).not.toHaveBeenCalled();
});
@@ -60,12 +61,30 @@ describe("notifyActionError", () => {
expect(notifyMock).toHaveBeenCalled();
});
it("reports one failure once, however many callers try", () => {
// What keeps the app-wide mutation reporter quiet for a mutation whose own onError already spoke.
const err = new HttpError(404, "gone");
notifyActionError(err, "FIRST");
notifyActionError(err, "SECOND");
expect(notifyMock).toHaveBeenCalledTimes(1);
expect(alreadyReported(err)).toBe(true);
});
it("says nothing about an upload the user cancelled", () => {
notifyActionError(new HttpError(0, "aborted"), "FAILED");
expect(notifyMock).not.toHaveBeenCalled();
});
it("says a bare-English server reason in the reader's language, not the server's", () => {
// `reportFailure` leaves 403/404/429 to the caller, so for exactly those statuses whatever
// English a route hand-wrote went straight into the toast — "No user with that email." in an
// otherwise Hungarian UI, while the caller's translated fallback sat unused (round 5).
notifyActionError(new HttpError(404, "No user with that email."), "TRANSLATED");
expect(notifyMock).toHaveBeenCalledWith({ level: "error", message: "TRANSLATED" });
expect(notifyMock).toHaveBeenCalledWith({
level: "error",
title: "TRANSLATED",
message: i18n.t("errors.notFound.generic"),
});
});
it("tells a demo user that the demo account is the reason", async () => {
@@ -84,13 +103,14 @@ describe("notifyActionError", () => {
notifyActionError(err, "GENERIC FALLBACK");
expect(notifyMock).toHaveBeenCalledWith({
level: "error",
title: "GENERIC FALLBACK",
message: i18n.t("errors.demo"),
});
});
it("still prefers a STRUCTURED reason, which the request layer has already localized", () => {
// `req` runs `{code, reason}` through localizeDetail, so `detail` is in the reader's language
// AND more specific than the caller's generic fallback — quota numbers, which row is gone.
// AND more specific than anything a status can say — quota numbers, which row is gone.
const err = new HttpError(404, "Ez a csatorna már nincs a könyvtárban.", {
code: "not_found",
reason: "channel",
@@ -98,7 +118,54 @@ describe("notifyActionError", () => {
notifyActionError(err, "TRANSLATED FALLBACK");
expect(notifyMock).toHaveBeenCalledWith({
level: "error",
title: "TRANSLATED FALLBACK",
message: "Ez a csatorna már nincs a könyvtárban.",
});
});
});
describe("failureReason — why a write failed, when the server did not say", () => {
it.each([
[403, "errors.reason.forbidden"],
[404, "errors.notFound.generic"],
[429, "errors.reason.rateLimited"],
[502, "errors.reason.unreachable"],
[503, "errors.reason.unreachable"],
[504, "errors.reason.unreachable"],
[0, "errors.reason.unreachable"], // uploadReq's network failure
[500, "errors.generic"],
])("a %s reads %s", (status, key) => {
expect(failureReason(new HttpError(status, "hand-written English"))).toBe(i18n.t(key));
});
it("a request that never reached the server reads as unreachable", () => {
expect(failureReason(new TypeError("Failed to fetch"))).toBe(
i18n.t("errors.reason.unreachable"),
);
});
it("anything else is the app's own fault, not the server's", () => {
expect(failureReason(new Error("key import failed"))).toBe(i18n.t("errors.reason.unexpected"));
});
it("every reason exists in both languages", () => {
for (const lng of ["en", "hu"])
for (const key of [
"errors.reason.forbidden",
"errors.reason.rateLimited",
"errors.reason.unreachable",
"errors.reason.unexpected",
])
expect(i18n.exists(key, { lng }), `${lng}:${key}`).toBe(true);
});
});
describe("notifyKnownFailure", () => {
it("shows the caller's specific sentence and marks the failure reported", () => {
const err = new HttpError(409, "x", { code: "resend", reason: "email_off" });
notifyKnownFailure(err, "EMAIL IS OFF");
expect(notifyMock).toHaveBeenCalledWith({ level: "error", message: "EMAIL IS OFF" });
notifyActionError(err, "GENERIC");
expect(notifyMock).toHaveBeenCalledTimes(1);
});
});
+51 -17
View File
@@ -1,28 +1,62 @@
// From the core, not the `./api` barrel: these two are pure (no request), and a test that mocks the
// From the core, not the `./api` barrel: these are pure (no request), and a test that mocks the
// barrel wholesale to stub its requests must not take the error path's own plumbing with it.
import { alreadyReported, HttpError } from "./api/core";
import { alreadyReported, HttpError, isAborted, markReported } from "./api/core";
import { notify } from "./notifications";
import i18n from "../i18n";
const GATEWAY = new Set([502, 503, 504]);
/**
* Surface a failed write action as an error toast instead of the click silently doing nothing.
* Prefers the server's own reason over the caller's already-translated generic fallback — but only
* when the server sent a STRUCTURED detail (`{code, reason}`), which `req` has already run through
* `localizeDetail`. A plain-string `detail` is whatever English the route hand-wrote, and
* `reportFailure` deliberately leaves 403/404/429 to the caller, so exactly those statuses used to
* put the route's English sentence into an otherwise Hungarian UI — "No user with that email.",
* "Not subscribed to this channel" — where the caller's own translated fallback was right there
* (deep-review round 5). Domain-agnostic: used by the Plex watched-toggles and the saved-view ops,
* and delegated to by `notifyYouTubeActionError` for its non-403 case.
* WHY a write failed, in the reader's language — the line under the toast's title.
*
* A STRUCTURED server detail (`{code, reason}`) wins: `req` has already run it through
* `localizeDetail`, and it is the most specific thing there is to say (quota numbers, which row is
* gone). A plain-string `detail` never does — it is whatever English the route hand-wrote, and
* `reportFailure` leaves exactly 403/404/429 to the caller, so those used to put "No user with that
* email." into a Hungarian UI (deep-review round 5). Otherwise the status says it.
*/
export function failureReason(err: unknown): string {
const t = i18n.t.bind(i18n);
if (err instanceof HttpError) {
if (err.detailData != null && err.detail) return err.detail;
// 0: `uploadReq`'s network failure. 502–504: the gateway could not reach the app. Either way the
// change did not go through, which the connection notice alone does not say.
if (err.status === 0 || GATEWAY.has(err.status)) return t("errors.reason.unreachable");
if (err.status === 403) return t("errors.reason.forbidden");
if (err.status === 404) return t("errors.notFound.generic");
if (err.status === 429) return t("errors.reason.rateLimited");
return t("errors.generic");
}
// `fetch` rejects with a TypeError when the request never reached the server.
if (err instanceof TypeError) return t("errors.reason.unreachable");
return t("errors.reason.unexpected");
}
/**
* Surface a failed write as an error toast instead of the click silently doing nothing: the title
* names WHAT failed (the caller's translated "Couldn't hide the channel"), the line under it says
* WHY (`failureReason`). Domain-agnostic — the one way every action failure is reported, including
* the app-wide mutation reporter in lib/queryClient.
*/
export function notifyActionError(
err: unknown,
fallbackMessage: string,
title: string,
level: "error" | "warning" = "error",
): void {
// Already on screen (`req` opened the error dialog, or the tab is being signed out): a toast on
// top reported one fault twice. Playlists filtered this by status by hand; the watched toggles,
// the saved views and a playlist delete did not.
// Already on screen (`req` opened the error dialog, the tab is being signed out, or a caller's own
// `onError` got there first): a second toast reported one fault twice. A cancel is not a failure.
if (alreadyReported(err) || isAborted(err)) return;
notify({ level, title, message: failureReason(err) });
markReported(err);
}
/**
* A failure the caller has a SPECIFIC sentence for, because it read the error's own code ("this
* instance cannot send email", "someone registered on that address") — shown as it is, and marked,
* so the app-wide mutation reporter does not put a second, generic toast next to it.
*/
export function notifyKnownFailure(err: unknown, message: string): void {
if (alreadyReported(err)) return;
const localized = err instanceof HttpError && err.detailData != null ? err.detail : null;
notify({ level, message: localized || fallbackMessage });
notify({ level: "error", message });
markReported(err);
}
+8
View File
@@ -23,10 +23,18 @@ export const uiLang = (): string => i18n.language.split("-")[0] || "en";
// removes itself on the next answer — which every caller's own resync refetch fetches at once. Marked
// as reported, a delete that hit a keepalive reset (a 502) flashed the notice for a moment and was
// then reported nowhere at all, the list still there (deep-review round 4).
//
// A caller that reports a failure itself marks it too (`notifyActionError` does), which is what lets
// the ONE app-wide mutation reporter (lib/queryClient) stay quiet for a mutation whose own `onError`
// already said something, and speak for every one that did not.
const reported = new WeakSet<object>();
export function alreadyReported(err: unknown): boolean {
return typeof err === "object" && err !== null && reported.has(err);
}
/** Record that this failure is on screen now, so nothing reports it a second time. */
export function markReported(err: unknown): void {
if (typeof err === "object" && err !== null) reported.add(err);
}
/** What an upload the USER cancelled rejects with. A cancel is not a failure to report, and the
* only other rejection carrying status 0 is a genuine network drop, which IS worth reporting — so
@@ -0,0 +1,83 @@
// @vitest-environment jsdom
//
// The two shared channel write flows report their own failures, with the real error (E11 S2b).
// Before: the channel detail page's tag toggle and both pages' priority stepper only refetched, so a
// refused write just snapped back; and the manager reported a failed tag toggle by handing the
// CHANNEL ID to the reporter as though it were the error.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ReactNode } from "react";
import { act, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("./notifications", () => ({ notify: vi.fn(() => 0) }));
import { api, HttpError } from "./api";
import { notify } from "./notifications";
import { useChannelTagActions } from "./useChannelTagActions";
import { useChannelPriority } from "./useChannelPriority";
import i18n from "../i18n";
const notifyMock = vi.mocked(notify);
beforeEach(() => notifyMock.mockClear());
afterEach(() => vi.restoreAllMocks());
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={new QueryClient()}>{children}</QueryClientProvider>
);
describe("useChannelTagActions", () => {
it("names a refused tag toggle with its real reason, then lets the caller revert", async () => {
vi.spyOn(api, "attachChannelTag").mockRejectedValue(new HttpError(403, "nope"));
const onError = vi.fn();
const { result } = renderHook(
() => useChannelTagActions({ optimisticToggle: () => {}, onError }),
{ wrapper },
);
act(() => result.current.toggleTag("UC1", 7, false));
await waitFor(() => expect(onError).toHaveBeenCalledWith("UC1"));
expect(notifyMock).toHaveBeenCalledWith({
level: "error",
title: i18n.t("channels.tags.updateFailed"),
message: i18n.t("errors.reason.forbidden"),
});
});
it("names a refused create-and-attach", async () => {
vi.spyOn(api, "createTag").mockRejectedValue(new HttpError(429, "slow"));
const { result } = renderHook(
() => useChannelTagActions({ optimisticToggle: () => {}, onError: () => {} }),
{ wrapper },
);
act(() => result.current.createAndAttachTag("UC1", "Music"));
await waitFor(() =>
expect(notifyMock).toHaveBeenCalledWith({
level: "error",
title: i18n.t("channels.tags.createFailed"),
message: i18n.t("errors.reason.rateLimited"),
}),
);
});
});
describe("useChannelPriority", () => {
it("names a refused priority save, then lets the caller revert", async () => {
vi.spyOn(api, "updateChannel").mockRejectedValue(new TypeError("Failed to fetch"));
const onError = vi.fn();
let priority = 1;
const { result } = renderHook(() =>
useChannelPriority({
read: () => priority,
optimistic: (_id, next) => void (priority = next),
onError,
delay: 0,
}),
);
act(() => result.current.bumpPriority("UC1", 1));
await waitFor(() => expect(onError).toHaveBeenCalledWith("UC1"));
expect(notifyMock).toHaveBeenCalledWith({
level: "error",
title: i18n.t("errors.writeFailed.channelPriority"),
message: i18n.t("errors.reason.unreachable"),
});
});
});
+14 -6
View File
@@ -139,13 +139,21 @@ describe("closeOverlaysThenNavigate", () => {
expect(closed).toEqual(["palette", "player"]);
expect(navigate).not.toHaveBeenCalled();
await settle(); // the reconcile reclaims both entries in one go
expect(back).toHaveBeenCalledTimes(2);
// Not inside the popstate dispatch that settled it — a later listener would still apply the
// OLD entry's page over the push.
expect(navigate).not.toHaveBeenCalled();
await tick();
// OLD entry's page over the push. Asserted FROM such a later listener (registered after the
// helper's own), not after `settle()`: that async act can flush the unmounts on a macrotask,
// and the navigate task sometimes ran first — a 1-in-5 false failure (E11 S2b gate).
const navigatedByPopstate: number[] = [];
const laterListener = () => navigatedByPopstate.push(navigate.mock.calls.length);
window.addEventListener("popstate", laterListener);
try {
await settle(); // the reconcile reclaims both entries in one go
expect(back).toHaveBeenCalledTimes(2);
await tick();
} finally {
window.removeEventListener("popstate", laterListener);
}
expect(navigatedByPopstate).toEqual([0, 0]);
expect(navigate).toHaveBeenCalledTimes(1);
await tick();
expect(back).toHaveBeenCalledTimes(2); // nothing reclaims over the entry navigate pushed
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
// Every `useMutation` NAMES what failed (E11 S2b, owner decision: the toast's title says what, the
// line under it why). The app-wide reporter (lib/queryClient) reports a mutation that names nothing
// too — so nothing is silent any more — but under the generic "Couldn't complete that", which is the
// very toast that says nothing about what it is about when it lands a few seconds after the click.
//
// A mutation passes when it carries one of:
// - `meta: { failTitle }` the reporter's title for it,
// - `meta: { errorShownInline }` the page itself renders the failure,
// - an `onError` which reports (or rolls back — the reporter then speaks for it,
// under the title from `meta` if there is one).
//
// The assertion pins the SET of unnamed mutations, never a count, and checks the glob matched at
// all: a guard whose glob stops matching is green over nothing (E10 review round 4, round 7).
const NAMES = /\bfailTitle\b|\berrorShownInline\b|\bonError\b/;
/** The source of each `useMutation(...)` call, found by paren balance with comments stripped —
* a comment with a stray "(" would otherwise run one block into the next one's `failTitle`. */
export function mutationCalls(source: string): string[] {
const code = source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:"'`])\/\/.*$/gm, "$1");
const calls: string[] = [];
let from = 0;
for (;;) {
const at = code.indexOf("useMutation(", from);
if (at < 0) return calls;
let depth = 0;
let i = at + "useMutation".length;
for (; i < code.length; i++) {
if (code[i] === "(") depth++;
else if (code[i] === ")" && --depth === 0) break;
}
calls.push(code.slice(at, i + 1));
from = i + 1;
}
}
function unnamed(): { hits: string[]; mutations: number } {
const files = import.meta.glob("../**/*.{ts,tsx}", {
query: "?raw",
import: "default",
eager: true,
});
const hits: string[] = [];
let mutations = 0;
for (const [rel, source] of Object.entries(files)) {
const path = rel.startsWith("./") ? `lib/${rel.slice(2)}` : rel.replace(/^\.\.\//, "");
if (/\.test\.tsx?$|\.d\.ts$/.test(path)) continue;
mutationCalls(String(source)).forEach((call, n) => {
mutations++;
if (!NAMES.test(call)) hits.push(`${path}#${n + 1}`);
});
}
return { hits: hits.sort(), mutations };
}
describe("the failed-write contract", () => {
it("has no mutation that leaves its failure unnamed", () => {
const { hits, mutations } = unnamed();
expect(mutations).toBeGreaterThan(50);
expect(hits).toEqual([]);
});
it("reads a mutation call to its end, whatever its body holds", () => {
const src = `
const a = useMutation({
mutationFn: () => api.x(fn(1), (2)),
// a stray "(" in a comment must not swallow the next call
});
const b = useMutation({ mutationFn: api.y, meta: { failTitle: "k" } });
`;
const calls = mutationCalls(src);
expect(calls).toHaveLength(2);
expect(NAMES.test(calls[0] ?? "")).toBe(false);
expect(NAMES.test(calls[1] ?? "")).toBe(true);
});
});
+104
View File
@@ -0,0 +1,104 @@
// @vitest-environment jsdom
//
// The ONE app-wide reporter for a failed mutation (E11 S2b). `req` shows its blocking dialog for
// 400/409/422/5xx only, so before this every mutation without a reporting `onError` failed in
// silence on a 403/404/429 or a lost connection — 34 of them.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("./notifications", () => ({ notify: vi.fn(() => 0) }));
vi.mock("./errorDialog", () => ({ reportError: vi.fn() }));
import { MutationObserver, type MutationObserverOptions } from "@tanstack/react-query";
import { createQueryClient } from "./queryClient";
import { HttpError, req } from "./api/core";
import { notify } from "./notifications";
import { reportError } from "./errorDialog";
import { notifyActionError } from "./actionErrors";
import i18n from "../i18n";
const notifyMock = vi.mocked(notify);
beforeEach(() => {
notifyMock.mockClear();
vi.mocked(reportError).mockClear();
});
afterEach(() => vi.restoreAllMocks());
async function fail(options: MutationObserverOptions<unknown, unknown, void>): Promise<void> {
const observer = new MutationObserver(createQueryClient(), options);
await observer.mutate().catch(() => {});
// The reporter waits a task for the mutation's own onError; give it that task.
await new Promise((r) => setTimeout(r, 5));
}
const TITLE = "errors.writeFailed.hideChannel";
describe("a failed mutation", () => {
it("is named, with the reason under it, when the mutation did not report it itself", async () => {
await fail({
mutationFn: () => Promise.reject(new HttpError(404, "Not subscribed")),
meta: { failTitle: TITLE },
});
expect(notifyMock.mock.calls).toEqual([
[{ level: "error", title: i18n.t(TITLE), message: i18n.t("errors.notFound.generic") }],
]);
});
it("is still reported under the generic title when the mutation names nothing", async () => {
await fail({ mutationFn: () => Promise.reject(new HttpError(429, "slow down")) });
expect(notifyMock).toHaveBeenCalledWith({
level: "error",
title: i18n.t("errors.title"),
message: i18n.t("errors.reason.rateLimited"),
});
});
it("says a lost connection is why the change did not go through", async () => {
await fail({
mutationFn: () => Promise.reject(new TypeError("Failed to fetch")),
meta: { failTitle: TITLE },
});
expect(notifyMock).toHaveBeenCalledWith(
expect.objectContaining({ message: i18n.t("errors.reason.unreachable") }),
);
});
it("gets ONE toast when the mutation's own onError reported it — react-query runs that one second", async () => {
await fail({
mutationFn: () => Promise.reject(new HttpError(404, "gone")),
meta: { failTitle: TITLE },
onError: (e) => notifyActionError(e, "OWN TITLE"),
});
expect(notifyMock.mock.calls.map(([n]) => n.title)).toEqual(["OWN TITLE"]);
});
it("is still reported when the mutation's onError only rolls back", async () => {
const rollback = vi.fn();
await fail({
mutationFn: () => Promise.reject(new HttpError(403, "nope")),
meta: { failTitle: TITLE },
onError: rollback,
});
expect(rollback).toHaveBeenCalled();
expect(notifyMock.mock.calls.map(([n]) => n.title)).toEqual([i18n.t(TITLE)]);
});
it("gets no toast when the page shows the failure itself", async () => {
await fail({
mutationFn: () => Promise.reject(new HttpError(403, "nope")),
meta: { errorShownInline: true },
});
expect(notifyMock).not.toHaveBeenCalled();
});
it("gets no toast on top of the error dialog the request layer already opened", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ detail: "conflict" }), {
status: 409,
headers: { "content-type": "application/json" },
}),
);
await fail({ mutationFn: () => req("/api/x", { method: "POST" }), meta: { failTitle: TITLE } });
expect(reportError).toHaveBeenCalled();
expect(notifyMock).not.toHaveBeenCalled();
});
});
+42
View File
@@ -0,0 +1,42 @@
import { MutationCache, QueryClient } from "@tanstack/react-query";
import i18n from "../i18n";
import { notifyActionError } from "./actionErrors";
declare module "@tanstack/react-query" {
interface Register {
mutationMeta: {
/** i18n key of the toast title that names what failed ("Couldn't hide the channel"). Without
* one the failure is still reported, under the generic "Couldn't complete that". */
failTitle?: string;
/** The component renders this mutation's failure on the page itself — no toast. */
errorShownInline?: boolean;
};
}
}
type FailMeta = { failTitle?: string; errorShownInline?: boolean } | undefined;
/**
* The ONE reporter for a failed mutation. `req` shows its blocking dialog for 400/409/422/5xx only;
* a 403/404/429 was left to the caller and a 502–504 or network failure got just the connection
* notice — so every `useMutation` without a reporting `onError` failed in silence: a channel hide
* that snapped back, a share link that never revoked, a scheduler run that never started (E11 S2b
* counted 34 such mutations). Reporting centrally means a new mutation cannot be silent.
*
* react-query awaits this BEFORE the mutation's own `onError`, so it waits a task: a site that
* reported the failure itself (through `notifyActionError`, which marks it) has had its turn, and
* `notifyActionError` skips what is already on screen.
*/
function reportMutationFailure(err: unknown, meta: FailMeta): void {
if (meta?.errorShownInline) return;
setTimeout(() => notifyActionError(err, i18n.t(meta?.failTitle ?? "errors.title")), 0);
}
export function createQueryClient(): QueryClient {
return new QueryClient({
mutationCache: new MutationCache({
onError: (err, _vars, _onMutateResult, mutation) => reportMutationFailure(err, mutation.meta),
}),
defaultOptions: { queries: { retry: false, staleTime: 30_000, refetchOnWindowFocus: false } },
});
}
+8 -1
View File
@@ -1,5 +1,7 @@
import { useCallback, useEffect, useRef } from "react";
import { api } from "./api";
import { notifyActionError } from "./actionErrors";
import i18n from "../i18n";
/** The shared feed-priority flow used by both the channel manager and the channel detail page:
* an optimistic bump/set with a debounced server flush (~400ms after the last notch) so a
@@ -34,7 +36,12 @@ export function useChannelPriority(opts: {
api
.updateChannel(channelId, { priority: latest })
.then(() => optsRef.current.onSuccess?.(channelId))
.catch(() => optsRef.current.onError(channelId));
.catch((err: unknown) => {
// Said here, once, for both callers: their `onError` only refetches, so a refused priority
// used to just snap back — often after the stepper had already been let go (E11 S2b).
notifyActionError(err, i18n.t("errors.writeFailed.channelPriority"));
optsRef.current.onError(channelId);
});
}, []);
// Set an absolute priority (used by both the arrows — via bumpPriority — and the inline edit),
+13 -4
View File
@@ -2,12 +2,16 @@ import { useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { api } from "./api";
import { qk } from "./queryKeys";
import { notify } from "./notifications";
import { notifyActionError } from "./actionErrors";
/** The shared channel-tag mutation flow used by both the channel manager and the channel detail
* page: optimistic attach/detach + a create-then-attach shortcut, with the cache-shape-specific
* optimistic update injected (the manager maps over a ManagedChannel[]; the detail patches one
* ChannelDetail). Keeps the two call sites from drifting. */
* ChannelDetail). Keeps the two call sites from drifting.
*
* A failure is REPORTED here, once, with the real error: the manager used to do it in its `onError`
* and hand the channel id to the reporter as though it were the error, and the detail page did not
* do it at all — a tag toggled there just snapped back (E11 S2b). */
export function useChannelTagActions(opts: {
/** Apply the optimistic tag change to whichever cache the caller owns. `attach` true = add. */
optimisticToggle: (channelId: string, tagId: number, attach: boolean) => void;
@@ -24,7 +28,12 @@ export function useChannelTagActions(opts: {
const call = hasTag
? api.detachChannelTag(channelId, tagId)
: api.attachChannelTag(channelId, tagId);
call.then(() => opts.onSuccess?.(channelId)).catch(() => opts.onError(channelId));
call
.then(() => opts.onSuccess?.(channelId))
.catch((err: unknown) => {
notifyActionError(err, t("channels.tags.updateFailed"));
opts.onError(channelId);
});
};
// Create a brand-new personal tag and attach it in one step (so tagging doesn't require the
@@ -36,7 +45,7 @@ export function useChannelTagActions(opts: {
qc.invalidateQueries({ queryKey: qk.tags() });
toggleTag(channelId, tag.id, false);
})
.catch(() => notify({ level: "error", message: t("channels.tags.createFailed") }));
.catch((err: unknown) => notifyActionError(err, t("channels.tags.createFailed")));
};
return { toggleTag, createAndAttachTag };
+6 -1
View File
@@ -14,7 +14,9 @@ const { store, savePrefs } = vi.hoisted(() => ({
savePrefs: vi.fn(() => Promise.resolve({})),
}));
vi.mock("./storage", () => ({
// Partial: i18n (which usePref now imports, to title a refused save) reads its key from `LS`.
vi.mock("./storage", async (importOriginal) => ({
...(await importOriginal<typeof import("./storage")>()),
readAccount: (base: string, fallback: unknown) => {
const raw = store.get(base);
return raw == null ? fallback : JSON.parse(raw);
@@ -24,6 +26,9 @@ vi.mock("./storage", () => ({
},
}));
vi.mock("./api", () => ({ api: { savePrefs, me: vi.fn() } }));
// The failure path (usePref.saveFailure.dom.test.ts) is not this file's subject; stubbed so the
// reporter's notifications → storage chain does not meet the storage mock above.
vi.mock("./actionErrors", () => ({ notifyActionError: vi.fn() }));
import { usePref, useSavePref } from "./usePref";
import { qk } from "./queryKeys";
@@ -0,0 +1,51 @@
// @vitest-environment jsdom
//
// A preference save the server refuses is REPORTED (E11 S2b). The local state and the `me` cache
// already show the new value, so a swallowed failure was invisible until the next load quietly put
// the old value back — and the comment above the swallow claimed `req` toasted it, which it does not
// for a 403/404/429 or a lost connection.
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createElement, type ReactNode } from "react";
const { savePrefs, notifyActionError } = vi.hoisted(() => ({
savePrefs: vi.fn(),
notifyActionError: vi.fn(),
}));
vi.mock("./api", () => ({ api: { savePrefs, me: vi.fn() } }));
vi.mock("./actionErrors", () => ({ notifyActionError }));
import { usePref, useSavePref } from "./usePref";
import i18n from "../i18n";
afterEach(() => {
savePrefs.mockReset();
notifyActionError.mockReset();
});
const wrapper = ({ children }: { children: ReactNode }) =>
createElement(QueryClientProvider, { client: new QueryClient() }, children);
describe("a refused preference save", () => {
it("is reported from the usePref setter", async () => {
const refusal = new TypeError("Failed to fetch");
savePrefs.mockRejectedValue(refusal);
const { result } = renderHook(() => usePref<boolean>("hintsOn", true), { wrapper });
act(() => result.current[1](false));
await waitFor(() =>
expect(notifyActionError).toHaveBeenCalledWith(refusal, i18n.t("errors.writeFailed.prefs")),
);
expect(result.current[0]).toBe(false); // the local value stays; the toast says it did not save
});
it("is reported from the raw useSavePref persister", async () => {
const refusal = new Error("403");
savePrefs.mockRejectedValue(refusal);
const { result } = renderHook(() => useSavePref(), { wrapper });
act(() => result.current({ stripSources: { c1: false } }));
await waitFor(() =>
expect(notifyActionError).toHaveBeenCalledWith(refusal, i18n.t("errors.writeFailed.prefs")),
);
});
});
+12 -4
View File
@@ -4,6 +4,14 @@ import { qk } from "./queryKeys";
import { api } from "./api";
import type { Me } from "./api/account";
import { readAccount, writeAccount } from "./storage";
import { notifyActionError } from "./actionErrors";
import i18n from "../i18n";
// The local state + cache already show the new value, so a refused save is otherwise invisible until
// the next load quietly puts the old one back. `req` reports none of 403/404/429 and only the
// connection notice for a lost connection — say it (E11 S2b).
const reportPrefSaveFailure = (err: unknown) =>
notifyActionError(err, i18n.t("errors.writeFailed.prefs"));
// The ONE per-account + server preference hook. A preference (unlike an ephemeral filter) persists to
// the account on the server (PUT /api/me/preferences, which MERGES the partial payload) AND is adopted
@@ -55,7 +63,7 @@ export function useSavePref(): (patch: Record<string, unknown>) => void {
const qc = useQueryClient();
return useCallback(
(patch: Record<string, unknown>) => {
api.savePrefs(patch).catch(() => {});
api.savePrefs(patch).catch(reportPrefSaveFailure);
mergePrefsCache(qc, patch);
},
[qc],
@@ -116,9 +124,9 @@ export function usePref<T>(
// see the new value without a refetch. Merges into preferences, mirroring the server PUT.
const payload = { [serverKey]: next };
mergePrefsCache(qc, payload);
// savePrefs is fire-and-forget: api.req() already surfaces a failure toast, and the local state +
// cache already reflect the change (idempotent PUT retries a transient blip).
const flush = () => api.savePrefs(payload).catch(() => {});
// savePrefs is fire-and-forget (idempotent PUT, so `req` retries a transient blip once); a
// failure that survives that is reported, not swallowed.
const flush = () => api.savePrefs(payload).catch(reportPrefSaveFailure);
if (debounceMs) {
window.clearTimeout(saveTimer.current);
pendingSave.current = flush;
+2
View File
@@ -1,4 +1,5 @@
import { HttpError } from "./api";
import { markReported } from "./api/core";
import { notify } from "./notifications";
import { notifyActionError } from "./actionErrors";
import i18n from "../i18n";
@@ -22,6 +23,7 @@ export function notifyYouTubeActionError(
? { label: i18n.t("channels.notify.connect"), onClick: onConnect }
: undefined,
});
markReported(err);
return;
}
// Any other error: prefer the server's own reason (e.g. "Not enough quota left today") over the
+3 -4
View File
@@ -1,6 +1,7 @@
import React, { lazy, Suspense } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { QueryClientProvider } from "@tanstack/react-query";
import { createQueryClient } from "./lib/queryClient";
import { FloatingTree } from "@floating-ui/react";
import ErrorBoundary from "./components/ErrorBoundary";
import { ConfirmProvider } from "./components/ConfirmProvider";
@@ -28,9 +29,7 @@ const PrivacyPolicy = lazy(() => import("./components/legal/PrivacyPolicy"));
const Terms = lazy(() => import("./components/legal/Terms"));
const WatchPage = lazy(() => import("./components/WatchPage"));
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: 30_000, refetchOnWindowFocus: false } },
});
const queryClient = createQueryClient();
// Baseline theme so the token CSS vars (--bg/--surface/--accent/…) resolve on first paint — including
// the standalone public pages (/watch, /privacy, /terms) that render outside <App> and never call