All seven ysync-review bugs fixed and verified; every repro's expected-fail marker removed (they now run as regression tests). Also fixes two bugs found while verifying (doc 17 F5/F6): file-seeded Y bodies are re-upserted in the editor's serialization (doc-16 F4 was an artifact of bug 01), and the 0008-era "asyncify-fragile envelope parse" was really wrapInBoardEnvelope emitting display layer names — canonical LSET::Name() fixes track/via/zone v2 applies; makeFromBlob now logs parse errors instead of swallowing them. Verified: shared 98, standalone collab 37, ysync e2e 20/20 (chromium), collab regression set 21 passed / 3 pre-existing skips (firefox). Bumps: kicad (board_commit child-removal listener notification), web/pcbjam-shared (slot prune + arbitrated seed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgThWXtdvrYLK47EDFoGdq
4.8 KiB
Bug 07 — Sheet switch leaves the DOWN hook pointing at the old room; cross-room contamination window
Severity: medium (small window on warm rooms; a full network round-trip — or forever — on cold/failed switches) Status: FIXED 2026-07-03 — see 17 (batch 4: destroyed-flag hook + switch retry; fix direction 2 still open)
Where
web/standalone/src/wasm/collab/kicad-binding.ts:191-195—KicadBinding.destroy()only callsitems.unobserveDeep(observer); the DOWN hook registered viabridge.onItems(...)(→window.kicadCollab.onItems,moduleItemsBridge:216-222) is never unregisteredweb/standalone/src/wasm/collab/sheet-manager.ts:158-203—doSwitch: destroys the old binding synchronously, thenawait ensureRoom(sheetPath)(network for a cold room), thenbindKicadCollabre-registersonItems- UP-side mirror:
wasm/bindings/eeschema_embind.cpp:935-943— a queuedschCollabApplyItemsCallAfter applies toaFrame->GetScreen()— whatever sheet is active when it runs, not when it was queued
The DOWN-side hole (main issue)
Between old.binding.destroy() and bindKicadCollab(room.doc, bridge) there is an
async gap. During that gap window.kicadCollab.onItems still points at the old
binding's closure, which writes into the old sheet's Y.Doc.
The C++ side has already rebaselined to the new screen (OnSchSheetChanged →
rebaseline() fires from DisplayCurrentSheet before the JS switch completes), so a
local edit in the gap emits a new-sheet-scoped diff — and the stale hook applies it
to the old room:
- the old room's doc gains the new sheet's items (
applyDeltaToYupserts them and appends root layout slots); - peers bound to the old room receive them and add the wrong sheet's items to their editor screens;
- the old sheet's materialized file now contains foreign items.
Window size:
- Warm room (already in the pool): one microtask — tiny but nonzero.
- Cold room (
switchTobeforeconnectAllfinished warming it): a full provider connect +whenSynced()round-trip. - Failed switch (
ensureRoomthrows — network down):doSwitchaborts withactivePath = nulland no retry; the stale hook stays live indefinitely, and every subsequent edit on the new sheet flows into the old room until the user navigates again successfully. - Coalesced rapid navigation: superseded switches are skipped
(
requestedPath !== sheetPath), correctly — but the hook keeps pointing at the last bound room, which may be several sheets back, until the final switch completes.
The UP-side mirror (smaller)
An applyItems already queued into the C++ CallAfter pipeline before a navigation
lands on the new screen: doApplyItems resolves existing hierarchy-wide (fine)
but commit.Add(item, aFrame->GetScreen()) targets the now-active sheet — items from
sheet A's room can be added to sheet B's screen. Sub-frame window; lower priority than
the DOWN side but the fix below covers it too.
Fix direction
- Detach the DOWN hook in
destroy(): give the bridge anoffItems()(or havebindKicadCollabinstall a wrapper that checks adestroyedflag and drops — or buffers — emits). Dropping is acceptable only if the C++ baseline is rolled back; otherwise the edit silently never syncs (a mini version of bug 05). Better: buffer emits while unbound and let the nextseed()'s adopt/baseline pass reconcile them (the adopt already reconciles editor↔doc wholesale, so buffered emits can simply be discarded after a successful adopt-bind — the adopt reads the editor's current truth). - Generation-tag the apply path: include the target sheet path (the room's file)
in the wire envelope and have
doApplyItemsverify it againstcurrentScreen()->GetFileName(), dropping mismatches. This closes both the UP-side race and any residual DOWN-side echo. - Retry / re-run failed switches: on
ensureRoomfailure, keeprequestedPathand re-attempt (with backoff) instead of leaving the editor unbound.
Verification
Sheet-manager unit test: destroy old binding, delay ensureRoom (fake provider),
fire onItems during the gap, assert the old doc did NOT change. Integration:
throttle the network, navigate to a cold subsheet and immediately draw a wire; assert
the wire lands in the new sheet's room only.
Repro (2026-07-03): web/standalone/src/wasm/collab/ysync-repros.test.ts — 07a
(post-destroy() emit must not write into the doc) and 07b (REAL sheet-manager +
REAL kicad-binding + REAL yjs with only connectKicadDoc faked; the cold-switch gap
is held open and the stale hook's emit lands in the old sheet's doc). Both it.fails.
See 16.