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,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 (
|
||||
<TaskContextMenu
|
||||
task={task}
|
||||
isPinned={isPinned}
|
||||
isArchived={isArchived}
|
||||
onTogglePin={handleTogglePin}
|
||||
onToggleArchive={handleToggleArchive}
|
||||
onMarkUnread={handleMarkUnread}
|
||||
onRename={handleRename}
|
||||
onDelete={handleDelete}
|
||||
>
|
||||
<AnimatedHeightReveal animate={isNew}>
|
||||
<SidebarTaskItem
|
||||
task={task}
|
||||
hideTeamName={hideTeamName}
|
||||
hideProjectName={hideProjectName}
|
||||
showTeamName={showTeamName}
|
||||
teamOffline={teamOffline}
|
||||
renamingKey={effectiveRenamingKey}
|
||||
onRenameComplete={onRenameComplete}
|
||||
onRenameCancel={onRenameCancel}
|
||||
getDisplaySubject={getDisplaySubject}
|
||||
ownerColorName={ownerColorName}
|
||||
/>
|
||||
</AnimatedHeightReveal>
|
||||
</TaskContextMenu>
|
||||
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 (
|
||||
<TaskContextMenu
|
||||
task={task}
|
||||
isPinned={isPinned}
|
||||
isArchived={isArchived}
|
||||
onTogglePin={handleTogglePin}
|
||||
onToggleArchive={handleToggleArchive}
|
||||
onMarkUnread={handleMarkUnread}
|
||||
onRename={handleRename}
|
||||
onDelete={handleDelete}
|
||||
>
|
||||
<AnimatedHeightReveal animate={isNew}>
|
||||
<SidebarTaskItem
|
||||
task={task}
|
||||
hideTeamName={hideTeamName}
|
||||
hideProjectName={hideProjectName}
|
||||
showTeamName={showTeamName}
|
||||
teamOffline={teamOffline}
|
||||
renamingKey={effectiveRenamingKey}
|
||||
onRenameComplete={onRenameComplete}
|
||||
onRenameCancel={onRenameCancel}
|
||||
getDisplaySubject={getDisplaySubject}
|
||||
ownerColorName={ownerColorName}
|
||||
/>
|
||||
</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,123 +89,125 @@ export const TaskFiltersPopover = ({
|
|||
<Filter className="size-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-3" align="end" sideOffset={6}>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<span className="text-[11px] font-semibold text-text-secondary">
|
||||
{t('taskFilters.status')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] text-text-muted hover:text-text-secondary"
|
||||
onClick={handleSelectAll}
|
||||
>
|
||||
{allSelected ? t('taskFilters.clearAll') : t('taskFilters.selectAll')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{STATUS_OPTIONS.map((opt) => (
|
||||
<label
|
||||
key={opt.id}
|
||||
className="flex cursor-pointer items-center gap-2 text-[12px] text-text"
|
||||
{open ? (
|
||||
<PopoverContent className="w-64 p-3" align="end" sideOffset={6}>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<span className="text-[11px] font-semibold text-text-secondary">
|
||||
{t('taskFilters.status')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] text-text-muted hover:text-text-secondary"
|
||||
onClick={handleSelectAll}
|
||||
>
|
||||
<Checkbox
|
||||
checked={draft.statusIds.has(opt.id)}
|
||||
onCheckedChange={() => toggleStatus(opt.id)}
|
||||
style={{ '--color-accent': opt.color } as React.CSSProperties}
|
||||
/>
|
||||
<span
|
||||
className="inline-block size-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: opt.color }}
|
||||
/>
|
||||
{t(`taskFilters.statusOptions.${opt.labelKey}`)}
|
||||
</label>
|
||||
))}
|
||||
{allSelected ? t('taskFilters.clearAll') : t('taskFilters.selectAll')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{STATUS_OPTIONS.map((opt) => (
|
||||
<label
|
||||
key={opt.id}
|
||||
className="flex cursor-pointer items-center gap-2 text-[12px] text-text"
|
||||
>
|
||||
<Checkbox
|
||||
checked={draft.statusIds.has(opt.id)}
|
||||
onCheckedChange={() => toggleStatus(opt.id)}
|
||||
style={{ '--color-accent': opt.color } as React.CSSProperties}
|
||||
/>
|
||||
<span
|
||||
className="inline-block size-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: opt.color }}
|
||||
/>
|
||||
{t(`taskFilters.statusOptions.${opt.labelKey}`)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="mb-1.5 block text-[11px] font-semibold text-text-secondary">
|
||||
{t('taskFilters.team')}
|
||||
</span>
|
||||
<Combobox
|
||||
options={[
|
||||
{ value: '__all__', label: t('taskFilters.allTeams') },
|
||||
...teams.map((t) => ({ value: t.teamName, label: t.displayName })),
|
||||
]}
|
||||
value={draft.teamName ?? '__all__'}
|
||||
onValueChange={(v) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
teamName: v === '__all__' ? null : v,
|
||||
})
|
||||
}
|
||||
placeholder={t('taskFilters.allTeams')}
|
||||
searchPlaceholder={t('taskFilters.searchTeams')}
|
||||
emptyMessage={t('taskFilters.noTeamsFound')}
|
||||
className="text-[12px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{projectOptions.length > 0 && (
|
||||
<div>
|
||||
<span className="mb-1.5 block text-[11px] font-semibold text-text-secondary">
|
||||
{t('taskFilters.project')}
|
||||
{t('taskFilters.team')}
|
||||
</span>
|
||||
<Combobox
|
||||
options={projectOptions}
|
||||
value={draft.projectPath ?? ''}
|
||||
onValueChange={(v) => setDraft({ ...draft, projectPath: v || null })}
|
||||
placeholder={t('taskFilters.allProjects')}
|
||||
searchPlaceholder={t('taskFilters.searchProjects')}
|
||||
emptyMessage={t('taskFilters.noProjects')}
|
||||
options={[
|
||||
{ value: '__all__', label: t('taskFilters.allTeams') },
|
||||
...teams.map((t) => ({ value: t.teamName, label: t.displayName })),
|
||||
]}
|
||||
value={draft.teamName ?? '__all__'}
|
||||
onValueChange={(v) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
teamName: v === '__all__' ? null : v,
|
||||
})
|
||||
}
|
||||
placeholder={t('taskFilters.allTeams')}
|
||||
searchPlaceholder={t('taskFilters.searchTeams')}
|
||||
emptyMessage={t('taskFilters.noTeamsFound')}
|
||||
className="text-[12px]"
|
||||
resetLabel={t('taskFilters.allProjects')}
|
||||
onReset={() => setDraft({ ...draft, projectPath: null })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<span className="mb-1.5 block text-[11px] font-semibold text-text-secondary">
|
||||
{t('taskFilters.comments')}
|
||||
</span>
|
||||
<div className="flex rounded-md border border-[var(--color-border)]">
|
||||
{READ_FILTER_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={`flex-1 px-2 py-1 text-[11px] font-medium transition-colors first:rounded-l-[5px] last:rounded-r-[5px] ${
|
||||
draft.readFilter === opt.value
|
||||
? 'bg-[var(--color-surface-raised)] text-text'
|
||||
: 'text-text-muted hover:text-text-secondary'
|
||||
}`}
|
||||
onClick={() =>
|
||||
setDraft({
|
||||
...draft,
|
||||
readFilter: opt.value,
|
||||
unreadOnly: opt.value === 'unread',
|
||||
})
|
||||
}
|
||||
>
|
||||
{t(`taskFilters.read.${opt.labelKey}`)}
|
||||
</button>
|
||||
))}
|
||||
{projectOptions.length > 0 && (
|
||||
<div>
|
||||
<span className="mb-1.5 block text-[11px] font-semibold text-text-secondary">
|
||||
{t('taskFilters.project')}
|
||||
</span>
|
||||
<Combobox
|
||||
options={projectOptions}
|
||||
value={draft.projectPath ?? ''}
|
||||
onValueChange={(v) => setDraft({ ...draft, projectPath: v || null })}
|
||||
placeholder={t('taskFilters.allProjects')}
|
||||
searchPlaceholder={t('taskFilters.searchProjects')}
|
||||
emptyMessage={t('taskFilters.noProjects')}
|
||||
className="text-[12px]"
|
||||
resetLabel={t('taskFilters.allProjects')}
|
||||
onReset={() => setDraft({ ...draft, projectPath: null })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<span className="mb-1.5 block text-[11px] font-semibold text-text-secondary">
|
||||
{t('taskFilters.comments')}
|
||||
</span>
|
||||
<div className="flex rounded-md border border-[var(--color-border)]">
|
||||
{READ_FILTER_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={`flex-1 px-2 py-1 text-[11px] font-medium transition-colors first:rounded-l-[5px] last:rounded-r-[5px] ${
|
||||
draft.readFilter === opt.value
|
||||
? 'bg-[var(--color-surface-raised)] text-text'
|
||||
: 'text-text-muted hover:text-text-secondary'
|
||||
}`}
|
||||
onClick={() =>
|
||||
setDraft({
|
||||
...draft,
|
||||
readFilter: opt.value,
|
||||
unreadOnly: opt.value === 'unread',
|
||||
})
|
||||
}
|
||||
>
|
||||
{t(`taskFilters.read.${opt.labelKey}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={handleApply}
|
||||
>
|
||||
{t('taskFilters.apply')}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={handleApply}
|
||||
>
|
||||
{t('taskFilters.apply')}
|
||||
</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