diff --git a/web/standalone/src/components/WasmTool.tsx b/web/standalone/src/components/WasmTool.tsx
index f8646d9..6a68f6f 100644
--- a/web/standalone/src/components/WasmTool.tsx
+++ b/web/standalone/src/components/WasmTool.tsx
@@ -156,12 +156,19 @@ const LIB_KIND_FOR_TOOL: Partial> = {
/** What the download-consent dialog quotes (standalone-load-ux 0001). */
interface ConsentInfo {
- /** Over-the-wire bytes for the editor bundle — null when the CDN carries no
- * size info and HEAD yielded none ("large download" wording then). */
+ /** 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 pre-synced at boot for this tool ("downloaded now"). */
+ /** The lib kind this tool pre-syncs — warmed in the background once the
+ * editor is open, not ahead of it (see startLibPresync). */
libNowKind: "symbol" | "footprint" | null;
libNow: LibsSyncState | null;
/** The other kind a merged-bundle session can pull lazily ("only if used"). */
@@ -204,6 +211,9 @@ async function gatherConsentInfo(
};
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),
@@ -1308,6 +1318,13 @@ export function WasmTool({
};
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();
+
void (async () => {
try {
// Real identity (collab-presence 0009 A): resolve the session user in
@@ -1344,26 +1361,38 @@ export function WasmTool({
setConsent(null);
if (!ok) return; // unmounted while waiting — never start the download
}
- // Pre-warm the lib bundles into IDB in PARALLEL with the wasm download, so
- // the editor's first enumerate reads a warm cache instead of freezing on N
- // cold bundle fetches. Non-blocking + best-effort; the SyncStack dedups, so
- // a lib the wasm reaches mid-presync just awaits the same in-flight fetch.
+ // 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.
const libKind = LIB_KIND_FOR_TOOL[tool];
- if (source?.presync && libKind) {
+ 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
.presync({
kind: libKind,
- onProgress: ({ done, total }) =>
- setLibSync(
- done >= total ? null : { kind: libKind, done, total },
- ),
+ 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);
});
- }
+ };
await bootKicadTool({
tool,
base,
@@ -1450,6 +1479,14 @@ export function WasmTool({
log: append,
onStatus: setStatus,
});
+ // 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):
@@ -1597,6 +1634,10 @@ export function WasmTool({
// 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();
@@ -1718,11 +1759,11 @@ export function WasmTool({
{status || "Loading…"}
- {libSync && (
-
- {libSyncLabel(libSync)}
-
- )}
+ {/* 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`. */}
{warmBoot
? "Loading from your browser's cache — no download needed."
@@ -1941,8 +1982,10 @@ export function WasmTool({
{/* DEV: presence style tuner (VITE_PRESENCE_TUNER=1). */}
{ready && tunerMod && }
- {/* Lib pre-sync still warming IDB after the editor opened (big set) — small
- unobtrusive indicator so the user knows browsing is still filling in. */}
+ {/* 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 && (
{" "}
@@ -2025,14 +2068,83 @@ function approxMB(bytes: number): string {
return `~${mb >= 10 ? Math.round(mb) : Math.max(0.1, mb).toFixed(1)} MB`;
}
-/** One consent row's size figure, from a (possibly partial) LibsSyncState. */
+/** "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) return null;
- if (s.total > 0 && s.warm >= s.total) return "already cached";
+ 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 in the background once the editor is open (it moved off
+ // the critical path, so "downloaded now" would be wrong as well as vague).
+ if (!s || s.total === 0) return `${base} — fetched in the background`;
const cold = s.total - s.warm;
- const count = `${cold} librar${cold === 1 ? "y" : "ies"}`;
- if (!s.sizesKnown) return count;
- return s.warm > 0 ? `${approxMB(s.coldBytes)} (${count})` : approxMB(s.coldBytes);
+ 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}
+
+
+ {approxMB(info.toolRawBytes)} uncompressed
+
+ >
+ );
}
/**
@@ -2053,7 +2165,7 @@ function DownloadConsent({
const row = (
title: string,
detail: string,
- figure: string | null,
+ figure: React.ReactNode,
testid: string,
) => (
{detail}
{figure && (
-
+
{figure}
)}
@@ -2090,20 +2202,20 @@ function DownloadConsent({
{row(
"Editor engine",
"the KiCad build, downloaded now",
- info.toolBytes !== null ? approxMB(info.toolBytes) : "large (hundreds of MB)",
+ toolFigure(info),
"consent-row-tool",
)}
{info.libNowKind &&
row(
kindTitle(info.libNowKind),
- "downloaded now — this editor browses them",
+ libNowDetail(info.libNow),
libStateLabel(info.libNow),
"consent-row-now",
)}
{info.libLaterKind &&
row(
kindTitle(info.libLaterKind),
- "downloaded later, only if you use them",
+ libLaterDetail(info.libLater),
libStateLabel(info.libLater),
"consent-row-later",
)}
diff --git a/web/standalone/src/wasm/libs/source.ts b/web/standalone/src/wasm/libs/source.ts
index ebd2adb..8f841b6 100644
--- a/web/standalone/src/wasm/libs/source.ts
+++ b/web/standalone/src/wasm/libs/source.ts
@@ -79,9 +79,11 @@ export interface LibsSource {
): Promise>;
/**
* Pre-warm this source's per-lib caches (IndexedDB bundles) WITHOUT touching the
- * WASM runtime — call it in parallel with the wasm download so the editor's
- * first enumerate reads a warm cache instead of freezing on N cold bundle
- * fetches. Best-effort: a lib that fails to presync is skipped (it still loads
+ * WASM runtime, so the editor's first enumerate reads a warm cache instead of
+ * freezing on N cold bundle fetches. Call it AFTER the wasm bundle and the
+ * project's own files are in — with a scope of ~155 libs this is hundreds of
+ * fetches, and running it any earlier just starves the downloads the user is
+ * actually blocked on. Best-effort: a lib that fails to presync is skipped (it still loads
* lazily later); the SyncStack dedups, so a lib the wasm reaches mid-presync
* just awaits the same in-flight fetch. `onProgress` reports per-lib so the load
* screen can name what's syncing. Optional: sources without a client-side cache