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;
}
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]);
function taskCommentsDisplayEqual(
prev: GlobalTask['comments'],
next: GlobalTask['comments']
): boolean {
if (prev === next) return true;
if (!prev || !next) return (prev?.length ?? 0) === (next?.length ?? 0);
if (prev.length !== next.length) return false;
for (let i = 0; i < prev.length; i += 1) {
if (prev[i].id !== next[i].id || prev[i].createdAt !== next[i].createdAt) {
return false;
}
}
return true;
}
function taskSidebarFieldsEqual(prev: GlobalTask, next: GlobalTask): boolean {
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 ||
(prev.id === next.id &&
prev.teamName === next.teamName &&
prev.teamDisplayName === next.teamDisplayName &&
prev.teamDeleted === next.teamDeleted &&
prev.subject === next.subject &&
prev.owner === next.owner &&
prev.status === next.status &&
prev.createdAt === next.createdAt &&
prev.updatedAt === next.updatedAt &&
prev.projectPath === next.projectPath &&
prev.reviewState === next.reviewState &&
prev.kanbanColumn === next.kanbanColumn &&
prev.deletedAt === next.deletedAt &&
taskCommentsDisplayEqual(prev.comments, next.comments))
);
});
}
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 {
tasks: GlobalTask[];

View file

@ -53,7 +53,7 @@ export const TaskFiltersPopover = ({
}, [open, filters]);
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 => {
if (allSelected) {
@ -89,123 +89,125 @@ export const TaskFiltersPopover = ({
<Filter className="size-3.5" />
</button>
</PopoverTrigger>
<PopoverContent className="w-64 p-3" align="end" sideOffset={6}>
<div className="space-y-3">
<div>
<div className="mb-1.5 flex items-center justify-between">
<span className="text-[11px] font-semibold text-text-secondary">
{t('taskFilters.status')}
</span>
<button
type="button"
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"
{open ? (
<PopoverContent className="w-64 p-3" align="end" sideOffset={6}>
<div className="space-y-3">
<div>
<div className="mb-1.5 flex items-center justify-between">
<span className="text-[11px] font-semibold text-text-secondary">
{t('taskFilters.status')}
</span>
<button
type="button"
className="text-[10px] text-text-muted hover:text-text-secondary"
onClick={handleSelectAll}
>
<Checkbox
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>
))}
{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
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>
<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>
<span className="mb-1.5 block text-[11px] font-semibold text-text-secondary">
{t('taskFilters.project')}
{t('taskFilters.team')}
</span>
<Combobox
options={projectOptions}
value={draft.projectPath ?? ''}
onValueChange={(v) => setDraft({ ...draft, projectPath: v || null })}
placeholder={t('taskFilters.allProjects')}
searchPlaceholder={t('taskFilters.searchProjects')}
emptyMessage={t('taskFilters.noProjects')}
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]"
resetLabel={t('taskFilters.allProjects')}
onReset={() => setDraft({ ...draft, projectPath: null })}
/>
</div>
)}
<div>
<span className="mb-1.5 block text-[11px] font-semibold text-text-secondary">
{t('taskFilters.comments')}
</span>
<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>
))}
{projectOptions.length > 0 && (
<div>
<span className="mb-1.5 block text-[11px] font-semibold text-text-secondary">
{t('taskFilters.project')}
</span>
<Combobox
options={projectOptions}
value={draft.projectPath ?? ''}
onValueChange={(v) => setDraft({ ...draft, projectPath: v || null })}
placeholder={t('taskFilters.allProjects')}
searchPlaceholder={t('taskFilters.searchProjects')}
emptyMessage={t('taskFilters.noProjects')}
className="text-[12px]"
resetLabel={t('taskFilters.allProjects')}
onReset={() => setDraft({ ...draft, projectPath: null })}
/>
</div>
)}
<div>
<span className="mb-1.5 block text-[11px] font-semibold text-text-secondary">
{t('taskFilters.comments')}
</span>
<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>
<Button
type="button"
variant="default"
size="sm"
className="w-full"
onClick={handleApply}
>
{t('taskFilters.apply')}
</Button>
</div>
</PopoverContent>
<Button
type="button"
variant="default"
size="sm"
className="w-full"
onClick={handleApply}
>
{t('taskFilters.apply')}
</Button>
</div>
</PopoverContent>
) : null}
</Popover>
);
};

View file

@ -35,6 +35,7 @@ interface StoreState {
const storeState = {} as StoreState;
const toggleCollapsedGroup = vi.fn();
const sidebarTaskItemRenderSpy = vi.hoisted(() => vi.fn());
const taskLocalState = {
isPinned: vi.fn(() => false),
isArchived: vi.fn(() => false),
@ -103,8 +104,9 @@ vi.mock('../../../../src/renderer/components/sidebar/SidebarTaskItem', () => ({
task: GlobalTask;
hideProjectName?: boolean;
teamOffline?: boolean;
}) =>
React.createElement(
}) => {
sidebarTaskItemRenderSpy(task.id);
return React.createElement(
'div',
{
'data-testid': 'sidebar-task-item',
@ -112,7 +114,8 @@ vi.mock('../../../../src/renderer/components/sidebar/SidebarTaskItem', () => ({
'data-team-offline': teamOffline ? 'true' : 'false',
},
task.subject
),
);
},
}));
vi.mock('../../../../src/renderer/components/sidebar/TaskFiltersPopover', () => ({
@ -236,6 +239,7 @@ describe('GlobalTaskList project grouping', () => {
taskLocalState.togglePin.mockClear();
taskLocalState.toggleArchive.mockClear();
taskLocalState.renameTask.mockClear();
sidebarTaskItemRenderSpy.mockClear();
setElectronApiForTest(undefined);
localStorage.clear();
localStorage.setItem('sidebarTasksGrouping', 'project');
@ -604,4 +608,57 @@ describe('GlobalTaskList project grouping', () => {
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();
});
});
});