fix(feed): stop search-box flicker (keepPreviousData + debounced query)
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.
This commit is contained in:
parent
8b19faaed1
commit
74cf31b58d
3 changed files with 35 additions and 8 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 ?? {};
|
||||
|
|
|
|||
13
frontend/src/lib/useDebounced.ts
Normal file
13
frontend/src/lib/useDebounced.ts
Normal file
|
|
@ -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<T>(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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue