perf: memoize MemberBadge, CurrentTaskIndicator, MemberPresenceDot, ReplyQuoteBlock, GlobalTaskList

Prevent unnecessary re-renders on these frequently-rendered components
that appear in MemberCard rows, activity feeds, and the sidebar task list.
This commit is contained in:
Mike 2026-05-02 21:28:17 +05:00
parent 8b30930c04
commit e300d4cbd5
5 changed files with 821 additions and 804 deletions

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { confirm } from '@renderer/components/common/ConfirmDialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
@ -173,13 +173,14 @@ function applyProjectFilter(tasks: GlobalTask[], projectPath: string | null): Gl
return tasks.filter((t) => t.projectPath && normalizePath(t.projectPath) === normalized);
}
export const GlobalTaskList = ({
export const GlobalTaskList = memo(
({
hideHeader = false,
filters: externalFilters,
onFiltersChange: externalOnFiltersChange,
filtersPopoverOpen: externalFiltersPopoverOpen,
onFiltersPopoverOpenChange: externalOnFiltersPopoverOpenChange,
}: GlobalTaskListProps = {}): React.JSX.Element => {
}: GlobalTaskListProps = {}): React.JSX.Element => {
const {
globalTasks,
globalTasksLoading,
@ -209,7 +210,8 @@ export const GlobalTaskList = ({
const filters = externalFilters ?? internalFilters;
const setFilters = externalOnFiltersChange ?? setInternalFilters;
const filtersPopoverOpen = externalFiltersPopoverOpen ?? internalFiltersPopoverOpen;
const setFiltersPopoverOpen = externalOnFiltersPopoverOpenChange ?? setInternalFiltersPopoverOpen;
const setFiltersPopoverOpen =
externalOnFiltersPopoverOpenChange ?? setInternalFiltersPopoverOpen;
const [searchQuery, setSearchQuery] = useState('');
const [groupingMode, setGroupingModeState] = useState<TaskGroupingMode>(loadGroupingMode);
const [sortMode, setSortModeState] = useState<TaskSortMode>(loadSortMode);
@ -416,7 +418,10 @@ export const GlobalTaskList = ({
);
const projectVisibleCountByKey = useMemo(
() =>
syncProjectGroupVisibleCountByKey(projectRequestedVisibleCountByKey, projectGroupVisibility),
syncProjectGroupVisibleCountByKey(
projectRequestedVisibleCountByKey,
projectGroupVisibility
),
[projectRequestedVisibleCountByKey, projectGroupVisibility]
);
@ -573,7 +578,9 @@ export const GlobalTaskList = ({
onClick={() => setGroupingMode(mode)}
className={cn(
'rounded px-1.5 py-0.5 transition-colors',
groupingMode === mode ? 'text-text' : 'text-text-muted hover:text-text-secondary'
groupingMode === mode
? 'text-text'
: 'text-text-muted hover:text-text-secondary'
)}
>
{label}
@ -850,4 +857,5 @@ export const GlobalTaskList = ({
</div>
</div>
);
};
}
);

View file

@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { memo, useMemo } from 'react';
import {
getTeamColorSet,
@ -37,7 +37,8 @@ interface MemberBadgeProps {
* When onClick is provided, both avatar and badge are clickable as one unit.
* Wrapped in MemberHoverCard to show member info on hover.
*/
export const MemberBadge = ({
export const MemberBadge = memo(
({
name,
color,
teamName,
@ -45,7 +46,7 @@ export const MemberBadge = ({
hideAvatar,
onClick,
disableHoverCard,
}: MemberBadgeProps): React.JSX.Element => {
}: MemberBadgeProps): React.JSX.Element => {
const colors = getTeamColorSet(color ?? '');
const { isLight } = useTheme();
const selectedTeamName = useStore((s) => s.selectedTeamName);
@ -114,4 +115,5 @@ export const MemberBadge = ({
{content}
</MemberHoverCard>
);
};
}
);

View file

@ -1,4 +1,4 @@
import { useState } from 'react';
import { memo, useState } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
@ -20,12 +20,13 @@ interface ReplyQuoteBlockProps {
/** Threshold (characters) above which the "more/less" toggle is shown. */
const LONG_QUOTE_THRESHOLD = 200;
export const ReplyQuoteBlock = ({
export const ReplyQuoteBlock = memo(
({
reply,
memberColor,
bodyMaxHeight = 'max-h-56',
replyTaskRefs,
}: ReplyQuoteBlockProps): React.JSX.Element => {
}: ReplyQuoteBlockProps): React.JSX.Element => {
const isLong = reply.originalText.length > LONG_QUOTE_THRESHOLD;
const [expanded, setExpanded] = useState(false);
@ -76,4 +77,5 @@ export const ReplyQuoteBlock = ({
/>
</div>
);
};
}
);

View file

@ -1,3 +1,5 @@
import { memo } from 'react';
import { SyncedLoader2 } from '@renderer/components/ui/SyncedLoader2';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
@ -15,13 +17,14 @@ interface CurrentTaskIndicatorProps {
* Inline indicator showing a spinning loader + "working on" + task label button.
* Shared between MemberCard and MemberHoverCard.
*/
export const CurrentTaskIndicator = ({
export const CurrentTaskIndicator = memo(
({
task,
borderColor,
maxSubjectLength,
activityLabel = 'working on',
onOpenTask,
}: CurrentTaskIndicatorProps): React.JSX.Element => {
}: CurrentTaskIndicatorProps): React.JSX.Element => {
const subjectText =
typeof maxSubjectLength === 'number' &&
maxSubjectLength > 0 &&
@ -53,4 +56,5 @@ export const CurrentTaskIndicator = ({
</button>
</div>
);
};
}
);

View file

@ -1,3 +1,5 @@
import { memo } from 'react';
import { useSyncedAnimationStyle } from '@renderer/hooks/useSyncedAnimationStyle';
import { cn } from '@renderer/lib/utils';
@ -8,10 +10,8 @@ interface MemberPresenceDotProps {
label: string;
}
export const MemberPresenceDot = ({
className,
label,
}: MemberPresenceDotProps): React.JSX.Element => {
export const MemberPresenceDot = memo(
({ className, label }: MemberPresenceDotProps): React.JSX.Element => {
const shouldSyncPulse = className?.includes('animate-pulse') === true;
const syncedPulseStyle = useSyncedAnimationStyle(shouldSyncPulse, PULSE_DURATION_MS);
@ -25,4 +25,5 @@ export const MemberPresenceDot = ({
aria-label={label}
/>
);
};
}
);