Repository navigation
Bug: [lexical-y] Two collaborators bolding overlapping ranges at the same time leave part of the text plain #9626
Description
Lexical version: main at 4421e5b (@lexical/y), and the 20261030 nightly (0.53.1-nightly.20261030.0, e28f4dd).
When two collaborators apply the same format to overlapping ranges of a line at the same time, part of the text ends up without the format, on every client. Both users applied it, and nobody removed it.
Relation to the documented limits. The Limits section of docs/collaboration/y.md ("Other races") says: "Bold ‖ bold on adjacent ranges of one line can leave part unformatted." The ranges here overlap (one inside the other, or sharing words), which that sentence does not cover, and the decision register (packages/lexical-y/DECISIONS.md) has no row for it. It is not rare: in a randomized run with 2 to 3 clients each applying 1 to 2 random formats or comments to whole-word ranges of one line, 108 of 1,000 sessions lost a format where two clients' ranges of the same format overlapped (1 of 1,000 where they were only adjacent). Comments were never lost (their ids are separate keys).
Mechanism. The same edits on a plain @y/y text (format cleanup off) lose the bold the same way, so this comes from how Yjs stores formats as begin/end items (the class of yjs/yjs#382). @lexical/y writes its own format items, so a binding-side rule may be able to avoid it.
Links lose part of their range the same way. A links t2z t3z t4z t5z while B links t3z (to another URL, or the same one): every client shows t1z t2z t3z t4z t5z t6z: t4z t5z lose A's link. The same for A t2z..t5z ‖ B t3z..t4z, and A t2z..t3z ‖ B t3z..t5z, in both client id orders (the second test below). A link a collaborator applies inside your link at the same time should only change the text it covers.
Removals too. The same happens when two collaborators remove a format, or a comment, from overlapping ranges at once: on t1z t2z t3z t4z t5z, all bold, A removes bold from t2z while B removes it from t2z t3z t4z: every client shows t2z plain and t3z t4z bold again (B's removal is lost past A's word); the same with a comment ($removeMarkID('c1', …): resolving a comment on part of its text while a collaborator resolves it on more leaves it on the rest), and with A t3z ‖ B t2z..t4z or A t2z..t3z ‖ B t3z..t4z, in both id orders. A plain @y/y text with the same format(…, {bold: null}) calls gives the same result. Repro cells: unbold by splitting the selection (selection.extract(), then setFormat(format & ~1) on each text node) or $removeMarkID, then sync.
Styles and text NodeState too. The nested shape (A t2z..t5z ‖ B t3z..t4z) with $patchStyleText(selection, {color: 'red'}) on both clients, or with both setting the same value of a text NodeState key, loses the style or the state on part of A's range the same way (4/4 cells, both id orders).
Steps To Reproduce
- Two clients bind one document holding one paragraph
t1z t2z t3z t4z t5z t6z. - While offline from each other: client A bolds
t2z t3z t4z t5z; client B boldst3z t4z. - The clients 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. On main every Lexical case (formats and links) fails in both client id orders, and the plain Yjs control passes (it shows the same loss).
Repro test (main at 4421e5b and the 20261030 nightly: 8 format cases and 8 link cases fail)
/**
* 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.
*
*/
import {$toggleLink, LinkNode} from '@lexical/link';
import * as Y from '@y/y';
import {$createParagraphNode, $createTextNode, $getRoot, $setSelection} from 'lexical';
import {describe, expect, test} from 'vitest';
import {$selectIn, expectConverged, setup} from '../utils/harness';
// "t1z t2z t3z t4z t5z t6z": t2z 4..7, t3z 8..11, t4z 12..15, t5z 16..19
const CASES: [string, [number, number, string][], [number, number, string][]][] = [
['A bolds t2z-t5z, B bolds t3z-t4z', [[4, 19, 'bold']], [[8, 15, 'bold']]],
['A bolds t2z-t5z, B bolds t2z-t4z', [[4, 19, 'bold']], [[4, 15, 'bold']]],
['A bolds t2z-t3z, B bolds t3z-t5z', [[4, 11, 'bold']], [[8, 19, 'bold']]],
['A bolds t2z-t5z, B bolds t2z-t4z and italicizes t3z-t5z', [[4, 19, 'bold']], [[4, 15, 'bold'], [8, 19, 'italic']]],
];
describe('two collaborators bold overlapping ranges at the same time', () => {
for (const ids of [
[1, 2],
[2, 1],
]) {
for (const [name, aOps, bOps] of CASES) {
test(`${name} (ids ${ids})`, async () => {
const t = await setup(() => {
$getRoot().append($createParagraphNode().append($createTextNode('t1z t2z t3z t4z t5z t6z')));
}, {ids});
t.offline();
for (const [peer, ops] of [
[t.a, aOps],
[t.b, bOps],
] as const) {
for (const [start, end, format] of ops) {
peer.update(() => {
$selectIn([0], start, end).formatText(format as 'bold');
$setSelection(null);
});
}
}
await t.sync();
await expectConverged(t.peers);
// Every character a bold covered is bold.
const covered = new Set<number>();
for (const [start, end, format] of [...aOps, ...bOps]) {
if (format === 'bold') {
for (let i = start; i < end; i++) {
covered.add(i);
}
}
}
const notBold = t.a.read(() => {
const out: number[] = [];
let offset = 0;
for (const node of $getRoot().getAllTextNodes()) {
for (let i = 0; i < node.getTextContentSize(); i++) {
if (covered.has(offset + i) && !node.hasFormat('bold')) {
out.push(offset + i);
}
}
offset += node.getTextContentSize();
}
return out;
});
expect({line: t.a.describe(), notBold}).toEqual({line: t.a.describe(), notBold: []});
});
}
}
test('control: the same formats on a plain Yjs text lose the bold too', () => {
for (const [ida, idb] of [
[1, 2],
[2, 1],
]) {
const make = (id: number) => {
const doc = new Y.Doc();
doc.clientID = id;
(doc as unknown as {cleanupFormatting: boolean}).cleanupFormatting = false;
return doc;
};
const a = make(ida);
const b = make(idb);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const text = (doc: Y.Doc) => doc.get('t') as any;
text(a).insert(0, 't1z t2z t3z t4z t5z t6z');
Y.applyUpdate(b, Y.encodeStateAsUpdate(a), 'remote');
text(a).format(4, 15, {bold: true});
text(b).format(4, 11, {bold: true});
text(b).format(8, 11, {italic: true});
Y.applyUpdate(b, Y.encodeStateAsUpdate(a), 'remote');
Y.applyUpdate(a, Y.encodeStateAsUpdate(b), 'remote');
// " t5z" keeps only italic: A's bold is lost there.
expect(JSON.stringify(text(a).toDelta())).toContain('" t5z","format":{"italic":true}');
}
});
});
describe('two collaborators link overlapping ranges at the same time', () => {
for (const ids of [
[1, 2],
[2, 1],
]) {
for (const [name, a, b, url] of [
['A links t2z-t5z, B links t3z to another URL', [4, 19], [8, 11], 'b'],
['A links t2z-t5z, B links t3z to the same URL', [4, 19], [8, 11], 'a'],
['A links t2z-t5z, B links t3z-t4z to another URL', [4, 19], [8, 15], 'b'],
['A links t2z-t3z, B links t3z-t5z to another URL', [4, 11], [8, 19], 'b'],
] as [string, [number, number], [number, number], string][]) {
test(`${name} (ids ${ids})`, async () => {
const t = await setup(() => {
$getRoot().append($createParagraphNode().append($createTextNode('t1z t2z t3z t4z t5z t6z')));
}, {ids});
t.offline();
t.a.update(() => {
$selectIn([0], a[0], a[1]);
$toggleLink('https://x.test/a');
$setSelection(null);
});
t.b.update(() => {
$selectIn([0], b[0], b[1]);
$toggleLink('https://x.test/' + url);
$setSelection(null);
});
await t.sync();
await expectConverged(t.peers);
// Every character either client linked is in a link.
const notLinked = t.a.read(() => {
const out: number[] = [];
let offset = 0;
for (const node of $getRoot().getAllTextNodes()) {
for (let i = 0; i < node.getTextContentSize(); i++) {
const at = offset + i;
const covered = (at >= a[0] && at < a[1]) || (at >= b[0] && at < b[1]);
if (covered && !(node.getParent() instanceof LinkNode)) {
out.push(at);
}
}
offset += node.getTextContentSize();
}
return out;
});
expect({line: t.a.describe(), notLinked}).toEqual({line: t.a.describe(), notLinked: []});
});
}
}
});The current behavior
A bolds t2z t3z t4z t5z while B bolds t3z t4z. After the sync, every client shows t1z t2z t3z t4z t5z t6z: t4z and t5z are plain, although A bolded both and B bolded t4z. The other three shapes lose the bold on part of the range in the same way, in both client id orders.
The expected behavior
Every character either client bolded is bold (t2z t3z t4z t5z bold), as when the two edits are made one after the other. If this cannot be fixed, the Limits section and the decision register should say that overlapping ranges, not only adjacent ones, can lose a format.
Impact of fix
Two people formatting the same passage at the same time is common (both fixing a heading, an agent and a person bolding the same key terms). The lost format converges, so nobody is told; one of the users sees their formatting silently undone.
Activity
[lexical-y] Chore: Pin the overlapping-bold races that a link no long…
zurfyx commentedon Oct 31, 2026
Two more shapes of this race, with a link. Both lose formatting only, never text.
1. A range crossing a link's end. A links
t5z t6z, then boldst4z..t7z, while B boldst3z..t6zat the same time.t7zshould end bold: A bolded it and nobody else touched it.2. A link's first word, with a collaborator one update behind.
t5z t6z, linkst7z..t10zand underlinest6z, and B gets those three changes.t6z t7z(into the link's first word) while B typesq12zaftert5z, inside the old bold run.t7zends plain on [lexical][lexical-clipboard][lexical-website][lexical-y] Bug Fix: Keep a link a collaborator removes removed when another collaborator formats text covering it #9601 too (main keeps it). Ids [2, 1] keep both.Why the link changes it. On main, a format over a link also writes the link's value again, which opens the format inside the link. That is what kept
t7zbold in those cells. #9601 stops storing a link'stextFormatwhile it holds text (the fix for #9597), so a link now behaves like a mark there: with a comment instead of the link, main losest7z's bold the same way. #9601 pins both shapes as expected failures naming this issue (BoldAcrossLinkEnd.test.ts,BoldIntoLinkBehind.test.ts).Repro for shape 1 (save as
packages/lexical-y/src/__tests__/unit/Repro.test.ts)[lexical-website][lexical-y] Documentation Update: Say what a link's …
zurfyx commentedon Oct 31, 2026
Another shape of this class, with styles and two different values: the overlap leaves part of the text with a value nobody wrote.
A line
azy aamma jotwithammagreen. While offline from each other, client C colorszy agreen (so its green joins the existing green run) and client B colorsaammblue; then they sync. Every client, and a freshreadYState, shows:a/zygreen /ablue /ammno color /agreen /jotammwas green before, only B wrote to it (blue), and it ends uncolored: neither the old value nor B's. Same result in both client id orders, on main 551fe4a, on 4421e5b and on 0823b74. A build with a removal that keeps other changes' items and overrides them (a measurement build of the alternative design, not a PR) keepsaammblue.Found by a three-client randomized test that recolors a few characters in turn next to formatted text that is deleted and retyped.
Repro test (fails on main in both id orders)
zurfyx commentedon Oct 31, 2026
A related shape where a character ends with a color nobody wrote to it in that round: an older value comes back.
One line
abcdefghijklmnopqrstuvwx, two clients.mngreen, B colorsnred. Sync.mngray, B colorsmred. Sync.Only A wrote to
nin step 2 (gray). Every client ends withmred andngreen: A's value from step 1, which step 1 had already replaced onn. Both client id orders, on main c7f6139 and on 0823b74. The same calls on a plain@y/ytext (format(…, {style}), with and withoutcreateYDoc) end withmnred:ntakes B's red, which B wrote only tom, so Yjs's begin/end items give a value nobody wrote there as well (the yjs/yjs#382 class this issue already names). A measurement build where a removal keeps other changes' items and overrides them (the alternative design, not a PR) passes this case.Rate: in a randomized test (3 clients, 1-3 color patches each per round over random ranges of one line, 3 rounds, one client also formats-deletes-retypes 1-3 characters; each character checked against the colors written to it in that round, or its previous color), 278 of 1,000 sessions end with at least one character in a color nobody wrote to it that round, identical on main c7f6139, 551fe4a and 0823b74; the alternative-design build: 62 of 1,000. The test above is that rate's shortest case (shrunk from 15 ops to 4).
Repro test (fails on main in both id orders)