Skip to content

Bug: [lexical-y] A remote insert into the composing line is shown mid-composition when the line was made by joining two lines #9492

Open

Description

@zurfyx

Lexical version: @lexical/y in #9400 (head e036931). The binding is not on main yet.

Steps To Reproduce

Two clients, A and B, on the same document.

  1. Content: abc def / ghi. A joins the two lines into abc def ghi, as Backspace at the start of the second line does. Sync.
  2. A starts an IME composition at the end of the line (composed text て).
  3. While A is still composing, B types XY at offset 1, which is before the point where the two lines were joined. Sync.

Link to code example: the test below. Save as packages/lexical-y/src/__tests__/unit/Repro.test.ts on #9400's branch and run pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/Repro.test.ts; it uses the package's test harness.

Repro test (the offset-1 case fails on #9400 and on #9439)
import {
  $createParagraphNode,
  $createTextNode,
  $getRoot,
  $setCompositionKey,
  type ElementNode,
  type TextNode,
} from 'lexical';
import {expect, test} from 'vitest';

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

const $line = (i: number) => $getRoot().getChildAtIndex<ElementNode>(i)!;
const $text = () => $line(0).getFirstChildOrThrow<TextNode>();

test.each([1, 8])(
  'a remote insert at %i into a joined line waits for the composition to end',
  async (at) => {
    const t = await setup(() =>
      $getRoot().append(
        $createParagraphNode().append($createTextNode('abc def')),
        $createParagraphNode().append($createTextNode(' ghi')),
      ),
    );
    // A joins the two lines (as Backspace at the start of the second does).
    t.a.update(() => {
      const sel = $text().select(7, 7);
      sel.focus.set($line(1).getFirstChildOrThrow().getKey(), 0, 'text');
      sel.removeText();
    });
    await t.sync();
    // A starts composing at the end of the joined line.
    t.a.update(() => {
      $text().select(11, 11);
      $setCompositionKey($text().getKey());
    });
    t.a.update(() => {
      $text().setTextContent('abc def ghiて');
      $text().select(12, 12);
    });
    await t.sync();
    // B types earlier in the same line.
    t.b.update(() => $text().select(at, at).insertText('XY'));
    await t.sync();
    // at 8 (after the join point): held back. at 1 (before it): 'aXYbc def ghiて'.
    expect(t.a.read(() => $text().getTextContent())).toBe('abc def ghiて');
  },
);

The current behavior

A's composing text node becomes aXYbc def ghiて at once, in the middle of the composition. If B types at offset 8, after the join point, the insert is held back until the composition ends, as intended. A line that was never joined also holds the insert back.

The expected behavior

The composing line keeps its nodes while composing ("The composing line keeps its nodes while composing" in Composition.test.ts). B's insert shows when A's composition ends.

Impact of fix

In a browser, the binding rewrites the DOM text node the IME is composing into. That can break or garble the composition. It converges once the composition ends, and no text is lost.

Cause, checked with a probe: an insert is held back only when the reader does not flag the change as structural. When Reader.$unitOf walks right from the insert to the line's end, it passes the deleted line marker that the join left behind, and that sets flags.structural. So the line is read in full at once. #9439 behaves the same way.

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

    collabRelated to Lexical collaboration featurescompositionRelating to IME inputs

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions