fix(eeschema): warm collab rooms for the OPENED hierarchy only, not every project schematic

connectAll warmed a room for every .kicad_sch in the project — a
repo-as-project upload (N boards × sheets) opened dozens of sockets for
schematics the wasm never loads. Only the opened root plus its transitive
(property "Sheetfile" …) closure is in memory, so only those need rooms:
no in-memory copy, no divergence risk, no clobber, and C++ sheet navigation
can only reach hierarchy members anyway (same reasoning as pcbnew's
directory-scoped sibling restage).

resolveSheetHierarchy: regex closure over staged MEMFS text, refs resolved
against the referencing sheet's directory (../ and ${KIPRJMOD}/ handled),
non-project refs ignored, unreadable sheets kept warmed but unexpanded, and
an unscopable root (fileless boot) falls back to all project sheets —
over-warming costs sockets, under-warming would cost collab. In-editor
"Add Sheet" children keep their created-hook warm-up.

Verified on the Arduino repo-as-project (27 schematics): 26 board-room
sockets → 5 (root + 3 hierarchy children + presence).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLSht9cadprtT2mhynawWu
This commit is contained in:
Gergő Törcsvári 2026-08-18 11:22:28 +02:00
commit f9deee60a7
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
3 changed files with 189 additions and 1 deletions

View file

