feat(ysync): lib_symbols travel + layout save-sync (miss 08), TS hot-path opts (12), diff-on-rebind adopt (13) — doc 18

Miss 08A: the binding stores wire-carried (lib_symbols …) definitions in
kdoc_libsymbols and prefixes them on apply wires — a joiner that never saw a
symbol adopts it WITH its definition (new e2e ysync-libsymbols.spec.ts).
Miss 08B: registerSaveHook gains onSavedText; WasmTool routes saved-file text
to syncLayoutToY (per sheet room via the manager's syncLayoutFromSave, or the
single-room doc) so title block / paper / setup edits converge instead of
drifting. Opt 12 (TS half): zod off the observer hot path (yToItemUnchecked),
children index built once per conversion. Opt 13: seed()'s adopt diffs the
editor snapshot against the doc view and applies only the doc-authoritative
difference — clean rebinds apply nothing, the adopt undo entry shrinks to the
real changed set. Opt 14 deliberately deferred (doc 18). All TS-side; no wasm
rebuild (the C++ blob/findLib sides already carried definitions).

Verified: shared 107, standalone 79 (+2 known pre-existing wasm-assets),
ysync e2e 21/21 chromium, collab regression 21/3-skip firefox.

Bumps: web/pcbjam-shared (lib_symbols channel + syncLayoutToY + opts).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgThWXtdvrYLK47EDFoGdq
This commit is contained in:
Gergő Törcsvári 2026-07-06 08:35:18 +02:00
commit 62ca571802
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
14 changed files with 570 additions and 27 deletions

@ -1 +1 @@
Subproject commit f12d9af9595c915bb17a31bb59d156d38b256cbf
Subproject commit 2387cecba80221e463fde02636d9ce4c3c24a044

View file

