Skip to content

Bug: [lexical-y] Undoing a collaborator's format steps around text you typed can spread or drop a format, in rare cases the #9671 and #9599 rules introduced #9690

Open

Description

@zurfyx

Lexical version: main at 60022ae (@lexical/y, @y/y 14.0.0-rc.29). Not on cfcd3c4 (main before #9679) or the 20261030 nightly. Garbage collection on and off unless noted.

#9679 added three rules that fix #9671 and the #9599 family: a format over a range that starts at the writer's own earlier insert null removes that null (as Yjs's format does); a format whose range ends at the writer's own earlier insert restore removes that restore and joins the runs; and a restore's scan for the run it restates stops at another client's text (D78). Each fixes the cases it was written for, and each makes a rare other case wrong that main before #9679 got right:

Rule Sequence (B's steps are separate undo steps) Expected Actual on main
restore removed at the range end abc(i) def ghi, ids [2, 1]: B italicizes ef g, types Z after e, italicizes Z through gh; sync; A types a plain 1 after Z; B undoes three times abc(i) def ghi hi italic
restore scan stops at another client's text abc(s) def(i,s) ghi jkl(b,s) mno(s), ids [2, 1]: B types Z after ghi; sync; B removes strikethrough from Zjkl m; A types a plain 1 after l at the same time; B undoes twice mno struck mno loses strikethrough
null removed at the range start 3 clients; a collaborator types plain text inside a bold run while another deletes around it; after a sync the first bolds the line and undoes it while a third types inside (test below) the line after stays plain the next paragraph is bold (gc off; with gc on this reduced script also fails before #9679)

Measured on fresh seeds (gc off and on): a three-client view property, 2 seeds of 15,000 per setting where main is wrong and main before #9679 right; a raw-Yjs-oracle op-list generator, 5 of 36,000 runs; a long-session soak, 1 session of 20,600 (25 the other way). The same rules fix 24 #9671 cells, 5 #9599 cells and 7 of 1,000 run-sequence seeds. No narrower rule keeps those gains without these cases: the writer cannot tell, from what is stored, whether a collaborator's lone null ends a format range or starts a removal. Recorded as an open trade in DECISIONS.md (D95).

Steps To Reproduce

Link to code example: the tests below. Save the first 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 (4 of 4 fail on main, 0 on cfcd3c4). The second uses the package's test harness (packages/lexical-y/src/__tests__/utils/harness.ts); save it next to the first.

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

// Three short sequences in which a format ends up on text nobody formatted.
// Each passes on cfcd3c40 (main before #9679) and fails on main, gc on and off.
// B makes its steps (each its own undo step), A types a plain character, B
// undoes everything. Expected: every original letter keeps exactly its
// original formats, A's character is plain, B's "Z" is gone.

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

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

const CONFIG = {extensions: [RichTextExtension], schemaVersion: 1};
type F = 'b' | 'i' | 's';
const FMT: Record<F, TextFormatType> = {b: 'bold', i: 'italic', s: 'strikethrough'};
type Op =
  | {k: 'fmt'; i: number; j: number; key: F; v: boolean}
  | {k: 'Z'; i: number}
  | {k: 'A'; i: number; ch: string}
  | {k: 'sync'}
  | {k: 'undo'};

function $texts(): TextNode[] {
  const p = $getRoot().getFirstChildOrThrow();
  return $isElementNode(p) ? p.getChildren().filter($isTextNode) : [];
}
function $at(i: number): [TextNode, number] {
  let acc = 0;
  const ts = $texts();
  for (const t of ts) {
    if (i <= acc + t.getTextContentSize()) return [t, i - acc];
    acc += t.getTextContentSize();
  }
  const last = ts[ts.length - 1];
  return [last, last.getTextContentSize()];
}
function $select(i: number, j: number) {
  const s = $createRangeSelection();
  const [a, ao] = $at(i);
  const [b, bo] = $at(j);
  s.anchor.set(a.getKey(), ao, 'text');
  s.focus.set(b.getKey(), bo, 'text');
  $setSelection(s);
  return s;
}
function $typePlain(i: number, text: string) {
  const s = $select(i, i);
  s.format = 0;
  s.insertText(text);
  $setSelection(null);
}
function $setRange(i: number, j: number, key: F, v: boolean) {
  for (const n of $select(i, j).extract()) {
    if ($isTextNode(n) && n.hasFormat(FMT[key]) !== v) n.toggleFormat(FMT[key]);
  }
  $setSelection(null);
}
const $chars = () =>
  $texts().flatMap(t => [...t.getTextContent()].map(c => c + (['b', 'i', 's'] as F[]).filter(k => t.hasFormat(FMT[k])).join('')));

function run(words: [string, F[]][], ids: [number, number], gc: boolean, ops: Op[]) {
  const seeded = seedYState(CONFIG, () => {
    const p = $createParagraphNode();
    for (const [w, fs] of words) {
      const t = $createTextNode(w);
      for (const f of fs) t.toggleFormat(FMT[f]);
      p.append(t);
    }
    $getRoot().clear().append(p);
  });
  const [A, B] = ids.map(id => {
    const doc = createYDoc(seeded, {gc});
    doc.clientID = id;
    const editor = buildEditorFromExtensions(
      defineExtension({
        dependencies: [RichTextExtension, configExtension(YCollabExtension, {captureTimeout: 2 ** 30, doc, schemaVersion: 1, synced: true})],
        name: `repro/${id}`,
      }),
    );
    const {binding} = getExtensionDependencyFromEditor(editor, YCollabExtension).output;
    return {binding, doc, editor};
  });
  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)));
  };
  for (const op of ops) {
    if (op.k === 'sync') sync();
    else if (op.k === 'undo') B.editor.dispatchCommand(UNDO_COMMAND, undefined);
    else if (op.k === 'A') A.editor.update(() => $typePlain(op.i, op.ch), {discrete: true});
    else {
      B.editor.update(() => (op.k === 'Z' ? $typePlain(op.i, 'Z') : $setRange(op.i, op.j, op.key, op.v)), {discrete: true});
      B.binding.undoManager?.stopCapturing();
    }
  }
  sync();
  const a = A.editor.getEditorState().read($chars);
  expect(B.editor.getEditorState().read($chars)).toEqual(a);
  return a.filter(c => !/^[0-9]/.test(c)).join(' ');
}

