From 74cf31b58df89692d8f2e17b7cd54af6646a073f Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 29 Jun 2026 02:19:18 +0200 Subject: [PATCH] fix(feed): stop search-box flicker (keepPreviousData + debounced query) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing in the search box changed the feed/count/facets query keys on every keystroke, so each query dropped to its loading state and blanked the content — the whole feed area flickered. Debounce the search term feeding the queries (the input still updates instantly) so they only re-run after a pause, and keep previous results on screen during a refetch via placeholderData: keepPreviousData, so the feed and tag counts update in place without blanking. --- frontend/src/components/Feed.tsx | 19 ++++++++++++++----- frontend/src/components/Sidebar.tsx | 11 ++++++++--- frontend/src/lib/useDebounced.ts | 13 +++++++++++++ 3 files changed, 35 insertions(+), 8 deletions(-) create mode 100644 frontend/src/lib/useDebounced.ts diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index ac289ea..59e1697 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -1,10 +1,11 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query"; +import { keepPreviousData, useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowDown, ArrowUp, ArrowLeft, RefreshCw, Youtube } from "lucide-react"; import { api, HttpError, type FeedFilters, type Video } from "../lib/api"; import i18n from "../i18n"; import { notify, resolveVideo } from "../lib/notifications"; +import { useDebounced } from "../lib/useDebounced"; import VirtualFeed from "./VirtualFeed"; import PlayerModal from "./PlayerModal"; @@ -100,12 +101,19 @@ export default function Feed({ const ytActive = !!ytSearch; + // Debounce only the search term feeding the queries: the input still updates instantly (it + // owns filters.q), but the feed/count keys settle after a pause, so typing doesn't refetch — + // and re-fetched pages keep the previous results on screen, so the feed never blanks/flickers. + const debouncedQ = useDebounced(filters.q, 300); + const queryFilters: FeedFilters = { ...filters, q: debouncedQ }; + const query = useInfiniteQuery({ - queryKey: ["feed", filters], - queryFn: ({ pageParam }) => api.feed(filters, pageParam as string | null, PAGE), + queryKey: ["feed", queryFilters], + queryFn: ({ pageParam }) => api.feed(queryFilters, pageParam as string | null, PAGE), initialPageParam: null as string | null, getNextPageParam: (last) => last.next_cursor ?? undefined, enabled: !ytActive, // don't load the normal feed while showing live YouTube results + placeholderData: keepPreviousData, }); // Live YouTube search results. Each page spends 100 quota units, so we never auto-paginate @@ -135,10 +143,11 @@ export default function Feed({ }, [query.dataUpdatedAt]); const countQuery = useQuery({ - queryKey: ["feed-count", filters], - queryFn: () => api.feedCount(filters), + queryKey: ["feed-count", queryFilters], + queryFn: () => api.feedCount(queryFilters), staleTime: 30_000, enabled: !ytActive, + placeholderData: keepPreviousData, }); const { hasNextPage, isFetchingNextPage, fetchNextPage } = query; diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index ecf64f0..e58643c 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { useQuery } from "@tanstack/react-query"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { Check, ChevronDown, @@ -36,6 +36,7 @@ import { } from "../lib/sidebarLayout"; import { shareUrl } from "../lib/urlState"; import { notify } from "../lib/notifications"; +import { useDebounced } from "../lib/useDebounced"; import { Switch } from "./ui/form"; import TagManager from "./TagManager"; @@ -131,10 +132,14 @@ export default function Sidebar({ // Live per-tag channel counts for the current filter context (scope, channel, date, // search, watch state, other category's tags). Lets us show contextual counts and hide // chips that no longer match anything. Keyed on filters so it refetches as they change. + // Debounce the search term (matching the feed) so typing doesn't refire facets per + // keystroke, and keep the previous counts on screen during a refetch so chips don't flicker. + const facetFilters: FeedFilters = { ...filters, q: useDebounced(filters.q, 300) }; const facetsQuery = useQuery({ - queryKey: ["facets", filters], - queryFn: () => api.facets(filters), + queryKey: ["facets", facetFilters], + queryFn: () => api.facets(facetFilters), staleTime: 30_000, + placeholderData: keepPreviousData, }); const facetsReady = !!facetsQuery.data; const facetCounts = facetsQuery.data?.counts ?? {}; diff --git a/frontend/src/lib/useDebounced.ts b/frontend/src/lib/useDebounced.ts new file mode 100644 index 0000000..3de9c4b --- /dev/null +++ b/frontend/src/lib/useDebounced.ts @@ -0,0 +1,13 @@ +import { useEffect, useState } from "react"; + +// Returns `value` after it has stopped changing for `delay` ms. Lets a text input stay +// responsive (it owns the immediate value) while the query it drives only re-runs once the +// user pauses — so we don't refetch the feed on every keystroke. +export function useDebounced(value: T, delay = 300): T { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const id = window.setTimeout(() => setDebounced(value), delay); + return () => window.clearTimeout(id); + }, [value, delay]); + return debounced; +}