feat: editor loading chrome — boot overlay, item spinner, error toast

The WASM tools boot + fetch lib items with no visible feedback, so a big tool
download looks like a freeze and a failed body fetch (e.g. a backend 404) looks
like nothing happened. Add a full-screen boot overlay that stays until the wx UI
has actually built (waitForWxUi, gated on wxElementRegistry — not just boot
resolve, which flashes a blank editor), a debounced spinner while an item is
being opened/saved, and an auto-dismissing error toast when a body can't be
loaded. Driven by events the libs bridge now dispatches (LIB_BUSY/LIB_ERROR).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergő Törcsvári 2026-06-16 09:31:47 +02:00
commit 92b5fd7a42
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
2 changed files with 166 additions and 5 deletions

View file

@ -11,7 +11,7 @@ import {
type KicadDoc,
type Tool,
} from "@pcbjam/shared";
import { ChevronDown, ChevronUp } from "lucide-react";
import { ChevronDown, ChevronUp, Loader2 } from "lucide-react";
import {
libsSourceConfig,
WASM_ASSET_BASE_URL,
@ -19,7 +19,13 @@ import {
type DocSource,
} from "@/lib/config";
import { bootKicadTool } from "@/wasm/boot";
import type { LibsSource } from "@/wasm/libs/source";
import {
LIB_BUSY_EVENT,
LIB_ERROR_EVENT,
type LibBusyDetail,
type LibErrorDetail,
type LibsSource,
} from "@/wasm/libs/source";
import { memfsFilePath, memfsProjectDir } from "@/wasm/constants";
import { driveProjectIntoTool, type ToolFile } from "@/wasm/kicad-runner";
import { registerSaveHook, type SaveBytes } from "@/wasm/save-flow";
@ -332,6 +338,21 @@ async function maybeStartCollab(
clog("connected ✓");
}
/**
* Wait until the wxWidgets UI has actually built some elements it populates a
* frame or two AFTER the boot sequence resolves, so dropping the loading overlay
* on boot-resolve flashes a blank editor. Polls `wxElementRegistry` (the same
* "UI built" signal the e2e suite uses) and falls through after a timeout so a
* tool with a minimal UI can never hang the overlay.
*/
async function waitForWxUi(win: ToolWindow, timeoutMs = 25_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if ((win.wxElementRegistry?.findAll({}).length ?? 0) > 3) return;
await new Promise((r) => setTimeout(r, 150));
}
}
/**
* Boots a KiCad tool directly in this React document (no iframe): builds the
* Emscripten `Module` config, injects the proven harness scripts (wx.js +
@ -388,6 +409,13 @@ export function WasmTool({
const [logs, setLogs] = React.useState<string[]>([]);
const [showLog, setShowLog] = React.useState(false);
const [oomExhausted, setOomExhausted] = React.useState(false);
// Editor lifecycle for the loading chrome: false until the tool has booted +
// opened (covers the big WASM-compile freeze with a full-screen overlay).
const [ready, setReady] = React.useState(false);
// A library item currently being fetched (open/save), for a transient spinner.
const [libBusy, setLibBusy] = React.useState<string | null>(null);
// Last lib error (e.g. a backend 404 on open), shown as a dismissible toast.
const [libError, setLibError] = React.useState<string | null>(null);
const base = (assetBaseUrl ?? WASM_ASSET_BASE_URL).replace(/\/$/, "");
const append = React.useCallback(
@ -395,6 +423,40 @@ export function WasmTool({
[],
);
// Loading/error chrome for library item fetches (open/save), driven by events
// the libs bridge dispatches (wasm/libs/source). The fetch is otherwise
// invisible; a 404 would silently do nothing without this.
React.useEffect(() => {
let busyTimer: ReturnType<typeof setTimeout> | undefined;
const onBusy = (e: Event) => {
const d = (e as CustomEvent<LibBusyDetail>).detail;
clearTimeout(busyTimer);
if (d.busy) {
// Debounce — only flag slow fetches, so fast ones don't flicker.
busyTimer = setTimeout(() => setLibBusy(d.name || "library item"), 180);
} else {
setLibBusy(null);
}
};
const onError = (e: Event) => {
setLibError((e as CustomEvent<LibErrorDetail>).detail.message);
};
window.addEventListener(LIB_BUSY_EVENT, onBusy);
window.addEventListener(LIB_ERROR_EVENT, onError);
return () => {
clearTimeout(busyTimer);
window.removeEventListener(LIB_BUSY_EVENT, onBusy);
window.removeEventListener(LIB_ERROR_EVENT, onError);
};
}, []);
// Auto-dismiss the lib error toast.
React.useEffect(() => {
if (!libError) return;
const t = setTimeout(() => setLibError(null), 6000);
return () => clearTimeout(t);
}, [libError]);
React.useEffect(() => {
const removeNavigationHook = installToolNavigationHook(window as ToolWindow, {
slug,
@ -488,6 +550,11 @@ export function WasmTool({
log: append,
onStatus: setStatus,
});
// Tool booted + project opened. Wait for the wx UI to actually build
// before dropping the overlay, so we don't reveal a still-blank editor.
await waitForWxUi(win);
setStatus("");
setReady(true);
} catch (err) {
append(`[fatal] ${String(err)}`);
setStatus(`Error: ${String(err)}`);
@ -521,12 +588,61 @@ export function WasmTool({
/>
)}
{status && (
{/* Boot overlay covers the big WASM download/compile freeze until the
tool has booted + opened. */}
{!ready && (
<div className="absolute inset-0 z-30 flex flex-col items-center justify-center gap-3 bg-[#1a1a2e] text-white">
{status.startsWith("Error") ? (
<>
<p className="max-w-md px-6 text-center font-mono text-sm text-red-300">
{status}
</p>
<button
className="rounded border border-white/30 px-3 py-1 text-xs hover:bg-white/10"
onClick={() => window.location.reload()}
>
Reload
</button>
</>
) : (
<>
<Loader2 className="animate-spin" size={32} />
<p className="font-mono text-sm text-white/80">
{status || "Loading…"}
</p>
<p className="font-mono text-xs text-white/40">
First load downloads the tool (large) this can take a moment.
</p>
</>
)}
</div>
)}
{/* Transient post-boot status (e.g. file open). */}
{ready && status && (
<div className="pointer-events-none absolute left-3 top-3 z-20 rounded bg-black/70 px-3 py-2 font-mono text-xs text-white">
{status}
</div>
)}
{/* A library item is being fetched (open/save). */}
{ready && libBusy && (
<div className="pointer-events-none absolute left-1/2 top-3 z-20 flex -translate-x-1/2 items-center gap-2 rounded bg-black/80 px-3 py-1.5 text-xs text-white">
<Loader2 className="animate-spin" size={14} /> Loading {libBusy}
</div>
)}
{/* Library error (e.g. a backend 404 on open) — auto-dismisses. */}
{libError && (
<button
className="absolute left-1/2 top-3 z-40 max-w-md -translate-x-1/2 rounded bg-red-950/95 px-3 py-2 text-center text-xs text-red-100 shadow-lg ring-1 ring-red-500/40"
onClick={() => setLibError(null)}
title="Dismiss"
>
{libError}
</button>
)}
<div className="absolute bottom-0 left-0 right-0 z-20">
<button
className="flex items-center gap-1 bg-black/70 px-3 py-1 font-mono text-xs text-white"