const CASES: {name: string; words: [string, F[]][]; ids: [number, number]; ops: Op[]}[] = [
  {
    // italic over "ef", Z typed inside, italic over "Z" and the rest; A types 1
    ids: [2, 1],
    name: 'restore of own earlier insert removed (#9671 rule, restore side)',
    ops: [{i: 4, j: 9, k: 'fmt', key: 'i', v: true}, {i: 7, k: 'Z'}, {i: 7, j: 11, k: 'fmt', key: 'i', v: true}, {k: 'sync'}, {ch: '1', i: 8, k: 'A'}, {k: 'undo'}, {k: 'undo'}, {k: 'undo'}],
    words: [['abc', ['i']], ['def', []], ['ghi', []]],
  },
  {
    // Z typed into the struck text, then strikethrough removed over it; A types 1
    ids: [2, 1],
    name: 'restore scan stops at another client\'s text (#9599 rule)',
    ops: [{i: 9, k: 'Z'}, {k: 'sync'}, {i: 9, j: 15, k: 'fmt', key: 's', v: false}, {ch: '1', i: 12, k: 'A'}, {k: 'undo'}, {k: 'undo'}],
    words: [['abc', ['s']], ['def', ['i', 's']], ['ghi', []], ['jkl', ['b', 's']], ['mno', ['s']]],
  },
];

for (const c of CASES) {
  for (const gc of [false, true]) {
    test(`${c.name} (gc ${gc ? 'on' : 'off'}, ids ${c.ids})`, () => {
      const orig = c.words.flatMap(([w, fs]) => [...w].map(ch => ch + (['b', 'i', 's'] as F[]).filter(k => fs.includes(k)).join(''))).join(' ');
      expect(run(c.words, c.ids, gc, c.ops)).toBe(orig);
    });
  }
}
Repro test, three clients (main at 60022ae: fails with gc off)
// @vitest-environment node
import {$addMarkID, $getNodeMarkIDs, $removeMarkID} from '@lexical/mark';
import {$createParagraphNode, $createTextNode, $getRoot, type LexicalNode, type TextNode} from 'lexical';
import {expect, test} from 'vitest';

import {$block, $selectIn, expectConverged, setup} from '../utils/harness';
import {prepareMarkState as prepare} from '../utils/sizeOps';

void $removeMarkID;
const words = [["w0ab", "w1abc", "w2abcd", "w3a", "w4a", "w5ab", "w6abc"], ["w7ab", "w8abc", "w9ab", "w10abc"]];
const preFmt = [[0, 0, 0, 0, 8, 0, 0], [1, 2, 0, 0]];
function $plain(l: number, off: number, text: string) {
  for (const node of $block([l]).getAllTextNodes() as TextNode[]) {
    const size = node.getTextContentSize();
    if (off > 0 && off < size) {
      const [, tail] = node.splitText(off);
      tail.insertBefore($createTextNode(text));
      return;
    }
    if (off === size) {
      node.insertAfter($createTextNode(text));
      return;
    }
    off -= size;
  }
}

