Skip to content

Bug: [lexical-y] setTextContent still writes the middle of a line again: a word a collaborator deleted comes back (and twice after their undo) #9666

Open

Description

@zurfyx

Lexical version: main at 21bcf62 (@lexical/y), and 4421e5b.

#9621's shape, on the setTextContent path: when a line's text node is set in place with TextNode#setTextContent and two or more words change, a word a collaborator deleted between them at the same time comes back, and after the collaborator's undo it shows twice, glued to the next word.

Relation to the docs. y.md documents this path as keeping only the common start and end ("setTextContent, which sets a node's text whole: such a change ... keeps the start and end its text shares with the node's old text"), so everything between the first and the last change is written again: this is a documented limit today. #9621 had the same result on the remade-line path (text nodes made again from JSON), and #9625 fixed that path by keeping every run of unchanged words (D29's word LCS). The same word LCS could apply to setTextContent (and to DOM text changes the binding diffs the same way), which would remove this case; the caret-anchored placement (#9641, #9657) only decides where a single edit goes.

Steps To Reproduce

  1. Two clients bind one document whose line is one text node t1z t2z t3z t4z t5z t6z t7z.
  2. At the same time: client B deletes t5z; client A sets the text node to t1z n9z t3z t4z t5z t6z n8z (t2z → n9z, t7z → n8z) with setTextContent.
  3. The clients sync; then B undoes its delete.

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. It fails on main for both client id orders.

Repro test (main at 21bcf62: 2 failed)
/**
 * 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,
  $createRangeSelection,
  $createTextNode,
  $getRoot,
  $setSelection,
  type ElementNode,
  type TextNode,
} from 'lexical';
import {expect, test} from 'vitest';

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

const LINE = 't1z t2z t3z t4z t5z t6z t7z';

// B deletes " t5z"; at the same time A sets the line's text node to a text
// with two words changed (setTextContent, in place); then B undoes.
for (const ids of [[1, 2], [2, 1]]) {
  test(`setTextContent with two words changed (ids ${ids})`, async () => {
    const t = await setup(() => {
      $getRoot().append($createParagraphNode().append($createTextNode(LINE)));
    }, {ids});
    t.offline();
    t.b.update(() => {
      const n = ($getRoot().getFirstChild() as ElementNode).getFirstChild() as TextNode;
      const s = $createRangeSelection();
      const at = LINE.indexOf('t5z');
      s.anchor.set(n.getKey(), at - 1, 'text');
      s.focus.set(n.getKey(), at + 3, 'text');
      $setSelection(s);
      s.removeText();
      $setSelection(null);
    });
    t.a.update(() => {
      const n = ($getRoot().getFirstChild() as ElementNode).getFirstChild() as TextNode;
      n.setTextContent('t1z n9z t3z t4z t5z t6z n8z');
    });
    await t.sync();
    const afterSync = t.a.read(() => $getRoot().getTextContent());
    t.b.undo();
    await t.sync();
    const afterUndo = t.a.read(() => $getRoot().getTextContent());
    expect({afterSync, afterUndo}).toEqual({
      afterSync: 't1z n9z t3z t4z t6z n8z',
      afterUndo: 't1z n9z t3z t4z t5z t6z n8z',
    });
  });
}

The current behavior

After the sync every client shows t1z n9z t3z t4z t5z t6z n8z (B's delete undone by A's change), and after B's undo t1z t5zn9z t3z t4z t5z t6z n8z (t5z twice, one glued to n9z). With one word changed it is fine (t1z t2z t3z t4z t6z n8z, then t5z once after the undo).

The expected behavior

t1z n9z t3z t4z t6z n8z after the sync and t1z n9z t3z t4z t5z t6z n8z after B's undo, as the remade-line path gives since #9625.

Impact of fix

Agents and apps that rewrite a line with setTextContent (the simplest API for it) silently undo collaborators' deletes between their changes and duplicate text on undo. A randomized test that rewrites lines with setTextContent while collaborators edit them hits a collaborator's delete brought back in about 5% of sessions (25 of 500), and a duplicated word after an undo in about 1 in 500.

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