Skip to content

Bug: [lexical-y] Undoing a format takes it off a word a collaborator later formatted the same way over a wider range #9675

Open

Description

@zurfyx

Lexical version: main at cfcd3c4 (@lexical/y, @y/y 14.0.0-rc.29). Also on the no-collapse branch zurfyx/y-no-collapse at d9a0223, with Yjs garbage collection on and off.

Steps To Reproduce

  1. Two clients bind one document (createYDoc, RichTextExtension) holding two paragraphs: alpha beta gamma / delta epsilon.
  2. Client B bolds beta. Sync.
  3. Client C bolds alpha beta gamma / delta (a range that contains beta). Sync. Both clients show alpha beta gamma and delta bold.
  4. B undoes (its bold). Sync.

Nothing happens concurrently: the clients sync after every step.

The same happens with italic, with a comment (MarkStateExtension's markIds, B and C adding the same id) and with a color ($patchStyleText), for both client id orders.

Link to code example: the test below. Save it as packages/lexical-y/src/__tests__/unit/Repro.test.ts and run pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/Repro.test.ts. Both id orders fail on main.

Repro test (fails on main at cfcd3c4, ids [1, 2] and [2, 1])
/**
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 */

// @vitest-environment node

import {buildEditorFromExtensions} from '@lexical/extension';
import {RichTextExtension} from '@lexical/rich-text';
import {createYDoc, YCollabExtension} from '@lexical/y';
import {seedYState} from '@lexical/y/headless';
import * as Y from '@y/y';
import {
  $createParagraphNode,
  $createRangeSelection,
  $createTextNode,
  $getRoot,
  $setSelection,
  configExtension,
  defineExtension,
  type LexicalEditor,
  type TextNode,
  UNDO_COMMAND,
} from 'lexical';
import {expect, test} from 'vitest';

const CONFIG = {extensions: [RichTextExtension], schemaVersion: 1};

function client(seed: Uint8Array, id: number) {
  const doc = createYDoc();
  doc.clientID = id;
  Y.applyUpdate(doc, seed);
  const editor = buildEditorFromExtensions(
    defineExtension({
      dependencies: [
        RichTextExtension,
        configExtension(YCollabExtension, {doc, schemaVersion: 1, synced: true}),
      ],
      name: `repro/${id}`,
    }),
  );
  return {doc, editor};
}

// Bold the characters [from, to) of line `line0` up to [.., to) of line `line1`.
function bold(editor: LexicalEditor, line0: number, from: number, line1: number, to: number) {
  editor.update(
    () => {
      const a = $getRoot().getChildAtIndex(line0)!.getFirstDescendant() as TextNode;
      const b = $getRoot().getChildAtIndex(line1)!.getLastDescendant() as TextNode;
      const s = $createRangeSelection();
      s.anchor.set(a.getKey(), from, 'text');
      s.focus.set(b.getKey(), to - (b.getPreviousSiblings().reduce((n, x) => n + x.getTextContentSize(), 0)), 'text');
      s.format = a.getFormat();
      s.formatText('bold');
      $setSelection(null);
    },
    {discrete: true},
  );
}

const show = (editor: LexicalEditor) =>
  editor.getEditorState().read(() =>
    ($getRoot().getAllTextNodes() as TextNode[]).map(
      (n) => `${n.getTextContent()}${n.hasFormat('bold') ? '[b]' : ''}`,
    ),
  );

for (const [idB, idC] of [
  [1, 2],
  [2, 1],
]) {
  test(`undoing a bold keeps a collaborator's later bold over the same word (ids ${idB}, ${idC})`, () => {
    const seed = seedYState(CONFIG, () => {
      $getRoot()
        .clear()
        .append(
          $createParagraphNode().append($createTextNode('alpha beta gamma')),
          $createParagraphNode().append($createTextNode('delta epsilon')),
        );
    });
    const B = client(seed, idB);
    const C = client(seed, idC);
    const sync = () => {
      Y.applyUpdate(C.doc, Y.encodeStateAsUpdate(B.doc, Y.encodeStateVector(C.doc)));
      Y.applyUpdate(B.doc, Y.encodeStateAsUpdate(C.doc, Y.encodeStateVector(B.doc)));
    };
    bold(B.editor, 0, 6, 0, 10); // B bolds "beta"
    sync();
    bold(C.editor, 0, 0, 1, 5); // C bolds "alpha beta gamma" / "delta"
    sync();
    expect(show(B.editor)).toEqual(['alpha beta gamma[b]', 'delta[b]', ' epsilon']);
    B.editor.dispatchCommand(UNDO_COMMAND, undefined); // B undoes its bold
    sync();
    expect(show(C.editor)).toEqual(show(B.editor));
    // C bolded "beta" too: it stays bold.
    expect(show(B.editor)).toEqual(['alpha beta gamma[b]', 'delta[b]', ' epsilon']);
  });
}

The current behavior

After B's undo, beta is plain on both clients and in a fresh read of the document, inside C's bold run: alpha [b] beta gamma[b] / delta[b] epsilon. Both id orders, main and the no-collapse branch, Yjs GC on and off.

The expected behavior

beta stays bold: C bolded it after B did, and B's undo should not revert C's change. packages/lexical-y/DECISIONS.md (D25, line 59 at cfcd3c4) states that collaborative undo "must not revert other people's work".

Control with plain Yjs (@y/y 14.0.0-rc.29, one Y.Node per client, an UndoManager per client, remote updates applied with an untracked origin): B format(6, 4, {bold: true}), sync, C format(0, 22, {bold: true}) over alpha beta gamma\ndelta epsilon, sync, B undo(), sync. Result for both id orders: alpha beta gamma\ndelta bold, epsilon plain. Yjs's format deletes B's opener and closer inside C's range and writes its own, so B's undo has nothing left to remove.

In @lexical/y, C's change writes format items only for the text nodes whose format changed (alpha , gamma, delta); beta, already bold, keeps B's items, and C's run relies on them. B's undo deletes its own items, and beta loses the bold C gave it.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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