test("the sentinel line stays plain", async () => {
  const t = await setup(
    () => {
      words.forEach((ws, l) => {
        const p = $createParagraphNode();
        ws.forEach((w, i) => p.append($createTextNode(w + (i < ws.length - 1 ? ' ' : '')).setFormat(preFmt[l][i])));
        $getRoot().append(p);
      });
      $getRoot().append($createParagraphNode().append($createTextNode('tail sentinel line')));
    },
    {ids: [3, 1, 2], prepare},
  );
  for (const p of t.peers) p.binding.undoManager?.clear();
  t.offline();
  t.peers[1].update(() => $plain(1, 2, "xq17"));
  t.peers[0].update(() => void $selectIn([1], 0, 12).removeText());
  await t.sync();
  t.peers[1].update(() => void $selectIn([1], 0, 13).formatText("bold"));
  t.offline();
  t.peers[1].undo();
  t.peers[2].update(() => void $selectIn([1], 8).insertText("k18"));
  await t.sync();
  await expectConverged(t.peers);
  const sentinel = t.peers[0].read(() =>
    ($block([2]).getAllTextNodes() as TextNode[]).map(n => [n.getFormat(), $getNodeMarkIDs(n as LexicalNode).join(',')]),
  );
  expect(sentinel).toEqual([[0, '']]);
});

The current behavior

See the table: a format spreads to (hi, the next paragraph) or is dropped from (mno) text the undone steps never touched.

The expected behavior

Undoing B's steps returns every original letter to its formats; A's typed text stays plain.

Impact of fix

Rare (a few in tens of thousands of random sessions); recorded as a trade against the #9671 and #9599 fixes until stored provenance can tell the two kinds of null apart.

