diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx index e13c5505..7ceeb7b4 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx @@ -242,6 +242,55 @@ describe('KanbanTaskCard comment badge pulse', () => { }); }); + it('skips rerender when an unrelated taskMap entry changes', async () => { + const memberColorMap = new Map([['alice', 'blue']]); + const { root } = await renderTaskCard({ + task: { ...baseTask, blockedBy: [], blocks: [], comments: [] }, + taskMap: new Map([['other-task', { ...baseTask, id: 'other-task', subject: 'Other task' }]]), + memberColorMap, + }); + + unreadCommentCountMock.calls = 0; + await rerenderTaskCard(root, { + task: { ...baseTask, blockedBy: [], blocks: [], comments: [] }, + taskMap: new Map([ + ['other-task', { ...baseTask, id: 'other-task', subject: 'Updated unrelated task' }], + ]), + memberColorMap, + }); + + expect(unreadCommentCountMock.calls).toBe(0); + + await act(async () => { + root.unmount(); + await flushReact(); + }); + }); + + it('rerenders when a displayed dependency task changes', async () => { + const memberColorMap = new Map([['alice', 'blue']]); + const blockedTask = { ...baseTask, id: 'dep-1', displayId: 'dep1', subject: 'Dependency A' }; + const { root } = await renderTaskCard({ + task: { ...baseTask, blockedBy: ['dep-1'], blocks: [], comments: [] }, + taskMap: new Map([['dep-1', blockedTask]]), + memberColorMap, + }); + + unreadCommentCountMock.calls = 0; + await rerenderTaskCard(root, { + task: { ...baseTask, blockedBy: ['dep-1'], blocks: [], comments: [] }, + taskMap: new Map([['dep-1', { ...blockedTask, subject: 'Dependency B', status: 'done' }]]), + memberColorMap, + }); + + expect(unreadCommentCountMock.calls).toBeGreaterThan(0); + + await act(async () => { + root.unmount(); + await flushReact(); + }); + }); + it('rerenders when a hidden task field changes so click handlers stay current', async () => { const taskMap = new Map(); const memberColorMap = new Map([['alice', 'blue']]); diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx index c159813a..2dbc416f 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx @@ -108,6 +108,44 @@ function areKanbanTaskStatesEqual( ); } +function getTaskDependencyIds(task: TeamTaskWithKanban): string[] { + return [...(task.blockedBy ?? []), ...(task.blocks ?? [])].filter((id) => id.length > 0); +} + +function getDependencyTaskSignature(task: TeamTask | undefined): string { + if (!task) return ''; + const kanbanTask = task as Partial; + return [ + task.id, + task.displayId ?? '', + task.subject, + task.status, + task.reviewState ?? '', + kanbanTask.kanbanColumn ?? '', + ].join('\u001f'); +} + +function areTaskMapDependenciesEqual( + prevTask: TeamTaskWithKanban, + nextTask: TeamTaskWithKanban, + prevTaskMap: Map, + nextTaskMap: Map +): boolean { + const dependencyIds = new Set([ + ...getTaskDependencyIds(prevTask), + ...getTaskDependencyIds(nextTask), + ]); + for (const taskId of dependencyIds) { + if ( + getDependencyTaskSignature(prevTaskMap.get(taskId)) !== + getDependencyTaskSignature(nextTaskMap.get(taskId)) + ) { + return false; + } + } + return true; +} + function createCommentPulseState( taskKey: string, comments: readonly TaskComment[], @@ -682,7 +720,7 @@ export const KanbanTaskCard = memo( areKanbanTaskStatesEqual(prev.kanbanTaskState, next.kanbanTaskState) && prev.hasReviewers === next.hasReviewers && prev.compact === next.compact && - prev.taskMap === next.taskMap && + areTaskMapDependenciesEqual(prev.task, next.task, prev.taskMap, next.taskMap) && prev.memberColorMap === next.memberColorMap && prev.hasLiveTaskLogs === next.hasLiveTaskLogs && prev.onRequestReview === next.onRequestReview &&