RF-20: Frontend performance optimization — memoization for diff components

- Add memo + useMemo to DiffViewer, FileDiffView, DiffHunkView, FileTree
- Add useCallback for event handlers to prevent re-renders
- Fix unused typeIconName variable in TaskCard
- All diff viewer components now properly memoized to prevent unnecessary re-renders when comments change
This commit is contained in:
Brad Groux 2026-01-28 06:45:54 -06:00
parent b03630be5c
commit d7aeeeaeb2
8 changed files with 100 additions and 37 deletions

View file

@ -600,3 +600,5 @@
{"type":"task.status_changed","taskId":"task_20260128_Qh60Ao","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_IMzc-x6hnSTj","timestamp":"2026-01-28T12:29:48.687Z"}
{"type":"task.status_changed","taskId":"task_20260128_Qh60Ao","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_SljUoovW7DVZ","timestamp":"2026-01-28T12:31:58.645Z"}
{"type":"task.status_changed","taskId":"task_20260128_VnO-L8","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_pCeJ0lWaPH9N","timestamp":"2026-01-28T12:32:22.407Z"}
{"type":"task.status_changed","taskId":"task_20260128_VnO-L8","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_gTOhwBV5lGo0","timestamp":"2026-01-28T12:33:34.327Z"}
{"type":"task.status_changed","taskId":"task_20260128_ksQUvs","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_WyBRK_mNhgAl","timestamp":"2026-01-28T12:34:14.344Z"}

View file

