From 93355bae6b08e9c005eddace77ca0d04815b1332 Mon Sep 17 00:00:00 2001 From: dotta Date: Tue, 7 Apr 2026 09:35:05 -0500 Subject: [PATCH] Debounce issues search input --- ui/src/components/IssuesList.test.tsx | 54 +++++++++++++++++++++ ui/src/components/IssuesList.tsx | 67 +++++++++++++++++++++------ 2 files changed, 107 insertions(+), 14 deletions(-) diff --git a/ui/src/components/IssuesList.test.tsx b/ui/src/components/IssuesList.test.tsx index a8665118..89b9e422 100644 --- a/ui/src/components/IssuesList.test.tsx +++ b/ui/src/components/IssuesList.test.tsx @@ -148,11 +148,13 @@ describe("IssuesList", () => { mockIssuesApi.list.mockReset(); mockIssuesApi.listLabels.mockReset(); mockAuthApi.getSession.mockReset(); + mockIssuesApi.list.mockResolvedValue([]); mockIssuesApi.listLabels.mockResolvedValue([]); mockAuthApi.getSession.mockResolvedValue({ user: null, session: null }); }); afterEach(() => { + vi.useRealTimers(); container.remove(); }); @@ -184,4 +186,56 @@ describe("IssuesList", () => { root.unmount(); }); }); + + it("debounces search updates so typing does not notify the page on every keystroke", async () => { + vi.useFakeTimers(); + + const onSearchChange = vi.fn(); + const localIssue = createIssue({ id: "issue-local", identifier: "PAP-1", title: "Local issue" }); + + const { root } = renderWithQueryClient( + undefined} + />, + container, + ); + + const input = container.querySelector('input[aria-label="Search issues"]') as HTMLInputElement | null; + expect(input).not.toBeNull(); + const valueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set; + expect(valueSetter).toBeTypeOf("function"); + + act(() => { + if (!input || !valueSetter) return; + valueSetter.call(input, "a"); + input.dispatchEvent(new Event("input", { bubbles: true })); + valueSetter.call(input, "ab"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + + expect(onSearchChange).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(149); + }); + + expect(onSearchChange).not.toHaveBeenCalled(); + + await act(async () => { + vi.advanceTimersByTime(1); + await Promise.resolve(); + }); + + expect(onSearchChange).toHaveBeenCalledTimes(1); + expect(onSearchChange).toHaveBeenCalledWith("ab"); + + act(() => { + root.unmount(); + }); + }); }); diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 06718d6e..8814259d 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -1,4 +1,4 @@ -import { useDeferredValue, useEffect, useMemo, useState, useCallback, useRef } from "react"; +import { startTransition, useDeferredValue, useEffect, useMemo, useState, useCallback, useRef } from "react"; import { useQuery } from "@tanstack/react-query"; import { pickTextColorForPillBg } from "@/lib/color-contrast"; import { useDialog } from "../context/DialogContext"; @@ -30,6 +30,7 @@ import type { Issue } from "@paperclipai/shared"; const statusOrder = ["in_progress", "todo", "backlog", "in_review", "blocked", "done", "cancelled"]; const priorityOrder = ["critical", "high", "medium", "low"]; +const ISSUE_SEARCH_DEBOUNCE_MS = 150; function statusLabel(status: string): string { return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); @@ -177,6 +178,50 @@ interface IssuesListProps { onUpdateIssue: (id: string, data: Record) => void; } +function IssueSearchInput({ + value, + onDebouncedChange, +}: { + value: string; + onDebouncedChange?: (search: string) => void; +}) { + const [draftValue, setDraftValue] = useState(value); + const lastCommittedValueRef = useRef(value); + + useEffect(() => { + setDraftValue(value); + lastCommittedValueRef.current = value; + }, [value]); + + useEffect(() => { + if (!onDebouncedChange || draftValue === lastCommittedValueRef.current) return; + + const timeoutId = window.setTimeout(() => { + lastCommittedValueRef.current = draftValue; + startTransition(() => { + onDebouncedChange(draftValue); + }); + }, ISSUE_SEARCH_DEBOUNCE_MS); + + return () => window.clearTimeout(timeoutId); + }, [draftValue, onDebouncedChange]); + + return ( +
+ + { + setDraftValue(e.target.value); + }} + placeholder="Search issues..." + className="pl-7 text-xs sm:text-sm" + aria-label="Search issues" + /> +
+ ); +} + export function IssuesList({ issues, isLoading, @@ -394,19 +439,13 @@ export function IssuesList({ New Issue -
- - { - setIssueSearch(e.target.value); - onSearchChange?.(e.target.value); - }} - placeholder="Search issues..." - className="pl-7 text-xs sm:text-sm" - aria-label="Search issues" - /> -
+ { + setIssueSearch(nextSearch); + onSearchChange?.(nextSearch); + }} + />