View file

@ -73,6 +73,36 @@ declare global {
}
}
/**
* Events the libs bridge dispatches on `window` so the editor chrome (WasmTool)
* can show a loading state for the otherwise-invisible item fetch, and surface an
* error when a body can't be loaded (e.g. a backend 404). Decoupled via events so
* `wasm/libs` stays UI-agnostic.
*/
export const LIB_BUSY_EVENT = "pcbjam:lib-busy";
export const LIB_ERROR_EVENT = "pcbjam:lib-error";
export interface LibBusyDetail {
busy: boolean;
op: string;
kind: string;
name: string;
}
export interface LibErrorDetail {
message: string;
}
function emitLibBusy(detail: LibBusyDetail): void {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent(LIB_BUSY_EVENT, { detail }));
}
function emitLibError(message: string): void {
if (typeof window === "undefined") return;
window.dispatchEvent(
new CustomEvent(LIB_ERROR_EVENT, { detail: { message } }),
);
}
/** Optional artificial latency (`?libdelay=1500`) to exercise the bridge. */
function artificialDelayMs(): number {
const raw = new URLSearchParams(window.location.search).get("libdelay");
@ -142,6 +172,10 @@ export function installLibsProvider(
if (!id) return null;
if (delay) await sleep(delay);
// "get"/"save" are user-triggered (open/save an item) and otherwise give no
// visible feedback — broadcast busy + errors so the editor can show them.
const userFacing = op === "get" || op === "save";
if (userFacing) emitLibBusy({ busy: true, op, kind, name: arg });
try {
switch (op) {
case "list": {
@ -153,8 +187,15 @@ export function installLibsProvider(
const key = kind === "footprint" ? "footprints" : "symbols";
return JSON.stringify({ [key]: names });
}
case "get":
return await source.getItemBody(id, kind, arg);
case "get": {
const body = await source.getItemBody(id, kind, arg);
if (body === null) {
emitLibError(
`Couldn't open "${arg}" — the backend has no body for it (404).`,
);
}
return body;
}
case "save": {
let parsed: { name?: string; body?: string };
try {
@ -174,6 +215,7 @@ export function installLibsProvider(
parsed.name,
parsed.body,
);
if (!ok) emitLibError(`Couldn't save "${parsed.name}".`);
return ok ? "ok" : null;
}
default:
@ -181,7 +223,10 @@ export function installLibsProvider(
}
} catch (e) {
log(`[libs] request failed: ${String(e)}`);
if (userFacing) emitLibError(`Failed to ${op} "${arg}".`);
return null;
} finally {
if (userFacing) emitLibBusy({ busy: false, op, kind, name: arg });
}
};