Skip to content

Commit b98b323

Browse files
authored
🤖 fix: stabilize WorkspaceSidebarState getSnapshot (#1226)
Fix React `useSyncExternalStore` warning by ensuring `WorkspaceStore.getWorkspaceSidebarState()` returns a referentially-stable snapshot within a given `WorkspaceState` version (avoids `Date.now()`-derived instability). Adds a regression test covering the active-stream + pending-tool case. --- <details> <summary>📋 Implementation Plan</summary> # Fix React `useSyncExternalStore` getSnapshot caching warning (Workspace sidebar) ## What’s happening / why this warns React’s `useSyncExternalStore()` calls `getSnapshot()` more than once during a render/commit. If `getSnapshot()` returns a *different* value (by `Object.is`) between those reads, React warns: > “The result of getSnapshot should be cached to avoid an infinite loop” In our case the call chain is: - `WorkspaceStatusIndicator` → `useWorkspaceSidebarState(workspaceId)` - `useWorkspaceSidebarState` uses `useSyncExternalStore(..., () => store.getWorkspaceSidebarState(workspaceId))` - `WorkspaceStore.getWorkspaceSidebarState()` currently computes `timingStats` using `StreamingMessageAggregator.getActiveStreamTimingStats()`, which reads `Date.now()` (pending tool duration + live TPS). That makes consecutive `getSnapshot()` reads produce *different* objects even when the store didn’t “change” (no `bump()`), tripping the warning and risking update loops. --- ## Recommended approach (minimal, correct): cache sidebar snapshot per workspace-state version **Net LoC (product code): +15–30** ### Goal Make `WorkspaceStore.getWorkspaceSidebarState()` return the *exact same object reference* whenever the underlying workspace state hasn’t changed (i.e., between consecutive React snapshot reads). ### Implementation steps 1) **Add a per-workspace “source state” cache** in `WorkspaceStore`: - `sidebarStateCache: Map<string, WorkspaceSidebarState>` already exists. - Add `sidebarStateSourceRef: Map<string, WorkspaceState>` (or similar). 2) **Early-return the cached sidebar state when the underlying `WorkspaceState` reference is unchanged**: - At the top of `getWorkspaceSidebarState(workspaceId)`: - `const fullState = this.getWorkspaceState(workspaceId);` - If `cachedSidebar && sidebarStateSourceRef.get(workspaceId) === fullState`, return `cachedSidebar`. This ensures `getSnapshot()` is deterministic for a given store version and avoids calling any `Date.now()`-dependent code more than once per version. 3) Keep the existing “field equality” logic for the cross-version case: - When `fullState` changes because `states.bump(workspaceId)` happened, recompute the candidate sidebar state. - If relevant fields are unchanged, keep returning the previously cached object reference. 4) **Cleanup:** ensure `removeWorkspace()` deletes the new `sidebarStateSourceRef` entry. ### Verification - Manually: run the app, start a stream with an in-flight tool call, open the sidebar; confirm the React warning no longer appears. - Programmatically: add a unit regression test (below). --- ## Regression test (recommended) **Net LoC (tests only; not counted in product LoC): +30–60** Add a focused test in `src/browser/stores/WorkspaceStore.test.ts`: - Create a workspace + aggregator. - Drive the aggregator into an “active stream + pending tool” state: - `aggregator.handleStreamStart({ type: "stream-start", ... })` - `aggregator.handleToolCallStart({ type: "tool-call-start", ... })` - (No `store.states.bump()` in between.) - Call `store.getWorkspaceSidebarState(workspaceId)` twice. - Assert referential stability: - `expect(first).toBe(second)` This test specifically covers the prior failure mode where `Date.now()` made `timingStats` differ across consecutive snapshot reads. --- ## Alternatives (if we want a larger cleanup) ### A) Split “status UI” subscriptions from “timing stats” (strong long-term ergonomics) **Net LoC (product code): +40–90** - Introduce a narrower hook for status indicators, e.g. `useWorkspaceStatusIndicatorState(workspaceId)` that returns only `{ agentStatus, awaitingUserQuestion, canInterrupt, currentModel, recencyTimestamp }`. - Move timing-related data into a dedicated hook (or rely exclusively on `useWorkspaceStatsSnapshot`). Benefits: avoids coupling sidebar items to high-churn / time-derived fields. ### B) Make timing stats updates explicit (if we truly want “live” TPS/tool time) **Net LoC (product code): +60–140** - Remove `Date.now()` usage from *snapshot reads*. - Instead: - either compute “live” values in UI with a timer (`useNow(250ms)`), - or run a store timer that periodically recomputes timing stats and calls `states.bump(workspaceId)`. --- <details> <summary>Notes / follow-ups</summary> - There’s a similar `useSyncExternalStore` pitfall in `useAllExperiments()` (`ExperimentsContext.tsx`) which returns a freshly-created object each snapshot read. It’s currently unused, but worth fixing if we ever start using it. - The repo already has a good reference implementation for this exact React rule in `usePersistedState.ts` (it caches parsed JSON by raw localStorage string). </details> </details> --- _Generated with `mux` • Model: `openai:gpt-5.2` • Thinking: `xhigh`_ Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent d329140 commit b98b323

File tree

2 files changed

+73
-3
lines changed

2 files changed

+73
-3
lines changed

src/browser/stores/WorkspaceStore.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, it, beforeEach, afterEach, mock, type Mock } from "bun:test";
22
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
3+
import type { StreamStartEvent, ToolCallStartEvent } from "@/common/types/stream";
34
import type { WorkspaceChatMessage } from "@/common/orpc/types";
45
import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace";
56
import { WorkspaceStore } from "./WorkspaceStore";
@@ -370,6 +371,61 @@ describe("WorkspaceStore", () => {
370371
expect(state1).toBe(state2);
371372
});
372373

