Repository navigation
Bug: [lexical-y] A programmatic selection inside an emoji either throws or leaves the author with text no other client has #9701
Description
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
- Two clients bind one document holding
ab😀cd. - 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')orremoveText(). - 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