collab: paste-collision sync-delete fix — lenient sibling restage, forced removals for deleted dirty roots, eeschema child-blob lifting

Field bug (2026-08-31): copy-pasting a symbol whose pins kept their source
uuids corrupted the sheet's ydoc on delete (pcbjam-shared: cross-parent
re-key fix, bumped here); the pcbnew tab's sibling mirror then silently
froze on the dangling refs, so "Update PCB from Schematic" with delete
enabled never removed the deleted symbols' footprints — until any later
edit resynced the sheet.

- standalone sibling-restage: render tolerantly past dangling item refs
  (docToFile onMissingItem) and console.warn on drops/failures instead of
  only the gated debug log — a frozen mirror is no longer silent.
- pcbnew_embind flushDiff: dirty roots that are already STRUCT_DELETED emit
  forced removals on both wires instead of being P-5-skipped (observed: 8
  footprints deleted on the board stayed in the board room forever); they
  are erased from the next baseline so a redo re-adds them.
- eeschema_embind blobFor: lift an unlifted child (field/pin/sheet-pin) to
  its screen root before serializing — the selection writer emits nothing
  for such a child standalone, so the entry used to reach JS as an empty
  envelope and be skipped, silently dropping the edit; residual empty blobs
  now warn instead of shipping hollow envelopes. SCH_MARKER_T (ERC
  artifacts, never file content) stays out of the snapshot and seed.

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

@ -1 +1 @@
Subproject commit 8dabf2e8232f259a52eb2e2382c6a5a866bcbb1d
Subproject commit a4984c626d847bd07ce66148d6ec96fde8ee8e42

View file

@ -2,10 +2,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// Exercise ONLY the subscribe/debounce/restage orchestration: the room connect,
// the ydoc materialization, and the MEMFS write are all collaborators.
const { connectKicadDoc, restageFile, ydocHasState } = vi.hoisted(() => ({
const { connectKicadDoc, restageFile, ydocHasState, docToFile } = vi.hoisted(() => ({
connectKicadDoc: vi.fn(),
restageFile: vi.fn(),
ydocHasState: vi.fn(),
docToFile: vi.fn(
(_doc: unknown, _opts?: { onMissingItem?: (u: string) => void }) =>
"(kicad_sch materialized)",
),
}));
vi.mock("./index", () => ({ connectKicadDoc }));
@ -15,7 +19,7 @@ vi.mock("@pcbjam/shared", () => ({
ydocHasState,
ydocIsHollow: () => false,
yToDoc: (doc: unknown) => doc,
docToFile: () => "(kicad_sch materialized)",
docToFile,
}));
import { startSiblingRestage, type SiblingPresence } from "./sibling-restage";
@ -99,6 +103,7 @@ beforeEach(() => {
});
restageFile.mockReset();
ydocHasState.mockReset().mockReturnValue(true);
docToFile.mockReset().mockReturnValue("(kicad_sch materialized)");
});
afterEach(() => {
@ -130,6 +135,34 @@ describe("startSiblingRestage", () => {
expect(restageFile.mock.calls[0]![2]).toBe("main.kicad_sch");
});
it("restages leniently past dangling item refs, and warns (2026-08-31 corruption)", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
docToFile.mockImplementation(
(_doc: unknown, opts?: { onMissingItem?: (u: string) => void }) => {
opts?.onMissingItem?.("ghost-1");
return "(kicad_sch healed)";
},
);
await start(["main.kicad_sch"]);
expect(restageFile).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledWith(expect.stringContaining("dangling item ref"));
warn.mockRestore();
});
it("a restage failure is loud, not just debug-logged", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
docToFile.mockImplementation(() => {
throw new Error("renderItem: cycle through item x");
});
await start(["main.kicad_sch"]);
expect(restageFile).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("restage failed"),
expect.any(Error),
);
warn.mockRestore();
});
it("leaves the boot snapshot alone when the room is empty", async () => {
ydocHasState.mockReturnValue(false);
await start(["main.kicad_sch"]);

View file

@ -112,9 +112,23 @@ export async function startSiblingRestage(opts: {
// 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));
// Render tolerantly: a doc carrying a dangling item ref (pre-fix
// paste-collision corruption, 2026-08-31) must still restage — a strict
// throw here silently froze the MEMFS copy at its last good state, so
// "update PCB from schematic" kept seeing deleted symbols forever.
const missing: string[] = [];
const text = docToFile(yToDoc(doc), { onMissingItem: (u) => missing.push(u) });
if (missing.length) {
console.warn(
`[sibling] ${sheetPath}: dropped ${missing.length} dangling item ref(s): ` +
missing.slice(0, 5).join(", "),
);
}
restageFile(win, slug, sheetPath, new TextEncoder().encode(text), log);
} catch (err) {
// Loud on purpose: a swallowed failure here leaves the sibling mirror
// permanently stale with no visible symptom.
console.warn(`[sibling] restage failed for ${sheetPath}:`, err);
log(`[sibling] restage failed for ${sheetPath}: ${String(err)}`);
}
};