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,7 +223,48 @@ interface GlobalTaskRowProps {
ownerColorName?: string | null;
}
const GlobalTaskRow = memo(function GlobalTaskRow({
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 (
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,
@ -293,7 +334,28 @@ const GlobalTaskRow = memo(function GlobalTaskRow({
</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,6 +89,7 @@ export const TaskFiltersPopover = ({
<Filter className="size-3.5" />
</button>
</PopoverTrigger>
{open ? (
<PopoverContent className="w-64 p-3" align="end" sideOffset={6}>
<div className="space-y-3">
<div>
@ -206,6 +207,7 @@ export const TaskFiltersPopover = ({
</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();
});
});
});