From c02565b07d8eec70a24142a150b7108590a8d297 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sun, 31 May 2026 03:19:31 +0300 Subject: [PATCH] perf(renderer): skip unchanged kanban task cards --- .../team/kanban/KanbanTaskCard.test.tsx | 57 ++++++++++++++++++- .../components/team/kanban/KanbanTaskCard.tsx | 28 ++++++++- 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx index 72a3fc7c..e13c5505 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx @@ -10,6 +10,7 @@ const unreadBadgeMock = vi.hoisted(() => ({ const unreadCommentCountMock = vi.hoisted(() => ({ value: 0, + calls: 0, })); vi.mock('@renderer/components/team/MemberBadge', () => ({ @@ -71,7 +72,10 @@ vi.mock('@renderer/hooks/useTheme', () => ({ })); vi.mock('@renderer/hooks/useUnreadCommentCount', () => ({ - useUnreadCommentCount: () => unreadCommentCountMock.value, + useUnreadCommentCount: () => { + unreadCommentCountMock.calls += 1; + return unreadCommentCountMock.value; + }, })); /* eslint-enable @typescript-eslint/naming-convention -- Re-enable after component mocks. */ @@ -188,6 +192,7 @@ async function rerenderStrictTaskCard( afterEach(() => { unreadBadgeMock.props.length = 0; unreadCommentCountMock.value = 0; + unreadCommentCountMock.calls = 0; }); async function renderTaskCard( @@ -211,6 +216,56 @@ describe('KanbanTaskCard comment badge pulse', () => { document.body.innerHTML = ''; }); + it('skips rerender when refreshed task objects keep the same snapshot', async () => { + const taskMap = new Map(); + const memberColorMap = new Map([['alice', 'blue']]); + const { root } = await renderTaskCard({ + task: { ...baseTask, comments: [] }, + taskMap, + memberColorMap, + }); + + expect(unreadCommentCountMock.calls).toBeGreaterThan(0); + unreadCommentCountMock.calls = 0; + + await rerenderTaskCard(root, { + task: { ...baseTask, comments: [] }, + taskMap, + memberColorMap, + }); + + expect(unreadCommentCountMock.calls).toBe(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']]); + const { root } = await renderTaskCard({ + task: { ...baseTask, comments: [] }, + taskMap, + memberColorMap, + }); + + unreadCommentCountMock.calls = 0; + await rerenderTaskCard(root, { + task: { ...baseTask, comments: [], description: 'Updated hidden details' }, + taskMap, + memberColorMap, + }); + + expect(unreadCommentCountMock.calls).toBeGreaterThan(0); + + await act(async () => { + root.unmount(); + await flushReact(); + }); + }); + it('does not pulse on initial render with existing comments', async () => { const { host, root } = await renderTaskCard({ task: { ...baseTask, comments: [createComment('comment-1')] }, diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx index d3d03fcb..c159813a 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx @@ -83,6 +83,30 @@ interface CommentPulseSyncAction { } const EMPTY_TASK_COMMENTS: readonly TaskComment[] = []; +const taskCardSignatureCache = new WeakMap(); + +function getTaskCardSignature(task: TeamTaskWithKanban): string { + const cached = taskCardSignatureCache.get(task); + if (cached !== undefined) return cached; + + const signature = JSON.stringify(task); + taskCardSignatureCache.set(task, signature); + return signature; +} + +function areKanbanTaskStatesEqual( + prev: KanbanTaskState | undefined, + next: KanbanTaskState | undefined +): boolean { + if (prev === next) return true; + if (!prev || !next) return !prev && !next; + return ( + prev.column === next.column && + prev.reviewer === next.reviewer && + prev.errorDescription === next.errorDescription && + prev.movedAt === next.movedAt + ); +} function createCommentPulseState( taskKey: string, @@ -652,10 +676,10 @@ export const KanbanTaskCard = memo( ); }, (prev, next) => - prev.task === next.task && + getTaskCardSignature(prev.task) === getTaskCardSignature(next.task) && prev.teamName === next.teamName && prev.columnId === next.columnId && - prev.kanbanTaskState === next.kanbanTaskState && + areKanbanTaskStatesEqual(prev.kanbanTaskState, next.kanbanTaskState) && prev.hasReviewers === next.hasReviewers && prev.compact === next.compact && prev.taskMap === next.taskMap &&