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();
});
});