Skip to content

Commit 86f8221

Browse files
authored
🤖 Fix sidebar re-renders and infinite loops with custom MapStore (#246)
Replaces Zustand with custom MapStore to fix sidebar re-render performance and eliminate unnecessary state management complexity. ## Problems Fixed **Sidebar flickering during streaming** - Every stream delta caused full sidebar re-renders because global state tracking forced all components to update. **Infinite render loops** - React's `useSyncExternalStore` requires stable snapshot references. Previous implementation returned new objects on every call. ## Solution **Removed global state cache** - Moved unread computation to individual `WorkspaceListItem` components. Each component subscribes only to its workspace using `useWorkspaceSidebarState(workspaceId)`. **Stable snapshot references** - Hooks using `getAllStates()` now cache Maps and use `compareMaps()` to return stable references when underlying data hasn't changed. ## Implementation Details - Custom `MapStore` with versioned caching replaces Zustand - `StreamingMessageAggregator` caches recency timestamps (O(1) access, no recalculation on every delta) - `useResumeManager` and `useAutoCompactContinue` cache workspace state snapshots - Each `WorkspaceListItem` computes its own unread state from `lastReadTimestamp` prop ## Result **Zero sidebar re-renders during streaming** - Components only update when their specific workspace changes. **~400 lines net change** (+775/-383) - Removes Zustand dependency and CacheManager, adds MapStore. _Generated with `cmux`_
1 parent 5e6a528 commit 86f8221

18 files changed

+845
-491
lines changed

bun.lock

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@
3434
"write-file-atomic": "^6.0.0",
3535
"zod": "^4.1.11",
3636
"zod-to-json-schema": "^3.24.6",
37-
"zustand": "^5.0.8",
3837
},
3938
"devDependencies": {
4039
"@eslint/js": "^9.36.0",
@@ -2245,8 +2244,6 @@
22452244

22462245
"zod-to-json-schema": ["zod-to-json-schema@3.24.6", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg=="],
22472246

2248-
"zustand": ["zustand@5.0.8", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw=="],
2249-
22502247
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
22512248

22522249
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],

package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,7 @@
5959
"undici": "^7.16.0",
6060
"write-file-atomic": "^6.0.0",
6161
"zod": "^4.1.11",
62-
"zod-to-json-schema": "^3.24.6",
63-
"zustand": "^5.0.8"
62+
"zod-to-json-schema": "^3.24.6"
6463
},
6564
"devDependencies": {
6665
"@eslint/js": "^9.36.0",

src/App.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -185,8 +185,8 @@ function AppInner() {
185185
}
186186
}, [workspaceMetadata, gitStatusStore]);
187187

188-
// Track unread message status for all workspaces
189-
const { unreadStatus, toggleUnread } = useUnreadTracking(selectedWorkspace);
188+
// Track last-read timestamps for unread indicators
189+
const { lastReadTimestamps, onToggleUnread } = useUnreadTracking(selectedWorkspace);
190190

