feat: ysync 0008 Stage D — wire the v2 items collab into the app + verify

- WasmTool: hard cutover to startKicadCollab (gates on
  kicadCollabSnapshotItems; scalar reconciler no longer runs in the app)
- file-seed (ysync 0005 tie-in): empty rooms are seeded from the opened
  file via fileToDoc/docToY (meta+layout+items — file recoverable from the
  Y.Doc alone); populated rooms still adopt (seed-once unchanged)
- binding: gate UP applies until seed() — the provider's initial state
  sync otherwise streams the full doc into an editor that already holds
  the file (trapped eeschema's paste path in the real app)
- pcbnew blobForItem: footprints Format a uuid-corrected copy directly —
  SaveSelection's copy regenerated mandatory-field uuids (FOOTPRINT copy
  ctor assigns into fresh fields), breaking wire identity (rebuild)
- roundtrip.spec on the v2 items wire; new passing pcbnew
  footprint-containment round trip (the 0004 containment win); full
  fixture stays fixme on the tracked envelope-parse limit
- real-app two-tab emit verified for eeschema + pcbnew (see 0008 doc)

Full kicad suite: 45 passed, 3 skipped (known), 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergő Törcsvári 2026-06-11 15:25:30 +02:00
commit 07863da416
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
7 changed files with 273 additions and 46 deletions

View file

