perf(renderer): wrap heavy view components in React.memo

TeamDetailView (3166L), TeamListView (1180L), DateGroupedSessions (1117L),
and MarkdownViewer (1198L) were re-rendering on every parent render cycle.
Wrapping them in memo() prevents cascading re-renders when their props and
store subscriptions have not changed, targeting VSCode-level UI responsiveness.
This commit is contained in:
Mike 2026-05-02 20:29:19 +05:00
parent 7609c548c5
commit f764af17d8
4 changed files with 2344 additions and 2314 deletions

View file

@ -946,7 +946,8 @@ export const CompactMarkdownPreview: React.FC<CompactMarkdownPreviewProps> = Rea
} }
); );
export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({ export const MarkdownViewer: React.FC<MarkdownViewerProps> = React.memo(
({
content, content,
maxHeight = 'max-h-96', maxHeight = 'max-h-96',
className = '', className = '',
@ -958,7 +959,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
baseDir, baseDir,
teamColorByName: providedTeamColorByName, teamColorByName: providedTeamColorByName,
onTeamClick: providedOnTeamClick, onTeamClick: providedOnTeamClick,
}) => { }) => {
const [showRaw, setShowRaw] = React.useState(false); const [showRaw, setShowRaw] = React.useState(false);
const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS); const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS);
const { isLight } = useTheme(); const { isLight } = useTheme();
@ -1062,8 +1063,8 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
{isTooLarge && ( {isTooLarge && (
<div className="px-3 pb-2 text-[11px]" style={{ color: COLOR_TEXT_MUTED }}> <div className="px-3 pb-2 text-[11px]" style={{ color: COLOR_TEXT_MUTED }}>
Content is very large ({content.length.toLocaleString()} chars). Showing raw preview to Content is very large ({content.length.toLocaleString()} chars). Showing raw preview
keep the UI responsive. to keep the UI responsive.
</div> </div>
)} )}
@ -1195,4 +1196,5 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
</div> </div>
</div> </div>
); );
}; }
);

View file