191191
// Auto-resume interrupted streams on app startup and when failures occur
192192
useResumeManager();
@@ -587,8 +587,8 @@ function AppInner() {
587587
onRemoveProject={(path) => void handleRemoveProject(path)}
588588
onRemoveWorkspace={removeWorkspace}
589589
onRenameWorkspace={renameWorkspace}
590-
unreadStatus={unreadStatus}
591-
onToggleUnread={toggleUnread}
590+
lastReadTimestamps={lastReadTimestamps}
591+
onToggleUnread={onToggleUnread}
592592
collapsed={sidebarCollapsed}
593593
onToggleCollapsed={() => setSidebarCollapsed((prev) => !prev)}
594594
onGetSecrets={handleGetSecrets}

src/components/LeftSidebar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ interface LeftSidebarProps {
3535
workspaceId: string,
3636
newName: string
3737
) => Promise<{ success: boolean; error?: string }>;
38-
unreadStatus: Map<string, boolean>;
38+
lastReadTimestamps: Record<string, number>;
3939
onToggleUnread: (workspaceId: string) => void;
4040
collapsed: boolean;
4141
onToggleCollapsed: () => void;

src/components/ProjectSidebar.tsx

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -586,7 +586,7 @@ interface ProjectSidebarProps {
586586
workspaceId: string,
587587
newName: string
588588
) => Promise<{ success: boolean; error?: string }>;
589-
unreadStatus: Map<string, boolean>;
589+
lastReadTimestamps: Record<string, number>;
590590
onToggleUnread: (workspaceId: string) => void;
591591
collapsed: boolean;
592592
onToggleCollapsed: () => void;
@@ -603,7 +603,7 @@ interface WorkspaceListItemProps {
603603
projectPath: string;
604604
projectName: string;
605605
isSelected: boolean;
606-
isUnread: boolean;
606+
lastReadTimestamp: number;
607607
isEditing: boolean;
608608
editingName: string;
609609
originalName: string;
@@ -623,7 +623,7 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
623623
projectPath,
624624
projectName,
625625
isSelected,
626-
isUnread,
626+
lastReadTimestamp,
627627
isEditing,
628628
editingName,
629629
renameError,
@@ -643,6 +643,12 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
643643
const isStreaming = sidebarState.canInterrupt;
644644
const streamingModel = sidebarState.currentModel;
645645

646+
// Compute unread status locally based on recency vs last read timestamp
647+
// Note: We don't check !isSelected here because user should be able to see
648+
// and toggle unread status even for the selected workspace
649+
const isUnread =
650+
sidebarState.recencyTimestamp !== null && sidebarState.recencyTimestamp > lastReadTimestamp;
651+
646652
return (
647653
<React.Fragment>
648654
<WorkspaceItem
@@ -752,7 +758,7 @@ const ProjectSidebar: React.FC<ProjectSidebarProps> = ({
752758
onRemoveProject,
753759
onRemoveWorkspace,
754760
onRenameWorkspace,
755-
unreadStatus,
761+
lastReadTimestamps,
756762
onToggleUnread: _onToggleUnread,
757763
collapsed,
758764
onToggleCollapsed,
@@ -1125,7 +1131,6 @@ const ProjectSidebar: React.FC<ProjectSidebarProps> = ({
11251131
if (!metadata) return null;
11261132

11271133
const workspaceId = metadata.id;
1128-
const isUnread = unreadStatus.get(workspaceId) ?? false;
11291134
const isEditing = editingWorkspaceId === workspaceId;
11301135
const isSelected =
11311136
selectedWorkspace?.workspacePath === workspace.path;
@@ -1139,7 +1144,7 @@ const ProjectSidebar: React.FC<ProjectSidebarProps> = ({
11391144
projectPath={projectPath}
11401145
projectName={projectName}
11411146
isSelected={isSelected}
1142-
isUnread={isUnread}
1147+
lastReadTimestamp={lastReadTimestamps[metadata.id] ?? 0}
11431148
isEditing={isEditing}
11441149
editingName={editingName}
11451150
originalName={originalName}

src/hooks/useAutoCompactContinue.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { useRef, useEffect } from "react";
2-
import { useWorkspaceStoreZustand } from "@/stores/WorkspaceStore";
1+
import { useRef, useEffect, useSyncExternalStore } from "react";
2+
import { useWorkspaceStoreRaw, type WorkspaceState } from "@/stores/WorkspaceStore";
33
import { getCompactContinueMessageKey } from "@/constants/storage";
44
import { buildSendMessageOptions } from "@/hooks/useSendMessageOptions";
5+
import { compareMaps } from "./useStableReference";
56

67
/**
78
* Hook to manage auto-continue after compaction
@@ -19,7 +20,21 @@ import { buildSendMessageOptions } from "@/hooks/useSendMessageOptions";
1920
*/
2021
export function useAutoCompactContinue() {
2122
// Get workspace states from store (subscribe to all changes)
22-
const workspaceStates = useWorkspaceStoreZustand((state) => state.store.getAllStates());
23+
const store = useWorkspaceStoreRaw();
24+
25+
// Cache the Map to avoid infinite re-renders (getAllStates returns new Map each time)
26+
const cachedStatesRef = useRef<Map<string, WorkspaceState>>(new Map());
27+
const getSnapshot = (): Map<string, WorkspaceState> => {
28+
const newStates = store.getAllStates();
29+
// Only return new reference if something actually changed (compare by reference)
30+
if (compareMaps(cachedStatesRef.current, newStates)) {
31+
return cachedStatesRef.current;
32+
}
33+
cachedStatesRef.current = newStates;
34+
return newStates;
35+
};
36+
37+
const workspaceStates = useSyncExternalStore(store.subscribe, getSnapshot);
2338

2439
// Prevent duplicate auto-sends if effect runs more than once while the same
2540
// compacted summary is visible (e.g., rapid state updates after replaceHistory)

src/hooks/useResumeManager.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import { useEffect, useRef } from "react";
2-
import { useWorkspaceStoreZustand } from "@/stores/WorkspaceStore";
1+
import { useEffect, useRef, useSyncExternalStore } from "react";
2+
import { useWorkspaceStoreRaw, type WorkspaceState } from "@/stores/WorkspaceStore";
33
import { CUSTOM_EVENTS } from "@/constants/events";
44
import { getAutoRetryKey, getRetryStateKey } from "@/constants/storage";
55
import { getSendOptionsFromStorage } from "@/utils/messages/sendOptions";
66
import { readPersistedState } from "./usePersistedState";
77
import { hasInterruptedStream } from "@/utils/messages/retryEligibility";
8+
import { compareMaps } from "./useStableReference";
89

910
interface RetryState {
1011
attempt: number;
@@ -55,7 +56,21 @@ const MAX_DELAY = 60000; // 60 seconds
5556
*/
5657
export function useResumeManager() {
5758
// Get workspace states from store (subscribe to all changes)
58-
const workspaceStates = useWorkspaceStoreZustand((state) => state.store.getAllStates());
59+
const store = useWorkspaceStoreRaw();
60+
61+
// Cache the Map to avoid infinite re-renders (getAllStates returns new Map each time)
62+
const cachedStatesRef = useRef<Map<string, WorkspaceState>>(new Map());
63+
const getSnapshot = (): Map<string, WorkspaceState> => {
64+
const newStates = store.getAllStates();
65+
// Only return new reference if something actually changed (compare by reference)
66+
if (compareMaps(cachedStatesRef.current, newStates)) {
67+
return cachedStatesRef.current;
68+
}
69+
cachedStatesRef.current = newStates;
70+
return newStates;
71+
};
72+
73+
const workspaceStates = useSyncExternalStore(store.subscribe, getSnapshot);
5974

6075
// Use ref to avoid effect re-running on every state change
6176
const workspaceStatesRef = useRef(workspaceStates);

src/hooks/useStableReference.test.ts

Lines changed: 1 addition & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,7 @@
55
* The comparator functions are the critical logic and are thoroughly tested here.
66
* The hook itself is a thin wrapper around useMemo and useRef with manual testing.
77
*/
8-
import { compareMaps, compareRecords, compareArrays, compareGitStatus } from "./useStableReference";
9-
import type { GitStatus } from "@/types/workspace";
8+
import { compareMaps, compareRecords, compareArrays } from "./useStableReference";
109

1110
describe("compareMaps", () => {
1211
it("returns true for empty maps", () => {
@@ -132,48 +131,6 @@ describe("compareArrays", () => {
132131
});
133132
});
134133

135-
describe("compareGitStatus", () => {
136-
it("returns true for two null values", () => {
137-
expect(compareGitStatus(null, null)).toBe(true);
138-
});
139-
140-
it("returns false when one is null and the other is not", () => {
141-
const status: GitStatus = { ahead: 0, behind: 0, dirty: false };
142-
expect(compareGitStatus(null, status)).toBe(false);
143-
expect(compareGitStatus(status, null)).toBe(false);
144-
});
145-
146-
it("returns true for identical git status", () => {
147-
const a: GitStatus = { ahead: 1, behind: 2, dirty: true };
148-
const b: GitStatus = { ahead: 1, behind: 2, dirty: true };
149-
expect(compareGitStatus(a, b)).toBe(true);
150-
});
151-
152-
it("returns false when ahead differs", () => {
153-
const a: GitStatus = { ahead: 1, behind: 2, dirty: false };
154-
const b: GitStatus = { ahead: 2, behind: 2, dirty: false };
155-
expect(compareGitStatus(a, b)).toBe(false);
156-
});
157-
158-
it("returns false when behind differs", () => {
159-
const a: GitStatus = { ahead: 1, behind: 2, dirty: false };
160-
const b: GitStatus = { ahead: 1, behind: 3, dirty: false };
161-
expect(compareGitStatus(a, b)).toBe(false);
162-
});
163-
164-
it("returns false when dirty differs", () => {
165-
const a: GitStatus = { ahead: 1, behind: 2, dirty: false };
166-
const b: GitStatus = { ahead: 1, behind: 2, dirty: true };
167-
expect(compareGitStatus(a, b)).toBe(false);
168-
});
169-
170-
it("returns true for clean status (all zeros)", () => {
171-
const a: GitStatus = { ahead: 0, behind: 0, dirty: false };
172-
const b: GitStatus = { ahead: 0, behind: 0, dirty: false };
173-
expect(compareGitStatus(a, b)).toBe(true);
174-
});
175-
});
176-
177134
// Hook integration tests would require jsdom setup with bun.
178135
// The comparator functions above are the critical logic and are thoroughly tested.
179136
// The hook itself is tested manually through its usage in useUnreadTracking,

src/hooks/useStableReference.ts

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { useRef, useMemo, type DependencyList } from "react";
2-
import type { GitStatus } from "@/types/workspace";
32

43
/**
54
* Compare two Maps for deep equality (same keys and values).
@@ -75,21 +74,6 @@ export function compareArrays<V>(
7574
return true;
7675
}
7776

78-
/**
79-
* Compare two GitStatus objects for equality.
80-
* Used to stabilize git status Map identity when values haven't changed.
81-
*
82-
* @param a Previous GitStatus
83-
* @param b Next GitStatus
84-
* @returns true if GitStatus objects are equal, false otherwise
85-
*/
86-
export function compareGitStatus(a: GitStatus | null, b: GitStatus | null): boolean {
87-
if (a === null && b === null) return true;
88-
if (a === null || b === null) return false;
89-
90-
return a.ahead === b.ahead && a.behind === b.behind && a.dirty === b.dirty;
91-
}
92-
9377
/**
9478
* Hook to stabilize reference identity for computed values.
9579
*

0 commit comments

Comments
 (0)