@ -3,13 +3,14 @@ import { test, expect } from "./fixtures";
import { sexprDiff } from "../../web/standalone/src/wasm/collab/sexpr-diff";
/**
* Round-trip integration tests (feature 0004): for each collab-capable, file-
* bearing tool prove the Y.Doc representation is lossless —
* Round-trip integration tests (feature 0004; v2 items wire since ysync 0008
* Stage D): for each collab-capable, file-bearing tool prove the Y.Doc
* representation is lossless —
*
* load a fixture file → save it (ORIG, normalized through the serializer)
* → kicadCollabSnapshot (the yjs item representation)
* → kicadCollabSnapshotItems (per-item s-expr blobs, the Slot-model wire)
* → RELOAD the page (fresh process-global wasm; boot.ts) → open an EMPTY doc
* → kicadCollabApply(snapshot) (rebuild the model purely from the yjs items)
* → kicadCollabApplyItems(snapshot) (rebuild the model purely from the blobs)
* → save (REGEN)
* → assert sexprDiff(ORIG, REGEN).equal
*
@ -40,8 +41,8 @@ interface ToolCfg {
type Mod = {
kicadOpenFile(p: string): unknown;
kicadCollabSnapshot(): string;
kicadCollabApply(j: string): unknown;
kicadCollabSnapshotItems(): string;
kicadCollabApplyItems(j: string): unknown;
} & Record<string, (p: string) => unknown>;
type FS = {
mkdirTree(p: string): void;
@ -67,8 +68,8 @@ async function bootOpen(page: Page, cfg: ToolCfg, content: string, name: string)
const m = (window as unknown as { Module?: Mod }).Module;
return (
typeof m?.kicadOpenFile === "function" &&
typeof m?.kicadCollabSnapshot === "function" &&
typeof m?.kicadCollabApply === "function" &&
typeof m?.kicadCollabSnapshotItems === "function" &&
typeof m?.kicadCollabApplyItems === "function" &&
typeof m?.[saveFn] === "function"
);
},
@ -131,16 +132,18 @@ async function roundTrip(
const extract = await context.newPage();
await bootOpen(extract, cfg, cfg.fixture, "rt");
const orig = await saveRead(extract, cfg, "orig_dump");
const snap = await extract.evaluate(() => window.Module.kicadCollabSnapshot());
const snap = await extract.evaluate(() => window.Module.kicadCollabSnapshotItems());
await extract.close();
const ids: string[] = JSON.parse(snap).added.map((i: { id: string }) => i.id);
// v2 wire: { added: [{ sexpr, parent }] } — uuids live inside the blobs.
const blobs: string[] = JSON.parse(snap).added.map((w: { sexpr: string }) => w.sexpr);
const ids = [...new Set(blobs.flatMap((s) => [...s.matchAll(/\(uuid "([^"]+)"\)/g)].map((m) => m[1]!)))];
expect(ids.length, "fixture must contain uuid items").toBeGreaterThan(0);
// 4–5: fresh page (fresh wasm), open an empty doc, rebuild the model from the snapshot.
const rebuild = await context.newPage();
await bootOpen(rebuild, cfg, cfg.empty, "rt");
await rebuild.evaluate((s) => window.Module.kicadCollabApply(s), snap);
await rebuild.evaluate((s) => window.Module.kicadCollabApplyItems(s), snap);
// apply() runs async for eeschema/pcbnew (CallAfter + coroutine). Best-effort wait
// for the first applied item to materialize — but DON'T fail here: proceed to save
@ -268,6 +271,46 @@ const PCB: ToolCfg = {
ignoreTokens: ["generator_version"],
};
// Footprint-only board: the v2 items wire's PROVEN pcbnew apply path (bare
// footprint blob parse + children — the 0004 containment win). Kept separate
// from PCB so the fragile-envelope types (via/text/segment, see the fixme
// below) don't mask the passing footprint coverage.
const PCB_FP: ToolCfg = {
...PCB,
fixture: `(kicad_pcb
(version 20241229)
(generator "pcbnew")
(generator_version "9.0")
(general (thickness 1.6))
(paper "A4")
(layers
(0 "F.Cu" signal)
(2 "B.Cu" signal)
(37 "F.SilkS" user)
(25 "Edge.Cuts" user)
)
(setup)
(net 0 "")
(footprint "TestLib:R"
(layer "F.Cu")
(uuid "66666666-0000-0000-0000-000000000001")
(at 100 100)
(attr smd)
(property "Reference" "R1" (at 0 -4.2 0) (layer "F.SilkS") (uuid "66666666-0000-0000-0000-0000000000aa") (effects (font (size 1 1) (thickness 0.15))))
(property "Value" "R" (at 0 4.6 0) (layer "F.Fab") (uuid "66666666-0000-0000-0000-0000000000bb") (effects (font (size 1 1) (thickness 0.15))))
(fp_text user "HELLO" (at 0 0 0) (layer "F.SilkS") (uuid "66666666-0000-0000-0000-0000000000cc") (effects (font (size 1 1) (thickness 0.15))))
)
(footprint "TestLib:C"
(layer "F.Cu")
(uuid "99999999-0000-0000-0000-000000000009")
(at 50 50)
(attr smd)
(property "Reference" "C1" (at 0 -2 0) (layer "F.SilkS") (uuid "99999999-0000-0000-0000-0000000000aa") (effects (font (size 1 1) (thickness 0.15))))
)
)
`,
};
test.beforeAll(() => {
// No reconciler bundle needed — the round trip drives only the C++ Module.*
// exports (open / snapshot / apply / save).
@ -298,15 +341,29 @@ test.describe("round trip: file → yjs → file", () => {
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
});
// KNOWN BRIDGE GAPS (0004 phase-4 findings — tracked, not test bugs). The harness
// boots, round-trips, and the comparator reports precisely what pcbnew's
// kicadCollabApply does NOT yet reconstruct from a snapshot:
// - footprints + their fields are not recreated (FP removed entirely);
// - a footprint-child fp_text comes back as a board gr_text at absolute position;
// - segment `width` and via `size` are lost (default 0);
// - zones are not reconstructed.
// Un-fixme as each apply converter lands. Run with --grep-invert skipped to see the
// live diff.
// THE 0004 CONTAINMENT WIN (ysync 0008 Stage C/D): footprints round-trip via the
// items wire — the bare-footprint blob parse recreates the footprint WITH its
// children (properties, fp_text) in place, which the scalar wire never could.
test("pcbnew preserves footprint containment through a yjs round trip", async ({
context,
testLogger,
}) => {
const { orig, regen } = await roundTrip(context, PCB_FP);
const diff = sexprDiff(orig, regen, { ignoreTokens: PCB_FP.ignoreTokens });
expect(
diff.equal,
`round trip lost data:\n${JSON.stringify(diff, null, 2)}`,
).toBe(true);
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
});
// REMAINING KNOWN GAP (ysync 0008 status, known limit 1 — tracked, not a test
// bug): pcbnew track/via/zone/text APPLY rides the `(kicad_pcb …)` envelope
// parse, the codebase's documented asyncify-fragile path (even a verbatim
// SaveSelection envelope for a segment dies silently in the commit). The full
// fixture (via + gr_text + segments) therefore still loses those items on the
// rebuild side. Un-fixme when the envelope parse is solved. Run with
// --grep-invert skipped to see the live diff.
test.fixme(
"pcbnew preserves items through a yjs round trip",
async ({ context, testLogger }) => {

View file

@ -26,6 +26,8 @@
#include <eda_text.h>
#include <pcb_edit_frame.h>
#include <kicad_clipboard.h>
#include <io/kicad/kicad_io_utils.h>
#include <richio.h>
#include <tools/pcb_selection.h>
#include <geometry/shape_poly_set.h>
#include <geometry/shape_line_chain.h>
@ -253,8 +255,52 @@ json itemToJson( BOARD_ITEM* aItem )
// Serialize one live board item to a clipboard blob (used only for `added` payloads — NOT the
// diff unit, so `changed`/`removed` stay light and the blob never drives change detection).
//
// Footprints DON'T go through SaveSelection: its "make safe to transfer" step copies the
// footprint, and FOOTPRINT's copy ctor ASSIGNS the mandatory fields into the new footprint's
// freshly-constructed ones (`*existingField = *field`; EDA_ITEM::operator= keeps the target's
// uuid) — so Reference/Value/Datasheet/Description would carry NEW uuids in every blob,
// breaking the wire's identity-by-uuid (every emit would read as field remove+add, and round
// trips lose the field uuids). Instead we make the same safety copy ourselves, RESTORE the
// mandatory-field uuids from the source, and Format it directly — the same Format machinery
// SaveSelection uses internally, so asyncify behavior is identical.
std::string blobForItem( BOARD* aBoard, BOARD_ITEM* aItem )
{
if( aItem->Type() == PCB_FOOTPRINT_T )
{
const FOOTPRINT* src = static_cast<const FOOTPRINT*>( aItem );
FOOTPRINT copy( *src );
for( PCB_FIELD* field : copy.GetFields() )
{
if( field->IsMandatory() )
{
if( const PCB_FIELD* srcField = src->GetField( field->GetId() ) )
const_cast<KIID&>( field->m_Uuid ) = srcField->m_Uuid;
}
}
// The rest of SaveSelection's footprint safety steps, minus the refPoint move
// (the wire carries absolute positions).
for( PAD* pad : copy.Pads() )
pad->SetNetCode( 0 );
copy.SetLocked( false );
CLIPBOARD_IO io;
STRING_FORMATTER fmt;
io.SetBoard( aBoard );
io.SetOutputFormatter( &fmt );
io.Format( &copy );
copy.SetParent( nullptr );
copy.SetParentGroup( nullptr );
std::string out = fmt.GetString();
KICAD_FORMAT::Prettify( out, KICAD_FORMAT::FORMAT_MODE::COMPACT_TEXT_PROPERTIES );
return out;
}
PCB_SELECTION sel;
sel.Add( aItem ); // pointer-only insert; no mutation of the live item

View file

@ -3,20 +3,22 @@ import {
collabRoomId,
EXTENSION_TOOL,
FILELESS_TOOLS,
fileToDoc,
toolSchema,
type KicadDoc,
type Tool,
} from "@pcbjam/shared";
import { ChevronDown, ChevronUp } from "lucide-react";
import { WASM_ASSET_BASE_URL, yjsProviderConfig } from "@/lib/config";
import { bootKicadTool } from "@/wasm/boot";
import { memfsProjectDir } from "@/wasm/constants";
import { memfsFilePath, memfsProjectDir } from "@/wasm/constants";
import { driveProjectIntoTool, type ToolFile } from "@/wasm/kicad-runner";
import type { CollabWindow } from "@/wasm/collab";
import type { KicadItemsWindow } from "@/wasm/collab";
import { clog, cwarn } from "@/wasm/collab/debug";
import { createOomWatch, respawnInNewTab } from "@/recovery/oom-watch";
import { MemoryExhaustedDialog } from "@/recovery/MemoryExhaustedDialog";
// Tools with a working collab bridge (kicadCollabSnapshot/Apply embind exports).
// Tools with the v2 items bridge (kicadCollabSnapshotItems/ApplyItems embind exports).
const COLLAB_TOOLS = new Set<Tool>(["pl_editor", "eeschema", "pcbnew"]);
const LEGACY_EXTENSION_TOOL: Record<string, Tool> = {
".sch": "eeschema",
@ -170,10 +172,35 @@ function installToolNavigationHook(
}
/**
* Collaborative editing (features/yjs-bridge), ON BY DEFAULT for any tool that has the
* collab bridge. Open the same project URL in two tabs to edit together: the channel is
* keyed to project+file, so both tabs share one Y.Doc over BroadcastChannel. Editor edits
* (add/move items) fire the tool's change hook → the bridge → the peer tab.
* Read the opened file back from MEMFS (what the editor actually loaded) and
* parse it into the full `KicadDoc` (ysync 0007 `fileToDoc`). Used to seed the
* Y.Doc LOSSLESSLY when this client opens an empty room (ysync 0005): the doc
* then carries meta + layout + items, so the file is recoverable from the Y.Doc
* alone. Falls back to undefined (→ editor-snapshot seed, items only) when the
* file is absent or doesn't parse as a KiCad s-expr document.
*/
function seedDocFromMemfs(
win: ToolWindow,
slug: string,
targetPath?: string,
): KicadDoc | undefined {
if (!targetPath) return undefined;
try {
const text = win.FS?.readFile(memfsFilePath(slug, targetPath), { encoding: "utf8" });
if (typeof text !== "string") return undefined;
return fileToDoc(text);
} catch (err) {
cwarn("seed: fileToDoc failed — falling back to editor-snapshot seed", err);
return undefined;
}
}
/**
* Collaborative editing (ysync 0008, Slot-model items wire), ON BY DEFAULT for any
* tool that has the collab bridge. Open the same project URL in two tabs to edit
* together: the channel is keyed to project+file, so both tabs share one Y.Doc over
* BroadcastChannel. Editor edits (add/move items) fire the tool's change hook → the
* bridge → the peer tab.
*
* Opt OUT with `?collab=0` (or `collab=false`). Tools without a bridge are skipped anyway.
*/
@ -181,6 +208,7 @@ async function maybeStartCollab(
win: ToolWindow,
opts: {
tool: Tool;
slug: string;
projectId: string;
targetPath?: string;
log: (m: string) => void;
@ -193,8 +221,8 @@ async function maybeStartCollab(
collabParam,
tool: opts.tool,
hasModule: !!mod,
hasSnapshot: typeof mod?.kicadCollabSnapshot,
hasApply: typeof mod?.kicadCollabApply,
hasSnapshotItems: typeof mod?.kicadCollabSnapshotItems,
hasApplyItems: typeof mod?.kicadCollabApplyItems,
url: win.location.href,
});
@ -207,23 +235,28 @@ async function maybeStartCollab(
clog(`tool ${opts.tool} has no collab bridge — skipping`);
return;
}
if (typeof mod?.kicadCollabSnapshot !== "function") {
if (typeof mod?.kicadCollabSnapshotItems !== "function") {
cwarn(
"BRIDGE NOT PRESENT: Module.kicadCollabSnapshot is",
typeof mod?.kicadCollabSnapshot,
`— the loaded ${opts.tool}.wasm predates the collab bridge. Rebuild + \`npm run setup:kicad\` and restart the dev server.`,
"BRIDGE NOT PRESENT: Module.kicadCollabSnapshotItems is",
typeof mod?.kicadCollabSnapshotItems,
`— the loaded ${opts.tool}.wasm predates the v2 items bridge (ysync 0008 Stage C). Rebuild + \`npm run setup:kicad\` and restart the dev server.`,
);
return;
}
const { startCollab } = await import("@/wasm/collab");
const { startKicadCollab } = await import("@/wasm/collab");
const provider = yjsProviderConfig();
// One room per (project, document). Two tabs of the same build compute the
// same id, so cross-tab BroadcastChannel still works; network providers use it
// verbatim to namespace + persist (see @pcbjam/shared collabRoomId).
const room = collabRoomId(opts.projectId, opts.targetPath ?? opts.tool);
clog("starting collab", provider.kind, "room", room);
await startCollab(mod, win as unknown as CollabWindow, { provider, room });
const seedDoc = seedDocFromMemfs(win, opts.slug, opts.targetPath);
clog("starting collab", provider.kind, "room", room, "seedDoc:", !!seedDoc);
await startKicadCollab(mod, win as unknown as KicadItemsWindow, {
provider,
room,
seedDoc,
});
opts.log(`[collab] ${provider.kind} connected on ${room}`);
opts.onStatus("Collab: connected");
clog("connected ✓");
@ -324,7 +357,7 @@ export function WasmTool({
log: append,
onStatus: setStatus,
});
await maybeStartCollab(win, { tool, projectId, targetPath, log: append, onStatus: setStatus });
await maybeStartCollab(win, { tool, slug, projectId, targetPath, log: append, onStatus: setStatus });
} catch (err) {
append(`[fatal] ${String(err)}`);
setStatus(`Error: ${String(err)}`);

View file

@ -14,7 +14,7 @@ import type { ProviderConfig, ProviderKind } from "@/wasm/collab";
* Which Yjs collab provider this deployment uses (one active per env), and its
* endpoint/token. Defaults to `broadcastchannel` so a vanilla checkout keeps
* the cross-tab-only behavior with no backend. Built here at the composition
* root and passed into `startCollab`, so `wasm/collab` stays env-agnostic.
* root and passed into `startKicadCollab`, so `wasm/collab` stays env-agnostic.
*/
export function yjsProviderConfig(): ProviderConfig {
const kind = (import.meta.env.VITE_YJS_PROVIDER ?? "broadcastchannel") as ProviderKind;

View file

@ -1,4 +1,5 @@
import * as Y from "yjs";
import type { KicadDoc } from "@pcbjam/shared";
import { clog } from "./debug";
import {
connectProvider,
@ -55,6 +56,13 @@ export interface StartCollabOptions {
provider: ProviderConfig;
/** Room id — see @pcbjam/shared `collabRoomId`. Identifies one shared doc. */
room: string;
/**
* The full `KicadDoc` parsed from the opened file (`fileToDoc`) — when the
* room is empty, the Y.Doc is seeded from THIS (meta + layout + items, so the
* file is recoverable from the doc alone — ysync 0005) instead of the editor
* snapshot. Ignored by the legacy scalar `startCollab`.
*/
seedDoc?: KicadDoc;
}
export interface CollabHandle {
@ -127,7 +135,7 @@ export async function startKicadCollab(
const provider = await connectProvider(doc, opts.provider, { room: opts.room });
await provider.whenSynced();
binding.seed();
binding.seed(opts.seedDoc);
clog("startKicadCollab: ready; doc items =", binding.items.size);
return {

View file

@ -1,10 +1,13 @@
import { describe, expect, it } from "vitest";
import * as Y from "yjs";
import {
docToFile,
fileToDoc,
itemsWireToDelta,
parseItemsWireDelta,
renderItem,
sexprToItems,
yToDoc,
type KicadItem,
} from "@pcbjam/shared";
import { bindKicadCollab, type KicadItemsBridge } from "./kicad-binding";
@ -150,6 +153,61 @@ describe("bindKicadCollab — two editors over relayed Y.Docs", () => {
expect(edB.store["fp-1"]).toBeDefined(); // adopted
});
it("seed(seedDoc) writes the FULL doc — file recoverable from the Y.Doc; peer adopts", () => {
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);
// Editor A opened the same file: its model holds the same flattened items.
Object.assign(edA.store, seedDoc.items);
bindA.seed(seedDoc); // empty room → file-seeds meta + layout + items
bindB.seed(); // joins → adopts
// The peer's editor received the items via adopt.
expect(edB.store["r-1"]).toBeDefined();
// Lossless: the file is recoverable from the Y.Doc ALONE (ysync 0005/0007),
// which the editor-snapshot seed (items only, no layout/meta) cannot do.
expect(docToFile(yToDoc(a))).toBe(docToFile(seedDoc));
// The file-seed must not have echoed an apply into the seeding editor.
expect(edA.applied.length).toBe(0);
});
it("seed(seedDoc) on a populated room still ADOPTS (doc authority wins over the file)", () => {
const { edA, edB, bindA, bindB } = setup();
seedEditor(edA, FP);
bindA.seed(); // A seeds the room from its editor
// B cold-opens a divergent copy of the file and offers it as seedDoc.
const fileB = `(kicad_wks (version 20220228)
(rect (uuid "fp-DIVERGENT") (name "border"))
)
`;
const seedDocB = fileToDoc(fileB);
Object.assign(edB.store, seedDocB.items);
bindB.seed(seedDocB);
expect(edB.store["fp-DIVERGENT"]).toBeUndefined(); // dropped
expect(edB.store["fp-1"]).toBeDefined(); // adopted
});
it("pre-seed remote state does NOT stream into the editor (adopt covers it)", () => {
const { edA, edB, bindA, bindB } = setup();
seedEditor(edA, FP);
bindA.seed(); // relays the full state into B's Y.Doc immediately
// B has NOT seeded yet: the initial state sync must not have been applied
// item-by-item (in the real app that's a redundant full blob apply into an
// editor that already opened the file).
expect(edB.applied.length).toBe(0);
bindB.seed(); // the adopt delivers the same state, once
expect(Object.keys(edB.store).sort()).toEqual(["fld-1", "fp-1", "pad-1"]);
});
it("destroy() detaches the editor from further remote changes", () => {
const { edA, edB, bindA, bindB } = setup();
seedEditor(edA, FP);

View file

@ -3,6 +3,7 @@ import {
applyDeltaToY,
deltaFromYEvents,
deltaToItemsWire,
docToY,
isEmptyItemsWireDelta,
isEmptyKicadDelta,
itemsWireToDelta,
@ -11,6 +12,7 @@ import {
renderItem,
yToItem,
type ItemsWireDelta,
type KicadDoc,
type KicadItem,
type KicadYItems,
} from "@pcbjam/shared";
@ -46,12 +48,15 @@ export interface KicadItemsBridge {
export interface KicadBinding {
/**
* Seed-once join: if the shared doc holds no items this client seeds it from
* the editor snapshot; otherwise the editor adopts the doc (doc authority —
* local-only roots are removed, doc roots applied). Call once after the
* doc/provider are connected.
* Seed-once join: if the shared doc holds no items this client seeds it —
* from `seedDoc` (the FULL `KicadDoc` parsed from the opened file via
* `fileToDoc`; writes meta + layout + items so `docToFile` can regenerate the
* file from the Y.Doc alone — ysync 0005/0007) when given, else from the
* editor snapshot (items only). Otherwise the editor adopts the doc (doc
* authority — local-only roots are removed, doc roots applied). Call once
* after the doc/provider are connected.
*/
seed(): void;
seed(seedDoc?: KicadDoc): void;
destroy(): void;
/** The underlying kdoc items map (exposed for tests/inspection). */
readonly items: KicadYItems;
@ -61,6 +66,12 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind
const items = kicadItemsMap(doc);
// Opaque per-instance origin tag so we can distinguish our own writes from peers'.
const ORIGIN = { local: true };
// Remote events arriving BEFORE seed() (e.g. the provider's initial state sync)
// must not stream into the editor item-by-item: the editor already holds the
// opened file, so that would be a redundant full-document blob apply (observed
// to trap eeschema's paste path in the real app). seed()'s adopt branch covers
// everything those early events contained.
let seeded = false;
/** Plain snapshot of the Y items (the `current`/`view` the conversions need). */
const itemsView = (): Record<string, KicadItem> => {
@ -94,6 +105,7 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind
// (the runtime); the event→delta computation is the shared default impl.
const observer = (events: Y.YEvent<Y.Map<unknown>>[], txn: Y.Transaction) => {
if (txn.origin === ORIGIN) return; // our own echo — ignore
if (!seeded) return; // pre-seed state sync — seed()'s adopt covers it
const delta = deltaFromYEvents(items, events);
if (isEmptyKicadDelta(delta)) return;
const wire = deltaToItemsWire(delta, itemsView());
@ -107,7 +119,20 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind
};
items.observeDeep(observer);
function seed(): void {
function seed(seedDoc?: KicadDoc): void {
seeded = true; // open the UP gate; everything below runs synchronously
if (items.size === 0 && seedDoc) {
// First tab, file-seeded: write the FULL doc (meta + layout + items) so
// the Y.Doc — not the editor snapshot — is the lossless source of truth
// (the file is recoverable via docToFile). The editor already opened the
// same file, so no applyItems is needed.
clog(
`seed: doc empty → SEEDING from file (${Object.keys(seedDoc.items).length} item(s), root ${seedDoc.root})`,
);
docToY(seedDoc, doc, ORIGIN);
return;
}
let wire: ItemsWireDelta;
try {
wire = parseItemsWireDelta(bridge.snapshotItems());
@ -123,7 +148,7 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind
);
if (items.size === 0) {
// First tab: seed the shared doc from the editor model.
// First tab, no file source: seed the shared doc from the editor model.
applyDeltaToY(doc, local, ORIGIN);
return;
}