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 <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-03-28 10:23:31 -05:00
parent b825a121cb
commit 4dea302791

View file

@ -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<string, unknown>) => 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 (
<div className="relative w-48 sm:w-64 md:w-80">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Search issues..."
className="pl-7 text-xs sm:text-sm"
aria-label="Search issues"
/>
</div>
);
}
export function IssuesList({
issues,
isLoading,
@ -249,7 +221,8 @@ export function IssuesList({
const [assigneePickerIssueId, setAssigneePickerIssueId] = useState<string | null>(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<IssueViewState>) => {
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<string, string> = {};
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 (
<KanbanBoard
issues={filtered}
agents={agents}
liveIssueIds={liveIssueIds}
onUpdateIssue={onUpdateIssue}
/>
);
}
return groupedContent.map((group) => (
<Collapsible
key={group.key}
open={!viewState.collapsedGroups.includes(group.key)}
onOpenChange={(open) => {
updateView({
collapsedGroups: open
? viewState.collapsedGroups.filter((k) => k !== group.key)
: [...viewState.collapsedGroups, group.key],
});
}}
>
{group.label && (
<div className="flex items-center py-1.5 pl-1 pr-3">
<CollapsibleTrigger className="flex items-center gap-1.5">
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90" />
<span className="text-sm font-semibold uppercase tracking-wide">
{group.label}
</span>
</CollapsibleTrigger>
<Button
variant="ghost"
size="icon-xs"
className="ml-auto text-muted-foreground"
onClick={() => openNewIssue(newIssueDefaults(group.key))}
>
<Plus className="h-3 w-3" />
</Button>
</div>
)}
<CollapsibleContent>
{group.items.map((issue) => (
<IssueRow
key={issue.id}
issue={issue}
issueLinkState={issueLinkState}
desktopLeadingSpacer
mobileLeading={(
<span
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<StatusIcon
status={issue.status}
onChange={(s) => onUpdateIssue(issue.id, { status: s })}
/>
</span>
)}
desktopMetaLeading={(
<>
<span
className="hidden shrink-0 sm:inline-flex"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<StatusIcon
status={issue.status}
onChange={(s) => onUpdateIssue(issue.id, { status: s })}
/>
</span>
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
{liveIssueIds?.has(issue.id) && (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-1.5 py-0.5 sm:gap-1.5 sm:px-2">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500" />
</span>
<span className="hidden text-[11px] font-medium text-blue-600 dark:text-blue-400 sm:inline">
Live
</span>
</span>
)}
</>
)}
mobileMeta={timeAgo(issue.updatedAt)}
desktopTrailing={(
<>
{(issue.labels ?? []).length > 0 && (
<span className="hidden items-center gap-1 overflow-hidden md:flex md:max-w-[240px]">
{(issue.labels ?? []).slice(0, 3).map((label) => (
<span
key={label.id}
className="inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-medium"
style={{
borderColor: label.color,
color: pickTextColorForPillBg(label.color, 0.12),
backgroundColor: `${label.color}1f`,
}}
>
{label.name}
</span>
))}
{(issue.labels ?? []).length > 3 && (
<span className="text-[10px] text-muted-foreground">
+{(issue.labels ?? []).length - 3}
</span>
)}
</span>
)}
<Popover
open={assigneePickerIssueId === issue.id}
onOpenChange={(open) => {
setAssigneePickerIssueId(open ? issue.id : null);
if (!open) setAssigneeSearch("");
}}
>
<PopoverTrigger asChild>
<button
className="flex w-[180px] shrink-0 items-center rounded-md px-2 py-1 transition-colors hover:bg-accent/50"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" />
) : issue.assigneeUserId ? (
<span className="inline-flex items-center gap-1.5 text-xs">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
<User className="h-3 w-3" />
</span>
{formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"}
</span>
) : (
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
<User className="h-3 w-3" />
</span>
Assignee
</span>
)}
</button>
</PopoverTrigger>
<PopoverContent
className="w-56 p-1"
align="end"
onClick={(e) => e.stopPropagation()}
onPointerDownOutside={() => setAssigneeSearch("")}
>
<input
className="mb-1 w-full border-b border-border bg-transparent px-2 py-1.5 text-xs outline-none placeholder:text-muted-foreground/50"
placeholder="Search assignees..."
value={assigneeSearch}
onChange={(e) => setAssigneeSearch(e.target.value)}
autoFocus
/>
<div className="max-h-48 overflow-y-auto overscroll-contain">
<button
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent/50",
!issue.assigneeAgentId && !issue.assigneeUserId && "bg-accent",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
assignIssue(issue.id, null, null);
}}
>
No assignee
</button>
{currentUserId && (
<button
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50",
issue.assigneeUserId === currentUserId && "bg-accent",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
assignIssue(issue.id, null, currentUserId);
}}
>
<User className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span>Me</span>
</button>
)}
{(agents ?? [])
.filter((agent) => {
if (!assigneeSearch.trim()) return true;
return agent.name
.toLowerCase()
.includes(assigneeSearch.toLowerCase());
})
.map((agent) => (
<button
key={agent.id}
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50",
issue.assigneeAgentId === agent.id && "bg-accent",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
assignIssue(issue.id, agent.id, null);
}}
>
<Identity name={agent.name} size="sm" className="min-w-0" />
</button>
))}
</div>
</PopoverContent>
</Popover>
</>
)}
trailingMeta={formatDate(issue.createdAt)}
/>
))}
</CollapsibleContent>
</Collapsible>
));
}, [
agents,
agentName,
assigneePickerIssueId,
assigneeSearch,
assignIssue,
currentUserId,
filtered,
groupedContent,
issueLinkState,
liveIssueIds,
newIssueDefaults,
onUpdateIssue,
openNewIssue,
updateView,
viewState.collapsedGroups,
]);
return (
<div className="space-y-4">
@ -372,10 +575,19 @@ export function IssuesList({
<Plus className="h-4 w-4 sm:mr-1" />
<span className="hidden sm:inline">New Issue</span>
</Button>
<IssuesSearchInput
initialValue={initialSearch ?? ""}
onValueCommitted={handleIssueSearchCommit}
/>
<div className="relative w-48 sm:w-64 md:w-80">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={issueSearch}
onChange={(e) => {
setIssueSearch(e.target.value);
onSearchChange?.(e.target.value);
}}
placeholder="Search issues..."
className="pl-7 text-xs sm:text-sm"
aria-label="Search issues"
/>
</div>
</div>
<div className="flex items-center gap-0.5 sm:gap-1 shrink-0">
@ -658,231 +870,7 @@ export function IssuesList({
/>
)}
{viewState.viewMode === "board" ? (
<KanbanBoard
issues={filtered}
agents={agents}
liveIssueIds={liveIssueIds}
onUpdateIssue={onUpdateIssue}
/>
) : (
groupedContent.map((group) => (
<Collapsible
key={group.key}
open={!viewState.collapsedGroups.includes(group.key)}
onOpenChange={(open) => {
updateView({
collapsedGroups: open
? viewState.collapsedGroups.filter((k) => k !== group.key)
: [...viewState.collapsedGroups, group.key],
});
}}
>
{group.label && (
<div className="flex items-center py-1.5 pl-1 pr-3">
<CollapsibleTrigger className="flex items-center gap-1.5">
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90" />
<span className="text-sm font-semibold uppercase tracking-wide">
{group.label}
</span>
</CollapsibleTrigger>
<Button
variant="ghost"
size="icon-xs"
className="ml-auto text-muted-foreground"
onClick={() => openNewIssue(newIssueDefaults(group.key))}
>
<Plus className="h-3 w-3" />
</Button>
</div>
)}
<CollapsibleContent>
{group.items.map((issue) => (
<IssueRow
key={issue.id}
issue={issue}
issueLinkState={issueLinkState}
desktopLeadingSpacer
mobileLeading={(
<span
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<StatusIcon
status={issue.status}
onChange={(s) => onUpdateIssue(issue.id, { status: s })}
/>
</span>
)}
desktopMetaLeading={(
<>
<span
className="hidden shrink-0 sm:inline-flex"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<StatusIcon
status={issue.status}
onChange={(s) => onUpdateIssue(issue.id, { status: s })}
/>
</span>
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
{liveIssueIds?.has(issue.id) && (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-1.5 py-0.5 sm:gap-1.5 sm:px-2">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500" />
</span>
<span className="hidden text-[11px] font-medium text-blue-600 dark:text-blue-400 sm:inline">
Live
</span>
</span>
)}
</>
)}
mobileMeta={timeAgo(issue.updatedAt)}
desktopTrailing={(
<>
{(issue.labels ?? []).length > 0 && (
<span className="hidden items-center gap-1 overflow-hidden md:flex md:max-w-[240px]">
{(issue.labels ?? []).slice(0, 3).map((label) => (
<span
key={label.id}
className="inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-medium"
style={{
borderColor: label.color,
color: pickTextColorForPillBg(label.color, 0.12),
backgroundColor: `${label.color}1f`,
}}
>
{label.name}
</span>
))}
{(issue.labels ?? []).length > 3 && (
<span className="text-[10px] text-muted-foreground">
+{(issue.labels ?? []).length - 3}
</span>
)}
</span>
)}
<Popover
open={assigneePickerIssueId === issue.id}
onOpenChange={(open) => {
setAssigneePickerIssueId(open ? issue.id : null);
if (!open) setAssigneeSearch("");
}}
>
<PopoverTrigger asChild>
<button
className="flex w-[180px] shrink-0 items-center rounded-md px-2 py-1 transition-colors hover:bg-accent/50"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" />
) : issue.assigneeUserId ? (
<span className="inline-flex items-center gap-1.5 text-xs">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
<User className="h-3 w-3" />
</span>
{formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"}
</span>
) : (
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
<User className="h-3 w-3" />
</span>
Assignee
</span>
)}
</button>
</PopoverTrigger>
<PopoverContent
className="w-56 p-1"
align="end"
onClick={(e) => e.stopPropagation()}
onPointerDownOutside={() => setAssigneeSearch("")}
>
<input
className="mb-1 w-full border-b border-border bg-transparent px-2 py-1.5 text-xs outline-none placeholder:text-muted-foreground/50"
placeholder="Search assignees..."
value={assigneeSearch}
onChange={(e) => setAssigneeSearch(e.target.value)}
autoFocus
/>
<div className="max-h-48 overflow-y-auto overscroll-contain">
<button
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent/50",
!issue.assigneeAgentId && !issue.assigneeUserId && "bg-accent",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
assignIssue(issue.id, null, null);
}}
>
No assignee
</button>
{currentUserId && (
<button
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50",
issue.assigneeUserId === currentUserId && "bg-accent",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
assignIssue(issue.id, null, currentUserId);
}}
>
<User className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span>Me</span>
</button>
)}
{(agents ?? [])
.filter((agent) => {
if (!assigneeSearch.trim()) return true;
return agent.name
.toLowerCase()
.includes(assigneeSearch.toLowerCase());
})
.map((agent) => (
<button
key={agent.id}
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50",
issue.assigneeAgentId === agent.id && "bg-accent",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
assignIssue(issue.id, agent.id, null);
}}
>
<Identity name={agent.name} size="sm" className="min-w-0" />
</button>
))}
</div>
</PopoverContent>
</Popover>
</>
)}
trailingMeta={formatDate(issue.createdAt)}
/>
))}
</CollapsibleContent>
</Collapsible>
))
)}
{listContent}
</div>
);
}