Skip to content

UndoManager: redo across separate undo steps loses text when LoroTree node is recreated with new TreeID #938

@hondrytravis

Description

@hondrytravis

// Minimal reproduction: LoroTree undo/redo loses text content when
// splitBlock-like operations span multiple undo steps.
//
// We are building a high-performance Block-Native rich text editor on top
// of Loro — similar in ambition to AFFiNE but with ProseMirror/CodeMirror 6
// level performance. We use LoroTree for document block structure and
// doc.getText('text_${treeNodeId}') for per-block text containers.
//
// A block editor's "splitBlock" is: deleteText + createNode + insertText,
// committed as one undo item. Subsequent typing is a separate undo item.
//
// Bug: redo of createNode generates NEW TreeID, but redo of insertText
// still targets OLD TreeID's text container. container_remap does not
// persist across separate redo() calls.
//
// Environment: loro-crdt ^1.10.6
// To run: npx vitest run undo-redo-tree-text-repro.test.ts

import { describe, it, expect } from 'vitest';
import { LoroDoc, UndoManager } from 'loro-crdt';

describe('LoroTree undo/redo: text lost after splitBlock + type + undo + redo', () => {
  it('two separate undo items: redo loses text on second item', () => {
    const doc = new LoroDoc();
    const undoMgr = new UndoManager(doc, { mergeInterval: 0 });
    const tree = doc.getTree('tree');

    // Setup: root + paragraph A with "Hello World" (excluded from undo)
    undoMgr.addExcludeOriginPrefix('setup');
    const root = tree.createNode();
    const paraA = root.createNode();
    const textA = doc.getText(`text_${paraA.id}`);
    textA.insert(0, 'Hello World');
    doc.commit({ origin: 'setup' });

    // --- Undo item 1: splitBlock at offset 5 ---
    textA.delete(5, 6);                              // delete " World"
    const paraB = root.createNode();                  // create new node
    const paraBId = paraB.id;
    const textB = doc.getText(`text_${paraBId}`);
    textB.insert(0, ' World');                        // move tail to new node
    doc.commit();

    expect(textA.toString()).toBe('Hello');
    expect(textB.toString()).toBe(' World');

    // --- Undo item 2: user types "你好" at start of paraB ---
    textB.insert(0, '你好');
    doc.commit();
    expect(textB.toString()).toBe('你好 World');

    // --- Undo x2 ---
    expect(undoMgr.undo()).toBe(true); // undo "你好"
    expect(undoMgr.undo()).toBe(true); // undo splitBlock
    expect(textA.toString()).toBe('Hello World');
    expect(paraB.isDeleted()).toBe(true);

    // --- Redo step 1: redo splitBlock ---
    // Creates a NEW TreeID (different from original paraBId)
    expect(undoMgr.redo()).toBe(true);

    const children = root.children()!;
    expect(children.length).toBe(2);
    const redoParaB = children[1];
    const redoParaBId = redoParaB.id;

    const sameId = JSON.stringify(paraBId) === JSON.stringify(redoParaBId);

    // --- Redo step 2: redo insertText ---
    expect(undoMgr.redo()).toBe(true);

    // --- Verify ---
    const visibleText = doc.getText(`text_${redoParaBId}`);

    if (!sameId) {
      const ghostText = doc.getText(`text_${paraBId}`);
      console.log('REPRODUCTION CONFIRMED:');
      console.log('  Original paraB TreeID:', JSON.stringify(paraBId));
      console.log('  Redo paraB TreeID:    ', JSON.stringify(redoParaBId));
      console.log('  Visible node text:    ', JSON.stringify(visibleText.toString()));
      console.log('  Ghost node text:      ', JSON.stringify(ghostText.toString()));
      console.log('');
      console.log('  Expected: visible node has "你好 World"');
      console.log('  Actual:   visible node is empty, text went to ghost node');
    }

    // EXPECTED: "你好 World" on the visible (redo-created) node
    // ACTUAL BUG: "" on visible node, "你好 World" on ghost (old TreeID) node
    expect(visibleText.toString()).toBe('你好 World');
  });
});

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