Describe the bug
When the editor is embedded as an iframe with allowParentController (controller mode), postHostMessageAsync in pxteditor/editorcontroller.ts creates a Promise that only resolves when the host posts back a pxthost message with a matching id. There is no timeout and no reject path anywhere in the codebase, so if the host is slow to respond or drops the message, the Promise stays pending forever.
Because workspace.initAsync() → syncAsync() → iframeworkspace.listAsync() calls postHostMessageAsync({ action: "workspacesync", response: true }), the entire bootstrap .then chain in webapp/src/app.tsx halts. The #loading overlay (defined statically in webapp/public/index.html) is only removed near the end of that chain, so users see an infinite loading spinner.
The existing fallback syncAsync().catch(() => switchToMemoryWorkspace(...)) at webapp/src/workspace.ts:~1692 is already wired to recover from a rejection, but never triggers because no rejection is ever produced.
To Reproduce
- Open any MakeCode editor embedded as an iframe in a host that implements the workspacesync / workspaceloaded protocol
- Open Chrome DevTools → Network tab → set throttling to Slow 4G
- Reload the page
- The loading spinner remains indefinitely; the editor never mounts
- Switch throttling back to No throttling → the editor eventually loads as the delayed host response arrives
Expected behavior
postHostMessageAsync should reject pending requests after a reasonable timeout (e.g. 30s), allowing the already-present switchToMemoryWorkspace fallback in syncAsync to take over so the editor at least boots with an in-memory workspace instead of hanging forever.
Verification of fix direction
Verified in-browser without redeploying by attaching a DevTools conditional breakpoint on the pendingRequests[env.id] = { resolve, reject } line with this
expression:
(function(){var id=env.id,a=env.action;setTimeout(function(){var r=pendingRequests[id];if(r){console.warn("[POSTHOST TIMEOUT]",a,id);delete
pendingRequests[id];r.reject(new Error("host no-response: "+a));}},15000);return false;})()
Under Slow 4G reproduction, both workspacesync and workspaceloaded time out at 15s, the syncAsync catch triggers, memory workspace fallback kicks in, and the
editor renders normally. Confirms that adding a timeout alone is sufficient to unblock the UI.
Proposed fix (sketch)
Add a timeout in postHostMessageAsync when msg.response === true:
const HOST_MESSAGE_TIMEOUT_MS = 15000; // or configurable
if (msg.response) {
pendingRequests[env.id] = { resolve, reject };
setTimeout(() => {
const req = pendingRequests[env.id];
if (req) {
delete pendingRequests[env.id];
pxt.warn(`postHostMessageAsync timeout: ${env.action} (${env.id})`);
req.reject(new Error(`host no-response: ${env.action}`));
}
}, HOST_MESSAGE_TIMEOUT_MS);
}
Happy to open a PR if the direction is acceptable. Open questions for the maintainers:
- Default timeout value (30s suggested to accommodate legitimately slow host workflows like OAuth)
- Whether to expose it as a per-request opt-out (e.g. msg.timeout, 0 = no timeout to preserve current behavior for hosts that need it)
- Whether to apply the same treatment to iframeDriver.sendRequest for symmetry
Desktop (please complete the following information):
- OS: win11
- Browser : chrome 147.0.7727.102
- Version : pxt-core 12.3.5
Describe the bug
When the editor is embedded as an iframe with allowParentController (controller mode), postHostMessageAsync in pxteditor/editorcontroller.ts creates a Promise that only resolves when the host posts back a pxthost message with a matching id. There is no timeout and no reject path anywhere in the codebase, so if the host is slow to respond or drops the message, the Promise stays pending forever.
Because workspace.initAsync() → syncAsync() → iframeworkspace.listAsync() calls postHostMessageAsync({ action: "workspacesync", response: true }), the entire bootstrap .then chain in webapp/src/app.tsx halts. The #loading overlay (defined statically in webapp/public/index.html) is only removed near the end of that chain, so users see an infinite loading spinner.
The existing fallback syncAsync().catch(() => switchToMemoryWorkspace(...)) at webapp/src/workspace.ts:~1692 is already wired to recover from a rejection, but never triggers because no rejection is ever produced.
To Reproduce
Expected behavior
postHostMessageAsync should reject pending requests after a reasonable timeout (e.g. 30s), allowing the already-present switchToMemoryWorkspace fallback in syncAsync to take over so the editor at least boots with an in-memory workspace instead of hanging forever.
Verification of fix direction
Verified in-browser without redeploying by attaching a DevTools conditional breakpoint on the pendingRequests[env.id] = { resolve, reject } line with this
expression:
Under Slow 4G reproduction, both workspacesync and workspaceloaded time out at 15s, the syncAsync catch triggers, memory workspace fallback kicks in, and the
editor renders normally. Confirms that adding a timeout alone is sufficient to unblock the UI.
Proposed fix (sketch)
Add a timeout in postHostMessageAsync when msg.response === true:
Happy to open a PR if the direction is acceptable. Open questions for the maintainers:
Desktop (please complete the following information):