fix(react): StrictMode cleanup + relax late-handler guard for re-reg (#631)

* fix(react): close App on useApp unmount; relax late-handler guard for re-registration

useApp's effect cleanup now closes the App (and its transport) instead
of only flipping a `mounted` flag. Under React StrictMode dev
double-invoke, the abandoned first instance's PostMessageTransport
otherwise keeps a window `message` listener and receives every host
postMessage alongside the second instance.

The late-handler guard from #629 now tracks `_everHadListener` per
event and only flags the *first* registration of a one-shot event
post-handshake. Re-registration (React useEffect cleanup + re-add on
dep change) of an event that had a pre-connect handler is silent,
including under `strict: true`.

* feat(app-bridge): warn on second ui/initialize

If a View double-mounts (e.g. React StrictMode in dev) without closing
the previous App, AppBridge now logs a console.warn when it receives a
second ui/initialize. Behavior is unchanged — it still responds and the
latest appInfo/appCapabilities replace the previous values — this is
purely a diagnostic for host implementers.
This commit is contained in:
Olivier Chafik 2026-04-21 15:44:57 +01:00 • committed by GitHub
commit cc02e17e21
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -15,6 +15,7 @@ import {
import { z } from "zod/v4";
import { App } from "./app";
import { LATEST_PROTOCOL_VERSION } from "./types";
import {
AppBridge,
getToolUiResourceUri,
@ -2021,6 +2022,45 @@ describe("App <-> AppBridge integration", () => {
expect(warnings()).toEqual([]);
});
it("does not warn on re-registration when a handler existed pre-connect", async () => {
const h = () => {};
app.addEventListener("toolresult", h);
await bridge.connect(bridgeTransport);
await app.connect(appTransport);
// React-style: useEffect cleanup removes, dep change re-adds.
app.removeEventListener("toolresult", h);
app.addEventListener("toolresult", () => {});
expect(warnings().filter((m) => lateMsg.test(m))).toEqual([]);
});
it("warns once for the first late registration, not on subsequent ones", async () => {
await bridge.connect(bridgeTransport);
await app.connect(appTransport);
app.addEventListener("toolinput", () => {});
app.addEventListener("toolinput", () => {});
app.addEventListener("toolinput", () => {});
const late = warnings().filter((m) => lateMsg.test(m));
expect(late).toHaveLength(1);
expect(late[0]).toContain('"toolinput"');
});
it("tracks first-handler per event independently", async () => {
app.addEventListener("toolinput", () => {});
await bridge.connect(bridgeTransport);
await app.connect(appTransport);
app.addEventListener("toolinput", () => {}); // had pre-connect handler → silent
app.addEventListener("toolresult", () => {}); // first reg, late → warns
const late = warnings().filter((m) => lateMsg.test(m));
expect(late).toHaveLength(1);
expect(late[0]).toContain('"toolresult"');
});
it("throws instead of warning when strict: true", async () => {
const [strictAppT, strictBridgeT] =
InMemoryTransport.createLinkedPair();
@ -2034,6 +2074,9 @@ describe("App <-> AppBridge integration", () => {
{},
{ autoResize: false, strict: true },
);
// Pre-connect registration for toolcancelled — later swap must not throw.
strictApp.addEventListener("toolcancelled", () => {});
await strictBridge.connect(strictBridgeT);
await strictApp.connect(strictAppT);
@ -2043,10 +2086,57 @@ describe("App <-> AppBridge integration", () => {
expect(() => {
strictApp.addEventListener("toolinput", () => {});
}).toThrow(lateMsg);
expect(warnings()).toEqual([]);
// Swapping a handler that existed pre-connect is allowed under strict.
expect(() => {
strictApp.addEventListener("toolcancelled", () => {});
}).not.toThrow();
expect(warnings().filter((m) => lateMsg.test(m))).toEqual([]);
});
});
it("AppBridge warns on a second ui/initialize (View double-mount)", async () => {
await bridge.connect(bridgeTransport);
await app.connect(appTransport);
expect(warnings()).toEqual([]);
// Simulate a second View instance re-running the handshake.
appTransport.send({
jsonrpc: "2.0",
id: 99,
method: "ui/initialize",
params: {
protocolVersion: LATEST_PROTOCOL_VERSION,
appInfo: testAppInfo,
appCapabilities: {},
},
});
await flush();
const doubleInit = warnings().filter((m) =>
/second ui\/initialize/.test(m),
);
expect(doubleInit).toHaveLength(1);
});
it("close() stops further notification delivery (StrictMode cleanup relies on this)", async () => {
const received: unknown[] = [];
app.addEventListener("toolresult", (r) => received.push(r));
await bridge.connect(bridgeTransport);
await app.connect(appTransport);
await bridge.sendToolResult({
content: [{ type: "text", text: "before" }],
});
expect(received).toHaveLength(1);
await app.close();
await bridge
.sendToolResult({ content: [{ type: "text", text: "after" }] })
.catch(() => {});
expect(received).toHaveLength(1);
});
it("AppBridge warns on tools/call from a View that skipped the handshake", async () => {
bridge.oncalltool = async () => ({ content: [] });
await bridge.connect(bridgeTransport);