feat(standalone): "open in a new tab" escape hatch on OOM dialog (0002)

The automatic recovery uses an in-place location.replace reload, which does NOT
reliably release the OOM'd wasm heap in every browser (notably Firefox), so the
respawn can land right back in an out-of-memory state.

Add a user-gesture escape hatch to the terminal MemoryExhaustedDialog: an "Open
in a new tab" button that opens the editor in a fresh top-level browsing context
(clean retry chain) and closes the current tab. Because it runs from a real
click, window.open is not popup-blocked; window.close() may still be refused on
the original user-opened tab, which is fine — the new tab is the survivor. Copy
tells the user this sometimes helps where the in-place reload didn't.

- oom-watch.ts: export respawnInNewTab(win) — fresh-tab respawn with ?oomRetry
  cleared.
- MemoryExhaustedDialog: "Open in a new tab" (primary) + "Reload this tab"
  (secondary), with honest copy about Firefox + unsaved edits.
- WasmTool: wire onOpenNewTab to respawnInNewTab.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergő Törcsvári 2026-06-10 15:41:18 +02:00
commit 0773acefaa
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
3 changed files with 61 additions and 10 deletions

View file

@ -13,7 +13,7 @@ import { memfsProjectDir } from "@/wasm/constants";
import { driveProjectIntoTool, type ToolFile } from "@/wasm/kicad-runner";
import type { CollabWindow } from "@/wasm/collab";
import { clog, cwarn } from "@/wasm/collab/debug";
import { createOomWatch } from "@/recovery/oom-watch";
import { createOomWatch, respawnInNewTab } from "@/recovery/oom-watch";
import { MemoryExhaustedDialog } from "@/recovery/MemoryExhaustedDialog";
// Tools with a working collab bridge (kicadCollabSnapshot/Apply embind exports).
@ -349,7 +349,10 @@ export function WasmTool({
<div id="window-container" />
{oomExhausted && (
<MemoryExhaustedDialog onReload={() => window.location.reload()} />
<MemoryExhaustedDialog
onOpenNewTab={() => respawnInNewTab()}
onReload={() => window.location.reload()}
/>
)}
{status && (

View file

@ -4,20 +4,38 @@ import { BlockingDialog } from "@/preflight/BlockingDialog";
* Terminal "not enough memory" UI for feature 0002 — shown after the OOM retry
* chain hits MAX_RETRIES. Reuses 0001's blocking dialog for visual consistency.
*
* The copy is honest about the two limitations the spec calls out: a respawn
* re-opens the same file but loses in-tab edits not yet pushed to the backend /
* Y.Doc, and `window.close()` may not close the very first (user-opened) tab.
* Offers two manual actions:
* - "Open in a new tab" (primary): a user-gesture respawn in a fresh browsing
* context. This sometimes succeeds where the automatic in-place reload did
* not, because a brand-new tab is the most reliable way to actually release
* the OOM'd wasm heap (an in-place reload doesn't, reliably, in every
* browser — notably Firefox).
* - "Reload this tab" (secondary): the simple in-place retry.
*
* Copy is honest about lost unsaved edits (a respawn re-opens the same file but
* loses in-tab edits not yet synced to the backend / Y.Doc).
*/
export function MemoryExhaustedDialog({ onReload }: { onReload?: () => void }) {
export function MemoryExhaustedDialog({
onOpenNewTab,
onReload,
}: {
onOpenNewTab?: () => void;
onReload?: () => void;
}) {
return (
<BlockingDialog
title="Your device ran out of memory"
description="The editor tried to recover a few times but kept running out of memory, so it stopped to avoid looping. This design is likely too large for this device's available memory."
reasons={[
{
title: "What you can try",
title: "Try a fresh tab",
detail:
"Close other tabs and applications to free memory, use a desktop machine with more RAM, or open a smaller design.",
"Opening the editor in a brand-new browser tab (and closing this one) sometimes frees enough memory to continue — especially in Firefox, where reloading in place may not fully release the previous session's memory.",
},
{
title: "Free up memory",
detail:
"Close other tabs and applications, use a desktop machine with more RAM, or open a smaller design.",
},
{
title: "Unsaved changes",
@ -26,10 +44,13 @@ export function MemoryExhaustedDialog({ onReload }: { onReload?: () => void }) {
},
]}
primary={
onReload
? { label: "Reload and try again", onClick: onReload }
onOpenNewTab
? { label: "Open in a new tab", onClick: onOpenNewTab }
: undefined
}
secondary={
onReload ? { label: "Reload this tab", onClick: onReload } : undefined
}
/>
);
}

View file

@ -308,6 +308,33 @@ export function createOomWatch(opts: OomWatchOptions): OomWatch {
return { start, stop, onAbort };
}
/**
* User-gesture escape hatch for the terminal dialog: open the editor in a brand
* new tab with a clean retry chain, then close this one.
*
* This is deliberately NOT the automatic recovery path: it runs from a real
* click, so `window.open` is not popup-blocked, and a fresh top-level browsing
* context is the most reliable way to actually drop the OOM'd wasm heap — an
* in-place `location.replace` reload does not reliably release it in every
* browser (notably Firefox). `window.close()` may still be refused on the
* original, user-opened tab; that's fine — the new tab is the working one and
* the user can close the old one. Returns whether a new tab was opened.
*/
export function respawnInNewTab(win: Window = window): boolean {
const url = new URL(win.location.href);
url.searchParams.delete(RETRY_PARAM); // fresh chain — this is a manual retry
const opened = win.open(url.toString(), "_blank");
if (opened) {
try {
win.close();
} catch {
/* original tab may refuse to close — new tab is the survivor anyway */
}
return true;
}
return false;
}
function safeLocalStorage(win: Window): Storage | null {
try {
return win.localStorage;