From 9f9d2544efee0a6df6920928b0c9a44e671f55e2 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 18 Jun 2026 23:27:59 +0200 Subject: [PATCH] fix(web): only announce a state change after the server confirms it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'Marked watched' / 'Hidden' notices (and resolving a stale notice on unwatch/unhide) fired synchronously, before the api.setState call resolved. When the write failed — e.g. the API was unreachable — the optimistic card override was rolled back in .catch, but the notice had already been emitted and lingered, falsely claiming a change that never persisted. Move all three branches into the .then() so the notice is produced only once the server has actually applied the change; on failure the card reverts and nothing is announced (the connection-lost status already explains why). --- frontend/src/components/Feed.tsx | 66 +++++++++++++++++--------------- 1 file changed, 36 insertions(+), 30 deletions(-) diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 860e3e3..423be85 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -144,9 +144,44 @@ export default function Feed({ (id: string, status: string) => { setOverrides((o) => ({ ...o, [id]: status })); // Refetch once the server has the change so other views (e.g. Hidden) are in sync. + // Announce the change only AFTER the server confirms it: a "Marked watched"/"Hidden" + // notice (or resolving a stale one) is a claim of success, so firing it optimistically + // would falsely report a change that the .catch below is about to roll back (e.g. when + // the API is unreachable). api .setState(id, status) - .then(() => qc.invalidateQueries({ queryKey: ["feed"] })) + .then(() => { + qc.invalidateQueries({ queryKey: ["feed"] }); + if (status === "hidden") { + const v = loadedRef.current.find((x) => x.id === id); + notify({ + message: v?.title + ? i18n.t("feed.hiddenNamed", { title: v.title }) + : i18n.t("feed.hidden"), + action: { label: i18n.t("feed.undo"), onClick: () => onState(id, "new") }, + meta: { + kind: "video-hidden", + videoId: id, + title: v?.title ?? "this video", + channelId: v?.channel_id ?? "", + channelName: v?.channel_title ?? "", + }, + }); + } else if (status === "watched") { + const v = loadedRef.current.find((x) => x.id === id); + notify({ + message: v?.title + ? i18n.t("feed.markedWatchedNamed", { title: v.title }) + : i18n.t("feed.markedWatched"), + action: { label: i18n.t("feed.unwatch"), onClick: () => onState(id, "new") }, + meta: { kind: "video-watched", videoId: id, title: v?.title ?? "this video" }, + }); + } else if (status === "new") { + // Unhide / unwatch (from a card, the toast's Undo, or the center): quietly resolve + // any stale hide/watch notice for this video so it doesn't linger with a dead action. + resolveVideo(id); + } + }) .catch(() => // Server rejected the change — drop the optimistic override so the card // reverts to the real (unchanged) state instead of staying phantom-hidden. @@ -156,35 +191,6 @@ export default function Feed({ return next; }) ); - if (status === "hidden") { - const v = loadedRef.current.find((x) => x.id === id); - notify({ - message: v?.title - ? i18n.t("feed.hiddenNamed", { title: v.title }) - : i18n.t("feed.hidden"), - action: { label: i18n.t("feed.undo"), onClick: () => onState(id, "new") }, - meta: { - kind: "video-hidden", - videoId: id, - title: v?.title ?? "this video", - channelId: v?.channel_id ?? "", - channelName: v?.channel_title ?? "", - }, - }); - } else if (status === "watched") { - const v = loadedRef.current.find((x) => x.id === id); - notify({ - message: v?.title - ? i18n.t("feed.markedWatchedNamed", { title: v.title }) - : i18n.t("feed.markedWatched"), - action: { label: i18n.t("feed.unwatch"), onClick: () => onState(id, "new") }, - meta: { kind: "video-watched", videoId: id, title: v?.title ?? "this video" }, - }); - } else if (status === "new") { - // Unhide / unwatch (from a card, the toast's Undo, or the center): quietly resolve any - // stale hide/watch notice for this video so it doesn't linger with a dead action. - resolveVideo(id); - } }, [qc] );