Repository navigation
Conversation
|
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: |
1c99d6d to
a09ceb8
Compare
a09ceb8 to
93afbbf
Compare
There was a problem hiding this comment.
Thanks for the thorough write-up. Cases 1 and 2 are well covered by UndoRunEnds.test.ts: 59 of its cases fail on #9562's branch and pass here. The listener and re-entrancy handling in applyUndo is a good hardening. Two issues should be fixed before this merges, plus a few smaller points.
1. A restore kept by an undo re-opens a value a collaborator undoes at the same time (packages/lexical-y/src/internal/Binding.ts:797-802, :5554)
A bolds "one two". F types plain " mid" after "one", and both peers sync. Then, concurrently, F undoes its typing while A undoes the bold. After sync, " two" stays bold on every client, in all six id orders. On #9562's branch nothing is bold.
F's undo now keeps its null and its restore, and the restore gives the bold back after F's deleted text. restateRunEnds cannot end it: it runs only on A, where F's insert was still live when A undid, and boundsOwnInsert requires the insert's content to be deleted. The existing sequential test passes only because A has already seen F's undo. A test in the style of UndoRunEnds.test.ts:
test.each(ORDERS)(
'F undoes " mid" while A undoes the bold: nothing stays bold (ids %i %i %i)',
async (...ids) => {
const t = await setup(
() =>
$getRoot().append(
$createParagraphNode().append($createTextNode('one two')),
),
{ids},
);
const [a, , f] = t.peers;
a.update(() => {
const selection = $first().select(0, 7);
selection.format = 0;
selection.formatText('bold');
});
await t.sync();
f.update(() => {
const selection = $first().select(3, 3);
selection.format = 0;
selection.insertText(' mid');
});
await t.sync();
t.offline();
f.undo();
a.undo();
await t.sync();
await expectConverged(t.peers);
// On this branch: 'one two*'
expect(t.a.read($show).replace(/\|/g, '')).toBe('one two');
},
);Randomized three-peer sessions show the same shape, with the re-opened bold reaching into the following line. One possible fix: when a later change, local or remote, removes the value a kept restore gives back, end the restore there, as restateRunEnds already does after this client's own undo.
2. collapseUndone is quadratic in the length of the deleted content (Binding.ts:5699-5719)
For each deleted item, it walks back to the start of that item's run and rescans the run from there. One long run, such as an undo of a paste or a redo of a delete, therefore costs O(n²). In a quick benchmark with formatted text:
- Undoing a 4,800-word paste takes about 1.0 s here, against about 46 ms on #9562's branch.
- Redoing a delete of the same size takes about 1.7 s, against about 40 ms.
- Doubling the size takes about four times as long.
Grouping the step's deleted items into runs in one pass per container would keep it linear.
Smaller points
- Case 1 still reproduces when the typed text was deleted and that delete was undone before the typing is undone (type, delete, undo, undo).
undoingholds the step's original items, but the undo manager deletes their live copies (followRedone). SoneutralRunEndssees no content of the step next to the run ends, and it treats the copy as a collaborator's text. E's text still turns bold. Followingredonewhen collecting the step's content would cover this chain. undo()andredo()now returnfalsewhen called from a listener during another undo or redo. The doc comment (Binding.ts:5325-5336) anddocs/collaboration/y.mdstill say they returnfalseonly while the binding is not active.- It would help to pin the two still-failing randomized sessions from the description as
test.failscases: L1B fmt 226508 (the documented trade-off) and L1B mix 143920. - Minor: on #9562's branch, the six "end a kept restore relies on" cases fail only on the
hNulls.length === 2item count, and their rendered result is the same on both branches. The twoboundsOwnInsertcases fail only because the function is new. Both sets are fine as guards, but the Before list reads as if they reproduced bugs.
The documented trade-off behaves as described: a word typed further inside the run loses the value with the run, while text typed right after the restore keeps it.
| bounds(it) && | ||
| (!shown || equalFormatValue((it.content as ContentFormat).value, base)) | ||
| ) { | ||
| kept.add(it); |
There was a problem hiding this comment.
This keeps F's restore, and the null rule 5 wrote after the run's end, even when the value the restore gives back is removed concurrently. Repro: A bolds "one two", F types plain " mid" after "one", both sync; then F undoes the typing while A undoes the bold. " two" stays bold on every client, in all six id orders; on #9562's branch nothing is bold. restateRunEnds cannot end it, because on A the insert was still live (boundsOwnInsert, L5554). The review comment has a test.
There was a problem hiding this comment.
Fixed in 470933f: the run ends an undo kept are checked again after later changes, local or remote (settleRunEnds, read from the document, so also after a reload). Where they give text that does not rely on them a value it would not have without them, they are deleted, or, where text a collaborator typed next to the undone text relies on them, a correction ends their value after that text. The case is in UndoRunEnds.test.ts (the case where F undoes " mid" while A undoes the value, for bold, italic, underline and a comment, in all six client id orders, then a redo of the value), with variants for a collaborator's text after the null, a restart before the other undo arrives, and several inserts.
There was a problem hiding this comment.
Update: the settlement that fixed this in 470933f and b812b7d is removed in 08d68c9. It wrote while applying remote updates, and when moved to the client's next change it changed formats of text that change did not touch. Per the maintainer's decision, the concurrent case (A's undo has not seen F's) is now a documented limit and a regression against main, tracked in #9593 and pinned as test.fails in UndoRunEnds.test.ts (two client id orders). Where A's undo has seen F's, nothing stays bold: the undo keeps no run end that gives the value the content already has (neutralRunEnds).
| while (start.left !== null && !isLiveContent(start.left)) { | ||
| start = start.left; | ||
| } | ||
| const run = runs.get(start); | ||
| if (run === undefined) { | ||
| runs.set(start, [s, s]); | ||
| } else { | ||
| let first = run[0]; | ||
| let last = run[1]; | ||
| for (let it: Item | null = start; it !== null; it = it.right) { | ||
| if (it === s) { | ||
| if (first !== run[0] || it === first) { | ||
| break; | ||
| } | ||
| first = s; | ||
| break; | ||
| } | ||
| if (it === run[0]) { | ||
| break; | ||
| } | ||
| } |
There was a problem hiding this comment.
For every deleted item, this walks back to the start of its run and rescans the run from there. One long run, such as an undo of a paste or a redo of a delete, is therefore O(n²): undoing a 4,800-word formatted paste took about 1 s, against about 46 ms before this change. Building the runs in one left-to-right pass per container would keep it linear.
There was a problem hiding this comment.
Fixed in 470933f: collapseUndone groups the step's deleted items into runs in one left-to-right walk per container. A test counts the item links read when undoing a formatted paste of n, 2n and 4n words (linear), and the documents it leaves are byte-identical to the old walk's on every collapse in UndoRunEnds, Fuzz, FollowUpFuzz, ConcurrentShapesFuzz, NestedFuzz, SlotsFuzz and UndoSelection.
| this.restoring = names; | ||
| this.removing = removing; | ||
| this.undoing = undoing; | ||
| this.keptRunEnds = neutralRunEnds( |
There was a problem hiding this comment.
undoing comes from top.inserts as recorded. When that text was deleted and then brought back by an earlier undo, the undo manager deletes the live copy (followRedone), so neutralRunEnds sees no content of the step next to the run ends, and it treats the copy as a collaborator's text. As a result, type, delete, undo, undo, with a collaborator typing right after "one", still turns the collaborator's text bold.
There was a problem hiding this comment.
Fixed in 470933f: the items the undo deletes now include the live copies an earlier undo made of the step's text, found the way the undo manager finds them (followRedone), so the run ends next to that copy are the step's own. Test: "type, delete, undo, undo: text typed after the null stays plain" in UndoRunEnds.test.ts, six client id orders.
93afbbf to
8f4d6c4
Compare
There was a problem hiding this comment.
Thanks for the thorough write-up and test file. The two spreads this fixes are real: 59 of the 111 new cases in UndoRunEnds.test.ts fail on the base branch and all pass here, and the updated L1B-1 expectation in regressions/Formats.test.ts is right (C's text replaces J's plain text, so it should stay plain). Two problems need fixing before this lands.
Must fix
1. A restore that F's undo keeps re-opens a bold that A undoes at the same time (packages/lexical-y/src/internal/Binding.ts:798-802, :577-579)
A bolds "one two"; F types plain " mid" after "one"; everyone syncs. Then F undoes its typing while A undoes the bold. After sync every client shows " two" still bold, in all six client-id orders; on the base branch nothing is bold.
F's undo now keeps its null and its restore (neutralRunEnds). The restore keeps giving the bold to the rest of A's range, up to A's closer, which A's undo keeps because that restore is live before it (isKeptByUndo). restateRunEnds never ends it: it runs only on A, and on A boundsOwnInsert returns false because F's text was still live when A undid. The existing case "F types " mid" into A's bold and undos it; A undoes the bold…" passes only because A has already seen F's undo.
const [a, , f] = t.peers; // "one two", ids from ORDERS
a.update(() => {
const s = $first().select(0, 7);
s.format = 0;
s.formatText('bold');
});
await t.sync();
f.update(() => {
const s = $first().select(3, 3);
s.format = 0;
s.insertText(' mid');
});
await t.sync();
t.offline();
f.undo();
a.undo();
await t.sync();
await expectConverged(t.peers);
expect(t.a.read($show).replace(/\|/g, '')).toBe('one two'); // fails: 'one two*'One direction: when a remote change removes the value a kept restore gives back, end it there the way restateRunEnds does. Please add this case to UndoRunEnds.test.ts.
2. collapseUndone is quadratic in the run it collapses (Binding.ts:5699-5701)
For each deleted item it walks left across the run to its start, so one run of n deleted items costs O(n²). With the package's test harness (30 alternately bold words per line, self-check off), undoing a 160-line paste takes 0.6-1.2 s against 26-44 ms on the base branch, and redoing a 320-line delete 9-22 s against 43-65 ms; nearly all of that time is spent in collapseUndone. Remembering each deleted item's run start and stopping the walk at deleted content already seen brings the 160-line cases down to 39-73 ms with UndoRunEnds.test.ts, regressions/Formats.test.ts and OwnRunRestoreEnd.test.ts still passing; one left-to-right walk per container would also cover a step whose deleted items are not visited in document order.
Worth considering
- Two undo paths still delete the run ends (unchanged from the base branch, so not a regression). When the undo manager skips a step a collaborator overwrote and undoes the step below it in the same call (the skip
docs/collaboration/y.mddescribes),scanStackItemhas computed the kept run ends for the skipped top step only (Binding.ts:5901,:5941-5946). When the step's text was put back by an earlier undo,undoingholds the deleted original rather than the live copy (followRedone), soboundsfinds no text of the step. In both, E's plain " plain" next to the undone text still turns bold. If these stay out of scope, the changeset's "The undo now keeps those run ends" should say so. - Docs. The Undo bullet in
packages/lexical-website/docs/collaboration/y.mdshould mention the trade-off the PR description documents: a word a collaborator typed inside a run, after the restore of an insert that is gone, now loses the run's value when the run's format is undone.YBinding.undo()/redo()now returnfalsewhen called during another undo or redo (Binding.ts:5355,:5386); the doc comment at:5325-5328and the binding section ofy.md(lines 248-253) still describefalseas meaning only an inactive binding. - Tests. The description lists L1B mix 143920 (an undo of an underline gives six words on another line the underline) as still failing with this change and not on main. Could you add a reduced case, even as
test.fails, or explain it? Separately, eight of the 59 base failures are not base bugs: the twoboundsOwnInsertcases fail because the helper does not exist on the base branch, and the six "the end a kept restore relies on…" cases fail only at the internalhNulls.lengthcount. The Test plan's Before list (AGENTS.md asks for the real before and after output) should not present them as reproduced bugs.
The changeset follows the format in CONTRIBUTING.md ([fix] with a Verify: paragraph).
| bounds(it) && | ||
| (!shown || equalFormatValue((it.content as ContentFormat).value, base)) | ||
| ) { | ||
| kept.add(it); |
There was a problem hiding this comment.
This keeps F's restore even where the run's value comes from another client's change. If that client undoes the change at the same time (A undoes its bold while F undoes " mid"), the restore keeps giving the bold to the rest of A's range: restateRunEnds runs only on A, and on A boundsOwnInsert (line 577) is false because F's text was still live. After sync " two" stays bold on every client in all six client-id orders, where the base branch leaves it plain. The review comment has a repro.
There was a problem hiding this comment.
Fixed in 470933f: the run ends an undo kept are checked again after later changes, local or remote (settleRunEnds, read from the document, so also after a reload). Where they give text that does not rely on them a value it would not have without them, they are deleted, or, where text a collaborator typed next to the undone text relies on them, a correction ends their value after that text. The case is in UndoRunEnds.test.ts (the case where F undoes " mid" while A undoes the value, for bold, italic, underline and a comment, in all six client id orders, then a redo of the value), with variants for a collaborator's text after the null, a restart before the other undo arrives, and several inserts.
There was a problem hiding this comment.
Update: the settlement that fixed this in 470933f and b812b7d is removed in 08d68c9. It wrote while applying remote updates, and when moved to the client's next change it changed formats of text that change did not touch. Per the maintainer's decision, the concurrent case (A's undo has not seen F's) is now a documented limit and a regression against main, tracked in #9593 and pinned as test.fails in UndoRunEnds.test.ts (two client id orders). Where A's undo has seen F's, nothing stays bold: the undo keeps no run end that gives the value the content already has (neutralRunEnds).
| while (start.left !== null && !isLiveContent(start.left)) { | ||
| start = start.left; | ||
| } |
There was a problem hiding this comment.
This walk runs once per deleted item and crosses the whole run each time, so a run of n deleted items costs O(n²): undoing a 160-line formatted paste takes 0.6-1.2 s (base branch: 26-44 ms) and redoing a 320-line delete 9-22 s (base branch: 43-65 ms). Remembering each deleted item's run start (a Map<Item, Item>) and stopping here when the walk reaches deleted content already seen keeps it linear for a step visited in document order.
There was a problem hiding this comment.
Fixed in 470933f: collapseUndone groups the step's deleted items into runs in one left-to-right walk per container. A test counts the item links read when undoing a formatted paste of n, 2n and 4n words (linear), and the documents it leaves are byte-identical to the old walk's on every collapse in UndoRunEnds, Fuzz, FollowUpFuzz, ConcurrentShapesFuzz, NestedFuzz, SlotsFuzz and UndoSelection.
| this.restoring = names; | ||
| this.removing = removing; | ||
| this.undoing = undoing; | ||
| this.keptRunEnds = neutralRunEnds( |
There was a problem hiding this comment.
top is the stack's last item, but Yjs's popStackItem skips a step that changes nothing (a collaborator deleted its text) and undoes the next one in the same call. In that case these run ends are computed for the skipped step, and the undo deletes the real step's run ends as before: a collaborator's plain text typed next to the undone text still turns bold. Computing them for the step the manager actually undoes would cover it.
There was a problem hiding this comment.
Fixed in 470933f: when the undo manager skips an emptied step and undoes the next one in the same call, the kept run ends are computed for the step whose items the delete filter sees (followStep). Test: "an undo that skips a step a collaborator emptied keeps the run ends of the step it undoes" in UndoRunEnds.test.ts, six client id orders.
8f4d6c4 to
470933f
Compare
470933f to
b812b7d
Compare
There was a problem hiding this comment.
Thanks for the follow-up. The concurrent undo race from the previous round (F undoes " mid" while A undoes the value) and the per-item collapse walk are fixed, and the new cases in UndoRunEnds.test.ts cover them. Two problems remain in the new settleRunEnds path, plus a formatting failure in CI.
1. A redo after the concurrent undos brings the undone text back with the collaborator's format (packages/lexical-y/src/internal/Binding.ts, settleRunEnds, line 6516)
Three peers, any client-id order: A bolds (or italicizes, or comments) "one two"; F types plain " mid" after "one"; sync. F undoes its typing while A undoes the bold; sync. A redoes the bold; sync. F redoes " mid". The base branch ends with one*| mid| two* (F's text plain); this branch ends with one mid two* on every client, and the same with italic and with a comment. The changeset's own Verify: line ("no text that nobody formatted becomes bold or commented") fails here.
Why: F's undo now keeps its null and restore (neutralRunEnds), so they are not part of F's undo step, and F's redo can bring back only the text. A's redo reaches the other peers as two transactions: the redo, then unrestate deleting the item A's undo placed after F's restore. On the first one, staleRunEnds sees F's run ends give " two" a value it would not have without them, and every client deletes them (line 6516). F's redo then reinserts " mid" with no run ends, inside A's bold. One direction: don't settle against a state where an undo's or redo's follow-up write has not arrived (an item that relies on the restore could count as ending it), and let a redo write back the run ends of its text when they were settled away. A case for UndoRunEnds.test.ts:
// helpers and ORDERS as in UndoRunEnds.test.ts
const t = await setup(
() =>
$getRoot().append(
$createParagraphNode().append($createTextNode('one two')),
),
{ids},
);
const [a, , f] = t.peers;
a.update(() => $give('bold'));
await t.sync();
f.update(() => {
const [one] = $first().splitText(3);
one.insertAfter($createTextNode(' mid'));
});
await t.sync();
t.offline();
f.undo();
a.undo();
await t.sync();
a.binding.redo();
await t.sync();
f.binding.redo();
await t.sync();
await expectConverged(t.peers);
expect(t.a.read($show)).toBe('one*| mid| two*'); // this branch: 'one mid two*'2. Every format change now costs each client a pass proportional to gone inserts times document length (Binding.ts, staleRunEnds lines 1101-1147, called from settleRunEnds line 6511)
touchesKeptEnds schedules settleRunEnds on every client for any local or remote transaction that writes or deletes a format item: formatting, typing a character of another format, deleting formatted text, pastes, comments, links. For each gone insert in the container (the run ends of text typed with another value inside a run and later undone, or deleted by hand), staleRunEnds starts a fresh reaches memo, and relies() follows origin/rightOrigin through store.getItem; for text written in one go that chain runs back to the start of the document. On the package's unit harness (two peers, self-check off), making one plain word bold in a 4,000-line document takes about 1.5 s per keystroke once 32 such insertions elsewhere were undone (about 0.7 s per client in settleRunEnds), against about 63 ms on the base; with 8 it is about 0.44 s. The package's own FollowUpCheckCost.test.ts holds a keystroke's follow-up work to the part it changed in a 10,000-block document; this pass costs the whole document per gone insert. Sharing the reliance answers across the gone inserts of one pass, or bounding each walk to the items between a gone insert's ends and the next item of its key, should make it linear. The two "walks ... once" tests count only left/right reads, so they do not see these lookups; counting store.getItem calls and varying the document length would.
3. core-tests / integrity fails on Prettier. AGENTS.md lists pnpm run ci-check as the full check (TypeScript, Flow, Prettier, ESLint). It reports packages/lexical-y/src/__tests__/unit/OwnRunRestoreEnd.test.ts: since test.fails( became test( (line 216), Prettier wants the one-line test(`S9-12b: ...`, async () => { form. pnpm run prettier:fix fixes it. Because ci-check failed, that job's build and build-www steps did not run on this head.
Optional, non-blocking:
- Each undo now walks whole containers (
insideSegmentsandcollapseUndonestart at the container's start), so undoing one typed word costs the document's length. It could be bounded to the step's region while the pass is reworked. settleRunEnds' doc comment (line 6481) says "the client that wrote them ends the value". In fact every active client writes a correction and the lowest id wins, as the commit message says.
| // Back in step (a redo of the value): no fix is needed. | ||
| drop.push(...fixes); | ||
| } else if (!relied) { | ||
| drop.push(ends.first, ends.last, ...fixes); |
There was a problem hiding this comment.
This runs on every peer as soon as a redo of the value arrives, before that redo's unrestate write does. F's null and restore (kept by F's undo, so not part of its undo step) are deleted here, and F's later redo brings " mid" back with A's bold/italic/comment: A bolds "one two", F types plain " mid", F undoes while A undoes the bold, then A redoes, then F redoes. The base keeps " mid" plain.
There was a problem hiding this comment.
Settled by 3136d33: the settlement this describes (settleRunEnds) is removed, by maintainer decision (it also wrote while applying remote updates). The case (A bolds "one two", F types plain " mid", F undoes while A undoes, A redoes, F redoes) is now a test in UndoRunEnds.test.ts, six client id orders. It passes, giving one*| mid| two* as the base does.
| const insert = first.id.client; | ||
| const from = first.id.clock; | ||
| const to = last.id.clock; | ||
| const reaches = new Map<Item, boolean>(); |
There was a problem hiding this comment.
This memo starts empty for each gone insert, and relies() follows origin/rightOrigin back through text written in one go, up to the document's start. So one settleRunEnds pass costs gone inserts times document length, on every client and for every format change. In a 4,000-line document with 32 undone insertions, one bold keystroke takes about 1.5 s against 63 ms on the base. Sharing the answers across the inserts of one pass, or bounding the walk, would keep it linear.
| // and A undoes both writes: the undos keep the run ends of the text they | ||
| // remove (see neutralRunEnds in Binding.ts), so the comment still ends | ||
| // there. Main passes (it writes no null of A's own for A's own run). | ||
| test( |
There was a problem hiding this comment.
Prettier (part of pnpm run ci-check, per AGENTS.md) wants test(`S9-12b: ...`, async () => { on one line now that this is test( rather than test.fails(. That is why core-tests / integrity fails; pnpm run prettier:fix fixes it.
b812b7d to
08d68c9
Compare
…can stay on text after a collaborator's typing when both undo at once ## Description A rare race of undos is now a documented limit of `@lexical/y` (#9593). A client bolds `one two`; a collaborator types plain ` mid` after `one` and undoes it while the client, which has not seen the typing, undoes the bold. With #9561, the collaborator's undo keeps its own run ends (text typed next to ` mid` would rely on them), and the client's undo removes the bold's opener and end, so the restore after ` mid` gives ` two` the bold on every client. No text is lost. Settling it needs a write after both undos are seen, which no client may make while it applies an update. The Undo bullet of the Limits section now says so, next to the other undo limits. #9561 pins the case as a `test.fails` naming #9593. Checked in all six client-id orders: | | main `e28f4dd` | #9561 head `08d68c9` | |---|---|---| | the collaborator types ` mid` offline and undoes it while the client undoes the bold | `paragraph("one two")` | `paragraph("one" " two"{1})` | | the collaborator types ` mid`, everyone syncs, then both undo at once | `paragraph("one two")` | `paragraph("one two")` | So this holds once #9561 lands; main does not have it. ## Test plan ``` pnpm run ci-check ``` ### Before ``` $ pnpm run ci-check Found 0 errors ``` ### After ``` $ pnpm run ci-check Changeset check passed: 31 pending changeset(s) valid. Found 0 errors ```
08d68c9 to
3136d33
Compare
3136d33 to
4724c9d
Compare
… too ## Description The 4 ownership scan cells marked for #9578 (A writes a token or segmented node and text right before B's first one; B undoes all three writes) also pass with #9561: on main with #9562 and #9561 applied they report as passing unexpectedly, and with #9562 alone they still fail. The comment over their `test.fails` now names both, so the marks are dropped by whichever of the two lands first. ## Test plan ``` pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/matrix/OwnershipScans.test.ts ``` ### Before Comment change only. On main `cad9015`: ``` Tests 220 passed | 4 expected fail (224) ``` ### After On main `cad9015`, unchanged: ``` Tests 220 passed | 4 expected fail (224) ``` On main with #9562 `5a34d5c` and #9561 `4724c9d`, the 4 marked cells pass: ``` Tests 4 failed | 220 passed (224) (the 4 are "Expect test to fail") ```
…nd a value an undo ends past other writes' run ends ## Description Depends on #9562 (based on its branch): a restore that relies on a run end of its own client's run gets its own end there. The undo here keeps such restores (case 1), so without #9562 a collaborator's deletes that collapse that run end away let the restore spread the value (a comment on a word, text typed inside it and undone offline, the word and its neighbours deleted by a collaborator: the comment covered the rest of the document). Cases where a bold, a comment or another format value spread onto text nobody gave it, on every client and in a fresh read: 1. An undo of text typed inside a run of another value deleted the run ends that text was written with. F types plain " mid" into bold "one two": the write ends the bold before " mid" and gives it back after it. E types plain " plain" right after "one": that text lands after F's null and writes no item of its own (its value matches). F undoes (whether or not it has seen E's text): the undo deleted F's null and F's restore with " mid", and " plain" turned bold (or, with a comment, commented). The same with a paragraph inserted between the halves of a bold run split by Enter, and with bold text typed into plain text (text typed before it inside its run lost the bold). A delete of " mid" by hand keeps those run ends (`Cursor#collapseGap`); the undo did not. The undo now keeps them (`neutralRunEnds`, through the undo manager's delete filter): the format items of its step next to content of its own that it deletes (past format items and content written there since: content whose Yjs origins lead to an item of the step), where deleting them would not change the value of content that was there before the step, or that the undo brings back (that value is the one the item of the key before them gives, past the run ends a collaborator's write put around text it typed inside the step's content, deleted since: those give nothing). Where it keeps a restore (the item that gives a run's value back after the text), it also keeps the null its write put right after the end of that run, which the restore relies on (`Cursor#format`, rule 5). Items of a format change (next to content the step did not write) are deleted as before, so an undo of a format change still removes it. Then, as a delete does, the run of items without live content the undo leaves is collapsed (`collapseUndone`, through `Cursor#collapseDeleted`), so text of another value typed and undone again and again at one place leaves a bounded number of items. It deletes only this client's own items there (`Cursor#collapsible`): another write's run end can be all a collaborator's text relies on once that collaborator undoes its own items. That collapse starts from the formats the editor shows, as the cursor it uses walks them: a collaborator's format a composition holds back is not counted (counting it lost an offline peer's bold or comment when the undo came while composing). 2. An insert's run ends inside another write's run restate that run's value: F's restore after " mid" gives the rest of A's bold back. A bolds "one two"; F types " mid" inside and deletes it (on main), or undoes it (with 1.); A undoes the bold: " two" stayed bold. After an undo or redo, where the restore of an insert that is gone gives back the value the content before that insert's null had, and the undo or redo changed that value, an item of this client's own now gives the content after it the new value (`restateRunEnds`), placed after what was written next to the restore since (text a collaborator typed right after it relies on its value and keeps it). Such a restore (`boundsOwnInsert`) is one of two items one insert wrote around text of its own, all of it deleted since, inserted before content that was there already, and giving back the value the key had where the insert went. A format change's closer ends its own value, and the items of a write that formats its own content (a document written in one go) open values of their own: both are left alone. The item is recorded on the step the undo or redo pushed, and the next redo or undo of that step deletes it before restating again (`unrestate`). An undo or redo and its follow-up writes are one operation: the steps it took and pushed are taken from the undo manager's events, by listeners the binding registers first (so a listener of the app that clears the history or throws does not skip the follow-up writes, and its error is thrown after them), what `restateRunEnds` writes is recorded on its step before it is written, and `undo()`/`redo()` asked for during the operation (from a listener) does nothing. `regressions/Formats.test.ts` L1B-1 expected C's text, typed over J's plain text after a commented word, to take the comments when J's typing was undone at the same time: that was case 1 (J's undo deleted the null C's text relied on). C's text now stays plain. ## Status against the randomized sessions that found these Sessions that change the format of a word no op touched, on this change and not on main with the same ops: - Fixed (pass now): L1B fmt 206163 (an underline undo took a document's own underline off a later word), L1B mark 324160 (gained bold), L1B mark 336261 (gained a link), L1B undoheavy 413112 (lost bold), N brand 1200036 and 1200107 (gained bold). - Still failing: L1B fmt 226508 (a word typed inside an italic run, after the restore of an insert that is gone, loses the italic when the run is undone: see the known regression above), L1B mix 143920 (an undo of an underline gives six words on another line the underline; under investigation). ## Changeset `.changeset/lexical-y-undo-run-ends.md` (`[fix]`). ## Test plan New `packages/lexical-y/src/__tests__/unit/UndoRunEnds.test.ts` (all six client id orders where the order matters): bold and comment, a paragraph in a split bold run, bold text typed into plain text, the undoing client having seen the collaborator's text, a redo, bounds on the format items left by typing and undoing at one place (one value, and a new style each time), controls (no undo, a delete by hand), undo-redo-undo chains of the bold after a word typed inside it was undone or deleted, a third peer's text inside the collaborator's, a format change's closer next to undone text, the null a kept restore relies on surviving a collaborator's delete, `boundsOwnInsert` on an insert's run ends, a format change's and a document's written in one go, an undo of an underline next to a document's own run ends (its later underline stays), a comment's ends a collaborator's deletes drop (with #9562), an undo while a composition holds a collaborator's bold or comment, the restore an undo keeps when a collaborator's undone text inside left run ends, a collaborator's format over text an undo removes, text another peer typed right after a restore or before the run's next word, and listeners that undo again, clear the history or throw (of the undo manager and of the follow-up write). The comment-ends, composition and kept-restore cases pass on main and guard against regressions of this change. Known regression vs main on one shape (a randomized session): a word a collaborator typed inside a run, taking its value, after the restore of an insert that is gone, loses the value when the run's format is undone; on main it keeps it there because that restore keeps giving it. Without such a restore in front of it the word loses the value on main too (A italics "one two three", B types italic "X " inside, A undoes: "X " is plain on main), which is what this change gives. ### Before ``` $ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/UndoRunEnds.test.ts Tests 59 failed | 52 passed (111) × bold: text typed after its null stays plain (6 of 6 id orders) × comment: text typed after its null stays uncommented (6 of 6) × a paragraph inserted into a bold run split by Enter (6 of 6) × bold text typed into plain text: text typed before it inside its run stays bold (6 of 6) × F has seen E's text when it undoes; then A undoes the bold (6 of 6) × text a third peer typed inside the collaborator's text stays plain too (2 of 2) × F types " mid" into A's bold and deletes it; A undoes the bold, redoes it, undoes it (6 of 6) × a redo puts the text back with its own value × the end a kept restore relies on stays ended when a collaborator deletes the run (6 of 6) × only an insert's own run ends restate a value (not a format change's) × the format items of a write that formats its own content do not restate a value (S9-9) × text another peer typed in the run's value after " mid" / before " two" keeps it when the run is undone (12) ✓ F types " mid" into A's bold and undos it; A undoes the bold, redoes it, undoes it (6 of 6) ✓ controls, bounds and stack listener cases AssertionError: expected 'one plain two*' to be 'one*| plain| two*' // Object.is equality AssertionError: expected 'one plain two[c1]' to be 'one[c1]| plain| two[c1]' // Object.is equality AssertionError: expected 'one* / plain* / two*' to be 'one* / plain / two*' // Object.is equality AssertionError: expected 'one B two' to be 'one| B*| two' // Object.is equality AssertionError: expected 'one two*' to be 'one two' // Object.is equality ``` ### After ``` $ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/UndoRunEnds.test.ts Test Files 1 passed (1) Tests 111 passed (111) ``` The `@lexical/y` and `@lexical/mark` unit suites pass (on #9562), the randomized ones included (`Fuzz`, `FollowUpFuzz`, `ConcurrentShapesFuzz`, `NestedFuzz`, `SlotsFuzz`): ``` $ pnpm exec vitest run --project unit packages/lexical-y packages/lexical-mark Test Files 182 passed | 2 skipped (184) Tests 8755 passed | 46 expected fail | 4 skipped (8805) ``` `pnpm run ci-check` passes.
…ue an undo ends past seen text still there, and collapse an undo's runs in one pass
## Description
Follow-up to the run ends an undo keeps (case 1) and the values an undo
ends past other writes' run ends (case 2) in this PR.
1. Run ends an undo kept gave text a value it did not have before.
A gives "one two" a value (bold, italic, underline or a comment); F
types plain " mid" after "one"; both sync. Then F undoes its typing and
A undoes the value: F's undo kept its null and the restore after " mid",
and the restore gave " two" the value for everyone (nothing kept it
before this PR). The same with a block moved into a collaborator's
underlined lines: the undo of the move kept an opener of the moved
copy's underline (it gave the value the collaborator's run gave), and
the collaborator's undo of the underline left it without an end, so the
underline ran on over the lines after it.
- An undo no longer keeps a run end that gives the value the content
there has without it (from the value before the stretch and the items
kept before it): it changes nothing, and nothing relies on it
(`neutralRunEnds`). Leaving one out changes no value.
- Where the two undos are concurrent (A's undo has not seen F's), the
kept restore still gives " two" the value: a known limit of this PR,
#9593 (`main` passes that case, since its undo deletes the run ends
with the text, which loses the value of text a collaborator typed
next to it, the case this PR fixes). Settling it needs a write once a
client has seen both undos, which the binding may not make while it
applies a remote update, nor on a later change of its own (it would
change text that change did not touch). `UndoRunEnds.test.ts` pins
it as an expected failure, in two client id orders.
- No run end is kept inside one non-mergeable text node (a token,
segmented or code highlight node, whose segment key goes on across it
once the undo is done: `insideSegments`), and no node's type or
segment key is kept: content there is that node's, which no one
else's text joins, and keeping an end there split the node, whose
mode or state then covered only part of it (an agent's format of one
half took characters of an untouched word; a suggestion state was
lost from part of a code word; a link ran on over untouched words).
A style is a run like a format and is kept.
2. Where Yjs skips a step a collaborator emptied and undoes the next one
in the same call, the kept run ends were computed for the skipped step
only, and the undo deleted the real step's run ends: they are now
computed for the step whose items the undo deletes (`followStep`).
3. Type, delete, undo, undo. `undoing` held the items of the step as
recorded, but the undo manager deletes the live copies an undo of a
delete made of them (`followRedone`), so `neutralRunEnds` saw no
content of the step next to its run ends and deleted them: text a
collaborator typed after the null turned bold. The undo's items now
include those copies, found the way the undo manager finds them.
4. An undo now ends its value also past the restore of text it has seen
that is still there: A bolds "one two", F types plain " mid" inside, A
(having seen it) undoes the bold, and " two" stayed bold. The run ends
of an insert now restate a value whether its text is still there or
gone (`boundsOwnInsert`). Text typed inside the range that the undo has
not seen still keeps the old value after it, as y.md's Limits say (the
sentence now says that exactly).
5. An undo of one step that formats a run, types text and formats that
text (a format, plain " zz", a format of " zz") keeps the run ends of
its own typed text (1.), and the format change's closers after the
first run were then deleted: the walk that keeps a closer where the
content after it would otherwise take a value (`isKeptByUndo`) counted
only the step's deleted items as the step's, so the kept run ends of
" zz" made it delete them, and a collaborator's restore inside the
first run (plain "X" typed inside it, seen before the undo) lost its
end and gave its value to the untouched words after it. A run end the
undo keeps now counts as gone there too.
6. `collapseUndone` walked back to the start of a deleted item's run and
rescanned the run for each deleted item: quadratic in a long run (the
undo of a pasted formatted text, about 1 s for 4,800 words). It now
walks each container once, left to right. The documents it leaves are
byte-identical to the old walk's on every undo of `UndoRunEnds`,
`Fuzz`, `FollowUpFuzz`, `ConcurrentShapesFuzz`, `NestedFuzz`,
`SlotsFuzz` and `UndoSelection` (the encoded state after each collapse,
compared run by run).
S9-12b in `OwnRunRestoreEnd.test.ts` (#9562 pins it as an expected
failure: a comment's end that a collaborator's collapse drops, after its
author's two writes inside it are undone) passes with this PR's undo,
which keeps the run ends of the text it removes: it is a plain test here.
Docs: `undo()` / `redo()` (comment and y.md) say they return `false`,
doing nothing, when called during another undo or redo; y.md's Undo limit
names the unseen case only, and says that a word a collaborator typed
inside the range with its value, right after text someone typed inside it
and removed again, loses the value with the range when it is undone, as
text typed anywhere else inside the range does (it kept it before, through
the restore of the removed text). The changeset names the #9593 limit.
## Test plan
New cases in `packages/lexical-y/src/__tests__/unit/UndoRunEnds.test.ts`
("run ends an undo kept, after changes made since"): concurrent undos of
the value and of the typing for bold, italic, underline and a comment, each
in six client id orders, then a redo of the value; the same with text a
collaborator typed after the null (stays plain, and the redo gives " two"
its bold back); type, delete, undo, undo; the undo of a block moved into a
collaborator's underline during its undo; the #9593 case (concurrent
undos, pinned as an expected failure); after those concurrent undos, a
redo of the bold and then of the typing gives " mid" back plain (six
orders; it passes on the first commit, and an earlier version of this
follow-up failed it); an undo that skips a step a
collaborator emptied; `RunSequenceUndo.test.ts` (from #9562) fails only
on seed 87, as on main (#9599); an undo of text put inside a segmented or
token text node, commented or not, leaves the node whole; a style, as
bold; an undo ending its value past
seen text still there, and its unseen counterpart as a control of the
documented limit; the undo of a long formatted paste counting item link
reads (n, 2n, 4n). The expected strings were checked against main where
main has no case 1 (the concurrent undos with redo, the moved block, the
control and the walk count pass on main).
### Before
```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/UndoRunEnds.test.ts
Tests 56 failed | 123 passed | 2 expected fail (181)
AssertionError: expected ' two' to be '' // Object.is equality
AssertionError: expected 'one plain two*' to be 'one plain two' // Object.is equality
AssertionError: expected 'one plain two*' to be 'one*| plain| two*' // Object.is equality
AssertionError: expected 'xx_| yy / one / two_ / three' to be 'xx_| yy / one / two / three' // Object.is equality
AssertionError: expected 'one mid two*' to be 'one mid two' // Object.is equality
AssertionError: expected 3.9225617279005305 to be less than 2.5
AssertionError: expected false to be true // Object.is equality
```
(The undos of the value and of the typing, the skipped step, the text
node modes, the style, the collaborator's text, type delete undo undo, the
moved block, the seen restore, the walk count, and the `boundsOwnInsert`
assertion on an insert whose text is there.)
### After
```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/UndoRunEnds.test.ts
Tests 179 passed | 2 expected fail (181)
$ pnpm exec vitest run --project unit packages/lexical-y packages/lexical-mark
Test Files 182 passed | 2 skipped (184)
Tests 8837 passed | 53 expected fail | 4 skipped (8894)
```
The randomized sessions that found the moved-block case (L1B mix 143920),
the text node mode case (L1B catalog tab-heavy 3500310), the link case
(L1B catalog undo-heavy 3301123) and the code word state case (L2 catalog
4000092) pass, alone and merged with the other open changes; they failed
on this PR's previous head and pass on #9562. The randomized sessions
that found a write while applying a remote update and lost
tokens in an IME session in an earlier version of this follow-up (L1B
tab-heavy 3500073, 3500177, 3500479; mix 3100412, 3100480, 3100495,
3101172, 3101324; fmt 3900538, 3900574, 3900645, 3900699, 3900792,
3901108, 3901847; undo-heavy 3300430, 3300727; L1C imestrict 3600025,
3600062) show no flag that main does not show.
`pnpm run ci-check` passes.
4724c9d to
fbb792b
Compare
…9562 only: drop the concurrent-undo limit, and extend the run-end format limit to a format applied from inside the run ## Description #9561 is not landing with this, so the Limits section describes main, with #9562 for the sentences that say so. - **Undo: both undo at once.** The sentence added for #9593 (a format staying on text after a collaborator's typing when both undo at once) describes a case that only #9561 produces; main gives `paragraph("one two")`. It is removed. The format-undo sentence that main has (the text after a collaborator's typing inside the range keeps the format, whether the undoing client saw that text or not) and its `Races.test.ts` cases are unchanged and still describe main. - **A collaborator's format after a run's end (#9589, #9603).** The sentence now also covers a format applied from inside the run past its end, and says that the format comes back when the edit inside the run is undone: with `Kilo` bold, one client types plain text inside `Kilo` (or unbolds `il`) while another bolds `lo bb`, and with some client id orders ` bb` stays plain. No text is lost. Checked with `aa Kilo bb cc dd`, A bolds `Kilo` and syncs, then concurrently A types plain `X` after `Ki` (or unbolds `il`) while B bolds the range named below; then A undoes. Ids `[1, 2]` and `[2, 1]` (A = first id): | B bolds | main `cad9015` | main + #9562 `ac2c143` | after A's undo | |---|---|---|---| | ` bb` (7-10), A types `X` | ` bb` bold | ` bb` plain with `[2, 1]` | ` bb` bold everywhere | | ` bb` (7-10), A unbolds `il` | ` bb` bold | ` bb` plain with `[2, 1]` | ` bb` bold everywhere | | `lo bb` (5-10), A types `X` | ` bb` bold | ` bb` plain with `[2, 1]` | ` bb` bold everywhere | | `ilo bb` (4-10), A types `X` | ` bb` bold | ` bb` plain with `[2, 1]` | ` bb` bold everywhere | | `o bb cc` (6-13), A types `X` | ` bb cc` bold | ` bb cc` plain with `[2, 1]` | ` bb cc` bold everywhere | ## Test plan ``` pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/Races.test.ts pnpm run ci-check ``` ### Before ``` $ vitest --run --project unit packages/lexical-y/src/__tests__/unit/Races.test.ts Tests 118 passed | 5 expected fail (123) ``` ### After ``` $ vitest --run --project unit packages/lexical-y/src/__tests__/unit/Races.test.ts Tests 118 passed | 5 expected fail (123) $ pnpm run ci-check Changeset check passed: 31 pending changeset(s) valid. Found 0 errors ```
There was a problem hiding this comment.
This looks good to me. The undo now keeps the run ends of the text it removes and ends a value past other writes' run ends. The new UndoRunEnds.test.ts cases cover both shapes across client-id orders, and pinning the concurrent-undo shape as a test.fails tracked in #9593 records the trade-off clearly. A few optional, non-blocking suggestions:
-
Add the skipped-step test (
packages/lexical-y/src/internal/Binding.tsL6117,followStep). The follow-up commit's Test plan lists "an undo that skips a step a collaborator emptied" as a new case inUndoRunEnds.test.ts, but that file has no such case at this head, so no test guardsfollowStep. CONTRIBUTING.md asks for tests with code that should be tested. A shape that exercises it: bold "one two" and a second line "tail"; F types plain " mid" after "one", then " x" after "tail"; A deletes " x", which empties that step; offline, E types " plain" after "one" while F undoes. With the fix every peer showsone*| plain| two* / tail; without it, E's " plain" turns bold. -
State the order in the new y.md Undo sentences (
packages/lexical-website/docs/collaboration/y.mdL957-962). As written, they say that a word typed inside the range with its color, "right after text someone typed inside it and removed again", loses the color when the color is undone, and that text typed inside the range that you have seen loses it too.UndoRunEnds.test.tsL897-950 asserts the opposite when the word is typed while that text is still there and the text is removed afterwards (one| PEER/| two). The word loses the color only when the text was removed before the word was typed. Saying "typed after that text was removed", and noting that a word typed right after it while it was still there keeps the color, would bring the page in line with the tests. -
Refresh the Test plan output in the description. It reports
188 passed | 3 expected fail (191)forUndoRunEnds.test.tsandOwnRunRestoreEnd.test.ts, while those two files now report196 passed | 7 expected fail (203). AGENTS.md asks the Test plan to carry the real output of the command that was run.
| * text) and undoes the next one in the same call, so the step scanned | ||
| * first (the stack's top) is not always the one being undone. | ||
| */ | ||
| private followStep(item: Item): void { |
There was a problem hiding this comment.
Optional: the follow-up commit's Test plan lists "an undo that skips a step a collaborator emptied" in UndoRunEnds.test.ts, but that file has no such case at this head, so no test guards followStep. A shape that exercises it: bold "one two" plus a line "tail"; F types plain " mid" after "one", then " x" after "tail"; A deletes " x"; offline, E types " plain" after "one" while F undoes. Expected on every peer: one*| plain| two* / tail. Without followStep, E's " plain" turns bold.
| collaborator types or pastes inside the range as you undo, which your | ||
| undo has not seen; text typed inside it that you have seen loses the | ||
| color with the rest of the range). A word a collaborator typed inside the | ||
| range with its color, right after text someone typed inside it and | ||
| removed again, loses the color too when you undo the color, as text | ||
| typed anywhere else inside the range does. Undoing a link |
There was a problem hiding this comment.
Optional: UndoRunEnds.test.ts L897-950 asserts that a word typed with the range's color right after another insert's text, while that text is still there, keeps the color when the range's color is undone. It gives one| PEER/| two even after that text is removed. These sentences read as if such a word always loses the color. Naming the order ("typed after that text was removed") would match the tests.
…and count each id's holders in the HTML import check ## Description #9540 and #9584 merged, so the cells marked `test.fails` for them pass on main and report as passing unexpectedly: letters composed past the end of a marked run (26 cells in `MarksIME.test.ts`) and the `@lexical/yjs` v2 table row cell of #9581 (`C6Settles.test.ts`, `yjs-v2, table edits, follow-ups none, repair none, seed 11`). They are plain tests again. The marks for #9553, #9568, #9578 (and #9561) and #9606 stay. The import check of `C7Html.test.ts` collected the imported blocks' ids in a map, so two blocks holding the same id counted as one and "exactly one imported block" was never asserted. It now keeps every holder and requires exactly one, of the same type. ## Test plan ``` pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/matrix pnpm run ci-check ``` ### Before On main `04e899d` with the marks: ``` ❯ packages/lexical-y/src/__tests__/unit/matrix/MarksIME.test.ts (56 tests | 26 failed) ❯ packages/lexical-y/src/__tests__/unit/matrix/C6Settles.test.ts (360 tests | 1 failed) ``` ### After On main `04e899d`: ``` Test Files 13 passed (13) Tests 2395 passed | 13 expected fail (2408) $ pnpm run ci-check Found 0 errors ```
Parked (10/30)
This PR is parked as a draft. The branch and its tests stay as they are.
Why. In the randomized run-sequence sessions (a client formats a run, types plain text, formats again, and undoes those writes while a collaborator types and undoes plain text inside the first run), this branch failed 12 sessions that main passes on the same op paths, and main failed 2 that this branch passes. That is the same symptom this PR exists to fix (a format spreads onto untouched text), and the history went regress, fix, regress. The remaining shape is #9593: A types plain text inside B's run, which B has not seen; then A undoes it while B undoes the run's format. A's undo keeps the run ends of the text it removes (the point of this PR), B's undo removes the run's opener and end, and A's kept restore gives the format back to the rest of B's run. With B having seen A's text before its undos, the case passes.
What was tried. Dropping a kept null and restore pair with no content between them fixes #9593's shape but breaks this PR's main case: a collaborator's text typed next to the undone text, which the undoing client has not seen, relies on exactly such a pair. Before that, a settlement that re-read kept run ends from the document after later changes fixed the concurrent case but wrote while applying remote updates and lost IME tokens; it was removed.
What would fix it (the (C) design, #9593). Settle the kept run ends inside the undo that makes them stale, where the undoing client has seen them (B's undo of the run's format, having seen A's undo of its text, ends the value after A's text). The truly concurrent case, where neither undo has seen the other, needs a write after both are seen, which the binding rules out while applying remote updates; it would stay a documented limit.
What this branch still fixes (its tests pass here, and are added to main as expected failures in a test-only PR): plain text a collaborator typed next to text typed into a bold or commented run stays plain when that text's insert is undone (and the same for italic, underline, style, a paragraph inserted at an Enter split, type-delete-undo-undo, and the skipped-step path); an undo of a value ends it past the restore of text it has seen; no run end is kept inside one token, segmented or code highlight node;
collapseUndonewalks each container once (linear); a one-step format/type/format undo keeps the first run's closers (randomized seed 175).Description
Depends on #9562 (based on its branch): a restore that relies on a run end
of its own client's run gets its own end there. The undo here keeps such
restores (case 1), so without #9562 a collaborator's deletes that collapse
that run end away let the restore spread the value (a comment on a word,
text typed inside it and undone offline, the word and its neighbours
deleted by a collaborator: the comment covered the rest of the document).
Cases where a bold, a comment or another format value spread onto text
nobody gave it, on every client and in a fresh read:
An undo of text typed inside a run of another value deleted the run
ends that text was written with. F types plain " mid" into bold "one
two": the write ends the bold before " mid" and gives it back after it.
E types plain " plain" right after "one": that text lands after F's null
and writes no item of its own (its value matches). F undoes (whether or
not it has seen E's text): the undo deleted F's null and F's restore with
" mid", and " plain" turned bold (or, with a comment, commented). The
same with a paragraph inserted between the halves of a bold run split by
Enter, and with bold text typed into plain text (text typed before it
inside its run lost the bold). A delete of " mid" by hand keeps those run
ends (
Cursor#collapseGap); the undo did not.The undo now keeps them (
neutralRunEnds, through the undo manager'sdelete filter): the format items of its step next to content of its own
that it deletes (past format items and content written there since:
content whose Yjs origins lead to an item of the step), where deleting
them would not change the value of content that was there before the
step, or that the undo brings back (that value is the one the item of
the key before them gives, past the run ends a collaborator's write put
around text it typed inside the step's content, deleted since: those
give nothing). Where it keeps a restore (the item that gives a run's
value back after the text), it also keeps the null
its write put right after the end of that run, which the restore relies
on (
Cursor#format, rule 5). Items of a format change (next to contentthe step did not write) are deleted as before, so an undo of a format
change still removes it. Then, as a delete does, the run of items
without live content the undo leaves is collapsed (
collapseUndone,through
Cursor#collapseDeleted), so text of another value typed andundone again and again at one place leaves a bounded number of items.
It deletes only this client's own items there (
Cursor#collapsible):another write's run end can be all a collaborator's text relies on once
that collaborator undoes its own items.
That collapse starts from the formats the editor shows, as the cursor it
uses walks them: a collaborator's format a composition holds back is not
counted (counting it lost an offline peer's bold or comment when the
undo came while composing).
An insert's run ends inside another write's run restate that run's
value: F's restore after " mid" gives the rest of A's bold back. A bolds
"one two"; F types " mid" inside and deletes it (on main), or undoes it
(with 1.); A undoes the bold: " two" stayed bold. After an undo or redo,
where the restore of an insert that is gone gives back the value the
content before that insert's null had, and the undo or redo changed that
value, an item of this client's own now gives the content after it the
new value (
restateRunEnds), placed after what was written next to therestore since (text a collaborator typed right after it relies on its
value and keeps it). Such a restore
(
boundsOwnInsert) is one of two items one insert wrote around text ofits own, all of it deleted since, inserted before content that was there
already, and giving back the value the key had where the insert went.
A format change's closer ends its own value, and the items of a write
that formats its own content (a document written in one go) open values
of their own: both are left alone. The item is recorded on the step the
undo or redo pushed, and the next redo or undo of that step deletes it
before restating again (
unrestate).An undo or redo and its follow-up writes are one operation: the steps it
took and pushed are taken from the undo manager's events, by listeners the
binding registers first (so a listener of the app that clears the history
or throws does not skip the follow-up writes, and its error is thrown after
them), what
restateRunEndswrites is recorded on its step before it iswritten, and
undo()/redo()asked for during the operation (from alistener) does nothing.
regressions/Formats.test.tsL1B-1 expected C's text, typed over J'splain text after a commented word, to take the comments when J's typing
was undone at the same time: that was case 1 (J's undo deleted the null
C's text relied on). C's text now stays plain.
Status against the randomized sessions that found these
Sessions that change the format of a word no op touched, on this change and
not on main with the same ops:
underline off a later word), L1B mark 324160 (gained bold), L1B mark
336261 (gained a link), L1B undoheavy 413112 (lost bold), N brand 1200036
and 1200107 (gained bold).
node split by a kept mode end), L1B catalog undo-heavy 3301123 (a link on
five untouched words), L2 catalog 4000092 (a suggestion state lost from
part of a code word), and L1B mix 143920 (an undo of a block moved
into a collaborator's underlined lines kept an underline opener of the
moved copy, and the collaborator's concurrent undo of the underline left
it without an end: six words on another line gained the underline).
the restore of an insert that is gone, loses the italic when the run is
undone: see the known regression above, now in y.md's Limits).
Merge order / conflicts
This PR and #9569 both change y.md's Undo bullet. #9569 documents that
text a collaborator types inside a format range keeps the format whether
or not the undoing client saw it, with a regular test of the seen case and
a
test.failsfor its fix (AUDIT-U1 inRaces.test.ts). This PR fixes theseen case. Whichever of the two lands second keeps this PR's sentence for
the seen case and flips #9569's AUDIT-U1 seen-case test: the regular test
asserts the fixed result and the
test.failsgoes.Changeset
.changeset/lexical-y-undo-run-ends.md([fix]).Test plan
New
packages/lexical-y/src/__tests__/unit/UndoRunEnds.test.ts(all sixclient id orders where the order matters): bold and comment, a paragraph in
a split bold run, bold text typed into plain text, the undoing client having
seen the collaborator's text, a redo, bounds on the format items left by
typing and undoing at one place (one value, and a new style each time),
controls (no undo, a delete by hand), undo-redo-undo chains of the bold
after a word typed inside it was undone or deleted, a third peer's text
inside the collaborator's, a format change's closer next to undone text,
the null a kept restore relies on surviving a collaborator's delete,
boundsOwnInserton an insert's run ends, a format change's and adocument's written in one go, an undo of an underline next to a
document's own run ends (its later underline stays), a comment's ends a collaborator's deletes
drop (with #9562), an undo while a composition holds a collaborator's bold
or comment, the restore an undo keeps when a collaborator's undone text
inside left run ends, a collaborator's format over text an undo removes,
text another peer typed right after a restore or
before the run's next word, and listeners that undo again, clear the
history or throw (of the undo manager and of the follow-up write). The
comment-ends, composition and kept-restore cases pass on main and guard
against regressions of this change.
Known regression vs main on one shape (a randomized session): a word a
collaborator typed inside a run, taking its value, after the restore of
an insert that is gone, loses the value when the run's format is undone;
on main it keeps it there because that restore keeps giving it. Without
such a restore in front of it the word loses the value on main too (A
italics "one two three", B types italic "X " inside, A undoes: "X " is
plain on main), which is what this change gives.
Known regression vs main, accepted (#9593): A bolds "one two"; offline, F
types plain " mid" after "one" and undoes it while A undoes the bold.
F's undo keeps the null before " mid" and the restore of the bold after
it, and A's undo, which has not seen F's, removes the bold's opener and
end, so the restore gives " two" bold for everyone.
mainpasses thiscase because its undo deletes those run ends with the text, which is
exactly what makes a collaborator's plain text typed next to the undone
text turn bold or commented there, the bug this PR fixes. Settling it
needs a write once a client has seen both undos, and the binding writes
nothing while it applies a remote update (an earlier version of the
follow-up settled it then; the randomized sessions read that as writes on
apply, and a version that settled on the client's next change changed
formats of text that change did not touch).
UndoRunEnds.test.tspins itas an expected failure in two client id orders; #9593 tracks settling it
inside the undo that makes the run ends stale when that client has seen
them.
Follow-up commit (see its message): an undo keeps no run end that gives
the value the content there has without it (the case above where A's undo
has seen F's, and a block moved into a collaborator's underline), no run
end inside one token, segmented or code highlight node (randomized
sessions L1B tab-heavy 3500310, undo-heavy 3301123, L2 4000092), the step
Yjs actually undoes when it skips an emptied one, type-delete-undo-undo,
an undo ending its value past seen text still there, and
collapseUndonein one pass per container. Its own Before/After is in the commit message;
with it:
OwnRunRestoreEnd.test.tsS9-12b, an expected failure on #9562, passeshere and is a plain test.
Review notes the follow-up resolves:
collapseUndone's quadratic walk(one pass per container); the step Yjs actually undoes when it skips an
emptied one; the copies an earlier undo made of the step's text
(
followRedone); docs forundo()/redo()returningfalseduringanother undo or redo and y.md's Undo limit (the unseen case, and the
trade-off of the randomized session L1B fmt 226508); a reduced case for
L1B mix 143920 (the moved block). The note on a restore an undo keeps
re-opening a value a collaborator undoes at the same time is the accepted
limit above (#9593).
Before (first commit)
Of the 59 failures below, 51 reproduce the bugs. The other 8 are guards:
the six "the end a kept restore relies on stays ended" cases fail on
#9562's branch only at an internal item count (
hNulls.length === 2;their rendered result is the same on both branches), and the two
boundsOwnInsertcases fail only because that function is new ("not afunction").
After (first commit)
The
@lexical/yand@lexical/markunit suites pass (on #9562), therandomized ones included (
Fuzz,FollowUpFuzz,ConcurrentShapesFuzz,NestedFuzz,SlotsFuzz):pnpm run ci-checkpasses.