Commit b98b323
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
2 files changed
+73
-3
lines changed| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
| 3 | + | |
3 | 4 | | |
4 | 5 | | |
5 | 6 | | |
| |||
370 | 371 | | |
371 | 372 | | |
372 | 373 | | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
| 421 | + | |
| 422 | + | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
373 | 429 | | |
374 | 430 | | |
375 | 431 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
755 | 755 | | |
756 | 756 | | |
757 | 757 | | |
| 758 | + | |
| 759 | + | |
| 760 | + | |
| 761 | + | |
| 762 | + | |
758 | 763 | | |
759 | 764 | | |
760 | 765 | | |
| |||
763 | 768 | | |
764 | 769 | | |
765 | 770 | | |
| 771 | + | |
| 772 | + | |
| 773 | + | |
| 774 | + | |
| 775 | + | |
| 776 | + | |
766 | 777 | | |
767 | 778 | | |
768 | 779 | | |
| |||
787 | 798 | | |
788 | 799 | | |
789 | 800 | | |
790 | | - | |
791 | | - | |
792 | | - | |
| 801 | + | |
793 | 802 | | |
794 | 803 | | |
795 | 804 | | |
| |||
804 | 813 | | |
805 | 814 | | |
806 | 815 | | |
| 816 | + | |
| 817 | + | |
| 818 | + | |
807 | 819 | | |
808 | 820 | | |
809 | 821 | | |
| |||
818 | 830 | | |
819 | 831 | | |
820 | 832 | | |
| 833 | + | |
821 | 834 | | |
822 | 835 | | |
823 | 836 | | |
| |||
1215 | 1228 | | |
1216 | 1229 | | |
1217 | 1230 | | |
| 1231 | + | |
1218 | 1232 | | |
1219 | 1233 | | |
1220 | 1234 | | |
| |||
0 commit comments