Repository navigation
[lexical-playground][lexical-website][lexical-y] Bug Fix: Hold back remote changes to the line being composed until the composition ends - #9507
Merged
Conversation
zurfyx
requested review from
acywatson,
etrepum,
ivailop7 and
potatowagon
as code owners
October 29, 2026 01:01
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
lexical
@lexical/a11y
@lexical/clipboard
@lexical/code
@lexical/code-core
@lexical/code-prism
@lexical/code-shiki
@lexical/compiler
@lexical/devtools-core
@lexical/dragon
@lexical/eslint-plugin
@lexical/extension
@lexical/fast-check
@lexical/file
@lexical/hashtag
@lexical/headless
@lexical/history
@lexical/html
@lexical/internal
@lexical/link
@lexical/list
@lexical/mark
@lexical/markdown
@lexical/mdast
@lexical/offset
@lexical/overflow
@lexical/plain-text
@lexical/react
@lexical/rich-text
@lexical/selection
@lexical/table
@lexical/tailwind
@lexical/text
@lexical/utils
@lexical/y
@lexical/yjs
commit: |
zurfyx
force-pushed
the
zurfyx/y-composition-holdback
branch
from
October 29, 2026 02:54
6bc5caf to
ac575d2
Compare
This was referenced Oct 29, 2026
Merged
zurfyx
force-pushed
the
zurfyx/y-composition-holdback
branch
from
October 29, 2026 05:53
ac575d2 to
cf109e0
Compare
zurfyx
force-pushed
the
zurfyx/y-preserve-unknown-attrs
branch
from
October 29, 2026 05:53
7a4a983 to
7fcd687
Compare
zurfyx
changed the base branch from
zurfyx/y-preserve-unknown-attrs
to
zurfyx/y-move-keeps-unknown-fields
October 29, 2026 05:53
zurfyx
force-pushed
the
zurfyx/y-move-keeps-unknown-fields
branch
from
October 29, 2026 06:15
a4a2302 to
89665c7
Compare
zurfyx
force-pushed
the
zurfyx/y-composition-holdback
branch
from
October 29, 2026 06:15
cf109e0 to
a0564e7
Compare
zurfyx
force-pushed
the
zurfyx/y-composition-holdback
branch
from
October 29, 2026 06:39
a0564e7 to
2af685c
Compare
zurfyx
force-pushed
the
zurfyx/y-move-keeps-unknown-fields
branch
from
October 29, 2026 06:39
89665c7 to
c552868
Compare
zurfyx
force-pushed
the
zurfyx/y-move-keeps-unknown-fields
branch
from
October 29, 2026 10:47
c552868 to
b2da1ef
Compare
zurfyx
force-pushed
the
zurfyx/y-composition-holdback
branch
from
October 29, 2026 10:47
2af685c to
16a85fb
Compare
vjeux
approved these changes
Oct 29, 2026
zurfyx
force-pushed
the
zurfyx/y-move-keeps-unknown-fields
branch
from
October 29, 2026 19:33
b2da1ef to
4054696
Compare
…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
changed the base branch from
zurfyx/y-move-keeps-unknown-fields
to
main
October 29, 2026 19:50
zurfyx
force-pushed
the
zurfyx/y-composition-holdback
branch
from
October 29, 2026 19:50
16a85fb to
005b397
Compare
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Note
Stack, part 9 of 10. Based on #9527 (
zurfyx/y-move-keeps-unknown-fields). Review only this PR's own commits.The
@lexical/yfixes 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
mainbefore merging it (git rebase --onto main <old base branch>).The stack
zurfyx/update-error-rollbackmainzurfyx/write-before-unread-changezurfyx/y-keep-chars-at-format-boundaryzurfyx/y-undo-member-closerzurfyx/y-initial-state-bindingzurfyx/y-reorder-intentzurfyx/y-preserve-unknown-attrszurfyx/y-move-keeps-unknown-fieldszurfyx/y-composition-holdbackzurfyx/y-incremental-read-marksFollow-ups for after the stack (not in it):
Unread(changes Yjs applied that the binding has not read) and [lexical-playground][lexical-website][lexical-y] Bug Fix: Hold back remote changes to the line being composed until the composition ends #9507'sHidden(changes held back while a line is composed) answer one question, and call sites combine them by hand. One owner module (isGone,isShown,attrOf/attrsOfand the write primitives), an item-level held overlay instead of thewas/nowmaps, and a lint or grep test that allows raw.deleted/getAttr(s)only in the Reader and in named provenance scans. [lexical-website][lexical-y] Bug Fix: Stop a format, comment or link from spreading to the end of the document after an undo or delete races an edit inside it #9505's run-opener scans (liveItemBefore,runOpener,restatedEnd) still read raw deletedness and move there too.$groupReference,$priorGroupItem, the nearest neighbour's marker before the write,priorGroupMarker), with separate predecessor stores for markers and nodes. One stored-predecessor lookup would replace it.Description
While the local editor composes with an IME in a line,
@lexical/yheld 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:alpha betan你好 ga, S7-15).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, aMarkNodewrap) 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.tsare updated). A code block turned into a heading still replaces the composed node, because its class changes.y.mddocuments the rule and its limits.Follow-up commits:
Closes #9492
Restack (October 29)
Rebased onto #9527 (it was based on #9504). Conflicts, resolved in its first commit:
Binding.ts,Cursor.ts,State.tsandWrite.ts:isShownEndis!isGone && isEndItem && !hidden.hides; the cursor steps overisGone(r) || isHeldFormat(r);unitLeftandprevLiveEndskip gone ends.Write.ts:$writeMarker(item, d: Desired)withlet desired = this.$marker(d), then this PR's held merge, before owners and$markerAsStoredare computed.New commits:
Writerconstructor no longer refreshes held marker values from raw Yjs. Under [lexical-y] Bug Fix: Write a local update made before an unread remote change on the document it was made on #9523 that read a remote change the binding had not read yet and wrote it as the client's own, so a composition step that never touched the alignment changed which concurrent value Yjs kept (justifybecameright, three clients). Test:N2:inregressions/Composition.test.ts.$holdsandHidden'sisShownjudge deletedness withisGone, asshownLengthandisShownEnddo, and the Reader's held-attribute check usesattrsBeforefromUnread.tsinstead of a copy inBinding.ts.Hidden#marker's TSDoc points toownsMarkerAttr.Fixed after a randomized soak test of the whole stack (one commit):
insertParagraphand composition data ending in a line break do), also after later splits of that line.cursor advanced past the end, which dropped every later step and lost the composed text.Tests
L1C-3:,L1C-4:,S8-4:andS8-6:inregressions/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 aVerify: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.tsruns 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):After
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)The #9504 test
SchemaSkew.test.ts"releasing what a composition held back" now injects its reader failure into$applyon the composing client, because the release no longer calls$refreshComposed.