@ -9,6 +9,7 @@ import {
projectToolPath,
toolSchema,
ydocHasState,
syncLayoutToY,
yToDoc,
type KicadDoc,
type Tool,
@ -570,6 +571,9 @@ export function WasmTool({
const startedRef = React.useRef(false);
const driftRef = React.useRef<{ stop(): void } | null>(null);
const sheetManagerRef = React.useRef<SheetCollabManager | null>(null);
// The single-room collab doc (pcbnew/pl_editor), for the layout save-sync
// (miss 08B); eeschema routes per sheet through the manager instead.
const collabDocRef = React.useRef<import("yjs").Doc | null>(null);
const [status, setStatus] = React.useState("Loading tool…");
const [logs, setLogs] = React.useState<string[]>([]);
const [showLog, setShowLog] = React.useState(false);
@ -795,6 +799,21 @@ export function WasmTool({
onSaved: (relPath) => {
if (relPath.endsWith(".kicad_sch")) void sheetManagerRef.current?.onboard(relPath);
},
// Non-item document state (title block, paper, setup…) only reaches the
// room at seed time; reconcile it from every save (miss 08B).
onSavedText: (relPath, text) => {
if (sheetManagerRef.current) {
sheetManagerRef.current.syncLayoutFromSave(relPath, text);
return;
}
if (collabDocRef.current && relPath === targetPath) {
try {
syncLayoutToY(fileToDoc(text), collabDocRef.current, "layout-save");
} catch (err) {
append(`[save] layout sync failed: ${String(err)}`);
}
}
},
});
const { session, targetBytes } = await maybeConnectDocSession(win, {
docSource,
@ -865,6 +884,7 @@ export function WasmTool({
log: append,
onStatus: setStatus,
});
collabDocRef.current = collabHandle?.doc ?? null;
if (collabHandle && targetPath && COLLAB_TOOLS.has(tool)) {
driftRef.current = startDriftDetection({
doc: collabHandle.doc,
@ -897,6 +917,7 @@ export function WasmTool({
// onActiveChange(null).
sheetManagerRef.current?.destroy();
sheetManagerRef.current = null;
collabDocRef.current = null;
oom.stop();
};
// Boot is one-shot per mount; deps intentionally exclude files/targetPath so

View file

@ -4,6 +4,7 @@ import {
docToFile,
fileToDoc,
itemsWireToDelta,
kicadLibSymbolsMap,
parseItemsWireDelta,
renderItem,
sexprToItems,
@ -244,4 +245,100 @@ describe("bindKicadCollab — two editors over relayed Y.Docs", () => {
sexprToItems(`(pad "1" smd (at 0 0) (uuid "pad-1"))`, "fp-1").items["pad-1"]!.body,
);
});
it("adopt applies only the DIFFERENCE (opt 13) — identical items cost nothing", () => {
const { edA, edB, bindA, bindB } = setup();
const SEG = `(segment (start 0 0) (end 1 1) (uuid "seg-1"))`;
seedEditor(edA, FP);
seedEditor(edA, SEG);
bindA.seed();
// B's editor already holds the IDENTICAL footprint but not the segment.
seedEditor(edB, FP);
bindB.seed();
expect(edB.store["seg-1"]).toBeDefined(); // caught up
expect(edB.applied).toHaveLength(1);
const wire = parseItemsWireDelta(edB.applied[0]!);
// Only the missing segment travelled — the matching footprint did not.
expect(wire.added).toHaveLength(1);
expect(wire.added[0]!.sexpr).toContain("seg-1");
expect(wire.changed).toHaveLength(0);
expect(wire.removed).toHaveLength(0);
});
it("adopt with a fully matching editor applies NOTHING (clean rebind)", () => {
const { edA, edB, bindA, bindB } = setup();
seedEditor(edA, FP);
bindA.seed();
seedEditor(edB, FP);
bindB.seed();
expect(edB.applied).toHaveLength(0);
});
it("adopt re-applies a differing item's DOC version, lifted to its root", () => {
const { edA, edB, bindA, bindB } = setup();
seedEditor(edA, FP);
bindA.seed();
// B holds the same footprint but its pad drifted (never-synced local state).
seedEditor(edB, FP.replace(`(pad "1" smd (at 0 0)`, `(pad "1" smd (at 9 9)`));
bindB.seed();
// Doc authority: B's editor converges on the doc's pad, via ONE root re-apply.
expect(edB.store["pad-1"]!.body).toEqual(
sexprToItems(`(pad "1" smd (at 0 0) (uuid "pad-1"))`, "fp-1").items["pad-1"]!.body,
);
expect(edB.applied).toHaveLength(1);
const wire = parseItemsWireDelta(edB.applied[0]!);
expect(wire.changed).toHaveLength(1);
expect(wire.changed[0]!.sexpr).toContain(`(uuid "fp-1")`); // the root, not the bare pad
});
});
describe("lib_symbols flow through the binding (miss 08A)", () => {
const DEF = `(symbol "Device:R" (property "Reference" "R" (at 2 0 90)))`;
const INSTANCE = `(symbol (lib_id "Device:R") (at 100 50 0) (uuid "sym-1"))`;
it("an emitted placement's definition is stored and re-rendered for the peer", () => {
const { a, b } = pair();
const edA = new FakeEditor();
const edB = new FakeEditor();
bindKicadCollab(a, edA).seed();
bindKicadCollab(b, edB).seed();
// A places a symbol: the eeschema blob is multi-form (definition + instance).
edA.localUpsert(`(lib_symbols ${DEF}) ${INSTANCE}`, null, "added");
// B's editor received the instance WITH its definition prefixed (findLib's
// first branch), even though B has never seen this symbol.
expect(edB.store["sym-1"]).toBeDefined();
const applied = edB.applied.map((j) => parseItemsWireDelta(j));
const symWire = applied
.flatMap((w) => [...w.added, ...w.changed])
.find((w) => w.sexpr.includes("sym-1"));
expect(symWire, "the symbol reached B").toBeTruthy();
expect(symWire!.sexpr).toMatch(/^\(lib_symbols \(symbol "Device:R"/);
// And the definition landed in the room's defs map on BOTH sides
// (materialization injection is covered by the shared-lib tests — this
// room was editor-snapshot-seeded, which carries no layout/meta).
expect(kicadLibSymbolsMap(b).get("Device:R")).toContain(`"Device:R"`);
expect(kicadLibSymbolsMap(a).get("Device:R")).toContain(`"Device:R"`);
});
it("adopt of a doc holding a symbol carries the definition too", () => {
const { a, b } = pair();
const edA = new FakeEditor();
const edB = new FakeEditor();
bindKicadCollab(a, edA).seed();
edA.localUpsert(`(lib_symbols ${DEF}) ${INSTANCE}`, null, "added");
// B joins with an empty editor → adopts the doc.
bindKicadCollab(b, edB).seed();
expect(edB.store["sym-1"]).toBeDefined();
const wire = parseItemsWireDelta(edB.applied[0]!);
const symWire = [...wire.added, ...wire.changed].find((w) => w.sexpr.includes("sym-1"));
expect(symWire!.sexpr).toMatch(/^\(lib_symbols \(symbol "Device:R"/);
});
});

View file

@ -7,13 +7,16 @@ import {
isEmptyKicadDelta,
itemsWireToDelta,
kicadItemsMap,
kicadLibSymbolsMap,
parseItemsWireDelta,
renderItem,
seedDocToY,
upsertLibSymbolsToY,
wireItemUuids,
wireLibSymbols,
Y_KDOC_META,
Y_KDOC_SEED_NONCE,
ydocHasState,
yToItem,
yToItemUnchecked,
type ItemsWireDelta,
type KicadDoc,
type KicadItem,
@ -87,15 +90,24 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind
// Concurrent double-seed arbitration cleanup (bug 06); set by the file-seed branch.
let detachSeedArbitration: (() => void) | undefined;
/** Plain snapshot of the Y items (the `current`/`view` the conversions need). */
/**
* Plain snapshot of the Y items (the `current`/`view` the conversions need).
* Unchecked reads (opt 12): this runs on every local emit AND every remote
* batch; the zod walk of each body tree dominated at scale. The wire parse
* zod-validates at the trust boundary; seed/materialize keep checked reads.
*/
const itemsView = (): Record<string, KicadItem> => {
const view: Record<string, KicadItem> = {};
items.forEach((ym, uuid) => {
view[uuid] = yToItem(ym);
view[uuid] = yToItemUnchecked(ym);
});
return view;
};
/** kdoc_libsymbols reader for the apply direction (miss 08). */
const libDefs = (libId: string): string | undefined =>
kicadLibSymbolsMap(doc).get(libId);
// DOWN: local editor change → Y.Doc
bridge.onItems((json: string) => {
if (destroyed) return; // stale hook (bug 07) — a destroyed binding is inert
@ -107,13 +119,19 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind
return;
}
const delta = itemsWireToDelta(wire, itemsView());
if (isEmptyKicadDelta(delta)) return;
// Library definitions the blob carried (a placed symbol's lib_symbols
// context — miss 08): store them alongside the items, same transaction.
const defs = wireLibSymbols(wire);
if (isEmptyKicadDelta(delta) && Object.keys(defs).length === 0) return;
clog("⬇ onItems (local edit):", {
added: delta.added.length,
updated: delta.updated.length,
removed: delta.removed.length,
});
applyDeltaToY(doc, delta, ORIGIN);
doc.transact(() => {
applyDeltaToY(doc, delta, ORIGIN);
upsertLibSymbolsToY(doc, defs, ORIGIN);
}, ORIGIN);
});
// UP: remote Y change → editor. The subscription + origin policy live HERE
@ -123,7 +141,7 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind
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());
const wire = deltaToItemsWire(delta, itemsView(), libDefs);
if (isEmptyItemsWireDelta(wire)) return;
clog("⬆ remote Y change → apply to editor:", {
added: wire.added.length,
@ -203,32 +221,69 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind
cwarn("seed: snapshotItems unparseable", err);
return;
}
const local = itemsWireToDelta(wire, {});
const hasState = ydocHasState(doc);
clog(
`seed: doc has ${items.size} item(s), editor has ${local.added.length}`,
hasState ? "ADOPTING doc (joining)" : "SEEDING doc (first tab)",
);
if (!hasState) {
// First tab, no file source: seed the shared doc from the editor model.
applyDeltaToY(doc, local, ORIGIN);
const local = itemsWireToDelta(wire, {});
clog(`seed: doc empty → SEEDING from editor snapshot (${local.added.length} item(s))`);
doc.transact(() => {
applyDeltaToY(doc, local, ORIGIN);
upsertLibSymbolsToY(doc, wireLibSymbols(wire), ORIGIN);
}, ORIGIN);
return;
}
// Joining a populated doc: the editor adopts it (seed-once authority, same
// rationale as the scalar reconciler §2 — divergent local uuids from a
// never-saved cold open must yield to the doc's identity). Apply the doc's
// ROOT items (their sexprs embed all descendants) and remove local-only roots.
// never-saved cold open must yield to the doc's identity). Diff the editor
// snapshot against the doc VIEW and apply only the DIFFERENCE (opt 13):
// identical items cost nothing, the apply commit (and its undo entry — the
// adopt undo-bomb, miss 09) shrinks to the real changed set, and a clean
// rebind degrades to baseline-only.
const view = itemsView();
const docRoots = Object.entries(view)
.filter(([, item]) => item.parent === null)
.map(([uuid]) => ({ sexpr: renderItem({ items: view }, uuid), parent: null }));
const removed = local.added
const editorDelta = itemsWireToDelta(wire, view); // editor state vs doc view
const editorUuids = wireItemUuids(wire);
// Doc authority, inverted per class:
// - doc-only ROOTS → add to the editor (their sexprs embed descendants;
// a doc-only CHILD makes its shared parent's body differ → covered below);
// - items that DIFFER → re-apply the doc's version, lifted to their root
// (the C++ upsert replaces roots; a bare child apply would mis-parent);
// - editor-only ROOTS → remove (editor-only children disappear with their
// parent's re-apply).
const liftToRoot = (uuid: string): string => {
let cur = uuid;
while (view[cur]?.parent != null) cur = view[cur]!.parent!;
return cur;
};
const docOnly = Object.entries(view)
.filter(([uuid, it]) => it.parent === null && !editorUuids.has(uuid))
.map(([uuid, it]) => ({ uuid, ...it }));
const changedRoots = [
...new Set(
editorDelta.updated.filter((it) => it.uuid in view).map((it) => liftToRoot(it.uuid)),
),
]
.filter((uuid) => !docOnly.some((it) => it.uuid === uuid))
.map((uuid) => ({ uuid, ...view[uuid]! }));
const removed = editorDelta.added
.filter((it) => it.parent === null && !(it.uuid in view))
.map((it) => it.uuid);
bridge.applyItems(JSON.stringify({ added: docRoots, changed: [], removed }));
const adoptWire = deltaToItemsWire(
{ added: docOnly, updated: changedRoots, removed },
view,
libDefs,
);
clog(
`seed: doc has ${items.size} item(s) → ADOPTING diff:`,
`+${adoptWire.added.length} ~${adoptWire.changed.length} -${adoptWire.removed.length}`,
);
if (isEmptyItemsWireDelta(adoptWire)) return; // editor already matches — baseline only
bridge.applyItems(JSON.stringify(adoptWire));
}
return {

View file

@ -1,5 +1,5 @@
import type * as Y from "yjs";
import { collabRoomId, type KicadDoc } from "@pcbjam/shared";
import { collabRoomId, fileToDoc, syncLayoutToY, type KicadDoc } from "@pcbjam/shared";
import { connectKicadDoc, type KicadDocSession } from "./index";
import {
bindKicadCollab,
@ -40,6 +40,12 @@ export interface SheetCollabManager {
switchTo(sheetPath: string): Promise<void>;
/** Warm a sheet created mid-session (driven by the save hook on an unknown path). */
onboard(sheetPath: string): Promise<void>;
/**
* Coarse non-item layout sync from a just-saved sheet file (miss 08B): title
* block / paper / settings edits reconcile into the sheet's room doc, which
* otherwise only carries them from seed time. No-op for unknown sheets.
*/
syncLayoutFromSave(sheetPath: string, fileText: string): void;
/** The currently-bound sheet, for drift-detection wiring (null before first switch). */
active(): { sheetPath: string; doc: Y.Doc } | null;
/** Tear down ALL bindings + providers + docs (session end / unmount). */
@ -251,6 +257,20 @@ export function createSheetCollabManager(opts: SheetManagerOptions): SheetCollab
}
}
function syncLayoutFromSave(sheetPath: string, fileText: string): void {
const room = rooms.get(sheetPath);
if (!room) return; // not a collab sheet (or still onboarding) — nothing to sync
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.
if (syncLayoutToY(fileToDoc(fileText), room.doc, "layout-save")) {
clog(`[sheet] layout save-sync: ${sheetPath} updated`);
}
} catch (err) {
cwarn(`[sheet] layout save-sync failed for ${sheetPath}`, err);
}
}
async function connectAll(sheetPaths: string[]): Promise<void> {
await Promise.all(
sheetPaths.map((p) =>
@ -289,7 +309,7 @@ export function createSheetCollabManager(opts: SheetManagerOptions): SheetCollab
opts.onActiveChange?.(null);
}
return { connectAll, switchTo, onboard, active, destroy };
return { connectAll, switchTo, onboard, syncLayoutFromSave, active, destroy };
}
export interface SheetChangedWindow {

View file

@ -45,4 +45,39 @@ describe("registerSaveHook path routing", () => {
expect(onSaved).not.toHaveBeenCalled();
expect(saveBytes).not.toHaveBeenCalled();
});
it("onSavedText receives the decoded file text (layout save-sync, miss 08B)", () => {
const onSavedText = vi.fn();
const win: SaveHookWindow = {
FS: {
readFile: () => new TextEncoder().encode("(kicad_sch (version 1))"),
} as unknown as SaveHookWindow["FS"],
kicadCollab: {},
};
registerSaveHook(win, {
slug: SLUG,
onSavedText,
log: () => {},
onStatus: () => {},
});
win.kicadCollab!.onSave!(`${PROJ}/sheet.kicad_sch`);
expect(onSavedText).toHaveBeenCalledWith("sheet.kicad_sch", "(kicad_sch (version 1))");
});
it("an onSavedText read failure is logged, not thrown", () => {
const onSavedText = vi.fn();
const log = vi.fn();
const win: SaveHookWindow = {
FS: {
readFile: () => {
throw new Error("gone");
},
} as unknown as SaveHookWindow["FS"],
kicadCollab: {},
};
registerSaveHook(win, { slug: SLUG, onSavedText, log, onStatus: () => {} });
win.kicadCollab!.onSave!(`${PROJ}/sheet.kicad_sch`);
expect(onSavedText).not.toHaveBeenCalled();
expect(log).toHaveBeenCalledWith(expect.stringContaining("onSavedText read failed"));
});
});

View file

@ -35,6 +35,12 @@ export function registerSaveHook(
* ("Add Sheet"), which the page-load file list can't contain.
*/
onSaved?: (relPath: string) => void;
/**
* Like `onSaved` but with the saved file's TEXT (read back from MEMFS).
* The collab layout save-sync (miss 08B) uses it to reconcile non-item
* document state (title block, paper, setup) into the room doc.
*/
onSavedText?: (relPath: string, text: string) => void;
},
): void {
const projectPrefix = `${memfsProjectDir(opts.slug)}/`;
@ -64,6 +70,17 @@ export function registerSaveHook(
opts.onSaved?.(relPath);
if (opts.onSavedText) {
try {
const data = win.FS?.readFile(absPath);
if (data instanceof Uint8Array) {
opts.onSavedText(relPath, new TextDecoder().decode(data));
}
} catch (err) {
opts.log(`[save] onSavedText read failed for ${relPath}: ${String(err)}`);
}
}
if (!opts.saveBytes) {
opts.log(`[save] ${relPath} saved in MEMFS (no external save target)`);
return;