@ -4,7 +4,7 @@
* Supports multi-select with bulk actions and hidden session filtering. * Supports multi-select with bulk actions and hidden session filtering.
*/ */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { recordRecentProjectOpenPaths } from '@features/recent-projects/renderer'; import { recordRecentProjectOpenPaths } from '@features/recent-projects/renderer';
@ -184,7 +184,7 @@ function matchesSessionSearch(session: Session, query: string): boolean {
return haystack.includes(query); return haystack.includes(query);
} }
export const DateGroupedSessions = (): React.JSX.Element => { export const DateGroupedSessions = memo((): React.JSX.Element => {
const { const {
sessions, sessions,
selectedSessionId, selectedSessionId,
@ -1114,4 +1114,4 @@ export const DateGroupedSessions = (): React.JSX.Element => {
</div> </div>
</div> </div>
); );
}; });

View file

@ -947,10 +947,8 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
); );
}); });
export const TeamDetailView = ({ export const TeamDetailView = memo(
teamName, ({ teamName, isPaneFocused = false }: TeamDetailViewProps): React.JSX.Element => {
isPaneFocused = false,
}: TeamDetailViewProps): React.JSX.Element => {
const { isLight } = useTheme(); const { isLight } = useTheme();
const [requestChangesTaskId, setRequestChangesTaskId] = useState<string | null>(null); const [requestChangesTaskId, setRequestChangesTaskId] = useState<string | null>(null);
const [selectedTask, setSelectedTask] = useState<TeamTaskWithKanban | null>(null); const [selectedTask, setSelectedTask] = useState<TeamTaskWithKanban | null>(null);
@ -959,8 +957,8 @@ export const TeamDetailView = ({
initialTab?: MemberDetailTab; initialTab?: MemberDetailTab;
initialActivityFilter?: MemberActivityFilter; initialActivityFilter?: MemberActivityFilter;
} | null>(null); } | null>(null);
const [pendingRepliesByMember, setPendingRepliesByMember] = useState<Record<string, number>>(() => const [pendingRepliesByMember, setPendingRepliesByMember] = useState<Record<string, number>>(
getTeamPendingRepliesState(teamName) () => getTeamPendingRepliesState(teamName)
); );
const [createTaskDialog, setCreateTaskDialog] = useState<CreateTaskDialogState>({ const [createTaskDialog, setCreateTaskDialog] = useState<CreateTaskDialogState>({
open: false, open: false,
@ -1192,7 +1190,9 @@ export const TeamDetailView = ({
const [stoppingTeam, setStoppingTeam] = useState(false); const [stoppingTeam, setStoppingTeam] = useState(false);
const [trashOpen, setTrashOpen] = useState(false); const [trashOpen, setTrashOpen] = useState(false);
const [sendDialogRecipient, setSendDialogRecipient] = useState<string | undefined>(undefined); const [sendDialogRecipient, setSendDialogRecipient] = useState<string | undefined>(undefined);
const [sendDialogDefaultText, setSendDialogDefaultText] = useState<string | undefined>(undefined); const [sendDialogDefaultText, setSendDialogDefaultText] = useState<string | undefined>(
undefined
);
const [sendDialogDefaultChip, setSendDialogDefaultChip] = useState<InlineChip | undefined>( const [sendDialogDefaultChip, setSendDialogDefaultChip] = useState<InlineChip | undefined>(
undefined undefined
); );
@ -1353,7 +1353,8 @@ export const TeamDetailView = ({
maxWidth: 600, maxWidth: 600,
side: 'left', side: 'left',
}); });
const { isResizing: isLogsPanelResizing, handleProps: logsPanelHandleProps } = useResizablePanel({ const { isResizing: isLogsPanelResizing, handleProps: logsPanelHandleProps } =
useResizablePanel({
height: sidebarLogsHeight, height: sidebarLogsHeight,
onHeightChange: setSidebarLogsHeight, onHeightChange: setSidebarLogsHeight,
minHeight: 120, minHeight: 120,
@ -1703,11 +1704,17 @@ export const TeamDetailView = ({
}, [activeMembers, data?.config.members, launchParams?.providerId]); }, [activeMembers, data?.config.members, launchParams?.providerId]);
const shouldShowLeadContextUi = canShowLeadContextUi(leadProviderId); const shouldShowLeadContextUi = canShowLeadContextUi(leadProviderId);
const taskMap = useMemo(() => new Map((data?.tasks ?? []).map((t) => [t.id, t])), [data?.tasks]); const taskMap = useMemo(
() => new Map((data?.tasks ?? []).map((t) => [t.id, t])),
[data?.tasks]
);
const taskMapRef = useRef(taskMap); const taskMapRef = useRef(taskMap);
taskMapRef.current = taskMap; taskMapRef.current = taskMap;
const memberTaskCounts = useMemo(() => buildTaskCountsByOwner(data?.tasks ?? []), [data?.tasks]); const memberTaskCounts = useMemo(
() => buildTaskCountsByOwner(data?.tasks ?? []),
[data?.tasks]
);
const openCreateTaskDialog = useCallback( const openCreateTaskDialog = useCallback(
(subject = '', description = '', owner = '', startImmediately?: boolean): void => { (subject = '', description = '', owner = '', startImmediately?: boolean): void => {
@ -2011,7 +2018,13 @@ export const TeamDetailView = ({
startImmediately, startImmediately,
}); });
if (prompt && owner && data?.isAlive && !isTeamProvisioning && startImmediately !== false) { if (
prompt &&
owner &&
data?.isAlive &&
!isTeamProvisioning &&
startImmediately !== false
) {
const msg = `New task assigned to ${owner}: "${subject}". Instructions:\n${prompt}`; const msg = `New task assigned to ${owner}: "${subject}". Instructions:\n${prompt}`;
try { try {
await api.teams.processSend(teamName, msg); await api.teams.processSend(teamName, msg);
@ -2351,7 +2364,10 @@ export const TeamDetailView = ({
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-3 gap-y-0.5"> <div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-3 gap-y-0.5">
{data.config.projectPath && ( {data.config.projectPath && (
<span className="flex items-center gap-1 text-[11px] text-[var(--color-text-secondary)]"> <span className="flex items-center gap-1 text-[11px] text-[var(--color-text-secondary)]">
<FolderOpen size={11} className="shrink-0 text-[var(--color-text-muted)]" /> <FolderOpen
size={11}
className="shrink-0 text-[var(--color-text-muted)]"
/>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span className="max-w-60 truncate font-mono"> <span className="max-w-60 truncate font-mono">
@ -2386,7 +2402,10 @@ export const TeamDetailView = ({
className="flex items-center gap-1 text-[11px] text-[var(--color-text-secondary)]" className="flex items-center gap-1 text-[11px] text-[var(--color-text-secondary)]"
title={leadBranch} title={leadBranch}
> >
<GitBranch size={11} className="shrink-0 text-[var(--color-text-muted)]" /> <GitBranch
size={11}
className="shrink-0 text-[var(--color-text-muted)]"
/>
<span className="max-w-32 truncate">{leadBranch}</span> <span className="max-w-32 truncate">{leadBranch}</span>
</span> </span>
)} )}
@ -2415,7 +2434,9 @@ export const TeamDetailView = ({
</div> </div>
{(() => { {(() => {
const currentPath = data.config.projectPath; const currentPath = data.config.projectPath;
const history = data.config.projectPathHistory?.filter((p) => p !== currentPath); const history = data.config.projectPathHistory?.filter(
(p) => p !== currentPath
);
if (!history || history.length === 0) return null; if (!history || history.length === 0) return null;
return ( return (
<div <div
@ -2508,7 +2529,9 @@ export const TeamDetailView = ({
sessionsError={sessionsError} sessionsError={sessionsError}
leadSessionId={data.config.leadSessionId} leadSessionId={data.config.leadSessionId}
selectedSessionId={kanbanFilter.sessionId} selectedSessionId={kanbanFilter.sessionId}
onSelectSession={(id) => setKanbanFilter((prev) => ({ ...prev, sessionId: id }))} onSelectSession={(id) =>
setKanbanFilter((prev) => ({ ...prev, sessionId: id }))
}
projectPath={data.config.projectPath} projectPath={data.config.projectPath}
/> />
</CollapsibleTeamSection> </CollapsibleTeamSection>
@ -2910,7 +2933,11 @@ export const TeamDetailView = ({
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button variant="ghost" size="sm" onClick={() => setRemoveMemberConfirm(null)}> <Button
variant="ghost"
size="sm"
onClick={() => setRemoveMemberConfirm(null)}
>
Cancel Cancel
</Button> </Button>
<Button <Button
@ -2934,8 +2961,8 @@ export const TeamDetailView = ({
<DialogHeader> <DialogHeader>
<DialogTitle>Delete team</DialogTitle> <DialogTitle>Delete team</DialogTitle>
<DialogDescription> <DialogDescription>
Delete team &ldquo;{data.config.name}&rdquo;? This action is irreversible. All Delete team &ldquo;{data.config.name}&rdquo;? This action is irreversible.
team data and tasks will be deleted. All team data and tasks will be deleted.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
@ -3163,4 +3190,5 @@ export const TeamDetailView = ({
{renderBody()} {renderBody()}
</> </>
); );
}; }
);

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { recordRecentProjectOpenPaths } from '@features/recent-projects/renderer'; import { recordRecentProjectOpenPaths } from '@features/recent-projects/renderer';
import { api, isElectronMode } from '@renderer/api'; import { api, isElectronMode } from '@renderer/api';
@ -233,7 +233,7 @@ const StatusBadge = ({ status }: { status: TeamStatus }): React.JSX.Element => {
} }
}; };
export const TeamListView = (): React.JSX.Element => { export const TeamListView = memo((): React.JSX.Element => {
const { isLight } = useTheme(); const { isLight } = useTheme();
const electronMode = isElectronMode(); const electronMode = isElectronMode();
const [showCreateDialog, setShowCreateDialog] = useState(false); const [showCreateDialog, setShowCreateDialog] = useState(false);
@ -1177,4 +1177,4 @@ export const TeamListView = (): React.JSX.Element => {
</div> </div>
</TooltipProvider> </TooltipProvider>
); );
}; });