I hit this when trying to make a site that went between pages: Page A -> Page B -> Page A using TipTap+Loro instead of TipTap+Hocuspocus. When I went back to Page A, all of the content was wiped.
Basically, the problem looks like this:
- LoroDoc is populated with server content
- Editor state created with LoroSyncPlugin
- any
docChanged transaction before setTimeout(init,0) fires
appendTransaction creates doc-changed
apply calls updateLoroToPmState with the empty mapping from state.init()
- All loro content replaced with the default empty prosemirror state
I've added two things here:
- Tests proving this works
- The patch that I made to get around it - but you could just tell me that I'm doing something unbelievably stupid!
Test reproduction
/**
* Regression tests for LoroSyncPlugin pre-init content wipe.
*
* Bug: LoroSyncPlugin defers init() via setTimeout(0). Before init() fires,
* the plugin's mapping is empty (new Map()). If any docChanged transaction
* triggers appendTransaction before init(), the resulting "doc-changed"
* handler calls updateLoroToPmState() with the empty mapping against a
* populated LoroDoc. updateLoroMapChildren can't match PM nodes to Loro
* containers without the mapping, so it deletes all Loro content and
* replaces it with the (empty/default) ProseMirror state.
*
* Real-world trigger: editor frameworks like TipTap create a default empty
* document when the editor mounts. The LoroDoc already has server content
* (imported before the editor renders). A docChanged transaction before
* init() wipes the LoroDoc, and subscribeLocalUpdates sends the wipe to
* the server.
*
* @see https://github.com/loro-dev/loro-prosemirror/issues/XXX
*/
import { describe, expect, test } from "vitest";
import { LoroDoc } from "loro-crdt";
import { EditorState } from "prosemirror-state";
import {
ROOT_DOC_KEY,
createNodeFromLoroObj,
getLoroMapChildren,
updateLoroToPmState,
type LoroDocType,
type LoroNodeMapping,
} from "../src/lib";
import { LoroSyncPlugin } from "../src/sync-plugin";
import { loroSyncPluginKey } from "../src/sync-plugin-key";
import { schema } from "./schema";
import { insertLoroMap, insertLoroText, setupLoroMap } from "./utils";
/**
* Creates a LoroDoc with two paragraphs of text, mimicking server state
* that was persisted and will be loaded on a subsequent page visit.
*/
function populateLoroDoc(loroDoc: LoroDocType): void {
const rootMap = loroDoc.getMap(ROOT_DOC_KEY);
setupLoroMap(rootMap, ROOT_DOC_KEY);
const children = getLoroMapChildren(rootMap);
const p1 = insertLoroMap(children, "paragraph");
const p1Text = insertLoroText(getLoroMapChildren(p1 as any));
p1Text.insert(0, "First paragraph");
const p2 = insertLoroMap(children, "paragraph");
const p2Text = insertLoroText(getLoroMapChildren(p2 as any));
p2Text.insert(0, "Second paragraph");
loroDoc.commit();
}
/** Snapshot of the populated LoroDoc for assertions. */
const populatedLoroContent = {
[ROOT_DOC_KEY]: {
nodeName: ROOT_DOC_KEY,
attributes: {},
children: [
{
nodeName: "paragraph",
attributes: {},
children: ["First paragraph"],
},
{
nodeName: "paragraph",
attributes: {},
children: ["Second paragraph"],
},
],
},
};
/** A single empty paragraph — the default ProseMirror doc before sync. */
const emptyDocJson = {
type: "doc",
content: [{ type: "paragraph" }],
};
describe("updateLoroToPmState with stale mapping", () => {
test("empty mapping against populated LoroDoc wipes content", () => {
// Setup: LoroDoc with two paragraphs of server content
const loroDoc: LoroDocType = new LoroDoc();
populateLoroDoc(loroDoc);
expect(loroDoc.toJSON()).toEqual(populatedLoroContent);
// Simulate the editor's default empty state (one empty paragraph)
const emptyEditorState = EditorState.create({
schema,
doc: schema.nodeFromJSON(emptyDocJson),
});
// Call updateLoroToPmState with a FRESH empty mapping.
// This is what happens when the "doc-changed" handler fires before
// init() has populated the mapping from the LoroDoc.
const freshMapping: LoroNodeMapping = new Map();
updateLoroToPmState(loroDoc, freshMapping, emptyEditorState);
// Without mapping entries, updateLoroMapChildren can't match PM nodes
// to Loro containers. It treats the PM state as authoritative and
// replaces all Loro content with the empty paragraph.
//
// This is correct behavior for updateLoroToPmState — it syncs PM→Loro.
// The danger is when the PLUGIN calls it before init() has populated
// the mapping, which is what the next test suite demonstrates.
const children = getLoroMapChildren(loroDoc.getMap(ROOT_DOC_KEY));
expect(children.length).toBe(1); // Was 2, now wiped to 1
});
test("populated mapping preserves content on identical sync", () => {
// Setup: LoroDoc with content
const loroDoc: LoroDocType = new LoroDoc();
populateLoroDoc(loroDoc);
// Build a correct mapping by reading the LoroDoc (what init() does)
const mapping: LoroNodeMapping = new Map();
const rootMap = loroDoc.getMap(ROOT_DOC_KEY);
const node = createNodeFromLoroObj(schema, rootMap, mapping);
// Create an EditorState with the content from Loro (what init() produces)
const editorState = EditorState.create({
schema,
doc: node,
});
// With a correct mapping, syncing identical content is a no-op
updateLoroToPmState(loroDoc, mapping, editorState);
expect(loroDoc.toJSON()).toEqual(populatedLoroContent);
});
});
describe("LoroSyncPlugin pre-init race condition", () => {
test("docChanged transaction before init() wipes populated LoroDoc", () => {
// Setup: LoroDoc already has server content (loaded before editor mounts)
const loroDoc: LoroDocType = new LoroDoc();
populateLoroDoc(loroDoc);
expect(loroDoc.toJSON()).toEqual(populatedLoroContent);
// Create the plugin and an EditorState with a default empty document.
// This mirrors what happens when a framework creates the editor before
// LoroSyncPlugin.init() fires (deferred by setTimeout(0)).
//
// EditorState.create() calls plugin.state.init(), which sets:
// mapping: new Map() (empty — init() hasn't populated it yet)
const plugin = LoroSyncPlugin({ doc: loroDoc as any });
const state = EditorState.create({
schema,
doc: schema.nodeFromJSON(emptyDocJson),
plugins: [plugin],
});
// Simulate a transaction that changes the PM document before init().
// In real apps this can come from: extension onCreate hooks, default
// content setup, focus/selection changes that normalize the document, etc.
const tr = state.tr.insertText("x", 1);
const stateAfterEdit = state.apply(tr);
// appendTransaction detects the docChanged transaction and creates
// a "doc-changed" follow-up that will call updateLoroToPmState().
const docChangedTr = plugin.spec.appendTransaction!(
[tr],
state,
stateAfterEdit,
);
expect(docChangedTr).not.toBeNull();
expect(docChangedTr!.getMeta(loroSyncPluginKey)).toEqual({
type: "doc-changed",
});
// Applying the "doc-changed" transaction triggers the plugin's apply(),
// which calls updateLoroToPmState(doc, EMPTY_MAPPING, newEditorState).
stateAfterEdit.apply(docChangedTr!);
// The LoroDoc should still have its original content — the plugin
// should not write to the LoroDoc before init() has run.
//
// BUG: The LoroDoc is wiped to a single paragraph containing "x"
// because updateLoroToPmState was called with an empty mapping.
expect(loroDoc.toJSON()).toEqual(populatedLoroContent);
});
});
describe("LoroSyncPlugin appendTransaction filtering", () => {
test("should not create doc-changed follow-up for sync-internal transactions", () => {
const loroDoc: LoroDocType = new LoroDoc();
const plugin = LoroSyncPlugin({ doc: loroDoc as any });
const state = EditorState.create({
schema,
doc: schema.nodeFromJSON(emptyDocJson),
plugins: [plugin],
});
// Create a transaction that changes the doc AND is tagged with
// loroSyncPluginKey. This simulates init()'s "update-state" transaction
// which replaces the empty PM doc with content from the LoroDoc.
const syncInternalTr = state.tr
.insertText("content from Loro", 1)
.setMeta(loroSyncPluginKey, {
type: "update-state",
state: { mapping: new Map() },
});
const newState = state.apply(syncInternalTr);
// appendTransaction should NOT create a "doc-changed" follow-up for
// transactions that are already part of the sync flow (tagged with
// loroSyncPluginKey). These transactions originate from init() or
// updateNodeOnLoroEvent() and should not trigger a write-back to Loro.
//
// BUG: appendTransaction only checks tr.docChanged, not whether the
// transaction is already tagged. So init()'s "update-state" transaction
// triggers an unnecessary "doc-changed" → updateLoroToPmState() call.
const appended = plugin.spec.appendTransaction!(
[syncInternalTr],
state,
newState,
);
expect(appended).toBeNull();
});
});
Patch I've applied
I put this into a lore-prosemirror@0.4.3.patch to fix this on my side but I'm happy to also submit a PR if this looks good?
diff --git a/dist/index.js b/dist/index.js
index e41095d5b08a1fdebb48143beceb7fe93aa20565..ed24beac1eeb83d1971f8c499af156e96b6640f0 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -562,7 +562,8 @@ const LoroSyncPlugin = (props) => {
doc: props.doc,
mapping: props.mapping ?? /* @__PURE__ */ new Map(),
changedBy: "local",
- containerId: props.containerId
+ containerId: props.containerId,
+ initialized: false
};
},
apply: (tr, state, oldEditorState, newEditorState) => {
@@ -572,12 +573,13 @@ const LoroSyncPlugin = (props) => {
else state.changedBy = "local";
switch (meta?.type) {
case "doc-changed":
- if (!undoState?.isUndoing.current) updateLoroToPmState(state.doc, state.mapping, newEditorState, props.containerId);
+ if (state.initialized && !undoState?.isUndoing.current) updateLoroToPmState(state.doc, state.mapping, newEditorState, props.containerId);
break;
case "update-state":
state = {
...state,
- ...meta.state
+ ...meta.state,
+ initialized: true
};
state.doc.commit({
origin: "sys:init",
@@ -590,7 +592,7 @@ const LoroSyncPlugin = (props) => {
}
},
appendTransaction: (transactions, _oldEditorState, newEditorState) => {
- if (transactions.some((tr) => tr.docChanged)) return newEditorState.tr.setMeta(loroSyncPluginKey, { type: "doc-changed" });
+ if (transactions.some((tr) => tr.docChanged && !tr.getMeta(loroSyncPluginKey))) return newEditorState.tr.setMeta(loroSyncPluginKey, { type: "doc-changed" });
return null;
},
view: (view) => {
I hit this when trying to make a site that went between pages: Page A -> Page B -> Page A using TipTap+Loro instead of TipTap+Hocuspocus. When I went back to Page A, all of the content was wiped.
Basically, the problem looks like this:
docChangedtransaction beforesetTimeout(init,0)firesappendTransactioncreatesdoc-changedapplycallsupdateLoroToPmStatewith the empty mapping fromstate.init()I've added two things here:
Test reproduction
Patch I've applied
I put this into a
lore-prosemirror@0.4.3.patchto fix this on my side but I'm happy to also submit a PR if this looks good?