Skip to content

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

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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions