Skip to content

[lexical-y] Bug Fix: Typing over a range formatted from inside an emoji keeps the node format and re-reads the line in place (#9717) - #9724

Open
Monier-Ayman wants to merge 1 commit into
meta:mainfrom
Monier-Ayman:fix/y-surrogate-reread-selection
Open

Monier-Ayman wants to merge 1 commit into
meta:mainfrom
Monier-Ayman:fix/y-surrogate-reread-selection

Conversation

@Monier-Ayman

Copy link
Copy Markdown
Contributor

Description

When a range selection with one end between the halves of a surrogate pair is formatted (e.g. bolding [3, 6) or [2, 3) in ab😀cd), Lexical splits the pair across adjacent text nodes or retains one half. Under D57, @lexical/y writes lone surrogate halves as U+FFFD and queues a re-read so the author's editor reflects what other peers and fresh reads show.

During this re-read:

  1. The binding previously restored selection from Yjs document relative positions ($resolvePosition and $pointAtAtom). At the boundary between text nodes, $pointAtAtom resolved the point to the end of the preceding plain text node rather than offset 0 of the formatted text node. Consequently, typing Z over the selection inserted text with the unformatted node's format.
  2. In addition, $writeLineAlone added the container u.cs to this.refused, causing the re-read to reconstruct the entire document container (such as every paragraph in the root) instead of re-reading only the affected line.

This PR addresses both:

  • Captures the line-relative node path and offset before the re-read and restores the selection to the corresponding node in the re-read line. Because U+FFFD preserves character length 1:1 and maintains the line's node hierarchy, the anchor and focus remain on the formatted text node.
  • Tracks line units whose writes held lone surrogates in refusedLines and uses Reader#$readLineInPlace to re-read only the touched line in place without reconstructing the rest of the container.

Closes #9717

Changeset

.changeset/lexical-y-surrogate-reread-selection-format.md included (patch for @lexical/y).

Test plan

Before

  • Typing over a range formatted from inside an emoji resulted in plain text instead of bold text ("ab\ufffdZ" instead of "ab\ufffd" "Z"{bold}).
  • Re-reading reconstructed the entire document container.

After

  • Tests in SurrogateSelectionFormat.test.ts pass:
    • All three ranges ([3, 6), [6, 3), and [2, 3)) retain the bold format when typed over, matching plain Lexical behavior and converging across peers.
    • Multi-line documents re-read only the touched line in place, leaving the node keys of all other paragraphs untouched.
  • All 66 regression test suites in @lexical/y (5,323 tests) pass.
@vercel

vercel Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
lexical Ready Ready Preview Nov 2, 2026 3:13pm UTC
lexical-playground Ready Ready Preview Nov 2, 2026 3:13pm UTC

Request Review

@Monier-Ayman
Monier-Ayman requested a review from etrepum as a code owner November 2, 2026 15:11
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Nov 2, 2026
@meta-code-review

meta-code-review Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟡 Reviewed (for maintainers only) Nov 2, 2026, 3:26 PM UTC
@pkg-pr-new

pkg-pr-new Bot commented Nov 2, 2026

Copy link
Copy Markdown
lexical

npm i https://pkg.pr.new/lexical@9724

@lexical/a11y

npm i https://pkg.pr.new/@lexical/a11y@9724

@lexical/clipboard

npm i https://pkg.pr.new/@lexical/clipboard@9724

@lexical/code

npm i https://pkg.pr.new/@lexical/code@9724

@lexical/code-core

npm i https://pkg.pr.new/@lexical/code-core@9724

@lexical/code-prism

npm i https://pkg.pr.new/@lexical/code-prism@9724

@lexical/code-shiki

npm i https://pkg.pr.new/@lexical/code-shiki@9724

@lexical/compiler

npm i https://pkg.pr.new/@lexical/compiler@9724

@lexical/devtools-core

npm i https://pkg.pr.new/@lexical/devtools-core@9724

@lexical/dragon

npm i https://pkg.pr.new/@lexical/dragon@9724

@lexical/eslint-plugin

npm i https://pkg.pr.new/@lexical/eslint-plugin@9724

@lexical/extension

npm i https://pkg.pr.new/@lexical/extension@9724

@lexical/fast-check

npm i https://pkg.pr.new/@lexical/fast-check@9724

@lexical/file

npm i https://pkg.pr.new/@lexical/file@9724

@lexical/hashtag

npm i https://pkg.pr.new/@lexical/hashtag@9724

@lexical/headless

npm i https://pkg.pr.new/@lexical/headless@9724

@lexical/history

npm i https://pkg.pr.new/@lexical/history@9724

@lexical/html

npm i https://pkg.pr.new/@lexical/html@9724

@lexical/internal

npm i https://pkg.pr.new/@lexical/internal@9724

@lexical/link

npm i https://pkg.pr.new/@lexical/link@9724

@lexical/list

npm i https://pkg.pr.new/@lexical/list@9724

@lexical/mark

npm i https://pkg.pr.new/@lexical/mark@9724

@lexical/markdown

npm i https://pkg.pr.new/@lexical/markdown@9724

@lexical/mdast

npm i https://pkg.pr.new/@lexical/mdast@9724

@lexical/offset

npm i https://pkg.pr.new/@lexical/offset@9724

@lexical/overflow

npm i https://pkg.pr.new/@lexical/overflow@9724

@lexical/plain-text

npm i https://pkg.pr.new/@lexical/plain-text@9724

@lexical/react

npm i https://pkg.pr.new/@lexical/react@9724

@lexical/rich-text

npm i https://pkg.pr.new/@lexical/rich-text@9724

@lexical/selection

npm i https://pkg.pr.new/@lexical/selection@9724

@lexical/table

npm i https://pkg.pr.new/@lexical/table@9724

@lexical/tailwind

npm i https://pkg.pr.new/@lexical/tailwind@9724

@lexical/text

npm i https://pkg.pr.new/@lexical/text@9724

@lexical/utils

npm i https://pkg.pr.new/@lexical/utils@9724

@lexical/y

npm i https://pkg.pr.new/@lexical/y@9724

@lexical/yjs

npm i https://pkg.pr.new/@lexical/yjs@9724

commit: 8d9512f

@meta-code-review meta-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for tackling #9717. The in-place line re-read works, and the three format cases fail without the change and pass with it. One issue needs fixing before this can merge.

1. The line-relative selection restore also runs after full container re-reads (packages/lexical-y/src/internal/Binding.ts:5350)

$rereadRefused now tries $restoreLineRelativeSelection first for every queued re-read, not only for the lines in refusedLines. Containers in refused are read in full because the editor shows something that is not stored, for example two adjacent text nodes with equal attributes that are stored as one run (Write.ts:2987-2988). Their line keeps its key while its children change, so $resolveLinePoint follows the old child index into the new structure and the Yjs-cursor restore never runs. A repro in the shape of the existing object-NodeState case in SurrogateSelection.test.ts, using the harness and $typeZ from your test file:

const objectState = createState('object', {
  parse: (v: unknown) => (v !== null && typeof v === 'object' ? v : null),
});
const t = await setup(() =>
  $getRoot().append($createParagraphNode().append($createTextNode('x'))),
);
t.b.update(() => {
  const second = $setState($createTextNode('cd'), objectState, {v: 1});
  ($getRoot().getFirstChild() as ElementNode).clear().append(
    $setState($createTextNode('ab'), objectState, {v: 1}),
    second,
    $createTextNode('ef').toggleFormat('bold'),
  );
  const s = $createRangeSelection();
  s.anchor.set(second.getKey(), 0, 'text');
  s.focus.set(second.getKey(), 1, 'text'); // selects "c"
  $setSelection(s);
});
await t.sync();
t.b.editor.update($typeZ, {discrete: true});
await t.sync();

On main the selection stays on c and the paragraph becomes "ab" "Z" "d" "ef"{1}. On this branch the selection moves to e in the bold node and the paragraph becomes "abcd" "Zf"{1}: the unselected e is deleted and the selected c stays. A collapsed caret in "cd" moves into "ef" the same way. Restricting the line-relative restore to the lines this pass re-read in place (for example only when no container was read in full and both points' lines are among them), and keeping $selectionAfterRead(cursor, caret) otherwise, avoids this. packages/lexical-y/AGENTS.md asks every bug fix to land with a regression test in src/__tests__/unit/regressions/, so please add this case there as well.

Optional, non-blocking suggestions

  1. SurrogateSelectionFormat.test.ts:121: the "re-reads only the touched line" test also passes on main, because a full container read keeps every paragraph's key. Asserting that Reader#$readFull is not called (for example with vi.spyOn(Reader.prototype, '$readFull')) would make it fail without the in-place read.
  2. The cursor capture and restore are wrapped in try/catch so that putting the selection back never fails the read it ends (Binding.ts:6054-6060, :6202-6213). $lineRelativeSelection and $restoreLineRelativeSelection could follow the same pattern and fall back to the cursor path.
  3. When a remote transaction is read before the queued re-read runs, refused lines are re-read in place but the selection is still restored from the Yjs cursor (Binding.ts:3069-3075), so the #9717 symptom can remain on that path.
  4. The two new methods sit between $caretOnText's JSDoc (Binding.ts:6075-6084) and $caretOnText itself; moving them above that comment keeps it attached to the right method.
  5. AGENTS.md ("Commits and Pull Requests") asks the Test plan to paste the actual before and after output of the command that was run, rather than describing it.
});
reader.$settle(settle);
this.$selectionAfterRead(cursor, caret);
if (!this.$restoreLineRelativeSelection(lineSel, caret)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This runs for every queued re-read, including the containers in refused that were just read in full (for example a line whose two equal-attribute text nodes are stored as one run). Those reads change the line's children while the line keeps its key, so the child path captured before the read can name a different node: with "ab" "cd" (equal object NodeState) and bold "ef", a selection over c comes back over e, and typing deletes e. Could the line-relative restore apply only to the lines this pass re-read in place, with $selectionAfterRead(cursor, caret) for everything else?

});
}

test('re-reads only the touched line without recreating other lines in the container', async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: this test also passes on main, because a full container read keeps every paragraph's key. Spying on Reader.prototype.$readFull and asserting it is not called would make it fail without the in-place read.

* new paragraph), and Backspace deletes structure (a nested list of other
* users' text).
*/
private $lineRelativeSelection(): LineRelativeSelection | null {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the JSDoc just above belongs to $caretOnText (now further down); moving the two new methods above that comment keeps it attached to the right method.

This branch was successfully deployed

2 active deployments
Preview – lexical — 8d9512fb Deployed Nov 2, 2026 by vercel[bot]
Preview – lexical-playground — 8d9512fb Deployed Nov 2, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

1 participant