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

@ -138,6 +138,7 @@ export function useApp({
useEffect(() => {
let mounted = true;
let appInstance: App | undefined;
async function connect() {
try {
@ -145,18 +146,24 @@ export function useApp({
window.parent,
window.parent,
);
const app = new App(appInfo, capabilities, { autoResize, strict });
appInstance = new App(appInfo, capabilities, { autoResize, strict });
// Register handlers BEFORE connecting
onAppCreated?.(app);
onAppCreated?.(appInstance);
await app.connect(transport);
await appInstance.connect(transport);
if (mounted) {
setApp(app);
setIsConnected(true);
setError(null);
if (!mounted) {
// Unmounted during the handshake (e.g. React StrictMode dev
// double-invoke). Close so the abandoned transport's window
// `message` listener doesn't keep receiving host postMessages
// alongside the second mount's instance.
void appInstance.close();
return;
}
setApp(appInstance);
setIsConnected(true);
setError(null);
} catch (error) {
if (mounted) {
setApp(null);
@ -172,6 +179,7 @@ export function useApp({
return () => {
mounted = false;
void appInstance?.close();
};
}, []); // Intentionally not including options to avoid reconnection