import * as React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fetchBylines } from "../../src/lib/api";
import { BylinesPage } from "../../src/routes/bylines";
import { render } from "../utils/render.tsx";
import { QueryWrapper } from "../utils/test-helpers.tsx";
// The bylines page reads the active locale from the URL and navigates on
// locale switches; neither matters for the search-debounce behaviour, so we
// stub the router hooks to a single-locale, no-op shape.
vi.mock("@tanstack/react-router", async () => {
const actual = await vi.importActual("@tanstack/react-router");
return {
...actual,
useNavigate: () => vi.fn(),
useSearch: () => ({}),
};
});
// fetchManifest is imported from the client module directly.
vi.mock("../../src/lib/api/client.js", async () => {
const actual = await vi.importActual("../../src/lib/api/client.js");
return {
...actual,
fetchManifest: vi.fn().mockResolvedValue({}),
};
});
vi.mock("../../src/lib/api", async () => {
const actual = await vi.importActual("../../src/lib/api");
return {
...actual,
fetchBylines: vi.fn(),
fetchUsers: vi.fn().mockResolvedValue({ items: [] }),
fetchByline: vi.fn().mockResolvedValue(null),
fetchBylineTranslations: vi.fn().mockResolvedValue({ items: [] }),
};
});
const fetchBylinesMock = vi.mocked(fetchBylines);
function searchArgs(): (string | undefined)[] {
return fetchBylinesMock.mock.calls.map((call) => call[0]?.search);
}
describe("BylinesPage search", () => {
beforeEach(() => {
vi.clearAllMocks();
fetchBylinesMock.mockResolvedValue({ items: [], nextCursor: undefined });
});
it("debounces rapid typing into a single refetch and keeps the input mounted", async () => {
vi.useFakeTimers();
try {
const screen = await render(
,
);
// Let the initial bylines query resolve so the full-page loader
// gate (isLoading && !data) clears and the list view renders.
await vi.advanceTimersByTimeAsync(0);
expect(searchArgs()).toEqual([undefined]);
const input = screen.getByPlaceholder("Search bylines");
await expect.element(input).toBeInTheDocument();
// Three keystrokes in quick succession (under the 300ms window).
await input.fill("a");
await input.fill("al");
await input.fill("ali");
// No new fetch yet: the debounce has not elapsed, and the input
// must stay mounted/focused rather than being unmounted by a
// full-page loader takeover on every keystroke.
expect(searchArgs()).toEqual([undefined]);
await expect.element(input).toHaveValue("ali");
// After the debounce window, exactly one additional refetch fires
// for the final value — not one per intermediate keystroke.
await vi.advanceTimersByTimeAsync(300);
await vi.waitFor(() => {
expect(searchArgs()).toEqual([undefined, "ali"]);
});
// The list view (and its search input) is still mounted.
await expect.element(screen.getByPlaceholder("Search bylines")).toBeInTheDocument();
} finally {
vi.useRealTimers();
}
});
it("keeps the previous results mounted while a search refetch is in flight", async () => {
vi.useFakeTimers();
try {
// Initial load returns one byline; the search refetch is held
// in-flight so we can observe what the page renders *during* the
// new query — the moment the original full-page loader takeover
// (#1220) blanks the screen and drops input focus.
let resolveSearch: (value: { items: unknown[]; nextCursor: undefined }) => void = () => {};
const pendingSearch = new Promise<{ items: unknown[]; nextCursor: undefined }>((resolve) => {
resolveSearch = resolve;
});
fetchBylinesMock
.mockResolvedValueOnce({
items: [
{ id: "1", slug: "alice", displayName: "Alice Example", isGuest: false, userId: null },
],
nextCursor: undefined,
} as never)
.mockReturnValueOnce(pendingSearch as never);
const screen = await render(
,
);
await vi.advanceTimersByTimeAsync(0);
await expect.element(screen.getByText("Alice Example")).toBeInTheDocument();
const input = screen.getByPlaceholder("Search bylines");
await input.fill("ali");
// Elapse the debounce so the (still-pending) search refetch fires.
await vi.advanceTimersByTimeAsync(300);
await vi.waitFor(() => {
expect(searchArgs()).toEqual([undefined, "ali"]);
});
// While the refetch is in flight the page must NOT collapse into
// the centered full-page loader: the search input keeps focus and
// the previous results stay visible.
await expect.element(screen.getByPlaceholder("Search bylines")).toBeInTheDocument();
await expect.element(screen.getByText("Alice Example")).toBeInTheDocument();
resolveSearch({ items: [], nextCursor: undefined });
} finally {
vi.useRealTimers();
}
});
});