Repository navigation
[lexical-y] Bug Fix: A write recovered from after it failed part way does not repeat what it stored #9697
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[lexical-y] Bug Fix: A write recovered from after it failed part way does not repeat what it stored #9697
Changes from all commits
0609ba2
c1c79e0
6eb35bc
d7773fc
6aa9a74
195d750
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| --- | ||
| '@lexical/y': patch | ||
| --- | ||
|
|
||
| [fix] A write that fails part way is recovered from without repeating what it already stored: the rewrite diffs each line against the document instead of writing the recorded keystrokes again | ||
|
|
||
| Verify: when a local write throws after storing part of a change (reported as `@lexical/y/recovered-write`), every client shows the change once, and the author's editor matches its document. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| --- | ||
| '@lexical/y': patch | ||
| --- | ||
|
|
||
| [fix] An undo that ends a restored run keeps the document's item list linked: the client that undid no longer holds an item a walk of its list skips, which could make its editor stop matching its document | ||
|
|
||
| Verify: two clients synced after every step; one deletes two characters of a line, the other comments the end of one line and part of another and bolds part of a third, the first types " new " and deletes part of it, removes the first comment and undoes that. Every item's neighbours link back to it on both clients. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1324,7 +1324,11 @@ not merge as a user might expect: | |
| `Kilo` bolded by one client, another unbolds `il` or types plain text | ||
| inside `Kilo` as the first or a third bolds ` bb` (or `lo bb`), and with | ||
| some client id orders ` bb` stays plain. No text is lost, and when that | ||
| client undoes its edit, ` bb` is bold again. | ||
| client undoes its edit, ` bb` is bold again. A local change the binding | ||
| could not write and recovered from (reported as | ||
| `@lexical/y/recovered-write`) can land as two undo steps: what the failed | ||
| write stored, and its rewrite, when the rewrite takes longer than the | ||
| undo manager's `captureTimeout` (a very long line). | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Optional: the recovery rewrites every line of the document, so its duration grows with the document rather than with one line. At the default |
||
| - **Large lists.** A structural edit in a list costs time proportional to | ||
| the list on every client. | ||
| - **Older app versions.** Clients of different `schemaVersion`s never | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,125 @@ | ||
| /** | ||
| * Copyright (c) Meta Platforms, Inc. and affiliates. | ||
| * | ||
| * This source code is licensed under the MIT license found in the | ||
| * LICENSE file in the root directory of this source tree. | ||
| * | ||
| */ | ||
|
|
||
| // @vitest-environment node | ||
|
|
||
| // A write that fails part way is recovered from: the document is rewritten | ||
| // from the editor. The rewrite wrote a changed line through the keystroke | ||
| // fast path, which writes the text edits Lexical recorded against the | ||
| // previous editor state; where the failed write had already stored such an | ||
| // edit, the rewrite stored it again: the other client showed the inserted | ||
| // text twice, and the author's editor no longer matched its own document. | ||
| // The rewrite diffs each line against the document instead. The failure is | ||
| // injected (the second item a cursor writes throws). | ||
|
|
||
| import {$getRoot} from 'lexical'; | ||
| import {expect, test} from 'vitest'; | ||
|
|
||
| import {Cursor} from '../../../internal/Cursor'; | ||
| import {$p, $selectIn, freshReader, setup} from '../../utils/harness'; | ||
|
|
||
| for (const ids of [ | ||
| [1, 2], | ||
| [2, 1], | ||
| ]) { | ||
| test(`the rewrite after a failed write does not repeat what it stored (ids ${ids})`, async () => { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Optional: a stored delete was the more damaging case before this fix. With |
||
| const t = await setup( | ||
| () => | ||
| $getRoot().append( | ||
| $p('one alpha beta'), | ||
| $p('two gamma delta'), | ||
| $p('untouched words here'), | ||
| ), | ||
| {ids}, | ||
| ); | ||
| const proto = Cursor.prototype as unknown as { | ||
| newItem: (...args: unknown[]) => unknown; | ||
| }; | ||
| const newItem = proto.newItem; | ||
| let calls = 0; | ||
| proto.newItem = function (this: unknown, ...args: unknown[]) { | ||
| if (++calls === 2) { | ||
| throw new Error('injected failure'); | ||
| } | ||
| return newItem.apply(this, args); | ||
| }; | ||
| try { | ||
| t.a.update(() => { | ||
| $selectIn([0], 3).insertText(' INSERT'); | ||
| $selectIn([1], 3).insertText(' CHANGE'); | ||
| }); | ||
|
Comment on lines
+52
to
+55
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Optional: a sibling case that stores a delete before the throw (for example |
||
| } finally { | ||
| proto.newItem = newItem; | ||
| } | ||
| expect( | ||
| t.a.warnings.filter(w => w.includes('could not be written')), | ||
| ).toHaveLength(1); | ||
| await t.sync(); | ||
| const expected = | ||
| 'paragraph("one INSERT alpha beta") / paragraph("two CHANGE gamma delta") / paragraph("untouched words here")'; | ||
| for (const peer of t.peers) { | ||
| expect(peer.describe()).toBe(expected); | ||
| const fresh = await freshReader(peer); | ||
| expect(fresh.describe()).toBe(expected); | ||
| fresh.stop(); | ||
| } | ||
| t.a.undo(); | ||
| await t.sync(); | ||
| for (const peer of t.peers) { | ||
| expect(peer.describe()).toBe( | ||
| 'paragraph("one alpha beta") / paragraph("two gamma delta") / paragraph("untouched words here")', | ||
| ); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| // The failed write and its rewrite are two transactions: a rewrite slower | ||
| // than the undo manager's capture timeout (a very long line) makes one edit | ||
| // two undo steps. An open question (D97): accept it as a limit, or join the | ||
| // two at the transaction level. A capture timeout of 0 stands for a rewrite | ||
| // slower than any timeout. | ||
| for (const ids of [ | ||
| [1, 2], | ||
| [2, 1], | ||
| ]) { | ||
| test.fails( | ||
| `D97: the rewrite after a failed write is in the same undo step (ids ${ids})`, | ||
| async () => { | ||
| const t = await setup( | ||
| () => $getRoot().append($p('one alpha beta'), $p('two gamma delta')), | ||
| {ids}, | ||
| ); | ||
| const um = t.a.binding.undoManager!; | ||
| um.captureTimeout = 0; | ||
| const steps = um.undoStack.length; | ||
| const proto = Cursor.prototype as unknown as { | ||
| newItem: (...args: unknown[]) => unknown; | ||
| }; | ||
| const newItem = proto.newItem; | ||
| let calls = 0; | ||
| proto.newItem = function (this: unknown, ...args: unknown[]) { | ||
| if (++calls === 2) { | ||
| throw new Error('injected failure'); | ||
| } | ||
| return newItem.apply(this, args); | ||
| }; | ||
| try { | ||
| t.a.update(() => { | ||
| $selectIn([0], 3).insertText(' INSERT'); | ||
| $selectIn([1], 3).insertText(' CHANGE'); | ||
| }); | ||
| } finally { | ||
| proto.newItem = newItem; | ||
| } | ||
| expect( | ||
| t.a.warnings.filter(w => w.includes('could not be written')), | ||
| ).toHaveLength(1); | ||
| expect(um.undoStack.length).toBe(steps + 1); | ||
| }, | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Optional: before this fix the recovery could also lose text, not only repeat it (a stored delete merged two lines on the other clients). "…without repeating or losing what it already stored", with a
Verify:that ends "with nothing around it lost", would tell people upgrading (.changeset/README.md: the entry is written for someone who uses Lexical).