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}
-
- )}
-
+
))}