Skip to content

[lexical-y] Bug Fix: Keep a rewrite's unchanged characters next to a word that was a node of its own - #9522

Merged
zurfyx merged 1 commit into
mainfrom
zurfyx/y-keep-chars-at-format-boundary
Oct 29, 2026
Merged

zurfyx merged 1 commit into
mainfrom
zurfyx/y-keep-chars-at-format-boundary

Conversation

@zurfyx

@zurfyx zurfyx commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

Note

Stack, part 3 of 10. Based on #9523 (zurfyx/write-before-unread-change). 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 (this PR) 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 zurfyx/y-composition-holdback #9527
10 #9510 zurfyx/y-incremental-read-marks #9507

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

Description

When a line's children are made again as one node with one word changed
(an agent's block rewrite), @lexical/y wrote some unchanged characters
again as new text, if the changed word had been a text node of its own (for
example italic). A collaborator's concurrent edit of that word then landed
inside the word before it, on every client:

  • "t16z " | "t63z " | " t19z", rewritten as one plain node with
    t63z changed to t80z, wrote "z t80z " instead of "t80z ". A
    collaborator who replaced t63z with t83z at the same time got
    "t16t83zz t80z t19z".
  • The same happens when the dropped node is the last one ("t58z t59z" |
    " t60z t61z" rewrote the z of t59z), and with an IME composition in
    the rewritten line.

The cause is in how a line's characters are matched. A removed node's text
is matched whole or not at all, so the italic "t63z " is deleted whole and
the old text is matched without it, as "t16z t19z". The final check keeps
only what a second diff, which takes the common end first, keeps too. Over
that shortened text, the second diff read the change as "z t80" inserted
inside t16z. It disagreed with the first diff about the z , and the
check dropped it.

The check now also reads the old text as it was, with the text deleted whole
still in place, wherever the shortened text left some out. A character
either reading keeps the same way is kept. The change can only keep more
than before, and only where a removed node's text was deleted whole.
Elsewhere the stored bytes are unchanged.

Restack (October 29)

Rebased onto #9523 with no conflicts. It adds no raw attribute or deletedness reads on the write path, so nothing here changes for #9523's view.

Changeset

.changeset/lexical-y-keep-chars-at-format-boundary.md: [fix] for
@lexical/y, with a Verify: paragraph.

Test plan

New regression cases in
packages/lexical-y/src/__tests__/unit/regressions/TextIdentity.test.ts:
three line shapes, each in both client orders. Each checks the rewrite's own
deleted and inserted text, and that the text before the changed node stays
whole on every client and in a fresh read.

Before

$ npx vitest --project unit --no-watch packages/lexical-y/src/__tests__/unit/regressions/TextIdentity.test.ts -t "S7-39"
 Test Files  1 failed (1)
      Tests  6 failed | 95 skipped (101)

AssertionError: expected { del: 'z t63z ', ins: 'z t80z ' } to deeply equal { del: 't63z ', ins: 't80z ' }
AssertionError: expected { del: ' cd ', ins: ' gh ' } to deeply equal { del: 'cd ', ins: 'gh ' }
-   "del": " t60z t61z",
-   "ins": " t70z t61z",
+   "del": "z t60z t61z",
+   "ins": "z t70z t61z",

After

$ npx vitest --project unit --no-watch packages/lexical-y/src/__tests__/unit/regressions/TextIdentity.test.ts -t "S7-39"
      Tests  6 passed | 95 skipped (101)

$ npx vitest --project unit --no-watch --maxWorkers=7 packages/lexical-y
 Test Files  148 passed | 2 skipped (150)
      Tests  4919 passed | 16 expected fail | 2 skipped (4937)

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

I also ran randomized text-identity sessions from earlier reviews (a local
harness, not committed): 1,000 seeds of whole-node sets and swaps, 1,000 of
sets with made nodes, and 800 of plugin, agent and paste sessions. Every
summary and failure list matched main. The stored document state matched
main in all 783 sessions that are deterministic across two runs of main. A
replay of a randomized IME session that showed this bug (a composed word
landing inside the word before it) no longer does. Browser e2e was not
run.

@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 5:55pm UTC
lexical-playground Ready Ready Preview Oct 29, 2026 5:55pm 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@9522

@lexical/a11y

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

@lexical/clipboard

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

@lexical/code

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

@lexical/code-core

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

@lexical/code-prism

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

@lexical/code-shiki

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

@lexical/compiler

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

@lexical/devtools-core

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

@lexical/dragon

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

@lexical/eslint-plugin

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

@lexical/extension

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

@lexical/fast-check

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

@lexical/file

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

@lexical/hashtag

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

@lexical/headless

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

@lexical/history

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

@lexical/html

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

@lexical/internal

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

@lexical/link

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

@lexical/list

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

@lexical/mark

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

@lexical/markdown

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

@lexical/mdast

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

@lexical/offset

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

@lexical/overflow

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

@lexical/plain-text

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

@lexical/react

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

@lexical/rich-text

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

@lexical/selection

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

@lexical/table

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

@lexical/tailwind

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

@lexical/text

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

@lexical/utils

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

@lexical/y

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

@lexical/yjs

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

commit: f45dfa2

@zurfyx
zurfyx force-pushed the zurfyx/y-keep-chars-at-format-boundary branch from cc442e2 to 3638f93 Compare October 29, 2026 05:32
@zurfyx
zurfyx changed the base branch from main to zurfyx/write-before-unread-change October 29, 2026 05:33
@zurfyx
zurfyx force-pushed the zurfyx/write-before-unread-change branch from 334bfea to 2dd6321 Compare October 29, 2026 17:35
@zurfyx
zurfyx force-pushed the zurfyx/y-keep-chars-at-format-boundary branch from 3638f93 to eabc52e Compare October 29, 2026 17:51
@zurfyx
zurfyx changed the base branch from zurfyx/write-before-unread-change to main October 29, 2026 17:51
…word that was a node of its own

## Description

When a line's children are made again as one node with one word changed
(an agent's block rewrite), `@lexical/y` wrote some unchanged characters
again as new text, if the changed word had been a text node of its own (for
example italic). A collaborator's concurrent edit of that word then landed
inside the word before it, on every client:

- `"t16z "` | _`"t63z "`_ | `" t19z"`, rewritten as one plain node with
  `t63z` changed to `t80z`, wrote `"z t80z "` instead of `"t80z "`. A
  collaborator who replaced `t63z` with `t83z` at the same time got
  `"t16t83zz t80z  t19z"`.
- The same happens when the dropped node is the last one (_`"t58z t59z"`_ |
  `" t60z t61z"` rewrote the `z` of `t59z`), and with an IME composition in
  the rewritten line.

The cause is in how a line's characters are matched. A removed node's text
is matched whole or not at all, so the italic `"t63z "` is deleted whole and
the old text is matched without it, as `"t16z  t19z"`. The final check keeps
only what a second diff, which takes the common end first, keeps too. Over
that shortened text, the second diff read the change as `"z t80"` inserted
inside `t16z`. It disagreed with the first diff about the `z `, and the
check dropped it.

The check now also reads the old text as it was, with the text deleted whole
still in place, wherever the shortened text left some out. A character
either reading keeps the same way is kept. The change can only keep more
than before, and only where a removed node's text was deleted whole.
Elsewhere the stored bytes are unchanged.

## Changeset

`.changeset/lexical-y-keep-chars-at-format-boundary.md`: `[fix]` for
`@lexical/y`, with a `Verify:` paragraph.

## Test plan

New regression cases in
`packages/lexical-y/src/__tests__/unit/regressions/TextIdentity.test.ts`:
three line shapes, each in both client orders. Each checks the rewrite's own
deleted and inserted text, and that the text before the changed node stays
whole on every client and in a fresh read.

### Before

```
$ npx vitest --project unit --no-watch packages/lexical-y/src/__tests__/unit/regressions/TextIdentity.test.ts -t "S7-39"
 Test Files  1 failed (1)
      Tests  6 failed | 95 skipped (101)

AssertionError: expected { del: 'z t63z ', ins: 'z t80z ' } to deeply equal { del: 't63z ', ins: 't80z ' }
AssertionError: expected { del: ' cd ', ins: ' gh ' } to deeply equal { del: 'cd ', ins: 'gh ' }
-   "del": " t60z t61z",
-   "ins": " t70z t61z",
+   "del": "z t60z t61z",
+   "ins": "z t70z t61z",
```

### After

```
$ npx vitest --project unit --no-watch packages/lexical-y/src/__tests__/unit/regressions/TextIdentity.test.ts -t "S7-39"
      Tests  6 passed | 95 skipped (101)

$ npx vitest --project unit --no-watch --maxWorkers=7 packages/lexical-y
 Test Files  148 passed | 2 skipped (150)
      Tests  4919 passed | 16 expected fail | 2 skipped (4937)

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

I also ran randomized text-identity sessions from earlier reviews (a local
harness, not committed): 1,000 seeds of whole-node sets and swaps, 1,000 of
sets with made nodes, and 800 of plugin, agent and paste sessions. Every
summary and failure list matched main. The stored document state matched
main in all 783 sessions that are deterministic across two runs of main. A
replay of a randomized IME session that showed this bug (a composed word
landing inside the word before it) no longer does. Browser e2e was not
run.
@zurfyx
zurfyx force-pushed the zurfyx/y-keep-chars-at-format-boundary branch from eabc52e to f45dfa2 Compare October 29, 2026 17:54

This branch was successfully deployed

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

2 participants