@ -66,6 +66,7 @@ import {
usedLibNicknames,
type ToolFile,
} from "@/wasm/kicad-runner";
import { resolveSheetHierarchy } from "@/wasm/collab/sheet-hierarchy";
import { dump as dumpTrace, mark } from "@/wasm/load-trace";
import { errorMessage, isTerminalError } from "@/wasm/terminal-error";
import { registerSaveHook, type SaveBytes } from "@/wasm/save-flow";
@ -791,9 +792,27 @@ async function startSheetCollab(
: undefined,
});
const sheetPaths = opts.files
// Warm ONLY the opened hierarchy (root + transitive Sheetfile references),
// not every schematic in the project: a repo-as-project upload can hold
// dozens of unrelated boards' schematics that the wasm never loads — no
// in-memory copy, no divergence risk, no room needed (sheet-hierarchy.ts).
// A root we can't scope (fileless boot, unreadable staging) falls back to
// all project sheets — over-warming costs sockets, under-warming would cost
// collab. In-editor "Add Sheet" children are warmed by the created hook.
const allSheets = opts.files
.filter((f) => f.path.endsWith(".kicad_sch"))
.map((f) => f.path);
const sheetPaths =
opts.targetPath?.endsWith(".kicad_sch") && allSheets.includes(opts.targetPath)
? resolveSheetHierarchy(
opts.targetPath,
(p) => {
const bytes = readStagedFile(win, opts.slug, p);
return bytes ? new TextDecoder().decode(bytes) : null;
},
allSheets,
)
: allSheets;
// C++ navigation → rebind the active room to the now-shown sheet.
registerSheetChangedHook(win as unknown as SheetChangedWindow, (abs) => {

View file

@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import { resolveSheetHierarchy } from "./sheet-hierarchy";
const sheetRef = (file: string) =>
`(sheet (at 10 10) (property "Sheetfile" "${file}") (property "Sheetname" "x"))`;
function reader(files: Record<string, string>) {
return (p: string): string | null => files[p] ?? null;
}
describe("resolveSheetHierarchy", () => {
it("returns the root plus its transitive Sheetfile closure only", () => {
// A repo-as-project: two boards' schematics side by side. Opening
// Leonardo must warm ITS hierarchy, never the Mega's.
const files = {
"boards/leonardo/root.kicad_sch":
sheetRef("cpu.kicad_sch") + sheetRef("power.kicad_sch"),
"boards/leonardo/cpu.kicad_sch": "(kicad_sch)",
"boards/leonardo/power.kicad_sch": sheetRef("regulator.kicad_sch"),
"boards/leonardo/regulator.kicad_sch": "(kicad_sch)",
"boards/mega/root.kicad_sch": sheetRef("cpu.kicad_sch"),
"boards/mega/cpu.kicad_sch": "(kicad_sch)",
};
const all = Object.keys(files);
expect(
resolveSheetHierarchy("boards/leonardo/root.kicad_sch", reader(files), all),
).toEqual([
"boards/leonardo/root.kicad_sch",
"boards/leonardo/cpu.kicad_sch",
"boards/leonardo/power.kicad_sch",
"boards/leonardo/regulator.kicad_sch",
]);
});
it("resolves ../ and ${KIPRJMOD}/ references and ignores non-project ones", () => {
const files = {
"a/root.kicad_sch":
sheetRef("../shared/common.kicad_sch") +
sheetRef("${KIPRJMOD}/b/top.kicad_sch") +
sheetRef("missing.kicad_sch"),
"shared/common.kicad_sch": "(kicad_sch)",
"b/top.kicad_sch": "(kicad_sch)",
};
const all = Object.keys(files);
expect(resolveSheetHierarchy("a/root.kicad_sch", reader(files), all)).toEqual([
"a/root.kicad_sch",
"shared/common.kicad_sch",
"b/top.kicad_sch",
]);
});
it("survives reference cycles and duplicate references", () => {
const files = {
"root.kicad_sch": sheetRef("child.kicad_sch") + sheetRef("child.kicad_sch"),
"child.kicad_sch": sheetRef("root.kicad_sch"),
};
const all = Object.keys(files);
expect(resolveSheetHierarchy("root.kicad_sch", reader(files), all)).toEqual([
"root.kicad_sch",
"child.kicad_sch",
]);
});
it("keeps an unreadable sheet warmed without expanding it", () => {
// Staging failed for the child: still give it a room (erring toward
// inclusion), but its own references are unknowable.
const files = {
"root.kicad_sch": sheetRef("child.kicad_sch"),
};
const all = ["root.kicad_sch", "child.kicad_sch", "orphan.kicad_sch"];
expect(resolveSheetHierarchy("root.kicad_sch", reader(files), all)).toEqual([
"root.kicad_sch",
"child.kicad_sch",
]);
});
it("handles escaped quotes in the Sheetfile value", () => {
const files = {
'root.kicad_sch': '(property "Sheetfile" "my \\"quoted\\" sheet.kicad_sch")',
'my "quoted" sheet.kicad_sch': "(kicad_sch)",
};
const all = Object.keys(files);
expect(resolveSheetHierarchy("root.kicad_sch", reader(files), all)).toEqual([
"root.kicad_sch",
'my "quoted" sheet.kicad_sch',
]);
});
});

View file

@ -0,0 +1,81 @@
/**
* Which schematic files does the OPENED root actually load? KiCad's eeschema
* loads the root plus the transitive closure of its `(property "Sheetfile"
* "child.kicad_sch")` references — and nothing else. Only those in-memory
* sheets need collab rooms: a schematic outside the hierarchy is never loaded,
* so this session can neither diverge from nor clobber it, and C++ sheet
* navigation can only reach hierarchy members anyway.
*
* This matters for repo-as-project uploads: a repository with N boards holds
* N×sheets `.kicad_sch` files, and warming a room for every one of them (the
* pre-scoping behavior) opened dozens of sockets per session for schematics
* the wasm never even parsed. Same reasoning as pcbnew's directory-scoped
* sibling restage.
*
* Heuristic parser (regex over s-expr text) with a SAFE fallback: any file
* that cannot be read stays in the set unexpanded, and the caller falls back
* to all project sheets when the closure cannot be computed at all the cost
* of over-warming is sockets, the cost of under-warming would be missed
* collab, so unknowns err toward inclusion.
*/
/** `(property "Sheetfile" "…")` — value may contain escaped quotes. */
const SHEETFILE_RE = /\(property\s+"Sheetfile"\s+"((?:[^"\\]|\\.)*)"/g;
function unescapeSexpr(value: string): string {
return value.replace(/\\(.)/g, "$1");
}
/**
* Resolve a Sheetfile reference against the REFERENCING sheet's directory.
* KiCad writes plain relative paths (`Power.kicad_sch`, `../shared/x.kicad_sch`);
* a `${KIPRJMOD}/` prefix (project root) appears in some hand-edited files.
*/
function resolveRef(parentPath: string, ref: string): string {
let r = unescapeSexpr(ref).trim().replace(/\\/g, "/");
const projRelative = r.startsWith("${KIPRJMOD}/");
if (projRelative) r = r.slice("${KIPRJMOD}/".length);
const baseDir = projRelative
? []
: parentPath.split("/").slice(0, -1);
const out = [...baseDir];
for (const seg of r.split("/")) {
if (seg === "" || seg === ".") continue;
if (seg === "..") {
out.pop();
continue;
}
out.push(seg);
}
return out.join("/");
}
/**
* The opened hierarchy: `rootPath` plus every transitively referenced sheet
* that exists in the project. Returns them in discovery order (root first).
*/
export function resolveSheetHierarchy(
rootPath: string,
readText: (relPath: string) => string | null,
allSheets: readonly string[],
): string[] {
const known = new Set(allSheets);
const visited = new Set<string>([rootPath]);
const order: string[] = [rootPath];
const queue: string[] = [rootPath];
while (queue.length) {
const parent = queue.shift()!;
const text = readText(parent);
if (text === null) continue; // unreadable: keep it warmed, don't expand
for (const match of text.matchAll(SHEETFILE_RE)) {
const child = resolveRef(parent, match[1]!);
// Only project members get rooms — a reference outside the file list
// has nothing to collaborate on (missing file, external path).
if (!known.has(child) || visited.has(child)) continue;
visited.add(child);
order.push(child);
queue.push(child);
}
}
return order;
}