Skip to content

[lexical-playground][lexical-website][lexical-y] Bug Fix: Hold back remote changes to the line being composed until the composition ends - #9507

Merged
zurfyx merged 7 commits into
mainfrom
zurfyx/y-composition-holdback
Oct 29, 2026
Merged

zurfyx merged 7 commits into
mainfrom
zurfyx/y-composition-holdback

Conversation

@zurfyx

@zurfyx zurfyx commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

Note

Stack, part 9 of 10. Based on #9527 (zurfyx/y-move-keeps-unknown-fields). Review only this PR's own commits.

The @lexical/y fixes touch the same writer, cursor, reader and binding internals, so they are one linear stack: each PR is based on the one below it, each conflict is resolved once, in the PR whose change causes it, and every CI run tests the real combination.

Merge order: bottom first, #9509 → #9523 → #9522 → #9505 → #9508 → #9502 → #9504 → #9527 → #9507 → #9510. With squash merges, rebase the next PR onto main before merging it (git rebase --onto main <old base branch>).

The stack
# PR Branch Base
1 #9509 zurfyx/update-error-rollback main
2 #9523 zurfyx/write-before-unread-change #9509
3 #9522 zurfyx/y-keep-chars-at-format-boundary #9523
4 #9505 zurfyx/y-undo-member-closer #9522
5 #9508 zurfyx/y-initial-state-binding #9505
6 #9502 zurfyx/y-reorder-intent #9508
7 #9504 zurfyx/y-preserve-unknown-attrs #9502
8 #9527 zurfyx/y-move-keeps-unknown-fields #9504
9 #9507 (this PR) zurfyx/y-composition-holdback #9527
10 #9510 zurfyx/y-incremental-read-marks #9507

Follow-ups for after the stack (not in it):

Description

While the local editor composes with an IME in a line, @lexical/y held back only plain remote inserts into that line, and only when the change had no deletion, format or marker. It read everything else mid-composition, and the hold-back itself could lose or duplicate text:

  • Bug: [lexical-y] A remote insert into the composing line is shown mid-composition when the line was made by joining two lines #9492: in a line made by joining two lines, the deleted marker of the join made a remote insert look structural, so it was read mid-composition.
  • A remote bold or comment split the text node being composed (S7-9). A remote Enter later in the line gave the composed text a new key, so the browser restarted the composition and committed its first character for everyone (alpha betan你好 ga, S7-15).
  • Enter while composing, after a collaborator typed in the line: the held word never showed, and the composer's next edit of the line deleted it for everyone (S7-12).
  • A remote paste into the composed line followed by typing in it, two people composing at one place, a joined line with a bold first word, or offline edits at the start of an emptied line stored a word twice (S7-28).
  • In a highlighted code line, a remote format during a composition left the composer with other code-highlight segments than its peers and a fresh read, and the self-check warned (S7-8).

This change replaces that with one rule. While the editor composes in a line, every remote change to that line that Yjs keeps whole is held back: inserted text and inline nodes, formats and comments, line markers (a split, pasted lines), deletions of what is held, and the attributes set on the line's marker, with the values they had, taken from the transaction. Every reader and writer of the line works on the editor's view of it, Yjs minus what is held (Hidden). A change that removes something the editor shows (a deletion, a join, a MarkNode wrap) is read at once and whole; the text node being composed then keeps its key for the text that holds the composition caret, found by Yjs identity. The hold lasts while the composition goes on in that line and everything held is still in it. When it ends (the composition ends or moves, or a local edit such as Enter, Backspace or a paste takes held content out of the line), one release path reads every held change where its content is now. A local edit of the composed line's attributes keeps each held value it did not change. The text node a read shows a piece in also takes that piece's segment key (S7-8).

Behavior changes: a remote split or change of type of the line being composed now waits for the composition to end, and a held insert survives a remote join (two existing tests in Composition.test.ts are updated). A code block turned into a heading still replaces the composed node, because its class changes. y.md documents the rule and its limits.

Follow-up commits:

  • S7-37 (tests only): a remote delete while inserts are held in the composing line. The first commit already handles it; the regression test and a matrix shape (a three-node line with a third peer) cover it.
  • MF-1 and B-2: the hold captures only marker keys the client owns, and a change of keys it does not own creates no hold. A held alignment or type now stays on both halves of a split made while composing (the composed line and the new line that ends at the old marker). Lines the composed line was joined into keep their own values.

Closes #9492

Restack (October 29)

Rebased onto #9527 (it was based on #9504). Conflicts, resolved in its first commit:

New commits:

