From c74ceb73e3518abb16d3dafdfdaa8fa2dd83ef8c Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 3 Oct 2026 01:01:46 +0200 Subject: [PATCH] fix(feed): the count pill dims while a new search's count loads keepPreviousData held the last filter's count, and a broad word's count takes seconds: the pill showed the old number as the new search's. It now dims and is aria-busy until the new count lands, as AdminLogs does. --- .../components/Feed.countStale.dom.test.tsx | 98 +++++++++++++++++++ frontend/src/components/Feed.tsx | 2 +- .../src/components/ui/ItemCount.dom.test.tsx | 8 ++ frontend/src/components/ui/ItemCount.tsx | 12 ++- 4 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/Feed.countStale.dom.test.tsx diff --git a/frontend/src/components/Feed.countStale.dom.test.tsx b/frontend/src/components/Feed.countStale.dom.test.tsx new file mode 100644 index 00000000..c2ac8e76 --- /dev/null +++ b/frontend/src/components/Feed.countStale.dom.test.tsx @@ -0,0 +1,98 @@ +// @vitest-environment jsdom +// +// The count pill while a new search's count loads. keepPreviousData holds the last filter's count, and a +// broad word's count takes seconds ("a": ~3 s on the prod-sized library, E31): the pill showed the old +// number as if it were the new search's. It now dims and is aria-busy until the new count lands. +import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; +import { render, screen, waitFor, cleanup } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), +})); +vi.mock("./wizardContext", () => ({ useWizard: () => ({ openWizard: vi.fn() }) })); +vi.mock("./VirtualFeed", () => ({ default: () => null })); +vi.mock("./SelectedViewChip", () => ({ default: () => null })); +vi.mock("./ActiveFilterChips", () => ({ default: () => null })); + +import Feed from "./Feed"; +import { api, DEFAULT_TAG_MODES, type FeedFilters } from "../lib/api"; +import { qk } from "../lib/queryKeys"; +import type { ReactElement } from "react"; +import { PageToolbarSlot } from "./ui/pageToolbarSlot"; + +beforeAll(() => { + class Stub { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } + globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; + globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver; +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +const FILTERS: FeedFilters = { + tags: [], + tagModes: DEFAULT_TAG_MODES, + channelIds: [], + q: "", + sort: "newest", + scope: "my", + includeNormal: true, + includeShorts: false, + includeLive: false, + show: "unwatched", +}; + +// The pill lives in the toolbar, which portals into the shell's band: give it one. +const slot = document.body.appendChild(document.createElement("div")); +const feed = (q: string, qc: QueryClient): ReactElement => ( + + + {}} + view="cards" + setView={() => {}} + canRead + ytSearch="" + onYtSearch={() => {}} + onExitYtSearch={() => {}} + /> + + +); + +describe("Feed — the count while a new search's count loads", () => { + it("dims the previous count and marks it busy until the new one lands", async () => { + vi.spyOn(api, "feed").mockResolvedValue({ items: [], next_cursor: null } as never); + let settle: (v: unknown) => void = () => {}; + vi.spyOn(api, "feedSummary").mockImplementation(((f: FeedFilters) => + f.q === "kitchen" + ? Promise.resolve({ count: 1937, facets: {} }) + : new Promise((r) => (settle = r))) as never); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} }); + const pill = () => screen.getByTestId("item-count"); + + const view = render(feed("kitchen", qc)); + await waitFor(() => expect(pill().getAttribute("title")).toBe("1,937")); + expect(pill().getAttribute("aria-busy")).toBeNull(); + + view.rerender(feed("a", qc)); + await waitFor(() => expect(pill().getAttribute("aria-busy")).toBe("true")); + expect(pill().getAttribute("title")).toBe("1,937"); // still the old figure, now marked as such + + settle({ count: 178991, facets: {} }); + await waitFor(() => expect(pill().getAttribute("title")).toBe("178,991")); + expect(pill().getAttribute("aria-busy")).toBeNull(); + }); +}); diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 4a32e5c0..3f87416a 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -710,7 +710,7 @@ export default function Feed({
{/* The item count is the fixed leading element — a fixed-width pill so a changing number never shoves the controls beside it (see ItemCount). */} - + { expect(text()).toBe(""); // the Layers icon carries no text expect(screen.getByTestId("item-count").getAttribute("title")).toBeNull(); }); + + it("dims the previous filter's count and marks it busy while the new one loads", () => { + const { rerender } = render(); + expect(screen.getByTestId("item-count").getAttribute("aria-busy")).toBeNull(); + rerender(); + expect(screen.getByTestId("item-count").getAttribute("aria-busy")).toBe("true"); + expect(screen.getByText("5 items").className).toContain("opacity-60"); + }); }); diff --git a/frontend/src/components/ui/ItemCount.tsx b/frontend/src/components/ui/ItemCount.tsx index 38bc47bf..a7f0ee65 100644 --- a/frontend/src/components/ui/ItemCount.tsx +++ b/frontend/src/components/ui/ItemCount.tsx @@ -1,3 +1,4 @@ +import clsx from "clsx"; import { useTranslation } from "react-i18next"; import { Layers } from "lucide-react"; import { formatNumber } from "../../lib/format"; @@ -12,8 +13,10 @@ const OVERFLOW_AT = 1_000_000; // changing number must never shove the controls beside it, so the box is sized to the widest value // ("999,999 items") and the content is centred within it. A count of a million or more collapses to // "1M+", with the exact figure in the title tooltip. Deliberately generic ("item(s)", dynamic plural) -// so any surface can reuse it for whatever it counts. -export default function ItemCount({ count }: { count?: number }) { +// so any surface can reuse it for whatever it counts. `stale`: the count is the previous filter's, +// kept while the new one loads (keepPreviousData) — dimmed and aria-busy, as AdminLogs dims its lines, +// so a broad search's slow count ("a": ~3 s) never reads as the new result. +export default function ItemCount({ count, stale = false }: { count?: number; stale?: boolean }) { const { t, i18n } = useTranslation(); const lang = i18n.language; // `count` is undefined only on a first, un-cached load — keep the fixed box (nothing shifts), just @@ -29,10 +32,13 @@ export default function ItemCount({ count }: { count?: number }) { ); }