perf(feed): stop the card-action + player-close feed refetch storm (E2 S5, PERF-2)

Marking a card watched/hidden/reset and closing the player each fired a full
`invalidateQueries(qk.feed())`, refetching EVERY loaded infinite-query page — 10
pages x a few quick marks = ~50 feed refetches, each a full scan (x PERF-1). The
optimistic `overrides` layer + the matchesView filter already render the correct
state (a hidden/watched card drops out instantly), so the refetch was pure waste.

Switch these paths to `refetchType: "none"`: the queries are marked stale but not
refetched now; the next filter change / remount pulls fresh server data. Applies
to the two Feed card-action paths (setState/clearState) and the PlayerModal
close (feed + playlists — progress is already persisted server-side, only the
on-screen resume bar waits for the next natural refetch).

Left as immediate refetch on purpose: channel/tag/playlist mutations (one-off, on
pages where the main feed is unmounted anyway) and the onboarding / notification-
undo paths (which must reveal the changed content right away). Gate green:
frontend tsc/eslint/knip/prettier/222 vitest (backend unchanged, 344 pytest).
This commit is contained in:
2026-08-18 15:49:49 +02:00
parent 21a3aae700
commit 943143ef45
2 changed files with 13 additions and 5 deletions
+7 -2
View File
@@ -254,7 +254,11 @@ export default function Feed({
api api
.setState(id, status) .setState(id, status)
.then(() => { .then(() => {
qc.invalidateQueries({ queryKey: qk.feed() }); // Mark stale WITHOUT refetching: the optimistic `overrides` layer + the matchesView filter
// already show the correct state (a hidden/watched card drops out immediately), so refetching
// every loaded infinite-query page here was pure waste — 10 pages x rapid marks = a refetch
// storm (PERF-2). The next filter change / remount refetches fresh from the server.
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
if (status === "hidden") { if (status === "hidden") {
const v = loadedRef.current.find((x) => x.id === id); const v = loadedRef.current.find((x) => x.id === id);
notify({ notify({
@@ -311,7 +315,8 @@ export default function Feed({
setOverrides((o) => ({ ...o, [id]: "new" })); setOverrides((o) => ({ ...o, [id]: "new" }));
api api
.clearState(id) .clearState(id)
.then(() => qc.invalidateQueries({ queryKey: qk.feed() })) // Stale-mark only (see setState above): the optimistic override already reset the card.
.then(() => qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" }))
.catch(() => .catch(() =>
setOverrides((o) => { setOverrides((o) => {
const next = { ...o }; const next = { ...o };
+6 -3
View File
@@ -862,10 +862,13 @@ export default function PlayerModal({
cancelled = true; cancelled = true;
window.clearInterval(timer); window.clearInterval(timer);
window.removeEventListener("pagehide", onHide); window.removeEventListener("pagehide", onHide);
// Final flush, then refresh the feed + playlists so resume bars reflect this session. // Final flush, then mark the feed + playlists stale so resume bars reflect this session on the
// next natural refetch — WITHOUT refetching every loaded feed page on each player close (PERF-2:
// opening/closing the player through a watch session otherwise re-runs the whole infinite query
// each time). Progress is already persisted server-side; only the on-screen resume bar waits.
Promise.resolve(persist()).finally(() => { Promise.resolve(persist()).finally(() => {
qc.invalidateQueries({ queryKey: qk.feed() }); qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
qc.invalidateQueries({ queryKey: qk.playlist() }); qc.invalidateQueries({ queryKey: qk.playlist(), refetchType: "none" });
}); });
const p = playerRef.current; const p = playerRef.current;
if (p && typeof p.destroy === "function") { if (p && typeof p.destroy === "function") {