pcbjam/web/standalone/src/components/WasmTool.tsx
Gergő Törcsvári 84a40d4492
diag(asyncify): write-time instrumentation + local warm-load repro findings
The prod differential ladder finished: staged byte VOLUME on a warm load is
the only trigger left (V1a siblings-without-lib-tables dies, V1b +120 files
survives, V1c sibling KiCad files renamed byte-for-byte dies, V1d Leonardo +
123MB of inert markdown dies on loads 3-4; 14MB never dies). 3D models,
collab/ydoc/presence, lib tables, sibling KiCad handling and file count are
all exonerated — volume only loads the dice on the underlying race.

That made the crash reproducible locally for the first time in six campaigns:
a persistent browser profile + a 110MB project fails every warm load with the
exact prod signature. Iteration is now ~12 minutes instead of a release cycle.

Shim: every fiber switch now records the departing side's remaining asyncify
buffer and its recorded rewind entry (rem=/rf=), which is what identified the
unrewindable capture and disproved buffer overflow. The deferral family is
closed for good — a microtask-deferred retry on a clean empty stack died
identically to the nested rewind, because the suspension is broken at write
time, not by nesting.

Shell: log the origin stack when wx reports the top window destroyed. That
notification fires from ~wxTopLevelWindowWasm for ANY top-level window, so a
transient frame dying mid-load navigates the user out of the editor — a real
bug in its own right, found while chasing the empty flight-recorder dumps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SE4o46Lnq3hF574FFq8x4
2026-08-03 11:05:10 +02:00

2723 lines
115 KiB
TypeScript

