fix(editor): show a fatal overlay instead of a blank page, above the console
The boot overlay only renders while `!ready`, so anything that killed the runtime AFTER the editor came up — a wasm trap, an abort, a staging fetch failing late — unmounted the last thing on screen and left a white page with no explanation. Adds a `fatal` state rendered INDEPENDENTLY of `ready`, set both from the boot catch and from window error / unhandledrejection listeners that promote only genuinely terminal signatures (RuntimeError, abort, table index out of bounds, indirect call signature, unreachable). Ordinary app errors must not hijack a working editor, so anything else is ignored. The console panel moves from z-20 to z-40, above both overlays, and is forced visible on a fatal even with chrome hidden: when a load fails, that log is the only account of what was loading when it happened, and it was being covered by the very overlay reporting the failure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0137pGo8W7asomGUTRMB7RzM
This commit is contained in:
parent
3b5d4d0662
commit
1ab46eacd7
1 changed files with 76 additions and 2 deletions
|
|
@ -927,6 +927,11 @@ export function WasmTool({
|
||||||
const [logs, setLogs] = React.useState<string[]>([]);
|
const [logs, setLogs] = React.useState<string[]>([]);
|
||||||
const [showLog, setShowLog] = React.useState(false);
|
const [showLog, setShowLog] = React.useState(false);
|
||||||
const [oomExhausted, setOomExhausted] = React.useState(false);
|
const [oomExhausted, setOomExhausted] = React.useState(false);
|
||||||
|
// Terminal failure, rendered INDEPENDENTLY of `ready`. The boot overlay only
|
||||||
|
// exists while `!ready`, so anything that killed the runtime after the editor
|
||||||
|
// came up (a wasm abort/trap, a failed staging fetch surfacing late) used to
|
||||||
|
// leave a blank page with no explanation at all — the "white screen of death".
|
||||||
|
const [fatal, setFatal] = React.useState<string | null>(null);
|
||||||
// Editor lifecycle for the loading chrome: false until the tool has booted +
|
// Editor lifecycle for the loading chrome: false until the tool has booted +
|
||||||
// opened (covers the big WASM-compile freeze with a full-screen overlay).
|
// opened (covers the big WASM-compile freeze with a full-screen overlay).
|
||||||
const [ready, setReady] = React.useState(false);
|
const [ready, setReady] = React.useState(false);
|
||||||
|
|
@ -1141,6 +1146,37 @@ export function WasmTool({
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [ready, consent]);
|
}, [ready, consent]);
|
||||||
|
|
||||||
|
// Runtime death AFTER the boot succeeded. A wasm trap ("indirect call
|
||||||
|
// signature mismatch", "table index is out of bounds") or an abort() arrives
|
||||||
|
// as a window error / unhandled rejection long after `ready` flipped, with the
|
||||||
|
// boot overlay already gone — so without this the page just goes blank. Only
|
||||||
|
// genuinely terminal signatures promote to the fatal overlay; ordinary app
|
||||||
|
// errors must not hijack a working editor.
|
||||||
|
React.useEffect(() => {
|
||||||
|
const terminal = (msg: string) =>
|
||||||
|
/RuntimeError|\babort(ed)?\b|table index is out of bounds|indirect call signature|memory access out of bounds|unreachable executed/i.test(
|
||||||
|
msg,
|
||||||
|
);
|
||||||
|
const onError = (e: ErrorEvent) => {
|
||||||
|
const msg = e.error instanceof Error ? `${e.error.message}` : String(e.message ?? "");
|
||||||
|
if (!terminal(msg)) return;
|
||||||
|
append(`[fatal] window error: ${msg}`);
|
||||||
|
setFatal(msg);
|
||||||
|
};
|
||||||
|
const onRejection = (e: PromiseRejectionEvent) => {
|
||||||
|
const msg = e.reason instanceof Error ? e.reason.message : String(e.reason ?? "");
|
||||||
|
if (!terminal(msg)) return;
|
||||||
|
append(`[fatal] unhandled rejection: ${msg}`);
|
||||||
|
setFatal(msg);
|
||||||
|
};
|
||||||
|
window.addEventListener("error", onError);
|
||||||
|
window.addEventListener("unhandledrejection", onRejection);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("error", onError);
|
||||||
|
window.removeEventListener("unhandledrejection", onRejection);
|
||||||
|
};
|
||||||
|
}, [append]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const win = window as ToolWindow;
|
const win = window as ToolWindow;
|
||||||
const removeNavigationHook = installToolNavigationHook(win, {
|
const removeNavigationHook = installToolNavigationHook(win, {
|
||||||
|
|
@ -1724,6 +1760,7 @@ export function WasmTool({
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
append(`[fatal] ${String(err)}`);
|
append(`[fatal] ${String(err)}`);
|
||||||
setStatus(`Error: ${String(err)}`);
|
setStatus(`Error: ${String(err)}`);
|
||||||
|
setFatal(String(err));
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
@ -2150,8 +2187,45 @@ export function WasmTool({
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!effectiveChromeHidden && (
|
{/* Terminal failure — z-35, ABOVE the boot overlay but below the console
|
||||||
<div className="absolute bottom-0 left-0 right-0 z-20">
|
panel, and independent of `ready` so a post-boot runtime death still
|
||||||
|
says something instead of blanking the page. */}
|
||||||
|
{fatal && (
|
||||||
|
<div
|
||||||
|
data-testid="fatal-overlay"
|
||||||
|
className="absolute inset-0 z-[35] flex flex-col items-center justify-center gap-3 bg-[#1a1a2e]/95 text-white"
|
||||||
|
>
|
||||||
|
<p className="font-mono text-sm text-red-300">Something went wrong</p>
|
||||||
|
<p className="max-w-lg px-6 text-center font-mono text-xs text-red-200/80">
|
||||||
|
{fatal}
|
||||||
|
</p>
|
||||||
|
<p className="max-w-md px-6 text-center font-mono text-xs text-white/50">
|
||||||
|
The editor stopped. Open the console below for the full log — it
|
||||||
|
records what was loading when this happened.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
className="rounded border border-white/30 px-3 py-1 text-xs hover:bg-white/10"
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
>
|
||||||
|
Reload
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded border border-white/30 px-3 py-1 text-xs hover:bg-white/10"
|
||||||
|
onClick={() => setShowLog(true)}
|
||||||
|
>
|
||||||
|
Show console
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* z-40 (above the z-30 boot overlay and the z-35 fatal overlay): when a
|
||||||
|
load fails, the log this panel holds is the only account of WHY, so it
|
||||||
|
must never end up underneath the thing reporting the failure. Forced
|
||||||
|
visible on a fatal even with chrome hidden, for the same reason. */}
|
||||||
|
{(!effectiveChromeHidden || fatal) && (
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 z-40">
|
||||||
<button
|
<button
|
||||||
className="flex items-center gap-1 bg-black/70 px-3 py-1 font-mono text-xs text-white"
|
className="flex items-center gap-1 bg-black/70 px-3 py-1 font-mono text-xs text-white"
|
||||||
onClick={() => setShowLog((s) => !s)}
|
onClick={() => setShowLog((s) => !s)}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue