From d37327e4c4179f1c13d81898395c131f85b33102 Mon Sep 17 00:00:00 2001 From: scottbuscemi Date: Fri, 29 May 2026 15:16:43 -0700 Subject: [PATCH] fix(admin): debounce byline search to stop full-page reload per keystroke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The byline search input fed the query key directly with no debounce, and the full-page loader gate (`if (isLoading)`) replaced the whole page — including the search input — on every keystroke, so the field lost focus and flashed. Debounce the search (300ms) before it drives the query and only show the full-page loader when there is no data yet, matching the users page. Closes #1220. --- .changeset/fix-byline-search-debounce.md | 5 ++ packages/admin/src/routes/bylines.tsx | 21 +++-- packages/admin/tests/routes/bylines.test.tsx | 92 ++++++++++++++++++++ 3 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 .changeset/fix-byline-search-debounce.md create mode 100644 packages/admin/tests/routes/bylines.test.tsx diff --git a/.changeset/fix-byline-search-debounce.md b/.changeset/fix-byline-search-debounce.md new file mode 100644 index 000000000..f7eacc1c9 --- /dev/null +++ b/.changeset/fix-byline-search-debounce.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/admin": patch +--- + +Fixes the byline search box reloading the whole page on every keystroke. The search term is now debounced (300ms) before it feeds the bylines query, and the full-page loader only takes over when there is no data yet (`isLoading && !data`) instead of on every new query key. Typing now stays responsive and keeps the input focused, matching the behaviour of the users page. The load-more snapshot and its filter-match check both use the debounced search value so appended pages are no longer discarded. diff --git a/packages/admin/src/routes/bylines.tsx b/packages/admin/src/routes/bylines.tsx index 8ffa5a0e5..45fedf997 100644 --- a/packages/admin/src/routes/bylines.tsx +++ b/packages/admin/src/routes/bylines.tsx @@ -21,6 +21,7 @@ import { type UserListItem, } from "../lib/api"; import { fetchManifest } from "../lib/api/client.js"; +import { useDebouncedValue } from "../lib/hooks.js"; interface BylineFormState { slug: string; @@ -86,6 +87,10 @@ export function BylinesPage() { const navigate = useNavigate(); const { locale: routeLocale } = useSearch({ from: "/_admin/bylines" }); const [search, setSearch] = React.useState(""); + // Debounce the search before it feeds the query key/fetch so typing stays + // responsive — the input stays bound to raw `search` while only the + // debounced value drives refetches. + const debouncedSearch = useDebouncedValue(search, 300); const [guestFilter, setGuestFilter] = React.useState<"all" | "guest" | "linked">("all"); const [selectedId, setSelectedId] = React.useState(null); const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false); @@ -118,10 +123,10 @@ export function BylinesPage() { }; const { data, isLoading, error } = useQuery({ - queryKey: ["bylines", search, guestFilter, activeLocale ?? null], + queryKey: ["bylines", debouncedSearch, guestFilter, activeLocale ?? null], queryFn: () => fetchBylines({ - search: search || undefined, + search: debouncedSearch || undefined, isGuest: guestFilter === "all" ? undefined : guestFilter === "guest", locale: activeLocale, limit: 50, @@ -158,7 +163,13 @@ export function BylinesPage() { return { result, snapshot }; }, onSuccess: ({ result, snapshot }) => { - if (!loadMoreSnapshotMatches(snapshot, { search, guestFilter, locale: activeLocale })) { + if ( + !loadMoreSnapshotMatches(snapshot, { + search: debouncedSearch, + guestFilter, + locale: activeLocale, + }) + ) { return; } setAllItems((prev) => [...prev, ...result.items]); @@ -275,7 +286,7 @@ export function BylinesPage() { }, }); - if (isLoading) { + if (isLoading && !data) { return (
@@ -370,7 +381,7 @@ export function BylinesPage() { className="w-full mt-2" onClick={() => loadMoreMutation.mutate({ - search, + search: debouncedSearch, guestFilter, locale: activeLocale, cursor: nextCursor, diff --git a/packages/admin/tests/routes/bylines.test.tsx b/packages/admin/tests/routes/bylines.test.tsx new file mode 100644 index 000000000..f7e44c179 --- /dev/null +++ b/packages/admin/tests/routes/bylines.test.tsx @@ -0,0 +1,92 @@ +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); + 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(); + } + }); +});