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.
This commit is contained in:
2026-10-03 01:01:46 +02:00
parent e0e28c006c
commit c74ceb73e3
4 changed files with 116 additions and 4 deletions
@@ -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<typeof import("react-i18next")>()),
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 => (
<QueryClientProvider client={qc}>
<PageToolbarSlot.Provider value={slot}>
<Feed
filters={{ ...FILTERS, q }}
setFilters={() => {}}
view="cards"
setView={() => {}}
canRead
ytSearch=""
onYtSearch={() => {}}
onExitYtSearch={() => {}}
/>
</PageToolbarSlot.Provider>
</QueryClientProvider>
);
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();
});
});
+1 -1
View File
@@ -710,7 +710,7 @@ export default function Feed({
<div className="flex items-center gap-1.5 gap-y-2 flex-wrap"> <div className="flex items-center gap-1.5 gap-y-2 flex-wrap">
{/* The item count is the fixed leading element — a fixed-width pill so a changing number {/* The item count is the fixed leading element — a fixed-width pill so a changing number
never shoves the controls beside it (see ItemCount). */} never shoves the controls beside it (see ItemCount). */}
<ItemCount count={summaryQuery.data?.count} /> <ItemCount count={summaryQuery.data?.count} stale={summaryQuery.isPlaceholderData} />
<FilterFoldButton <FilterFoldButton
fold={fold} fold={fold}
label={t("sidebar.filters")} label={t("sidebar.filters")}
@@ -62,4 +62,12 @@ describe("ItemCount", () => {
expect(text()).toBe(""); // the Layers icon carries no text expect(text()).toBe(""); // the Layers icon carries no text
expect(screen.getByTestId("item-count").getAttribute("title")).toBeNull(); 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(<ItemCount count={5} />);
expect(screen.getByTestId("item-count").getAttribute("aria-busy")).toBeNull();
rerender(<ItemCount count={5} stale />);
expect(screen.getByTestId("item-count").getAttribute("aria-busy")).toBe("true");
expect(screen.getByText("5 items").className).toContain("opacity-60");
});
}); });
+9 -3
View File
@@ -1,3 +1,4 @@
import clsx from "clsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Layers } from "lucide-react"; import { Layers } from "lucide-react";
import { formatNumber } from "../../lib/format"; 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 // 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 // ("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) // "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. // so any surface can reuse it for whatever it counts. `stale`: the count is the previous filter's,
export default function ItemCount({ count }: { count?: number }) { // 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 { t, i18n } = useTranslation();
const lang = i18n.language; const lang = i18n.language;
// `count` is undefined only on a first, un-cached load — keep the fixed box (nothing shifts), just // `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 }) {
<span <span
data-testid="item-count" data-testid="item-count"
title={count == null ? undefined : formatNumber(count, lang)} title={count == null ? undefined : formatNumber(count, lang)}
aria-busy={stale || undefined}
className="inline-flex h-7 min-w-[9rem] shrink-0 items-center justify-center gap-1.5 rounded-full bg-fg/8 px-3 text-sm whitespace-nowrap text-muted" className="inline-flex h-7 min-w-[9rem] shrink-0 items-center justify-center gap-1.5 rounded-full bg-fg/8 px-3 text-sm whitespace-nowrap text-muted"
> >
<Layers className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" /> <Layers className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
<span className="tabular-nums">{label}</span> <span className={clsx("tabular-nums transition-opacity", stale && "opacity-60")}>
{label}
</span>
</span> </span>
); );
} }