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

View file

@ -93,3 +93,4 @@ sending half emits NOTHING, not the bare removal the doc predicted).
| 15 | [15-plan-repro-tests-and-v2-e2e.md](15-plan-repro-tests-and-v2-e2e.md) | The approved plan: repro tests for bugs 0107 + the v2 e2e port | | 15 | [15-plan-repro-tests-and-v2-e2e.md](15-plan-repro-tests-and-v2-e2e.md) | The approved plan: repro tests for bugs 0107 + the v2 e2e port |
| 16 | [16-repro-suite-results-and-empirical-findings.md](16-repro-suite-results-and-empirical-findings.md) | Plan 15 executed (2026-07-03): suite map, verified failure sites, empirical findings F1F4 | | 16 | [16-repro-suite-results-and-empirical-findings.md](16-repro-suite-results-and-empirical-findings.md) | Plan 15 executed (2026-07-03): suite map, verified failure sites, empirical findings F1F4 |
| 17 | [17-fixes-bugs-01-07.md](17-fixes-bugs-01-07.md) | Bugs 0107 fixed & verified (2026-07-03); findings F5F6; remaining follow-ups | | 17 | [17-fixes-bugs-01-07.md](17-fixes-bugs-01-07.md) | Bugs 0107 fixed & verified (2026-07-03); findings F5F6; remaining follow-ups |
| 18 | [18-miss08-opts-12-13.md](18-miss08-opts-12-13.md) | Miss 08 (lib_symbols + layout save-sync) + opts 12/13 implemented; opt 14 deferred (2026-07-03) |

View file

@ -1,7 +1,7 @@
# Design miss 08 — Non-item document state only syncs at seed; `lib_symbols` is a landmine for the symbol-libraries milestone # Design miss 08 — Non-item document state only syncs at seed; `lib_symbols` is a landmine for the symbol-libraries milestone
**Severity:** design gap (silent divergence for settings edits; structural blocker later) **Severity:** design gap (silent divergence for settings edits; structural blocker later)
**Status:** open decision **Status:** IMPLEMENTED 2026-07-03 — see [18](18-miss08-opts-12-13.md) (lib_symbols channel + coarse layout save-sync; nets stay seed-frozen by design)
## Where ## Where

View file

@ -1,7 +1,7 @@
# Optimization 12 — O(full-model) work on every edit, apply, and remote batch # Optimization 12 — O(full-model) work on every edit, apply, and remote batch
**Severity:** performance (fine on demo boards; seconds-per-edit territory at 510k items) **Severity:** performance (fine on demo boards; seconds-per-edit territory at 510k items)
**Status:** open **Status:** TS items DONE 2026-07-03 (zod off the observer path, children index once — see [18](18-miss08-opts-12-13.md)); C++ item 1 delivered by [17](17-fixes-bugs-01-07.md) batch 3 except the scalar-scan/legacy-wire retirement (goes with the legacy-wire removal)
## The costs, per hot path ## The costs, per hot path

View file

@ -1,7 +1,7 @@
# Optimization 13 — Parked-dirty sheet rebind re-applies the entire sheet instead of the delta # Optimization 13 — Parked-dirty sheet rebind re-applies the entire sheet instead of the delta
**Severity:** performance + UX (heavy on big sheets; creates the adopt undo-bomb) **Severity:** performance + UX (heavy on big sheets; creates the adopt undo-bomb)
**Status:** open **Status:** FIXED 2026-07-03 — option 2 (diff on rebind) — see [18](18-miss08-opts-12-13.md)
## Where ## Where

View file

@ -1,7 +1,7 @@
# Optimization 14 — Item-level body granularity: whole-item payloads per nudge, LWW drops concurrent property edits # Optimization 14 — Item-level body granularity: whole-item payloads per nudge, LWW drops concurrent property edits
**Severity:** known/documented v1 tradeoff — recorded here so its costs are visible when prioritizing **Severity:** known/documented v1 tradeoff — recorded here so its costs are visible when prioritizing
**Status:** open (deliberate design decision; revisit trigger below) **Status:** open (deliberate design decision; revisit trigger below — reaffirmed 2026-07-03, see [18](18-miss08-opts-12-13.md))
## Where ## Where

View file

@ -0,0 +1,102 @@
# Miss 08 + opts 12/13 implemented; opt 14 deliberately deferred (2026-07-03)
**Status:** miss [08](08-miss-layout-state-never-syncs.md) (both halves), opt
[12](12-opt-hot-path-full-model-work.md)'s TS items, and opt
[13](13-opt-parked-dirty-full-sheet-replace.md) implemented and verified. All
TS/shared-side — the C++ already did its part (eeschema's `itemBlob` emits the
`lib_symbols` context; `doApplyItems`' `findLib` was designed to prefer a
blob-carried definition), so no WASM rebuild was needed.
## 08A — lib_symbols channel
- New `kdoc_libsymbols` Y.Map (lib id → `(symbol …)` definition text): concurrent
placements of DIFFERENT symbols merge per definition instead of LWW-clobbering
one layout slot.
- `docToY` extracts the layout's `(lib_symbols …)` definitions into the map and
keeps the layout slot EMPTY (the injection point); `yToDoc` re-injects the
map's definitions sorted by lib id — the order KiCad's own writer emits, so
materialization matches editor saves.
- Emit direction: `wireLibSymbols(wire)` recovers definitions from the
multi-form clipboard blob (`unwrapWireItem` still strips them from the ITEM
payload); the binding stores them in the same transaction as the item delta.
- Apply direction: `deltaToItemsWire(delta, view, libDefs)` prefixes a root
item carrying `(lib_id …)` with its definition — exactly the multi-form shape
eeschema's paste path parses, so `findLib`'s first branch resolves it and a
peer that has never seen the symbol renders it correctly.
- e2e: `tests/kicad/ysync-libsymbols.spec.ts` — a joiner whose cold copy lacks
the symbol adopts it WITH its definition (chromium two-tab; F2 applies).
## 08B — coarse layout save-sync
- New shared `syncLayoutToY(fileDoc, ydoc, origin)`: reconciles non-item layout
slots per HEAD KEYWORD group (title block, paper, setup, settings…) from a
just-saved file — a changed group is replaced wholesale (LWW at head
granularity; different heads merge). `{item}` slots are never touched.
- Deliberate freezes: `net` (pcbnew's root net table — net-creating edits are
not possible in the standalone; repeated positional heads can't be reconciled
by name) stays seed-frozen; `lib_symbols` routes to the defs map ADDITIVELY
(a save can't know about a peer's not-yet-applied placement).
- Wiring: `registerSaveHook` gained `onSavedText` (decoded saved text);
WasmTool routes it — eeschema per sheet via the manager's new
`syncLayoutFromSave` (parked rooms sync too; the dirty mark it causes costs
only the real delta now, see opt 13), pcbnew/pl_editor via the single-room
doc.
- Known limitation (unchanged from the doc's "coarse but converging"): a peer's
EDITOR does not live-update non-item state — the room doc converges, so
materialization/reopen and drift-detect see it; live propagation into an open
editor's title block would need a C++ apply for non-item state.
## Opt 12 — TS hot-path (items 2 + 4)
- `yToItemUnchecked` (no zod) feeds the binding's `itemsView()` — both hot
directions (every local emit, every remote batch). zod stays at the trust
boundaries: wire parse, seed, materialize.
- `itemsWireToDelta` builds the parent→children index ONCE per conversion
(was: full-scan `descendants()` per wire item and per removed id).
- NOT taken from doc 12: retiring the C++ scalar scan + legacy emit (the scan
still feeds the legacy wire and covers eeschema's unreported post-Push
connectivity cleanup — retire it together with the legacy-wire removal,
miss 11 follow-up), and the lazy `deltaToItemsWire` view (item 3) — killing
zod removed the dominant cost; revisit if profiling says otherwise.
## Opt 13 — diff-on-rebind adopt (option 2)
`seed()`'s adopt no longer renders and re-applies EVERY doc root. It diffs the
editor snapshot against the doc view (`itemsWireToDelta` + `wireItemUuids`) and
applies only the doc-authoritative difference:
- doc-only ROOTS → added (their sexprs embed descendants; a doc-only child makes
its shared parent differ and rides the parent's re-apply);
- differing items → the DOC's version re-applied, lifted to their root (the C++
upsert replaces roots; a bare child apply would mis-parent);
- editor-only ROOTS → removed (editor-only children vanish with their parent's
re-apply).
An empty diff degrades to baseline-only — the parked-dirty rebind, the clean
revisit, and the cold divergent adopt now share one code path, and the adopt's
single commit (and undo entry — the miss-09 "adopt undo bomb") shrinks to the
real changed set.
## Opt 14 — deferred, on purpose
The slot-level CRDT refinement is a meaningful rewrite of `kicad-y`'s write path
plus the whole conversion layer, with real interleaving-anomaly risk. Doc 14's
own revisit triggers (measured bandwidth/storage cost; concurrent same-item UX
complaints) are unmet, and the flatten already gives per-child granularity.
Nothing in this batch forecloses it — `body` stayed one JSON value.
## Verification
- pcbjam-shared: 107 (9 new in `test/layout-libsymbols-sync.test.ts`)
- standalone: 79 + 2 known pre-existing wasm-assets fixture failures
(collab suite 42, incl. 3 new diff-adopt + 2 lib_symbols binding tests;
save-flow +2)
- e2e (chromium): full ysync set + `ysync-libsymbols.spec.ts` green
- No C++ changes; no WASM rebuild.
## Remaining from the review
Misses [09](09-miss-undo-not-collab-aware.md) (collab-aware undo — the adopt
shrink here removes its worst case) and [10](10-miss-no-repair-path.md) (drift
repair), opt 12's C++ retirement half (with the legacy-wire removal), opt 14
(triggers above), and doc 17's carried-over follow-ups.

View file

@ -0,0 +1,195 @@
import * as path from "path";
import type { Page } from "@playwright/test";
import { test, expect } from "./fixtures";
/**
* Miss 08A e2e embedded library definitions travel the v2 wire.
*
* A schematic symbol is only renderable with its `(lib_symbols …)` definition.
* The TS layer used to strip that envelope ("sender context") so a peer that
* had never seen the symbol received a definition-less instance and the room's
* materialization referenced a lib id it didn't contain. Now definitions live
* in `kdoc_libsymbols` and are re-prefixed on every apply wire.
*
* Two kicad_editor instances Firefox's per-process wasm budget (finding F2):
* chromium only, like the other two-tab specs.
*/
const SYM1 = "44444444-0000-0000-0000-000000000001";
const WIRE1 = "22222222-0000-0000-0000-000000000001";
const SCH_WITH_SYMBOL = `(kicad_sch
\t(version 20250114)
\t(generator "eeschema")
\t(generator_version "9.0")
\t(uuid "11111111-1111-1111-1111-111111111111")
\t(paper "A4")
\t(lib_symbols
\t\t(symbol "Device:R" (pin_numbers (hide yes)) (pin_names (offset 0)) (exclude_from_sim no) (in_bom yes) (on_board yes)
\t\t\t(property "Reference" "R" (at 2.032 0 90) (effects (font (size 1.27 1.27))))
\t\t\t(property "Value" "R" (at 0 0 90) (effects (font (size 1.27 1.27))))
\t\t\t(symbol "R_0_1"
\t\t\t\t(rectangle (start -1.016 -2.54) (end 1.016 2.54) (stroke (width 0.254) (type default)) (fill (type none)))
\t\t\t)
\t\t\t(symbol "R_1_1"
\t\t\t\t(pin passive line (at 0 3.81 270) (length 1.27) (name "~" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27)))))
\t\t\t\t(pin passive line (at 0 -3.81 90) (length 1.27) (name "~" (effects (font (size 1.27 1.27)))) (number "2" (effects (font (size 1.27 1.27)))))
\t\t\t)
\t\t)
\t)
\t(wire (pts (xy 50.8 50.8) (xy 101.6 50.8)) (stroke (width 0) (type default)) (uuid "${WIRE1}"))
\t(symbol (lib_id "Device:R") (at 63.5 63.5 0) (unit 1) (exclude_from_sim no) (in_bom yes) (on_board yes) (dnp no)
\t\t(uuid "${SYM1}")
\t\t(property "Reference" "R1" (at 66.04 62.23 0) (effects (font (size 1.27 1.27)) (justify left)))
\t\t(property "Value" "10k" (at 66.04 64.77 0) (effects (font (size 1.27 1.27)) (justify left)))
\t\t(property "Footprint" "" (at 0 0 0) (effects (font (size 1.27 1.27)) (hide yes)))
\t\t(property "Datasheet" "" (at 0 0 0) (effects (font (size 1.27 1.27)) (hide yes)))
\t\t(pin "1" (uuid "44444444-0000-0000-0000-0000000000a1"))
\t\t(pin "2" (uuid "44444444-0000-0000-0000-0000000000a2"))
\t\t(instances (project "rt" (path "/11111111-1111-1111-1111-111111111111" (reference "R1") (unit 1))))
\t)
\t(sheet_instances (path "/" (page "1")))
)
`;
// B's cold copy: the same sheet BEFORE the symbol was placed — no instance, no
// definition. The adopt must deliver both.
const SCH_WITHOUT_SYMBOL = `(kicad_sch
\t(version 20250114)
\t(generator "eeschema")
\t(generator_version "9.0")
\t(uuid "11111111-1111-1111-1111-111111111111")
\t(paper "A4")
\t(lib_symbols)
\t(wire (pts (xy 50.8 50.8) (xy 101.6 50.8)) (stroke (width 0) (type default)) (uuid "${WIRE1}"))
\t(sheet_instances (path "/" (page "1")))
)
`;
type FS = {
mkdirTree(p: string): void;
writeFile(p: string, d: string): void;
readFile(p: string, o: { encoding: "utf8" }): string;
};
type Mod = {
kicadOpenFile(p: string): unknown;
kicadCollabSnapshotItems(): string;
kicadCollabApplyItems(j: string): unknown;
kicadSaveSchematic(p: string): unknown;
};
const BOOT_TIMEOUT = 150000;
const BUNDLE = path.resolve(__dirname, "../apps/kicad/collab-bundle-v2.js");
function hasAbort(l: { consoleLogs: string[]; errors: string[] }): boolean {
return [...l.consoleLogs, ...l.errors].some((s) => s.includes("Aborted("));
}
async function bootOpen(page: Page, content: string, name: string): Promise<void> {
await page.goto("/kicad/eeschema.html");
await expect(page.locator("#canvas")).toBeVisible({ timeout: BOOT_TIMEOUT });
await page.waitForFunction(
() => {
const m = (window as unknown as { Module?: Mod }).Module;
return (
!!m &&
typeof m.kicadOpenFile === "function" &&
typeof m.kicadCollabSnapshotItems === "function" &&
typeof m.kicadSaveSchematic === "function"
);
},
null,
{ timeout: BOOT_TIMEOUT },
);
await page.waitForFunction(
() =>
!!window.wxElementRegistry &&
window.wxElementRegistry
.findAll({ visible: true })
.some((e) => /Frame$/.test(e.typeName) || (e.name || "").endsWith("Frame")),
null,
{ timeout: BOOT_TIMEOUT },
);
await page.evaluate(
({ content, name }) => {
const w = window as unknown as { FS: FS; Module: Mod };
try {
w.FS.mkdirTree("/home/kicad/documents");
} catch {
/* exists */
}
const p = `/home/kicad/documents/${name}.kicad_sch`;
w.FS.writeFile(p, content);
w.Module.kicadOpenFile(p);
},
{ content, name },
);
await expect.poll(() => page.title(), { timeout: 30000 }).toMatch(new RegExp(name, "i"));
await page.addScriptTag({ path: BUNDLE });
}
function startV2(
page: Page,
opts: { room: string; settleMs?: number; seedText?: string },
): Promise<void> {
return page.evaluate(async (o) => {
const w = window as unknown as {
KicadCollabV2: { start: (m: unknown, win: unknown, o: unknown) => Promise<void> };
Module: unknown;
};
await w.KicadCollabV2.start(w.Module, window, o);
}, opts);
}
function saveText(page: Page): Promise<string> {
return page.evaluate(() => {
const w = window as unknown as { FS: FS; Module: Mod };
const out = "/home/kicad/documents/_dump.kicad_sch";
w.Module.kicadSaveSchematic(out);
return w.FS.readFile(out, { encoding: "utf8" });
});
}
test.describe("v2 items wire — lib_symbols travel (miss 08A)", () => {
test.describe.configure({ timeout: 420000 });
test("a joiner that never saw the symbol adopts it WITH its definition", async ({
context,
testLogger,
}) => {
test.skip(
test.info().project.name === "firefox",
"two kicad_editor tabs exceed Firefox's per-process wasm budget",
);
const room = `ysync-libsym-${test.info().workerIndex}`;
const tabA = await context.newPage();
const tabB = await context.newPage();
await bootOpen(tabA, SCH_WITH_SYMBOL, "tabA");
await bootOpen(tabB, SCH_WITHOUT_SYMBOL, "tabB");
await startV2(tabA, { room, seedText: SCH_WITH_SYMBOL }); // fresh → file-seed
await startV2(tabB, { room }); // joins → diff-adopt (doc authority)
// The adopt delivered the INSTANCE…
await expect
.poll(async () => (await saveText(tabB)).includes(SYM1), {
timeout: 20000,
intervals: [400],
})
.toBe(true);
// …AND its DEFINITION (findLib resolved it from the wire's lib_symbols
// context; before miss 08A the peer kept an empty lib_symbols and the
// instance rendered broken / materialized as an invalid file).
const saved = await saveText(tabB);
expect(saved).toContain(`(symbol "Device:R"`);
expect(saved).toContain(`(lib_id "Device:R")`);
// The untouched wire survived the diff-adopt (only the difference applied).
expect(saved).toContain(WIRE1);
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
await tabA.close();
await tabB.close();
});
});

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

View file

@ -9,6 +9,7 @@ import {
projectToolPath, projectToolPath,
toolSchema, toolSchema,
ydocHasState, ydocHasState,
syncLayoutToY,
yToDoc, yToDoc,
type KicadDoc, type KicadDoc,
type Tool, type Tool,
@ -570,6 +571,9 @@ export function WasmTool({
const startedRef = React.useRef(false); const startedRef = React.useRef(false);
const driftRef = React.useRef<{ stop(): void } | null>(null); const driftRef = React.useRef<{ stop(): void } | null>(null);
const sheetManagerRef = React.useRef<SheetCollabManager | 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 [status, setStatus] = React.useState("Loading tool…");
const [logs, setLogs] = React.useState<string[]>([]); const [logs, setLogs] = React.useState<string[]>([]);
const [showLog, setShowLog] = React.useState(false); const [showLog, setShowLog] = React.useState(false);
@ -795,6 +799,21 @@ export function WasmTool({
onSaved: (relPath) => { onSaved: (relPath) => {
if (relPath.endsWith(".kicad_sch")) void sheetManagerRef.current?.onboard(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, { const { session, targetBytes } = await maybeConnectDocSession(win, {
docSource, docSource,
@ -865,6 +884,7 @@ export function WasmTool({
log: append, log: append,
onStatus: setStatus, onStatus: setStatus,
}); });
collabDocRef.current = collabHandle?.doc ?? null;
if (collabHandle && targetPath && COLLAB_TOOLS.has(tool)) { if (collabHandle && targetPath && COLLAB_TOOLS.has(tool)) {
driftRef.current = startDriftDetection({ driftRef.current = startDriftDetection({
doc: collabHandle.doc, doc: collabHandle.doc,
@ -897,6 +917,7 @@ export function WasmTool({
// onActiveChange(null). // onActiveChange(null).
sheetManagerRef.current?.destroy(); sheetManagerRef.current?.destroy();
sheetManagerRef.current = null; sheetManagerRef.current = null;
collabDocRef.current = null;
oom.stop(); oom.stop();
}; };
// Boot is one-shot per mount; deps intentionally exclude files/targetPath so // Boot is one-shot per mount; deps intentionally exclude files/targetPath so

View file

@ -4,6 +4,7 @@ import {
docToFile, docToFile,
fileToDoc, fileToDoc,
itemsWireToDelta, itemsWireToDelta,
kicadLibSymbolsMap,
parseItemsWireDelta, parseItemsWireDelta,
renderItem, renderItem,
sexprToItems, 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, 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, isEmptyKicadDelta,
itemsWireToDelta, itemsWireToDelta,
kicadItemsMap, kicadItemsMap,
kicadLibSymbolsMap,
parseItemsWireDelta, parseItemsWireDelta,
renderItem,
seedDocToY, seedDocToY,
upsertLibSymbolsToY,
wireItemUuids,
wireLibSymbols,
Y_KDOC_META, Y_KDOC_META,
Y_KDOC_SEED_NONCE, Y_KDOC_SEED_NONCE,
ydocHasState, ydocHasState,
yToItem, yToItemUnchecked,
type ItemsWireDelta, type ItemsWireDelta,
type KicadDoc, type KicadDoc,
type KicadItem, 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. // Concurrent double-seed arbitration cleanup (bug 06); set by the file-seed branch.
let detachSeedArbitration: (() => void) | undefined; 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 itemsView = (): Record<string, KicadItem> => {
const view: Record<string, KicadItem> = {}; const view: Record<string, KicadItem> = {};
items.forEach((ym, uuid) => { items.forEach((ym, uuid) => {
view[uuid] = yToItem(ym); view[uuid] = yToItemUnchecked(ym);
}); });
return view; 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 // DOWN: local editor change → Y.Doc
bridge.onItems((json: string) => { bridge.onItems((json: string) => {
if (destroyed) return; // stale hook (bug 07) — a destroyed binding is inert 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; return;
} }
const delta = itemsWireToDelta(wire, itemsView()); 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):", { clog("⬇ onItems (local edit):", {
added: delta.added.length, added: delta.added.length,
updated: delta.updated.length, updated: delta.updated.length,
removed: delta.removed.length, removed: delta.removed.length,
}); });
doc.transact(() => {
applyDeltaToY(doc, delta, ORIGIN); applyDeltaToY(doc, delta, ORIGIN);
upsertLibSymbolsToY(doc, defs, ORIGIN);
}, ORIGIN);
}); });
// UP: remote Y change → editor. The subscription + origin policy live HERE // 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 if (!seeded) return; // pre-seed state sync — seed()'s adopt covers it
const delta = deltaFromYEvents(items, events); const delta = deltaFromYEvents(items, events);
if (isEmptyKicadDelta(delta)) return; if (isEmptyKicadDelta(delta)) return;
const wire = deltaToItemsWire(delta, itemsView()); const wire = deltaToItemsWire(delta, itemsView(), libDefs);
if (isEmptyItemsWireDelta(wire)) return; if (isEmptyItemsWireDelta(wire)) return;
clog("⬆ remote Y change → apply to editor:", { clog("⬆ remote Y change → apply to editor:", {
added: wire.added.length, added: wire.added.length,
@ -203,32 +221,69 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind
cwarn("seed: snapshotItems unparseable", err); cwarn("seed: snapshotItems unparseable", err);
return; return;
} }
const local = itemsWireToDelta(wire, {});
const hasState = ydocHasState(doc); 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) { if (!hasState) {
// First tab, no file source: seed the shared doc from the editor model. // First tab, no file source: seed the shared doc from the editor model.
const local = itemsWireToDelta(wire, {});
clog(`seed: doc empty → SEEDING from editor snapshot (${local.added.length} item(s))`);
doc.transact(() => {
applyDeltaToY(doc, local, ORIGIN); applyDeltaToY(doc, local, ORIGIN);
upsertLibSymbolsToY(doc, wireLibSymbols(wire), ORIGIN);
}, ORIGIN);
return; return;
} }
// Joining a populated doc: the editor adopts it (seed-once authority, same // Joining a populated doc: the editor adopts it (seed-once authority, same
// rationale as the scalar reconciler §2 — divergent local uuids from a // 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 // never-saved cold open must yield to the doc's identity). Diff the editor
// ROOT items (their sexprs embed all descendants) and remove local-only roots. // 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 view = itemsView();
const docRoots = Object.entries(view) const editorDelta = itemsWireToDelta(wire, view); // editor state vs doc view
.filter(([, item]) => item.parent === null) const editorUuids = wireItemUuids(wire);
.map(([uuid]) => ({ sexpr: renderItem({ items: view }, uuid), parent: null }));
const removed = local.added // 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)) .filter((it) => it.parent === null && !(it.uuid in view))
.map((it) => it.uuid); .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 { return {

View file

@ -1,5 +1,5 @@
import type * as Y from "yjs"; 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 { connectKicadDoc, type KicadDocSession } from "./index";
import { import {
bindKicadCollab, bindKicadCollab,
@ -40,6 +40,12 @@ export interface SheetCollabManager {
switchTo(sheetPath: string): Promise<void>; switchTo(sheetPath: string): Promise<void>;
/** Warm a sheet created mid-session (driven by the save hook on an unknown path). */ /** Warm a sheet created mid-session (driven by the save hook on an unknown path). */
onboard(sheetPath: string): Promise<void>; 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). */ /** The currently-bound sheet, for drift-detection wiring (null before first switch). */
active(): { sheetPath: string; doc: Y.Doc } | null; active(): { sheetPath: string; doc: Y.Doc } | null;
/** Tear down ALL bindings + providers + docs (session end / unmount). */ /** 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> { async function connectAll(sheetPaths: string[]): Promise<void> {
await Promise.all( await Promise.all(
sheetPaths.map((p) => sheetPaths.map((p) =>
@ -289,7 +309,7 @@ export function createSheetCollabManager(opts: SheetManagerOptions): SheetCollab
opts.onActiveChange?.(null); opts.onActiveChange?.(null);
} }
return { connectAll, switchTo, onboard, active, destroy }; return { connectAll, switchTo, onboard, syncLayoutFromSave, active, destroy };
} }
export interface SheetChangedWindow { export interface SheetChangedWindow {

View file

@ -45,4 +45,39 @@ describe("registerSaveHook path routing", () => {
expect(onSaved).not.toHaveBeenCalled(); expect(onSaved).not.toHaveBeenCalled();
expect(saveBytes).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. * ("Add Sheet"), which the page-load file list can't contain.
*/ */
onSaved?: (relPath: string) => void; 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 { ): void {
const projectPrefix = `${memfsProjectDir(opts.slug)}/`; const projectPrefix = `${memfsProjectDir(opts.slug)}/`;
@ -64,6 +70,17 @@ export function registerSaveHook(
opts.onSaved?.(relPath); 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) { if (!opts.saveBytes) {
opts.log(`[save] ${relPath} saved in MEMFS (no external save target)`); opts.log(`[save] ${relPath} saved in MEMFS (no external save target)`);
return; return;