Skip to content

Bug: [lexical-y] Removing a word with setTextContent while a collaborator deletes or types after it takes a letter from the next word #9636

Closed

Description

@zurfyx

Lexical version: main at 4421e5b (@lexical/y), the 20261030 nightly (0.53.1-nightly.20261030.0, e28f4dd), and the head of #9625 (456250b).

When a client removes a word with setTextContent (an agent or app code editing a text node in place) while a collaborator deletes the same word, or types right after it, the merged text loses a letter of the next word, which nobody changed.

Relation to the documented limits. docs/collaboration/y.md (Write rules) says setTextContent "keeps the start and end its text shares with the node's old text, anchored at the caret, which cannot tell where an edit next to equal text was made without one; a replacement found that way replaces whole words". Removing t2z from t1z t2z t3z is such an edit next to equal text: without a caret the change is found as the deletion of 2z t (the t of t2z is kept as the t of t3z), not of t2z or t2z. The docs explain that the position is ambiguous, but not that the result can take a letter out of another word: here the collaborator's deletion of t2z and the client's deletion of 2z t together delete the t of t3z. Only replacements are widened to whole words, not pure deletions or insertions.

Steps To Reproduce

  1. Two clients bind one document holding one paragraph with one text node t1z t2z t3z.
  2. While offline from each other: client A calls setTextContent('t1z t3z') on the text node; client B selects t2z (offsets 4..7) and deletes it.
  3. 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. The first three cases fail in both client id orders; a replacement and a control without shared letters pass.

Repro test (main at 4421e5b, the 20261030 nightly and #9625 at 456250b: 6 of 10 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.
 *
 */

// A setTextContent edit and a collaborator's edit of the same word at the same
// time: the result must not take letters from a word nobody changed.
import {$createParagraphNode, $createRangeSelection, $createTextNode, $getRoot, $setSelection, type ElementNode, type TextNode} from 'lexical';
import {expect, test} from 'vitest';

import {expectConverged, setup} from '../utils/harness';

const $node = () => ($getRoot().getFirstChildOrThrow() as ElementNode).getFirstChildOrThrow() as TextNode;
for (const ids of [
  [1, 2],
  [2, 1],
]) {
  for (const [name, base, agent, cs, ce, ins, want] of [
    ['both delete t2z', 't1z t2z t3z', 't1z t3z', 4, 7, null, /^t1z +t3z$/],
    ['agent deletes t2z, C types after t2z', 't1z t2z t3z', 't1z t3z', 7, 7, ' q9z', /^t1z +q9z t3z$|^t1z +t3z q9z$|^t1z q9z +t3z$/],
    ['agent inserts t5z after t1z, C deletes t2z', 't1z t2z t3z', 't1z t5z t2z t3z', 4, 7, null, /^t1z t5z +t3z$/],
    ['agent replaces t2z with n5z, C deletes t3z', 't1z t2z t3z t4z', 't1z n5z t3z t4z', 8, 11, null, /^t1z n5z +t4z$/],
    ['control: agent deletes a2b, C deletes a2b (no shared letters)', 'a1b c2d e3f', 'a1b e3f', 4, 7, null, /^a1b +e3f$/],
  ] as [string, string, string, number, number, string | null, RegExp][]) {
    test(`${name} (ids ${ids})`, async () => {
      const t = await setup(() => {
        $getRoot().append($createParagraphNode().append($createTextNode(base)));
      }, {ids});
      t.offline();
      t.a.update(() => {
        $node().setTextContent(agent);
        $setSelection(null);
      });
      t.b.update(() => {
        const n = $node();
        const s = $createRangeSelection();
        s.anchor.set(n.getKey(), cs, 'text');
        s.focus.set(n.getKey(), ce, 'text');
        $setSelection(s);
        if (ins) s.insertText(ins);
        else s.removeText();
        $setSelection(null);
      });
      await t.sync();
      await expectConverged(t.peers);
      const text = t.a.read(() => $getRoot().getTextContent().trim());
      expect({ok: want.test(text), text}).toEqual({ok: true, text});
    });
  }
}

The current behavior

  • A setTextContent('t1z t3z') ‖ B deletes t2z: t1z 3z (the t of t3z is gone).
  • A setTextContent('t1z t3z') ‖ B types q9z after t2z: t1z t q9z3z (t3z is split by B's word).
  • A pure insertion too: A setTextContent('t1z t5z t2z t3z') (inserting t5z after t1z) ‖ B deletes t2z: t1z 5z t t3z (A's new word loses its t, which is left over on its own: the insertion was found as 5z t after t1z t).
  • Control: the same edits on a1b c2d e3f (no letter shared between the removed word and the next one) give a1b e3f; a replacement (t2z -> n5z) is widened to whole words and keeps the other words.

The expected behavior

t1z t3z (with one or two spaces), and B's word next to t3z, not inside it: a word neither client changed keeps all its letters.

A possible fix: when an edit found without a caret is a pure insertion or deletion inside a run of equal text, slide it within that run to a word boundary (here, delete t2z or t2z), or widen it to whole words as replacements are. Making a node's text again from its JSON with the word removed (the alternative the docs recommend) keeps t3z whole.

Impact of fix

Agents and app code commonly edit a node's text with setTextContent, and two people (or a person and an agent) removing the same word is common. In a randomized run where one client rewrites a line's text nodes with setTextContent (zero to two words replaced, inserted or deleted) while collaborators comment, format, type and delete, 11 of 1,000 sessions lost a letter or more of a word nobody deleted, 8 lost part of a word the rewrite inserted, and 39 left a word broken into pieces, the same on main and on #9625's head (whose fix covers lines made again from JSON). The same run with the client's edits made by TextNode#spliceText at each word (the in-place path the docs recommend) has none of these.

Activity

  1. added 3 commits that reference this issue on Oct 31, 2026
    cf47bdf
    e6e85c6
    862ecca
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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions