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:
parent
2bda324e1a
commit
8b30930c04
5 changed files with 1263 additions and 1238 deletions
|
|
@ -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,
|
||||||
|
|
@ -77,7 +78,7 @@ export const SidebarTaskItem = ({
|
||||||
onRenameComplete,
|
onRenameComplete,
|
||||||
onRenameCancel,
|
onRenameCancel,
|
||||||
getDisplaySubject,
|
getDisplaySubject,
|
||||||
}: SidebarTaskItemProps): React.JSX.Element => {
|
}: SidebarTaskItemProps): React.JSX.Element => {
|
||||||
const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail);
|
const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail);
|
||||||
const teamMembers = useStore(useShallow((s) => s.teamByName[task.teamName]?.members));
|
const teamMembers = useStore(useShallow((s) => s.teamByName[task.teamName]?.members));
|
||||||
const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments);
|
const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments);
|
||||||
|
|
@ -283,4 +284,5 @@ export const SidebarTaskItem = ({
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
@ -338,7 +339,7 @@ export const KanbanBoard = ({
|
||||||
onDeleteTask,
|
onDeleteTask,
|
||||||
deletedTaskCount,
|
deletedTaskCount,
|
||||||
onOpenTrash,
|
onOpenTrash,
|
||||||
}: KanbanBoardProps): React.JSX.Element => {
|
}: KanbanBoardProps): React.JSX.Element => {
|
||||||
const boardRef = useRef<HTMLDivElement>(null);
|
const boardRef = useRef<HTMLDivElement>(null);
|
||||||
const scrollRestoreTimeoutsRef = useRef<number[]>([]);
|
const scrollRestoreTimeoutsRef = useRef<number[]>([]);
|
||||||
const [viewMode, setViewMode] = useState<KanbanViewMode>('grid');
|
const [viewMode, setViewMode] = useState<KanbanViewMode>('grid');
|
||||||
|
|
@ -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;
|
||||||
};
|
}
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,13 +387,14 @@ const LoadedKanbanGridLayout = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const KanbanGridLayout = ({
|
export const KanbanGridLayout = memo(
|
||||||
|
({
|
||||||
columns,
|
columns,
|
||||||
allColumnIds,
|
allColumnIds,
|
||||||
primaryColumnId,
|
primaryColumnId,
|
||||||
onPrimaryColumnWidthChange,
|
onPrimaryColumnWidthChange,
|
||||||
skeletonDelayMs = SKELETON_HIDE_DELAY_MS,
|
skeletonDelayMs = SKELETON_HIDE_DELAY_MS,
|
||||||
}: KanbanGridLayoutProps): React.JSX.Element => {
|
}: KanbanGridLayoutProps): React.JSX.Element => {
|
||||||
const visibleColumnIds = useMemo(() => columns.map((column) => column.id), [columns]);
|
const visibleColumnIds = useMemo(() => columns.map((column) => column.id), [columns]);
|
||||||
const { visibleItems, applyVisibleItems, isLoaded } = usePersistedGridLayout({
|
const { visibleItems, applyVisibleItems, isLoaded } = usePersistedGridLayout({
|
||||||
scopeKey: GRID_SCOPE_KEY,
|
scopeKey: GRID_SCOPE_KEY,
|
||||||
|
|
@ -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. */
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
@ -119,7 +120,7 @@ export const MemberCard = ({
|
||||||
onAssignTask,
|
onAssignTask,
|
||||||
onRestartMember,
|
onRestartMember,
|
||||||
onSkipMemberForLaunch,
|
onSkipMemberForLaunch,
|
||||||
}: MemberCardProps): React.JSX.Element => {
|
}: MemberCardProps): React.JSX.Element => {
|
||||||
// NOTE: lead context display disabled — usage formula is inaccurate
|
// NOTE: lead context display disabled — usage formula is inaccurate
|
||||||
// const teamName = useStore((s) => s.selectedTeamName);
|
// const teamName = useStore((s) => s.selectedTeamName);
|
||||||
// const leadContext = useStore((s) =>
|
// const leadContext = useStore((s) =>
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue