perf(renderer): skip unchanged kanban task cards

This commit is contained in:
777genius 2026-05-31 03:19:31 +03:00
parent a491cd6c1c
commit c02565b07d
2 changed files with 82 additions and 3 deletions

View file

@ -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')] },

View file

@ -83,6 +83,30 @@ interface CommentPulseSyncAction {
}
const EMPTY_TASK_COMMENTS: readonly TaskComment[] = [];
const taskCardSignatureCache = new WeakMap<TeamTaskWithKanban, string>();
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 &&