Skip to content

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

Open

Description

@zurfyx

Lexical version: main at 4c04461 (@lexical/y, after #9625), and the 20261030 nightly (0.53.1-nightly.20261030.0, e28f4dd).

Steps To Reproduce

  1. 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).
  2. 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 word t167z is unchanged.
    • B bolds t167z, the word right after the bold node.
  3. 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    collabRelated to Lexical collaboration featuresformatting

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions