diff --git a/src/renderer/components/sidebar/GlobalTaskList.tsx b/src/renderer/components/sidebar/GlobalTaskList.tsx index f5868894..5b25d45b 100644 --- a/src/renderer/components/sidebar/GlobalTaskList.tsx +++ b/src/renderer/components/sidebar/GlobalTaskList.tsx @@ -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 ( - - - - - + 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 ( + + + + + + ); + }, + (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[]; diff --git a/src/renderer/components/sidebar/TaskFiltersPopover.tsx b/src/renderer/components/sidebar/TaskFiltersPopover.tsx index 1753cbb9..7d5d348c 100644 --- a/src/renderer/components/sidebar/TaskFiltersPopover.tsx +++ b/src/renderer/components/sidebar/TaskFiltersPopover.tsx @@ -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 = ({ - -
-
-
- - {t('taskFilters.status')} - - -
-
- {STATUS_OPTIONS.map((opt) => ( -
+ + ) : null} ); }; diff --git a/test/renderer/components/sidebar/GlobalTaskList.test.ts b/test/renderer/components/sidebar/GlobalTaskList.test.ts index a84e54d3..0a1e1a6b 100644 --- a/test/renderer/components/sidebar/GlobalTaskList.test.ts +++ b/test/renderer/components/sidebar/GlobalTaskList.test.ts @@ -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(); + }); + }); });