`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.
87 lines
3.6 KiB
TypeScript
87 lines
3.6 KiB
TypeScript
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
|
|
* press-and-hold sends ONE request, not one per step, and pending flushes fire on unmount so a
|
|
* last-moment bump isn't lost. The cache-shape-specific bits are injected (the manager maps over a
|
|
* ManagedChannel[]; the detail patches one ChannelDetail), so the two call sites can't drift — the
|
|
* same drift the sibling `useChannelTagActions` hook prevents for tags.
|
|
*
|
|
* Timers are keyed by channel id, so a pending flush survives the caller switching channels: it
|
|
* fires later and reads the right cache via `read(id)`, rather than needing the caller to flush on
|
|
* every id change. `read`/`optimistic`/`onSuccess`/`onError` are read through a ref so the returned
|
|
* callbacks stay stable — the unmount flush then runs only on unmount, never on every render. */
|
|
export function useChannelPriority(opts: {
|
|
/** Current priority for this channel from whichever cache the caller owns (undefined if absent). */
|
|
read: (channelId: string) => number | undefined;
|
|
/** Apply the optimistic priority to the caller's cache. */
|
|
optimistic: (channelId: string, next: number) => void;
|
|
/** Called after the server write lands (e.g. invalidate sibling caches). Optional. */
|
|
onSuccess?: (channelId: string) => void;
|
|
/** Called if the server write fails — revert by refetching the owning cache. */
|
|
onError: (channelId: string) => void;
|
|
/** Debounce window in ms; defaults to 400. */
|
|
delay?: number;
|
|
}) {
|
|
const timers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
|
const optsRef = useRef(opts);
|
|
optsRef.current = opts;
|
|
|
|
const flush = useCallback((channelId: string) => {
|
|
const latest = optsRef.current.read(channelId);
|
|
if (latest == null) return;
|
|
api
|
|
.updateChannel(channelId, { priority: latest })
|
|
.then(() => optsRef.current.onSuccess?.(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),
|
|
// optimistically + a debounced flush.
|
|
const setPriority = useCallback(
|
|
(channelId: string, next: number) => {
|
|
optsRef.current.optimistic(channelId, next);
|
|
const pending = timers.current.get(channelId);
|
|
if (pending) clearTimeout(pending);
|
|
timers.current.set(
|
|
channelId,
|
|
setTimeout(() => {
|
|
timers.current.delete(channelId);
|
|
flush(channelId);
|
|
}, optsRef.current.delay ?? 400),
|
|
);
|
|
},
|
|
[flush],
|
|
);
|
|
|
|
// Read the LATEST value from the cache each step (not a render closure) so a hold-repeat
|
|
// accumulates correctly.
|
|
const bumpPriority = useCallback(
|
|
(channelId: string, delta: number) => {
|
|
setPriority(channelId, (optsRef.current.read(channelId) ?? 0) + delta);
|
|
},
|
|
[setPriority],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const pending = timers.current;
|
|
return () => {
|
|
pending.forEach((timer, channelId) => {
|
|
clearTimeout(timer);
|
|
flush(channelId);
|
|
});
|
|
pending.clear();
|
|
};
|
|
}, [flush]);
|
|
|
|
return { setPriority, bumpPriority };
|
|
}
|