@ -1,4 +1,37 @@
[
{
"id": "activity_1769603654345_x6t8uc64f",
"type": "status_changed",
"taskId": "task_20260128_ksQUvs",
"taskTitle": "RF-20: Frontend Performance Optimization",
"details": {
"from": "todo",
"status": "in-progress"
},
"timestamp": "2026-01-28T12:34:14.345Z"
},
{
"id": "activity_1769603622409_5thybkvj9",
"type": "comment_added",
"taskId": "task_20260128_VnO-L8",
"taskTitle": "RF-12: Extract Shared WebSocket Hook",
"details": {
"author": "Veritas",
"preview": "Created useWebSocket.ts with connection lifecycle,..."
},
"timestamp": "2026-01-28T12:33:42.409Z"
},
{
"id": "activity_1769603614328_ci4tk6yfp",
"type": "status_changed",
"taskId": "task_20260128_VnO-L8",
"taskTitle": "RF-12: Extract Shared WebSocket Hook",
"details": {
"from": "in-progress",
"status": "done"
},
"timestamp": "2026-01-28T12:33:34.329Z"
},
{
"id": "activity_1769603542408_0wffse0lg",
"type": "status_changed",

View file

@ -1,4 +1,4 @@
import { useState } from 'react';
import { memo, useState, useMemo, useCallback } from 'react';
import { useDiffSummary } from '@/hooks/useDiff';
import { FileTree } from './diff/FileTree';
import { FileDiffView } from './diff/FileDiffView';
@ -12,12 +12,16 @@ interface DiffViewerProps {
onRemoveComment: (commentId: string) => void;
}
export function DiffViewer({ task, onAddComment, onRemoveComment }: DiffViewerProps) {
export const DiffViewer = memo(function DiffViewer({ task, onAddComment, onRemoveComment }: DiffViewerProps) {
const hasWorktree = !!task.git?.worktreePath;
const { data: summary, isLoading, error } = useDiffSummary(task.id, hasWorktree);
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const comments = task.reviewComments || [];
const comments = useMemo(() => task.reviewComments || [], [task.reviewComments]);
const handleSelectFile = useCallback((file: string | null) => {
setSelectedFile(file);
}, []);
if (!hasWorktree) {
return (
@ -79,7 +83,7 @@ export function DiffViewer({ task, onAddComment, onRemoveComment }: DiffViewerPr
<FileTree
files={summary.files}
selectedFile={selectedFile}
onSelectFile={setSelectedFile}
onSelectFile={handleSelectFile}
comments={comments}
/>
</div>
@ -105,4 +109,4 @@ export function DiffViewer({ task, onAddComment, onRemoveComment }: DiffViewerPr
</div>
</FeatureErrorBoundary>
);
}
});

View file

@ -1,3 +1,4 @@
import { memo, useMemo } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { cn } from '@/lib/utils';
@ -40,7 +41,7 @@ const priorityColors: Record<TaskPriority, string> = {
low: 'bg-slate-500/20 text-slate-400',
};
export function TaskCard({ task, isDragging, onClick, isSelected, isBlocked, blockerTitles }: TaskCardProps) {
export const TaskCard = memo(function TaskCard({ task, isDragging, onClick, isSelected, isBlocked, blockerTitles }: TaskCardProps) {
const { taskTypes, projects, sprints } = useTaskConfig();
const {
attributes,
@ -79,22 +80,34 @@ export function TaskCard({ task, isDragging, onClick, isSelected, isBlocked, blo
const isAgentRunning = task.attempt?.status === 'running';
// Get type info dynamically
const typeConfig = taskTypes.find(t => t.id === task.type);
const typeIconName = typeConfig?.icon || 'Code';
const typeLabel = typeConfig?.label || task.type;
const TypeIconComponent = getTypeIcon(typeIconName);
const typeColor = getTypeColor(taskTypes, task.type);
// Memoize type info
const { typeLabel, TypeIconComponent, typeColor } = useMemo(() => {
const typeConfig = taskTypes.find(t => t.id === task.type);
const iconName = typeConfig?.icon || 'Code';
return {
typeLabel: typeConfig?.label || task.type,
TypeIconComponent: getTypeIcon(iconName),
typeColor: getTypeColor(taskTypes, task.type),
};
}, [taskTypes, task.type]);
// Get project info dynamically
const projectColor = task.project ? getProjectColor(projects, task.project) : 'bg-muted';
const projectLabel = task.project ? getProjectLabel(projects, task.project) : '';
// Memoize project info
const { projectColor, projectLabel } = useMemo(() => ({
projectColor: task.project ? getProjectColor(projects, task.project) : 'bg-muted',
projectLabel: task.project ? getProjectLabel(projects, task.project) : '',
}), [projects, task.project]);
// Subtask progress
const subtasks = task.subtasks || [];
const subtaskTotal = subtasks.length;
const subtaskCompleted = subtasks.filter(s => s.completed).length;
const allSubtasksDone = subtaskTotal > 0 && subtaskCompleted === subtaskTotal;
// Memoize subtask progress
const { subtaskTotal, subtaskCompleted, allSubtasksDone } = useMemo(() => {
const subtasks = task.subtasks || [];
const total = subtasks.length;
const completed = subtasks.filter(s => s.completed).length;
return {
subtaskTotal: total,
subtaskCompleted: completed,
allSubtasksDone: total > 0 && completed === total,
};
}, [task.subtasks]);
return (
<TooltipProvider>
@ -265,4 +278,4 @@ export function TaskCard({ task, isDragging, onClick, isSelected, isBlocked, blo
</Tooltip>
</TooltipProvider>
);
}
});

View file

@ -1,3 +1,4 @@
import { memo } from 'react';
import { DiffLineView } from './DiffLine';
import type { DiffHunk } from '@/lib/api';
import type { ReviewComment } from '@veritas-kanban/shared';
@ -12,7 +13,7 @@ interface DiffHunkProps {
onRemoveComment: (commentId: string) => void;
}
export function DiffHunkView({
export const DiffHunkView = memo(function DiffHunkView({
hunk,
comments,
addingCommentAtLine,
@ -43,4 +44,4 @@ export function DiffHunkView({
</div>
</div>
);
}
});

View file

@ -1,3 +1,4 @@
import { memo, useMemo } from 'react';
import { Plus, MessageSquare } from 'lucide-react';
import { CommentInput, CommentDisplay } from './ReviewComment';
import type { DiffLine } from '@/lib/api';
@ -14,7 +15,7 @@ interface DiffLineProps {
onRemoveComment: (commentId: string) => void;
}
export function DiffLineView({
export const DiffLineView = memo(function DiffLineView({
line,
comments,
addingCommentAtLine,
@ -24,7 +25,10 @@ export function DiffLineView({
onRemoveComment,
}: DiffLineProps) {
const lineNumber = line.newNumber || line.oldNumber;
const lineComments = comments.filter(c => c.line === lineNumber);
const lineComments = useMemo(
() => comments.filter(c => c.line === lineNumber),
[comments, lineNumber]
);
const isAddingHere = addingCommentAtLine === lineNumber;
return (
@ -94,4 +98,4 @@ export function DiffLineView({
)}
</>
);
}
});

View file

@ -1,4 +1,4 @@
import { useState } from 'react';
import { memo, useState, useMemo, useCallback } from 'react';
import { useFileDiff } from '@/hooks/useDiff';
import { DiffHunkView } from './DiffHunk';
import {
@ -29,7 +29,7 @@ interface FileDiffViewProps {
onRemoveComment: (commentId: string) => void;
}
export function FileDiffView({
export const FileDiffView = memo(function FileDiffView({
taskId,
filePath,
comments,
@ -39,9 +39,12 @@ export function FileDiffView({
const { data: diff, isLoading, error } = useFileDiff(taskId, filePath);
const [addingCommentAtLine, setAddingCommentAtLine] = useState<number | null>(null);
const fileComments = comments.filter(c => c.file === filePath);
const fileComments = useMemo(
() => comments.filter(c => c.file === filePath),
[comments, filePath]
);
const handleSubmitComment = (content: string) => {
const handleSubmitComment = useCallback((content: string) => {
if (addingCommentAtLine === null) return;
const comment: ReviewComment = {
@ -54,7 +57,9 @@ export function FileDiffView({
onAddComment(comment);
setAddingCommentAtLine(null);
};
}, [addingCommentAtLine, filePath, onAddComment]);
const handleCancelComment = useCallback(() => setAddingCommentAtLine(null), []);
if (isLoading) {
return (
@ -110,11 +115,11 @@ export function FileDiffView({
addingCommentAtLine={addingCommentAtLine}
onStartAddComment={setAddingCommentAtLine}
onSubmitComment={handleSubmitComment}
onCancelComment={() => setAddingCommentAtLine(null)}
onCancelComment={handleCancelComment}
onRemoveComment={onRemoveComment}
/>
))}
</div>
</div>
);
}
});

View file

@ -1,3 +1,4 @@
import { memo, useMemo } from 'react';
import {
FileCode,
FilePlus,
@ -23,11 +24,11 @@ interface FileTreeProps {
comments: ReviewComment[];
}
export function FileTree({ files, selectedFile, onSelectFile, comments }: FileTreeProps) {
const commentsByFile = comments.reduce((acc, c) => {
export const FileTree = memo(function FileTree({ files, selectedFile, onSelectFile, comments }: FileTreeProps) {
const commentsByFile = useMemo(() => comments.reduce((acc, c) => {
acc[c.file] = (acc[c.file] || 0) + 1;
return acc;
}, {} as Record<string, number>);
}, {} as Record<string, number>), [comments]);
return (
<div className="space-y-1">
@ -61,4 +62,4 @@ export function FileTree({ files, selectedFile, onSelectFile, comments }: FileTr
))}
</div>
);
}
});