Repository navigation
Bug: [lexical-y] A collaborator's bold on a word right after a bold node is lost when another client remakes the line with several words changed #9646
Description
Lexical version: main at 4c04461 (@lexical/y, after #9625), and the 20261030 nightly (0.53.1-nightly.20261030.0, e28f4dd).
Steps To Reproduce
- Two clients A and B bind one document. Its second line is two text nodes: a bold one (
t552z, t860z t701z) and a plain one (t167z). - While offline from each other:
- A makes the line's text nodes again as plain text with several words changed (
t490z t169z. t611z. t167z): the old nodes are removed and new ones appended, as an app that rewrites a line from its text does. The wordt167zis unchanged. - B bolds
t167z, the word right after the bold node.
- A makes the line's text nodes again as plain text with several words changed (
- The clients sync.
Test (save as packages/lexical-y/src/__tests__/unit/Repro.test.ts; uses the package's test harness). Case 0 is the steps above, case 1 another line of the same shape (the remade text split over two nodes), case 2 the control with a plain node before the word:
Repro test
/**
* 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 {
$createParagraphNode,
$createTextNode,
$getRoot,
$setSelection,
type ElementNode,
} from 'lexical';
import {describe, expect, test} from 'vitest';
import {freshReader, type Peer, setup} from '../utils/harness';
const $line = () => $getRoot().getChildAtIndex(1) as ElementNode;
const bold = (p: Peer) =>
p.read(() =>
$line()
.getAllTextNodes()
.filter(n => n.hasFormat('bold'))
.map(n => n.getTextContent())
.join('|'),
);
const text = (p: Peer) => p.read(() => $line().getTextContent());
const CASES = [
{
made: ['t490z t169z. t611z. t167z'],
old: [['t552z, t860z t701z ', true], ['t167z', false]] as [string, boolean][],
victim: 't167z',
},
{
made: ['t477z. t7', '82z'],
old: [['t168z t435z ', true], ['t782z t194z', false]] as [string, boolean][],
victim: 't782z',
},
{
// control: the node before the word is plain
made: ['t490z t169z. t611z. t167z'],
old: [['t552z, t860z t701z ', false], ['t167z', false]] as [string, boolean][],
victim: 't167z',
},
];
describe('a bold on an unchanged word, concurrent with a remake of its line', () => {
for (const [i, c] of CASES.entries()) {
for (const ids of [
[1, 2],
[2, 1],
]) {
test(`case ${i} (${c.old[0][1] ? 'bold first node' : 'control: plain first node'}), ids ${ids}`, async () => {
const t = await setup(
() => {
$getRoot().append(
$createParagraphNode().append($createTextNode('Title')),
$createParagraphNode().append(
...c.old.map(([s, b]) => {
const n = $createTextNode(s);
return b ? n.toggleFormat('bold') : n;
}),
),
);
},
{ids},
);
t.offline();
t.a.update(() => {
for (const o of $line().getChildren()) {
o.remove();
}
$line().append(...c.made.map(s => $createTextNode(s)));
$setSelection(null);
});
t.b.update(() => {
const old = c.old.map(([s]) => s).join('');
const vAt = old.indexOf(c.victim);
let acc = 0;
for (const leaf of $line().getAllTextNodes()) {
const size = leaf.getTextContentSize();
const s0 = Math.max(vAt, acc);
const s1 = Math.min(vAt + c.victim.length, acc + size);
if (s1 > s0) {
const parts = leaf.splitText(s0 - acc, s1 - acc);
const mid = s0 - acc === 0 ? parts[0] : parts[1];
if (!mid.hasFormat('bold')) {
mid.toggleFormat('bold');
}
}
acc += size;
}
$setSelection(null);
});
await t.sync();
const fresh = await freshReader(t.a);
expect(text(t.a)).toBe(c.made.join(''));
expect(text(t.b)).toBe(text(t.a));
expect(text(fresh)).toBe(text(t.a));
for (const p of [t.a, t.b, fresh]) {
expect(bold(p)).toBe(c.victim);
}
});
}
}
});The current behavior
With client ids [2, 1], case 0 and case 1 fail on main: B's bold is gone on both clients and in a fresh reader of the document (expected '' to be 't167z'); the text itself is right. With ids [1, 2], and in the control (plain node before the word), the word stays bold.
| tree | failing cases (of 6) |
|---|---|
| main 4c04461 (and 2e2acb7, the #9625 merge) | 2: cases 0 and 1, ids [2, 1] |
| main before #9625 (551fe4a) | 3 |
| 20261030 nightly | 6 (in case 0 the whole line also turns bold) |
So it predates #9625, which narrows it. #9625's own property test finds what is left: RemadeTextStretches.test.ts, "concurrent: random lines made again with 2 to 4 word edits ‖ the peer bolds an unchanged word", with LEXICAL_Y_SEEDS=6000 fails 9 of 6,000 seeds (203, 373, 2124, 2570, 2931, 3410, 5007, 5499, 5540). All are ids [2, 1], and in every one the bolded word comes right after a bold (in 2 seeds italic-then-bold) text node of the old line. The default 120 seeds pass, and the test's other 25 variants pass at 6,000 seeds.
Not the same as #9626 (two collaborators bold overlapping ranges; here only B formats) or #9635 (an undone delete shows twice).
The expected behavior
The collaborator's bold stays on the word: the remake did not change that word, so a format someone applied to it at the same time must survive (as it does with ids [1, 2], and when the node before it is plain).
Activity