Skip to content

Commit cc02e17

Browse files
authored
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.
1 parent 9658678 commit cc02e17

4 files changed

Lines changed: 133 additions & 12 deletions

File tree

src/app-bridge.test.ts

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
import { z } from "zod/v4";
1616

1717
import { App } from "./app";
18+
import { LATEST_PROTOCOL_VERSION } from "./types";
1819
import {
1920
AppBridge,
2021
getToolUiResourceUri,
@@ -2021,6 +2022,45 @@ describe("App <-> AppBridge integration", () => {
20212022
expect(warnings()).toEqual([]);
20222023
});
20232024

2025+
it("does not warn on re-registration when a handler existed pre-connect", async () => {
2026+
const h = () => {};
2027+
app.addEventListener("toolresult", h);
2028+
await bridge.connect(bridgeTransport);
2029+
await app.connect(appTransport);
2030+
2031+
// React-style: useEffect cleanup removes, dep change re-adds.
2032+
app.removeEventListener("toolresult", h);
2033+
app.addEventListener("toolresult", () => {});
2034+
2035+
expect(warnings().filter((m) => lateMsg.test(m))).toEqual([]);
2036+
});
2037+
2038+
it("warns once for the first late registration, not on subsequent ones", async () => {
2039+
await bridge.connect(bridgeTransport);
2040+
await app.connect(appTransport);
2041+
2042+
app.addEventListener("toolinput", () => {});
2043+
app.addEventListener("toolinput", () => {});
2044+
app.addEventListener("toolinput", () => {});
2045+
2046+
const late = warnings().filter((m) => lateMsg.test(m));
2047+
expect(late).toHaveLength(1);
2048+
expect(late[0]).toContain('"toolinput"');
2049+
});
2050+
2051+
it("tracks first-handler per event independently", async () => {
2052+
app.addEventListener("toolinput", () => {});
2053+
await bridge.connect(bridgeTransport);
2054+
await app.connect(appTransport);
2055+
2056+
app.addEventListener("toolinput", () => {}); // had pre-connect handler → silent
2057+
app.addEventListener("toolresult", () => {}); // first reg, late → warns
2058+
2059+
const late = warnings().filter((m) => lateMsg.test(m));
2060+
expect(late).toHaveLength(1);
2061+
expect(late[0]).toContain('"toolresult"');
2062+
});
2063+
20242064
it("throws instead of warning when strict: true", async () => {
20252065
const [strictAppT, strictBridgeT] =
20262066
InMemoryTransport.createLinkedPair();
@@ -2034,6 +2074,9 @@ describe("App <-> AppBridge integration", () => {
20342074
{},
20352075
{ autoResize: false, strict: true },
20362076
);
2077+
// Pre-connect registration for toolcancelled — later swap must not throw.
2078+
strictApp.addEventListener("toolcancelled", () => {});
2079+
20372080
await strictBridge.connect(strictBridgeT);
20382081
await strictApp.connect(strictAppT);
20392082

@@ -2043,8 +2086,55 @@ describe("App <-> AppBridge integration", () => {
20432086
expect(() => {
20442087
strictApp.addEventListener("toolinput", () => {});
20452088
}).toThrow(lateMsg);
2046-
expect(warnings()).toEqual([]);
2089+
// Swapping a handler that existed pre-connect is allowed under strict.
2090+
expect(() => {
2091+
strictApp.addEventListener("toolcancelled", () => {});
2092+
}).not.toThrow();
2093+
expect(warnings().filter((m) => lateMsg.test(m))).toEqual([]);
2094+
});
2095+
});
2096+
2097+
it("AppBridge warns on a second ui/initialize (View double-mount)", async () => {
2098+
await bridge.connect(bridgeTransport);
2099+
await app.connect(appTransport);
2100+
expect(warnings()).toEqual([]);
2101+
2102+
// Simulate a second View instance re-running the handshake.
2103+
appTransport.send({
2104+
jsonrpc: "2.0",
2105+
id: 99,
2106+
method: "ui/initialize",
2107+
params: {
2108+
protocolVersion: LATEST_PROTOCOL_VERSION,
2109+
appInfo: testAppInfo,
2110+
appCapabilities: {},
2111+
},
20472112
});
2113+
await flush();
2114+
2115+
const doubleInit = warnings().filter((m) =>
2116+
/second ui\/initialize/.test(m),
2117+
);
2118+
expect(doubleInit).toHaveLength(1);
2119+
});
2120+
2121+
it("close() stops further notification delivery (StrictMode cleanup relies on this)", async () => {
2122+
const received: unknown[] = [];
2123+
app.addEventListener("toolresult", (r) => received.push(r));
2124+
await bridge.connect(bridgeTransport);
2125+
await app.connect(appTransport);
2126+
2127+
await bridge.sendToolResult({
2128+
content: [{ type: "text", text: "before" }],
2129+
});
2130+
expect(received).toHaveLength(1);
2131+
2132+
await app.close();
2133+
2134+
await bridge
2135+
.sendToolResult({ content: [{ type: "text", text: "after" }] })
2136+
.catch(() => {});
2137+
expect(received).toHaveLength(1);
20482138
});
20492139

20502140
it("AppBridge warns on tools/call from a View that skipped the handshake", async () => {

src/app-bridge.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1463,6 +1463,15 @@ export class AppBridge extends ProtocolWithEvents<
14631463
): Promise<McpUiInitializeResult> {
14641464
const requestedVersion = request.params.protocolVersion;
14651465

1466+
if (this._appInfo !== undefined) {
1467+
console.warn(
1468+
"[ext-apps] AppBridge received a second ui/initialize. The View may " +
1469+
"be double-mounting (e.g. React StrictMode in dev) without closing " +
1470+
"the previous App instance. Responding normally; the latest " +
1471+
"appInfo/appCapabilities replace the previous values.",
1472+
);
1473+
}
1474+
14661475
this._appCapabilities = request.params.appCapabilities;
14671476
this._appInfo = request.params.appInfo;
14681477

src/app.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -395,14 +395,28 @@ export class App extends ProtocolWithEvents<
395395
new Set(["toolinput", "toolinputpartial", "toolresult", "toolcancelled"]);
396396

397397
/**
398-
* Warn (or throw under `strict`) when a one-shot event handler is registered
399-
* after the `ui/initialize` → `ui/notifications/initialized` handshake has
400-
* completed. The host may have already fired the notification by then.
398+
* One-shot events that have had at least one handler registered (via `on*`
399+
* setter or `addEventListener`) at any point. Once an event is in this set,
400+
* subsequent late registrations are not flagged — only the *first* handler
401+
* matters for the missed-notification race, and re-registration (e.g. React
402+
* `useEffect` cleanup → re-add on dep change) is a legitimate pattern.
403+
*/
404+
private readonly _everHadListener = new Set<keyof AppEventMap>();
405+
406+
/**
407+
* Warn (or throw under `strict`) when the *first* handler for a one-shot
408+
* event is registered after the `ui/initialize` → `ui/notifications/initialized`
409+
* handshake has completed. The host may have already fired the notification
410+
* by then. Subsequent registrations for the same event are not flagged.
401411
*
402412
* Mirrors {@link _assertInitialized `_assertInitialized`} (the outbound-side guard).
403413
*/
404414
private _assertHandlerTiming(event: keyof AppEventMap): void {
405-
if (!this._initializedSent || !App.ONE_SHOT_EVENTS.has(event)) return;
415+
if (!App.ONE_SHOT_EVENTS.has(event) || this._everHadListener.has(event)) {
416+
return;
417+
}
418+
this._everHadListener.add(event);
419+
if (!this._initializedSent) return;
406420
const msg =
407421
`[ext-apps] "${String(event)}" handler registered after connect() ` +
408422
`completed the ui/initialize handshake. The host may have already sent ` +

src/react/useApp.tsx

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -138,25 +138,32 @@ export function useApp({
138138

139139
useEffect(() => {
140140
let mounted = true;
141+
let appInstance: App | undefined;
141142

142143
async function connect() {
143144
try {
144145
const transport = new PostMessageTransport(
145146
window.parent,
146147
window.parent,
147148
);
148-
const app = new App(appInfo, capabilities, { autoResize, strict });
149+
appInstance = new App(appInfo, capabilities, { autoResize, strict });
149150

150151
// Register handlers BEFORE connecting
151-
onAppCreated?.(app);
152+
onAppCreated?.(appInstance);
152153

153-
await app.connect(transport);
154+
await appInstance.connect(transport);
154155

155-
if (mounted) {
156-
setApp(app);
157-
setIsConnected(true);
158-
setError(null);
156+
if (!mounted) {
157+
// Unmounted during the handshake (e.g. React StrictMode dev
158+
// double-invoke). Close so the abandoned transport's window
159+
// `message` listener doesn't keep receiving host postMessages
160+
// alongside the second mount's instance.
161+
void appInstance.close();
162+
return;
159163
}
164+
setApp(appInstance);
165+
setIsConnected(true);
166+
setError(null);
160167
} catch (error) {
161168
if (mounted) {
162169
setApp(null);
@@ -172,6 +179,7 @@ export function useApp({
172179

173180
return () => {
174181
mounted = false;
182+
void appInstance?.close();
175183
};
176184
}, []); // Intentionally not including options to avoid reconnection
177185

0 commit comments

Comments
 (0)