Skip to content

LoroSyncPlugin: content wipe when docChanged transaction fires before init() #77

Description

@mgrinshpon

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:

  1. LoroDoc is populated with server content
  2. Editor state created with LoroSyncPlugin
  3. any docChanged transaction before setTimeout(init,0) fires
  4. appendTransaction creates doc-changed
  5. apply calls updateLoroToPmState with the empty mapping from state.init()
  6. All loro content replaced with the default empty prosemirror state

I've added two things here:

  1. Tests proving this works
  2. 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) => {

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions