LoroSyncPlugin Wipes Imported Page Content — Patch + Seed Safety Net
LoroSyncPlugin Wipes Imported Page Content — Patch + Seed Safety Net
Problem
After importing a markdown vault, clicking any imported page shows content briefly, then LoroSyncPlugin.init() wipes
it to an empty editor with the “Start writing…” placeholder. The context panel still shows the correct word count —
proving the content exists in the backend but is destroyed in the editor.
Root Cause
loro-prosemirror’s LoroSyncPlugin has a design gap in its init() function (called via setTimeout(0) after the
editor mounts):
// loro-prosemirror/dist/index.js — init()const innerDoc = state.doc.getMap(ROOT_DOC_KEY);if (innerDoc.size === 0) { // Unconditionally wipes ALL ProseMirror content const tr = view.state.tr.delete(0, view.state.doc.content.size); view.dispatch(tr);}The assumption is: “empty LoroDoc = new empty document.” But imported pages have content in ProseMirror (loaded from
markdown) and no CRDT snapshot (content_loro is NULL). The backend cannot create a PM-compatible LoroDoc because
that requires the full ProseMirror schema (node types, mark types, attributes), which lives only in the JavaScript
frontend.
Why only imported pages?
| Page type | PM content | LoroDoc | init() behavior |
|---|---|---|---|
| New page | Empty | Empty | Wipes nothing — invisible |
| Existing (with CRDT) | From CRDT | Populated | Reconstructs from CRDT — correct |
| Imported | Has | Empty | Wipes content — DATA LOSS |
Fix: Two-Layer Defense
Layer 1: pnpm patch on loro-prosemirror (root cause fix)
File: patches/loro-prosemirror.patch Tracked in: package.json → pnpm.patchedDependencies
The patch modifies init() to check for PM content before deciding what to do with an empty LoroDoc:
if (innerDoc.size === 0 && view.state.doc.content.size > 0) { // Empty LoroDoc + PM has content → seed LoroDoc from PM state updateLoroToPmState(state.doc, mapping, view.state, state.containerId); // Dispatch state update (no content deletion) view.dispatch(tr);} else if (innerDoc.size === 0) { // Empty LoroDoc + PM is empty → original behavior (delete, which is a no-op) view.dispatch(tr);} else { // LoroDoc has content → reconstruct PM from CRDT (original behavior)}This is applied automatically by pnpm install. If the patch fails to apply after a loro-prosemirror version bump,
the install will error — forcing explicit review of whether the upstream issue was fixed.
Layer 2: Seed plugin in LoroSyncExtension.ts (safety net)
File: apps/desktop/src-react/lib/loro-sync/LoroSyncExtension.ts
A ProseMirror plugin registered before LoroSyncPlugin that detects the empty-LoroDoc-with-content condition and
dispatches a "doc-changed" transaction via queueMicrotask:
Plugin order: [seedPlugin, LoroSyncPlugin, LoroUndoPlugin]Timing: seedPlugin.view() → queueMicrotask (microtask) → init() setTimeout(0) (macrotask)The microtask-before-macrotask ordering is guaranteed by the HTML spec. If the patch is active, the seed plugin becomes
a harmless no-op (LoroDoc is already populated by the time the microtask fires, so the rootMap.size === 0 check
fails).
When to remove
- Patch: Remove when
loro-prosemirrorships a version that handles the empty-LoroDoc-with-content case natively. Checkinit()for theview.state.doc.content.size > 0guard. - Seed plugin: Remove only after confirming the upstream fix is stable across at least one minor version. Until then, keep as zero-cost safety net.
Verification
Integration tests in apps/desktop/tests/specs/editor-loro.spec.ts → "Loro Imported Page Seeding" describe block:
- Content not wiped: Navigates to an imported page (content + no CRDT snapshot), waits 500ms past the
setTimeout(0)window, asserts content survives and placeholder is not visible. - Save produces CRDT snapshot: Edits imported page, saves, verifies both markdown text and LoroDoc blob are persisted — proving the LoroDoc was successfully seeded.
Key Constraints
- Backend cannot create PM-compatible LoroDoc:
text_to_loro_bytes()produces a flat-text LoroDoc with a “content” text container.loro-prosemirrorexpects a structured “doc” map with ProseMirror node/mark tree. The validation inloroHelpers.ts(loadLoroDoc) rejects flat-text snapshots viarootMap.size === 0check. - Frontend is the correct layer: Only the editor has both the ProseMirror schema and access to
updateLoroToPmState(which requiresLoroNodeMapping, managed internally byLoroSyncPlugin). updateLoroToPmStateis not independently callable: It requires aLoroNodeMappingthatLoroSyncPlugincreates and owns. Both the patch and the seed plugin work by triggering the library’s own code paths to populate the mapping correctly.
Infrastructure Naming Discipline Across Architecture Layers Next
LWW Metadata Sync: Timestamp Preservation and Cascade Semantics
Was this page helpful?
Thanks for your feedback!