perf(renderer): reduce sidebar task list render work
This commit is contained in:
parent
12c578a44d
commit
f06a50f859
3 changed files with 297 additions and 176 deletions
|
|
@ -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,
|
||||
|
|
@ -242,7 +283,7 @@ const GlobalTaskRow = memo(function GlobalTaskRow({
|
|||
onRenameCancel,
|
||||
getDisplaySubject,
|
||||
ownerColorName,
|
||||
}: GlobalTaskRowProps): React.JSX.Element {
|
||||
}: GlobalTaskRowProps): React.JSX.Element {
|
||||
const taskRenamingKey = `${task.teamName}:${task.id}`;
|
||||
const effectiveRenamingKey = renamingKey === taskRenamingKey ? renamingKey : null;
|
||||
|
||||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue