Skip to content

Bug: [lexical-y] A comment comes back over the rest of the document when a collaborator undoes removing it after its author undid the typing and comment together #9689

Open

Description

@zurfyx

Lexical version: main at 60022ae (@lexical/y, @y/y 14.0.0-rc.29). Also on cfcd3c4 and the 20261030 nightly (e28f4dd), with Yjs garbage collection on and off, both client id orders.

A types yy at the start of a paragraph and comments it (or bolds it) in one undo step. After a sync, B removes the comment while A undoes its step. After the next sync, B undoes its removal. Expected: the comment's text is gone, so nothing shows the comment. Actual: the comment covers the rest of that paragraph and every paragraph after it, on every client and in a fresh read of the document.

Case Expected Every client shows
A undoes while B removes, then B undoes two two two / three three three two two two[c1] / three three three[c1]
A types, then comments, in two undo steps; A undoes both while B removes; B undoes same same as actual above
bold instead of a comment two two two plain two two two bold to the end
control: B does not undo as expected as expected
control: comment on existing text only (#9623's base form) as expected as expected (fixed by #9679)

Format items (paragraph 3, gc off): A writes {c1=true A} "yy " {c1=null A}. B's removal deletes A's opener and writes nothing, relying on A's closer. A's undo, made before it sees the removal, deletes "yy " and its own closer (when A has already seen the removal it keeps the closer, so this exact sequence passes when fully sequential; other fully sequential sequences still fail, for example, syncing after every step: A comments a phrase (c1); B comments the same characters (c2); A types yy at the start of the phrase, which joins both comments; B undoes (c2 leaves the phrase but stays on yy); B removes c2; A undoes; B undoes its removal: c2 covers the rest of that paragraph and the next two). B's undo brings A's opener back as a copy of its own; the closer that ended the run is gone, and nothing between the copy and where that closer was is shown, so no end is written for it and the copy's run goes on to the end of the document (closeRestoredRuns writes the end of a restored run only when content between the copy and that end is shown).

This meets the "comments or formats spreading onto text nobody touched" bar. Related: #9623 (the same undo sequence with the comment on existing text, fixed by #9679).

Steps To Reproduce

  1. Two clients bind one document holding zero zero zero / one one one / two two two / three three three.
  2. A types yy at the start of two two two and comments yy (one undo step). Sync.
  3. B removes the comment. At the same time A undoes. Sync.
  4. B undoes. Sync.

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. All 4 cells fail on main.

Repro test (main at 60022ae: 4 of 4 fail)
// @vitest-environment node

import {
  buildEditorFromExtensions,
  getExtensionDependencyFromEditor,
} from '@lexical/extension';
import {
  $addMarkID,
  $getNodeMarkIDs,
  $removeMarkID,
  MarkStateExtension,
} from '@lexical/mark';
import {RichTextExtension} from '@lexical/rich-text';
import * as Y from '@y/y';
import {
  $createParagraphNode,
  $createRangeSelection,
  $createTextNode,
  $getRoot,
  $isElementNode,
  $isTextNode,
  $setSelection,
  configExtension,
  defineExtension,
  type LexicalEditor,
  type RangeSelection,
  UNDO_COMMAND,
} from 'lexical';
import {expect, test} from 'vitest';

import {createYDoc, YCollabExtension} from '../..';
import {seedYState} from '../../headless';

const CONFIG = {extensions: [RichTextExtension, MarkStateExtension], schemaVersion: 1};
const PARAS = ['zero zero zero', 'one one one', 'two two two', 'three three three'];

const $show = () =>
  $getRoot()
    .getChildren()
    .map(b =>
      $isElementNode(b)
        ? b
            .getChildren()
            .map(c => {
              const ids = $isTextNode(c) ? $getNodeMarkIDs(MarkStateExtension, c) : [];
              return `${c.getTextContent()}${ids.length ? `[${ids.join(',')}]` : ''}`;
            })
            .join('|')
        : '?',
    );

function client(seeded: Uint8Array, id: number, gc: boolean) {
  const doc = createYDoc(seeded, {gc});
  doc.clientID = id;
  const editor = buildEditorFromExtensions(
    defineExtension({
      dependencies: [
        RichTextExtension,
        MarkStateExtension,
        configExtension(YCollabExtension, {captureTimeout: 2 ** 30, doc, schemaVersion: 1, synced: true}),
      ],
      name: `repro/${id}`,
    }),
  );
  const {binding} = getExtensionDependencyFromEditor(editor, YCollabExtension).output;
  return {doc, editor, step: () => binding.undoManager?.stopCapturing()};
}

function onRange(editor: LexicalEditor, a: number, b: number, f: (s: RangeSelection) => void) {
  editor.update(
    () => {
      const t = ($getRoot().getChildAtIndex(2) as ReturnType<typeof $createParagraphNode>).getFirstChild();
      if (!$isTextNode(t)) throw new Error('no text');
      const s = $createRangeSelection();
      s.anchor.set(t.getKey(), a, 'text');
      s.focus.set(t.getKey(), b, 'text');
      $setSelection(s);
      f(s);
      $setSelection(null);
    },
    {discrete: true},
  );
}

for (const gc of [false, true]) {
  for (const [idA, idB] of [[1, 2], [2, 1]]) {
    test(`the comment stays off the paragraphs after it (gc ${gc ? 'on' : 'off'}, ids ${idA}, ${idB})`, () => {
      const seeded = seedYState(CONFIG, () => {
        $getRoot().clear().append(...PARAS.map(t => $createParagraphNode().append($createTextNode(t))));
      });
      const A = client(seeded, idA, gc);
      const B = client(seeded, idB, gc);
      const sync = () => {
        Y.applyUpdate(B.doc, Y.encodeStateAsUpdate(A.doc, Y.encodeStateVector(B.doc)));
        Y.applyUpdate(A.doc, Y.encodeStateAsUpdate(B.doc, Y.encodeStateVector(A.doc)));
      };
      onRange(A.editor, 0, 0, s => {
        s.format = 0;
        s.insertText('yy ');
      });
      onRange(A.editor, 0, 3, s => $addMarkID('c1', s));
      A.step();
      sync();
      B.editor.update(() => $removeMarkID('c1'), {discrete: true});
      B.step();
      A.editor.dispatchCommand(UNDO_COMMAND, undefined); // before A sees B's removal
      sync();
      B.editor.dispatchCommand(UNDO_COMMAND, undefined);
      sync();
      expect(A.editor.getEditorState().read($show)).toEqual(PARAS);
      expect(B.editor.getEditorState().read($show)).toEqual(PARAS);
    });
  }
}

The current behavior

two two two[c1] / three three three[c1] on both clients.

The expected behavior

The document as seeded: the comment's text is gone.

Impact of fix

A comment (or format) appears on text nobody commented, across the rest of the document, after an ordinary undo of a resolve.

Activity

  1. added 3 commits that reference this issue on Nov 1, 2026
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