From 943143ef454c34df570fa2b2fc8ab0fe3d63c27a Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 18 Aug 2026 15:49:49 +0200 Subject: [PATCH] perf(feed): stop the card-action + player-close feed refetch storm (E2 S5, PERF-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- frontend/src/components/Feed.tsx | 9 +++++++-- frontend/src/components/PlayerModal.tsx | 9 ++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 6802759f..360487ed 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -254,7 +254,11 @@ export default function Feed({ api .setState(id, status) .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") { const v = loadedRef.current.find((x) => x.id === id); notify({ @@ -311,7 +315,8 @@ export default function Feed({ setOverrides((o) => ({ ...o, [id]: "new" })); api .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(() => setOverrides((o) => { const next = { ...o }; diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 0b22c319..4360b43f 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -862,10 +862,13 @@ export default function PlayerModal({ cancelled = true; window.clearInterval(timer); 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(() => { - qc.invalidateQueries({ queryKey: qk.feed() }); - qc.invalidateQueries({ queryKey: qk.playlist() }); + qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" }); + qc.invalidateQueries({ queryKey: qk.playlist(), refetchType: "none" }); }); const p = playerRef.current; if (p && typeof p.destroy === "function") {