From c981e3dc75382da8adebf58e49c6e09b167aec1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Thu, 27 Aug 2026 10:17:23 +0200 Subject: [PATCH] =?UTF-8?q?collab:=20repair=20duplicated=20layout=20header?= =?UTF-8?q?=20groups=20after=20seed=20and=20on=20remote=20layout=20merges?= =?UTF-8?q?=20(ysync=200011=20follow-up;=20shared=20=E2=86=92=20f14401b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0189z2siCsezE39ugcDKNBMs --- web/pcbjam-shared | 2 +- .../src/wasm/collab/kicad-binding.test.ts | 27 ++++++++++++++++ .../src/wasm/collab/kicad-binding.ts | 31 +++++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/web/pcbjam-shared b/web/pcbjam-shared index 031faae..f14401b 160000 --- a/web/pcbjam-shared +++ b/web/pcbjam-shared @@ -1 +1 @@ -Subproject commit 031faaedd37365ab7391707c2e61463b0f842d9d +Subproject commit f14401b95d66c4997f66b6b0f7755745f4c59294 diff --git a/web/standalone/src/wasm/collab/kicad-binding.test.ts b/web/standalone/src/wasm/collab/kicad-binding.test.ts index 4698f20..0652705 100644 --- a/web/standalone/src/wasm/collab/kicad-binding.test.ts +++ b/web/standalone/src/wasm/collab/kicad-binding.test.ts @@ -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") diff --git a/web/standalone/src/wasm/collab/kicad-binding.ts b/web/standalone/src/wasm/collab/kicad-binding.ts index bb3bc8e..25be9b3 100644 --- a/web/standalone/src/wasm/collab/kicad-binding.ts +++ b/web/standalone/src/wasm/collab/kicad-binding.ts @@ -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);