From d7aeeeaeb2ea5d205d970242263b429b7a9c5a61 Mon Sep 17 00:00:00 2001 From: Brad Groux Date: Wed, 28 Jan 2026 06:45:54 -0600 Subject: [PATCH] =?UTF-8?q?RF-20:=20Frontend=20performance=20optimization?= =?UTF-8?q?=20=E2=80=94=20memoization=20for=20diff=20components?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../telemetry/events-2026-01-28.ndjson | 2 + server/.veritas-kanban/activity.json | 33 ++++++++++++++ web/src/components/task/DiffViewer.tsx | 14 +++--- web/src/components/task/TaskCard.tsx | 45 ++++++++++++------- web/src/components/task/diff/DiffHunk.tsx | 5 ++- web/src/components/task/diff/DiffLine.tsx | 10 +++-- web/src/components/task/diff/FileDiffView.tsx | 19 +++++--- web/src/components/task/diff/FileTree.tsx | 9 ++-- 8 files changed, 100 insertions(+), 37 deletions(-) diff --git a/.veritas-kanban/telemetry/events-2026-01-28.ndjson b/.veritas-kanban/telemetry/events-2026-01-28.ndjson index 6d05a848..d2303e0f 100644 --- a/.veritas-kanban/telemetry/events-2026-01-28.ndjson +++ b/.veritas-kanban/telemetry/events-2026-01-28.ndjson @@ -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"} diff --git a/server/.veritas-kanban/activity.json b/server/.veritas-kanban/activity.json index bb0baf05..05cb528d 100644 --- a/server/.veritas-kanban/activity.json +++ b/server/.veritas-kanban/activity.json @@ -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", diff --git a/web/src/components/task/DiffViewer.tsx b/web/src/components/task/DiffViewer.tsx index f6ac0b4f..9957bc81 100644 --- a/web/src/components/task/DiffViewer.tsx +++ b/web/src/components/task/DiffViewer.tsx @@ -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(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 @@ -105,4 +109,4 @@ export function DiffViewer({ task, onAddComment, onRemoveComment }: DiffViewerPr ); -} +}); diff --git a/web/src/components/task/TaskCard.tsx b/web/src/components/task/TaskCard.tsx index 9f09a9c9..f1416276 100644 --- a/web/src/components/task/TaskCard.tsx +++ b/web/src/components/task/TaskCard.tsx @@ -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 = { 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 ( @@ -265,4 +278,4 @@ export function TaskCard({ task, isDragging, onClick, isSelected, isBlocked, blo ); -} +}); diff --git a/web/src/components/task/diff/DiffHunk.tsx b/web/src/components/task/diff/DiffHunk.tsx index 8b0ef052..b7835ffd 100644 --- a/web/src/components/task/diff/DiffHunk.tsx +++ b/web/src/components/task/diff/DiffHunk.tsx @@ -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({ ); -} +}); diff --git a/web/src/components/task/diff/DiffLine.tsx b/web/src/components/task/diff/DiffLine.tsx index 8b46e464..f88b41e6 100644 --- a/web/src/components/task/diff/DiffLine.tsx +++ b/web/src/components/task/diff/DiffLine.tsx @@ -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({ )} ); -} +}); diff --git a/web/src/components/task/diff/FileDiffView.tsx b/web/src/components/task/diff/FileDiffView.tsx index e9cde728..4227d0ba 100644 --- a/web/src/components/task/diff/FileDiffView.tsx +++ b/web/src/components/task/diff/FileDiffView.tsx @@ -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(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} /> ))} ); -} +}); diff --git a/web/src/components/task/diff/FileTree.tsx b/web/src/components/task/diff/FileTree.tsx index 027cd56d..fdd56213 100644 --- a/web/src/components/task/diff/FileTree.tsx +++ b/web/src/components/task/diff/FileTree.tsx @@ -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); + }, {} as Record), [comments]); return (
@@ -61,4 +62,4 @@ export function FileTree({ files, selectedFile, onSelectFile, comments }: FileTr ))}
); -} +});