import * as React from "react";
import {
collabRoomId,
docToFile,
commentAuthorColors,
EXTENSION_TOOL,
FILELESS_TOOLS,
fileToDoc,
projectPath,
projectToolPath,
toolSchema,
ydocHasState,
syncLayoutToY,
yToDoc,
type KicadDoc,
type Tool,
} from "@pcbjam/shared";
import { ChevronDown, ChevronUp, Download, Eye, EyeOff, Loader2, Moon, PanelsTopLeft, Sun } from "lucide-react";
import {
API_BASE_URL,
APP_URL,
commentAuthor,
currentScope,
libsSourceConfig,
modelsSourceConfig,
presenceUser,
PRESENCE_TUNER_ENABLED,
yjsProviderConfig,
type DocSource,
} from "@/lib/config";
import { defaultFileName, newFileTemplate, withExtension } from "@/lib/new-file";
import { redirectTargetFor } from "@/lib/redirect";
import { loadSessionIdentity } from "@/lib/session-identity";
import { setTheme, useThemeValue } from "@/lib/theme";
import { bootKicadTool } from "@/wasm/boot";
import {
autoDownloadEnabled,
fetchWasmStoredSize,
hasAnyWasmDownload,
isWasmDownloaded,
markWasmDownloaded,
resolveWasmMeta,
setAutoDownloadEnabled,
type WasmMeta,
} from "@/wasm/wasm-assets";
import {
LIB_BUSY_EVENT,
LIB_ERROR_EVENT,
LIB_ITEM_UPDATED_EVENT,
LIB_LOADING_EVENT,
type LibBusyDetail,
type LibErrorDetail,
type LibItemUpdatedDetail,
type LibLoadingDetail,
type LibsSource,
type LibsSyncState,
} from "@/wasm/libs/source";
import {
MODELS_LOADING_EVENT,
type ModelsLoadingDetail,
} from "@/wasm/libs/models-bridge";
import { memfsFilePath, memfsProjectDir, TOOL_BUNDLE, TOOL_FRAME } from "@/wasm/constants";
import {
driveProjectIntoTool,
readStagedFile,
usedLibNicknames,
type ToolFile,
} from "@/wasm/kicad-runner";
import { dump as dumpTrace, mark } from "@/wasm/load-trace";
import { registerSaveHook, type SaveBytes } from "@/wasm/save-flow";
import type {
KicadCollabHandle,
KicadDocSession,
KicadItemsWindow,
YjsProvider,
} from "@/wasm/collab";
import {
createPresence,
type PresenceHandle,
type PresencePeer,
} from "@/wasm/collab/presence";
import {
bindKicadPresence,
hasPresenceBridge,
type PresenceKicadModule,
type PresenceKicadWindow,
} from "@/wasm/collab/presence-kicad";
import {
createFollow,
type FollowHandle,
type FollowTarget,
} from "@/wasm/collab/follow-user";
import { startCrossAppPresence, type CrossAppHandle } from "@/wasm/collab/cross-app";
import {
startSiblingRestage,
type SiblingRestageHandle,
} from "@/wasm/collab/sibling-restage";
import { DOC_REVERTED_EVENT } from "@/wasm/collab/kicad-binding";
import {
createComments,
hasCommentsBridge,
type CommentsController,
type ViewportState,
} from "@/wasm/collab/comments";
import { PresenceRoster } from "@/components/PresenceRoster";
import { CommentLayer } from "@/components/CommentLayer";
import {
OverlayMenu,
OverlayMenuSection,
overlayRowClass,
} from "@/components/OverlayMenu";
import { hasTunerBridge, PresenceTuner, type TunerModule } from "@/components/PresenceTuner";
import {
createSheetCollabManager,
registerSheetChangedHook,
registerSheetCreatedHook,
type ActiveSheet,
type SheetChangedWindow,
type SheetCollabManager,
type SheetCreatedWindow,
} from "@/wasm/collab/sheet-manager";
import { clog, cwarn } from "@/wasm/collab/debug";
import type * as Y from "yjs";
import { createOomWatch, respawnInNewTab } from "@/recovery/oom-watch";
import { MemoryExhaustedDialog } from "@/recovery/MemoryExhaustedDialog";
import type { SourceDescriptor } from "@/lib/project-source-shared";
import { SourceChip } from "@/components/SourceChip";
import { isMobileMode } from "@/lib/mobile-mode";
import {
isChromeToggleHotkey,
toggleChromeHidden,
useChromeHidden,
} from "@/lib/chrome-visibility";
import { recordFatalLog, showFatalScreen } from "@/wasm/fatal-screen";
// Tools with the v2 items bridge (kicadCollabSnapshotItems/ApplyItems embind exports).
const COLLAB_TOOLS = new Set<Tool>(["pl_editor", "eeschema", "pcbnew"]);
// Chrome (editor UI) toggle: only the merged kicad_editor bundle exports
// kicadSetChrome (gerbview/calculator/pl_editor don't) — everything about the
// toggle is feature-gated on the export being there.
function chromeSetter(win: Window): ((show: boolean) => boolean) | null {
const fn = (win as { Module?: { kicadSetChrome?: unknown } }).Module
?.kicadSetChrome;
return typeof fn === "function" ? (fn as (show: boolean) => boolean) : null;
}
// Tooltip only — the matcher accepts both chords on any platform.
const CHROME_HOTKEY_LABEL =
typeof navigator !== "undefined" && /Mac/i.test(navigator.platform)
? "⌘\\"
: "Ctrl+\\";
// Which library item kind each tool browses — drives the load-screen pre-sync
// (warm the right bundles into IDB while the wasm downloads). Tools that don't
// browse a library are omitted (no pre-sync).
const LIB_KIND_FOR_TOOL: Partial<Record<Tool, "symbol" | "footprint">> = {
symbol_editor: "symbol",
eeschema: "symbol",
footprint_editor: "footprint",
pcbnew: "footprint",
};
/** What the download-consent dialog quotes (standalone-load-ux 0001). */
interface ConsentInfo {
/** Over-the-wire (COMPRESSED) bytes for the editor bundle — null when the CDN
* carries no size info and HEAD yielded none ("large download" wording then).
* Quoted as "compressed" in the dialog: the load screen's progress bar counts
* RAW decoded bytes, which are several times this. */
toolBytes: number | null;
/** Raw (decoded) wasm bytes — the same total the progress bar counts, quoted
* next to `toolBytes` so the two figures can't read as a contradiction. Null
* when the manifest prices nothing (HEAD fallback knows the wire size only). */
toolRawBytes: number | null;
/** A previous version of this bundle was downloaded → word it as an update. */
update: boolean;
/** The lib kind this tool pre-syncs — warmed in parallel with the wasm
* download (the boot fan-out; see startLibPresync). Editors with a project
* file open without waiting on it; the lib editors wait (enumerate gate). */
libNowKind: "symbol" | "footprint" | null;
libNow: LibsSyncState | null;
/** The other kind a merged-bundle session can pull lazily ("only if used"). */
libLaterKind: "symbol" | "footprint" | null;
libLater: LibsSyncState | null;
}
/**
* Gather the consent dialog's figures. Everything is best-effort: only small
* JSON/HEAD requests run here (never a bundle or the wasm), and any missing
* piece degrades to vaguer wording rather than blocking the dialog.
*/
async function gatherConsentInfo(
meta: WasmMeta,
source: LibsSource | null,
tool: Tool,
): Promise<ConsentInfo> {
let toolBytes = meta.sizes?.totalStored ?? null;
if (toolBytes === null) {
toolBytes = await fetchWasmStoredSize(meta.base, meta.bundle);
}
const libNowKind = LIB_KIND_FOR_TOOL[tool] ?? null;
// The merged kicad_editor bundle seeds BOTH lib tables — the other kind loads
// lazily per-lib when a cross-face feature reaches it (see boot.ts libKinds).
const libLaterKind =
TOOL_BUNDLE[tool] === "kicad_editor" && libNowKind
? libNowKind === "symbol"
? ("footprint" as const)
: ("symbol" as const)
: null;
const state = async (
kind: "symbol" | "footprint" | null,
): Promise<LibsSyncState | null> => {
if (!kind || !source?.syncState) return null;
try {
return await source.syncState(kind);
} catch {
return null;
}
};
return {
toolBytes,
// Only the manifest prices the DECODED wasm (wasm-assets WasmBundleSizes);
// the HEAD fallback above sees the compressed body alone.
toolRawBytes: meta.sizes?.wasm ?? null,
update: hasAnyWasmDownload(meta.bundle),
libNowKind,
libNow: await state(libNowKind),
libLaterKind,
libLater: await state(libLaterKind),
};
}
const LEGACY_EXTENSION_TOOL: Record<string, Tool> = {
".sch": "eeschema",
".brd": "pcbnew",
};
let activeToolNavigationHook:
| ((toolName: string, fileName: string) => boolean)
| undefined;
const toolNavigationDispatcher = (toolName: string, fileName: string) =>
activeToolNavigationHook?.(toolName, fileName) ?? false;
function ensureToolNavigationDispatcher(win: ToolWindow): boolean {
if (win.kicadWebOpenTool === toolNavigationDispatcher) return true;
try {
Object.defineProperty(win, "kicadWebOpenTool", {
configurable: true,
value: toolNavigationDispatcher,
});
return true;
} catch {
return false;
}
}
if (typeof window !== "undefined") {
ensureToolNavigationDispatcher(window as ToolWindow);
}
function normalizeToolName(rawName: string): Tool | null {
const basename = rawName.replace(/\\/g, "/").split("/").pop() ?? rawName;
const withoutExe = basename.replace(/\.exe$/i, "");
const toolName = withoutExe === "pcb_calculator" ? "calculator" : withoutExe;
const parsed = toolSchema.safeParse(toolName);
return parsed.success ? parsed.data : null;
}
function relativeProjectPath(slug: string, path: string): string | undefined {
if (!path) return undefined;
const normalized = path.replace(/\\/g, "/");
const prefix = `${memfsProjectDir(slug)}/`;
if (normalized.startsWith(prefix)) return normalized.slice(prefix.length);
const marker = `/projects/${slug}/`;
const markerIndex = normalized.indexOf(marker);
if (markerIndex >= 0) return normalized.slice(markerIndex + marker.length);
return normalized.startsWith("/") ? undefined : normalized;
}
function fileStem(path: string): string {
const name = path.replace(/\\/g, "/").split("/").pop() ?? path;
return name.replace(/\.[^.]+$/, "");
}
function fileTool(path: string): Tool | undefined {
const lower = path.toLowerCase();
for (const [extension, mappedTool] of Object.entries({
...EXTENSION_TOOL,
...LEGACY_EXTENSION_TOOL,
})) {
if (lower.endsWith(extension)) return mappedTool;
}
return undefined;
}
function chooseToolFile(
files: ToolFile[],
nextTool: Tool,
requestedPath?: string,
currentPath?: string,
): string | undefined {
if (requestedPath && files.some((file) => file.path === requestedPath)) {
return requestedPath;
}
const candidates = files.filter((file) => fileTool(file.path) === nextTool);
const preferredStem = requestedPath
? fileStem(requestedPath)
: currentPath
? fileStem(currentPath)
: undefined;
if (preferredStem) {
const matchingStem = candidates.find(
(file) => fileStem(file.path) === preferredStem,
);
if (matchingStem) return matchingStem.path;
}
return candidates[0]?.path;
}
function installToolNavigationHook(
win: ToolWindow,
opts: {
slug: string;
files: ToolFile[];
targetPath?: string;
/** Persist a new file into the project (see the WasmTool prop). Absent ⇒
* this session can't create one, and a missing target stays a no-op. */
createFile?: (relPath: string, bytes: Uint8Array) => Promise<void>;
log: (m: string) => void;
},
): () => void {
// One create at a time: a double-fired menu item must not upload twice.
// Cleared only on failure — success navigates the page away.
let pendingCreate: string | null = null;
const hook = (rawToolName: string, rawFileName: string): boolean => {
const nextTool = normalizeToolName(rawToolName);
if (!nextTool) {
opts.log(`[nav] unsupported KiCad tool: ${rawToolName}`);
return false;
}
const requestedPath = relativeProjectPath(opts.slug, rawFileName);
const nextPath = FILELESS_TOOLS.has(nextTool)
? undefined
: chooseToolFile(opts.files, nextTool, requestedPath, opts.targetPath);
if (!FILELESS_TOOLS.has(nextTool) && !nextPath) {
// Native KiCad's "Switch to PCB Editor" with no board opens pcbnew on a
// NEW empty board at the derived path — mirror it by creating the
// templated counterpart in the project (the shape NewFileDialog writes)
// and navigating to it. Only sessions that can persist pass `createFile`
// (ToolPage); viewers and scratch/local-folder sessions keep the quiet
// no-op. C++ calls this hook synchronously (EM_ASM_INT) and ignores the
// result beyond a log line, so the create+navigate runs async and we
// answer true optimistically once it's kicked off.
const createFile = opts.createFile;
if (!createFile) {
opts.log(`[nav] no project file found for ${nextTool}: ${rawFileName}`);
return false;
}
if (pendingCreate) {
opts.log(`[nav] create already pending: ${pendingCreate}`);
return true;
}
const relPath =
requestedPath ??
(opts.targetPath
? withExtension(nextTool, fileStem(opts.targetPath))
: defaultFileName(nextTool));
const url =
projectPath(currentScope(), opts.slug, relPath) + win.location.search;
pendingCreate = relPath;
void (async () => {
try {
const bytes = new TextEncoder().encode(
newFileTemplate(nextTool, crypto.randomUUID()),
);
await createFile(relPath, bytes);
opts.log(`[nav] created missing ${nextTool} file ${relPath} -> ${url}`);
markDeliberateNavigation();
win.location.assign(url);
} catch (e) {
pendingCreate = null;
opts.log(
`[nav] create failed for ${relPath}: ${e instanceof Error ? e.message : String(e)}`,
);
}
})();
return true;
}
// Scope/kind/name grammar: a fileless tool boots at `…/-/:tool`; a file route
// carries the path (its tool is inferred). Scope = the current URL's scope.
const scope = currentScope();
const url =
(FILELESS_TOOLS.has(nextTool)
? projectToolPath(scope, opts.slug, nextTool)
: projectPath(scope, opts.slug, nextPath)) + win.location.search;
opts.log(`[nav] ${rawToolName} ${rawFileName || "(no file)"} -> ${url}`);
markDeliberateNavigation();
win.location.assign(url);
return true;
};
if (!ensureToolNavigationDispatcher(win)) {
opts.log("[nav] unable to install KiCad tool navigation hook");
}
activeToolNavigationHook = hook;
return () => {
if (activeToolNavigationHook === hook) activeToolNavigationHook = undefined;
};
}
// The wx wasm port calls window.wxAppTopWindowClosed() when the app's MAIN
// frame is destroyed (wxwidgets src/wasm/toplevel.cpp) — i.e. on a real
// File→Quit / window close. A close vetoed by the unsaved-changes prompt never
// destroys the frame, so it never fires. The port also closes the frame while
// the page itself unloads (app.cpp UnloadCallback), so the dispatcher latches
// off as soon as any unload/navigation is under way.
let activeQuitHook: (() => void) | undefined;
let quitHandled = false;
/**
* Latch the quit dispatcher off ahead of a deliberate in-app navigation (the
* tool-switch hook's location.assign). The wx port's UnloadCallback runs on
* BEFOREUNLOAD — i.e. the instant the navigation starts, while this document
* keeps running until the next one commits — and closes the top frame, which
* fires wxAppTopWindowClosed. Without the latch the quit hook then navigates
* to the exit URL over the in-flight navigation (the pagehide latch below is
* too late: pagehide only fires at commit time). One-shot per document, same
* as the pagehide latch — this page is on its way out.
*/
function markDeliberateNavigation() {
quitHandled = true;
}
const quitDispatcher = () => {
if (quitHandled) return;
quitHandled = true;
// The wasm side only calls this when the app's top window is genuinely
// destroyed. When that happens unexpectedly (2026-08-03: a guarded-off
// settle-window dispatch cascaded into a silent frame close), the stack is
// the only artifact that says WHO closed it — keep it in every log.
console.warn("[quit] wxAppTopWindowClosed invoked — top window destroyed", new Error("quit-origin").stack);
activeQuitHook?.();
};
function ensureQuitDispatcher(win: ToolWindow): boolean {
if (win.wxAppTopWindowClosed === quitDispatcher) return true;
try {
Object.defineProperty(win, "wxAppTopWindowClosed", {
configurable: true,
value: quitDispatcher,
});
return true;
} catch {
return false;
}
}
if (typeof window !== "undefined") {
ensureQuitDispatcher(window as ToolWindow);
// Latch off for a BROWSER-initiated unload: reload (F5), Back, closing the
// tab, typing a URL. markDeliberateNavigation covers only our own in-app
// navigations, and the pagehide latch below fires at commit time — too late.
// The wx port's UnloadCallback runs on beforeunload and closes the top frame,
// which fires wxAppTopWindowClosed; unlatched, the quit hook then navigated to
// the project overview OVER the in-flight reload, so every refresh of an
// editor URL bounced to the management app instead of reloading.
//
// Registered at MODULE scope, which runs on import — before the wasm boots and
// installs its own beforeunload handler. Listeners fire in registration order,
// so this latch is always set before UnloadCallback can close the frame.
//
// Tradeoff: if a beforeunload prompt is shown and the user chooses to stay,
// the latch stays set and a later File→Quit won't navigate on its own. That
// is strictly better than the alternative — a page that cannot be refreshed —
// and the user can still navigate manually.
window.addEventListener(
"beforeunload",
() => {
quitHandled = true;
},
{ capture: true },
);
}
function installQuitHook(
win: ToolWindow,
opts: { exitUrl: string; log: (m: string) => void },
): () => void {
const hook = () => {
// Quit always navigates to the exit URL (project overview / home). Never
// history.back(): every in-app entry AND every tool switch is a hard
// location.assign(), so after a schematic ⇄ pcb switch the previous
// history entry is another editor — unwinding history strands the user
// there instead of leaving the editor.
//
// Defer the navigation out of the wasm callback: this fires from inside the
// frame's C++ destructor (via EM_ASM under Asyncify), and the teardown keeps
// running after we return. A cross-document location.assign() started here is
// aborted by that continuing teardown — so hand it to a fresh task once the
// wasm stack has unwound.
setTimeout(() => {
opts.log(`[quit] editor closed — going to ${opts.exitUrl}`);
win.location.assign(opts.exitUrl);
}, 0);
};
if (!ensureQuitDispatcher(win)) {
opts.log("[quit] unable to install quit hook");
}
activeQuitHook = hook;
// Once the page is unloading for any reason, the hook must never navigate.
const markUnloading = () => {
quitHandled = true;
};
win.addEventListener("pagehide", markUnloading);
// A bfcache restore (Forward after quitting) would resurrect a page whose wx
// frame was already destroyed — force a clean re-boot instead.
const onPageShow = (e: PageTransitionEvent) => {
if (e.persisted) win.location.reload();
};
win.addEventListener("pageshow", onPageShow);
return () => {
if (activeQuitHook === hook) activeQuitHook = undefined;
win.removeEventListener("pagehide", markUnloading);
win.removeEventListener("pageshow", onPageShow);
};
}
/**
* Read the opened file back from MEMFS (what the editor actually loaded) and
* parse it into the full `KicadDoc` (ysync 0007 `fileToDoc`). Used to seed the
* Y.Doc LOSSLESSLY when this client opens an empty room (ysync 0005): the doc
* then carries meta + layout + items, so the file is recoverable from the Y.Doc
* alone. Falls back to undefined (→ editor-snapshot seed, items only) when the
* file is absent or doesn't parse as a KiCad s-expr document.
*/
function seedDocFromMemfs(
win: ToolWindow,
slug: string,
targetPath?: string,
): KicadDoc | undefined {
if (!targetPath) return undefined;
try {
const text = win.FS?.readFile(memfsFilePath(slug, targetPath), { encoding: "utf8" });
if (typeof text !== "string") return undefined;
return fileToDoc(text);
} catch (err) {
cwarn("seed: fileToDoc failed — falling back to editor-snapshot seed", err);
return undefined;
}
}
/**
* The `docSource: "ydoc"` pre-step (config/env-selected — same /p/ URLs as "api"
* mode): connect the document's collab room BEFORE the file opens and, when the
* room already holds the doc, materialize the file from it (docToFile) so the
* editor opens the doc's state instead of the API's copy. An empty room (first
* ever open) falls back to the API fetch — the seed() that follows file-seeds
* the room from it. Returns the session for `maybeStartCollab` to attach to.
*/
async function maybeConnectDocSession(
win: ToolWindow,
opts: {
docSource?: DocSource;
tool: Tool;
scopeId: string;
projectId: string;
targetPath?: string;
log: (m: string) => void;
},
): Promise<{ session?: KicadDocSession; targetBytes?: Uint8Array }> {
if (opts.docSource !== "ydoc") return {};
if (!opts.targetPath || !COLLAB_TOOLS.has(opts.tool)) return {};
const { connectKicadDoc } = await import("@/wasm/collab");
const room = collabRoomId(opts.scopeId, opts.projectId, opts.targetPath);
const session = await connectKicadDoc({ provider: yjsProviderConfig(), room });
// Use the full doc state (meta + layout + items), NOT just item count: a
// populated drawing sheet (pl_editor `.kicad_wks`) has zero uuid items, so an
// items-only check makes a joining tab refetch the stale file instead of
// materializing the shared doc's current state.
if (!ydocHasState(session.doc)) {
opts.log(`[ydoc] room ${room} is empty — falling back to the API fetch (will file-seed)`);
return { session };
}
try {
const text = docToFile(yToDoc(session.doc));
opts.log(`[ydoc] materialized ${opts.targetPath} from room ${room} (${text.length} chars)`);
return { session, targetBytes: new TextEncoder().encode(text) };
} catch (err) {
cwarn("ydoc: materialize failed — falling back to the API fetch", err);
return { session };
}
}
/**
* Collaborative editing (ysync 0008, Slot-model items wire), ON BY DEFAULT for any
* tool that has the collab bridge. Open the same project URL in two tabs to edit
* together: the channel is keyed to project+file, so both tabs share one Y.Doc over
* BroadcastChannel. Editor edits (add/move items) fire the tool's change hook → the
* bridge → the peer tab.
*
* Opt OUT with `?collab=0` (or `collab=false`). Tools without a bridge are skipped anyway.
*/
async function maybeStartCollab(
win: ToolWindow,
opts: {
tool: Tool;
slug: string;
scopeId: string;
projectId: string;
targetPath?: string;
collabSession?: KicadDocSession;
/** The opened file was materialized from collabSession's doc (ydoc source). */
editorMatchesDoc?: boolean;
/** Read-only viewer (read-only-viewer): see `bindKicadCollab`. */
readOnly?: boolean;
log: (m: string) => void;
onStatus: (t: string) => void;
},
): Promise<KicadCollabHandle | undefined> {
const collabParam = new URLSearchParams(win.location.search).get("collab");
const mod = win.Module;
clog("maybeStartCollab gate:", {
collabParam,
tool: opts.tool,
hasModule: !!mod,
hasSnapshotItems: typeof mod?.kicadCollabSnapshotItems,
hasApplyItems: typeof mod?.kicadCollabApplyItems,
url: win.location.href,
});
// On by default; only an explicit opt-out disables it. A pre-connected doc
// session (Y.Doc-load path) ignores the opt-out: the doc IS the data source,
// so detaching would silently drop every edit.
if (!opts.collabSession && (collabParam === "0" || collabParam === "false")) {
clog("disabled (?collab=0) — skipping");
return undefined;
}
if (!COLLAB_TOOLS.has(opts.tool)) {
clog(`tool ${opts.tool} has no collab bridge — skipping`);
return undefined;
}
if (typeof mod?.kicadCollabSnapshotItems !== "function") {
cwarn(
"BRIDGE NOT PRESENT: Module.kicadCollabSnapshotItems is",
typeof mod?.kicadCollabSnapshotItems,
`— the loaded ${opts.tool}.wasm predates the v2 items bridge (ysync 0008 Stage C). Rebuild + \`npm run setup:kicad\` and restart the dev server.`,
);
return undefined;
}
const { startKicadCollab, attachKicadCollab } = await import("@/wasm/collab");
const seedDoc = seedDocFromMemfs(win, opts.slug, opts.targetPath);
if (opts.collabSession) {
// docSource "ydoc": the provider is already connected. When the editor
// opened the file materialized from this very doc, attach + baseline only;
// when the room was empty (API fallback), seed() file-seeds it as usual.
clog("attaching to pre-connected doc session; editorMatchesDoc:", !!opts.editorMatchesDoc);
const handle = attachKicadCollab(mod, win as unknown as KicadItemsWindow, opts.collabSession, {
seedDoc,
editorMatchesDoc: opts.editorMatchesDoc,
readOnly: opts.readOnly,
});
opts.log(`[collab] attached to Y.Doc session`);
opts.onStatus("Collab: connected");
clog("connected ✓");
return handle;
}
const provider = yjsProviderConfig();
// One room per (project, document). Two tabs of the same build compute the
// same id, so cross-tab BroadcastChannel still works; network providers use it
// verbatim to namespace + persist (see @pcbjam/shared collabRoomId).
const room = collabRoomId(opts.scopeId, opts.projectId, opts.targetPath ?? opts.tool);
clog("starting collab", provider.kind, "room", room, "seedDoc:", !!seedDoc);
const handle = await startKicadCollab(mod, win as unknown as KicadItemsWindow, {
provider,
room,
seedDoc,
readOnly: opts.readOnly,
});
opts.log(`[collab] ${provider.kind} connected on ${room}`);
opts.onStatus("Collab: connected");
clog("connected ✓");
return handle;
}
/**
* Hierarchical-sheet (subschema) collaborative editing for eeschema: every `.kicad_sch`
* in the design is its own WARM collab room (provider kept open for the session), and the
* editor's single active-screen binding is re-routed between them on sheet navigation (the
* C++ `onSheetChanged` hook). Supersedes the single-room `maybeStartCollab` for eeschema;
* background sheets stay synced at the data layer, the active sheet is bound to the editor.
*
* Opt OUT with `?collab=0`; a pre-connected ydoc session ignores the opt-out (the doc IS
* the data source). Returns undefined when collab is off or the wasm predates the Phase-0
* items+sheet bridge.
*/
async function startSheetCollab(
win: ToolWindow,
opts: {
slug: string;
scopeId: string;
projectId: string;
targetPath?: string;
files: ToolFile[];
/** ydoc mode: the entry sheet's pre-connected room (from maybeConnectDocSession). */
session?: KicadDocSession;
/** The entry file was materialized from `session`'s doc (baseline-only first seed). */
editorMatchesDoc?: boolean;
onActiveChange: (active: ActiveSheet | null) => void;
/** Upload sink (project-backed sessions) — used to register a just-created subsheet. */
saveBytes?: SaveBytes;
/** Read-only viewer (read-only-viewer): see `createSheetCollabManager`. */
readOnly?: boolean;
log: (m: string) => void;
onStatus: (t: string) => void;
},
): Promise<SheetCollabManager | undefined> {
const collabParam = new URLSearchParams(win.location.search).get("collab");
const mod = win.Module;
if (!opts.session && (collabParam === "0" || collabParam === "false")) {
clog("[sheet] collab disabled (?collab=0) — skipping");
return undefined;
}
if (typeof mod?.kicadCollabSnapshotItems !== "function") {
cwarn(
"[sheet] BRIDGE NOT PRESENT: Module.kicadCollabSnapshotItems is",
typeof mod?.kicadCollabSnapshotItems,
"— the loaded eeschema.wasm predates the items+sheet bridge (subschema Phase 0). Rebuild + `npm run setup:kicad` and restart the dev server.",
);
return undefined;
}
const manager = createSheetCollabManager({
mod,
win: win as unknown as KicadItemsWindow,
scopeId: opts.scopeId,
projectId: opts.projectId,
provider: yjsProviderConfig(),
seedDocForPath: (sheet) => seedDocFromMemfs(win, opts.slug, sheet),
onActiveChange: opts.onActiveChange,
// Parked rooms carry a skeleton presence ("this user is on sheet X") so
// any sheet's roster shows the whole schematic's crew (0003). Read-only
// viewers publish none (invisible observer) — skeletons are broadcasts.
presenceUser: opts.readOnly ? undefined : presenceUser(),
readOnly: opts.readOnly,
log: opts.log,
initial:
opts.session && opts.targetPath
? {
sheetPath: opts.targetPath,
session: opts.session,
editorMatchesDoc: !!opts.editorMatchesDoc,
}
: undefined,
});
const sheetPaths = opts.files
.filter((f) => f.path.endsWith(".kicad_sch"))
.map((f) => f.path);
// C++ navigation → rebind the active room to the now-shown sheet.
registerSheetChangedHook(win as unknown as SheetChangedWindow, (abs) => {
const rel = relativeProjectPath(opts.slug, abs);
if (rel) void manager.switchTo(rel);
});
// C++ sheet creation ("Add Sheet") → the child .kicad_sch was just written to MEMFS by
// the hook; register it with the backend + warm its room, so a subsheet placed but never
// entered or saved still persists (the file-list snapshot can't contain it).
registerSheetCreatedHook(win as unknown as SheetCreatedWindow, (abs) => {
const rel = relativeProjectPath(opts.slug, abs);
if (rel && rel.endsWith(".kicad_sch")) {
persistCreatedSheet(win, opts.slug, rel, opts.saveBytes, manager, opts.log);
}
});
// Warm every schematic file in the project so later sheet switches are instant.
void manager.connectAll(sheetPaths);
if (opts.targetPath) await manager.switchTo(opts.targetPath);
opts.log(`[sheet] multi-room collab active (${sheetPaths.length} sheet(s) warmed)`);
opts.onStatus("Collab: connected");
return manager;
}
/**
* A subsheet was just created in-editor — the C++ `onSheetCreated` hook has already written
* the child .kicad_sch to MEMFS. Register it with the backend (so it survives reload and
* reaches peers) and warm its collab room. Covers a subsheet that's placed but never entered
* or saved, which the page-load file list can't contain.
*/
function persistCreatedSheet(
win: ToolWindow,
slug: string,
relPath: string,
saveBytes: SaveBytes | undefined,
manager: SheetCollabManager,
log: (m: string) => void,
): void {
void manager.onboard(relPath);
if (!saveBytes) return;
try {
const bytes = win.FS?.readFile(memfsFilePath(slug, relPath));
if (!(bytes instanceof Uint8Array)) return;
void saveBytes(relPath, bytes)
.then(() => log(`[sheet] registered created subsheet ${relPath} (${bytes.length} bytes)`))
.catch((err) => cwarn(`[sheet] upload of created subsheet ${relPath} failed`, err));
} catch (err) {
cwarn(`[sheet] read of created subsheet ${relPath} failed`, err);
}
}
/**
* 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));
}
}
/**
* Keeps a poisoned wasm runtime from taking the React tree down with it.
*
* The v0.1.21 prod crash logs showed the actual white-screen mechanism: after
* a wasm trap, some child's EFFECT calls into the dead runtime (an embind
* entry via a react-query subscription), the throw lands in React's commit,
* and React unmounts the whole root — destroying the fatal overlay AND the
* console panel, the two things built to report exactly this. The boundary
* absorbs descendant render/effect throws: it reports up (the parent promotes
* its fatal screen, which lives OUTSIDE this boundary) and renders nothing in
* place of the dead subtree. WasmTool's own state — logs included — survives.
*/
class WasmErrorBoundary extends React.Component<
{ onFatal: (msg: string) => void; children: React.ReactNode },
{ dead: boolean }
> {
state = { dead: false };
static getDerivedStateFromError() {
return { dead: true };
}
componentDidCatch(err: unknown) {
this.props.onFatal(err instanceof Error ? err.message : String(err));
}
render() {
return this.state.dead ? null : this.props.children;
}
}
/**
* Boots a KiCad tool directly in this React document (no iframe): builds the
* Emscripten `Module` config, injects the proven harness scripts (wx.js +
* <tool>.js, the same artifacts the e2e tests use) into the page, then syncs the
* project tree into MEMFS and drives File→Open. See src/wasm/boot.ts for why the
* runtime is single-instance per page load.
*/
export function WasmTool({
tool,
slug,
scopeId,
projectId,
files,
targetPath,
fetchBytes,
saveBytes,
createFile,
docSource,
assetBaseUrl,
libsSource,
sourceDescriptor,
readOnly = false,
}: {
tool: Tool;
slug: string;
/** Owning team's stable id (`"local"` when scope-less) — first room-id segment. */
scopeId: string;
/** Stable project id — used to key the collab room (see @pcbjam/shared). */
projectId: string;
files: ToolFile[];
targetPath?: string;
/** Where this project lives (local / remote-ro / remote-rw) — shown as a chip
* so the user knows whether/how Save persists. Omitted ⇒ no chip. */
sourceDescriptor?: SourceDescriptor;
/**
* Override the library source the editor browses. Omitted ⇒ the configured
* default (`libsSourceConfig`). Used to open a single library scoped to itself
* — a specific backend lib, or a local `.kicad_sym`/`.kicad_mod` file.
*/
libsSource?: LibsSource | null;
/** Fetch one project-relative file's bytes (contract loader or local folder). */
fetchBytes: (relPath: string) => Promise<Uint8Array>;
/**
* Persist one file the user saved in the editor (File→Save writes MEMFS, then
* the wasm fires window.kicadCollab.onSave → this). API upload for backend
* projects, disk write-back/download for local folders; omit to keep saves
* MEMFS-only (e.g. Y.Doc-backed sessions).
*/
saveBytes?: SaveBytes;
/**
* Create a new file in the project (tool-switch auto-create: eeschema's
* "Switch to PCB Editor" when no board exists yet). Persisted BEFORE the
* hook navigates, so the next ToolPage load finds it. Omit for sessions
* that can't persist a new project file (read-only viewers, scratch and
* local-folder sessions) — a missing switch target then stays a logged
* no-op.
*/
createFile?: (relPath: string, bytes: Uint8Array) => Promise<void>;
/**
* Where this project's DOCUMENT lives (see lib/config docSourceConfig):
* "ydoc" materializes the target file from its collab room when the room has
* state, with `fetchBytes` as the first-open fallback that seeds it. Defaults
* to "api" (plain fetch + open). Local-folder sessions don't pass this.
*/
docSource?: DocSource;
/** Override the resolved WASM asset base (used verbatim, e.g. e2e fixtures).
* Default: resolveWasmBase(tool) — the CDN manifest folder, or flat /wasm. */
assetBaseUrl?: string;
/**
* Read-only viewer session (read-only-viewer; see lib/read-only-mode): chrome
* force-hidden with the toggle disabled, no presence/comments/drift, the
* collab binding never seeds or pushes local edits, and the wasm frame is
* locked via kicadSetReadOnly (zoom/pan only) — failing CLOSED when the
* bundle lacks the export. Pair with an omitted `saveBytes`.
*/
readOnly?: boolean;
}) {
const containerRef = React.useRef<HTMLDivElement>(null);
const startedRef = React.useRef(false);
// Mobile device (features/mobile): boot installs the touch-gesture shim.
// Chrome/overlay visibility is the separate runtime toggle below.
const mobileUi = React.useMemo(() => isMobileMode(), []);
// Figma-like "hide UI" toggle: small screens (phones, tablets, narrow
// windows — startsChromeHidden) default to hidden, the floating button /
// Cmd+\ flips it live; shell overlays key off this, and the layout
// effect below applies it to the wasm frame.
const chromeHidden = useChromeHidden();
// Read-only sessions force-hide the chrome without touching the module-global
// toggle state (SPA-navigating away keeps normal behavior elsewhere).
const effectiveChromeHidden = readOnly || chromeHidden;
const driftRef = React.useRef<{ stop(): void } | null>(null);
const presenceRef = React.useRef<PresenceHandle | null>(null);
const presenceBridgeRef = React.useRef<{ destroy(): void } | null>(null);
// Follow-user (0008): mirror a peer's viewport until local input breaks it.
const followRef = React.useRef<FollowHandle | null>(null);
// Project-wide presence room (0006): joined once per session, survives
// eeschema sheet rebinds — the bridge re-reads it on every startPresence.
const crossAppRef = React.useRef<CrossAppHandle | null>(null);
const siblingRestageRef = React.useRef<SiblingRestageHandle | null>(null);
// Set at the boot effect's cleanup; deferred starters bail on it (the
// sibling-restage idle stagger can fire after unmount).
const disposedRef = React.useRef(false);
const sheetManagerRef = React.useRef<SheetCollabManager | null>(null);
// The single-room collab doc (pcbnew/pl_editor), for the layout save-sync
// (miss 08B); eeschema routes per sheet through the manager instead.
const collabDocRef = React.useRef<import("yjs").Doc | null>(null);
// Its owning handle, so unmount tears the room socket + doc down — eeschema's
// equivalent lives inside sheetManagerRef.
const collabHandleRef = React.useRef<KicadCollabHandle | null>(null);
const [status, setStatus] = React.useState("Loading tool…");
const [logs, setLogs] = React.useState<string[]>([]);
const [showLog, setShowLog] = 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 +
// opened (covers the big WASM-compile freeze with a full-screen overlay).
const [ready, setReady] = React.useState(false);
// Download progress for the (large) wasm, and a "this is taking too long" flag
// the overlay raises after a while so a stuck load doesn't read as a silent hang.
const [progress, setProgress] = React.useState<{
loaded: number;
total: number;
} | null>(null);
const [slow, setSlow] = React.useState(false);
// Download-consent gate (standalone-load-ux 0001): non-null while the boot
// waits on the user's OK before pulling the (large) cold wasm + lib bundles.
const [consent, setConsent] = React.useState<ConsentInfo | null>(null);
const consentResolveRef = React.useRef<((ok: boolean) => void) | null>(null);
// This bundle+version finished downloading before (completion marker) — the
// load overlay says "from cache" instead of the first-download excuse.
const [warmBoot, setWarmBoot] = React.useState(false);
// A library item currently being fetched (open/save), for a transient spinner.
const [libBusy, setLibBusy] = React.useState<string | null>(null);
// Load-screen pre-sync progress: warming the project's lib bundles into IDB in
// parallel with the wasm download. Null when idle/done. Counts only (no
// "current lib") — the fetches run several-at-a-time, so there is no single
// current one, and a fixed label keeps the line still while it ticks.
const [libSync, setLibSync] = React.useState<{
kind: string;
done: number;
total: number;
} | null>(null);
// Project-file staging progress (fetch + MEMFS write, overlapping the wasm
// download) for the boot overlay's "Project files" line. Null when idle/done.
const [fileSync, setFileSync] = React.useState<{
done: number;
total: number;
} | 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);
// A collaborator updated library items that are PLACED in the open document
// (LIB_ITEM_UPDATED_EVENT) — placed copies keep the previous version, so warn.
const [libUpdate, setLibUpdate] = React.useState<string | null>(null);
// The backend rolled this document back to its last valid state
// (kicad-validity 0001 — DOC_REVERTED_EVENT from the collab binding).
const [docReverted, setDocReverted] = React.useState<string | null>(null);
// Eager whole-library idb→wasm load in flight (the ~tens-of-seconds fat-load on
// first chooser/editor open). Drives a full-cover overlay so the freeze reads as
// "loading, just slow" rather than a hang. Null when idle; `done/total` count the
// per-lib fat-load crossings so the overlay can show a progress bar.
const [libLoading, setLibLoading] = React.useState<{
kind: string;
done: number;
total: number;
} | null>(null);
// Board 3D-model prefetch in flight (background; the viewer works without it —
// anything still missing lazy-loads per model). Small badge, not an overlay.
const [modelsSync, setModelsSync] = React.useState<string | null>(null);
// The OTHER users in this document's collab room (awareness roster) — drives
// the PresenceRoster chip next to SourceChip. Empty when collab is off, the
// provider has no awareness (kind "none"), or nobody else is here.
const [peers, setPeers] = React.useState<PresencePeer[]>([]);
// eeschema: the sheet THIS client is bound to — the roster dims peers whose
// skeleton state says they're on a different sheet (collab-presence 0003).
const [activeSheetPath, setActiveSheetPath] = React.useState<string | undefined>();
// Follow-user (0008): the followed roster client, for the ring + banner.
const [followingTarget, setFollowingTarget] = React.useState<FollowTarget | null>(null);
// Comments (0005): the bound doc's controller + the live viewport transform
// the DOM layer maps world→CSS with. Both rebind with the collab session
// (per sheet in eeschema).
const [commentsCtl, setCommentsCtl] = React.useState<CommentsController | null>(null);
// The overlay menu's comments section (0010): a ref-callback slot the
// CommentLayer portals its bar/panel into; null while the menu is closed.
const [commentsSlot, setCommentsSlot] = React.useState<HTMLDivElement | null>(null);
const [viewportState, setViewportState] = React.useState<ViewportState | null>(null);
const commentsRef = React.useRef<CommentsController | null>(null);
// Unread-comments rollup for the FAB badge (comments-ux 0001 C).
const [commentsUnread, setCommentsUnread] = React.useState({ threads: 0, mentioned: false });
const onCommentsUnread = React.useCallback(
(threads: number, mentioned: boolean) => setCommentsUnread({ threads, mentioned }),
[],
);
// Live canvas theme (comments-ux 0002 F4): shell toggles drive the GAL color
// theme through the bridge when the loaded wasm exposes it; older builds
// just keep their boot-seeded theme.
const theme = useThemeValue();
React.useEffect(() => {
if (!ready) return;
const mod = (window as { Module?: { kicadSetColorTheme?: (name: string) => void } }).Module;
mod?.kicadSetColorTheme?.(theme === "dark" ? "pcbjam-dark" : "_builtin_default");
}, [theme, ready]);
// Dev-time presence style tuner (VITE_PRESENCE_TUNER=1) — set once the wasm
// exposes the style bridge, mounts the floating panel.
const [tunerMod, setTunerMod] = React.useState<TunerModule | null>(null);
const append = React.useCallback((msg: string) => {
// Mirror into the React-independent fatal-screen ring: if React ever
// unmounts itself on a crash, the DOM floor still has the full log.
recordFatalLog(msg);
setLogs((prev) => [...prev.slice(-800), msg]);
}, []);
// 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);
};
const onItemUpdated = (e: Event) => {
const d = (e as CustomEvent<LibItemUpdatedDetail>).detail;
// Only warn when the update touches something PLACED here — the library
// tree already reflects updates to everything else.
if (d.usedNames.length === 0) return;
const names = d.usedNames.map((n) => `"${n}"`).join(", ");
setLibUpdate(
`${d.usedNames.length === 1 ? "Symbol" : "Symbols"} ${names} in "${d.lib}" ` +
`${d.usedNames.length === 1 ? "was" : "were"} updated by a collaborator — ` +
`placed copies keep the previous version until updated from the library.`,
);
};
const onDocReverted = (e: Event) => {
const d = (e as CustomEvent<{ reason?: string; at?: string }>).detail;
setDocReverted(
`This document was rolled back to its last valid state — invalid content ` +
`was detected${d?.reason ? ` (${d.reason})` : ""}. Recent edits may have been undone.`,
);
};
window.addEventListener(LIB_BUSY_EVENT, onBusy);
window.addEventListener(LIB_ERROR_EVENT, onError);
window.addEventListener(LIB_ITEM_UPDATED_EVENT, onItemUpdated);
window.addEventListener(DOC_REVERTED_EVENT, onDocReverted);
return () => {
clearTimeout(busyTimer);
window.removeEventListener(LIB_BUSY_EVENT, onBusy);
window.removeEventListener(LIB_ERROR_EVENT, onError);
window.removeEventListener(LIB_ITEM_UPDATED_EVENT, onItemUpdated);
window.removeEventListener(DOC_REVERTED_EVENT, onDocReverted);
};
}, []);
// Auto-dismiss the lib error toast.
React.useEffect(() => {
if (!libError) return;
const t = setTimeout(() => setLibError(null), 6000);
return () => clearTimeout(t);
}, [libError]);
// Auto-dismiss the lib update toast (a touch longer — it carries a caveat).
React.useEffect(() => {
if (!libUpdate) return;
const t = setTimeout(() => setLibUpdate(null), 10_000);
return () => clearTimeout(t);
}, [libUpdate]);
// Auto-dismiss the doc-reverted toast (longest — the user should see it).
React.useEffect(() => {
if (!docReverted) return;
const t = setTimeout(() => setDocReverted(null), 15_000);
return () => clearTimeout(t);
}, [docReverted]);
// Full-library eager load overlay. The fat-load fires one loading:true/false
// pair PER library (222 on the full set), and between them the C++ side parses
// with the main thread blocked. Show immediately on `true`, and only hide after
// a short quiet gap on `false` (reset by the next lib's `true`) — so the overlay
// stays continuous across the whole run and drops shortly after the last lib,
// instead of flickering 222 times.
React.useEffect(() => {
let hideTimer: ReturnType<typeof setTimeout> | undefined;
const onLoading = (e: Event) => {
const d = (e as CustomEvent<LibLoadingDetail>).detail;
clearTimeout(hideTimer);
// Update the bar on every event (true and false) so the count reflects the
// latest lib; arm the hide only when the run reports it's winding down.
setLibLoading({ kind: d.kind || "library", done: d.done, total: d.total });
if (!d.loading) {
hideTimer = setTimeout(() => setLibLoading(null), 700);
}
};
window.addEventListener(LIB_LOADING_EVENT, onLoading);
return () => {
clearTimeout(hideTimer);
window.removeEventListener(LIB_LOADING_EVENT, onLoading);
};
}, []);
// Board 3D-model prefetch progress (models-bridge prescan) — background badge.
React.useEffect(() => {
const onModels = (e: Event) => {
const d = (e as CustomEvent<ModelsLoadingDetail>).detail;
setModelsSync(
d.loading ? `Fetching 3D models — ${d.done}/${d.total}` : null,
);
};
window.addEventListener(MODELS_LOADING_EVENT, onModels);
return () => window.removeEventListener(MODELS_LOADING_EVENT, onModels);
}, []);
// "Taking too long": once the tool has been loading for a while without
// becoming ready, surface a hint (slow link / something may be wrong) + a
// reload, so a stalled boot doesn't look like a frozen blank screen. Paused
// while the consent dialog is up — waiting on the user isn't "slow".
React.useEffect(() => {
if (ready || consent) return;
const t = setTimeout(() => setSlow(true), 60_000);
return () => clearTimeout(t);
}, [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(() => {
// NOTE: matched against `e.error.message`, which is BARE — Firefox's first
// trap is literally "index out of bounds" (no "RuntimeError", no "table")
// and slipped through the original pattern; the v0.1.19 prod log opens with
// exactly that message.
const terminal = (msg: string) =>
/RuntimeError|\babort(ed)?\b|\bindex out of bounds|indirect call signature|memory access out of bounds|unreachable executed|null function or function signature/i.test(
msg,
);
// Promote to the fatal screen AND pop the console open: the log panel is
// the only account of what was loading, so a fatal must never leave it
// collapsed behind a mystery blue screen.
const promote = (kind: string, msg: string) => {
append(`[fatal] ${kind}: ${msg}`);
append(dumpTrace());
// The asyncify flight recorder (handlesleep.js shim): event ring +
// machine state at death — the targeting data for the fiber trap.
const rec = (
window as Window & { __wxAsyncifyDump?: () => string }
).__wxAsyncifyDump?.();
if (rec) append(rec);
setFatal(msg);
setShowLog(true);
// Arm the React-independent floor too: it stays invisible while our
// overlay is up, and takes over the instant React dies.
showFatalScreen(msg);
};
const onError = (e: ErrorEvent) => {
const msg = e.error instanceof Error ? `${e.error.message}` : String(e.message ?? "");
if (!terminal(msg)) return;
promote("window error", msg);
};
const onRejection = (e: PromiseRejectionEvent) => {
const msg = e.reason instanceof Error ? e.reason.message : String(e.reason ?? "");
if (!terminal(msg)) return;
promote("unhandled rejection", msg);
};
// With PROXY_TO_PTHREAD, main()/wx/timers — and therefore every asyncify
// trap in this family — throw INSIDE a pthread worker. A worker's uncaught
// error fires an ErrorEvent on the Worker OBJECT, never on `window`, so the
// two listeners below can't see the very traps this overlay exists for
// (v0.1.19 prod: three "Uncaught RuntimeError"s, overlay never promoted).
// Wrap the constructor attach-only: the glue spawns all pthread workers
// from this realm, so every one gets an error tap.
const NativeWorker = window.Worker;
const onWorkerError = (e: ErrorEvent) => {
const msg = String(e.message ?? "");
if (!terminal(msg)) return;
promote("worker error", msg);
};
const PatchedWorker = function (
this: unknown,
...args: ConstructorParameters<typeof Worker>
) {
const w = new NativeWorker(...args);
w.addEventListener("error", onWorkerError);
return w;
} as unknown as typeof Worker;
PatchedWorker.prototype = NativeWorker.prototype;
window.Worker = PatchedWorker;
window.addEventListener("error", onError);
window.addEventListener("unhandledrejection", onRejection);
return () => {
window.Worker = NativeWorker;
window.removeEventListener("error", onError);
window.removeEventListener("unhandledrejection", onRejection);
};
}, [append]);
React.useEffect(() => {
const win = window as ToolWindow;
const removeNavigationHook = installToolNavigationHook(win, {
slug,
files,
targetPath,
createFile,
log: append,
});
// File→Quit leaves the editor for the project overview — lib editors
// (/:scope/libs/:name) have none, so they exit home instead. Both are
// non-editor surfaces: on a backed deploy (APP_URL set) they belong to the
// management app, so quit goes straight there (one hop instead of letting
// App.tsx's 0006 redirect bounce it).
const segments = win.location.pathname.split("/").filter(Boolean);
const exitPath =
segments[1] === "libs" ? "/" : projectPath(currentScope(), slug);
const removeQuitHook = installQuitHook(win, {
exitUrl: redirectTargetFor(APP_URL, exitPath) ?? exitPath,
log: append,
});
return () => {
removeNavigationHook();
removeQuitHook();
};
}, [slug, files, targetPath, createFile, append]);
React.useEffect(() => {
// Guard re-entry: the WASM runtime is process-global and must boot exactly
// once (see boot.ts). StrictMode is disabled app-wide for the same reason.
if (startedRef.current) return;
startedRef.current = true;
const container = containerRef.current;
if (!container) {
setStatus("Error: tool container not mounted");
return;
}
// Fresh mount (or StrictMode re-run): re-arm the deferred starters.
disposedRef.current = false;
const win = window as ToolWindow;
// OOM recovery (feature 0002): watch for soft aborts + a stale hard-kill
// sentinel, respawning a fresh tab (capped). If the chain is already
// exhausted, skip boot and show the terminal dialog.
const oom = createOomWatch({
channelKey: `${slug}:${targetPath ?? tool}`,
showExhaustedDialog: () => setOomExhausted(true),
log: append,
});
const { proceed } = oom.start();
if (!proceed) return;
// (Re)bind presence to a collab room's awareness (collab-presence 0001):
// publish this user's identity and mirror the peers into the roster chip.
// pcbnew/pl_editor bind once; eeschema rebinds per active sheet, so the
// roster shows who is on the SAME sheet (room = sheet).
const startPresence = (
provider: YjsProvider | undefined,
sheetPath?: string,
doc?: import("yjs").Doc,
) => {
// Invisible observer (read-only-viewer): never bind presence — no roster,
// no cursor/selection emit, no awareness state (peers stays empty).
if (readOnly) return;
followRef.current?.destroy();
followRef.current = null;
setFollowingTarget(null);
presenceBridgeRef.current?.destroy();
presenceBridgeRef.current = null;
presenceRef.current?.destroy();
presenceRef.current = null;
const awareness = provider?.awareness;
if (!awareness) {
setPeers([]);
return;
}
const presence = createPresence({
awareness,
user: presenceUser(),
tool,
sheetPath,
// Round-robin colors seeded by the doc's comment authors (0009 C):
// claims avoid their slots, an author rejoining adopts their own.
...(doc ? { seedColors: () => commentAuthorColors(doc) } : {}),
});
presenceRef.current = presence;
presence.subscribe(setPeers);
setPeers(presence.peers());
setActiveSheetPath(sheetPath);
// Canvas presence (0002 pcbnew / 0003 eeschema): cursor + selection emit
// and the remote VIEW_OVERLAY render. The bridge gate skips tools without
// the exports and wasm builds predating them.
if ((tool === "pcbnew" || tool === "eeschema") && hasPresenceBridge(win.Module)) {
// Follow-user (0008): available when the wasm exports FitViewport.
const fitFn = (win.Module as PresenceKicadModule).kicadCollabFitViewport;
if (fitFn) {
const follow = createFollow({
presence,
fit: (cx, cy, halfW, halfH) => fitFn.call(win.Module, cx, cy, halfW, halfH),
ownSheetPath: () => sheetPath,
});
follow.subscribe(setFollowingTarget);
followRef.current = follow;
}
presenceBridgeRef.current = bindKicadPresence({
mod: win.Module,
win: win as unknown as PresenceKicadWindow,
presence,
// Cross-app selection (0006): the project presence room, if joined.
crossApp: crossAppRef.current ?? undefined,
// Live world↔screen transform for the DOM comment layer (0005) +
// the follow controller's echo/break detection (0008).
onViewport: (vp) => {
setViewportState(vp);
followRef.current?.noteLocalViewport(vp);
},
});
}
};
// (Re)bind the comments controller to the collab doc (collab-presence 0005):
// GAL pin dots + the DOM layer's thread data. Follows the same lifecycle as
// presence — eeschema rebinds per active sheet.
const startComments = (doc: import("yjs").Doc | undefined) => {
// Comments are hidden entirely for read-only viewers (read-only-viewer):
// no pins, no panel, no thread reads — commentsCtl stays null.
if (readOnly) return;
commentsRef.current?.destroy();
commentsRef.current = null;
setCommentsCtl(null);
if (!doc || (tool !== "pcbnew" && tool !== "eeschema") || !hasCommentsBridge(win.Module)) {
return;
}
const ctl = createComments({
doc,
mod: win.Module,
user: commentAuthor(),
tool,
// Author colors follow the live nth-in-room assignment when the
// author is present; offline authors fall back to the name hash.
colorFor: (id) => presenceRef.current?.colorOf(id),
});
commentsRef.current = ctl;
setCommentsCtl(ctl);
// Test/debug handle (mirrors window.kicadCollab): lets the e2e reset
// persisted threads deterministically without driving the whole UI.
(win as { __pcbjamComments?: CommentsController }).__pcbjamComments = ctl;
if (PRESENCE_TUNER_ENABLED && hasTunerBridge(win.Module)) {
setTunerMod(win.Module);
}
// Seed the transform (pushes only happen on input events after this).
try {
const vp = JSON.parse(win.Module.kicadCollabGetViewport() || "null");
if (vp && vp.w > 0) setViewportState(vp);
} catch {
/* frame not up yet — the first input push seeds it */
}
};
// Cmd/Ctrl+S belongs to the editor: preventDefault suppresses ONLY the
// browser's "save page" dialog (observed in Firefox) — the keydown still
// propagates to the wx canvas handler, which performs the actual save.
const swallowBrowserSave = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && !e.altKey && e.key.toLowerCase() === "s") {
e.preventDefault();
}
};
win.addEventListener("keydown", swallowBrowserSave, true);
// Cmd/Ctrl+\ (Figma's hide-UI chord) is ours alone: unlike Cmd+S it must
// NOT reach the wx layer, so also stop propagation — capture on window
// fires before wx's bubble-phase window listeners (wasm/app.cpp).
const chromeHotkey = (e: KeyboardEvent) => {
if (readOnly) return; // viewers can't reveal the chrome (read-only-viewer)
if (!isChromeToggleHotkey(e)) return;
if (!chromeSetter(win)) return; // bundle without the export
e.preventDefault();
e.stopImmediatePropagation();
toggleChromeHidden();
};
win.addEventListener("keydown", chromeHotkey, true);
// The background lib pre-sync outlives the boot IIFE (it keeps warming IDB
// long after the editor is interactive), so unmount has to be able to stop
// it: `presync` checks this signal between libs. Declared out here — the
// cleanup below closes over it — and never rejected, so aborting mid-sync
// leaks nothing and throws nothing.
const presyncAbort = new AbortController();
// The libs source THIS boot created (vs. one injected via props, which the
// caller owns) — cleanup disposes it so its SyncStack sockets don't outlive
// the editor.
let ownedLibsSource: LibsSource | null = null;
void (async () => {
try {
// Real identity (collab-presence 0009 A): resolve the session user in
// parallel with the WASM download; awaited after boot, before anything
// binds presence/comments, so presenceUser()/userSlug() speak for the
// authenticated user (anonymous/example backends resolve to null and
// the pre-auth slug fallback stays).
const identityReady = loadSessionIdentity(API_BASE_URL);
// Resolve the per-tool asset base at runtime (CDN manifest → versioned
// folder, or the flat local /wasm in dev). See wasm/wasm-assets.ts.
const meta = await resolveWasmMeta(tool, assetBaseUrl);
const base = meta.base;
// One source instance, shared by the wasm provider AND the pre-sync below
// (libsSourceConfig builds a fresh one each call — their SyncStack caches
// must be the same object for the warm-up to benefit the editor).
const source =
libsSource !== undefined ? libsSource : libsSourceConfig(projectId);
if (libsSource === undefined) ownedLibsSource = source;
// Download-consent gate (standalone-load-ux 0001): before pulling the
// (large) cold wasm + lib bundles, say how many MB and wait for the OK.
// Runs only on versioned CDN deploys (`meta.ver` — flat dev roots and
// e2e assetBaseUrl overrides have no version and skip it), when this
// exact bundle+version hasn't finished downloading before, and the user
// hasn't opted into silent downloads. Only small JSON/HEAD requests
// happen before consent.
const warm = isWasmDownloaded(meta.bundle, meta.ver);
setWarmBoot(warm);
if (meta.ver && !warm && !autoDownloadEnabled()) {
const info = await gatherConsentInfo(meta, source, tool);
const ok = await new Promise<boolean>((resolve) => {
consentResolveRef.current = resolve;
setConsent(info);
});
consentResolveRef.current = null;
setConsent(null);
if (!ok) return; // unmounted while waiting — never start the download
}
// Boot fan-out (load-fanout): everything that doesn't need the wasm
// starts NOW, in parallel with the (large) wasm download.
//
// - Lib presync: warms the per-lib IDB bundles. It briefly lived AFTER
// the project open (2a47103) because opening a board took minutes —
// but the real problem was ordering (libs awaited before the project
// files, which fetched serially), not bandwidth: the ~155 lib
// requests are latency-bound, and the files now stage 8-wide the
// moment FS is up. So the presync overlaps the whole download again.
// - Doc-room connect: the collab websocket is up BEFORE the project
// files are fetched, so a file changed while we load still reaches
// this client (the room materializes the target; sheet/sibling rooms
// bind after open, as before).
// - Project files: driveProjectIntoTool below starts fetching as soon
// as the glue scripts have evaluated — the non-modularized glue runs
// FS.staticInit() at script-eval, so MEMFS staging overlaps the wasm
// download/compile rather than following it.
const libKind = LIB_KIND_FOR_TOOL[tool];
// Best-effort and NEVER rejects: `presyncSettled` resolves when the
// warm-up finished, failed, or was aborted (immediately when the source
// has none). A lib that fails to presync still loads lazily later, and
// the SyncStack dedups — a lib the wasm reaches mid-presync just awaits
// the same in-flight fetch. The signal stops the walk on unmount; the
// setLibSync guards keep a late progress callback from re-lighting the
// indicator of a torn-down session.
const startLibPresync = (): Promise<void> => {
if (!source?.presync || !libKind) return Promise.resolve();
return source
.presync({
kind: libKind,
signal: presyncAbort.signal,
onProgress: ({ done, total }) => {
if (presyncAbort.signal.aborted) return;
setLibSync(done >= total ? null : { kind: libKind, done, total });
},
})
.then(() => setLibSync(null))
.catch((e) => {
append(`[presync] ${String(e)}`);
setLibSync(null);
});
};
const presyncSettled = startLibPresync();
void presyncSettled.then(() => append(mark("presync:settled")));
// Lib editors (fileless): their frame eagerly enumerates EVERY lib of
// its kind at boot, one mutex-serialized bridge crossing at a time
// (g_pcbjamProxyMutex in the plugins) — a cold lib would network-fetch
// inside its serial crossing. Hold enumerates until the 8-wide presync
// settles so the crossings read warm IDB. pcbnew/eeschema are NOT
// gated: their choosers open on demand, and a mid-session park with no
// overlay would read as a hang.
const enumerateGate =
FILELESS_TOOLS.has(tool) && libKind
? (kind: string) =>
kind === libKind ? presyncSettled : Promise.resolve()
: undefined;
// ?collab=0 is a FULL kill-switch as of 2026-08-02: it now also skips
// the doc-room join below, so the target file falls back to the plain
// fetch path instead of ydoc materialization. Before, it only gated
// the attach — which made the flag useless as a crash-hunt bisection
// lever (the 8/2 "collab=0" prod test still materialized from the
// room and even attached).
const collabOptOut =
new URLSearchParams(win.location.search).get("collab") === "0" ||
new URLSearchParams(win.location.search).get("collab") === "false";
// Connect the doc's collab room in parallel with the wasm download (it
// needs identity, not the wasm). Errors are captured and rethrown at
// the await below — rejecting here would surface as an unhandled
// rejection while the download is still running.
const docSessionReady: Promise<
| { session?: KicadDocSession; targetBytes?: Uint8Array }
| { error: unknown }
> = (async () => {
if (collabOptOut) {
append("[collab] ?collab=0 — doc room skipped, file loads from plain fetch");
return {};
}
try {
await identityReady;
return await maybeConnectDocSession(win, {
docSource,
tool,
scopeId,
projectId,
targetPath,
log: append,
});
} catch (error) {
return { error };
}
})();
// Project presence room, same fan-out slot as the doc room: it needs
// identity and a socket, NOT the wasm, so the websocket handshake
// happens while the wasm still downloads instead of queueing behind a
// multi-second board load (which also starves the handshake — Firefox
// drops it as "interrupted while the page was loading"). Only the
// wasm-bound half (bindKicadPresence) waits for the open to settle.
// Never rejects: presence is best-effort, exactly as before.
// Read-only viewers skip the room entirely — the server rejects their
// connection anyway (presence requires write).
const crossAppReady: Promise<CrossAppHandle | undefined> =
(tool === "pcbnew" || tool === "eeschema") && !collabOptOut && !readOnly
? (async () => {
try {
await identityReady;
return await startCrossAppPresence({
scopeId,
projectId,
provider: yjsProviderConfig(),
user: presenceUser(),
tool,
// Announce the open document (the active sheet for
// eeschema, re-published on navigation below) — peers'
// sibling-restage scopes its sockets to announced files.
docPath: targetPath,
});
} catch (err) {
append(`[collab] cross-app presence connect failed: ${String(err)}`);
return undefined;
}
})()
: Promise.resolve(undefined);
// Take ownership the moment it lands — a boot that dies before the
// handoff below would otherwise leave this socket open, since unmount
// only tears down what reached crossAppRef.
void crossAppReady.then((h) => {
if (!h) return;
if (presyncAbort.signal.aborted) h.destroy(); // unmounted mid-connect
else crossAppRef.current = h;
});
await bootKicadTool({
tool,
base,
container,
log: append,
onStatus: setStatus,
onAbort: oom.onAbort,
onProgress: (loaded, total) => setProgress({ loaded, total }),
// Truthful loading states (standalone-load-ux 0001): the manifest's
// raw wasm size fixes the progress total under br/gzip; the marker
// flips the label to "Loading" (HTTP cache) vs "Downloading"; the
// completion marker is recorded once download + compile succeeded.
expectedWasmBytes: meta.sizes?.wasm ?? null,
warmStart: warm,
onWasmInstantiated: () => markWasmDownloaded(meta.bundle, meta.ver),
libsSource: source,
enumerateGate,
// 3D models: lazy per-board source (null unless the CDN manifest is
// configured) — feeds the board prescan + the viewer's ensure fallback.
modelsSource: modelsSourceConfig(),
// footprint_editor/symbol_editor load the pcbnew/eeschema bundle; the
// frame token tells its single_top launcher which editor frame to open.
frame: TOOL_FRAME[tool],
mobile: mobileUi,
});
// Identity must be settled before the doc session / presence binds
// below — effectively instant, it raced the multi-second wasm boot.
await identityReady;
// Register the save sink before the file opens: from here on, every
// editor File→Save (MEMFS write) is routed onward through saveBytes.
// Read-only sessions register neither upload nor the save-driven room
// writers (onSaved onboarding, onSavedText layout sync) — saves, were
// any reachable past the wasm lock, stay MEMFS-only.
registerSaveHook(win, {
slug,
saveBytes: readOnly ? undefined : saveBytes,
log: append,
onStatus: setStatus,
...(readOnly
? {}
: {
// A sheet created mid-session ("Add Sheet") saves to a new .kicad_sch path the
// page-load file list can't contain — warm its collab room so it stays in sync.
onSaved: (relPath: string) => {
if (relPath.endsWith(".kicad_sch"))
void sheetManagerRef.current?.onboard(relPath);
},
// Non-item document state (title block, paper, setup…) only reaches the
// room at seed time; reconcile it from every save (miss 08B).
onSavedText: (relPath: string, text: string) => {
if (sheetManagerRef.current) {
sheetManagerRef.current.syncLayoutFromSave(relPath, text);
return;
}
if (collabDocRef.current && relPath === targetPath) {
try {
syncLayoutToY(fileToDoc(text), collabDocRef.current, "layout-save");
} catch (err) {
append(`[save] layout sync failed: ${String(err)}`);
}
}
},
}),
});
// The room connect started in the fan-out above — settle it before
// staging so the target file materializes from the doc when it has one.
const docResult = await docSessionReady;
if ("error" in docResult) throw docResult.error;
const { session, targetBytes } = docResult;
const openResult = await driveProjectIntoTool(win, {
tool,
slug,
files,
targetPath,
// ydoc source with a populated room: the target file's bytes come
// from the doc; everything else (sibling files) still fetches.
fetchBytes:
targetBytes && targetPath
? (relPath) =>
relPath === targetPath ? Promise.resolve(targetBytes) : fetchBytes(relPath)
: fetchBytes,
log: append,
onStatus: setStatus,
onFileProgress: (done, total) =>
setFileSync(done >= total ? null : { done, total }),
});
// Read-only viewer (read-only-viewer): lock the wasm frame BEFORE the
// boot overlay drops — the file is open, so the frame exists; poll the
// export like the chrome toggle does. Fails CLOSED (boot error overlay):
// a viewer must never get a writable-feeling frame. gerbview/calculator
// bundles have no lock export and nothing project-mutating to lock —
// they proceed (saves are already MEMFS-only above).
if (readOnly) {
const setRo = (
win.Module as { kicadSetReadOnly?: (v: boolean) => boolean } | undefined
)?.kicadSetReadOnly;
if (typeof setRo === "function") {
const t0 = Date.now();
while (setRo(true) !== true) {
if (Date.now() - t0 > 30_000) {
throw new Error("read-only lock did not apply");
}
await new Promise((r) => setTimeout(r, 150));
}
append("[readonly] wasm frame locked (kicadSetReadOnly)");
} else if (tool !== "gerbview" && tool !== "calculator") {
throw new Error(
"read-only mode is not supported by this build (kicadSetReadOnly missing)",
);
}
}
// Everything below drives BARE embind entries that walk the loaded
// model (collab snapshot/adopt, presence bind, drift). Deferred until
// the open chain settled (openResult) — calling them while the
// kicadOpenFile Asyncify chain is still parked mid-load walks a
// half-built model and traps ("indirect call signature mismatch").
const attachCollabAndPresence = async () => {
// Drift detection: while a sheet is collaboratively edited, periodically (every N
// edits + at session end) compare the WASM serialization to the Y.Doc and report
// divergence. Gated on a real collab session; re-targeted per active sheet below.
const { startDriftDetection } = await import("@/wasm/collab/drift-detect");
// Cross-app selection (0006): join the project-wide presence room BEFORE
// the per-file collab starts, so the first startPresence bind already
// routes xsel. Honors the same ?collab=0 opt-out as the room collab.
// Cross-app presence: the ROOM was joined back in the boot fan-out
// (pure network + Y.Doc, no wasm) — only the handoff to the wasm-bound
// presence below has to wait for the open. Settle it here.
crossAppRef.current = (await crossAppReady) ?? null;
// Test/debug handle (mirrors __pcbjamComments): lets the e2e assert
// the project-room peer view without driving pixels.
(win as { __pcbjamCrossApp?: CrossAppHandle | null }).__pcbjamCrossApp =
crossAppRef.current;
if (tool === "eeschema") {
// Multi-room (subschema) collab: every .kicad_sch is its own warm room; the
// active sheet is bound, navigation re-routes it (C++ onSheetChanged hook).
sheetManagerRef.current =
(await startSheetCollab(win, {
slug,
scopeId,
projectId,
targetPath,
files,
session,
saveBytes: readOnly ? undefined : saveBytes,
editorMatchesDoc: !!targetBytes,
readOnly,
// Re-point drift detection + presence at whichever sheet is bound.
onActiveChange: (activeRoom) => {
driftRef.current?.stop();
driftRef.current = null;
// Re-announce the actively-edited sheet in the project room —
// peers' sibling-restage tracks it (a pcbnew tab only mirrors
// sheets someone actually has open).
crossAppRef.current?.setDocPath(activeRoom?.sheetPath);
startPresence(activeRoom?.provider, activeRoom?.sheetPath, activeRoom?.doc);
startComments(activeRoom?.doc);
if (activeRoom && !readOnly) {
driftRef.current = startDriftDetection({
doc: activeRoom.doc,
mod: win.Module,
win,
tool,
slug,
targetPath: activeRoom.sheetPath,
log: append,
});
}
},
log: append,
onStatus: setStatus,
})) ?? null;
} else {
const collabHandle = await maybeStartCollab(win, {
tool,
slug,
scopeId,
projectId,
targetPath,
collabSession: session,
editorMatchesDoc: !!targetBytes,
readOnly,
log: append,
onStatus: setStatus,
});
collabHandleRef.current = collabHandle ?? null;
collabDocRef.current = collabHandle?.doc ?? null;
startPresence(collabHandle?.provider, undefined, collabHandle?.doc);
startComments(collabHandle?.doc);
// Live sibling mirror (project-sync 0001 bug 3): keep the schematic
// files a PCB session syncs from fresh in MEMFS, instead of the
// one-shot boot snapshot. Same opt-out as the room collab; read-only
// viewers skip it (they can't run the sync anyway).
//
// STAGGERED out of the settle window (2026-08-02, the ladder
// result): warm sibling-heavy projects crash at exactly this moment
// (V1/V4 fail 2/2 warm, V2/V3 without siblings never do), and the
// asyncify flight recorder places the fatal interleave inside the
// settle-time wake windows. The restage's room connects + restage
// fetches were the only sibling-specific traffic contending with
// those windows. Nothing here is needed for first paint — the boot
// snapshot staged every sibling seconds ago — so it starts when the
// main thread is idle (or after 5s, whichever first), well clear of
// the settle storm. Unmount-safety: the ref may be populated after
// unmount, so the cleanup check runs inside the callback too.
if (tool === "pcbnew" && collabHandle && !readOnly) {
const startRestageIdle = () => {
if (disposedRef.current) return;
void startSiblingRestage({
win,
slug,
scopeId,
projectId,
files,
targetPath,
// Presence-scoped: connect a sheet's room only while a peer
// announces it open (zero sibling sockets when alone). Absent
// (provider "none" / connect failed) ⇒ eager fallback.
presence: crossAppRef.current ?? undefined,
provider: yjsProviderConfig(),
log: append,
}).then((handle) => {
if (disposedRef.current) {
handle?.destroy();
return;
}
siblingRestageRef.current = handle;
});
};
type IdleWindow = Window & {
requestIdleCallback?: (cb: () => void, opts?: { timeout: number }) => number;
};
const w = window as IdleWindow;
if (typeof w.requestIdleCallback === "function") {
w.requestIdleCallback(startRestageIdle, { timeout: 5000 });
} else {
window.setTimeout(startRestageIdle, 3000);
}
}
if (collabHandle && targetPath && COLLAB_TOOLS.has(tool) && !readOnly) {
driftRef.current = startDriftDetection({
doc: collabHandle.doc,
mod: win.Module,
win,
tool,
slug,
targetPath,
log: append,
});
}
}
};
if (openResult === "failed") {
// The load never settled (or a legacy-wasm open timed out): entering
// the wasm now would race the parked open chain. Boot on without
// collab/presence — the board stays viewable, saves still route.
append("[collab] file open never settled — collab/presence disabled for this session");
} else {
try {
await attachCollabAndPresence();
} catch (err) {
// Version-skew refusal must still surface as the boot error.
if ((err as { name?: string } | undefined)?.name === "SexprVersionError") throw err;
// Degrade, don't die: a residual wasm trap here (reentrancy during
// some other parked chain) used to fail the whole boot.
append(`[collab] attach failed — continuing without collab: ${String(err)}`);
}
}
// Deferred-realtime upgrade: the scope libs source opens its stacks
// channel-less (no socket per org lib), so promote the libs the OPEN
// DOCUMENT references — a peer editing a PLACED symbol must still
// reach this session live (lib-update toast); everything else syncs
// on the next load. Fire-and-forget: boot never waits on sockets.
if (targetPath && source?.enableRealtime) {
const staged = readStagedFile(win, slug, targetPath);
const nicks = staged
? usedLibNicknames(new TextDecoder().decode(staged))
: [];
append(
`[libs] doc references ${nicks.length} lib nickname(s)` +
(staged ? "" : " (target not staged?)"),
);
if (nicks.length) {
void source
.enableRealtime(nicks)
.catch((e) => append(`[libs] realtime upgrade: ${String(e)}`));
}
}
// Lib editors: the enumerate gate holds their whole-set hydrate until
// the presync settles — wait for it here too, so the boot overlay (with
// its ticking lib line) stays up instead of revealing an empty tree.
if (enumerateGate) await presyncSettled;
// 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.
// First paint reached. Past this point the GAL is initialized, so the
// refresh-timer self-rearm loop — the precondition for the production
// load trap — is over. A crash report that contains this mark rules
// that mechanism out; one that stops before it does not.
await waitForWxUi(win);
append(mark("ui:ready"));
setStatus("");
setReady(true);
} catch (err) {
append(`[fatal] ${String(err)}`);
append(dumpTrace());
setStatus(`Error: ${String(err)}`);
setFatal(String(err));
}
})();
return () => {
// Deferred starters (the sibling-restage idle stagger) check this
// before creating anything after unmount.
disposedRef.current = true;
// A consent dialog pending at unmount resolves false — the boot IIFE
// bails without ever starting the download.
consentResolveRef.current?.(false);
consentResolveRef.current = null;
// Stop the background lib warm-up: presync checks the signal between libs,
// so in-flight bundle fetches finish (their IDB writes are still useful for
// the next mount) and no further ones start.
presyncAbort.abort();
win.removeEventListener("keydown", swallowBrowserSave, true);
win.removeEventListener("keydown", chromeHotkey, true);
commentsRef.current?.destroy();
commentsRef.current = null;
followRef.current?.destroy();
followRef.current = null;
presenceBridgeRef.current?.destroy();
presenceBridgeRef.current = null;
presenceRef.current?.destroy();
presenceRef.current = null;
crossAppRef.current?.destroy();
crossAppRef.current = null;
siblingRestageRef.current?.destroy();
siblingRestageRef.current = null;
driftRef.current?.stop();
driftRef.current = null;
// Tears down every warm room's provider/doc (the only place providers are
// destroyed — switching sheets keeps them connected) and clears drift via
// onActiveChange(null).
sheetManagerRef.current?.destroy();
sheetManagerRef.current = null;
// The single-room (pcbnew/pl_editor) counterpart: binding + provider +
// doc. Without this the board room's socket survived navigation.
collabHandleRef.current?.destroy();
collabHandleRef.current = null;
collabDocRef.current = null;
// Close the lib SyncStacks this boot opened (mirror mux + any dedicated
// sockets); IDB caches stay. Injected sources belong to the caller.
ownedLibsSource?.dispose?.();
ownedLibsSource = null;
oom.stop();
};
// Boot is one-shot per mount; deps intentionally exclude files/targetPath so
// they don't retrigger a (rejected) second boot.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tool, slug, assetBaseUrl, append]);
// kicadSetChrome, once the editor is up (null on bundles without it).
const setChromeFn = React.useMemo(
() => (ready ? chromeSetter(window) : null),
[ready],
);
// Apply the chrome-visibility state to the wasm frame. A LAYOUT effect with
// a synchronous first attempt: `ready` unmounts the opaque boot overlay in
// this same commit, and a passive effect would let one frame of full chrome
// paint on mobile. appliedRef skips the initial "shown" apply — never
// relayout a frame this component never hid.
const appliedRef = React.useRef<boolean | null>(null);
React.useLayoutEffect(() => {
if (!setChromeFn) return;
if (appliedRef.current === effectiveChromeHidden) return;
if (appliedRef.current === null && !effectiveChromeHidden) return;
const apply = () => {
try {
return setChromeFn(!effectiveChromeHidden) === true;
} catch (err) {
append(`[chrome] kicadSetChrome failed: ${String(err)}`);
return true; // don't retry a throwing binding
}
};
if (apply()) {
appliedRef.current = effectiveChromeHidden;
return;
}
// The editor frame can lag `ready` (waitForWxUi falls through after 25 s)
// — retry briefly rather than dropping the toggle.
const t0 = Date.now();
const tick = window.setInterval(() => {
if (apply()) {
appliedRef.current = effectiveChromeHidden;
window.clearInterval(tick);
} else if (Date.now() - t0 > 30_000) {
window.clearInterval(tick);
}
}, 300);
return () => window.clearInterval(tick);
}, [setChromeFn, effectiveChromeHidden, append]);
return (
<div className="relative h-screen w-screen overflow-hidden bg-[#1a1a2e]">
{/*
wx.js addresses the DOM by id: #main-window is its top-level (id=0)
window — it owns #canvas (created in boot's preRun) — and #window-container
parents every child window. Both ids must exist before the runtime boots,
mirroring the harness HTML (tests/apps/kicad/<tool>.html).
*/}
<div ref={containerRef} id="main-window" className="absolute inset-0 h-full w-full" />
<div id="window-container" />
{/* Everything that can throw into React's commit lives INSIDE the
boundary; the fatal screen and the console panel live OUTSIDE it, so
a runtime death can no longer white-screen the very UI that reports
it (see WasmErrorBoundary). */}
<WasmErrorBoundary
onFatal={(msg) => {
append(`[fatal] react tree died: ${msg}`);
append(dumpTrace());
setFatal(msg);
setShowLog(true);
showFatalScreen(msg);
}}
>
{oomExhausted && (
<MemoryExhaustedDialog
onOpenNewTab={() => respawnInNewTab()}
onReload={() => window.location.reload()}
/>
)}
{/* 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>
</>
) : consent ? (
<DownloadConsent
info={consent}
onAccept={(always) => {
if (always) setAutoDownloadEnabled(true);
consentResolveRef.current?.(true);
}}
/>
) : (
<>
<Loader2 className="animate-spin" size={32} />
<p className="font-mono text-sm text-white/80">
{status || "Loading…"}
</p>
<DownloadProgress progress={progress} />
{/* The parallel fan-out's other progress: project files staging
into MEMFS, and the lib warm-up. The lib line says "checking"
on purpose — the walk visits every lib but downloads only new
or changed ones, so a bare 15/155 next to the consent dialog's
MB figures would read as 155 big downloads. */}
{fileSync && fileSync.total > 0 && (
<p className="whitespace-pre font-mono text-xs text-white/50">
Project files {String(fileSync.done).padStart(String(fileSync.total).length, " ")}/{fileSync.total}
</p>
)}
{libSync && (
<p className="whitespace-pre font-mono text-xs text-white/50">
{libSyncLabel(libSync)}
</p>
)}
<p className="font-mono text-xs text-white/40">
{warmBoot
? "Loading from your browser's cache — no download needed."
: "Downloading the editor — it's cached for future visits."}
</p>
{slow && (
<>
<p className="max-w-sm px-6 text-center font-mono text-xs text-amber-300/90">
This is taking longer than usual a slow connection, or
something may be wrong. You can keep waiting, or reload.
</p>
<button
className="rounded border border-white/30 px-3 py-1 text-xs hover:bg-white/10"
onClick={() => window.location.reload()}
>
Reload
</button>
</>
)}
</>
)}
</div>
)}
{/* Eager library load overlay — the first chooser/editor open hydrates the
whole library set from IDB into wasm (tens of seconds on the full CDN
set) with the main thread blocked. Cover the (frozen) editor so it reads
as "loading, just slow" rather than a hang. Shown post-boot; before
`ready` the boot overlay already covers it. */}
{ready && libLoading && (
<div className="absolute inset-0 z-30 flex flex-col items-center justify-center gap-3 bg-[#1a1a2e]/95 text-white">
<Loader2 className="animate-spin" size={32} />
<p className="font-mono text-sm text-white/80">
{libLoading.kind === "library"
? "Loading libraries…"
: `Loading ${libLoading.kind} libraries…`}
</p>
{libLoading.total > 0 && (
<div className="w-64 max-w-[70vw]">
<div className="h-1.5 overflow-hidden rounded-full bg-white/15">
<div
className="h-full rounded-full bg-emerald-400 transition-[width] duration-200 ease-out"
style={{
width: `${Math.min(100, Math.round((libLoading.done / libLoading.total) * 100))}%`,
}}
/>
</div>
{/* Space-pad `done` to `total`'s width so the centered line
doesn't shift as the count gains a digit. */}
<p className="mt-1 whitespace-pre text-center font-mono text-[11px] text-white/50">
{String(Math.min(libLoading.done, libLoading.total)).padStart(
String(libLoading.total).length,
" ",
)}{" "}
/ {libLoading.total} libraries
</p>
</div>
)}
<p className="max-w-sm px-6 text-center font-mono text-xs text-white/40">
Moving the library set into the editor. The first open can take a
moment it's cached after this.
</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>
)}
{/* Follow-user (0008): who we're following + how to stop. Esc also works
because any canvas key input breaks the follow via noteLocalViewport
only when the viewport moves this banner is the explicit out. */}
{ready && followingTarget && (
<div
data-testid="follow-banner"
className="absolute left-1/2 top-3 z-20 flex -translate-x-1/2 items-center gap-2 rounded-full bg-black/70 px-3 py-1 text-xs text-white shadow-sm ring-1 ring-inset ring-white/20"
>
<span>
Following <span className="font-semibold">{followingTarget.name}</span> move to stop
</span>
<button
type="button"
className="rounded-full bg-white/15 px-2 py-0.5 font-medium hover:bg-white/25"
onClick={() => followRef.current?.unfollow()}
>
Stop
</button>
</div>
)}
{/* Overlay menu (0010): the single draggable circular icon replacing the
old top-right row. Its badge is the peer count; the panel stacks the
session sections — roster, source chip, view-only pill, follow row,
comments (portal slot filled by CommentLayer), chrome toggle. It is
the one control that stays up in canvas-only (chrome-hidden) mode. */}
{ready && (
<OverlayMenu
badge={peers.length}
unread={commentsUnread.threads}
unreadMention={commentsUnread.mentioned}
>
{/* PEOPLE — who else is here, and whose view you're locked to. The
follow state lives on each person's own row (PresenceRoster), so
there is no separate "Following…" banner to keep in sync. */}
{peers.length > 0 && (
<OverlayMenuSection label="People">
<PresenceRoster
peers={peers}
activeSheetPath={activeSheetPath}
following={followingTarget}
onFollow={(t) => {
if (t) followRef.current?.follow(t);
else followRef.current?.unfollow();
}}
/>
</OverlayMenuSection>
)}
{/* DOCUMENT — where this file came from and whether you may edit it.
SourceChip is shared with the light project pages, so instead of
restyling it we ask for its `muted` tone: colour drops to a dot,
and the chip sits in a normal row like everything else. */}
{(sourceDescriptor || readOnly) && (
<OverlayMenuSection label="Document">
{sourceDescriptor && (
<div className={`${overlayRowClass} cursor-default`}>
<SourceChip descriptor={sourceDescriptor} tone="muted" />
</div>
)}
{readOnly && (
<div
data-testid="view-only-pill"
className={`${overlayRowClass} cursor-default`}
>
<EyeOff size={14} className="shrink-0 text-neutral-400 dark:text-white/50" />
<span>View only</span>
<span className="ml-auto text-[10px] text-neutral-400 dark:text-white/40">
read-only
</span>
</div>
)}
</OverlayMenuSection>
)}
{commentsCtl && (
<OverlayMenuSection label="Comments">
<div
data-testid="overlay-menu-comments"
ref={setCommentsSlot}
className="flex w-full flex-col items-start gap-2"
/>
</OverlayMenuSection>
)}
<OverlayMenuSection label="View">
{setChromeFn !== null && !readOnly && (
<button
data-testid="chrome-toggle"
aria-pressed={chromeHidden}
className={overlayRowClass}
title={`${chromeHidden ? "Show" : "Hide"} UI (${CHROME_HOTKEY_LABEL})`}
onClick={() => toggleChromeHidden()}
>
{chromeHidden ? (
<PanelsTopLeft size={14} className="shrink-0 text-neutral-400 dark:text-white/50" />
) : (
<EyeOff size={14} className="shrink-0 text-neutral-400 dark:text-white/50" />
)}
<span>{chromeHidden ? "Show UI" : "Hide UI"}</span>
<kbd className="ml-auto rounded bg-black/10 px-1.5 py-0.5 text-[10px] font-medium text-neutral-500 dark:bg-white/10 dark:text-white/50">
{CHROME_HOTKEY_LABEL}
</kbd>
</button>
)}
{/* Light/dark toggle (comments-ux 0002): flips the shell theme;
the F4 effect above re-themes the GAL canvas through the
bridge. Available to viewers too — theming isn't editing. */}
<button
data-testid="overlay-theme-toggle"
aria-pressed={theme === "dark"}
className={overlayRowClass}
title={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
>
{theme === "dark" ? (
<Sun size={14} className="shrink-0 text-neutral-400 dark:text-white/50" />
) : (
<Moon size={14} className="shrink-0 text-neutral-400 dark:text-white/50" />
)}
<span>{theme === "dark" ? "Light mode" : "Dark mode"}</span>
</button>
</OverlayMenuSection>
</OverlayMenu>
)}
{/* Figma-like comments (0005): GAL pin dots + this DOM layer (hit targets,
thread popovers, comment mode, panel). The bar + list panel render
into the overlay menu's comments slot (0010). */}
{ready && commentsCtl && (
<CommentLayer
controller={commentsCtl}
viewport={viewportState}
currentUser={presenceUser().id}
menuSlot={commentsSlot}
onUnreadChange={onCommentsUnread}
mentionPeers={peers.map((p) => ({
slug: p.user.id,
name: p.user.name || p.user.id,
}))}
/>
)}
{/* DEV: presence style tuner (VITE_PRESENCE_TUNER=1). */}
{ready && tunerMod && <PresenceTuner mod={tunerMod} tool={tool} />}
{/* Lib pre-sync warming IDB after the editor opened (big set) — the ONLY
surface for it now that the warm-up starts post-open: a small unobtrusive
indicator so the user knows browsing is still filling in behind them,
never something they are waiting on. */}
{ready && libSync && (
<div className="pointer-events-none absolute bottom-9 left-3 z-20 flex items-center gap-2 rounded bg-black/80 px-3 py-1.5 font-mono text-xs text-emerald-200">
<Loader2 className="animate-spin" size={14} />{" "}
<span className="whitespace-pre">{libSyncLabel(libSync)}</span>
</div>
)}
{/* Board 3D models still prefetching into the cache (background). */}
{ready && modelsSync && (
<div className="pointer-events-none absolute bottom-[4.25rem] left-3 z-20 flex items-center gap-2 rounded bg-black/80 px-3 py-1.5 text-xs text-sky-200">
<Loader2 className="animate-spin" size={14} /> {modelsSync}
</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>
)}
{/* A collaborator updated a symbol PLACED in this document — auto-dismisses. */}
{libUpdate && (
<button
data-testid="lib-update-toast"
className="absolute left-1/2 top-3 z-40 max-w-md -translate-x-1/2 rounded bg-amber-950/95 px-3 py-2 text-center text-xs text-amber-100 shadow-lg ring-1 ring-amber-500/40"
onClick={() => setLibUpdate(null)}
title="Dismiss"
>
{libUpdate}
</button>
)}
{/* Backend rolled this doc back to the last valid state (kicad-validity). */}
{docReverted && (
<button
data-testid="doc-reverted-toast"
className="absolute left-1/2 top-3 z-40 max-w-md -translate-x-1/2 rounded bg-orange-950/95 px-3 py-2 text-center text-xs text-orange-100 shadow-lg ring-1 ring-orange-500/40"
onClick={() => setDocReverted(null)}
title="Dismiss"
>
{docReverted}
</button>
)}
</WasmErrorBoundary>
{/* Terminal failure — z-35, ABOVE the boot overlay but below the console
panel, OUTSIDE the error boundary, and independent of `ready`: a
post-boot runtime death gets a proper blue screen instead of a blank
page, with the console panel forced open beneath it. */}
{fatal && (
<div
data-testid="fatal-overlay"
className="absolute inset-0 z-[35] flex flex-col items-center justify-center gap-3 bg-[#1a1a2e] text-white"
>
<p className="font-mono text-4xl text-white/90">:(</p>
<p className="font-mono text-sm text-white">
The editor hit an unrecoverable error and stopped.
</p>
<p className="max-w-lg px-6 text-center font-mono text-xs text-blue-100/90">
{fatal}
</p>
<p className="max-w-md px-6 text-center font-mono text-xs text-blue-200/60">
The console below records what was loading when this happened
please copy it into a bug report.
</p>
<div className="flex gap-2">
<button
className="rounded border border-white/40 px-3 py-1 text-xs hover:bg-white/10"
onClick={() => window.location.reload()}
>
Reload
</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">
<div className="flex items-center bg-black/70">
<button
className="flex items-center gap-1 px-3 py-1 font-mono text-xs text-white"
onClick={() => setShowLog((s) => !s)}
>
{showLog ? <ChevronDown size={14} /> : <ChevronUp size={14} />} console
({logs.length})
</button>
{showLog && (
<button
className="ml-auto px-3 py-1 font-mono text-xs text-white/70 hover:text-white"
onClick={() => {
void navigator.clipboard.writeText(logs.join("\n")).then(
() => append("[console] copied to clipboard"),
() => append("[console] clipboard copy failed"),
);
}}
>
copy
</button>
)}
</div>
{showLog && (
<pre className="max-h-64 overflow-auto bg-black/85 p-3 font-mono text-[11px] leading-tight text-green-300">
{logs.join("\n")}
</pre>
)}
</div>
)}
</div>
);
}
/** "~173 MB" — coarse on purpose; these are quotes, not meters. */
function approxMB(bytes: number): string {
const mb = bytes / 1e6;
return `~${mb >= 10 ? Math.round(mb) : Math.max(0.1, mb).toFixed(1)} MB`;
}
/** "1 library" / "155 libraries". */
function libCount(n: number): string {
return `${n} librar${n === 1 ? "y" : "ies"}`;
}
/**
* One consent row's size figure — MB only; the COUNTS live in the row's detail
* line (libNowDetail/libLaterDetail), because "download" and "check" cover
* different sets of libs and one number can't stand for both.
*/
function libStateLabel(s: LibsSyncState | null): string | null {
if (!s || s.total === 0) return null;
if (s.warm >= s.total) return "already cached";
// sizesKnown false ⇒ some cold libs carry no published size, so coldBytes is a
// FLOOR, never the total: say "at least", and quote nothing at all when not a
// single cold lib was priced.
if (!s.sizesKnown) {
return s.coldBytes > 0 ? `at least ${approxMB(s.coldBytes)}` : null;
}
return approxMB(s.coldBytes);
}
/**
* Detail line for the kind this editor PRE-SYNCS at boot. Two different numbers
* matter here and quoting either alone reads as a lie: only the libs that aren't
* cached yet download their contents ("1 library"), but the pre-sync walks EVERY
* library of the kind to see whether it changed — and that walk is what the
* "Syncing footprint libraries — 99/155" bar counts. A null state (source can't
* tell) keeps the old count-free wording.
*/
function libNowDetail(s: LibsSyncState | null): string {
const base = "this editor browses them";
// No warmth answer at all: say only what stays true regardless of counts —
// the warm-up runs alongside the editor download and finishes in the
// background, so "downloaded now" would overpromise as well as vague.
if (!s || s.total === 0) return `${base} — fetched in the background`;
const cold = s.total - s.warm;
if (cold === 0) {
return `${base}${libCount(s.total)} already here, just checked for updates`;
}
if (s.warm === 0) return `${base} — downloads ${libCount(s.total)}`;
return `${base} — downloads ${libCount(cold)}, checks all ${s.total} for updates`;
}
/**
* Detail line for the OTHER kind of a merged-bundle session: never walked at
* boot (no pre-sync, no update check) — each lib is fetched lazily the first
* time a cross-face feature reaches it.
*/
function libLaterDetail(s: LibsSyncState | null): string {
const base = "downloaded later, only if you use them";
if (!s || s.total === 0) return base;
const cold = s.total - s.warm;
if (cold === 0) return `${libCount(s.total)} already here — nothing to download`;
return `${base} (${libCount(cold)} not here yet)`;
}
/**
* The editor bundle's figure. `toolBytes` is the OVER-THE-WIRE (compressed)
* size, while the load screen's progress bar counts RAW decoded bytes — quoting
* the first bare is what made "~32 MB" look like a lie next to a ~150 MB bar.
* Show both whenever the manifest prices them; the HEAD fallback knows only the
* wire size, so it says just that.
*/
function toolFigure(info: ConsentInfo): React.ReactNode {
if (info.toolBytes === null) return "large (hundreds of MB)";
const wire = `${approxMB(info.toolBytes)} compressed`;
if (info.toolRawBytes === null) return wire;
return (
<>
{wire}
<br />
<span className="text-white/50">
{approxMB(info.toolRawBytes)} uncompressed
</span>
</>
);
}
/**
* The download-consent card (standalone-load-ux 0001): what's about to be
* pulled onto this device — the editor bundle now, the tool's lib kind now,
* the other kind later on demand — and an OK that actually gates the fetches.
*/
function DownloadConsent({
info,
onAccept,
}: {
info: ConsentInfo;
onAccept: (always: boolean) => void;
}) {
const [always, setAlways] = React.useState(false);
const kindTitle = (k: "symbol" | "footprint") =>
k === "symbol" ? "Symbol libraries" : "Footprint libraries";
const row = (
title: string,
detail: string,
figure: React.ReactNode,
testid: string,
) => (
<li
data-testid={testid}
className="flex items-baseline gap-3 border-t border-white/10 py-2 first:border-t-0"
>
<div className="min-w-0 flex-1">
<p className="text-sm text-white/90">{title}</p>
<p className="text-xs text-white/50">{detail}</p>
</div>
{figure && (
<span className="whitespace-nowrap text-right font-mono text-xs leading-snug text-white/70">
{figure}
</span>
)}
</li>
);
return (
<div
data-testid="download-consent"
className="flex w-full max-w-md flex-col items-center gap-4 px-6"
>
<Download size={32} className="text-white/70" />
<h2 className="text-base font-semibold">
{info.update ? "Editor update available" : "One-time download needed"}
</h2>
<p className="text-center text-sm text-white/70">
PCBJam runs KiCad fully in your browser.{" "}
{info.update
? "This release ships a new editor build, so it needs downloading again — it's cached after that."
: "Opening this tool downloads it once — repeat visits load from your browser's cache."}
</p>
<ul className="w-full rounded-lg bg-white/5 px-4 py-1">
{row(
"Editor engine",
"the KiCad build, downloaded now",
toolFigure(info),
"consent-row-tool",
)}
{info.libNowKind &&
row(
kindTitle(info.libNowKind),
libNowDetail(info.libNow),
libStateLabel(info.libNow),
"consent-row-now",
)}
{info.libLaterKind &&
row(
kindTitle(info.libLaterKind),
libLaterDetail(info.libLater),
libStateLabel(info.libLater),
"consent-row-later",
)}
</ul>
<label className="flex cursor-pointer items-center gap-2 text-xs text-white/60">
<input
type="checkbox"
checked={always}
onChange={(e) => setAlways(e.target.checked)}
className="accent-white/80"
/>
Always download without asking
</label>
<button
data-testid="consent-accept"
className="rounded bg-white/90 px-4 py-1.5 text-sm font-medium text-[#1a1a2e] hover:bg-white"
onClick={() => onAccept(always)}
>
Download &amp; open
</button>
</div>
);
}
/**
* Fixed-width lib pre-sync line, e.g. "Checking symbol libraries — 42/208".
* "Checking", not "downloading": the walk visits every lib of the kind but
* downloads only the new/changed ones — a bare counter read as 155 downloads
* (standalone-load-ux follow-up). The prefix is constant and `done` is
* space-padded to `total`'s digit count, so the text stays still while the
* counter ticks (render it in a font-mono + whitespace-pre element so the pad
* spaces hold their width).
*/
function libSyncLabel(s: { kind: string; done: number; total: number }): string {
const total = String(s.total);
const done = String(Math.min(s.done, s.total)).padStart(total.length, " ");
return `Checking ${s.kind} libraries — ${done}/${total}`;
}
/**
* WASM download progress for the boot overlay. A determinate bar when the server
* sent a Content-Length the decoded stream agrees with; otherwise just MB so far
* (gzip/br makes Content-Length the COMPRESSED size, so `loaded` can pass it).
*/
function DownloadProgress({
progress,
}: {
progress: { loaded: number; total: number } | null;
}) {
if (!progress) return null;
const mb = (n: number) => `${(n / 1e6).toFixed(1)} MB`;
const determinate = progress.total > 0 && progress.loaded <= progress.total;
const pct = determinate
? Math.round((progress.loaded / progress.total) * 100)
: 0;
return (
<div className="w-64 max-w-[80vw]">
{determinate ? (
<>
<div className="h-1.5 w-full overflow-hidden rounded bg-white/15">
<div
className="h-full rounded bg-white/70 transition-[width]"
style={{ width: `${pct}%` }}
/>
</div>
<p className="mt-1 text-center font-mono text-xs text-white/50">
{mb(progress.loaded)} / {mb(progress.total)} ({pct}%)
</p>
</>
) : (
<p className="text-center font-mono text-xs text-white/50">
{mb(progress.loaded)} downloaded
</p>
)}
</div>
);
}