Skip to content

Commit 4bf71e9

Browse files
authored
🤖 fix: reduce ProjectSidebar re-renders during streaming (#218)
## Problem ProjectSidebar was re-rendering on every streaming delta when watching a chat in another workspace, causing unnecessary CPU usage and UI churn. ## Solution **Stabilize unreadStatus Map identity:** - Modified `useUnreadTracking` to only return a new Map reference when unread boolean values actually change - Prevents ProjectSidebar re-renders during streaming when unread state is stable - Uses ref-based deep comparison to maintain Map identity across renders **Code changes:** ```typescript // Before: New Map created on every workspaceStates or lastReadMap change const unreadStatus = useMemo(() => { const map = new Map<string, boolean>(); // ... calculate unread status return map; // New identity every time }, [workspaceStates, lastReadMap]); // After: Map identity preserved when values unchanged const unreadStatus = useMemo(() => { const next = new Map<string, boolean>(); // ... calculate unread status // Compare with previous - only return new Map if values changed const prev = unreadStatusRef.current; if (prev.size === next.size) { let same = true; for (const [k, v] of next) { if (prev.get(k) !== v) { same = false; break; } } if (same) return prev; // Maintain identity } unreadStatusRef.current = next; return next; }, [workspaceStates, lastReadMap]); ``` ## Testing Manual verification: - ✅ Streaming in workspace A while viewing workspace B no longer causes sidebar re-renders - ✅ Unread status updates correctly on workspace switches and stream completion - ✅ Toggle unread function works correctly - ✅ Drag-and-drop project reordering remains smooth ## Impact **Fixed:** - ProjectSidebar no longer re-renders on every streaming delta in non-selected workspaces - Reduced unnecessary CPU usage during streaming operations **Not fixed (separate issue):** - ProjectSidebar still re-renders every 3 seconds due to `useGitStatus()` call at line 611 - Will be addressed in a follow-up PR by removing the direct hook call and passing git status as a prop ## Notes The comparison logic handles all edge cases correctly: - Empty maps - Size changes (added/removed workspaces) - Key changes (different workspace IDs) - Value changes (unread status flips) _Generated with `cmux`_
1 parent 720ec9d commit 4bf71e9

File tree

2 files changed

+22
-5
lines changed

2 files changed

+22
-5
lines changed

src/contexts/GitStatusContext.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ interface GitStatusProviderProps {
3131
* - Max 5 concurrent git status checks to prevent bash process explosion
3232
*/
3333
// Configuration - enabled by default, no env variables needed
34-
const GIT_STATUS_INTERVAL_MS = 3000; // 3 seconds
34+
const GIT_STATUS_INTERVAL_MS = 3000; // 3 seconds - interactive updates
3535
const MAX_CONCURRENT_GIT_OPS = 5;
3636

3737
// Fetch configuration - aggressive intervals for fresh data

src/hooks/useUnreadTracking.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,13 +73,14 @@ export function useUnreadTracking(
7373
}, [selectedWorkspace?.workspaceId, workspaceStates, markAsRead]);
7474

7575
// Calculate unread status for all workspaces
76+
const unreadStatusRef = useRef<Map<string, boolean>>(new Map());
7677
const unreadStatus = useMemo(() => {
77-
const result = new Map<string, boolean>();
78+
const next = new Map<string, boolean>();
7879

7980
for (const [workspaceId, state] of workspaceStates) {
8081
// Streaming workspaces are never unread
8182
if (state.canInterrupt) {
82-
result.set(workspaceId, false);
83+
next.set(workspaceId, false);
8384
continue;
8485
}
8586

@@ -92,10 +93,26 @@ export function useUnreadTracking(
9293
msg.type !== "user" && msg.type !== "history-hidden" && (msg.timestamp ?? 0) > lastRead
9394
);
9495

95-
result.set(workspaceId, hasUnread);
96+
next.set(workspaceId, hasUnread);
9697
}
9798

98-
return result;
99+
// Return previous Map reference if nothing actually changed to keep identity stable
100+
const prev = unreadStatusRef.current;
101+
if (prev.size === next.size) {
102+
let same = true;
103+
for (const [k, v] of next) {
104+
if (prev.get(k) !== v) {
105+
same = false;
106+
break;
107+
}
108+
}
109+
if (same) {
110+
return prev;
111+
}
112+
}
113+
114+
unreadStatusRef.current = next;
115+
return next;
99116
}, [workspaceStates, lastReadMap]);
100117

101118
// Manual toggle function for clicking the indicator

0 commit comments

Comments
 (0)