Skip to content

Conversation

@ThomasK33
Copy link
Member

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.


📋 Implementation Plan

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:

  • WorkspaceStatusIndicatoruseWorkspaceSidebarState(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).

Notes / follow-ups
  • 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).

Generated with mux • Model: openai:gpt-5.2 • Thinking: xhigh

Change-Id: Ic5fad6fb97672629be20bc11b47ab1809ba01180
Signed-off-by: Thomas Kosiewski <tk@coder.com>
@ThomasK33 ThomasK33 added this pull request to the merge queue Dec 19, 2025
Merged via the queue into main with commit b98b323 Dec 19, 2025
20 checks passed
@ThomasK33 ThomasK33 deleted the fix-get-snapshot-cache branch December 19, 2025 18:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant