Repository navigation
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
Description
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
- Two clients bind one document holding
zero zero zero/one one one/two two two/three three three. - A types
yyat the start oftwo two twoand commentsyy(one undo step). Sync. - B removes the comment. At the same time A undoes. Sync.
- 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
[lexical-y] Chore: Pin #9689's fully sequential two-comment shape
[lexical-y] Chore: Name #9689 and #9690 in the pinned cells' titles; …
[lexical-y] Chore: Fit the revert to #9688 and #9691: #9690 cells bec…
[lexical-y] Chore: Pin the open undo-restore cases (meta#9689, meta#9690