fix: read the list error via useSyncExternalStore; clear correlation maps

Two fixes from Copilot's review of #1953.

useManagedListError re-synced React state from the `state` prop inside a
useEffect, which renders one frame carrying the PREVIOUS store's error
after `state` changes (switching servers) before the effect corrects it —
the pattern AGENTS.md forbids. useSyncExternalStore reads the snapshot
during render, so a store swap lands in the same frame, and it also
closes the window where an error recorded between render and subscribe
was missed. The snapshot returns the stored Error instance (or null), so
it is referentially stable as the API requires.

The `markResponseRejected` correlation maps were never cleared. Ids of
requests that never got a response — a timeout, a dropped connection —
are the only entries trackResponse can't remove, so they accumulated
across reconnects. Cleared on the start-clean connect path, per the
convention documented on resetSessionState (one route out, `onerror`
with no `onclose`, tears down nothing).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SW1p8E2uiyyLx4RKwrwSrt
This commit is contained in:
cliffhall 2026-08-08 17:41:27 -04:00
commit a6f233eb6a

View file

@ -84,4 +84,29 @@ describe("useManagedListError", () => {
await expect(state.refresh()).rejects.toThrow(boom);
expect(state.getError()).toBe(boom);
});
// The useState+useEffect subscribe pattern would render one frame carrying
// the PREVIOUS store's error here, before the effect re-synced. With
// useSyncExternalStore the snapshot is read during render, so the swap lands
// in the same frame — asserted immediately after rerender, with no waitFor
// and no act() flush, which is what makes it a regression test rather than a
// restatement of eventual consistency.
it("reflects a store swap in the same frame", async () => {
client.listAllTools.mockRejectedValueOnce(boom);
await expect(state.refresh()).rejects.toThrow(boom);
const other = new ManagedToolsState(client, 0);
const { result, rerender } = renderHook(
({ s }: { s: ManagedToolsState }) => useManagedListError(s),
{ initialProps: { s: state } },
);
expect(result.current).toBe(boom);
rerender({ s: other });
expect(result.current).toBeNull();
rerender({ s: state });
expect(result.current).toBe(boom);
});
});