374+
it("getWorkspaceSidebarState() returns same reference when WorkspaceState hasn't changed", () => {
375+
const originalNow = Date.now;
376+
let now = 1000;
377+
Date.now = () => now;
378+
379+
try {
380+
const workspaceId = "test-workspace";
381+
createAndAddWorkspace(store, workspaceId);
382+
383+
const aggregator = store.getAggregator(workspaceId);
384+
expect(aggregator).toBeDefined();
385+
if (!aggregator) {
386+
throw new Error("Expected aggregator to exist");
387+
}
388+
389+
const streamStart: StreamStartEvent = {
390+
type: "stream-start",
391+
workspaceId,
392+
messageId: "msg1",
393+
model: "claude-opus-4",
394+
historySequence: 1,
395+
startTime: 500,
396+
mode: "exec",
397+
};
398+
aggregator.handleStreamStart(streamStart);
399+
400+
const toolStart: ToolCallStartEvent = {
401+
type: "tool-call-start",
402+
workspaceId,
403+
messageId: "msg1",
404+
toolCallId: "tool1",
405+
toolName: "test_tool",
406+
args: {},
407+
tokens: 0,
408+
timestamp: 600,
409+
};
410+
aggregator.handleToolCallStart(toolStart);
411+
412+
// Simulate store update (MapStore version bump) after handling events.
413+
store.bumpState(workspaceId);
414+
415+
now = 1300;
416+
const sidebar1 = store.getWorkspaceSidebarState(workspaceId);
417+
418+
// Advance time without a store bump. Without snapshot caching, this would
419+
// produce a new object due to Date.now()-derived timing stats.
420+
now = 1350;
421+
const sidebar2 = store.getWorkspaceSidebarState(workspaceId);
422+
423+
expect(sidebar2).toBe(sidebar1);
424+
} finally {
425+
Date.now = originalNow;
426+
}
427+
});
428+
373429
it("syncWorkspaces() does not emit when workspaces unchanged", () => {
374430
const listener = mock(() => undefined);
375431
store.subscribe(listener);

src/browser/stores/WorkspaceStore.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -755,6 +755,11 @@ export class WorkspaceStore {
755755

756756
// Cache sidebar state objects to return stable references
757757
private sidebarStateCache = new Map<string, WorkspaceSidebarState>();
758+
// Map from workspaceId -> the WorkspaceState reference used to compute sidebarStateCache.
759+
// React's useSyncExternalStore may call getSnapshot() multiple times per render; this
760+
// ensures getWorkspaceSidebarState() returns a referentially stable snapshot for a given
761+
// MapStore version even when timingStats would otherwise change via Date.now().
762+
private sidebarStateSourceState = new Map<string, WorkspaceState>();
758763

759764
/**
760765
* Get sidebar state for a workspace (subset of full state).
@@ -763,6 +768,12 @@ export class WorkspaceStore {
763768
*/
764769
getWorkspaceSidebarState(workspaceId: string): WorkspaceSidebarState {
765770
const fullState = this.getWorkspaceState(workspaceId);
771+
772+
const cached = this.sidebarStateCache.get(workspaceId);
773+
if (cached && this.sidebarStateSourceState.get(workspaceId) === fullState) {
774+
return cached;
775+
}
776+
766777
const aggregator = this.aggregators.get(workspaceId);
767778

768779
// Get timing stats: prefer active stream, fall back to last completed
@@ -787,9 +798,7 @@ export class WorkspaceStore {
787798
// Get session-level aggregate stats
788799
const sessionStats = aggregator?.getSessionTimingStats() ?? null;
789800

790-
const cached = this.sidebarStateCache.get(workspaceId);
791-
792-
// Return cached if values match (timing stats checked by reference since they change frequently)
801+
// Return cached if values match.
793802
if (
794803
cached &&
795804
cached.canInterrupt === fullState.canInterrupt &&
@@ -804,6 +813,9 @@ export class WorkspaceStore {
804813
(cached.sessionStats?.totalDurationMs === sessionStats?.totalDurationMs &&
805814
cached.sessionStats?.responseCount === sessionStats?.responseCount))
806815
) {
816+
// Even if we re-use the cached object, mark it as derived from the current
817+
// WorkspaceState so repeated getSnapshot() reads during this render are stable.
818+
this.sidebarStateSourceState.set(workspaceId, fullState);
807819
return cached;
808820
}
809821

@@ -818,6 +830,7 @@ export class WorkspaceStore {
818830
sessionStats,
819831
};
820832
this.sidebarStateCache.set(workspaceId, newState);
833+
this.sidebarStateSourceState.set(workspaceId, fullState);
821834
return newState;
822835
}
823836

@@ -1215,6 +1228,7 @@ export class WorkspaceStore {
12151228
this.recencyCache.delete(workspaceId);
12161229
this.previousSidebarValues.delete(workspaceId);
12171230
this.sidebarStateCache.delete(workspaceId);
1231+
this.sidebarStateSourceState.delete(workspaceId);
12181232
this.workspaceCreatedAt.delete(workspaceId);
12191233
this.workspaceStats.delete(workspaceId);
12201234
this.statsStore.delete(workspaceId);

0 commit comments

Comments
 (0)