Skip to content

Bug: [lexical-y] A programmatic selection inside an emoji either throws or leaves the author with text no other client has #9701

Open

Description

@zurfyx

Lexical version: main at c750757 (@lexical/y, @y/y 14.0.0-rc.29). Also on cfcd3c4, with Yjs garbage collection on and off.

A paragraph holds ab😀cd (the emoji is one UTF-16 surrogate pair, offsets 2 and 3). An update sets a selection that starts or ends between the two halves of the pair (Lexical's own input never does this, but code can: anchor.set(key, 3, 'text')) and formats, types or deletes. Lexical then splits the pair into two text nodes or keeps one half. What the binding does next depends on the edit:

Edit (selection inside the emoji) Author's editor Other clients Outcome
bold [0, 3) (ends inside) ab\ud83d bold, \ude00cd ab\ufffd bold, \ufffdcd written; Yjs replaces each lone half with U+FFFD on the other clients; the author keeps the lone halves and warns that its editor differs from its document
bold [3, 6) (starts inside) ab\ud83d, \ude00cd bold ab😀cd throws @lexical/y: a write splits a surrogate pair; nothing is written, the author's editor keeps Lexical's split
type X at 3 ab\ud83dX\ude00cd ab\ufffdX\ufffdcd written; clients differ
delete [2, 3) (half of the emoji) ab\ude00cd ab\ufffdcd written; clients differ
control: bold a collapsed selection at 3 ab😀cd ab😀cd nothing to write

So the same mistake (a range boundary between the halves of a pair) is written in three cases and refused in one, and every written case leaves the author's editor with text no other client has (and that a fresh read of the author's own document does not have) until the line is read again.

Ask: one consistent behaviour. Either clamp a boundary inside a pair to the grapheme (Lexical's own selection normalisation does this for input) before writing, or refuse every such write the same way; and in either case have the author read back what the document holds (U+FFFD where a lone half was written) so clients converge.

Steps To Reproduce

  1. Two clients bind one document holding ab😀cd.
  2. Client B runs an update that sets a range selection with one end at offset 3 (between the emoji's halves) and calls formatText('bold'), insertText('X') or removeText().
  3. Sync and compare the clients.

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. All 4 cells fail on main.

Repro test (main at c750757: 4 of 4 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.
 *
 */

// @vitest-environment node

import {
  $createParagraphNode,
  $createRangeSelection,
  $createTextNode,
  $getRoot,
  $setSelection,
  type TextNode,
} from 'lexical';
import {expect, test} from 'vitest';

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

// "ab😀cd": the emoji is the UTF-16 pair at offsets 2 and 3.
const cases: [string, number, number, 'bold' | 'insert' | 'delete'][] = [
  ['bold a range that ends inside the emoji', 0, 3, 'bold'],
  ['bold a range that starts inside the emoji', 3, 6, 'bold'],
  ['type inside the emoji', 3, 3, 'insert'],
  ['delete half of the emoji', 2, 3, 'delete'],
];
for (const [name, anchor, focus, kind] of cases) {
  test(name, async () => {
    const t = await setup(() =>
      $getRoot().append(
        $createParagraphNode().append($createTextNode('ab\u{1F600}cd')),
      ),
    );
    t.b.update(() => {
      const n = $getRoot().getFirstDescendant() as TextNode;
      const s = $createRangeSelection();
      s.anchor.set(n.getKey(), anchor, 'text');
      s.focus.set(n.getKey(), focus, 'text');
      $setSelection(s);
      if (kind === 'bold') {
        s.formatText('bold');
      } else if (kind === 'insert') {
        s.insertText('X');
      } else {
        s.removeText();
      }
      $setSelection(null);
    });
    await t.sync();
    // Expected: one consistent outcome (the range clamped to the emoji, or
    // the update refused), and every client showing the same text.
    await expectConverged(t.peers);
    expect(t.b.warnings).toEqual([]);
  });
}

Actual output on main:

bold a range that ends inside the emoji
AssertionError: expected [ Array(1) ] to deeply equal []
bold a range that starts inside the emoji
Error: @lexical/y: a write splits a surrogate pair
type inside the emoji
AssertionError: expected 'paragraph("ab\ud83dX\ude00cd")' to be 'paragraph("ab\ufffdX\ufffdcd")' // Object.is equality
delete half of the emoji
AssertionError: expected 'paragraph("ab\ude00cd")' to be 'paragraph("ab\ufffdcd")' // Object.is equality

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions