perf: memoize KanbanBoard, KanbanGridLayout, MemberCard, TaskRow, SidebarTaskItem

Wrap five hot-path components in React.memo to prevent unnecessary
re-renders when parent state changes don't affect their props.
This commit is contained in:
Mike 2026-05-02 21:20:25 +05:00
parent 2bda324e1a
commit 8b30930c04
5 changed files with 1263 additions and 1238 deletions

View file

@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors'; import { getTeamColorSet } from '@renderer/constants/teamColors';
@ -69,7 +69,8 @@ interface SidebarTaskItemProps {
getDisplaySubject?: (task: GlobalTask) => string | undefined; getDisplaySubject?: (task: GlobalTask) => string | undefined;
} }
export const SidebarTaskItem = ({ export const SidebarTaskItem = memo(
({
task, task,
hideTeamName, hideTeamName,
showTeamName, showTeamName,
@ -283,4 +284,5 @@ export const SidebarTaskItem = ({
)} )}
</button> </button>
); );
}; }
);

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { DndContext, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; import { DndContext, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { arrayMove } from '@dnd-kit/sortable'; import { arrayMove } from '@dnd-kit/sortable';
@ -311,7 +311,8 @@ const SortableKanbanTaskCard = ({
); );
}; };
export const KanbanBoard = ({ export const KanbanBoard = memo(
({
tasks, tasks,
teamName, teamName,
kanbanState, kanbanState,
@ -532,7 +533,9 @@ export const KanbanBoard = ({
const columnModeSearchWidth = const columnModeSearchWidth =
primaryVisibleColumnId != null ? (columnWidths.get(primaryVisibleColumnId) ?? 256) : 256; primaryVisibleColumnId != null ? (columnWidths.get(primaryVisibleColumnId) ?? 256) : 256;
const toolbarLeftWidth = const toolbarLeftWidth =
viewMode === 'grid' ? (gridPrimaryColumnWidth ?? columnModeSearchWidth) : columnModeSearchWidth; viewMode === 'grid'
? (gridPrimaryColumnWidth ?? columnModeSearchWidth)
: columnModeSearchWidth;
const clearScheduledScrollRestore = useCallback(() => { const clearScheduledScrollRestore = useCallback(() => {
for (const timeoutId of scrollRestoreTimeoutsRef.current) { for (const timeoutId of scrollRestoreTimeoutsRef.current) {
@ -697,7 +700,12 @@ export const KanbanBoard = ({
showAddButton: columnSupportsAddButton(column.id, onAddTask), showAddButton: columnSupportsAddButton(column.id, onAddTask),
skeletonCards: columnTasks.map((task) => ({ skeletonCards: columnTasks.map((task) => ({
key: task.id, key: task.id,
height: estimateGridSkeletonCardHeight(task, column.id, kanbanState, hasReviewers), height: estimateGridSkeletonCardHeight(
task,
column.id,
kanbanState,
hasReviewers
),
})), })),
}; };
})} })}
@ -752,4 +760,5 @@ export const KanbanBoard = ({
} }
return boardContent; return boardContent;
}; }
);

View file

@ -1,5 +1,5 @@
/* eslint-disable tailwindcss/no-custom-classname -- this adapter needs stable non-Tailwind class hooks for react-grid-layout handles. */ /* eslint-disable tailwindcss/no-custom-classname -- this adapter needs stable non-Tailwind class hooks for react-grid-layout handles. */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import ReactGridLayout, { WidthProvider } from 'react-grid-layout/legacy'; import ReactGridLayout, { WidthProvider } from 'react-grid-layout/legacy';
import { usePersistedGridLayout } from '@renderer/hooks/usePersistedGridLayout'; import { usePersistedGridLayout } from '@renderer/hooks/usePersistedGridLayout';
@ -387,7 +387,8 @@ const LoadedKanbanGridLayout = ({
); );
}; };
export const KanbanGridLayout = ({ export const KanbanGridLayout = memo(
({
columns, columns,
allColumnIds, allColumnIds,
primaryColumnId, primaryColumnId,
@ -454,7 +455,8 @@ export const KanbanGridLayout = ({
) : null} ) : null}
</div> </div>
); );
}; }
);
export { SKELETON_HIDE_DELAY_MS, SKELETON_HIDE_DELAY_MS_ON_MODE_SWITCH }; export { SKELETON_HIDE_DELAY_MS, SKELETON_HIDE_DELAY_MS_ON_MODE_SWITCH };
/* eslint-enable tailwindcss/no-custom-classname -- stable class hooks remain scoped to this file. */ /* eslint-enable tailwindcss/no-custom-classname -- stable class hooks remain scoped to this file. */

View file

@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'; import { memo, useMemo, useState } from 'react';
import { Badge } from '@renderer/components/ui/badge'; import { Badge } from '@renderer/components/ui/badge';
import { SyncedLoader2 } from '@renderer/components/ui/SyncedLoader2'; import { SyncedLoader2 } from '@renderer/components/ui/SyncedLoader2';
@ -91,7 +91,8 @@ function splitRuntimeSummaryMemory(runtimeSummary: string | undefined): {
}; };
} }
export const MemberCard = ({ export const MemberCard = memo(
({
member, member,
memberColor, memberColor,
runtimeSummary, runtimeSummary,
@ -277,7 +278,9 @@ export const MemberCard = ({
const restartActionErrorFallback = canRelaunchOpenCode const restartActionErrorFallback = canRelaunchOpenCode
? 'Failed to relaunch OpenCode teammate' ? 'Failed to relaunch OpenCode teammate'
: 'Failed to retry teammate'; : 'Failed to retry teammate';
const handleRestartMember = async (event: React.MouseEvent<HTMLButtonElement>): Promise<void> => { const handleRestartMember = async (
event: React.MouseEvent<HTMLButtonElement>
): Promise<void> => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (!onRestartMember || retryingLaunch) { if (!onRestartMember || retryingLaunch) {
@ -470,7 +473,9 @@ export const MemberCard = ({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
type="button" type="button"
aria-label={retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel} aria-label={
retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel
}
className="rounded p-1 text-amber-300 transition-colors hover:bg-amber-500/10 hover:text-amber-200 disabled:cursor-not-allowed disabled:opacity-60" className="rounded p-1 text-amber-300 transition-colors hover:bg-amber-500/10 hover:text-amber-200 disabled:cursor-not-allowed disabled:opacity-60"
disabled={retryingLaunch} disabled={retryingLaunch}
onClick={handleRestartMember} onClick={handleRestartMember}
@ -539,7 +544,9 @@ export const MemberCard = ({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
type="button" type="button"
aria-label={retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel} aria-label={
retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel
}
className="rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200 disabled:cursor-not-allowed disabled:opacity-60" className="rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200 disabled:cursor-not-allowed disabled:opacity-60"
disabled={retryingLaunch || skippingLaunch} disabled={retryingLaunch || skippingLaunch}
onClick={handleRestartMember} onClick={handleRestartMember}
@ -581,7 +588,9 @@ export const MemberCard = ({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
type="button" type="button"
aria-label={retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel} aria-label={
retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel
}
className="rounded p-1 text-zinc-300 transition-colors hover:bg-zinc-500/10 hover:text-zinc-100 disabled:cursor-not-allowed disabled:opacity-60" className="rounded p-1 text-zinc-300 transition-colors hover:bg-zinc-500/10 hover:text-zinc-100 disabled:cursor-not-allowed disabled:opacity-60"
disabled={retryingLaunch} disabled={retryingLaunch}
onClick={handleRestartMember} onClick={handleRestartMember}
@ -709,4 +718,5 @@ export const MemberCard = ({
</div> </div>
</div> </div>
); );
}; }
);

View file

@ -1,3 +1,5 @@
import { memo } from 'react';
import { import {
KANBAN_COLUMN_DISPLAY, KANBAN_COLUMN_DISPLAY,
REVIEW_STATE_DISPLAY, REVIEW_STATE_DISPLAY,
@ -12,7 +14,7 @@ interface TaskRowProps {
task: TeamTaskWithKanban; task: TeamTaskWithKanban;
} }
export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => { export const TaskRow = memo(({ task }: TaskRowProps): React.JSX.Element => {
const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? []; const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? [];
const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? []; const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? [];
const kanbanColumn = getTaskKanbanColumn(task); const kanbanColumn = getTaskKanbanColumn(task);
@ -62,4 +64,4 @@ export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => {
</td> </td>
</tr> </tr>
); );
}; });