From 94c09727f126b7bcf379e8883dfa9dab112e6f23 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sun, 31 May 2026 07:43:41 +0300 Subject: [PATCH] perf(renderer): precompute sidebar task local state --- .../components/sidebar/GlobalTaskList.tsx | 210 +++++++++++------- .../components/sidebar/SidebarTaskItem.tsx | 5 +- .../components/sidebar/GlobalTaskList.test.ts | 10 +- 3 files changed, 139 insertions(+), 86 deletions(-) diff --git a/src/renderer/components/sidebar/GlobalTaskList.tsx b/src/renderer/components/sidebar/GlobalTaskList.tsx index 108ceaf1..9615cdef 100644 --- a/src/renderer/components/sidebar/GlobalTaskList.tsx +++ b/src/renderer/components/sidebar/GlobalTaskList.tsx @@ -203,15 +203,57 @@ function buildTaskTeamSummary(task: GlobalTask): TeamSummary { }; } +function buildTaskLocalPresentationKey(task: GlobalTask): string { + return `${task.teamName}:${task.id}`; +} + +function buildTaskLocalPresentationState( + task: GlobalTask, + pinnedIds: ReadonlySet, + archivedIds: ReadonlySet, + renamedSubjects: ReadonlyMap +): TaskLocalPresentationState { + const key = buildTaskLocalPresentationKey(task); + return { + key, + pinned: pinnedIds.has(key), + archived: archivedIds.has(key), + renamedSubject: renamedSubjects.get(key), + }; +} + +function buildTaskLocalPresentationByTask( + tasks: readonly GlobalTask[], + pinnedIds: ReadonlySet, + archivedIds: ReadonlySet, + renamedSubjects: ReadonlyMap +): WeakMap { + const presentationByTask = new WeakMap(); + for (const task of tasks) { + presentationByTask.set( + task, + buildTaskLocalPresentationState(task, pinnedIds, archivedIds, renamedSubjects) + ); + } + return presentationByTask; +} + type TaskRowAction = (teamName: string, taskId: string) => void; type TaskRowDeleteAction = (teamName: string, taskId: string) => void | Promise; -type TaskDisplaySubjectResolver = (task: GlobalTask) => string | undefined; -type TaskBooleanResolver = (teamName: string, taskId: string) => boolean; type TeamBooleanResolver = (teamName: string) => boolean; type TaskOwnerColorResolver = (task: GlobalTask) => string | null | undefined; type TeamHeaderFormatter = (teamDisplayName: string) => string; type ProjectGroupVisibleCountChange = (projectKey: string, visibleCount: number) => void; type TeamMemberColorInput = Parameters[0][number]; + +interface TaskLocalPresentationState { + key: string; + pinned: boolean; + archived: boolean; + renamedSubject: string | undefined; +} + +type TaskLocalPresentationResolver = (task: GlobalTask) => TaskLocalPresentationState; interface SidebarTeamsDerived { identityKey: string; filterTeams: { teamName: string; displayName: string }[]; @@ -340,6 +382,7 @@ function selectSidebarTeamsDerived(teams: readonly TeamSummary[]): SidebarTeamsD interface GlobalTaskRowProps { task: GlobalTask; + taskLocalKey: string; isPinned: boolean; isArchived: boolean; isNew: boolean; @@ -356,7 +399,7 @@ interface GlobalTaskRowProps { onDelete: TaskRowDeleteAction; onRenameComplete: (teamName: string, taskId: string, newSubject: string) => void; onRenameCancel: () => void; - getDisplaySubject: TaskDisplaySubjectResolver; + displaySubjectOverride?: string; ownerColorName?: string | null; } @@ -395,14 +438,14 @@ function taskSidebarFieldsEqual(prev: GlobalTask, next: GlobalTask): boolean { ); } -function effectiveRenamingKey(task: GlobalTask, renamingKey: string | null): string | null { - const taskRenamingKey = `${task.teamName}:${task.id}`; - return renamingKey === taskRenamingKey ? renamingKey : null; +function effectiveRenamingKey(taskLocalKey: string, renamingKey: string | null): string | null { + return renamingKey === taskLocalKey ? renamingKey : null; } const GlobalTaskRow = memo( function GlobalTaskRow({ task, + taskLocalKey, isPinned, isArchived, isNew, @@ -419,11 +462,10 @@ const GlobalTaskRow = memo( onDelete, onRenameComplete, onRenameCancel, - getDisplaySubject, + displaySubjectOverride, ownerColorName, }: GlobalTaskRowProps): React.JSX.Element { - const taskRenamingKey = `${task.teamName}:${task.id}`; - const effectiveRenamingKey = renamingKey === taskRenamingKey ? renamingKey : null; + const rowRenamingKey = effectiveRenamingKey(taskLocalKey, renamingKey); const handleTogglePin = useCallback(() => { onTogglePin(task.teamName, task.id); @@ -464,10 +506,10 @@ const GlobalTaskRow = memo( showTeamName={showTeamName} isLight={isLight} teamOffline={teamOffline} - renamingKey={effectiveRenamingKey} + renamingKey={rowRenamingKey} onRenameComplete={onRenameComplete} onRenameCancel={onRenameCancel} - getDisplaySubject={getDisplaySubject} + displaySubjectOverride={displaySubjectOverride} ownerColorName={ownerColorName} /> @@ -476,12 +518,13 @@ const GlobalTaskRow = memo( }, (prev, next) => taskSidebarFieldsEqual(prev.task, next.task) && + prev.taskLocalKey === next.taskLocalKey && 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) && + effectiveRenamingKey(prev.taskLocalKey, prev.renamingKey) === + effectiveRenamingKey(next.taskLocalKey, next.renamingKey) && prev.hideTeamName === next.hideTeamName && prev.hideProjectName === next.hideProjectName && prev.showTeamName === next.showTeamName && @@ -493,7 +536,7 @@ const GlobalTaskRow = memo( prev.onDelete === next.onDelete && prev.onRenameComplete === next.onRenameComplete && prev.onRenameCancel === next.onRenameCancel && - prev.getDisplaySubject === next.getDisplaySubject && + prev.displaySubjectOverride === next.displaySubjectOverride && prev.ownerColorName === next.ownerColorName ); @@ -501,8 +544,7 @@ interface TaskRowsProps { tasks: GlobalTask[]; visibleCount?: number; keyPrefix?: string; - isPinned: TaskBooleanResolver; - isArchived: TaskBooleanResolver; + getTaskLocalPresentation: TaskLocalPresentationResolver; isNewTask: (task: GlobalTask) => boolean; isTeamOffline: TeamBooleanResolver; renamingKey: string | null; @@ -521,7 +563,6 @@ interface TaskRowsProps { onDelete: TaskRowDeleteAction; onRenameComplete: (teamName: string, taskId: string, newSubject: string) => void; onRenameCancel: () => void; - getDisplaySubject: TaskDisplaySubjectResolver; getOwnerColorName: TaskOwnerColorResolver; } @@ -529,13 +570,11 @@ type TaskRowsDerivedProps = Pick< TaskRowsProps, | 'tasks' | 'visibleCount' - | 'isPinned' - | 'isArchived' + | 'getTaskLocalPresentation' | 'isNewTask' | 'isTeamOffline' | 'pinnedOverride' | 'archivedOverride' - | 'getDisplaySubject' | 'getOwnerColorName' >; @@ -547,14 +586,6 @@ function getTaskRowsVisibleTasks( : props.tasks; } -function resolveTaskBooleanState( - override: boolean | undefined, - resolver: TaskBooleanResolver, - task: GlobalTask -): boolean { - return override ?? resolver(task.teamName, task.id); -} - function areTaskRowsDerivedValuesEqual( prev: TaskRowsDerivedProps, next: TaskRowsDerivedProps @@ -571,14 +602,16 @@ function areTaskRowsDerivedValuesEqual( if (!prevTask || !nextTask) { return false; } + const prevLocalPresentation = prev.getTaskLocalPresentation(prevTask); + const nextLocalPresentation = next.getTaskLocalPresentation(nextTask); if ( - resolveTaskBooleanState(prev.pinnedOverride, prev.isPinned, prevTask) !== - resolveTaskBooleanState(next.pinnedOverride, next.isPinned, nextTask) || - resolveTaskBooleanState(prev.archivedOverride, prev.isArchived, prevTask) !== - resolveTaskBooleanState(next.archivedOverride, next.isArchived, nextTask) || + (prev.pinnedOverride ?? prevLocalPresentation.pinned) !== + (next.pinnedOverride ?? nextLocalPresentation.pinned) || + (prev.archivedOverride ?? prevLocalPresentation.archived) !== + (next.archivedOverride ?? nextLocalPresentation.archived) || prev.isNewTask(prevTask) !== next.isNewTask(nextTask) || prev.isTeamOffline(prevTask.teamName) !== next.isTeamOffline(nextTask.teamName) || - prev.getDisplaySubject(prevTask) !== next.getDisplaySubject(nextTask) || + prevLocalPresentation.renamedSubject !== nextLocalPresentation.renamedSubject || prev.getOwnerColorName(prevTask) !== next.getOwnerColorName(nextTask) ) { return false; @@ -592,8 +625,7 @@ const TaskRows = memo(function TaskRows({ tasks, visibleCount, keyPrefix = '', - isPinned, - isArchived, + getTaskLocalPresentation, isNewTask, isTeamOffline, renamingKey, @@ -612,7 +644,6 @@ const TaskRows = memo(function TaskRows({ onDelete, onRenameComplete, onRenameCancel, - getDisplaySubject, getOwnerColorName, }: TaskRowsProps): React.JSX.Element { let lastTeam: string | null = null; @@ -621,13 +652,15 @@ const TaskRows = memo(function TaskRows({ return ( <> {visibleTasks.map((task) => { + const taskLocalPresentation = getTaskLocalPresentation(task); const taskKey = `${keyPrefix}${task.teamName}-${task.id}`; const row = ( ); @@ -714,8 +747,7 @@ interface ProjectTaskGroupProps { noProjectGroupColor: ReturnType; showMoreLabel: string; showLessLabel: string; - isPinned: TaskBooleanResolver; - isArchived: TaskBooleanResolver; + getTaskLocalPresentation: TaskLocalPresentationResolver; isNewTask: (task: GlobalTask) => boolean; isTeamOffline: TeamBooleanResolver; renamingKey: string | null; @@ -730,7 +762,6 @@ interface ProjectTaskGroupProps { onDelete: TaskRowDeleteAction; onRenameComplete: (teamName: string, taskId: string, newSubject: string) => void; onRenameCancel: () => void; - getDisplaySubject: TaskDisplaySubjectResolver; getOwnerColorName: TaskOwnerColorResolver; } @@ -742,8 +773,7 @@ const ProjectTaskGroup = memo( noProjectGroupColor, showMoreLabel, showLessLabel, - isPinned, - isArchived, + getTaskLocalPresentation, isNewTask, isTeamOffline, renamingKey, @@ -758,7 +788,6 @@ const ProjectTaskGroup = memo( onDelete, onRenameComplete, onRenameCancel, - getDisplaySubject, getOwnerColorName, }: ProjectTaskGroupProps): React.JSX.Element | null { if (group.tasks.length === 0) return null; @@ -806,8 +835,7 @@ const ProjectTaskGroup = memo( )} @@ -887,21 +914,17 @@ const ProjectTaskGroup = memo( { tasks: prev.group.tasks, visibleCount: prev.visibleCount, - isPinned: prev.isPinned, - isArchived: prev.isArchived, + getTaskLocalPresentation: prev.getTaskLocalPresentation, isNewTask: prev.isNewTask, isTeamOffline: prev.isTeamOffline, - getDisplaySubject: prev.getDisplaySubject, getOwnerColorName: prev.getOwnerColorName, }, { tasks: next.group.tasks, visibleCount: next.visibleCount, - isPinned: next.isPinned, - isArchived: next.isArchived, + getTaskLocalPresentation: next.getTaskLocalPresentation, isNewTask: next.isNewTask, isTeamOffline: next.isTeamOffline, - getDisplaySubject: next.getDisplaySubject, getOwnerColorName: next.getOwnerColorName, } ) @@ -984,6 +1007,39 @@ export const GlobalTaskList = memo(function GlobalTaskList({ const taskLocalState = useTaskLocalState(); const electronMode = isElectronMode(); + const taskLocalPresentationByTask = useMemo( + () => + buildTaskLocalPresentationByTask( + globalTasks, + taskLocalState.pinnedIds, + taskLocalState.archivedIds, + taskLocalState.renamedSubjects + ), + [ + globalTasks, + taskLocalState.pinnedIds, + taskLocalState.archivedIds, + taskLocalState.renamedSubjects, + ] + ); + + const getTaskLocalPresentation = useCallback( + (task: GlobalTask): TaskLocalPresentationState => + taskLocalPresentationByTask.get(task) ?? + buildTaskLocalPresentationState( + task, + taskLocalState.pinnedIds, + taskLocalState.archivedIds, + taskLocalState.renamedSubjects + ), + [ + taskLocalPresentationByTask, + taskLocalState.pinnedIds, + taskLocalState.archivedIds, + taskLocalState.renamedSubjects, + ] + ); + const provisioningState = useMemo( () => ({ currentProvisioningRunIdByTeam, provisioningRuns }), [currentProvisioningRunIdByTeam, provisioningRuns] @@ -1012,14 +1068,14 @@ export const GlobalTaskList = memo(function GlobalTaskList({ isInitialTaskLoadRef.current = false; for (const t of globalTasks) { // eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true. - knownTaskIdsRef.current.add(`${t.teamName}:${t.id}`); + knownTaskIdsRef.current.add(buildTaskLocalPresentationKey(t)); } return new Set(); } const newIds = new Set(); for (const t of globalTasks) { - const key = `${t.teamName}:${t.id}`; + const key = buildTaskLocalPresentationKey(t); // eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true. if (!knownTaskIdsRef.current.has(key)) { newIds.add(key); @@ -1031,7 +1087,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({ }, [globalTasks, globalTasksInitialized]); const isNewTask = useCallback( - (task: GlobalTask): boolean => newTaskIds.has(`${task.teamName}:${task.id}`), + (task: GlobalTask): boolean => newTaskIds.has(buildTaskLocalPresentationKey(task)), [newTaskIds] ); @@ -1189,12 +1245,6 @@ export const GlobalTaskList = memo(function GlobalTaskList({ setRenamingTaskKey(`${teamName}:${taskId}`); }, []); - const getTaskDisplaySubject = useCallback( - (task: GlobalTask): string | undefined => - taskLocalState.getRenamedSubject(task.teamName, task.id), - [taskLocalState] - ); - const handleDeleteTask = useCallback( async (teamName: string, taskId: string): Promise => { const confirmed = await confirm({ @@ -1287,8 +1337,8 @@ export const GlobalTaskList = memo(function GlobalTaskList({ // Resolve project filter from filters state const selectedProjectPath = filters.projectPath; const hasArchivedTasks = useMemo( - () => globalTasks.some((t) => taskLocalState.isArchived(t.teamName, t.id)), - [globalTasks, taskLocalState] + () => globalTasks.some((t) => getTaskLocalPresentation(t).archived), + [globalTasks, getTaskLocalPresentation] ); const effectiveShowArchived = showArchived && hasArchivedTasks; @@ -1311,9 +1361,9 @@ export const GlobalTaskList = memo(function GlobalTaskList({ result = applySearch(result, searchQuery); // Archive filtering if (effectiveShowArchived) { - result = result.filter((t) => taskLocalState.isArchived(t.teamName, t.id)); + result = result.filter((t) => getTaskLocalPresentation(t).archived); } else { - result = result.filter((t) => !taskLocalState.isArchived(t.teamName, t.id)); + result = result.filter((t) => !getTaskLocalPresentation(t).archived); } return result; }, [ @@ -1325,17 +1375,17 @@ export const GlobalTaskList = memo(function GlobalTaskList({ searchQuery, readState, effectiveShowArchived, - taskLocalState, + getTaskLocalPresentation, ]); // Split into pinned and normal (non-pinned) tasks const pinnedTasks = useMemo( - () => filtered.filter((t) => taskLocalState.isPinned(t.teamName, t.id)), - [filtered, taskLocalState] + () => filtered.filter((t) => getTaskLocalPresentation(t).pinned), + [filtered, getTaskLocalPresentation] ); const normalTasks = useMemo( - () => filtered.filter((t) => !taskLocalState.isPinned(t.teamName, t.id)), - [filtered, taskLocalState] + () => filtered.filter((t) => !getTaskLocalPresentation(t).pinned), + [filtered, getTaskLocalPresentation] ); const sortedPinnedTasks = useMemo(() => sortTasksByFreshness(pinnedTasks), [pinnedTasks]); @@ -1508,8 +1558,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({ @@ -1612,8 +1660,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({ {groupingMode === 'none' && ( )} @@ -1646,8 +1692,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({ noProjectGroupColor={noProjectGroupColor} showMoreLabel={t('tasksPanel.showMore')} showLessLabel={t('tasksPanel.showLess')} - isPinned={taskLocalState.isPinned} - isArchived={taskLocalState.isArchived} + getTaskLocalPresentation={getTaskLocalPresentation} isNewTask={isNewTask} isTeamOffline={isTeamOffline} isLight={isLight} @@ -1662,7 +1707,6 @@ export const GlobalTaskList = memo(function GlobalTaskList({ onDelete={handleDeleteTask} onRenameComplete={handleRenameComplete} onRenameCancel={handleRenameCancel} - getDisplaySubject={getTaskDisplaySubject} getOwnerColorName={getOwnerColorName} /> ); @@ -1695,8 +1739,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({ {!isGroupCollapsed && ( )} diff --git a/src/renderer/components/sidebar/SidebarTaskItem.tsx b/src/renderer/components/sidebar/SidebarTaskItem.tsx index 2e1a25fb..110c45ab 100644 --- a/src/renderer/components/sidebar/SidebarTaskItem.tsx +++ b/src/renderer/components/sidebar/SidebarTaskItem.tsx @@ -81,6 +81,8 @@ interface SidebarTaskItemProps { onRenameCancel?: () => void; /** Returns a custom display subject if the task was renamed locally */ getDisplaySubject?: (task: GlobalTask) => string | undefined; + /** Precomputed custom display subject from list parents. */ + displaySubjectOverride?: string; ownerColorName?: string | null; } @@ -95,6 +97,7 @@ const SidebarTaskItemContent = ({ onRenameComplete, onRenameCancel, getDisplaySubject, + displaySubjectOverride, ownerColorName, }: SidebarTaskItemProps & { isLight: boolean }): React.JSX.Element => { const { t } = useAppTranslation('team'); @@ -109,7 +112,7 @@ const SidebarTaskItemContent = ({ const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments); const isRenaming = renamingKey === `${task.teamName}:${task.id}`; - const displaySubject = getDisplaySubject?.(task) ?? task.subject; + const displaySubject = displaySubjectOverride ?? getDisplaySubject?.(task) ?? task.subject; const [editValue, setEditValue] = useState(displaySubject); const inputRef = useRef(null); // Focus input when rename starts diff --git a/test/renderer/components/sidebar/GlobalTaskList.test.ts b/test/renderer/components/sidebar/GlobalTaskList.test.ts index 0a1e1a6b..ac435fc9 100644 --- a/test/renderer/components/sidebar/GlobalTaskList.test.ts +++ b/test/renderer/components/sidebar/GlobalTaskList.test.ts @@ -37,6 +37,9 @@ const storeState = {} as StoreState; const toggleCollapsedGroup = vi.fn(); const sidebarTaskItemRenderSpy = vi.hoisted(() => vi.fn()); const taskLocalState = { + pinnedIds: new Set(), + archivedIds: new Set(), + renamedSubjects: new Map(), isPinned: vi.fn(() => false), isArchived: vi.fn(() => false), getRenamedSubject: vi.fn(() => undefined), @@ -100,10 +103,12 @@ vi.mock('../../../../src/renderer/components/sidebar/SidebarTaskItem', () => ({ task, hideProjectName, teamOffline, + displaySubjectOverride, }: { task: GlobalTask; hideProjectName?: boolean; teamOffline?: boolean; + displaySubjectOverride?: string; }) => { sidebarTaskItemRenderSpy(task.id); return React.createElement( @@ -113,7 +118,7 @@ vi.mock('../../../../src/renderer/components/sidebar/SidebarTaskItem', () => ({ 'data-hide-project-name': hideProjectName ? 'true' : 'false', 'data-team-offline': teamOffline ? 'true' : 'false', }, - task.subject + displaySubjectOverride ?? task.subject ); }, })); @@ -233,6 +238,9 @@ describe('GlobalTaskList project grouping', () => { storeState.currentProvisioningRunIdByTeam = {}; storeState.leadActivityByTeam = {}; toggleCollapsedGroup.mockReset(); + taskLocalState.pinnedIds.clear(); + taskLocalState.archivedIds.clear(); + taskLocalState.renamedSubjects.clear(); taskLocalState.isPinned.mockClear(); taskLocalState.isArchived.mockClear(); taskLocalState.getRenamedSubject.mockClear();