From 4dea3027913493d620227f39441a0e4c28d74ff1 Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 28 Mar 2026 10:23:31 -0500 Subject: [PATCH] Speed up issues-page search Keep issue search local to the loaded list, defer heavy result updates, and memoize the rendered list body so typing stays responsive. Co-Authored-By: Paperclip --- ui/src/components/IssuesList.tsx | 578 +++++++++++++++---------------- 1 file changed, 283 insertions(+), 295 deletions(-) diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index a601aec7..952c649a 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -1,4 +1,4 @@ -import { startTransition, useEffect, useMemo, useState, useCallback, useRef } from "react"; +import { 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"; @@ -68,8 +68,6 @@ const quickFilterPresets = [ { label: "Backlog", statuses: ["backlog"] }, { label: "Done", statuses: ["done", "cancelled"] }, ]; -const ISSUE_SEARCH_COMMIT_DELAY_MS = 150; - function getViewState(key: string): IssueViewState { try { const raw = localStorage.getItem(key); @@ -144,6 +142,18 @@ function countActiveFilters(state: IssueViewState): number { return count; } +function matchesIssueSearch(issue: Issue, normalizedSearch: string): boolean { + if (!normalizedSearch) return true; + + return [ + issue.identifier, + issue.title, + issue.description, + ] + .filter((value): value is string => Boolean(value)) + .some((value) => value.toLowerCase().includes(normalizedSearch)); +} + /* ── Component ── */ interface Agent { @@ -175,44 +185,6 @@ interface IssuesListProps { onUpdateIssue: (id: string, data: Record) => void; } -interface IssuesSearchInputProps { - initialValue: string; - onValueCommitted: (value: string) => void; -} - -function IssuesSearchInput({ initialValue, onValueCommitted }: IssuesSearchInputProps) { - const [value, setValue] = useState(initialValue); - const onValueCommittedRef = useRef(onValueCommitted); - - useEffect(() => { - setValue(initialValue); - }, [initialValue]); - - useEffect(() => { - onValueCommittedRef.current = onValueCommitted; - }, [onValueCommitted]); - - useEffect(() => { - const timeoutId = window.setTimeout(() => { - onValueCommittedRef.current(value); - }, ISSUE_SEARCH_COMMIT_DELAY_MS); - return () => window.clearTimeout(timeoutId); - }, [value]); - - return ( -
- - setValue(e.target.value)} - placeholder="Search issues..." - className="pl-7 text-xs sm:text-sm" - aria-label="Search issues" - /> -
- ); -} - export function IssuesList({ issues, isLoading, @@ -249,7 +221,8 @@ export function IssuesList({ const [assigneePickerIssueId, setAssigneePickerIssueId] = useState(null); const [assigneeSearch, setAssigneeSearch] = useState(""); const [issueSearch, setIssueSearch] = useState(initialSearch ?? ""); - const normalizedIssueSearch = issueSearch.trim(); + const deferredIssueSearch = useDeferredValue(issueSearch); + const normalizedIssueSearch = deferredIssueSearch.trim().toLowerCase(); useEffect(() => { setIssueSearch(initialSearch ?? ""); @@ -266,13 +239,6 @@ export function IssuesList({ } }, [scopedKey, initialAssignees]); - const handleIssueSearchCommit = useCallback((nextSearch: string) => { - startTransition(() => { - setIssueSearch(nextSearch); - }); - onSearchChange?.(nextSearch); - }, [onSearchChange]); - const updateView = useCallback((patch: Partial) => { setViewState((prev) => { const next = { ...prev, ...patch }; @@ -280,27 +246,18 @@ export function IssuesList({ return next; }); }, [scopedKey]); - - const { data: searchedIssues = [] } = useQuery({ - queryKey: [ - ...queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId), - searchFilters ?? {}, - ], - queryFn: () => issuesApi.list(selectedCompanyId!, { q: normalizedIssueSearch, projectId, ...searchFilters }), - enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0, - placeholderData: (previousData) => previousData, - }); - const agentName = useCallback((id: string | null) => { if (!id || !agents) return null; return agents.find((a) => a.id === id)?.name ?? null; }, [agents]); const filtered = useMemo(() => { - const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues; + const sourceIssues = normalizedIssueSearch.length > 0 + ? issues.filter((issue) => matchesIssueSearch(issue, normalizedIssueSearch)) + : issues; const filteredByControls = applyFilters(sourceIssues, viewState, currentUserId); return sortIssues(filteredByControls, viewState); - }, [issues, searchedIssues, viewState, normalizedIssueSearch, currentUserId]); + }, [issues, viewState, normalizedIssueSearch, currentUserId]); const { data: labels } = useQuery({ queryKey: queryKeys.issues.labels(selectedCompanyId!), @@ -343,7 +300,7 @@ export function IssuesList({ })); }, [filtered, viewState.groupBy, agents, agentName, currentUserId]); - const newIssueDefaults = (groupKey?: string) => { + const newIssueDefaults = useCallback((groupKey?: string) => { const defaults: Record = {}; if (projectId) defaults.projectId = projectId; if (groupKey) { @@ -355,13 +312,259 @@ export function IssuesList({ } } return defaults; - }; + }, [projectId, viewState.groupBy]); - const assignIssue = (issueId: string, assigneeAgentId: string | null, assigneeUserId: string | null = null) => { + const assignIssue = useCallback((issueId: string, assigneeAgentId: string | null, assigneeUserId: string | null = null) => { onUpdateIssue(issueId, { assigneeAgentId, assigneeUserId }); setAssigneePickerIssueId(null); setAssigneeSearch(""); - }; + }, [onUpdateIssue]); + + const listContent = useMemo(() => { + if (viewState.viewMode === "board") { + return ( + + ); + } + + return groupedContent.map((group) => ( + { + updateView({ + collapsedGroups: open + ? viewState.collapsedGroups.filter((k) => k !== group.key) + : [...viewState.collapsedGroups, group.key], + }); + }} + > + {group.label && ( +
+ + + + {group.label} + + + +
+ )} + + {group.items.map((issue) => ( + { + e.preventDefault(); + e.stopPropagation(); + }} + > + onUpdateIssue(issue.id, { status: s })} + /> + + )} + desktopMetaLeading={( + <> + { + e.preventDefault(); + e.stopPropagation(); + }} + > + onUpdateIssue(issue.id, { status: s })} + /> + + + {issue.identifier ?? issue.id.slice(0, 8)} + + {liveIssueIds?.has(issue.id) && ( + + + + + + + Live + + + )} + + )} + mobileMeta={timeAgo(issue.updatedAt)} + desktopTrailing={( + <> + {(issue.labels ?? []).length > 0 && ( + + {(issue.labels ?? []).slice(0, 3).map((label) => ( + + {label.name} + + ))} + {(issue.labels ?? []).length > 3 && ( + + +{(issue.labels ?? []).length - 3} + + )} + + )} + { + setAssigneePickerIssueId(open ? issue.id : null); + if (!open) setAssigneeSearch(""); + }} + > + + + + e.stopPropagation()} + onPointerDownOutside={() => setAssigneeSearch("")} + > + setAssigneeSearch(e.target.value)} + autoFocus + /> +
+ + {currentUserId && ( + + )} + {(agents ?? []) + .filter((agent) => { + if (!assigneeSearch.trim()) return true; + return agent.name + .toLowerCase() + .includes(assigneeSearch.toLowerCase()); + }) + .map((agent) => ( + + ))} +
+
+
+ + )} + trailingMeta={formatDate(issue.createdAt)} + /> + ))} +
+
+ )); + }, [ + agents, + agentName, + assigneePickerIssueId, + assigneeSearch, + assignIssue, + currentUserId, + filtered, + groupedContent, + issueLinkState, + liveIssueIds, + newIssueDefaults, + onUpdateIssue, + openNewIssue, + updateView, + viewState.collapsedGroups, + ]); return (
@@ -372,10 +575,19 @@ 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" + /> +
@@ -658,231 +870,7 @@ export function IssuesList({ /> )} - {viewState.viewMode === "board" ? ( - - ) : ( - groupedContent.map((group) => ( - { - updateView({ - collapsedGroups: open - ? viewState.collapsedGroups.filter((k) => k !== group.key) - : [...viewState.collapsedGroups, group.key], - }); - }} - > - {group.label && ( -
- - - - {group.label} - - - -
- )} - - {group.items.map((issue) => ( - { - e.preventDefault(); - e.stopPropagation(); - }} - > - onUpdateIssue(issue.id, { status: s })} - /> - - )} - desktopMetaLeading={( - <> - { - e.preventDefault(); - e.stopPropagation(); - }} - > - onUpdateIssue(issue.id, { status: s })} - /> - - - {issue.identifier ?? issue.id.slice(0, 8)} - - {liveIssueIds?.has(issue.id) && ( - - - - - - - Live - - - )} - - )} - mobileMeta={timeAgo(issue.updatedAt)} - desktopTrailing={( - <> - {(issue.labels ?? []).length > 0 && ( - - {(issue.labels ?? []).slice(0, 3).map((label) => ( - - {label.name} - - ))} - {(issue.labels ?? []).length > 3 && ( - - +{(issue.labels ?? []).length - 3} - - )} - - )} - { - setAssigneePickerIssueId(open ? issue.id : null); - if (!open) setAssigneeSearch(""); - }} - > - - - - e.stopPropagation()} - onPointerDownOutside={() => setAssigneeSearch("")} - > - setAssigneeSearch(e.target.value)} - autoFocus - /> -
- - {currentUserId && ( - - )} - {(agents ?? []) - .filter((agent) => { - if (!assigneeSearch.trim()) return true; - return agent.name - .toLowerCase() - .includes(assigneeSearch.toLowerCase()); - }) - .map((agent) => ( - - ))} -
-
-
- - )} - trailingMeta={formatDate(issue.createdAt)} - /> - ))} -
-
- )) - )} + {listContent}
); }