Repository navigation
Bug: [lexical-y] Undoing a format takes it off a word a collaborator later formatted the same way over a wider range #9675
Description
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
- Two clients bind one document (
createYDoc,RichTextExtension) holding two paragraphs:alpha beta gamma/delta epsilon. - Client B bolds
beta. Sync. - Client C bolds
alpha beta gamma/delta(a range that containsbeta). Sync. Both clients showalpha beta gammaanddeltabold. - 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