Repository navigation
[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
Merged
Conversation
zurfyx
requested review from
acywatson,
ivailop7 and
potatowagon
as code owners
October 29, 2026 00:14
|
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-undo-member-closer
branch
from
October 29, 2026 01:03
008e924 to
091a734
Compare
zurfyx
force-pushed
the
zurfyx/y-undo-member-closer
branch
from
October 29, 2026 05:32
49ea400 to
45c9c15
Compare
zurfyx
changed the base branch from
main
to
zurfyx/y-keep-chars-at-format-boundary
October 29, 2026 05:33
This was referenced Oct 29, 2026
Merged
This was referenced Oct 29, 2026
vjeux
approved these changes
Oct 29, 2026
zurfyx
force-pushed
the
zurfyx/y-keep-chars-at-format-boundary
branch
2 times, most recently
from
October 29, 2026 17:54
eabc52e to
f45dfa2
Compare
…from spreading to the end of the document after an undo or delete races an edit inside it ## Description In `@lexical/y`, a format, comment or link could spread to the end of the document when a collaborator edited inside it while it was undone, or while another peer deleted text at its end. All of these converge on main, on every client and in a fresh read: - A makes "t56z t57z" italic and undoes it while B types plain text after "t56z": everything after B's text, the next paragraph included, turns italic. - B comments "t32z t33z", where a comment of another member of the same set ends, and undoes it while A types after "t33z": the removed comment covers the rest of the document. - B bolds "t6z t7z t8z" and undoes it while A removes bold from "t7z": " t8z t9z" and the next paragraph turn bold. A removal from the whole range, then its undo, does the same. - A undoes bold or a comment while B pastes a list or paragraphs inside it; B undoes a link while A makes text across the link's start italic. - B pastes inside a comment while C, offline for a long time, deletes text at the comment's end: once C is back, the comment covers words far after it. The cause is shared. The client that edits inside the range restates the value there: the write cursor restores the old value after the text it inserts or at the end of the range it formats, and an undo brings back the format items its change deleted. That restated run ended at the closer of the change that opened the run. When that change is undone, its opener and its closer are deleted; a concurrent delete can also drop the closer (it keeps a bounded number of the format items it leaves). Either way the restatement runs on to the end of the document. This change adds one rule, for every stored inline kind (text formats, styles, NodeState and its set members, links): a run that a client restates ends with an item of that client's own. - `Cursor`, format rule 5 (also used after inserted text): where a restore restates a value whose run another client's change opened (following restores back to the run's opener), and the item that ends the restored run is that change's own end of the run, the writer puts a null right after that end. If the change is undone, or a delete drops that end, the null ends the restored run there. It goes after the end, not before it, so text a collaborator types at the end of the run at the same time stays inside the run. A null by which a change made text inside the run plain is not such an end, so undoing that change still brings the value back. - `YBinding#closeRestoredRuns`, the undo path: a format item an undo brings back gets a null of the undoer's own right after the end its original change gave it, where that end is gone. This generalizes the guard added for #9493. The editor reads those nulls as part of the undo, with the selection the undo puts back, so the undoer's editor matches a fresh read. The stored layout does not change, and nothing more is written where no other client's change is involved. The restored value can still stay on the part of the range after the collaborator's edit, and an undone link stays on its own text when a collaborator's format change rewrote the link's textFormat (both documented in y.md); neither reaches past the range any more. ## Changeset `.changeset/lexical-y-undo-run-end.md`: `[fix]` for `@lexical/y`, with a `Verify:` paragraph. ## Test plan New regressions in `packages/lexical-y/src/__tests__/unit/regressions/Formats.test.ts`: each case in both client orders, with comment variants (another member of the set, three comments, comment and bold), the selection an undo puts back (direct and inside an editor update), and a matrix of bold or a comment removed (by undo or by the opposite change) while a collaborator types, presses Enter, pastes paragraphs or a list, or deletes inside the range. ### Before On main (0cd7448): ``` $ npx vitest --project unit --no-watch packages/lexical-y/src/__tests__/unit/regressions/Formats.test.ts Test Files 1 failed (1) Tests 38 failed | 146 passed | 4 expected fail (188) AssertionError: expected '"t56z t85z" " t57z t59z t60z"[I] / "t…' to be '"t56z t85z" " t57z"[I] " t59z t60z" /…' // Object.is equality Expected: ""t56z t85z" " t57z"[I] " t59z t60z" / "t78z t79z"" Received: ""t56z t85z" " t57z t59z t60z"[I] / "t78z t79z"[I]" AssertionError: expected '"t5z t6z t7z" " t8z t9z"[B] / "tail l…' to be '"t5z t6z t7z" " t8z"[B] " t9z" / "tai…' // Object.is equality Expected: ""t5z t6z t7z" " t8z"[B] " t9z" / "tail line"" Received: ""t5z t6z t7z" " t8z t9z"[B] / "tail line"[B]" AssertionError: expected 't7z t8z t9z / tail line' to be 't7z t8z t9z / ' // Object.is equality AssertionError: expected [ ' t57z t59z t60z', 't78z t79z' ] to deeply equal [] ``` ### After ``` $ npx vitest --project unit --no-watch packages/lexical-y/src/__tests__/unit/regressions/Formats.test.ts Test Files 1 passed (1) Tests 184 passed | 4 expected fail (188) $ npx vitest --project unit --no-watch --maxWorkers=6 packages/lexical-y Test Files 148 passed | 2 skipped (150) Tests 4983 passed | 16 expected fail | 2 skipped (5001) $ pnpm run ci-check Changeset check passed: 5 pending changeset(s) valid. Found 0 errors ``` Randomized collaborative sessions with formats, comments and undo (a local soak harness, not committed), compared with main: concurrent undo sessions 112 -> 107 failing of 4000 (none new), sequential undo sessions 200 -> 199 of 4000 (none new), format sessions and the format growth probes unchanged. An undo-vs-comment fuzz spread in 9 of 300 seeds on main and in 0 of 1000 with this change. A replay of the 4-hour session where a peer returned after 81 minutes offline no longer spreads the comment (main: 4 untouched words get it). Long sessions store about 2.5% more format items. Browser e2e was not run.
…it reuses another client's change end ## Description A follow-up to the rule above, for runs a write opens itself. When a write closes the run it opens (its range's value, or inserted text's) by reusing an item already there, and that item is another client's change end, the undo of that change takes the run's end away. With links only, on the previous commit: - B links "t2z t3z t4z" while C links "t3z" with the same URL. Then B undoes its link while C links "t2z t3z t4z" again. C's link over " t4z" closed on B's closer, and once B's undo removed it, the link covered t3z to t9z and the whole next paragraph, on every client and in a fresh read. The write now puts a null of its own right after such a reused end (`Cursor#insertNegatedFormats`, rule 5): the run ends where the write ended it, whatever happens to the other change. ## Test plan New regression in `packages/lexical-y/src/__tests__/unit/regressions/Formats.test.ts`, three clients in both orders. ### Before On the previous commit (091a734): ``` $ npx vitest --project unit --no-watch packages/lexical-y/src/__tests__/unit/regressions/Formats.test.ts -t "C links" × S7-26: B links "t2z t3z t4z" ‖ C links "t3z"; then B undoes it ‖ C links "t2z t3z t4z" (ids 1,2,3) × S7-26: B links "t2z t3z t4z" ‖ C links "t3z"; then B undoes it ‖ C links "t2z t3z t4z" (ids 3,2,1) Expected: "t3z t4z / " Received: "t3z t4z t5z t6z t7z t8z t9z / tail line" Test Files 1 failed (1) Tests 2 failed | 188 skipped (190) ``` ### After ``` $ npx vitest --project unit --no-watch --maxWorkers=6 packages/lexical-y Test Files 148 passed | 2 skipped (150) Tests 4985 passed | 16 expected fail | 2 skipped (5003) $ pnpm run ci-check Changeset check passed: 5 pending changeset(s) valid. Found 0 errors ``` The local soak lanes are unchanged from the previous commit (no new failures). The undo-vs-comment fuzz stays at 0 of 1000. Long sessions store about 0.5% more format items than the previous commit. Browser e2e was not run.
…ce plain text typed after a word two comments cover ## Description In `@lexical/y`, a comment could spread to the end of the document. A comments "t1z", and a second comment covers it too (from A or another client); J types plain text right after "t1z"; then J undoes the typing while C pastes over J's text. On every client and in a fresh read, the first comment then covered the rest of the line and every paragraph after it. With one comment, or with a manual delete instead of the undo, it did not happen. J's typing ends each comment with a null of J's own written right after the end the comment's author wrote, so that the comment ends even where the author's end goes. C's paste deletes J's text, and the delete collapses the format items it leaves after that text. Of the two nulls that end the first comment there, it kept only the last one, J's, as the one that gives the content after the run its value; J's concurrent undo deleted that null, and nothing ended the comment any more. With this change, where the delete keeps a key's last item, it also keeps the last item before it of that key with the same value (no other value between them) that another client wrote. Each client's undo deletes its own items only, so a run's end and the null another client wrote right after it each keep ending the run where the other's writer removes theirs. It keeps at most one more item per key, and only where two clients ended the same value at the same place. ## Test plan New test in `packages/lexical-y/src/__tests__/unit/regressions/Formats.test.ts`, for four client id orders, with the two comments from one client and from two: A comments "t1z", the second comment covers it, J types " t1011z" right after it, then J undoes the typing while C pastes "t1013z" over "t1011z". The comments end after the word on every client and in a fresh read (C's text, now right after "t1z" inside both comments' runs, takes both comments). `./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/Formats.test.ts -t "L1B-1" --reporter=verbose` ### Before ``` × regressions/Formats.test.ts > L1B-1: undo of plain text typed after a word two comments cover ‖ a paste over that text > L1B-1: the comments end after the word, from one client (ids 1,2,3,4) 223ms × regressions/Formats.test.ts > L1B-1: undo of plain text typed after a word two comments cover ‖ a paste over that text > L1B-1: the comments end after the word, from two clients (ids 1,2,3,4) 78ms (the same for ids 4,3,2,1, 2,4,1,3 and 3,1,4,2) AssertionError: expected '"t1zt1013z"[c12+c4] " t2"[c4] / "t3z"…' to be '"t1zt1013z"[c12+c4] " t2" / "t3z"' // Object.is equality Expected: ""t1zt1013z"[c12+c4] " t2" / "t3z"" Received: ""t1zt1013z"[c12+c4] " t2"[c4] / "t3z"[c4]" Tests 8 failed | 190 skipped (198) ``` ### After ``` ✓ regressions/Formats.test.ts > L1B-1: undo of plain text typed after a word two comments cover ‖ a paste over that text > L1B-1: the comments end after the word, from one client (ids 1,2,3,4) 256ms ✓ regressions/Formats.test.ts > L1B-1: undo of plain text typed after a word two comments cover ‖ a paste over that text > L1B-1: the comments end after the word, from two clients (ids 1,2,3,4) 88ms ✓ regressions/Formats.test.ts > L1B-1: undo of plain text typed after a word two comments cover ‖ a paste over that text > L1B-1: the comments end after the word, from one client (ids 4,3,2,1) 93ms ✓ regressions/Formats.test.ts > L1B-1: undo of plain text typed after a word two comments cover ‖ a paste over that text > L1B-1: the comments end after the word, from two clients (ids 4,3,2,1) 87ms ✓ regressions/Formats.test.ts > L1B-1: undo of plain text typed after a word two comments cover ‖ a paste over that text > L1B-1: the comments end after the word, from one client (ids 2,4,1,3) 85ms ✓ regressions/Formats.test.ts > L1B-1: undo of plain text typed after a word two comments cover ‖ a paste over that text > L1B-1: the comments end after the word, from two clients (ids 2,4,1,3) 80ms ✓ regressions/Formats.test.ts > L1B-1: undo of plain text typed after a word two comments cover ‖ a paste over that text > L1B-1: the comments end after the word, from one client (ids 3,1,4,2) 80ms ✓ regressions/Formats.test.ts > L1B-1: undo of plain text typed after a word two comments cover ‖ a paste over that text > L1B-1: the comments end after the word, from two clients (ids 3,1,4,2) 89ms Tests 8 passed | 190 skipped (198) ``` The other tests in `regressions/Formats.test.ts`, the S7-10, S7-20, S7-24, S7-26 and S7-33 cases included, pass unchanged. ``` $ pnpm exec vitest run --project unit --maxWorkers=8 <every unit test file of packages/lexical-y and packages/lexical/src> Test Files 240 passed | 2 skipped (242) Tests 7180 passed | 14 expected fail | 2 skipped (7196) ``` `tsc`, `tsc-test`, `tsc-scripts`, `tsc-extension`, `tsc-website`, `flow`, `eslint`, `prettier` and the changeset check pass on the repository's files.
zurfyx
force-pushed
the
zurfyx/y-undo-member-closer
branch
from
October 29, 2026 18:12
e3bc5a3 to
603812b
Compare
zurfyx
changed the base branch from
zurfyx/y-keep-chars-at-format-boundary
to
main
October 29, 2026 18:12
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 4 of 10. Based on #9522 (
zurfyx/y-keep-chars-at-format-boundary). 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
In
@lexical/y, a format, comment or link could spread to the end of thedocument when a collaborator edited inside it while it was undone, or while
another peer deleted text at its end. All of these converge on main, on
every client and in a fresh read:
"t56z": everything after B's text, the next paragraph included, turns
italic.
set ends, and undoes it while A types after "t33z": the removed comment
covers the rest of the document.
" t8z t9z" and the next paragraph turn bold. A removal from the whole
range, then its undo, does the same.
B undoes a link while A makes text across the link's start italic.
at the comment's end: once C is back, the comment covers words far after
it.
The cause is shared. The client that edits inside the range restates the
value there: the write cursor restores the old value after the text it
inserts or at the end of the range it formats, and an undo brings back the
format items its change deleted. That restated run ended at the closer of
the change that opened the run. When that change is undone, its opener and
its closer are deleted; a concurrent delete can also drop the closer (it
keeps a bounded number of the format items it leaves). Either way the
restatement runs on to the end of the document.
This change adds one rule, for every stored inline kind (text formats,
styles, NodeState and its set members, links): a run that a client
restates ends with an item of that client's own.
Cursor, format rule 5 (also used after inserted text): where a restorerestates a value whose run another client's change opened (following
restores back to the run's opener), and the item that ends the restored
run is that change's own end of the run, the writer puts a null right
after that end. If the change is undone, or a delete drops that end, the
null ends the restored run there. It goes after the end, not before it,
so text a collaborator types at the end of the run at the same time stays
inside the run. A null by which a change made text inside the run plain
is not such an end, so undoing that change still brings the value back.
YBinding#closeRestoredRuns, the undo path: a format item an undo bringsback gets a null of the undoer's own right after the end its original
change gave it, where that end is gone. This generalizes the guard added
for Bug: [lexical-y] Concurrent undo racing a collaborator's change of the same range can spread a format or comment to the end of the document #9493. The editor reads those nulls as part of the undo, with the
selection the undo puts back, so the undoer's editor matches a fresh read.
The stored layout does not change, and nothing more is written where no
other client's change is involved. The restored value can still stay on
the part of the range after the collaborator's edit, and an undone link
stays on its own text when a collaborator's format change rewrote the
link's textFormat (both documented in y.md); neither reaches past the
range any more.
Restack (October 29)
Rebased onto #9522 with no conflicts (it was based on a
mainone commit older). Left for the "seen of Yjs" follow-up in the stack notes above: the run-opener scans this PR adds (liveItemBefore,runOpener,restatedEnd) read raw deletedness, so inside #9523's unread window they see the unread change's items; not reproduced as a wrong result.Fixed after a randomized soak test of the whole stack:
L1B-1:inregressions/Formats.test.ts, 4 client-id orders, comments from one client and from two).Changeset
.changeset/lexical-y-undo-run-end.md:[fix]for@lexical/y, with aVerify:paragraph.Test plan
New regressions in
packages/lexical-y/src/__tests__/unit/regressions/Formats.test.ts: eachcase in both client orders, with comment variants (another member of the
set, three comments, comment and bold), the selection an undo puts back
(direct and inside an editor update), and a matrix of bold or a comment
removed (by undo or by the opposite change) while a collaborator types,
presses Enter, pastes paragraphs or a list, or deletes inside the range.
Before
On main (0cd7448):
After
Randomized collaborative sessions with formats, comments and undo (a local
soak harness, not committed), compared with main: concurrent undo sessions
112 -> 107 failing of 4000 (none new), sequential undo sessions 200 -> 199
of 4000 (none new), format sessions and the format growth probes
unchanged. An undo-vs-comment fuzz spread in 9 of 300 seeds on main and in
0 of 1000 with this change. A replay of the 4-hour session where a peer
returned after 81 minutes offline no longer spreads the comment (main: 4
untouched words get it). Long sessions store about 2.5% more format items.
Browser e2e was not run.