From 1e4436912efef78f166119388c235b5c687da4f6 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 22 Mar 2026 10:37:29 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=20Bolt:=20[performance=20improvement]?= =?UTF-8?q?=20Optimize=20React=20re-renders=20in=20Log=20and=20Progress=20?= =?UTF-8?q?panels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented `React.memo` for individual list items in `LogPanel` and `ProgressPanel` to prevent full list re-renders on high-frequency WebSocket and polling updates. - Added stable generated client IDs for logs to replace index keys. - Implemented custom equality functions in `DownloadItem` to handle object reference changes caused by the 5-second API polling fallback. - Addressed TypeScript typing issues by omitting explicit `React.FC` on memoized components. Co-authored-by: dzp5103 <214723817+dzp5103@users.noreply.github.com> --- .jules/bolt.md | 5 + frontend/src/components/LogPanel.tsx | 72 ++++--- frontend/src/components/ProgressPanel.tsx | 219 ++++++++++++---------- 3 files changed, 167 insertions(+), 129 deletions(-) create mode 100644 .jules/bolt.md diff --git a/.jules/bolt.md b/.jules/bolt.md new file mode 100644 index 0000000..e9a959f --- /dev/null +++ b/.jules/bolt.md @@ -0,0 +1,5 @@ +## 2024-03-22 - React Re-rendering Bottleneck with Frequent WebSocket Updates +**Learning:** Extracting list items into `React.memo` components is not enough when lists are updated frequently via polling/WebSockets. For `LogPanel`, new logs caused full list re-renders because `index` was used as the key. For `ProgressPanel`, the 5-second API polling fallback replaced the entire `downloads` array, generating new object references that bypassed standard `React.memo` shallow comparisons. +**Action:** When memoizing items fed by WebSockets or polling: +1. Ensure items have stable IDs (e.g. generating a fallback client ID `Date.now() + Math.random().toString()` when `crypto.randomUUID` is unavailable). +2. For objects that are fully replaced by API polling, implement a custom equality function for `React.memo` that explicitly compares primitive fields (`status`, `progress`, `speed`, etc.) instead of relying on object references. \ No newline at end of file diff --git a/frontend/src/components/LogPanel.tsx b/frontend/src/components/LogPanel.tsx index f963077..62ff13b 100644 --- a/frontend/src/components/LogPanel.tsx +++ b/frontend/src/components/LogPanel.tsx @@ -3,8 +3,45 @@ import { Terminal, Trash2 } from 'lucide-react' import { logsWS } from '@/services/websocket' import type { LogMessage } from '@/types/api' +// Extended log message interface with stable client ID +interface ClientLogMessage extends LogMessage { + id: string; +} + +// ⚡ Bolt: Memoized log item to prevent re-rendering entire log history on every new log +const LogItem = React.memo(({ log }: { log: ClientLogMessage }) => { + const getLogColor = (level: string): string => { + switch (level.toUpperCase()) { + case 'ERROR': return '#f44336' + case 'WARNING': return '#ff9800' + case 'INFO': return '#2196F3' + case 'SUCCESS': return '#4caf50' + default: return '#999' + } + } + + return ( +
+ + {new Date(log.timestamp).toLocaleTimeString()} + + {' '} + + [{log.level.toUpperCase()}] + + + {log.message} + +
+ ) +}) + const LogPanel: React.FC = () => { - const [logs, setLogs] = useState([]) + const [logs, setLogs] = useState([]) const [autoScroll, setAutoScroll] = useState(true) const [filter, setFilter] = useState('all') const logContainerRef = useRef(null) @@ -13,7 +50,9 @@ const LogPanel: React.FC = () => { // Listen for log messages via WebSocket const handleLogMessage = (data: any) => { if (data.type === 'log') { - const logMessage: LogMessage = { + const logMessage: ClientLogMessage = { + // ⚡ Bolt: Generate safe stable ID for list keying + id: `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, timestamp: data.timestamp, level: data.level, message: data.message, @@ -40,16 +79,6 @@ const LogPanel: React.FC = () => { setLogs([]) } - const getLogColor = (level: string): string => { - switch (level.toUpperCase()) { - case 'ERROR': return '#f44336' - case 'WARNING': return '#ff9800' - case 'INFO': return '#2196F3' - case 'SUCCESS': return '#4caf50' - default: return '#999' - } - } - const filteredLogs = filter === 'all' ? logs : logs.filter(log => log.level.toLowerCase() === filter.toLowerCase()) @@ -122,23 +151,8 @@ const LogPanel: React.FC = () => { No logs to display ) : ( - filteredLogs.map((log, index) => ( -
- - {new Date(log.timestamp).toLocaleTimeString()} - - {' '} - - [{log.level.toUpperCase()}] - - - {log.message} - -
+ filteredLogs.map(log => ( + )) )} diff --git a/frontend/src/components/ProgressPanel.tsx b/frontend/src/components/ProgressPanel.tsx index 5581d24..9239807 100644 --- a/frontend/src/components/ProgressPanel.tsx +++ b/frontend/src/components/ProgressPanel.tsx @@ -4,6 +4,124 @@ import { downloadsApi } from '@/services/api' import { progressWS } from '@/services/websocket' import type { DownloadStatusResponse, ProgressUpdate } from '@/types/api' +// ⚡ Bolt: Memoized download item. Compares primitive fields to prevent re-renders +// caused by object reference changes during the 5-second polling fallback. +const DownloadItem = React.memo(({ download }: { download: DownloadStatusResponse }) => { + const formatSpeed = (bytesPerSecond: number): string => { + if (bytesPerSecond < 1024) return `${bytesPerSecond.toFixed(0)} B/s` + if (bytesPerSecond < 1024 * 1024) return `${(bytesPerSecond / 1024).toFixed(1)} KB/s` + return `${(bytesPerSecond / (1024 * 1024)).toFixed(1)} MB/s` + } + + const formatETA = (seconds?: number): string => { + if (!seconds) return 'Calculating...' + if (seconds < 60) return `${Math.round(seconds)}s` + if (seconds < 3600) return `${Math.round(seconds / 60)}m` + return `${Math.round(seconds / 3600)}h` + } + + const getStatusColor = (status: string): string => { + switch (status) { + case 'downloading': return '#667eea' + case 'completed': return '#4caf50' + case 'failed': return '#f44336' + case 'cancelled': return '#999' + default: return '#ff9800' + } + } + + return ( +
+
+
+
+ {download.url} +
+
+ {download.current_file || 'Preparing...'} +
+
+ +
+ + {download.status.toUpperCase()} + + {download.completed_files} / {download.total_files} files +
+
+ +
+
+
+ +
+ {download.progress.toFixed(1)}% + + {formatSpeed(download.download_speed)} • ETA: {formatETA(download.eta_seconds)} + +
+ + {download.error_message && ( +
+ Error: {download.error_message} +
+ )} +
+ ) +}, (prevProps, nextProps) => { + const prev = prevProps.download + const next = nextProps.download + return ( + prev.status === next.status && + prev.progress === next.progress && + prev.download_speed === next.download_speed && + prev.eta_seconds === next.eta_seconds && + prev.current_file === next.current_file && + prev.completed_files === next.completed_files && + prev.error_message === next.error_message + ) +}) + const ProgressPanel: React.FC = () => { const [downloads, setDownloads] = useState([]) const [isLoading, setIsLoading] = useState(true) @@ -47,29 +165,6 @@ const ProgressPanel: React.FC = () => { } }, []) - const formatSpeed = (bytesPerSecond: number): string => { - if (bytesPerSecond < 1024) return `${bytesPerSecond.toFixed(0)} B/s` - if (bytesPerSecond < 1024 * 1024) return `${(bytesPerSecond / 1024).toFixed(1)} KB/s` - return `${(bytesPerSecond / (1024 * 1024)).toFixed(1)} MB/s` - } - - const formatETA = (seconds?: number): string => { - if (!seconds) return 'Calculating...' - if (seconds < 60) return `${Math.round(seconds)}s` - if (seconds < 3600) return `${Math.round(seconds / 60)}m` - return `${Math.round(seconds / 3600)}h` - } - - const getStatusColor = (status: string): string => { - switch (status) { - case 'downloading': return '#667eea' - case 'completed': return '#4caf50' - case 'failed': return '#f44336' - case 'cancelled': return '#999' - default: return '#ff9800' - } - } - if (isLoading) { return (
@@ -99,83 +194,7 @@ const ProgressPanel: React.FC = () => {
{downloads.map((download) => ( -
-
-
-
- {download.url} -
-
- {download.current_file || 'Preparing...'} -
-
- -
- - {download.status.toUpperCase()} - - {download.completed_files} / {download.total_files} files -
-
- -
-
-
- -
- {download.progress.toFixed(1)}% - - {formatSpeed(download.download_speed)} • ETA: {formatETA(download.eta_seconds)} - -
- - {download.error_message && ( -
- Error: {download.error_message} -
- )} -
+ ))}