Repository navigation
Description
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
zurfyx commentedon Nov 1, 2026
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:
aa bb cc dd ee ff, withaa bb ccbold (not on the undo stack).YZafteraa, boldaaYZ, type a plainYZafterbb(three undo steps).Expected:
aa bb ccbold, as before the steps. Actual:aa bbbold andccplain, though none of the undone steps touchedcc. 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`)
[lexical-y] Chore: Name #9689 and #9690 in the pinned cells' titles; …
[lexical-y] Chore: Pin the gc setting of two #9690 sessions; fix comm…
[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
zurfyx commentedon Nov 1, 2026
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
runOpenermakes 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: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-sizeon 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 runpnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/Repro2.test.ts.LEXICAL_Y_GC=1runs it with gc on.Address review: D99 condition is per table; pin D100, #9725 and the t…
Address review: test.fails wraps only the documented assertion; note #…