Noticed during Copilot's review of #1954 (which fixed the same bug in the new useManagedListError hook).
The problem
The state-subscription hooks in core/react/ seed local React state from their store prop and re-sync it inside a useEffect:
useEffect(() => {
if (!managedToolsState) { setTools([]); return; }
setTools(managedToolsState.getTools()); // <-- re-sync from prop
// ...subscribe
}, [managedToolsState]);
Two consequences:
- A stale frame on store swap. When the store prop changes — switching servers — the component renders once with the previous store's data before the effect runs and corrects it. React renders twice and the user can see the wrong frame.
- A missed-update window. An event dispatched between render and the effect subscribing is lost, because the snapshot was taken before the listener was attached.
This is the pattern AGENTS.md explicitly forbids under State and effects ("NEVER reset or re-sync local state from a prop inside a useEffect"), and which react-hooks/set-state-in-effect is meant to catch — it isn't firing on this shape today, which is worth understanding as part of the fix.
Affected hooks
All in core/react/:
useManagedTools, useManagedPrompts, useManagedResources, useManagedResourceTemplates
useManagedRequestorTasks
usePagedTools, usePagedPrompts, usePagedResources, usePagedResourceTemplates, usePagedRequestorTasks
useMessageLog, useFetchRequestLog, useStderrLog, usePendingClientRequests, useResourceSubscriptions
(Worth auditing the whole directory rather than trusting this list.)
Suggested approach
Convert to useSyncExternalStore, as useManagedListError now does (see #1954). It reads the snapshot during render, so a store swap lands in the same frame, and subscribing is atomic with the read.
The catch specific to these hooks: getSnapshot must be referentially stable across reads meaning "no change". Most of these stores return a defensive copy (getTools() is [...this.items]), so a naive useSyncExternalStore(subscribe, () => state.getTools()) returns a fresh array every read and loops infinitely. Each store needs to expose a stable snapshot — cache the array and replace the reference only when the contents change — which is the bulk of the work and why this is worth its own issue rather than a drive-by.
useValueChange (clients/web/src/hooks/useValueChange.ts) is the pattern AGENTS.md points to, but it isn't reachable from core/react/, which the CLI and TUI also consume. Either approach needs to work for all three clients.
Notes
Not user-visible in most flows today — the stale frame lasts one render and the missed-update window is small — which is why it has gone unnoticed. It becomes visible when switching between servers whose lists differ.
Noticed during Copilot's review of #1954 (which fixed the same bug in the new
useManagedListErrorhook).The problem
The state-subscription hooks in
core/react/seed local React state from their store prop and re-sync it inside auseEffect:Two consequences:
This is the pattern
AGENTS.mdexplicitly forbids under State and effects ("NEVER reset or re-sync local state from a prop inside auseEffect"), and whichreact-hooks/set-state-in-effectis meant to catch — it isn't firing on this shape today, which is worth understanding as part of the fix.Affected hooks
All in
core/react/:useManagedTools,useManagedPrompts,useManagedResources,useManagedResourceTemplatesuseManagedRequestorTasksusePagedTools,usePagedPrompts,usePagedResources,usePagedResourceTemplates,usePagedRequestorTasksuseMessageLog,useFetchRequestLog,useStderrLog,usePendingClientRequests,useResourceSubscriptions(Worth auditing the whole directory rather than trusting this list.)
Suggested approach
Convert to
useSyncExternalStore, asuseManagedListErrornow does (see #1954). It reads the snapshot during render, so a store swap lands in the same frame, and subscribing is atomic with the read.The catch specific to these hooks:
getSnapshotmust be referentially stable across reads meaning "no change". Most of these stores return a defensive copy (getTools()is[...this.items]), so a naiveuseSyncExternalStore(subscribe, () => state.getTools())returns a fresh array every read and loops infinitely. Each store needs to expose a stable snapshot — cache the array and replace the reference only when the contents change — which is the bulk of the work and why this is worth its own issue rather than a drive-by.useValueChange(clients/web/src/hooks/useValueChange.ts) is the pattern AGENTS.md points to, but it isn't reachable fromcore/react/, which the CLI and TUI also consume. Either approach needs to work for all three clients.Notes
Not user-visible in most flows today — the stale frame lasts one render and the missed-update window is small — which is why it has gone unnoticed. It becomes visible when switching between servers whose lists differ.