perf(renderer): reduce sidebar task list render work

This commit is contained in:
777genius 2026-05-31 02:55:14 +03:00
parent 12c578a44d
commit f06a50f859
3 changed files with 297 additions and 176 deletions

View file

@ -223,77 +223,139 @@ interface GlobalTaskRowProps {
ownerColorName?: string | null; ownerColorName?: string | null;
} }
const GlobalTaskRow = memo(function GlobalTaskRow({ function taskCommentsDisplayEqual(
task, prev: GlobalTask['comments'],
isPinned, next: GlobalTask['comments']
isArchived, ): boolean {
isNew, if (prev === next) return true;
teamOffline, if (!prev || !next) return (prev?.length ?? 0) === (next?.length ?? 0);
renamingKey, if (prev.length !== next.length) return false;
hideTeamName, for (let i = 0; i < prev.length; i += 1) {
hideProjectName, if (prev[i].id !== next[i].id || prev[i].createdAt !== next[i].createdAt) {
showTeamName, return false;
onTogglePin, }
onToggleArchive, }
onMarkUnread, return true;
onRename, }
onDelete,
onRenameComplete,
onRenameCancel,
getDisplaySubject,
ownerColorName,
}: GlobalTaskRowProps): React.JSX.Element {
const taskRenamingKey = `${task.teamName}:${task.id}`;
const effectiveRenamingKey = renamingKey === taskRenamingKey ? renamingKey : null;
const handleTogglePin = useCallback(() => {
onTogglePin(task.teamName, task.id);
}, [onTogglePin, task.id, task.teamName]);
const handleToggleArchive = useCallback(() => {
onToggleArchive(task.teamName, task.id);
}, [onToggleArchive, task.id, task.teamName]);
const handleMarkUnread = useCallback(() => {
onMarkUnread(task.teamName, task.id);
}, [onMarkUnread, task.id, task.teamName]);
const handleRename = useCallback(() => {
onRename(task.teamName, task.id);
}, [onRename, task.id, task.teamName]);
const handleDelete = useCallback(() => {
void onDelete(task.teamName, task.id);
}, [onDelete, task.id, task.teamName]);
function taskSidebarFieldsEqual(prev: GlobalTask, next: GlobalTask): boolean {
return ( return (
<TaskContextMenu prev === next ||
task={task} (prev.id === next.id &&
isPinned={isPinned} prev.teamName === next.teamName &&
isArchived={isArchived} prev.teamDisplayName === next.teamDisplayName &&
onTogglePin={handleTogglePin} prev.teamDeleted === next.teamDeleted &&
onToggleArchive={handleToggleArchive} prev.subject === next.subject &&
onMarkUnread={handleMarkUnread} prev.owner === next.owner &&
onRename={handleRename} prev.status === next.status &&
onDelete={handleDelete} prev.createdAt === next.createdAt &&
> prev.updatedAt === next.updatedAt &&
<AnimatedHeightReveal animate={isNew}> prev.projectPath === next.projectPath &&
<SidebarTaskItem prev.reviewState === next.reviewState &&
task={task} prev.kanbanColumn === next.kanbanColumn &&
hideTeamName={hideTeamName} prev.deletedAt === next.deletedAt &&
hideProjectName={hideProjectName} taskCommentsDisplayEqual(prev.comments, next.comments))
showTeamName={showTeamName}
teamOffline={teamOffline}
renamingKey={effectiveRenamingKey}
onRenameComplete={onRenameComplete}
onRenameCancel={onRenameCancel}
getDisplaySubject={getDisplaySubject}
ownerColorName={ownerColorName}
/>
</AnimatedHeightReveal>
</TaskContextMenu>
); );
}); }
function effectiveRenamingKey(task: GlobalTask, renamingKey: string | null): string | null {
const taskRenamingKey = `${task.teamName}:${task.id}`;
return renamingKey === taskRenamingKey ? renamingKey : null;
}
const GlobalTaskRow = memo(
function GlobalTaskRow({
task,
isPinned,
isArchived,
isNew,
teamOffline,
renamingKey,
hideTeamName,
hideProjectName,
showTeamName,
onTogglePin,
onToggleArchive,
onMarkUnread,
onRename,
onDelete,
onRenameComplete,
onRenameCancel,
getDisplaySubject,
ownerColorName,
}: GlobalTaskRowProps): React.JSX.Element {
const taskRenamingKey = `${task.teamName}:${task.id}`;
const effectiveRenamingKey = renamingKey === taskRenamingKey ? renamingKey : null;
const handleTogglePin = useCallback(() => {
onTogglePin(task.teamName, task.id);
}, [onTogglePin, task.id, task.teamName]);
const handleToggleArchive = useCallback(() => {
onToggleArchive(task.teamName, task.id);
}, [onToggleArchive, task.id, task.teamName]);
const handleMarkUnread = useCallback(() => {
onMarkUnread(task.teamName, task.id);
}, [onMarkUnread, task.id, task.teamName]);
const handleRename = useCallback(() => {
onRename(task.teamName, task.id);
}, [onRename, task.id, task.teamName]);
const handleDelete = useCallback(() => {
void onDelete(task.teamName, task.id);
}, [onDelete, task.id, task.teamName]);
return (
<TaskContextMenu
task={task}
isPinned={isPinned}
isArchived={isArchived}
onTogglePin={handleTogglePin}
onToggleArchive={handleToggleArchive}
onMarkUnread={handleMarkUnread}
onRename={handleRename}
onDelete={handleDelete}
>
<AnimatedHeightReveal animate={isNew}>
<SidebarTaskItem
task={task}
hideTeamName={hideTeamName}
hideProjectName={hideProjectName}
showTeamName={showTeamName}
teamOffline={teamOffline}
renamingKey={effectiveRenamingKey}
onRenameComplete={onRenameComplete}
onRenameCancel={onRenameCancel}
getDisplaySubject={getDisplaySubject}
ownerColorName={ownerColorName}
/>
</AnimatedHeightReveal>
</TaskContextMenu>
);
},
(prev, next) =>
taskSidebarFieldsEqual(prev.task, next.task) &&
prev.isPinned === next.isPinned &&
prev.isArchived === next.isArchived &&
prev.isNew === next.isNew &&
prev.teamOffline === next.teamOffline &&
effectiveRenamingKey(prev.task, prev.renamingKey) ===
effectiveRenamingKey(next.task, next.renamingKey) &&
prev.hideTeamName === next.hideTeamName &&
prev.hideProjectName === next.hideProjectName &&
prev.showTeamName === next.showTeamName &&
prev.onTogglePin === next.onTogglePin &&
prev.onToggleArchive === next.onToggleArchive &&
prev.onMarkUnread === next.onMarkUnread &&
prev.onRename === next.onRename &&
prev.onDelete === next.onDelete &&
prev.onRenameComplete === next.onRenameComplete &&
prev.onRenameCancel === next.onRenameCancel &&
prev.getDisplaySubject === next.getDisplaySubject &&
prev.ownerColorName === next.ownerColorName
);
interface TaskRowsProps { interface TaskRowsProps {
tasks: GlobalTask[]; tasks: GlobalTask[];

View file

@ -53,7 +53,7 @@ export const TaskFiltersPopover = ({
}, [open, filters]); }, [open, filters]);
const allSelected = const allSelected =
STATUS_OPTIONS.length > 0 && STATUS_OPTIONS.every((opt) => draft.statusIds.has(opt.id)); open && STATUS_OPTIONS.length > 0 && STATUS_OPTIONS.every((opt) => draft.statusIds.has(opt.id));
const handleSelectAll = (): void => { const handleSelectAll = (): void => {
if (allSelected) { if (allSelected) {
@ -89,123 +89,125 @@ export const TaskFiltersPopover = ({
<Filter className="size-3.5" /> <Filter className="size-3.5" />
</button> </button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-64 p-3" align="end" sideOffset={6}> {open ? (
<div className="space-y-3"> <PopoverContent className="w-64 p-3" align="end" sideOffset={6}>
<div> <div className="space-y-3">
<div className="mb-1.5 flex items-center justify-between"> <div>
<span className="text-[11px] font-semibold text-text-secondary"> <div className="mb-1.5 flex items-center justify-between">
{t('taskFilters.status')} <span className="text-[11px] font-semibold text-text-secondary">
</span> {t('taskFilters.status')}
<button </span>
type="button" <button
className="text-[10px] text-text-muted hover:text-text-secondary" type="button"
onClick={handleSelectAll} className="text-[10px] text-text-muted hover:text-text-secondary"
> onClick={handleSelectAll}
{allSelected ? t('taskFilters.clearAll') : t('taskFilters.selectAll')}
</button>
</div>
<div className="flex flex-col gap-1.5">
{STATUS_OPTIONS.map((opt) => (
<label
key={opt.id}
className="flex cursor-pointer items-center gap-2 text-[12px] text-text"
> >
<Checkbox {allSelected ? t('taskFilters.clearAll') : t('taskFilters.selectAll')}
checked={draft.statusIds.has(opt.id)} </button>
onCheckedChange={() => toggleStatus(opt.id)} </div>
style={{ '--color-accent': opt.color } as React.CSSProperties} <div className="flex flex-col gap-1.5">
/> {STATUS_OPTIONS.map((opt) => (
<span <label
className="inline-block size-2 shrink-0 rounded-full" key={opt.id}
style={{ backgroundColor: opt.color }} className="flex cursor-pointer items-center gap-2 text-[12px] text-text"
/> >
{t(`taskFilters.statusOptions.${opt.labelKey}`)} <Checkbox
</label> checked={draft.statusIds.has(opt.id)}
))} onCheckedChange={() => toggleStatus(opt.id)}
style={{ '--color-accent': opt.color } as React.CSSProperties}
/>
<span
className="inline-block size-2 shrink-0 rounded-full"
style={{ backgroundColor: opt.color }}
/>
{t(`taskFilters.statusOptions.${opt.labelKey}`)}
</label>
))}
</div>
</div> </div>
</div>
<div>
<span className="mb-1.5 block text-[11px] font-semibold text-text-secondary">
{t('taskFilters.team')}
</span>
<Combobox
options={[
{ value: '__all__', label: t('taskFilters.allTeams') },
...teams.map((t) => ({ value: t.teamName, label: t.displayName })),
]}
value={draft.teamName ?? '__all__'}
onValueChange={(v) =>
setDraft({
...draft,
teamName: v === '__all__' ? null : v,
})
}
placeholder={t('taskFilters.allTeams')}
searchPlaceholder={t('taskFilters.searchTeams')}
emptyMessage={t('taskFilters.noTeamsFound')}
className="text-[12px]"
/>
</div>
{projectOptions.length > 0 && (
<div> <div>
<span className="mb-1.5 block text-[11px] font-semibold text-text-secondary"> <span className="mb-1.5 block text-[11px] font-semibold text-text-secondary">
{t('taskFilters.project')} {t('taskFilters.team')}
</span> </span>
<Combobox <Combobox
options={projectOptions} options={[
value={draft.projectPath ?? ''} { value: '__all__', label: t('taskFilters.allTeams') },
onValueChange={(v) => setDraft({ ...draft, projectPath: v || null })} ...teams.map((t) => ({ value: t.teamName, label: t.displayName })),
placeholder={t('taskFilters.allProjects')} ]}
searchPlaceholder={t('taskFilters.searchProjects')} value={draft.teamName ?? '__all__'}
emptyMessage={t('taskFilters.noProjects')} onValueChange={(v) =>
setDraft({
...draft,
teamName: v === '__all__' ? null : v,
})
}
placeholder={t('taskFilters.allTeams')}
searchPlaceholder={t('taskFilters.searchTeams')}
emptyMessage={t('taskFilters.noTeamsFound')}
className="text-[12px]" className="text-[12px]"
resetLabel={t('taskFilters.allProjects')}
onReset={() => setDraft({ ...draft, projectPath: null })}
/> />
</div> </div>
)}
<div> {projectOptions.length > 0 && (
<span className="mb-1.5 block text-[11px] font-semibold text-text-secondary"> <div>
{t('taskFilters.comments')} <span className="mb-1.5 block text-[11px] font-semibold text-text-secondary">
</span> {t('taskFilters.project')}
<div className="flex rounded-md border border-[var(--color-border)]"> </span>
{READ_FILTER_OPTIONS.map((opt) => ( <Combobox
<button options={projectOptions}
key={opt.value} value={draft.projectPath ?? ''}
type="button" onValueChange={(v) => setDraft({ ...draft, projectPath: v || null })}
className={`flex-1 px-2 py-1 text-[11px] font-medium transition-colors first:rounded-l-[5px] last:rounded-r-[5px] ${ placeholder={t('taskFilters.allProjects')}
draft.readFilter === opt.value searchPlaceholder={t('taskFilters.searchProjects')}
? 'bg-[var(--color-surface-raised)] text-text' emptyMessage={t('taskFilters.noProjects')}
: 'text-text-muted hover:text-text-secondary' className="text-[12px]"
}`} resetLabel={t('taskFilters.allProjects')}
onClick={() => onReset={() => setDraft({ ...draft, projectPath: null })}
setDraft({ />
...draft, </div>
readFilter: opt.value, )}
unreadOnly: opt.value === 'unread',
}) <div>
} <span className="mb-1.5 block text-[11px] font-semibold text-text-secondary">
> {t('taskFilters.comments')}
{t(`taskFilters.read.${opt.labelKey}`)} </span>
</button> <div className="flex rounded-md border border-[var(--color-border)]">
))} {READ_FILTER_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
className={`flex-1 px-2 py-1 text-[11px] font-medium transition-colors first:rounded-l-[5px] last:rounded-r-[5px] ${
draft.readFilter === opt.value
? 'bg-[var(--color-surface-raised)] text-text'
: 'text-text-muted hover:text-text-secondary'
}`}
onClick={() =>
setDraft({
...draft,
readFilter: opt.value,
unreadOnly: opt.value === 'unread',
})
}
>
{t(`taskFilters.read.${opt.labelKey}`)}
</button>
))}
</div>
</div> </div>
</div>
<Button <Button
type="button" type="button"
variant="default" variant="default"
size="sm" size="sm"
className="w-full" className="w-full"
onClick={handleApply} onClick={handleApply}
> >
{t('taskFilters.apply')} {t('taskFilters.apply')}
</Button> </Button>
</div> </div>
</PopoverContent> </PopoverContent>
) : null}
</Popover> </Popover>
); );
}; };

View file

@ -35,6 +35,7 @@ interface StoreState {
const storeState = {} as StoreState; const storeState = {} as StoreState;
const toggleCollapsedGroup = vi.fn(); const toggleCollapsedGroup = vi.fn();
const sidebarTaskItemRenderSpy = vi.hoisted(() => vi.fn());
const taskLocalState = { const taskLocalState = {
isPinned: vi.fn(() => false), isPinned: vi.fn(() => false),
isArchived: vi.fn(() => false), isArchived: vi.fn(() => false),
@ -103,8 +104,9 @@ vi.mock('../../../../src/renderer/components/sidebar/SidebarTaskItem', () => ({
task: GlobalTask; task: GlobalTask;
hideProjectName?: boolean; hideProjectName?: boolean;
teamOffline?: boolean; teamOffline?: boolean;
}) => }) => {
React.createElement( sidebarTaskItemRenderSpy(task.id);
return React.createElement(
'div', 'div',
{ {
'data-testid': 'sidebar-task-item', 'data-testid': 'sidebar-task-item',
@ -112,7 +114,8 @@ vi.mock('../../../../src/renderer/components/sidebar/SidebarTaskItem', () => ({
'data-team-offline': teamOffline ? 'true' : 'false', 'data-team-offline': teamOffline ? 'true' : 'false',
}, },
task.subject task.subject
), );
},
})); }));
vi.mock('../../../../src/renderer/components/sidebar/TaskFiltersPopover', () => ({ vi.mock('../../../../src/renderer/components/sidebar/TaskFiltersPopover', () => ({
@ -236,6 +239,7 @@ describe('GlobalTaskList project grouping', () => {
taskLocalState.togglePin.mockClear(); taskLocalState.togglePin.mockClear();
taskLocalState.toggleArchive.mockClear(); taskLocalState.toggleArchive.mockClear();
taskLocalState.renameTask.mockClear(); taskLocalState.renameTask.mockClear();
sidebarTaskItemRenderSpy.mockClear();
setElectronApiForTest(undefined); setElectronApiForTest(undefined);
localStorage.clear(); localStorage.clear();
localStorage.setItem('sidebarTasksGrouping', 'project'); localStorage.setItem('sidebarTasksGrouping', 'project');
@ -604,4 +608,57 @@ describe('GlobalTaskList project grouping', () => {
await flushMicrotasks(); await flushMicrotasks();
}); });
}); });
it('does not rerender unchanged task rows when refreshed task objects keep the same visible fields', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.globalTasks = [makeTask(1), makeTask(2)];
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(GlobalTaskList));
await flushMicrotasks();
});
expect(sidebarTaskItemRenderSpy).toHaveBeenCalledTimes(2);
sidebarTaskItemRenderSpy.mockClear();
storeState.globalTasks = [makeTask(1), makeTask(2, { subject: 'Task 2 updated' })];
await act(async () => {
notifyStoreUpdate();
await flushMicrotasks();
});
expect(visibleSubjects(host)).toEqual(['Task 1', 'Task 2 updated']);
expect(sidebarTaskItemRenderSpy.mock.calls.map(([taskId]) => taskId)).toEqual(['task-2']);
sidebarTaskItemRenderSpy.mockClear();
storeState.globalTasks = [
makeTask(1, {
comments: [
{
id: 'comment-1',
author: 'alice',
text: 'note',
createdAt: '2026-04-18T11:00:00.000Z',
type: 'regular',
},
],
}),
makeTask(2, { subject: 'Task 2 updated' }),
];
await act(async () => {
notifyStoreUpdate();
await flushMicrotasks();
});
expect(sidebarTaskItemRenderSpy.mock.calls.map(([taskId]) => taskId)).toEqual(['task-1']);
await act(async () => {
root.unmount();
await flushMicrotasks();
});
});
}); });