Skip to content
Documentation GitHub
Architecture

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 typePM contentLoroDocinit() behavior
New pageEmptyEmptyWipes nothing — invisible
Existing (with CRDT)From CRDTPopulatedReconstructs from CRDT — correct
ImportedHasEmptyWipes 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.jsonpnpm.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-prosemirror ships a version that handles the empty-LoroDoc-with-content case natively. Check init() for the view.state.doc.content.size > 0 guard.
  • 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:

  1. 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.
  2. 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-prosemirror expects a structured “doc” map with ProseMirror node/mark tree. The validation in loroHelpers.ts (loadLoroDoc) rejects flat-text snapshots via rootMap.size === 0 check.
  • Frontend is the correct layer: Only the editor has both the ProseMirror schema and access to updateLoroToPmState (which requires LoroNodeMapping, managed internally by LoroSyncPlugin).
  • updateLoroToPmState is not independently callable: It requires a LoroNodeMapping that LoroSyncPlugin creates and owns. Both the patch and the seed plugin work by triggering the library’s own code paths to populate the mapping correctly.

Was this page helpful?