collab: repair duplicated layout header groups after seed and on remote layout merges (ysync 0011 follow-up; shared → f14401b)

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 10:17:23 +02:00
commit c981e3dc75
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
3 changed files with 59 additions and 1 deletions

View file

@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import * as Y from "yjs";
import {
docToFile,
duplicateSingletonHeadIndices,
fileToDoc,
itemsWireToDelta,
kicadLibSymbolsMap,
@ -226,6 +227,32 @@ describe("bindKicadCollab — two editors over relayed Y.Docs", () => {
expect(edB.store["r-1"]).toBeDefined();
});
it("a layout-only write racing a file seed converges to ONE header block (no double version)", () => {
// Session 1 (old client) save-all wrote a layout-only doc; session 2 file-seeded
// BEFORE that state arrived. Merged, the layout carried the header twice and
// KiCad rejected the materialized sheet ("Expecting … Got version").
const { a, b, 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);
// Hold the relay: build the two histories independently, then merge.
const hollow = new Y.Doc();
syncLayoutToY(seedDoc, hollow, "layout-save");
Object.assign(edA.store, seedDoc.items);
bindA.seed(seedDoc); // A file-seeds an (apparently) empty room
Y.applyUpdate(a, Y.encodeStateAsUpdate(hollow), "relay"); // the stale layout lands
const text = docToFile(yToDoc(a));
expect((text.match(/\(version /g) ?? []).length).toBe(1);
expect(text).toBe(docToFile(seedDoc));
// The doc itself was repaired (not just the render): B sees a single header.
expect(duplicateSingletonHeadIndices(yToDoc(b).layout)).toEqual([]);
bindB.seed();
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")

View file

@ -9,11 +9,13 @@ import {
kicadItemsMap,
kicadLibSymbolsMap,
parseItemsWireDelta,
repairLayoutY,
seedDocToY,
SEXPR_VERSION_SUPPORTED,
upsertLibSymbolsToY,
wireItemUuids,
wireLibSymbols,
Y_KDOC_LAYOUT,
Y_KDOC_META,
Y_KDOC_REVERT_AT,
Y_KDOC_REVERT_NONCE,
@ -235,6 +237,26 @@ export function bindKicadCollab(
};
items.observeDeep(observer);
// Layout convergence (ysync 0011 follow-up): a remote merge that lands a
// second copy of the header block (a layout-only save-sync racing a file
// seed) would materialize as a file KiCad refuses to load. Repair on every
// remote layout change and after seed(); peers delete the same entries, so
// the doc converges to one header. A viewer never writes.
const layout = doc.getArray(Y_KDOC_LAYOUT);
const repairLayout = (why: string): void => {
if (readOnly || destroyed) return;
try {
if (repairLayoutY(doc, ORIGIN)) clog(`layout repaired (duplicate header groups) — ${why}`);
} catch (err) {
cwarn("layout repair failed", err);
}
};
const onLayout = (_ev: unknown, txn: Y.Transaction) => {
if (txn.origin === ORIGIN) return;
repairLayout("remote layout change");
};
layout.observe(onLayout);
// Validity-revert notice (kicad-validity 0001 B3): the backend stamps
// kdoc_meta.revertNonce when it rolls the doc back to the last valid state
// (the content itself arrives through the normal item sync above). Watched
@ -261,6 +283,14 @@ export function bindKicadCollab(
revMeta.observe(onRevertMeta);
function seed(seedDoc?: KicadDoc, opts?: { editorMatchesDoc?: boolean }): void {
try {
seedInner(seedDoc, opts);
} finally {
repairLayout("post-seed");
}
}
function seedInner(seedDoc?: KicadDoc, opts?: { editorMatchesDoc?: boolean }): void {
seeded = true; // open the UP gate; everything below runs synchronously
// `ydocHasState` (meta + layout + items), NOT `items.size`: a populated
// drawing sheet (pl_editor .kicad_wks) has zero uuid items, so an items-only
@ -422,6 +452,7 @@ export function bindKicadCollab(
seed,
destroy: () => {
destroyed = true; // gates the DOWN hook — see bug 07 note above
layout.unobserve(onLayout);
detachSeedArbitration?.();
detachSeedArbitration = undefined;
items.unobserveDeep(observer);