Activity

  1. zurfyx commented on Nov 1, 2026

    @zurfyx
    MemberAuthor

    The restore-side rule (a format whose range ends at the writer's own earlier insert restore removes that restore and joins the runs) also changes a single client's own undo, with no collaborator acting. Passes on cfcd3c4 (main before #9679), fails on main at 60022ae, gc on and off, with one, two (the other only receiving) and both id orders:

    1. aa bb cc dd ee ff, with aa bb cc bold (not on the undo stack).
    2. Type a plain YZ after aa, bold aaYZ, type a plain YZ after bb (three undo steps).
    3. Undo three times.

    Expected: aa bb cc bold, as before the steps. Actual: aa bb bold and cc plain, though none of the undone steps touched cc. Disabling only that rule makes it pass. It came from a sequential undo fuzz (2 of 3,000 fresh seeds with two clients where main is wrong and cfcd3c4 right; 0 the other way, gc off).

    Repro test (uses the package's test harness, `packages/lexical-y/src/__tests__/utils/harness.ts`)
    // @vitest-environment node
    import {$createParagraphNode, $createTextNode, $getRoot, $isTextNode} from 'lexical';
    import {expect, test} from 'vitest';
    
    import {$selectIn, expectConverged, setup} from '../utils/harness';
    
    for (const ids of [[1], [1, 2], [2, 1]]) {
      test(`one client undoing its own steps keeps "cc" bold (ids ${ids})`, async () => {
        const t = await setup(() => $getRoot().append($createParagraphNode().append($createTextNode('aa bb cc dd ee ff'))), {ids});
        const $bold = (from: number, to: number) => {
          for (const n of $selectIn([0], from, to).extract()) if ($isTextNode(n) && !n.hasFormat('bold')) n.toggleFormat('bold');
        };
        const $typePlain = (at: number, text: string) => {
          const s = $selectIn([0], at);
          s.format = 0;
          s.insertText(text);
        };
        t.a.update(() => $bold(0, 8));
        await t.sync();
        for (const p of t.peers) p.binding.undoManager?.clear();
        t.a.update(() => $typePlain(2, 'YZ'));
        t.a.update(() => $bold(0, 4));
        t.a.update(() => $typePlain(7, 'YZ'));
        for (let k = 0; k < 3; k++) t.a.undo();
        await t.sync();
        await expectConverged(t.peers);
        expect(t.a.describe()).toBe('paragraph("aa bb cc"{1} " dd ee ff")');
      });
    }
  2. added 3 commits that reference this issue on Nov 1, 2026
  3. zurfyx commented on Nov 1, 2026

    @zurfyx
    MemberAuthor

    Another case of the third rule (a restore's scan stops at another client's text, D78), this time across paragraphs: a color comes back over the rest of the paragraph and onto the next one, which nobody touched. Main at 72b28f4 (also c750757), gc on and off, all six client id orders. Passes on cfcd3c4 (main before #9679), the 20261030 nightly and #9678 alone. Disabling only that rule's condition in runOpener makes it pass; the other two rules (the null and the restore removal) and the other changes of #9679 have no effect on it.

    Three clients, a document of two lines of words and a third line tail sentinel line:

    1. Client 1 colors part of line 1 red (offsets 9 to 18).
    2. Offline: client 1 types plain text inside its red run while client 2 deletes the start of line 1 (offsets 0 to 12). Sync.
    3. Client 2 types in line 1.
    4. Offline: client 1 undoes twice while client 2 types in line 1. Sync.

    Expected: the third line stays plain. Actual on main: the rest of line 1 and the whole third line are red (color: red;), on every client and in a fresh read of the document.

    Rate, fresh seeds of the three-client view property described above, gc off and on: on 60,000 seeds, 2 where main is wrong and main before #9679 is right, both from this rule (the other one sets font-size on the third line the same way); on another 15,000 seeds, this rule is what makes 2 seeds right that main before #9679 got wrong. So far it trades about one for one in this generator.

    Repro test (main at 72b28f4: 6 of 6 fail; cfcd3c4 and the nightly: 0 of 6)

    Save as packages/lexical-y/src/__tests__/unit/Repro2.test.ts (it uses the package's test harness) and run pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/Repro2.test.ts. LEXICAL_Y_GC=1 runs it with gc on.

    // @vitest-environment node
    
    // A color undone by its author while collaborators type and delete around it
    // comes back over the rest of the paragraph and onto the next paragraph.
    
    import {$getNodeMarkIDs} from '@lexical/mark';
    import {$patchStyleText} from '@lexical/selection';
    import {
      $createParagraphNode,
      $createTextNode,
      $getRoot,
      type LexicalNode,
      type TextNode,
    } from 'lexical';
    import {expect, test} from 'vitest';
    
    import {$block, $selectIn, expectConverged, setup} from '../utils/harness';
    import {prepareMarkState as prepare} from '../utils/sizeOps';
    
    const words = [
      ['w0abc', 'w1a', 'w2abcd', 'w3a', 'w4a', 'w5abc', 'w6ab'],
      ['w7a', 'w8a', 'w9a', 'w10ab', 'w11a', 'w12ab', 'w13ab', 'w14abcd'],
    ];
    // format bits per word (1 bold, 2 italic, 8 underline)
    const preFmt = [
      [0, 1, 2, 0, 0, 1, 0],
      [2, 2, 0, 0, 8, 0, 0, 0],
    ];
    
    // Plain text at offset `off` of line `l`, in a text node of its own.
    function $plain(l: number, off: number, text: string) {
      for (const node of $block([l]).getAllTextNodes() as TextNode[]) {
        const size = node.getTextContentSize();
        if (off > 0 && off < size) {
          const [, tail] = node.splitText(off);
          tail.insertBefore($createTextNode(text));
          return;
        }
        if (off === size) {
          node.insertAfter($createTextNode(text));
          return;
        }
        off -= size;
      }
    }
    
    for (const ids of [
      [1, 2, 3],
      [1, 3, 2],
      [2, 1, 3],
      [2, 3, 1],
      [3, 1, 2],
      [3, 2, 1],
    ]) {
      test(`an undone color stays off the next paragraph (ids ${ids})`, async () => {
        const t = await setup(
          () => {
            words.forEach((ws, l) => {
              const p = $createParagraphNode();
              ws.forEach((w, i) =>
                p.append(
                  $createTextNode(w + (i < ws.length - 1 ? ' ' : '')).setFormat(
                    preFmt[l][i],
                  ),
                ),
              );
              $getRoot().append(p);
            });
            $getRoot().append(
              $createParagraphNode().append($createTextNode('tail sentinel line')),
            );
          },
          {ids, prepare},
        );
        for (const p of t.peers) p.binding.undoManager?.clear();
        t.peers[0].update(
          () => void $patchStyleText($selectIn([1], 9, 18), {color: 'red'}),
        );
        t.offline();
        t.peers[0].update(() => $plain(1, 11, 'X17'));
        t.peers[1].update(() => void $selectIn([1], 0, 12).removeText());
        await t.sync();
        t.peers[1].update(() => $plain(1, 3, ' yz19'));
        t.offline();
        t.peers[0].undo();
        t.peers[0].undo();
        t.peers[1].update(() => $plain(1, 14, ' yz20'));
        await t.sync();
        await expectConverged(t.peers);
        const third = t.peers[0].read(() =>
          ($block([2]).getAllTextNodes() as TextNode[]).map(n => [
            n.getFormat(),
            $getNodeMarkIDs(n as LexicalNode).join(','),
            n.getStyle(),
          ]),
        );
        // Actual on main: [[0, '', 'color: red;']]
        expect(third).toEqual([[0, '', '']]);
      });
    }
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