Skip to content

AppFrame sandbox timeout not cleared on effect cleanup — unhandled rejection in React Strict Mode #189

@matsjfunke

Description

@matsjfunke

Bug

AppRenderer produces an unhandled "Timed out waiting for sandbox proxy iframe to be ready" rejection ~10s after mount when React Strict Mode is enabled. The UI renders correctly — the error comes from the first (cleaned-up) effect run's orphaned timeout.

I have to ship a patched version of @mcp-ui/client in production to avoid this error in development which is suboptimal.

Version: @mcp-ui/client@7.0.0, React 19.2.4, Next.js 16.1.7 (Turbopack)

Cause

Cv() (the sandbox iframe factory) creates a 10s setTimeout inside a Promise constructor. The timeout ID is scoped inside the Promise and not exposed:

// dist/index.mjs ~L5598
async function Cv(e) {
  const i = document.createElement("iframe");
  const n = new Promise((a, t) => {
    const u = setTimeout(() => {
      t(new Error("Timed out waiting for sandbox proxy iframe to be ready"));
    }, 1e4);
    // ...
  });
  return { iframe: i, onReady: n }; // no way to cancel `u`
}

AppFrame's effect cleanup only sets H = false — it does not clear the timeout:

// dist/index.mjs ~L5687
() => { H = !1; };

With Strict Mode: mount → cleanup (timeout still ticking) → remount (succeeds) → 10s later the orphaned timeout rejects with no .catch().

Important timing subtlety

A naive fix of returning cancel from Cv() and assigning it after await Cv(R) does not work:

// DOES NOT WORK — _cancel is assigned after `await`, but cleanup runs synchronously
// before the await resolves
let _cancel;
return (async () => {
  const { cancel } = await Cv(R); // await yields here
  _cancel = cancel;               // assigned in microtask — too late
})(), () => {
  H = !1;
  _cancel();  // still undefined when cleanup fires!
};

React's strict mode cleanup runs synchronously between effect invocations. Since Cv is an async function, await Cv(R) yields to the microtask queue even though Cv's body is synchronous. The cleanup fires in that gap before _cancel is assigned.

Suggested Fix

Pass a mutable ref object into Cv() that it populates synchronously inside the Promise constructor (before any async yield):

async function Cv(e, cancelRef) {
  const i = document.createElement("iframe");
  const n = new Promise((a, t) => {
    let r = !1;
    const o = () => { /* remove listeners */ };
    const u = setTimeout(() => {
      r || (r = !0, o(), t(new Error("Timed out waiting for sandbox proxy iframe to be ready")));
    }, 1e4);
    // Written synchronously during Promise construction — available immediately
    if (cancelRef) cancelRef.cancel = () => { r = !0; clearTimeout(u); o(); };
    // ...
  });
  return i.src = e.href, { iframe: i, onReady: n };
}

In AppFrame:

const _cancelRef = {};                              // created synchronously
return (async () => {
  const { iframe, onReady } = await Cv(R, _cancelRef); // populates _cancelRef.cancel synchronously
  // ...
})(), () => {
  H = !1;
  if (typeof _cancelRef.cancel === "function") _cancelRef.cancel(); // always available
};

This works because Cv's body (including the Promise constructor) executes synchronously before the await yields, so _cancelRef.cancel is set before React can fire cleanup.

Reproduction

Any React app with <React.StrictMode> (or reactStrictMode: true in Next.js) rendering <AppRenderer>. Wait ~10s after mount — the error overlay appears. The UI itself works fine.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions