fix: hollow sheet rooms wiped subsheets on entry (ysync 0011) + hierarchy pane icon sizing/selection colours

Collab: a save-all's layout-only sync into a never-entered sheet room left a
doc with layout but zero items; the first entry adopted it and removed every
item on screen. seed() now file-seeds a hollow doc, syncLayoutFromSave skips
never-seeded empty rooms, and the ydoc-boot / sibling-restage paths fall back
to the file. Tree: new tree-hier standalone app + e2e spec covering the
wxwidgets bundle-size and unfocused-selection fixes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189z2siCsezE39ugcDKNBMs
This commit is contained in:
Gergő Törcsvári 2026-08-27 08:58:40 +02:00
commit 8fa6bae084
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
12 changed files with 266 additions and 7 deletions

@ -1 +1 @@
Subproject commit 4fd6af24a2a8d818970931bbf13d15c9818e6169
Subproject commit 031faaedd37365ab7391707c2e61463b0f842d9d

View file

@ -4,6 +4,7 @@ import {
docToFile,
fileToDoc,
ydocHasState,
ydocIsHollow,
yToDoc,
type KicadDoc,
type Tool,
@ -87,7 +88,7 @@ export async function maybeConnectDocSession(
// 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)) {
if (!ydocHasState(session.doc) || ydocIsHollow(session.doc)) {
opts.log(`[ydoc] room ${room} is empty — falling back to the API fetch (will file-seed)`);
return { session };
}

View file

@ -9,7 +9,9 @@ import {
renderItem,
SEXPR_VERSION_CURRENT,
sexprToItems,
syncLayoutToY,
ydocHasState,
ydocIsHollow,
ydocSexprVersion,
yToDoc,
type KicadItem,
@ -198,6 +200,49 @@ describe("bindKicadCollab — two editors over relayed Y.Docs", () => {
expect(edB.store["fp-1"]).toBeDefined(); // adopted
});
it("a HOLLOW room (layout only from a save-all sync, never seeded) is file-seeded, NOT adopted", () => {
// Save-all fired the layout sync into a room nobody had entered: the doc got
// meta/layout but zero items and no seedNonce. Adopting it removed every
// item on the subsheet the editor had just shown (arduino subsheet blank bug).
const { a, edA, edB, bindA, bindB } = setup();
const file = `(kicad_wks (version 20220228) (generator "pl_editor")
(setup (textsize 1.5 1.5) (linewidth 0.15))
(rect (uuid "r-1") (name "border") (start 0 0 ltcorner) (end 0 0 rbcorner))
)
`;
const seedDoc = fileToDoc(file);
syncLayoutToY(seedDoc, a, "layout-save"); // the hollow footprint
expect(ydocHasState(a)).toBe(true);
expect(ydocIsHollow(a)).toBe(true);
Object.assign(edA.store, seedDoc.items); // editor shows the file
bindA.seed(seedDoc);
expect(edA.applied.length).toBe(0); // nothing removed from the editor
expect(edA.store["r-1"]).toBeDefined();
expect(ydocIsHollow(a)).toBe(false); // healed: items + seed marker in the doc
expect(docToFile(yToDoc(a))).toBe(docToFile(seedDoc));
bindB.seed(); // a peer joining now adopts the real content
expect(edB.store["r-1"]).toBeDefined();
});
it("a seeded room that was legitimately emptied is still adopted (not mistaken for hollow)", () => {
const { a, edA, edB, bindA, bindB } = setup();
const file = `(kicad_wks (version 20220228) (generator "pl_editor")
(rect (uuid "r-1") (name "border") (start 0 0 ltcorner) (end 0 0 rbcorner))
)
`;
const seedDoc = fileToDoc(file);
Object.assign(edA.store, seedDoc.items);
bindA.seed(seedDoc); // file-seeded → seed marker present
edA.localRemove("r-1"); // the peer deletes everything on the sheet
expect(ydocHasState(a)).toBe(true);
expect(ydocIsHollow(a)).toBe(false); // seeded, merely empty
Object.assign(edB.store, seedDoc.items); // B cold-opens the stale file
bindB.seed(seedDoc);
expect(edB.store["r-1"]).toBeUndefined(); // doc authority: the deletion wins
});
it("pre-seed remote state does NOT stream into the editor (adopt covers it)", () => {
const { edA, edB, bindA, bindB } = setup();
seedEditor(edA, FP);

View file

@ -20,6 +20,7 @@ import {
Y_KDOC_REVERT_REASON,
Y_KDOC_SEED_NONCE,
ydocHasState,
ydocIsHollow,
ydocSexprVersion,
yToItemUnchecked,
type ItemsWireDelta,
@ -264,7 +265,14 @@ export function bindKicadCollab(
// `ydocHasState` (meta + layout + items), NOT `items.size`: a populated
// drawing sheet (pl_editor .kicad_wks) has zero uuid items, so an items-only
// check would mis-classify a seeded room as empty and re-seed/clobber it.
if (opts?.editorMatchesDoc && ydocHasState(doc)) {
// A HOLLOW doc (layout/meta, zero items, never seeded — a save-all's
// layout sync into a room nobody had entered) counts as empty here:
// adopting it would remove every item on the sheet the editor just
// showed (the "subsheet renders then goes blank" bug), and a file
// materialized from it is title-block-only.
const hollow = ydocIsHollow(doc);
if (hollow) clog("seed: doc is HOLLOW (layout only, never seeded) → treating as empty");
if (opts?.editorMatchesDoc && ydocHasState(doc) && !hollow) {
// The editor opened exactly this doc's content (Y.Doc-load path): no
// adopt apply needed. snapshotItems() still runs to BASELINE the wasm
// differ — otherwise the first local edit would re-emit the full model.
@ -276,7 +284,7 @@ export function bindKicadCollab(
}
return;
}
if (!ydocHasState(doc) && seedDoc) {
if ((!ydocHasState(doc) || hollow) && seedDoc) {
if (readOnly) {
// A viewer never authors a room. The editor keeps showing the file it
// opened; when a writer later seeds this room, the (now-open) UP
@ -336,7 +344,7 @@ export function bindKicadCollab(
return;
}
const hasState = ydocHasState(doc);
const hasState = ydocHasState(doc) && !hollow;
if (!hasState) {
if (readOnly) {
@ -349,6 +357,12 @@ export function bindKicadCollab(
doc.transact(() => {
applyDeltaToY(doc, local, ORIGIN);
upsertLibSymbolsToY(doc, wireLibSymbols(wire), ORIGIN);
// Stamp the seed marker like the file path does: a seeded-then-emptied
// sheet must stay distinguishable from a hollow one (ydocIsHollow).
doc.getMap(Y_KDOC_META).set(
Y_KDOC_SEED_NONCE,
`${doc.clientID}:${Math.random().toString(36).slice(2)}`,
);
}, ORIGIN);
return;
}

View file

@ -10,8 +10,16 @@ const { connectKicadDoc, bindKicadCollab, moduleItemsBridge } = vi.hoisted(() =>
vi.mock("./index", () => ({ connectKicadDoc }));
vi.mock("./kicad-binding", () => ({ bindKicadCollab, moduleItemsBridge }));
const { ydocHasState, syncLayoutToY, fileToDoc } = vi.hoisted(() => ({
ydocHasState: vi.fn(() => false),
syncLayoutToY: vi.fn(() => true),
fileToDoc: vi.fn((t: string) => ({ text: t })),
}));
vi.mock("@pcbjam/shared", () => ({
collabRoomId: (s: string, p: string, d: string) => `${s}:${p}:${d}`,
ydocHasState,
syncLayoutToY,
fileToDoc,
}));
import { createSheetCollabManager } from "./sheet-manager";
@ -195,6 +203,35 @@ describe("sheet-manager warm pool", () => {
});
});
describe("sheet-manager layout save-sync (hollow-room guard)", () => {
beforeEach(() => {
ydocHasState.mockReset().mockReturnValue(false);
syncLayoutToY.mockReset().mockReturnValue(true);
});
it("skips a warmed-but-never-entered sheet whose doc is empty (save-all before first entry)", async () => {
const m = makeManager();
await m.connectAll(["root.kicad_sch", "Headers.kicad_sch"]);
await m.switchTo("root.kicad_sch");
// KiCad's SaveProject writes EVERY sheet → the hook fires for Headers too.
m.syncLayoutFromSave("Headers.kicad_sch", "(kicad_sch …)");
// Nothing written: a layout-only doc would be adopted as "remove everything"
// on the first entry into Headers.
expect(syncLayoutToY).not.toHaveBeenCalled();
});
it("still syncs the bound (seeded) sheet and any sheet whose doc already has state", async () => {
const m = makeManager();
await m.connectAll(["root.kicad_sch", "Headers.kicad_sch"]);
await m.switchTo("root.kicad_sch");
m.syncLayoutFromSave("root.kicad_sch", "(kicad_sch …)");
expect(syncLayoutToY).toHaveBeenCalledTimes(1);
ydocHasState.mockReturnValue(true); // Headers was seeded by a peer
m.syncLayoutFromSave("Headers.kicad_sch", "(kicad_sch …)");
expect(syncLayoutToY).toHaveBeenCalledTimes(2);
});
});
describe("sheet-manager lifecycle hardening (findings C-1/C-4/C-5)", () => {
it("a connect resolving after destroy() is torn down, not registered (C-1)", async () => {
let release!: () => void;

View file

@ -3,6 +3,7 @@ import {
collabRoomId,
fileToDoc,
syncLayoutToY,
ydocHasState,
type KicadDoc,
type PresenceUser,
} from "@pcbjam/shared";
@ -372,6 +373,15 @@ export function createSheetCollabManager(opts: SheetManagerOptions): SheetCollab
const room = rooms.get(sheetPath);
if (!room) return; // not a collab sheet (or still onboarding) — nothing to sync
const write = (): void => {
// A room nobody has entered yet (never bound this session) and whose
// doc is still empty has nothing to reconcile: a layout-only write
// would leave a HOLLOW doc (layout, zero items) that every later first
// entry would adopt by removing the whole sheet. The first bind
// file-seeds it — layout included — so nothing is lost by skipping.
if (!room.seeded && !ydocHasState(room.doc)) {
clog(`[sheet] layout save-sync: ${sheetPath} skipped (room never seeded)`);
return;
}
try {
// Writing to a PARKED room's doc marks it dirty via startWatch — fine:
// the diff-on-rebind adopt makes the catch-up cost the real delta only.

View file

@ -13,6 +13,7 @@ vi.mock("../kicad-runner", () => ({ restageFile }));
vi.mock("@pcbjam/shared", () => ({
collabRoomId: (s: string, p: string, d: string) => `${s}:${p}:${d}`,
ydocHasState,
ydocIsHollow: () => false,
yToDoc: (doc: unknown) => doc,
docToFile: () => "(kicad_sch materialized)",
}));

View file

@ -1,4 +1,4 @@
import { collabRoomId, docToFile, ydocHasState, yToDoc } from "@pcbjam/shared";
import { collabRoomId, docToFile, ydocHasState, ydocIsHollow, yToDoc } from "@pcbjam/shared";
import type * as Y from "yjs";
import { restageFile } from "../kicad-runner";
import { connectKicadDoc, type KicadDocSession } from "./index";
@ -102,6 +102,9 @@ export async function startSiblingRestage(opts: {
// An empty room means no one ever seeded this sheet — the boot-staged
// API snapshot is the freshest copy there is; leave it alone.
if (!ydocHasState(doc)) return;
// A hollow doc (layout only, never seeded) would restage a title-block-only
// file over the real one — the staged copy is the freshest there is.
if (ydocIsHollow(doc)) return;
const text = docToFile(yToDoc(doc));
restageFile(win, slug, sheetPath, new TextEncoder().encode(text), log);
} catch (err) {