Fixed after a randomized soak test of the whole stack (one commit):

  • L1C-3: a multi-paragraph paste into the line being composed, after a held insert, no longer stores its last paragraph twice when the composition ends: inserts the release already showed are read, not held again.
  • L1C-4: a remote bold on the composing line is shown when the composition ends with Enter in the same update (as Android's insertParagraph and composition data ending in a line break do), also after later splits of that line.
  • S8-4: the next composition step after a held insert and a held paste no longer throws cursor advanced past the end, which dropped every later step and lost the composed text.
  • S8-6: with BlockIdExtension, a pasted list item's blockId in the composer's editor matches the stored one.
    Tests L1C-3:, L1C-4:, S8-4: and S8-6: in regressions/Composition.test.ts; the test harness can enable BlockIdExtension per peer.

Important

Decision for Gerard (N1), shared with #9527: a split by a client that does not own a field keeps it on both lines. #9527 describes the alternative and how to switch; this PR's MF-1 test holds under either rule.

Changeset

.changeset/lexical-y-composition-holdback.md: [fix] for @lexical/y, with a Verify: paragraph.

Test plan

New tests:

  • packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts: one case per finding, Bug: [lexical-y] A remote insert into the composing line is shown mid-composition when the line was made by joining two lines #9492, S7-8, S7-9, S7-12, S7-15 and S7-28 in four shapes, and R-1 to R-4 from a review of this change (R-5 is in the block-id file).
  • packages/lexical-y/src/__tests__/unit/CompositionHoldBack.test.ts (matrix in __tests__/utils/compositionHoldBack.ts): 6 line shapes x 13 remote changes x 9 ways the composition ends or the composer edits the line's structure x 2 client id orders. Each case checks that the line keeps its nodes while a change is held, that the composed node keeps its key, that every editor equals a fresh read with a quiet self-check, and that nothing a collaborator did is lost or stored twice. CompositionHoldBackBlockIds.test.ts runs part of it with BlockIdExtension.
  • packages/lexical-playground/__tests__/e2e/CollabYNodes.spec.mjs: a peer presses Enter later in the line being composed (IME driven through CDP).

Before

On main (0cd7448):

$ ./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts packages/lexical-y/src/__tests__/unit/CompositionHoldBack.test.ts packages/lexical-y/src/__tests__/unit/CompositionHoldBackBlockIds.test.ts
 ❯ |unit| packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts (40 tests | 32 failed) 1702ms
 ❯ |unit| packages/lexical-y/src/__tests__/unit/CompositionHoldBackBlockIds.test.ts (157 tests | 79 failed) 8308ms
 ❯ |unit| packages/lexical-y/src/__tests__/unit/CompositionHoldBack.test.ts (1404 tests | 802 failed) 59265ms
 Test Files  3 failed (3)
      Tests  913 failed | 688 passed (1601)
 FAIL  |unit| packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts > #9492: a remote insert into a line made by joining two lines, while composing > waits for the composition to end (insert at 1, ids 1,2)
AssertionError: expected 'aXYbc def ghiて' to be 'abc def ghiて' // Object.is equality

 FAIL  |unit| packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts > S7-8: a peer formats a word of a highlighted code line while A composes in it > A, B and a fresh read agree after the composition (completed, ids 1,2)
AssertionError: expected [ …(3) ] to deeply equal []
+   "self-check: line 94: format k at 5",
+   "self-check: line 94: format k at 5",

 FAIL  |unit| packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts > S7-9: a peer bolds or comments a word in the text node A composes in > the composed node keeps its text until the composition ends (bold, ids 1,2)
Expected: "alpha beta gamma に"
Received: "alpha "

 FAIL  |unit| packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts > S7-12: Enter while composing, after a peer typed in the composed line > the held word shows, and stays (Enter at the composition start, then the end, ids 1,2)
Expected: "paragraph() / paragraph("s1z s2z s3z ")"
Received: "paragraph() / paragraph("s1z skb5z 2z s3z ")"

 FAIL  |unit| packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts > S7-15: a peer presses Enter later in the line A composes in > the composed node keeps its key, text and line (ids 1,2)
AssertionError: expected 1 to be +0 // Object.is equality

 FAIL  |unit| packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts > S7-28: a peer pastes paragraphs into the composed line, then types in them > every word is stored once (one composition step, ids 1,2)
AssertionError: expected 2 to be 1 // Object.is equality
$ E2E_BROWSER=chromium E2E_EDITOR_MODE=rich-text-with-collab-y ./node_modules/.bin/playwright test --project=chromium --workers=1 --retries=0 --repeat-each=3 packages/lexical-playground/__tests__/e2e/CollabYNodes.spec.mjs -g 'Collab Y: IME'
  ✘   3 [chromium] › packages/lexical-playground/__tests__/e2e/CollabYNodes.spec.mjs:221:3 › Collab Y: IME › compose while a peer splits the line after the composition (12.6s)
  ✘   7 [chromium] › packages/lexical-playground/__tests__/e2e/CollabYNodes.spec.mjs:221:3 › Collab Y: IME › compose while a peer splits the line after the composition (12.5s)
  ✘  11 [chromium] › packages/lexical-playground/__tests__/e2e/CollabYNodes.spec.mjs:221:3 › Collab Y: IME › compose while a peer splits the line after the composition (13.3s)
    -   "paragraph:s1z s2z日本 s3z",
    +   "paragraph:s1z s2zにh日本 s3z",
  3 failed
  9 passed (2.9m)

After

$ ./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts packages/lexical-y/src/__tests__/unit/CompositionHoldBack.test.ts packages/lexical-y/src/__tests__/unit/CompositionHoldBackBlockIds.test.ts
 ✓ |unit| packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts (40 tests) 2015ms
 ✓ |unit| packages/lexical-y/src/__tests__/unit/CompositionHoldBackBlockIds.test.ts (157 tests) 10015ms
 ✓ |unit| packages/lexical-y/src/__tests__/unit/CompositionHoldBack.test.ts (1404 tests) 73260ms
 Test Files  3 passed (3)
      Tests  1601 passed (1601)
$ ./node_modules/.bin/vitest --project unit --project scripts-unit --no-watch
 Test Files  563 passed | 3 skipped (566)
      Tests  16840 passed | 16 expected fail | 4 skipped (16860)
$ E2E_BROWSER=chromium E2E_EDITOR_MODE=rich-text-with-collab-y ./node_modules/.bin/playwright test --project=chromium packages/lexical-playground/__tests__/e2e/CollabYNodes.spec.mjs packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs packages/lexical-playground/__tests__/e2e/CollabYSync.spec.mjs packages/lexical-playground/__tests__/e2e/CollabYStructure.spec.mjs
  ✓  10 [chromium] › packages/lexical-playground/__tests__/e2e/CollabYNodes.spec.mjs:176:3 › Collab Y: IME › compose in a line while a peer types in the same line (14.3s)
  ✓  11 [chromium] › packages/lexical-playground/__tests__/e2e/CollabYNodes.spec.mjs:197:3 › Collab Y: IME › compose while a peer splits the line before the composition (13.5s)
  ✓  12 [chromium] › packages/lexical-playground/__tests__/e2e/CollabYNodes.spec.mjs:221:3 › Collab Y: IME › compose while a peer splits the line after the composition (13.4s)
  ✓  13 [chromium] › packages/lexical-playground/__tests__/e2e/CollabYNodes.spec.mjs:245:3 › Collab Y: IME › compose in a line while a peer joins it into the previous line (13.6s)
  32 passed (2.9m)
$ pnpm run ci-check
Changeset check passed: 5 pending changeset(s) valid.
Found 0 errors

Not exercised: the collab Y e2e specs in Firefox and WebKit (the IME specs drive the composition through CDP, so they run in Chromium only), and a manual check with a real IME.

After, stacked on #9504 (ac575d2b4)

$ ./node_modules/.bin/vitest --project unit --project scripts-unit --no-watch
 Test Files  564 passed | 3 skipped (567)
      Tests  17280 passed | 18 expected fail | 6 skipped (17304)

$ LEXICAL_Y_BLOCK_ID=1 ./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/CompositionHoldBack.test.ts packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts
 Test Files  2 passed (2)
      Tests  1815 passed | 2 skipped (1817)

$ E2E_BROWSER=chromium E2E_EDITOR_MODE=rich-text-with-collab-y ./node_modules/.bin/playwright test --project=chromium packages/lexical-playground/__tests__/e2e/CollabYNodes.spec.mjs packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs packages/lexical-playground/__tests__/e2e/CollabYSync.spec.mjs packages/lexical-playground/__tests__/e2e/CollabYStructure.spec.mjs
  32 passed (2.6m)

$ pnpm run ci-check
Changeset check passed: 7 pending changeset(s) valid.
Found 0 errors

The #9504 test SchemaSkew.test.ts "releasing what a composition held back" now injects its reader failure into $apply on the composing client, because the release no longer calls $refreshComposed.

@vercel

vercel Bot commented Oct 29, 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 Oct 29, 2026 7:52pm UTC
lexical-playground Ready Ready Preview Oct 29, 2026 7:52pm UTC

Request Review

@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 Oct 29, 2026
@pkg-pr-new

pkg-pr-new Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
lexical

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

@lexical/a11y

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

@lexical/clipboard

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

@lexical/code

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

@lexical/code-core

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

@lexical/code-prism

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

@lexical/code-shiki

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

@lexical/compiler

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

@lexical/devtools-core

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

@lexical/dragon

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

@lexical/eslint-plugin

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

@lexical/extension

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

@lexical/fast-check

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

@lexical/file

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

@lexical/hashtag

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

@lexical/headless

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

@lexical/history

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

@lexical/html

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

@lexical/internal

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

@lexical/link

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

@lexical/list

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

@lexical/mark

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

@lexical/markdown

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

@lexical/mdast

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

@lexical/offset

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

@lexical/overflow

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

@lexical/plain-text

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

@lexical/react

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

@lexical/rich-text

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

@lexical/selection

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

@lexical/table

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

@lexical/tailwind

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

@lexical/text

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

@lexical/utils

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

@lexical/y

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

@lexical/yjs

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

commit: 005b397

@zurfyx
zurfyx force-pushed the zurfyx/y-composition-holdback branch from 6bc5caf to ac575d2 Compare October 29, 2026 02:54
@zurfyx
zurfyx changed the base branch from main to zurfyx/y-preserve-unknown-attrs October 29, 2026 02:54
@zurfyx
zurfyx force-pushed the zurfyx/y-composition-holdback branch from ac575d2 to cf109e0 Compare October 29, 2026 05:53
@zurfyx
zurfyx force-pushed the zurfyx/y-preserve-unknown-attrs branch from 7a4a983 to 7fcd687 Compare October 29, 2026 05:53
@zurfyx
zurfyx changed the base branch from zurfyx/y-preserve-unknown-attrs to zurfyx/y-move-keeps-unknown-fields October 29, 2026 05:53
@zurfyx
zurfyx force-pushed the zurfyx/y-move-keeps-unknown-fields branch from a4a2302 to 89665c7 Compare October 29, 2026 06:15
@zurfyx
zurfyx force-pushed the zurfyx/y-move-keeps-unknown-fields branch from b2da1ef to 4054696 Compare October 29, 2026 19:33
…emote changes to the line being composed until the composition ends

While the local editor composes with an IME in a line, `@lexical/y` held back only plain remote inserts into that line, and only when the change had no deletion, format or marker. It read everything else mid-composition, and the hold-back itself could lose or duplicate text:

- #9492: in a line made by joining two lines, the deleted marker of the join made a remote insert look structural, so it was read mid-composition.
- A remote bold or comment split the text node being composed (S7-9). A remote Enter later in the line gave the composed text a new key, so the browser restarted the composition and committed its first character for everyone (`alpha betan你好 ga`, S7-15).
- Enter while composing, after a collaborator typed in the line: the held word never showed, and the composer's next edit of the line deleted it for everyone (S7-12).
- A remote paste into the composed line followed by typing in it, two people composing at one place, a joined line with a bold first word, or offline edits at the start of an emptied line stored a word twice (S7-28).
- In a highlighted code line, a remote format during a composition left the composer with other code-highlight segments than its peers and a fresh read, and the self-check warned (S7-8).

This change replaces that with one rule. While the editor composes in a line, every remote change to that line that Yjs keeps whole is held back: inserted text and inline nodes, formats and comments, line markers (a split, pasted lines), deletions of what is held, and the attributes set on the line's marker, with the values they had, taken from the transaction. Every reader and writer of the line works on the editor's view of it, Yjs minus what is held (`Hidden`). A change that removes something the editor shows (a deletion, a join, a `MarkNode` wrap) is read at once and whole; the text node being composed then keeps its key for the text that holds the composition caret, found by Yjs identity. The hold lasts while the composition goes on in that line and everything held is still in it. When it ends (the composition ends or moves, or a local edit such as Enter, Backspace or a paste takes held content out of the line), one release path reads every held change where its content is now. That read goes through the binding's fail-closed entry point (`guardedRead`), like every read: the release clears the hold first, so the read shows what it held, and a read that throws reads the whole document again. A local edit of the composed line's attributes keeps each held value it did not change. The text node a read shows a piece in also takes that piece's segment key (S7-8).

Behavior changes: a remote split or change of type of the line being composed now waits for the composition to end, and a held insert survives a remote join (two existing tests in `Composition.test.ts` are updated). A code block turned into a heading still replaces the composed node, because its class changes. `y.md` documents the rule and its limits.

Closes #9492

`.changeset/lexical-y-composition-holdback.md`: `[fix]` for `@lexical/y`, with a `Verify:` paragraph.

New tests:

- `packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts`: one case per finding, #9492, S7-8, S7-9, S7-12, S7-15 and S7-28 in four shapes, and R-1 to R-4 from a review of this change (R-5 is in the block-id file).
- `packages/lexical-y/src/__tests__/unit/CompositionHoldBack.test.ts` (matrix in `__tests__/utils/compositionHoldBack.ts`): 6 line shapes x 13 remote changes x 9 ways the composition ends or the composer edits the line's structure x 2 client id orders. Each case checks that the line keeps its nodes while a change is held, that the composed node keeps its key, that every editor equals a fresh read with a quiet self-check, and that nothing a collaborator did is lost or stored twice. `CompositionHoldBackBlockIds.test.ts` runs part of it with BlockIdExtension.
- `packages/lexical-playground/__tests__/e2e/CollabYNodes.spec.mjs`: a peer presses Enter later in the line being composed (IME driven through CDP).

On `main` (0cd7448):

```
$ ./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts packages/lexical-y/src/__tests__/unit/CompositionHoldBack.test.ts packages/lexical-y/src/__tests__/unit/CompositionHoldBackBlockIds.test.ts
 ❯ |unit| packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts (40 tests | 32 failed) 1702ms
 ❯ |unit| packages/lexical-y/src/__tests__/unit/CompositionHoldBackBlockIds.test.ts (157 tests | 79 failed) 8308ms
 ❯ |unit| packages/lexical-y/src/__tests__/unit/CompositionHoldBack.test.ts (1404 tests | 802 failed) 59265ms
 Test Files  3 failed (3)
      Tests  913 failed | 688 passed (1601)
```

```
 FAIL  |unit| packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts > #9492: a remote insert into a line made by joining two lines, while composing > waits for the composition to end (insert at 1, ids 1,2)
AssertionError: expected 'aXYbc def ghiて' to be 'abc def ghiて' // Object.is equality

 FAIL  |unit| packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts > S7-8: a peer formats a word of a highlighted code line while A composes in it > A, B and a fresh read agree after the composition (completed, ids 1,2)
AssertionError: expected [ …(3) ] to deeply equal []
+   "self-check: line 94: format k at 5",
+   "self-check: line 94: format k at 5",

 FAIL  |unit| packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts > S7-9: a peer bolds or comments a word in the text node A composes in > the composed node keeps its text until the composition ends (bold, ids 1,2)
Expected: "alpha beta gamma に"
Received: "alpha "

 FAIL  |unit| packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts > S7-12: Enter while composing, after a peer typed in the composed line > the held word shows, and stays (Enter at the composition start, then the end, ids 1,2)
Expected: "paragraph() / paragraph("s1z s2z s3z ")"
Received: "paragraph() / paragraph("s1z skb5z 2z s3z ")"

 FAIL  |unit| packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts > S7-15: a peer presses Enter later in the line A composes in > the composed node keeps its key, text and line (ids 1,2)
AssertionError: expected 1 to be +0 // Object.is equality

 FAIL  |unit| packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts > S7-28: a peer pastes paragraphs into the composed line, then types in them > every word is stored once (one composition step, ids 1,2)
AssertionError: expected 2 to be 1 // Object.is equality
```

```
$ E2E_BROWSER=chromium E2E_EDITOR_MODE=rich-text-with-collab-y ./node_modules/.bin/playwright test --project=chromium --workers=1 --retries=0 --repeat-each=3 packages/lexical-playground/__tests__/e2e/CollabYNodes.spec.mjs -g 'Collab Y: IME'
  ✘   3 [chromium] › packages/lexical-playground/__tests__/e2e/CollabYNodes.spec.mjs:221:3 › Collab Y: IME › compose while a peer splits the line after the composition (12.6s)
  ✘   7 [chromium] › packages/lexical-playground/__tests__/e2e/CollabYNodes.spec.mjs:221:3 › Collab Y: IME › compose while a peer splits the line after the composition (12.5s)
  ✘  11 [chromium] › packages/lexical-playground/__tests__/e2e/CollabYNodes.spec.mjs:221:3 › Collab Y: IME › compose while a peer splits the line after the composition (13.3s)
    -   "paragraph:s1z s2z日本 s3z",
    +   "paragraph:s1z s2zにh日本 s3z",
  3 failed
  9 passed (2.9m)
```

```
$ ./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts packages/lexical-y/src/__tests__/unit/CompositionHoldBack.test.ts packages/lexical-y/src/__tests__/unit/CompositionHoldBackBlockIds.test.ts
 ✓ |unit| packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts (40 tests) 2015ms
 ✓ |unit| packages/lexical-y/src/__tests__/unit/CompositionHoldBackBlockIds.test.ts (157 tests) 10015ms
 ✓ |unit| packages/lexical-y/src/__tests__/unit/CompositionHoldBack.test.ts (1404 tests) 73260ms
 Test Files  3 passed (3)
      Tests  1601 passed (1601)
```

```
$ ./node_modules/.bin/vitest --project unit --project scripts-unit --no-watch
 Test Files  563 passed | 3 skipped (566)
      Tests  16840 passed | 16 expected fail | 4 skipped (16860)
```

```
$ E2E_BROWSER=chromium E2E_EDITOR_MODE=rich-text-with-collab-y ./node_modules/.bin/playwright test --project=chromium packages/lexical-playground/__tests__/e2e/CollabYNodes.spec.mjs packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs packages/lexical-playground/__tests__/e2e/CollabYSync.spec.mjs packages/lexical-playground/__tests__/e2e/CollabYStructure.spec.mjs
  ✓  10 [chromium] › packages/lexical-playground/__tests__/e2e/CollabYNodes.spec.mjs:176:3 › Collab Y: IME › compose in a line while a peer types in the same line (14.3s)
  ✓  11 [chromium] › packages/lexical-playground/__tests__/e2e/CollabYNodes.spec.mjs:197:3 › Collab Y: IME › compose while a peer splits the line before the composition (13.5s)
  ✓  12 [chromium] › packages/lexical-playground/__tests__/e2e/CollabYNodes.spec.mjs:221:3 › Collab Y: IME › compose while a peer splits the line after the composition (13.4s)
  ✓  13 [chromium] › packages/lexical-playground/__tests__/e2e/CollabYNodes.spec.mjs:245:3 › Collab Y: IME › compose in a line while a peer joins it into the previous line (13.6s)
  32 passed (2.9m)
```

```
$ pnpm run ci-check
Changeset check passed: 5 pending changeset(s) valid.
Found 0 errors
```

Not exercised: the collab Y e2e specs in Firefox and WebKit (the IME specs drive the composition through CDP, so they run in Chromium only), and a manual check with a real IME.
…e composing line (S7-37)

## Description

S7-37 (soak7, the S7-28 family without a structural edit): A composes at the start of a line that holds an italic run. B types a word in that line, which A holds back, and deletes text after the italic run, which A reads at once. C then types inside B's word. On `main`, A's next composition step stored C's word twice for everyone, and a remote delete in the composing line also showed the held inserts mid-composition. The previous commit already fixes both: a read of the line keeps skipping what is held, and the composer writes only what it shows.

This commit adds the regression test and the shape to the hold-back matrix:

- A line whose text is three nodes (an italic run in the middle).
- A remote change made of an insert, a delete before it, and a third peer's insert inside the held word.

One combination is a known limit, in the S7-8 class: A joins a highlighted code line into the paragraph before while it composes during that change. The self-check then notes a code highlight type until the hold ends, after which every client shows the same. The matrix skips those two cases and says why.

## Test plan

### Before

On `main` (0cd7448):

```
$ ./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts -t S7-37
     × every word is stored once (ids 1,2,3) 170ms
     × every word is stored once (ids 3,2,1) 64ms
     × every word is stored once (ids 2,1,3) 53ms
AssertionError: expected 2 to be 1 // Object.is equality
      Tests  3 failed | 40 skipped (43)

$ ./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/CompositionHoldBack.test.ts -t 'insert, a delete, an insert in it|formatted run'
      Tests  124 failed | 234 passed | 1406 skipped (1764)
```

### After

```
$ ./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts -t S7-37
 ✓ |unit| packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts (43 tests | 40 skipped) 267ms
      Tests  3 passed | 40 skipped (43)

$ ./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/CompositionHoldBack.test.ts -t 'insert, a delete, an insert in it|formatted run'
      Tests  358 passed | 1406 skipped (1764)

$ ./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/
 Test Files  139 passed | 2 skipped (141)
      Tests  6648 passed | 12 expected fail | 4 skipped (6664)

$ LEXICAL_Y_BLOCK_ID=1 ./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/CompositionHoldBack.test.ts packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts
 Test Files  2 passed (2)
      Tests  1805 passed | 2 skipped (1807)

$ pnpm run ci-check
Changeset check passed: 5 pending changeset(s) valid.
Found 0 errors
```
… and keep held ones on both halves of a split

Two defects found in the composition hold-back by a review across PRs:

- **MF-1:** the hold captured every marker attribute a collaborator's change set, including keys the client does not own, such as a field a newer version of a class declares. When the composing client split the line, it wrote such a key onto both halves; without a composition, it stays on one. A change of only such keys also created a hold that holds nothing the client shows. The hold now captures only the keys the client owns, by Schema's ownership rule (`ownsMarkerAttr`: the binding's own keys, NodeState, and the fields its classes declare, from `fieldsOfTypes` over `ownedFieldsOf`). Ownership is judged over the types the change goes from and to, and the type the editor shows, so a change of type owns the fields of both. A change of keys the client does not own creates no hold and needs no read.
- **B-2:** a held attribute was lost on one half of a split made while composing. When a collaborator centered the line and the composing client pressed Enter mid-line, only one half stayed centered, and the client's write deleted `center` from the original marker. Paragraph Enter inherits the alignment, so both halves keep it now. The hold records every marker that stores the held attributes: the composed line's, and the old marker that the new line holding the rest of it ends at. A line on one of them keeps a held value its write left as the editor shows it. A line the composed line was joined into keeps its own values, as does the empty line Enter makes at the end of the line. Only an explicit change of the composed line itself ends the hold of an attribute, and the release reads every recorded marker.

New in `packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts`:

- B-2: both halves keep a held alignment or type and the lines around do not get it, plus a local explicit change of the alignment, which wins.
- MF-1: a field the client does not know, alone or with an owned alignment. Nothing of it is held, and after Enter it is stored on one marker.

The matrix in `__tests__/utils/compositionHoldBack.ts` now checks the composed line itself and the lines around it, instead of any line.

`./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts -t 'B-2|MF-1'`

On the previous commit, before the restack onto #9504 (6bc5caf):

```
     × both halves keep it, the other lines do not get it (center, ids 1,2) 142ms
     × both halves keep it, the other lines do not get it (heading, ids 1,2) 57ms
     ✓ both halves keep it, the other lines do not get it (center, then A aligns it left, ids 1,2) 49ms
     × both halves keep it, the other lines do not get it (center, ids 2,1) 40ms
     × both halves keep it, the other lines do not get it (heading, ids 2,1) 47ms
     ✓ both halves keep it, the other lines do not get it (center, then A aligns it left, ids 2,1) 50ms
     × A holds nothing of it and stores it once after Enter (alone, ids 1,2) 21ms
     × A holds nothing of it and stores it once after Enter (with an alignment, ids 1,2) 18ms
     × A holds nothing of it and stores it once after Enter (alone, ids 2,1) 19ms
     × A holds nothing of it and stores it once after Enter (with an alignment, ids 2,1) 20ms
AssertionError: expected [ 'paragraph::pre', …(3) ] to deeply equal [ 'paragraph::pre', …(3) ]
AssertionError: expected [ 'p:future' ] to deeply equal []
AssertionError: expected [ 'p:future', 'p:format' ] to deeply equal [ 'p:format' ]
      Tests  8 failed | 2 passed | 43 skipped (53)
```

```
 ✓ |unit| packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts (53 tests | 43 skipped) 546ms
      Tests  10 passed | 43 skipped (53)

$ ./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/CompositionHoldBack.test.ts packages/lexical-y/src/__tests__/unit/CompositionHoldBackBlockIds.test.ts packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts packages/lexical-y/src/__tests__/unit/Composition.test.ts
 ✓ |unit| packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts (53 tests) 2261ms
 ✓ |unit| packages/lexical-y/src/__tests__/unit/Composition.test.ts (63 tests) 6976ms
 ✓ |unit| packages/lexical-y/src/__tests__/unit/CompositionHoldBackBlockIds.test.ts (169 tests) 10316ms
 ✓ |unit| packages/lexical-y/src/__tests__/unit/CompositionHoldBack.test.ts (1764 tests | 2 skipped) 86242ms
 Test Files  4 passed (4)
      Tests  2047 passed | 2 skipped (2049)

$ pnpm run ci-check
Changeset check passed: 5 pending changeset(s) valid.
Found 0 errors
```
…ditor shows it when a remote change waits to be read

## Description

While a line is being composed, a collaborator's change of its marker
attributes is held back (`Hidden#marker`: `was`, the values the editor
shows, and `now`, the values the change set). The `Writer` constructor
refreshed `now` from raw Yjs, for the case of a remote change that arrived
while the local update waited to be written. Since a local update made
before an unread remote change is written on the document its editor shows
(see `Unread`), that refresh read a value the binding had not read yet, and
the write set it as the client's own: a composition step that never touched
the alignment re-asserted a collaborator's unread value and changed which
concurrent value Yjs kept (three clients, ids 3, 1, 2: `right` instead of
`justify`).

The refresh is gone. `now`, as last read, is already the held value in the
document the write sees; the unread change is read after the write and is
held as any other.

## Test plan

### Before

```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts -t N2
     × writes no alignment of its own and ends as without the step (ids 3,1,2)
     × writes no alignment of its own and ends as without the step (ids 2,1,3)
     × writes no alignment of its own and ends as without the step (ids 1,2,3)
AssertionError: expected 1 to be +0 // Object.is equality
      Tests  3 failed | 53 skipped (56)
```

### After

```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts -t N2
 ✓ |unit| packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts (56 tests | 53 skipped)
      Tests  3 passed | 53 skipped (56)
```
…, and keep one reader of the attributes before a change

## Description

A local update made before a remote change the binding has not read yet is
written on the document its editor shows (see `Unread`). The hold-back of a
composing line still read raw deletedness next to view-aware checks:
`YBinding#$holds`, which runs from the update listener while that view is
active, judged held items and held markers by `item.deleted` beside
`isShownEnd`, and `isShown` in `Hidden.ts` used `item.deleted` beside
`shownLength`, which uses `isGone`. Both now use `isGone`, so a hold is
judged on the document the editor shows. Reads (the Reader, `$release`) run
outside the view, where `isGone` is `item.deleted`, so they are unchanged.

`YBinding` also had its own copy of "the attributes before a transaction"
for the Reader's held-attribute check. It now uses `attrsBefore` from
`Unread.ts`, the one owner of that read (in a read there is no unread view,
so the values are the same).

`Hidden#marker`'s TSDoc pointed to `ownsMarkerKey`, which does not exist; it
now points to `ownsMarkerAttr` and `Reader#$ownedChanges`.

## Test plan

No behaviour change outside the unread window; the composition suites pass
unchanged.

### After

```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts packages/lexical-y/src/__tests__/unit/CompositionHoldBack.test.ts packages/lexical-y/src/__tests__/unit/Composition.test.ts packages/lexical-y/src/__tests__/unit/BatchedWrites.test.ts packages/lexical-y/src/__tests__/unit/CompositionHoldBackBlockIds.test.ts
 Test Files  5 passed (5)
      Tests  2078 passed | 1 expected fail | 2 skipped (2081)
```
…client stores when it splits the line

## Description

The MF-1 regression test asserted that a field the composing client does
not own is stored on one line after Enter in the middle of the line. That
was never a property of the composition: a split by a client that does not
own a field keeps it on both lines, composing or not (see the N1 tests in
`SchemaSkew.test.ts`), so the test failed once the moved-block fix it sits
on was below it.

The test now runs the same edits with and without a composition and
requires the same markers, and still checks what the composition holds
back (nothing of the field; the alignment only when it changes too). It
holds under either split rule.

## Test plan

### Before

```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts -t MF-1
     × A holds nothing of it and stores it once after Enter (alone, ids 1,2)
     × A holds nothing of it and stores it once after Enter (with an alignment, ids 1,2)
     × A holds nothing of it and stores it once after Enter (alone, ids 2,1)
     × A holds nothing of it and stores it once after Enter (with an alignment, ids 2,1)
AssertionError: expected [ …(2) ] to have a length of 1 but got 2
```

### After

```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts packages/lexical-y/src/__tests__/unit/CompositionHoldBack.test.ts packages/lexical-y/src/__tests__/unit/Composition.test.ts packages/lexical-y/src/__tests__/unit/BatchedWrites.test.ts packages/lexical-y/src/__tests__/unit/CompositionHoldBackBlockIds.test.ts
 Test Files  5 passed (5)
      Tests  2078 passed | 1 expected fail | 2 skipped (2081)
```
…its and pastes

## Description

Releasing a displaced composition hold can materialize a remote paste before
that transaction is applied again. Holding the already-visible inserts a second
time makes the writer's baseline disagree with the editor, duplicating pasted
text or losing later composition input. Remember inline owners consumed by the
same Reader so those inserts are read, not held again.

Track shown and stored starting formats separately for held-derived split
units, including their later split descendants. Release reads those surviving
units even when their stored-format cache already agrees, so remote formatting
is rendered instead of leaving a stale local view.

Add regression coverage for pasted-text multiplicity, same-update composition
end and Enter, subsequent splits, composition input preservation, and pasted
list block-ID NodeState. The test harness can explicitly enable BlockIdExtension
per peer while preserving its previous default and fresh-reader behavior.

## Test plan

### Before

Original regression subset, recorded before the additional cases were added:

```text
$ pnpm exec vitest run --project unit --maxWorkers=4 packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts -t 'ending composition after a held prefix insert|a held bold opener survives'
 Test Files  1 failed (1)
      Tests  4 failed | 60 skipped (64)
AssertionError: after end: [ke7]: expected 2 to be 1
AssertionError: released prefix must be bold: expected false to be true
```

### After

```text
$ pnpm exec vitest run --project unit --maxWorkers=4 packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts
 Test Files  1 passed (1)
      Tests  66 passed (66)

$ pnpm exec vitest run --project unit --maxWorkers=8 packages/lexical-y/src/__tests__/unit packages/lexical/src/__tests__/unit
 Test Files  236 passed | 2 skipped (238)
      Tests  8974 passed | 42 expected fail | 4 skipped (9020)

$ pnpm run ci-check --continue-on-error --print-label --print-name --aggregate-output
[check-changesets] Changeset check passed: 16 pending changeset(s) valid.
[flow            ] Found 0 errors
FINAL STATIC CI END 2026-10-29T10:18:04.156564+00:00 exit=0
```

All nine configured CI checks completed. Browser and e2e tests were not run.
@zurfyx
zurfyx changed the base branch from zurfyx/y-move-keeps-unknown-fields to main October 29, 2026 19:50
@zurfyx
zurfyx force-pushed the zurfyx/y-composition-holdback branch from 16a85fb to 005b397 Compare October 29, 2026 19:50
@zurfyx
zurfyx added this pull request to the merge queue Oct 29, 2026
Merged via the queue into main with commit d2a1fbb Oct 29, 2026
53 checks passed

This branch was successfully deployed

2 active deployments
Preview – lexical — 005b3977 Deployed Oct 29, 2026 by vercel[bot]
Preview – lexical-playground — 005b3977 Deployed Oct 29, 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.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants