diff --git a/clients/web/src/hooks/useOAuthRecovery.test.tsx b/clients/web/src/hooks/useOAuthRecovery.test.tsx index 91b3f10d..0ab47c70 100644 --- a/clients/web/src/hooks/useOAuthRecovery.test.tsx +++ b/clients/web/src/hooks/useOAuthRecovery.test.tsx @@ -393,6 +393,54 @@ describe("useOAuthRecovery", () => { expect(h.api().connectionInfoOAuth).toBeUndefined(); }); + it("clears the details when the state read rejects", async () => { + const client = fakeClient({ + getOAuthState: vi.fn().mockRejectedValue(new Error("backend down")), + }); + const props: HarnessProps = { + servers: [entry("a")], + activeServerId: "a", + client, + }; + const h = harness(props); + await waitFor(() => expect(client.getOAuthState).toHaveBeenCalled()); + expect(h.api().connectionInfoOAuth).toBeUndefined(); + + // The rejection is terminated, not floated: a later resolving read still + // populates the panel, which an unhandled rejection would have prevented + // by failing the run. + client.getOAuthState = vi + .fn() + .mockResolvedValue({ tokens: { access_token: "t" } }); + await act(async () => { + client.emit("oauthComplete", {}); + }); + await waitFor(() => expect(h.api().connectionInfoOAuth).toBeDefined()); + }); + + it("drops a rejected state read that lands after the session ended", async () => { + let fail: (reason: unknown) => void = () => {}; + const client = fakeClient({ + getOAuthState: vi.fn( + () => + new Promise((_resolve, reject) => { + fail = reject; + }), + ), + }); + const props: HarnessProps = { + servers: [entry("a")], + activeServerId: "a", + client, + }; + const h = harness(props); + h.rerender({ ...props, connectionStatus: "disconnected" }); + await act(async () => { + fail(new Error("backend down")); + }); + expect(h.api().connectionInfoOAuth).toBeUndefined(); + }); + it("drops a state read that lands after the session ended", async () => { let settle: (value: unknown) => void = () => {}; const client = fakeClient({ diff --git a/clients/web/src/hooks/useOAuthRecovery.ts b/clients/web/src/hooks/useOAuthRecovery.ts index 428352b7..c6b7d184 100644 --- a/clients/web/src/hooks/useOAuthRecovery.ts +++ b/clients/web/src/hooks/useOAuthRecovery.ts @@ -406,12 +406,23 @@ export function useOAuthRecovery({ let cancelled = false; const refresh = (): void => { - void inspectorClient.getOAuthState().then((state) => { - if (cancelled) return; - setConnectionInfoOAuthWhenConnected( - state ? oauthDetailsFromConnectionState(state) : undefined, - ); - }); + // void: a synchronous useEffect body cannot await. The chain is + // terminated below, so the rejection is handled rather than discarded. + void inspectorClient + .getOAuthState() + .then((state) => { + if (cancelled) return; + setConnectionInfoOAuthWhenConnected( + state ? oauthDetailsFromConnectionState(state) : undefined, + ); + }) + .catch(() => { + // The read failed (backend down, 401 on the API token, malformed + // stored state). Clear rather than keep the last successful read — + // a stale answer is indistinguishable from a fresh one in the panel. + if (cancelled) return; + setConnectionInfoOAuthWhenConnected(undefined); + }); }; const onAmbientAuthChallenge = (): void => {