diff --git a/web/standalone/src/components/WasmTool.tsx b/web/standalone/src/components/WasmTool.tsx
index 6a68f6f..63cb697 100644
--- a/web/standalone/src/components/WasmTool.tsx
+++ b/web/standalone/src/components/WasmTool.tsx
@@ -167,8 +167,9 @@ interface ConsentInfo {
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 the background once the
- * editor is open, not ahead of it (see startLibPresync). */
+ /** 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"). */
@@ -954,6 +955,12 @@ export function WasmTool({
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(null);
// A collaborator updated library items that are PLACED in the open document
@@ -1361,24 +1368,34 @@ export function WasmTool({
setConsent(null);
if (!ok) return; // unmounted while waiting — never start the download
}
- // Pre-warm the lib bundles into IDB — started LATER (right after the
- // project is staged + opened, below). It used to run in parallel with the
- // wasm download, but a scope with ~155 team libs then fired hundreds of
- // bundle fetches that contended with the 26MB+ wasm and the project's own
- // files on the same connection, so opening a board took minutes before
- // anything was interactive. The warm-up is an OPTIMIZATION (the editor's
- // first enumerate reads a warm cache instead of freezing on N cold bundle
- // fetches), never a precondition — so it yields to everything the user is
- // actually blocked on and fills in behind them.
+ // 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];
- const startLibPresync = () => {
- if (!source?.presync || !libKind) return;
- // Fire-and-forget + best-effort, unchanged: 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.
- void source
+ // 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 => {
+ if (!source?.presync || !libKind) return Promise.resolve();
+ return source
.presync({
kind: libKind,
signal: presyncAbort.signal,
@@ -1393,6 +1410,41 @@ export function WasmTool({
setLibSync(null);
});
};
+ const presyncSettled = startLibPresync();
+ // 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;
+ // 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 () => {
+ try {
+ await identityReady;
+ return await maybeConnectDocSession(win, {
+ docSource,
+ tool,
+ scopeId,
+ projectId,
+ targetPath,
+ log: append,
+ });
+ } catch (error) {
+ return { error };
+ }
+ })();
await bootKicadTool({
tool,
base,
@@ -1409,6 +1461,7 @@ export function WasmTool({
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(),
@@ -1456,14 +1509,11 @@ export function WasmTool({
},
}),
});
- const { session, targetBytes } = await maybeConnectDocSession(win, {
- docSource,
- tool,
- scopeId,
- projectId,
- targetPath,
- log: append,
- });
+ // 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;
await driveProjectIntoTool(win, {
tool,
slug,
@@ -1478,15 +1528,9 @@ export function WasmTool({
: fetchBytes,
log: append,
onStatus: setStatus,
+ onFileProgress: (done, total) =>
+ setFileSync(done >= total ? null : { done, total }),
});
- // Everything the user is blocked on has now downloaded: the wasm bundle
- // is up and the project's own files are staged into MEMFS with the target
- // file open. NOW warm the scope's lib bundles, in the background — the
- // boot tail below (collab rooms, wx UI build) is latency-bound chatter,
- // not bandwidth, so the pre-sync no longer delays the editor becoming
- // interactive. Whatever it hasn't reached by the time the user opens a
- // chooser is fetched lazily on demand, as it always was.
- startLibPresync();
// 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):
@@ -1618,6 +1662,10 @@ export function WasmTool({
});
}
}
+ // 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.
await waitForWxUi(win);
@@ -1759,11 +1807,21 @@ export function WasmTool({
{status || "Loading…"}
- {/* The lib pre-sync deliberately has NO line here anymore: it now
- starts only once the wasm + project files are in (see the boot
- IIFE), so the overlay would be showing a counter the user is not
- waiting on. Its progress surfaces in the unobtrusive bottom-left
- indicator below, after `ready`. */}
+ {/* 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 && (
+
+ Project files — {String(fileSync.done).padStart(String(fileSync.total).length, " ")}/{fileSync.total}
+
+ )}
+ {libSync && (
+
+ {libSyncLabel(libSync)}
+
+ )}
{warmBoot
? "Loading from your browser's cache — no download needed."
@@ -2101,8 +2159,8 @@ function libStateLabel(s: LibsSyncState | null): string | null {
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 in the background once the editor is open (it moved off
- // the critical path, so "downloaded now" would be wrong as well as vague).
+ // 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) {
@@ -2241,15 +2299,18 @@ function DownloadConsent({
}
/**
- * Fixed-width lib pre-sync line, e.g. "Syncing symbol libraries — 42/208".
- * 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).
+ * 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 `Syncing ${s.kind} libraries — ${done}/${total}`;
+ return `Checking ${s.kind} libraries — ${done}/${total}`;
}
/**
diff --git a/web/standalone/src/wasm/boot.test.ts b/web/standalone/src/wasm/boot.test.ts
new file mode 100644
index 0000000..0e99348
--- /dev/null
+++ b/web/standalone/src/wasm/boot.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from "vitest";
+import { hasWritableLib } from "./boot";
+import type { LibInfo } from "./libs/source";
+
+const lib = (type?: string): LibInfo => ({ id: `id-${type}`, name: `n-${type}`, type });
+
+describe("hasWritableLib", () => {
+ it("sees an example-backend 'user' lib", () => {
+ expect(hasWritableLib([[lib("origin"), lib("user")]])).toBe(true);
+ });
+
+ it("sees a private-platform 'org' lib (renamed from 'user') — the My Symbols 409", () => {
+ expect(hasWritableLib([[lib("origin")], [lib("mirror"), lib("org")]])).toBe(true);
+ });
+
+ it("false when only read-only libs are listed", () => {
+ expect(hasWritableLib([[lib("origin"), lib("mirror")], [lib(undefined)]])).toBe(false);
+ });
+
+ it("false on empty lists", () => {
+ expect(hasWritableLib([[], []])).toBe(false);
+ expect(hasWritableLib([])).toBe(false);
+ });
+});
diff --git a/web/standalone/src/wasm/boot.ts b/web/standalone/src/wasm/boot.ts
index 2233a0c..233aa32 100644
--- a/web/standalone/src/wasm/boot.ts
+++ b/web/standalone/src/wasm/boot.ts
@@ -29,6 +29,18 @@ import pcbjamDarkTheme from "./themes/pcbjam-dark.json";
/** The default user lib boot ensures exists, so there's a writable save target. */
const DEFAULT_USER_LIB_NAME = "My Symbols";
+/**
+ * True when any per-kind lib list already contains a writable lib — "user" on
+ * the example backend / local sources, "org" on the private platform (a scope's
+ * own writable lib; renamed from "user" in the team-libs rework). Missing "org"
+ * here made boot re-create "My Symbols" on every load, which the backend 409'd.
+ */
+export function hasWritableLib(lists: Iterable): boolean {
+ return [...lists].some((libs) =>
+ libs.some((l) => l.type === "user" || l.type === "org"),
+ );
+}
+
/**
* Boot a KiCad tool directly in the main React document — no iframe.
*
@@ -85,6 +97,12 @@ export interface BootOptions {
* (empty lib-tables are seeded). Its libs become sym-lib-table and/or
* fp-lib-table rows depending on the tool (see `libKinds` in doBoot). */
libsSource?: LibsSource | null;
+ /** Hold provider enumerates for a kind until this resolves — the lib editors
+ * pass their presync-settled promise so the boot-time whole-set hydrate
+ * reads a warm IndexedDB instead of cold-fetching one lib at a time inside
+ * the serialized bridge crossings. See `LibsProviderOptions.enumerateGate`.
+ * Never gates boot's own `listLibs` table seeding (a direct source call). */
+ enumerateGate?: (kind: string) => Promise;
/** 3D model source (lazy, per-board). Null/omitted ⇒ the viewer renders the
* bare board only, exactly as before models existed. */
modelsSource?: Model3dSource | null;
@@ -237,6 +255,7 @@ async function doBoot(opts: BootOptions): Promise {
onWasmInstantiated,
libsSource,
modelsSource,
+ enumerateGate,
} = opts;
// Truthful fetch label: a warm start (download-completion marker present)
// reads from the HTTP cache, so "Downloading" would be a lie — and vice versa.
@@ -298,7 +317,7 @@ async function doBoot(opts: BootOptions): Promise {
}
if (libsSource && libKinds.length) {
- installLibsProvider(libsSource, log);
+ installLibsProvider(libsSource, log, { enumerateGate });
// 3D models ride the same provider (kind "model3d"): the C++ ensure fallback
// and the board prescan both resolve through this source.
if (modelsSource) {
@@ -310,12 +329,9 @@ async function doBoot(opts: BootOptions): Promise {
// kind-agnostic containers and appear in every list).
const listsByKind = new Map<"symbol" | "footprint", LibInfo[]>();
for (const k of libKinds) listsByKind.set(k, await libsSource.listLibs(k));
- // Ensure the owner has at least one writable user lib to save items into.
- // A user lib holds either kind, so the created lib joins every table.
- const hasUserLib = [...listsByKind.values()].some((libs) =>
- libs.some((l) => l.type === "user"),
- );
- if (libsSource.createLib && !hasUserLib) {
+ // Ensure the owner has at least one writable lib to save items into.
+ // A writable lib holds either kind, so the created lib joins every table.
+ if (libsSource.createLib && !hasWritableLib(listsByKind.values())) {
const created = await libsSource.createLib(DEFAULT_USER_LIB_NAME);
if (created) {
for (const libs of listsByKind.values()) libs.push(created);
diff --git a/web/standalone/src/wasm/kicad-runner.test.ts b/web/standalone/src/wasm/kicad-runner.test.ts
index f6df7b5..01a5da7 100644
--- a/web/standalone/src/wasm/kicad-runner.test.ts
+++ b/web/standalone/src/wasm/kicad-runner.test.ts
@@ -94,6 +94,24 @@ describe("MEMFS project staging", () => {
expect(peak).toBeLessThanOrEqual(8);
});
+ it("reports per-file progress from 0 up to the total", async () => {
+ const { win } = fakeWin();
+ const files = ["a.kicad_sym", "b.kicad_sym", "c.kicad_sym"];
+ const seen: Array<[number, number]> = [];
+
+ await driveProjectIntoTool(win, {
+ ...opts(files, async () => new Uint8Array([1])),
+ onFileProgress: (done, total) => seen.push([done, total]),
+ });
+
+ // One up-front (0, total) so the line shows immediately, then one tick per
+ // staged file, ending exactly at the total.
+ expect(seen[0]).toEqual([0, files.length]);
+ expect(seen).toHaveLength(files.length + 1);
+ expect(seen.map(([done]) => done).sort((a, b) => a - b)).toEqual([0, 1, 2, 3]);
+ expect(seen.at(-1)?.[1]).toBe(files.length);
+ });
+
it("rejects when a fetch fails", async () => {
const { win } = fakeWin();
const files = ["ok1.kicad_sym", "bad.kicad_sym", "ok2.kicad_sym"];
diff --git a/web/standalone/src/wasm/kicad-runner.ts b/web/standalone/src/wasm/kicad-runner.ts
index ba811d3..93d8871 100644
--- a/web/standalone/src/wasm/kicad-runner.ts
+++ b/web/standalone/src/wasm/kicad-runner.ts
@@ -22,6 +22,10 @@ export interface DriveOptions {
fetchBytes: (relPath: string) => Promise;
log: (msg: string) => void;
onStatus: (text: string) => void;
+ /** Per-file staging progress (files fetched+written so far, total) — drives
+ * the boot overlay's "Project files — n/m" line. Reported once up front
+ * with done=0 so the line appears as soon as staging starts. */
+ onFileProgress?: (done: number, total: number) => void;
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
@@ -83,11 +87,14 @@ const STAGE_CONCURRENCY = 8;
async function syncProjectToMemfs(win: ToolWindow, opts: DriveOptions): Promise {
getFS(win).mkdirTree(memfsProjectDir(opts.slug));
+ let staged = 0;
+ opts.onFileProgress?.(0, opts.files.length);
const queue = [...opts.files];
const worker = async (): Promise => {
for (let file = queue.shift(); file; file = queue.shift()) {
const bytes = await opts.fetchBytes(file.path);
restageFile(win, opts.slug, file.path, bytes, opts.log);
+ opts.onFileProgress?.(++staged, opts.files.length);
// 3D models: prefetch every model this board references (R2 → IDB → MEMFS)
// so the 3D viewer's first open resolves locally. Fire-and-forget — project
// open never waits on it; a ref that misses falls back to the C++ per-model
diff --git a/web/standalone/src/wasm/libs/source.test.ts b/web/standalone/src/wasm/libs/source.test.ts
index 4e9c580..afe2e1e 100644
--- a/web/standalone/src/wasm/libs/source.test.ts
+++ b/web/standalone/src/wasm/libs/source.test.ts
@@ -152,3 +152,59 @@ describe("installLibsProvider — fat list (arg=bodies)", () => {
expect(await request("index", "/mnt/pcbjam/", "", "footprint")).toBeNull();
});
});
+
+describe("installLibsProvider — enumerate gate (load-fanout)", () => {
+ beforeEach(() => {
+ (globalThis as unknown as { window: unknown }).window = {
+ location: { search: "" },
+ dispatchEvent: () => true,
+ };
+ });
+ afterEach(() => {
+ delete (globalThis as unknown as { window?: unknown }).window;
+ });
+
+ it("parks list (names AND bodies) until the gate resolves; get is never gated", async () => {
+ let release!: () => void;
+ const gate = new Promise((r) => {
+ release = r;
+ });
+ const gateKinds: string[] = [];
+ installLibsProvider(
+ baseSource({ getAllItems: async () => itemsAsBytes() }),
+ () => {},
+ {
+ enumerateGate: (kind) => {
+ gateKinds.push(kind);
+ return gate;
+ },
+ },
+ );
+ const request = (
+ globalThis as unknown as { window: { kicadLibs: { request: Req } } }
+ ).window.kicadLibs.request;
+
+ let fatDone = false;
+ let namesDone = false;
+ const fat = request("list", libUri("Device"), "bodies", "symbol").then((r) => {
+ fatDone = true;
+ return r;
+ });
+ const names = request("list", libUri("Device"), "", "symbol").then((r) => {
+ namesDone = true;
+ return r;
+ });
+ // A user-triggered item open resolves while the gate is still held — only
+ // enumerates are parked.
+ await expect(request("get", libUri("Device"), "R", "symbol")).resolves.toBe(
+ "(kicad_symbol_lib (symbol R))",
+ );
+ expect(fatDone).toBe(false);
+ expect(namesDone).toBe(false);
+
+ release();
+ expect(parseFramed(await fat, "symbols")).toHaveLength(2);
+ expect(JSON.parse((await names) as string)).toEqual({ symbols: ["R", "C"] });
+ expect(gateKinds).toEqual(["symbol", "symbol"]);
+ });
+});
diff --git a/web/standalone/src/wasm/libs/source.ts b/web/standalone/src/wasm/libs/source.ts
index 8f841b6..649d123 100644
--- a/web/standalone/src/wasm/libs/source.ts
+++ b/web/standalone/src/wasm/libs/source.ts
@@ -13,7 +13,10 @@ export interface LibInfo {
/** Display nickname for the sym-lib-table row. */
name: string;
description?: string | null;
- /** 'origin' | 'mirror' | 'user' — drives "ensure a user lib exists" at boot. */
+ /** 'origin' | 'mirror' | 'user' | 'org' — drives "ensure a writable lib
+ * exists" at boot. 'org' is the private platform's name for a scope's own
+ * writable lib (renamed from 'user' in the team-libs rework); the example
+ * backend and local sources still say 'user'. */
type?: string;
/** Item count, when the source knows it cheaply (manifest/backend) — shown as a
* badge in the home-page library list. Omitted ⇒ no badge. */
@@ -310,9 +313,26 @@ export function buildFpLibTable(libsList: LibInfo[]): string {
* "index" -> the publish-time footprint index JSON (source-global; lib arg
* ignored) / null when the source has none — see getFpIndex.
*/
+export interface LibsProviderOptions {
+ /**
+ * Hold every enumerate ("list", including the fat "bodies" variant — a plain
+ * name list also opens the lib's sync stack, i.e. cold-fetches its bundle)
+ * for the given kind until the returned promise resolves. The lib editors
+ * pass their presync-settled promise: their frame eagerly enumerates EVERY
+ * lib of its kind at boot, one mutex-serialized bridge crossing at a time
+ * (see g_pcbjamProxyMutex in the plugins), so a cold lib would network-fetch
+ * inside its serial crossing — the gate lets the 8-wide parallel presync
+ * warm IndexedDB first and the crossings become local read + parse.
+ * MUST always resolve (the presync is best-effort and never rejects);
+ * "get"/"save"/"index" are user-triggered and never gated.
+ */
+ enumerateGate?: (kind: string) => Promise;
+}
+
export function installLibsProvider(
source: LibsSource,
log: (msg: string) => void,
+ opts?: LibsProviderOptions,
): void {
if (window.kicadLibs) return;
const delay = artificialDelayMs();
@@ -353,6 +373,9 @@ export function installLibsProvider(
try {
switch (op) {
case "list": {
+ // Enumerate gate (load-fanout): park this crossing (Asyncify) until
+ // the caller's precondition — typically the presync — has settled.
+ if (opts?.enumerateGate) await opts.enumerateGate(kind);
// Each plugin parses its own key: footprints / symbols.
const key = kind === "footprint" ? "footprints" : "symbols";
// "bodies" (arg) = the fat list: every item's body in one crossing, so