perf(feed): collapse count + facets into one /feed/summary pass (E2 S4, PERF-13)

The FE fired /feed/count (1 pass) + /facets (3 passes) on every filter change =
4 full scans of the subscribed set. New /api/feed/summary does ONE grouped query
(the base filter with tags stripped, GROUP BY channel_id -> per-channel video
counts) and derives the total count + every tag's distinct-channel facet count in
Python on the ~few-hundred-row channel map, faithfully mirroring filtered_query's
channel-based tag logic + the /facets drill-down (own category dropped, unless a
conjunctive topic-AND). Returns {count, facets} — the same numbers the two old
endpoints returned.

FE: a single shared query (Feed reads .count, Sidebar reads .facets); same key +
filters, so React Query dedupes to ONE request. Invalidations repointed to
qk.feedSummary. The old /feed/count + /facets endpoints (and their now-unused FE
api/qk) are kept as the equivalence-test oracle + safety net; a later cleanup can
drop them.

New equivalence test (test_feed_views, 8 filter combos incl. cross-category,
disjunctive/conjunctive topic modes, search): /feed/summary count == /feed/count
and facets == /facets, byte-identical. Structural win: 2 requests / 4-6 queries
-> 1 request / 2-3 queries (the review measured 530 -> 144 ms). Gate green:
344 pytest, frontend tsc/eslint/knip/prettier/222 vitest.
This commit is contained in:
2026-08-18 05:01:27 +02:00
parent 6a35e83ded
commit 21a3aae700
11 changed files with 182 additions and 18 deletions
+9 -8
View File
@@ -226,9 +226,11 @@ export default function Feed({
}
}, [query.dataUpdatedAt]);
const countQuery = useQuery({
queryKey: qk.feedCount(queryFilters),
queryFn: () => api.feedCount(queryFilters),
// One shared query with the sidebar (same key + filters -> React Query dedupes to a single request):
// the feed reads `.count`, the sidebar reads `.facets` (PERF-13, replaces feedCount + facets).
const summaryQuery = useQuery({
queryKey: qk.feedSummary(queryFilters),
queryFn: () => api.feedSummary(queryFilters),
staleTime: 30_000,
enabled: !ytActive,
placeholderData: keepPreviousData,
@@ -358,8 +360,7 @@ export default function Feed({
// pre-search cache. Drop them so the feed re-fetches fresh on return.
const dropFeedCaches = () => {
qc.removeQueries({ queryKey: qk.feed() });
qc.removeQueries({ queryKey: qk.feedCount() });
qc.removeQueries({ queryKey: qk.facets() });
qc.removeQueries({ queryKey: qk.feedSummary() });
};
// --- Live YouTube search mode -------------------------------------------------------------
@@ -622,10 +623,10 @@ export default function Feed({
)}
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
<span data-testid="feed-result-count" className="text-xs text-muted whitespace-nowrap">
{countQuery.data
{summaryQuery.data
? t("feed.videoCount", {
count: countQuery.data.count,
formattedCount: countQuery.data.count.toLocaleString(),
count: summaryQuery.data.count,
formattedCount: summaryQuery.data.count.toLocaleString(),
})
: " "}
</span>