From 9fcda3120cdeb09a2b95ab97a296df4ee3a97d68 Mon Sep 17 00:00:00 2001 From: Gerard Rovira Date: Mon, 2 Nov 2026 11:15:58 -0500 Subject: [PATCH 1/6] [lexical-y][lexical-website] Documentation Update: Scope D99, decide D95, record #9589 and the #9725 text entity limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Follow-ups to #9723 and three decided limits. Docs and one regression test; no source change. - **D99 (#9721, after #9723).** D99 and its y.md bullet said an undo or redo inside a container always keeps it and that a clear never releases it. That is true only when the undo step's deletion was the first keep on the cell: a cell the binding already kept for a format of its own (D88) is let go when the binding stops, and a deletion in the same cell that is still on a stack is released by the clear, parents included. D99 now says "can keep" with that qualifier (as D98 does), D88 and D98 point to D99 where they say a clear before stopping prevents the residue, and the y.md bullet gets a short label like its neighbours. D99 and the bullet link the Yjs report, yjs/yjs#833. `UndoRedoContainerKeeps.test.ts` (from #9723, kept and amended): asserts the cell's text after every edit, undo and redo (so a step that changed nothing cannot pass), adds the boundary cases (bold in the cell first, then type/undo/redo or delete/undo: 0 left; two deletes and an undo of the second: 0 left), fixes the header comment (destroying an UndoManager releases nothing), and checks the plain-Yjs undo and redo results. - **D95: DECIDED, keep.** The rules that fixed #9671 and #9599 stay; the row moves from Open to the storage model section with its trade numbers unchanged and a decision sentence (taken together the rules fix many more runs than they break, though the null and restore rules only show breaks in two lanes). - **D100, #9589's main variant: ACCEPTED LIMIT.** A format a collaborator applies starting where a run of that format ends can be lost when a client other than the run's author changes part of the run at the same time (12 of 24 cases: both edits, every order of three client ids, gc on and off). The y.md sentence that already describes it now links #9589. - **#9725 (text entities).** A matcher that looks back into a run of repeated characters (`/(?<=\$AA)A/`) can drop the NodeState of one existing character when typing extends the run. Recorded as a known limit under `registerLexicalTextEntity` in transforms.md. ## Changeset None: documentation and a test. ## Test plan ### Before `UndoRedoContainerKeeps.test.ts` on main (d5b30f09): 7 cases, none with a cell already kept, a second deletion, or a check that the edits happened. ``` $ ./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/UndoRedoContainerKeeps.test.ts ✓ |unit| packages/lexical-y/src/__tests__/unit/regressions/UndoRedoContainerKeeps.test.ts (7 tests) Tests 7 passed (7) ``` ### After ``` $ ./node_modules/.bin/vitest --run --project unit --reporter=verbose packages/lexical-y/src/__tests__/unit/regressions/UndoRedoContainerKeeps.test.ts ✓ a table a collaborator deletes is collected when A typed (gc on) ✓ a table a collaborator deletes is collected when A typed and undid (gc on) ✓ a table a collaborator deletes stays in A’s document when A typed, undid and redid (gc on) ✓ a table a collaborator deletes is collected when A deleted (gc on) ✓ a table a collaborator deletes stays in A’s document when A deleted and undid (gc on) ✓ a table a collaborator deletes is collected when A bolded first, then typed, undid and redid (gc on) ✓ a table a collaborator deletes is collected when A bolded first, then deleted and undid (gc on) ✓ a table a collaborator deletes is collected when A deleted twice and undid the second (gc on) ✓ an undo restoring deleted text in a container keeps it from gc (plain Yjs) ✓ a redo restoring an insert in a container keeps it from gc (plain Yjs) Tests 10 passed (10) ``` `prettier --check` and `eslint` pass on the changed files. --- .../lexical-website/docs/collaboration/y.md | 43 ++-- .../docs/concepts/transforms.md | 6 + packages/lexical-y/DECISIONS.md | 11 +- .../UndoRedoContainerKeeps.test.ts | 203 ++++++++++-------- 4 files changed, 148 insertions(+), 115 deletions(-) diff --git a/packages/lexical-website/docs/collaboration/y.md b/packages/lexical-website/docs/collaboration/y.md index fa4d9a85d9c..f7e86657b42 100644 --- a/packages/lexical-website/docs/collaboration/y.md +++ b/packages/lexical-website/docs/collaboration/y.md @@ -1185,7 +1185,8 @@ not merge as a user might expect: table a collaborator deletes meanwhile stays in that client's state until it reloads (about 100 to 180 bytes for a small table); one deleted after the binding stops is collected, when the undo history was cleared before - it stopped (see the next item). When the binding stops it lets them go, + it stopped (see the next item) and no undo or redo restored content in + it (see the item after). When the binding stops it lets them go, also where another undo manager on the document still relies on them, which then finds them unkept, as after a clear of a Yjs `UndoManager`. - **An undo manager destroyed without a clear.** When the binding stops @@ -1203,29 +1204,28 @@ not merge as a user might expect: document again does not free it. Call `binding.undoManager?.clear()` while the binding is started, before stopping, to prevent it where no other undo manager on the document still needs what it kept (a clear - releases that too); a table already deleted while the history held such + releases that too), except for content an undo or redo restored in the + cell (see the next item); a table already deleted while the history held such a step stays after the clear too; a reload, or a server or relay that loads the state with garbage collection on, compacts it. Any editor that destroys a Yjs `UndoManager` without clearing it first behaves the same; the binding does not clear at stop because a clear also releases what another undo manager on the document still keeps for its own undo. -- **An undo or redo that restores content inside a container keeps that container from collection until reload.** - When content inside a container (such as a table cell) is deleted—either - directly by an edit, or when undoing an insertion—the `UndoManager` records - that deletion on a stack item, keeping the deleted content and walking up - the parents (the cell, row and table). When an undo restores that deleted - content, or a redo re-creates the inserted text, `popStackItem` removes - that deletion step from the stack. Clearing every `UndoManager` and - stopping the binding does not release the parent containers: `clear()` - only walks steps still on a stack, so the keeps set by the popped deletion - step are never released (and in the redo case, setting only the re-created - copy's keep back to false still leaves the parents kept). If a collaborator - later deletes that container, this client's document and any state it - encodes keep the container structs until it is reloaded: about 139 to 151 - bytes for a one-cell table with nine characters, growing with the table's - content. A reload, or a peer or relay that loads the state with garbage - collection on, compacts it. Any editor using Yjs `UndoManager` behaves the - same. +- **An undo or redo inside a container.** An undo or redo that restores + content in a table cell can keep the cell, its row and its table after its + undo history is cleared and the binding stops: Yjs keeps what an undo step + deleted, parents included, and a clear releases only the steps still on + the stacks, not the one the undo or redo took off them. It does when that + step was the first thing to keep the cell; a cell the binding already + kept for a format of its own is let go when it stops, and a deletion in + the same cell that is still on a stack is released by the clear. A table + a collaborator deletes afterwards then stays in that client's document, + and in any state it encodes, until it reloads: 139 to 151 bytes for a + one-cell table with nine characters, more with more content. A reload, + or a server or relay that loads the state with garbage collection on, + compacts it. Any editor that uses a Yjs `UndoManager` behaves the same + ([#9721](https://github.com/meta/lexical/issues/9721); reported to Yjs as + [yjs/yjs#833](https://github.com/yjs/yjs/issues/833)). - **Named slots.** A slot holds one node: two collaborators setting the same slot at once keep one writer's node, and text typed in a slot that a collaborator replaces or removes at the same time is lost (as with delete @@ -1374,8 +1374,9 @@ not merge as a user might expect: format from part of the run or types inside it without the format: with `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. A local change the binding + some client id orders ` bb` stays plain + ([#9589](https://github.com/meta/lexical/issues/9589)). No text is lost, + and when that 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 diff --git a/packages/lexical-website/docs/concepts/transforms.md b/packages/lexical-website/docs/concepts/transforms.md index 3658dbfe659..a5222e07b22 100644 --- a/packages/lexical-website/docs/concepts/transforms.md +++ b/packages/lexical-website/docs/concepts/transforms.md @@ -193,6 +193,12 @@ registerLexicalTextEntity( ): Array<() => void>; ``` +Known limit: a matcher that looks back into a run of repeated characters +(such as `/(?<=\$AA)A/`, an `A` right after `$AA`) can drop the NodeState of +one existing character when typing extends the run, because the edit is read +as text inserted at the end of the run +([#9725](https://github.com/meta/lexical/issues/9725)). + ## Examples 1. [Emojis](https://github.com/meta/lexical/blob/main/packages/lexical-playground/src/plugins/EmojisExtension/index.ts) diff --git a/packages/lexical-y/DECISIONS.md b/packages/lexical-y/DECISIONS.md index a8af64cbf65..6c7a489bcb8 100644 --- a/packages/lexical-y/DECISIONS.md +++ b/packages/lexical-y/DECISIONS.md @@ -57,9 +57,10 @@ issues and docs that carry it. | D23 | **Garbage collection stays on**, as Yjs defaults; headless results are compacted as clients store them. Only temporary documents turn it off. Turning it off for history is an app's choice, on every client and server. | A server that skips compaction while clients compact is a trap, and undo and insert attribution work with GC on. | `DECIDED` | 10-23 | [#9437], [y-server.md][y-server] | | D24 | **Yjs's format cleanup is off** in `@lexical/y` containers and on every relay that loads a state (`createYDoc`). A delete keeps the ends of each deleted formatted run (within a bound per format key until D86 removed the bound: a delete keeps every format item it leaves). Not reported upstream. | The cleanup deletes format items a concurrent writer may still rely on. Upstream already knows that skipping it is the app's trade-off. | `DECIDED` | 10-28 | [#9400], [y.md: Servers][y-servers] | | D86 | **A delete keeps every format item it leaves (no collapse).** The collapse of the run of deleted content and format items a delete leaves (`Cursor#collapseGap`, D24's bound per format key) is removed; D24's cleanup-off stays. | The collapse dropped ends that a concurrent writer's restore or an undo relied on, and the value ran on: #9575, #9609, a #9620 cell, the concurrent restore cells of #9640, a KeepEndsSessions seed. Every attempt to keep the right ends in the collapse (#9617) needed to know which write relies on which item. Measured against main, same seeds: a three-client view fuzz of 15,000 seeds: 0 seeds wrong where main is right, 631 where main spreads a value and this does not. Cost: each formatted run whose text is deleted keeps its two items, none per keystroke or plain delete (F24A-1: 40, 80, 120 after 20, 40, 60 words typed and deleted at one place); a comment added and its word deleted 3,000 times at one place: 6,002 live format items (main 6); a downstream document library's corpus of 189 documents x1.003 at 10 cycles, x1.26 at 150; Jot x1.01 on its default profile, a concentrated recolor 438 items (main 345). Accepted: the linear growth with deleted formatted text, over the bound and its spreads. | `DECIDED` | 10-31 | [#9575], [#9609] | -| D88 | **A client keeps its own format items from garbage collection while they are undoable (memory only).** The binding sets Yjs's `Item.keep` on the format items its own transactions and its undo manager's insert, and on the end of each run they delete; it releases them, between transactions, when both undo stacks are empty (after a clear, an undo that leaves nothing, or a write they did not capture), and when it is disposed or its document destroyed. An item another binding or an `UndoManager` step also keeps stays kept. A clear of the binding's undo history makes its `UndoManager` release what the cleared steps kept, parents included, also where another `UndoManager` relies on them (Yjs's keep is one flag): the binding keeps again what it still relies on, and releases those keeps when it stops (the last binding on the document that keeps such an item releases it). A container a collaborator deletes meanwhile (a table) stays in this client's state until it reloads, about 108 to 178 bytes per cycle of an edit and a format in a cell, a clear and the table deleted (the same bytes as before this rule; releasing collects nothing); one deleted after the binding stops is collected (when the undo history was cleared before it stopped; D98). After the stop, another `UndoManager` that still needs such an item relies on it unkept, as after a clear of a Yjs `UndoManager`. Releasing collects nothing, as Yjs's `UndoManager` collects nothing when it releases what its steps kept: any `UndoManager` on the document may still restore the item. | An undo reads its change's format items, and the end each gave a run it deleted, after a collaborator deleted them (`isKeptByUndo`, `changeEnd`, `closeRestoredRuns`). With Yjs's default garbage collection, which products use, those were collected, the undo could not tell them apart from other content, and the restored value ran on over the rest of the document ([#9623]; a two-client undo fuzz: 221 of 3,000 seeds to 0). Nothing new is written and other clients are unchanged. The cost is local: a format item a collaborator deletes while this client can undo the change that wrote it keeps its deleted content in this client's document, and in a state it encodes, until the document reloads or a relay that loads its state with gc on compacts it: about 25 bytes per such deletion, in this client only (+0.6% to 0.8% of the client's state in a realistic 4,500-edit session). Accepted over a stored change (a `gcFilter` keeping every format item: +1.9% to 7.6% bytes on long notes, in every peer and relay) and over collecting on release, which was tried and dropped: it destroyed what another `UndoManager` on the same document still needed. A `gcFilter` keeping every format item fixed the same cases, but changes what every peer and relay stores (+1.9% to 7.6% bytes on long notes). | `DECIDED` | 10-31 | [#9623], [y.md: Limits][limits] | +| D88 | **A client keeps its own format items from garbage collection while they are undoable (memory only).** The binding sets Yjs's `Item.keep` on the format items its own transactions and its undo manager's insert, and on the end of each run they delete; it releases them, between transactions, when both undo stacks are empty (after a clear, an undo that leaves nothing, or a write they did not capture), and when it is disposed or its document destroyed. An item another binding or an `UndoManager` step also keeps stays kept. A clear of the binding's undo history makes its `UndoManager` release what the cleared steps kept, parents included, also where another `UndoManager` relies on them (Yjs's keep is one flag): the binding keeps again what it still relies on, and releases those keeps when it stops (the last binding on the document that keeps such an item releases it). A container a collaborator deletes meanwhile (a table) stays in this client's state until it reloads, about 108 to 178 bytes per cycle of an edit and a format in a cell, a clear and the table deleted (the same bytes as before this rule; releasing collects nothing); one deleted after the binding stops is collected (when the undo history was cleared before it stopped; D98), unless an undo or redo restored content in it (D99). After the stop, another `UndoManager` that still needs such an item relies on it unkept, as after a clear of a Yjs `UndoManager`. Releasing collects nothing, as Yjs's `UndoManager` collects nothing when it releases what its steps kept: any `UndoManager` on the document may still restore the item. | An undo reads its change's format items, and the end each gave a run it deleted, after a collaborator deleted them (`isKeptByUndo`, `changeEnd`, `closeRestoredRuns`). With Yjs's default garbage collection, which products use, those were collected, the undo could not tell them apart from other content, and the restored value ran on over the rest of the document ([#9623]; a two-client undo fuzz: 221 of 3,000 seeds to 0). Nothing new is written and other clients are unchanged. The cost is local: a format item a collaborator deletes while this client can undo the change that wrote it keeps its deleted content in this client's document, and in a state it encodes, until the document reloads or a relay that loads its state with gc on compacts it: about 25 bytes per such deletion, in this client only (+0.6% to 0.8% of the client's state in a realistic 4,500-edit session). Accepted over a stored change (a `gcFilter` keeping every format item: +1.9% to 7.6% bytes on long notes, in every peer and relay) and over collecting on release, which was tried and dropped: it destroyed what another `UndoManager` on the same document still needed. A `gcFilter` keeping every format item fixed the same cases, but changes what every peer and relay stores (+1.9% to 7.6% bytes on long notes). | `DECIDED` | 10-31 | [#9623], [y.md: Limits][limits] | | D96 | **A run ends in its own container, and no item is linked next to an item of another container.** `changeEnd` and `lastOwnItemBefore` look only at items in the run's shared type; the cursor refuses to link a new item next to an item of another type (the write throws and is recovered from, `RECOVERED_WRITE_WARNING`). | The end a write gives a restated run at its range's end (D78's rule 3, [#9679]) came from `changeEnd`, which walks the client's items in clock order across containers and could return an item in another type; the writer linked its end into that type's list while naming the run's container as its parent. Every other client took the item's parent from its origin and put it in the root's list, where it changes nothing, but the writer's own document held a malformed item, and its editor stopped reading part of the document for good (a downstream app's long notes: 15 of 133 sessions). A structure check (no item's neighbour in another container) now runs in every harness convergence check, the undo fuzz and a structure fuzz. | `DECIDED` | 11-01 | [#9694] | | D78 | **A format spreading after one client's triple undo:** a client formats a run, types plain text, formats again and undoes all three while a collaborator types plain text inside the first run. | Fixed at the source: a restore's scan (`runOpener`) stops at content of another client, so the writer's earlier end and later opener with a collaborator's text between them are no longer read as one restored run, and the undo writes the run's end. A two-client run-sequence fuzz: 8 of 1,000 seeds to 0, gc on and off. | `DECIDED` | 10-31 | [#9599] | +| D95 | **Keep the rules that fixed #9671 and #9599, although they make rare other cases wrong, one of them for a single client.** A format over a range that starts at the writer's own earlier insert null removes that null (the null rule), one whose range ends at the writer's own earlier insert restore removes that restore and joins the runs (the restore rule; both [#9671]), and a restore's scan for the run it restates stops at another client's text (the scan rule, D78, [#9599]). | The restore rule also changes a single client's own undo, with no collaborator acting: type plain text into a bold run, bold a range ending at it, type plain text later on, undo the three steps, and a word none of them touched loses its bold (main before these rules was right) ([#9690]). Each rule disabled alone, same fresh seeds, gc on and off (counts of runs main gets right and the rule's absence wrong, then the reverse): a raw-Yjs-oracle op-list generator, 36,000 runs: null rule 18 / 4, restore rule 28 / 0, scan rule 2 / 1 (gc on and off identical; all three off equals main before them); its 18,000-run original set: 20 / 0, 6 / 0, 0 / 0; a three-client view property, 15,000 seeds: null rule 0 / 2 (gc off), restore rule 0 / 1 (gc on), scan rule 2 / 0; a sequential undo fuzz, 3,000 seeds: restore rule 0 / 2 (one single-client); a two-client run-sequence fuzz, 1,000: scan rule 7 / 0; unit cells: null rule 8, restore rule 16, scan rule 5; a long-session soak, about 20,600 sessions: 2 sessions wrong (null rule 1, scan rule 1) against 25 the other way. None of the narrower variants tried kept the gains: the writer cannot tell from the document whether a collaborator's lone null ends a format range or starts a removal (a variant of the null rule that writes the range's own opener fixed its cases and lost all 8 of its unit cells). Decided on 11-02 to keep all three: taken together they fix many more runs than they break (in the 36,000-run generator, 18, 28 and 2 runs against 4, 0 and 1 for the null, restore and scan rules), although in the three-client view property and the sequential undo fuzz the null and restore rules only break runs (null rule 2, restore rule 1 and 2), and stored provenance, which would avoid both sides, is not ready to ship. Dropping the restore rule for its single-client case was weighed and not taken. | `DECIDED` | 11-02 | [#9690], [#9679], [#9671], [#9599] | | D25 | **Undo is Yjs's `UndoManager`**, scoped to the binding's root and this client's own edits (500 ms capture by default). It never deletes a container a collaborator has put content in (its `deleteFilter`). Do not combine it with `HistoryExtension`. | Collaborative undo must not revert other people's work; keeping a container whole once someone else wrote in it matches y-prosemirror's protected nodes and is cheap to maintain. The `UndoManager` was chosen on 10-23; keeping containers whole was confirmed on 10-30. | `DECIDED` | 10-30 | [#9400], [y.md: Limits][limits] | | D26 | **Undo favours people taking turns over concurrent races.** Where a fix makes undo correct for sequential edits at the cost of rare races between an undo and a simultaneous edit of the same text, the sequential case wins. | Taking turns is the common case; a race with a simultaneous edit of the same text is rare. Picked on 10-28, confirmed on 10-30. | `DECIDED` | 10-30 | [#9400] | | D27 | **Moves are a delete and a re-insert** at launch; the binding rewrites the cheaper side. Real moves within a container ([#9449], records after Yjs's unmerged `ContentMove` design) are paused and land last, backward compatible: a new layout version that older clients open read only. Move records leave tombstones, as every Yjs binding does. Moves across containers stay copies. | Every Yjs binding turns moves into delete plus insert; Yjs dropped its own move for complexity. Reliability of the rest came first. | `DECIDED` | 10-25 | [#9449], [#9455], [y.md: Limits][limits] | @@ -127,8 +128,9 @@ issues and docs that carry it. | D90 | **List numbering can lag after refused follow-ups:** after four follow-ups in a row that the binding does not write and reads again (such as `BlockIdExtension`'s naming updates), the author can show a derived list number from before them (1 where its document reads 2) until that list is refreshed (adding an item to it does; an edit elsewhere, or retyping an item's text, does not); its own fresh read, other clients and a reload show the right number. | The stop on the fourth re-read in a row is the backstop that ends an app's reply loops: [#9634] showed that fresh derived values after every re-read, bounded reply chains and app replies that read the derived value cannot all hold. Display only, the author's own editor, no stored change. | `ACCEPTED LIMIT` | 10-31 | [#9632], [#9634], [#9642], [limits] | | D91 | **Overlapping formats of one key from two writers can cancel:** two collaborators applying the same format (bold, the same comment id, a link) to overlapping ranges at once can leave part of the text without it. Since [#9601] this also reaches a link's edge and its first word (D82). | A Yjs text gives each place one value per key ([yjs#382]): a writer's end of its range ends the other writer's run there too. Avoiding it needs a marker per writer, a storage change. Formatting only, no text lost; [#9601] is kept. | `ACCEPTED LIMIT` | 10-31 | [#9626], [#9601], [limits] | | D93 | **A comment on a word a collaborator rewrites at the same moment can land on the new word:** with `setTextContent` for one client id order, and with a block rewrite (the line's children replaced by one new text node, as an agent writes it) for one order when the comment ends at the rewritten word and for both when it continues past it; words of the range the rewrite kept keep the comment. | The same race as text typed at the start of a range a collaborator formats (y.md, Other races): the new word is inserted where the commented word starts, and Yjs orders the two writes by client id. Before [#9598] a block rewrite dropped the comment from the whole range instead. Formatting only, no text lost. | `ACCEPTED LIMIT` | 10-31 | [#9610], [limits] | -| D98 | **A Yjs `UndoManager` destroyed without a clear keeps what its steps protected.** The binding destroys its undo manager without clearing it when it stops (`binding.dispose()`, or the cleanup its start returns: an editor unmounted or mounted again), and Yjs destroys an `UndoManager` when its document is destroyed. Yjs then keeps, until the document is reloaded, the items the destroyed manager's steps kept: a character deleted in a table cell can keep that cell, its row and its table. It does when the delete's step is the first thing to keep the cell (Yjs's `keepItem` stops at an ancestor already kept); where the binding already kept the cell for one of its own format items (D88) before the delete, the step keeps only the character, and the binding's release at stop lets the cell, row and table go. When it does, a table a collaborator deletes afterwards stays in that client's document, and in any state it encodes: 108 to 175 bytes per table where one character was deleted and one word bolded in one cell of a 1x1 to a 4x4 table, more where more was deleted or more cells were edited (about 1.3 KB for one character deleted in every cell of a 4x4 table). Binding the same document again does not free it. Clearing the history (`binding.undoManager?.clear()`, while the binding is started) before stopping and before the table is deleted prevents it, where no other `UndoManager` on the document still needs what it kept (a clear releases that too); a table deleted while the history still held such a step stays after a later clear too, as releasing a keep collects nothing (D88). Reloading the document, or loading its encoded state into a new document with garbage collection on (a server or relay), compacts it. | Yjs's `UndoManager#destroy` does not clear, and its keep is one flag set on an item and its parents; any editor that destroys a Yjs `UndoManager` without clearing it first keeps the same items. Measured over 50 cycles per table size (an edit in a cell, the binding stopped, the table deleted by a collaborator): the same bytes on the release before D88's own-format keep, with that keep turned off and on the current release, and none when the history was cleared before stopping. Clearing at stop in the binding was weighed and not taken: Yjs's clear releases with that one flag, so it would also release what another `UndoManager` on the document (an app's, or a second binding's) still keeps for its own undo, and a later collection could drop what that undo restores (the hazard D88 records for a clear). | `ACCEPTED LIMIT` | 11-01 | [#9698], [limits] | -| D99 | **An undo or redo that restores content inside a container keeps that container from garbage collection until reload.** When content inside a container (such as a table cell) is deleted—either directly by an edit, or when undoing an insertion—Yjs's `UndoManager` records that deletion on a stack item, keeping the deleted item and walking up the parents (the cell, row and table). When an undo restores that deleted content, or a redo re-creates the inserted text, `popStackItem` removes that deletion step from the stack. Clearing every `UndoManager` and stopping the binding does not release them: Yjs's `clearUndoManagerStackItem` only calls `keepItem(item, false)` for items in deletions of steps still on the stacks, so the keeps set by the popped deletion step are never un-kept (and in the redo case, setting only the re-created copy's keep back to false still leaves the parents kept). If a collaborator later deletes that container, this client's document and encoded state retain the container's structs until reload: about 139 to 151 bytes for a one-cell table with nine characters, growing with the table's content. Reloading the document, or loading its encoded state into a fresh document with garbage collection on (a server or relay), compacts it. Nothing reaches other peers. | Not a binding bug: reproduced on plain Yjs `UndoManager` calls without an editor and across every binding tree. A binding-side release would have to know which keeps came from a popped deletion step; Yjs's `keep` is a single boolean shared by every `UndoManager` on the document, so the binding cannot tell them apart safely without risking un-keeping items another manager relies on. | `ACCEPTED LIMIT` | 11-02 | [#9721], [limits] | +| D98 | **A Yjs `UndoManager` destroyed without a clear keeps what its steps protected.** The binding destroys its undo manager without clearing it when it stops (`binding.dispose()`, or the cleanup its start returns: an editor unmounted or mounted again), and Yjs destroys an `UndoManager` when its document is destroyed. Yjs then keeps, until the document is reloaded, the items the destroyed manager's steps kept: a character deleted in a table cell can keep that cell, its row and its table. It does when the delete's step is the first thing to keep the cell (Yjs's `keepItem` stops at an ancestor already kept); where the binding already kept the cell for one of its own format items (D88) before the delete, the step keeps only the character, and the binding's release at stop lets the cell, row and table go. When it does, a table a collaborator deletes afterwards stays in that client's document, and in any state it encodes: 108 to 175 bytes per table where one character was deleted and one word bolded in one cell of a 1x1 to a 4x4 table, more where more was deleted or more cells were edited (about 1.3 KB for one character deleted in every cell of a 4x4 table). Binding the same document again does not free it. Clearing the history (`binding.undoManager?.clear()`, while the binding is started) before stopping and before the table is deleted prevents it, where no other `UndoManager` on the document still needs what it kept (a clear releases that too), except for content an undo or redo restored in the cell (D99); a table deleted while the history still held such a step stays after a later clear too, as releasing a keep collects nothing (D88). Reloading the document, or loading its encoded state into a new document with garbage collection on (a server or relay), compacts it. | Yjs's `UndoManager#destroy` does not clear, and its keep is one flag set on an item and its parents; any editor that destroys a Yjs `UndoManager` without clearing it first keeps the same items. Measured over 50 cycles per table size (an edit in a cell, the binding stopped, the table deleted by a collaborator): the same bytes on the release before D88's own-format keep, with that keep turned off and on the current release, and none when the history was cleared before stopping. Clearing at stop in the binding was weighed and not taken: Yjs's clear releases with that one flag, so it would also release what another `UndoManager` on the document (an app's, or a second binding's) still keeps for its own undo, and a later collection could drop what that undo restores (the hazard D88 records for a clear). | `ACCEPTED LIMIT` | 11-01 | [#9698], [limits] | +| D99 | **An undo or redo that restores content inside a container can keep that container from garbage collection until reload.** Yjs's `UndoManager` keeps what a step deletes, walking up its parents (a table cell, its row and its table). An undo or redo that restores that content takes the step off the stacks, and Yjs's `clear()` releases only the steps still on them, so clearing the history before the binding stops does not release the cell, row and table. It does when that step's deletion is the first thing to keep the cell (Yjs's `keepItem` stops at an ancestor already kept): a cell the binding already kept for one of its own format items (D88) is let go when the binding stops, and a deletion in the same cell that is still on a stack is released by the clear, parents included. When it does, a table a collaborator deletes afterwards stays in that client's document, and in any state it encodes, until it reloads: 139 to 151 bytes for a one-cell table with nine characters, more with more content. Reloading the document, or loading its encoded state into a new document with garbage collection on (a server or relay), compacts it. Nothing reaches other clients. | Yjs behaviour, reproduced with plain `UndoManager` calls and no editor, and on every binding release. A release in the binding would have to know which keeps came from a step an undo or redo took off the stacks; Yjs's keep is one flag on each item, shared by every `UndoManager` on the document, so the binding cannot release it without risking what another undo manager still keeps for its own undo (the hazard D88 and D98 record). The fix belongs in Yjs: reported as [yjs#833]. | `ACCEPTED LIMIT` | 11-02 | [#9721], [#9723], [yjs#833], [limits] | +| D100 | **A format a collaborator applies starting right where a run of that format ends can be lost when a client other than the run's author changes part of the run at the same time:** with `Kilo` bolded by one client, another unbolds `il` or types plain text inside `Kilo` while the first or a third client bolds ` bb`; with some client id orders ` bb` stays plain on every client (12 of 24 cases in a test of both edits, every order of three client ids and gc on and off, on every version tested). No text is lost, and when the client that changed the run undoes its change, ` bb` is bold again. | The change inside the run writes a null right after the run's end, so that the run's value does not run on onto text a collaborator types there, and the other client's opener of ` bb` has the same neighbours; Yjs orders the two by client id, so with the writer's id higher its null lands between that opener and ` bb`. Writing the null anywhere else gives up that protection. A repair after the fact, or stored provenance, could avoid it; neither is worth its cost now. D52 records the same race for the client that formatted the run. | `ACCEPTED LIMIT` | 11-02 | [#9589], [limits] | ## Open @@ -142,7 +144,6 @@ issues and docs that carry it. | D63 | **A line without a marker joins a paragraph a collaborator appends.** | Every reader rule considered changes how stored documents read. | `OPEN` | 10-30 | [#9524] | | D64 | **Undoing a format while a collaborator edits inside its range.** | Yjs format items cannot be conditional on another item; the remaining option is a core or storage change. | `OPEN` | 10-30 | [#9560], [#9521], [#9682] | | D65 | **A comment spreading after an offline client types plain text into it.** | Since D86 a delete keeps every format item it leaves, so the [#9609] cells where a collaborator's delete took the run's end pass; the cells where the restore's run has no end of its own (`OwnRunRestoreEnd.test.ts`) still fail. [#9562], which extended the restore's end rule to runs the writer opened itself, is parked: each attempt regressed a case main handles. The case is tracked in [#9609], with the tests moving to main in [#9605]. A restore in a run whose format change wrote more than 64 items gets no end, because the end scan stops there ([#9603]). An undo of a removal can bring back a run whose text and closer its author undid at the same time, and that run then has no end ([#9689]); ending it at the end the author's write gave the run (`changeEnd`) was tried and parked ([#9687]): the restoring client cannot tell that end from the end of a later, separate write by the same author. | `OPEN` | 10-31 | [#9564], [#9609], [#9562], [#9605], [#9603], [#9689] | -| D95 | **The rules that fixed #9671 and #9599 make rare other cases wrong, one of them for a single client.** A format over a range that starts at the writer's own earlier insert null removes that null (the null rule), one whose range ends at the writer's own earlier insert restore removes that restore and joins the runs (the restore rule; both [#9671]), and a restore's scan for the run it restates stops at another client's text (the scan rule, D78, [#9599]). | The restore rule also changes a single client's own undo, with no collaborator acting: type plain text into a bold run, bold a range ending at it, type plain text later on, undo the three steps, and a word none of them touched loses its bold (main before these rules was right) ([#9690]). Each rule disabled alone, same fresh seeds, gc on and off (counts of runs main gets right and the rule's absence wrong, then the reverse): a raw-Yjs-oracle op-list generator, 36,000 runs: null rule 18 / 4, restore rule 28 / 0, scan rule 2 / 1 (gc on and off identical; all three off equals main before them); its 18,000-run original set: 20 / 0, 6 / 0, 0 / 0; a three-client view property, 15,000 seeds: null rule 0 / 2 (gc off), restore rule 0 / 1 (gc on), scan rule 2 / 0; a sequential undo fuzz, 3,000 seeds: restore rule 0 / 2 (one single-client); a two-client run-sequence fuzz, 1,000: scan rule 7 / 0; unit cells: null rule 8, restore rule 16, scan rule 5; a long-session soak, about 20,600 sessions: 2 sessions wrong (null rule 1, scan rule 1) against 25 the other way. None of the narrower variants tried kept the gains: the writer cannot tell from the document whether a collaborator's lone null ends a format range or starts a removal (a variant of the null rule that writes the range's own opener fixed its cases and lost all 8 of its unit cells). Kept for now. Options under consideration include stored provenance and dropping the restore rule because of the single-client case. | `OPEN` | 10-31 | [#9690], [#9679], [#9671], [#9599] | | D66 | **Server identity:** claim a client id on first use, or server-issued leases. | Claim on first use is the recommendation; not decided. | `OPEN` | 10-29 | | | D67 | **The rest of the server-defence proposals:** a relay gate judging version records against a published ceiling, budgets, recovery, an author map, agent write policies. | Researched and proposed; nothing decided beyond D46. | `OPEN` | 10-29 | | | D68 | **Splice-based text edit helpers exported from Lexical** for agents that rewrite text, and named APIs for intent that `setTextContent` drops. | Agents should edit text in place; the API shape is undecided. | `OPEN` | 10-27 | [#9483] | @@ -238,8 +239,10 @@ issues and docs that carry it. [#9689]: https://github.com/meta/lexical/issues/9689 [#9690]: https://github.com/meta/lexical/issues/9690 [yjs#382]: https://github.com/yjs/yjs/issues/382 +[yjs#833]: https://github.com/yjs/yjs/issues/833 [#9679]: https://github.com/meta/lexical/pull/9679 [#9694]: https://github.com/meta/lexical/issues/9694 [#9697]: https://github.com/meta/lexical/pull/9697 [#9701]: https://github.com/meta/lexical/issues/9701 [#9721]: https://github.com/meta/lexical/issues/9721 +[#9723]: https://github.com/meta/lexical/pull/9723 diff --git a/packages/lexical-y/src/__tests__/unit/regressions/UndoRedoContainerKeeps.test.ts b/packages/lexical-y/src/__tests__/unit/regressions/UndoRedoContainerKeeps.test.ts index cb4d095f3c2..a8de647fdb2 100644 --- a/packages/lexical-y/src/__tests__/unit/regressions/UndoRedoContainerKeeps.test.ts +++ b/packages/lexical-y/src/__tests__/unit/regressions/UndoRedoContainerKeeps.test.ts @@ -9,18 +9,20 @@ // @vitest-environment node // D99 (accepted limit): an undo or redo that restores content inside a -// container keeps that container from collection until reload (Yjs -// UndoManager). When content in a container (such as a table cell) is -// deleted—either directly by an edit, or when undoing an insertion—the -// UndoManager records that deletion on a stack item and keepItem walks up the -// parent chain setting keep = true on the cell, row and table. When an undo -// restores that deleted content (or a redo re-inserts it), popStackItem pops -// the deletion step off the stack. When the undo history is cleared or -// destroyed, Yjs's clearUndoManagerStackItem only un-keeps items in deletions -// of steps still on the stacks; the popped deletion step is no longer on any -// stack, so its parent keep walk is never released. If a collaborator later -// deletes that container, the deleted container structs stay in this client's -// document until reload (compacted when loaded into a fresh document with gc on). +// container (a table cell) can keep that container, its row and its table +// from garbage collection until reload. The UndoManager step that deleted the +// content kept it and, walking up, the cell, row and table (Yjs's keepItem +// stops at an ancestor already kept). The undo or redo that restores the +// content takes that step off the stacks, and Yjs's clear() releases only +// the steps still on them, so a clear before the binding stops does not +// release the cell. It does when the step's deletion was the first thing to +// keep the cell: a cell the binding already kept for one of its own format +// items (D88) is let go when the binding stops, and a deletion in the same +// cell still on a stack is released by the clear, parents included. If a +// collaborator then deletes the table, the deleted table, row and cell stay +// in this client's document until it reloads (compacted when its state is +// loaded into a fresh document with gc on). Destroying an UndoManager +// releases nothing (D98). import { $createTableCellNode, $createTableNode, @@ -76,34 +78,57 @@ const deletedContainers = (doc: Y.Doc): string[] => { return out; }; -type EditFlow = - | 'type' - | 'type, then undo' - | 'type, undo, then redo' - | 'delete' - | 'delete, then undo'; +type Step = 'type' | 'delete' | 'delete again' | 'bold' | 'undo' | 'redo'; -const CASES: [string, EditFlow, number][] = [ - ['is collected when client typed and cleared before stopping', 'type', 0], +// [name, A's steps in the cell, the cell's text after each step, containers +// left in A after A clears, stops and B deletes the table] +const CASES: [string, Step[], string[], number][] = [ + ['is collected when A typed', ['type'], ['cell typed text'], 0], [ - 'is collected when client typed, undid, and cleared before stopping', - 'type, then undo', + 'is collected when A typed and undid', + ['type', 'undo'], + ['cell typed text', 'cell text'], 0, ], [ - 'stays in document when client typed, undid, redid, and cleared before stopping', - 'type, undo, then redo', + 'stays in A\u2019s document when A typed, undid and redid', + ['type', 'undo', 'redo'], + ['cell typed text', 'cell text', 'cell typed text'], 3, ], - ['is collected when client deleted and cleared before stopping', 'delete', 0], + ['is collected when A deleted', ['delete'], ['cll text'], 0], [ - 'stays in document when client deleted, undid, and cleared before stopping', - 'delete, then undo', + 'stays in A\u2019s document when A deleted and undid', + ['delete', 'undo'], + ['cll text', 'cell text'], 3, ], + // The binding kept the cell for its own bold (D88) before the undo step's + // deletion, so that step kept only what it deleted (keepItem stops at a + // kept parent), and the binding lets the cell go when it stops. + [ + 'is collected when A bolded first, then typed, undid and redid', + ['bold', 'type', 'undo', 'redo'], + ['cell text', 'cell typed text', 'cell text', 'cell typed text'], + 0, + ], + [ + 'is collected when A bolded first, then deleted and undid', + ['bold', 'delete', 'undo'], + ['cell text', 'cll text', 'cell text'], + 0, + ], + // The first deletion's step is still on the undo stack, so the clear + // releases it, and with it the cell, row and table. + [ + 'is collected when A deleted twice and undid the second', + ['delete', 'delete again', 'undo'], + ['cll text', 'cl text', 'cll text'], + 0, + ], ]; -for (const [name, flow, kept] of CASES) { +for (const [name, steps, texts, kept] of CASES) { test(`a table a collaborator deletes ${name} (gc on)`, async () => { const t = await setup( () => { @@ -120,45 +145,45 @@ for (const [name, flow, kept] of CASES) { ); try { const [A, B] = t.peers; - if (flow === 'delete' || flow === 'delete, then undo') { - A.update(() => { - const text = ( - $getRoot().getChildAtIndex(1) as ElementNode - ).getFirstDescendant() as TextNode; - const s = $createRangeSelection(); - s.anchor.set(text.getKey(), 1, 'text'); - s.focus.set(text.getKey(), 2, 'text'); - $setSelection(s); - s.removeText(); - $setSelection(null); - }); - } else { - A.update(() => - ( - ( - $getRoot().getChildAtIndex(1) as ElementNode - ).getFirstDescendant() as TextNode - ) - .select(4, 4) - .insertText(' typed'), - ); - } - await t.sync(); - - if ( - flow === 'type, then undo' || - flow === 'type, undo, then redo' || - flow === 'delete, then undo' - ) { - A.undo(); - await t.sync(); - } - if (flow === 'type, undo, then redo') { - A.binding.redo(); + const $cellText = () => + ( + $getRoot().getChildAtIndex(1) as ElementNode + ).getFirstDescendant() as TextNode; + const $select = (from: number, to: number) => { + const text = $cellText(); + const s = $createRangeSelection(); + s.anchor.set(text.getKey(), from, 'text'); + s.focus.set(text.getKey(), to, 'text'); + $setSelection(s); + return s; + }; + for (let i = 0; i < steps.length; i++) { + const step = steps[i]; + if (step === 'undo') { + A.undo(); + } else if (step === 'redo') { + A.binding.undoManager!.stopCapturing(); + A.binding.redo(); + } else { + A.update(() => { + if (step === 'type') { + $select(4, 4).insertText(' typed'); + } else if (step === 'delete' || step === 'delete again') { + $select(1, 2).removeText(); + } else { + $select(5, 9).formatText('bold'); + } + $setSelection(null); + }); + } await t.sync(); + expect( + A.read(() => $getRoot().getChildAtIndex(1)!.getTextContent()), + `after ${step}`, + ).toBe(texts[i]); } - A.binding.undoManager?.clear(); + A.binding.undoManager!.clear(); A.stop(); const beforeDelete = liveContainers(A.doc); @@ -170,6 +195,7 @@ for (const [name, flow, kept] of CASES) { const removed = [...beforeDelete] .filter(id => !afterDelete.has(id)) .sort(); + // B's delete reached A: the table, its row and its cell. expect(removed).toHaveLength(3); const leftInA = deletedContainers(A.doc); @@ -187,7 +213,7 @@ for (const [name, flow, kept] of CASES) { }); } -test('an undo restoring deleted text in a container keeps it from gc (plain Yjs)', () => { +const plainTable = () => { const doc = new Y.Doc({gc: true}); const root = doc.get('root'); const table = new Y.Node('table'); @@ -197,50 +223,47 @@ test('an undo restoring deleted text in a container keeps it from gc (plain Yjs) table.insert(0, [row]); row.insert(0, [cell]); cell.insert(0, 'hello world'); - const um = new Y.UndoManager(root, {captureTimeout: 0}); - cell.delete(5, 6); // delete ' world': puts deletion on undoStack, keeps cell, row, table - um.undo(); // popStackItem pops deletion from undoStack; re-creates text - um.clear(); // popped deletion step is not on any stack, so clear() cannot un-keep it + return {cell, doc, root, um}; +}; + +test('an undo restoring deleted text in a container keeps it from gc (plain Yjs)', () => { + const {cell, doc, root, um} = plainTable(); + cell.delete(5, 6); // the step keeps ' world' and the cell, row and table + expect(cell.toString()).toBe('hello'); + expect(um.undo()).not.toBe(null); // takes the step off the undo stack + expect(cell.toString()).toBe('hello world'); + um.clear(); // releases only the steps still on the stacks um.destroy(); - // Collaborator deletes the table + // The table is deleted (the UndoManager no longer tracks anything). root.delete(0, 1); - // The table, row and cell stay kept in doc + // The table, row and cell stay in doc. expect(deletedContainers(doc)).toHaveLength(3); - // A fresh document loaded from that state collects them + // A fresh document loaded from that state collects them. const fresh = new Y.Doc({gc: true}); Y.applyUpdate(fresh, Y.encodeStateAsUpdate(doc)); expect(deletedContainers(fresh)).toHaveLength(0); }); test('a redo restoring an insert in a container keeps it from gc (plain Yjs)', () => { - const doc = new Y.Doc({gc: true}); - const root = doc.get('root'); - const table = new Y.Node('table'); - const row = new Y.Node('row'); - const cell = new Y.Node('cell'); - root.insert(0, [table]); - table.insert(0, [row]); - row.insert(0, [cell]); - cell.insert(0, 'hello'); - - const um = new Y.UndoManager(root, {captureTimeout: 0}); - cell.insert(5, ' world'); // insert - um.undo(); // undo: deletes ' world', puts deletion on redoStack, keeps cell, row, table - um.redo(); // redo: popStackItem pops deletion from redoStack, re-creates text - um.clear(); // popped deletion step is not on any stack, so clear() cannot un-keep it + const {cell, doc, root, um} = plainTable(); + cell.delete(5, 6); + um.clear(); // starts from a cell nothing keeps + cell.insert(5, ' there'); + expect(um.undo()).not.toBe(null); // its step keeps ' there' and the cell, row and table + expect(cell.toString()).toBe('hello'); + expect(um.redo()).not.toBe(null); // takes that step off the redo stack + expect(cell.toString()).toBe('hello there'); + um.clear(); um.destroy(); - // Collaborator deletes the table root.delete(0, 1); - // The table, row and cell stay kept in doc expect(deletedContainers(doc)).toHaveLength(3); - // A fresh document loaded from that state collects them const fresh = new Y.Doc({gc: true}); Y.applyUpdate(fresh, Y.encodeStateAsUpdate(doc)); expect(deletedContainers(fresh)).toHaveLength(0); From cd6fd8d6deabfec1c4e513b10dcc0ecbe06cc421 Mon Sep 17 00:00:00 2001 From: zurfyx Date: Mon, 2 Nov 2026 12:17:58 -0500 Subject: [PATCH 2/6] Address review: D99 condition is per table; pin D100, #9725 and the two-cell case - D99, y.md and the test header: the table is kept when the popped step was the first thing to keep it; a deletion elsewhere in the table that is still on a stack releases the row and table with the clear. - D88, D98 and y.md pointers to D99 say "and that kept it". - y.md documents the #9690 single-client undo that D95 keeps. - D100: drop the D52 pointer; the author-run case passes on main. - FormatAtRunEndRace.test.ts pins #9589 (12 orders pass, 12 test.fails). - registerLexicalTextEntityLookbehind.test.ts pins #9725 (test.fails). - UndoRedoContainerKeeps: a two-cell case collects the table. --- ...egisterLexicalTextEntityLookbehind.test.ts | 113 ++++++++++++++++++ .../lexical-website/docs/collaboration/y.md | 19 +-- packages/lexical-y/DECISIONS.md | 8 +- .../regressions/FormatAtRunEndRace.test.ts | 83 +++++++++++++ .../UndoRedoContainerKeeps.test.ts | 73 ++++++++++- 5 files changed, 282 insertions(+), 14 deletions(-) create mode 100644 packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityLookbehind.test.ts create mode 100644 packages/lexical-y/src/__tests__/unit/regressions/FormatAtRunEndRace.test.ts diff --git a/packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityLookbehind.test.ts b/packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityLookbehind.test.ts new file mode 100644 index 00000000000..48fa221702f --- /dev/null +++ b/packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityLookbehind.test.ts @@ -0,0 +1,113 @@ +/** + * 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 + +import { + $createHashtagNode, + HashtagExtension, + HashtagNode, +} from '@lexical/hashtag'; +import {registerLexicalTextEntity} from '@lexical/text'; +import { + $create, + $createParagraphNode, + $createTextNode, + $getRoot, + $getState, + $setState, + createEditor, + createState, + TextNode, +} from 'lexical'; +import {expect, test} from 'vitest'; + +class Letter extends TextNode { + $config() { + return this.config('letter', {extends: TextNode}); + } + isTextEntity(): true { + return true; + } +} + +const note = createState('note', { + parse: value => (typeof value === 'string' ? value : ''), +}); + +// #9725 (known limit, see transforms.md): typing into a run of repeated +// characters, read as text inserted at the end of the run, drops the NodeState +// of the last existing character when a matcher that looks back into the run +// makes it an entity. `test.fails` until the edit's reading is fixed. + +// Matches an "A" only when "$AA" comes right before it. +// eslint-disable-next-line compat/compat -- a Node test +const LOOKBEHIND = /^x$|^A$|(?<=^#)x|(?<=\$AA)A/; +const getMatch = (text: string) => { + const m = LOOKBEHIND.exec(text); + return m ? {end: m.index + m[0].length, start: m.index} : null; +}; + +test.fails( + 'typing into a run of repeated characters keeps NodeState on an existing character', + () => { + const editor = createEditor({ + nodes: [HashtagNode, Letter], + onError: error => { + throw error; + }, + }); + registerLexicalTextEntity( + editor, + HashtagExtension.config!.getHashtagMatch, + HashtagNode, + node => $createHashtagNode(node.getTextContent()), + ); + registerLexicalTextEntity(editor, getMatch, Letter, node => + $create(Letter).setTextContent(node.getTextContent()), + ); + editor.update( + () => { + $getRoot().append( + $createParagraphNode().append( + $createTextNode('y').setFormat('bold'), + $createTextNode('a'), + $createHashtagNode('#x'), + $setState($createTextNode('$AA'), note, 'c1'), + ), + ); + }, + {discrete: true}, + ); + editor.update( + () => { + $getRoot() + .getAllTextNodes() + .find(node => node.getTextContent() === '$AA')! + .select(1, 1) + .insertText('A'); + }, + {discrete: true}, + ); + const nodes = editor.read(() => + $getRoot() + .getAllTextNodes() + .map(node => ({ + note: $getState(node, note), + text: node.getTextContent(), + type: node.getType(), + })), + ); + const chars = nodes.flatMap(node => + node.text.split('').map(text => ({note: node.note, text})), + ); + // "ya#x$AAA": the typed A is at index 5; the A that was last before typing is at index 7. + expect(chars.map(c => c.text).join('')).toBe('ya#x$AAA'); + expect(chars[7]).toEqual({note: 'c1', text: 'A'}); + }, +); diff --git a/packages/lexical-website/docs/collaboration/y.md b/packages/lexical-website/docs/collaboration/y.md index f7e86657b42..6f3bbfc0bbe 100644 --- a/packages/lexical-website/docs/collaboration/y.md +++ b/packages/lexical-website/docs/collaboration/y.md @@ -1185,8 +1185,8 @@ not merge as a user might expect: table a collaborator deletes meanwhile stays in that client's state until it reloads (about 100 to 180 bytes for a small table); one deleted after the binding stops is collected, when the undo history was cleared before - it stopped (see the next item) and no undo or redo restored content in - it (see the item after). When the binding stops it lets them go, + it stopped (see the next item), unless an undo or redo restored content + in it and that kept it (see the item after). When the binding stops it lets them go, also where another undo manager on the document still relies on them, which then finds them unkept, as after a clear of a Yjs `UndoManager`. - **An undo manager destroyed without a clear.** When the binding stops @@ -1204,8 +1204,8 @@ not merge as a user might expect: document again does not free it. Call `binding.undoManager?.clear()` while the binding is started, before stopping, to prevent it where no other undo manager on the document still needs what it kept (a clear - releases that too), except for content an undo or redo restored in the - cell (see the next item); a table already deleted while the history held such + releases that too), except where an undo or redo restored content in the + cell and that kept it (see the next item); a table already deleted while the history held such a step stays after the clear too; a reload, or a server or relay that loads the state with garbage collection on, compacts it. Any editor that destroys a Yjs `UndoManager` without clearing it first behaves the same; @@ -1216,9 +1216,10 @@ not merge as a user might expect: undo history is cleared and the binding stops: Yjs keeps what an undo step deleted, parents included, and a clear releases only the steps still on the stacks, not the one the undo or redo took off them. It does when that - step was the first thing to keep the cell; a cell the binding already - kept for a format of its own is let go when it stops, and a deletion in - the same cell that is still on a stack is released by the clear. A table + step was the first thing to keep the table; a cell the binding already + kept for a format of its own is let go when it stops, and a deletion + elsewhere in the table that is still on a stack is released by the clear, + row and table included. A table a collaborator deletes afterwards then stays in that client's document, and in any state it encodes, until it reloads: 139 to 151 bytes for a one-cell table with nine characters, more with more content. A reload, @@ -1339,6 +1340,10 @@ not merge as a user might expect: collaborator moved out of the removed range at the same time shows twice (the original next to the moved copy), and when two collaborators remove the same text and both undo, it shows twice. + A client's own undo can remove a format from a word that none of its + undone steps touched: type plain text into a bold run, bold a range that + ends at that text, type plain text later on, and undo the three steps + ([#9690](https://github.com/meta/lexical/issues/9690); kept, D95). Undoing the replacement of a block with a new one (`replace` without `includeChildren`, written as a removal and a new block) after a collaborator edited the new block brings the old block's text back and diff --git a/packages/lexical-y/DECISIONS.md b/packages/lexical-y/DECISIONS.md index 6c7a489bcb8..b2a32b2d123 100644 --- a/packages/lexical-y/DECISIONS.md +++ b/packages/lexical-y/DECISIONS.md @@ -57,7 +57,7 @@ issues and docs that carry it. | D23 | **Garbage collection stays on**, as Yjs defaults; headless results are compacted as clients store them. Only temporary documents turn it off. Turning it off for history is an app's choice, on every client and server. | A server that skips compaction while clients compact is a trap, and undo and insert attribution work with GC on. | `DECIDED` | 10-23 | [#9437], [y-server.md][y-server] | | D24 | **Yjs's format cleanup is off** in `@lexical/y` containers and on every relay that loads a state (`createYDoc`). A delete keeps the ends of each deleted formatted run (within a bound per format key until D86 removed the bound: a delete keeps every format item it leaves). Not reported upstream. | The cleanup deletes format items a concurrent writer may still rely on. Upstream already knows that skipping it is the app's trade-off. | `DECIDED` | 10-28 | [#9400], [y.md: Servers][y-servers] | | D86 | **A delete keeps every format item it leaves (no collapse).** The collapse of the run of deleted content and format items a delete leaves (`Cursor#collapseGap`, D24's bound per format key) is removed; D24's cleanup-off stays. | The collapse dropped ends that a concurrent writer's restore or an undo relied on, and the value ran on: #9575, #9609, a #9620 cell, the concurrent restore cells of #9640, a KeepEndsSessions seed. Every attempt to keep the right ends in the collapse (#9617) needed to know which write relies on which item. Measured against main, same seeds: a three-client view fuzz of 15,000 seeds: 0 seeds wrong where main is right, 631 where main spreads a value and this does not. Cost: each formatted run whose text is deleted keeps its two items, none per keystroke or plain delete (F24A-1: 40, 80, 120 after 20, 40, 60 words typed and deleted at one place); a comment added and its word deleted 3,000 times at one place: 6,002 live format items (main 6); a downstream document library's corpus of 189 documents x1.003 at 10 cycles, x1.26 at 150; Jot x1.01 on its default profile, a concentrated recolor 438 items (main 345). Accepted: the linear growth with deleted formatted text, over the bound and its spreads. | `DECIDED` | 10-31 | [#9575], [#9609] | -| D88 | **A client keeps its own format items from garbage collection while they are undoable (memory only).** The binding sets Yjs's `Item.keep` on the format items its own transactions and its undo manager's insert, and on the end of each run they delete; it releases them, between transactions, when both undo stacks are empty (after a clear, an undo that leaves nothing, or a write they did not capture), and when it is disposed or its document destroyed. An item another binding or an `UndoManager` step also keeps stays kept. A clear of the binding's undo history makes its `UndoManager` release what the cleared steps kept, parents included, also where another `UndoManager` relies on them (Yjs's keep is one flag): the binding keeps again what it still relies on, and releases those keeps when it stops (the last binding on the document that keeps such an item releases it). A container a collaborator deletes meanwhile (a table) stays in this client's state until it reloads, about 108 to 178 bytes per cycle of an edit and a format in a cell, a clear and the table deleted (the same bytes as before this rule; releasing collects nothing); one deleted after the binding stops is collected (when the undo history was cleared before it stopped; D98), unless an undo or redo restored content in it (D99). After the stop, another `UndoManager` that still needs such an item relies on it unkept, as after a clear of a Yjs `UndoManager`. Releasing collects nothing, as Yjs's `UndoManager` collects nothing when it releases what its steps kept: any `UndoManager` on the document may still restore the item. | An undo reads its change's format items, and the end each gave a run it deleted, after a collaborator deleted them (`isKeptByUndo`, `changeEnd`, `closeRestoredRuns`). With Yjs's default garbage collection, which products use, those were collected, the undo could not tell them apart from other content, and the restored value ran on over the rest of the document ([#9623]; a two-client undo fuzz: 221 of 3,000 seeds to 0). Nothing new is written and other clients are unchanged. The cost is local: a format item a collaborator deletes while this client can undo the change that wrote it keeps its deleted content in this client's document, and in a state it encodes, until the document reloads or a relay that loads its state with gc on compacts it: about 25 bytes per such deletion, in this client only (+0.6% to 0.8% of the client's state in a realistic 4,500-edit session). Accepted over a stored change (a `gcFilter` keeping every format item: +1.9% to 7.6% bytes on long notes, in every peer and relay) and over collecting on release, which was tried and dropped: it destroyed what another `UndoManager` on the same document still needed. A `gcFilter` keeping every format item fixed the same cases, but changes what every peer and relay stores (+1.9% to 7.6% bytes on long notes). | `DECIDED` | 10-31 | [#9623], [y.md: Limits][limits] | +| D88 | **A client keeps its own format items from garbage collection while they are undoable (memory only).** The binding sets Yjs's `Item.keep` on the format items its own transactions and its undo manager's insert, and on the end of each run they delete; it releases them, between transactions, when both undo stacks are empty (after a clear, an undo that leaves nothing, or a write they did not capture), and when it is disposed or its document destroyed. An item another binding or an `UndoManager` step also keeps stays kept. A clear of the binding's undo history makes its `UndoManager` release what the cleared steps kept, parents included, also where another `UndoManager` relies on them (Yjs's keep is one flag): the binding keeps again what it still relies on, and releases those keeps when it stops (the last binding on the document that keeps such an item releases it). A container a collaborator deletes meanwhile (a table) stays in this client's state until it reloads, about 108 to 178 bytes per cycle of an edit and a format in a cell, a clear and the table deleted (the same bytes as before this rule; releasing collects nothing); one deleted after the binding stops is collected (when the undo history was cleared before it stopped; D98), unless an undo or redo restored content in it and that kept it (D99). After the stop, another `UndoManager` that still needs such an item relies on it unkept, as after a clear of a Yjs `UndoManager`. Releasing collects nothing, as Yjs's `UndoManager` collects nothing when it releases what its steps kept: any `UndoManager` on the document may still restore the item. | An undo reads its change's format items, and the end each gave a run it deleted, after a collaborator deleted them (`isKeptByUndo`, `changeEnd`, `closeRestoredRuns`). With Yjs's default garbage collection, which products use, those were collected, the undo could not tell them apart from other content, and the restored value ran on over the rest of the document ([#9623]; a two-client undo fuzz: 221 of 3,000 seeds to 0). Nothing new is written and other clients are unchanged. The cost is local: a format item a collaborator deletes while this client can undo the change that wrote it keeps its deleted content in this client's document, and in a state it encodes, until the document reloads or a relay that loads its state with gc on compacts it: about 25 bytes per such deletion, in this client only (+0.6% to 0.8% of the client's state in a realistic 4,500-edit session). Accepted over a stored change (a `gcFilter` keeping every format item: +1.9% to 7.6% bytes on long notes, in every peer and relay) and over collecting on release, which was tried and dropped: it destroyed what another `UndoManager` on the same document still needed. A `gcFilter` keeping every format item fixed the same cases, but changes what every peer and relay stores (+1.9% to 7.6% bytes on long notes). | `DECIDED` | 10-31 | [#9623], [y.md: Limits][limits] | | D96 | **A run ends in its own container, and no item is linked next to an item of another container.** `changeEnd` and `lastOwnItemBefore` look only at items in the run's shared type; the cursor refuses to link a new item next to an item of another type (the write throws and is recovered from, `RECOVERED_WRITE_WARNING`). | The end a write gives a restated run at its range's end (D78's rule 3, [#9679]) came from `changeEnd`, which walks the client's items in clock order across containers and could return an item in another type; the writer linked its end into that type's list while naming the run's container as its parent. Every other client took the item's parent from its origin and put it in the root's list, where it changes nothing, but the writer's own document held a malformed item, and its editor stopped reading part of the document for good (a downstream app's long notes: 15 of 133 sessions). A structure check (no item's neighbour in another container) now runs in every harness convergence check, the undo fuzz and a structure fuzz. | `DECIDED` | 11-01 | [#9694] | | D78 | **A format spreading after one client's triple undo:** a client formats a run, types plain text, formats again and undoes all three while a collaborator types plain text inside the first run. | Fixed at the source: a restore's scan (`runOpener`) stops at content of another client, so the writer's earlier end and later opener with a collaborator's text between them are no longer read as one restored run, and the undo writes the run's end. A two-client run-sequence fuzz: 8 of 1,000 seeds to 0, gc on and off. | `DECIDED` | 10-31 | [#9599] | | D95 | **Keep the rules that fixed #9671 and #9599, although they make rare other cases wrong, one of them for a single client.** A format over a range that starts at the writer's own earlier insert null removes that null (the null rule), one whose range ends at the writer's own earlier insert restore removes that restore and joins the runs (the restore rule; both [#9671]), and a restore's scan for the run it restates stops at another client's text (the scan rule, D78, [#9599]). | The restore rule also changes a single client's own undo, with no collaborator acting: type plain text into a bold run, bold a range ending at it, type plain text later on, undo the three steps, and a word none of them touched loses its bold (main before these rules was right) ([#9690]). Each rule disabled alone, same fresh seeds, gc on and off (counts of runs main gets right and the rule's absence wrong, then the reverse): a raw-Yjs-oracle op-list generator, 36,000 runs: null rule 18 / 4, restore rule 28 / 0, scan rule 2 / 1 (gc on and off identical; all three off equals main before them); its 18,000-run original set: 20 / 0, 6 / 0, 0 / 0; a three-client view property, 15,000 seeds: null rule 0 / 2 (gc off), restore rule 0 / 1 (gc on), scan rule 2 / 0; a sequential undo fuzz, 3,000 seeds: restore rule 0 / 2 (one single-client); a two-client run-sequence fuzz, 1,000: scan rule 7 / 0; unit cells: null rule 8, restore rule 16, scan rule 5; a long-session soak, about 20,600 sessions: 2 sessions wrong (null rule 1, scan rule 1) against 25 the other way. None of the narrower variants tried kept the gains: the writer cannot tell from the document whether a collaborator's lone null ends a format range or starts a removal (a variant of the null rule that writes the range's own opener fixed its cases and lost all 8 of its unit cells). Decided on 11-02 to keep all three: taken together they fix many more runs than they break (in the 36,000-run generator, 18, 28 and 2 runs against 4, 0 and 1 for the null, restore and scan rules), although in the three-client view property and the sequential undo fuzz the null and restore rules only break runs (null rule 2, restore rule 1 and 2), and stored provenance, which would avoid both sides, is not ready to ship. Dropping the restore rule for its single-client case was weighed and not taken. | `DECIDED` | 11-02 | [#9690], [#9679], [#9671], [#9599] | @@ -128,9 +128,9 @@ issues and docs that carry it. | D90 | **List numbering can lag after refused follow-ups:** after four follow-ups in a row that the binding does not write and reads again (such as `BlockIdExtension`'s naming updates), the author can show a derived list number from before them (1 where its document reads 2) until that list is refreshed (adding an item to it does; an edit elsewhere, or retyping an item's text, does not); its own fresh read, other clients and a reload show the right number. | The stop on the fourth re-read in a row is the backstop that ends an app's reply loops: [#9634] showed that fresh derived values after every re-read, bounded reply chains and app replies that read the derived value cannot all hold. Display only, the author's own editor, no stored change. | `ACCEPTED LIMIT` | 10-31 | [#9632], [#9634], [#9642], [limits] | | D91 | **Overlapping formats of one key from two writers can cancel:** two collaborators applying the same format (bold, the same comment id, a link) to overlapping ranges at once can leave part of the text without it. Since [#9601] this also reaches a link's edge and its first word (D82). | A Yjs text gives each place one value per key ([yjs#382]): a writer's end of its range ends the other writer's run there too. Avoiding it needs a marker per writer, a storage change. Formatting only, no text lost; [#9601] is kept. | `ACCEPTED LIMIT` | 10-31 | [#9626], [#9601], [limits] | | D93 | **A comment on a word a collaborator rewrites at the same moment can land on the new word:** with `setTextContent` for one client id order, and with a block rewrite (the line's children replaced by one new text node, as an agent writes it) for one order when the comment ends at the rewritten word and for both when it continues past it; words of the range the rewrite kept keep the comment. | The same race as text typed at the start of a range a collaborator formats (y.md, Other races): the new word is inserted where the commented word starts, and Yjs orders the two writes by client id. Before [#9598] a block rewrite dropped the comment from the whole range instead. Formatting only, no text lost. | `ACCEPTED LIMIT` | 10-31 | [#9610], [limits] | -| D98 | **A Yjs `UndoManager` destroyed without a clear keeps what its steps protected.** The binding destroys its undo manager without clearing it when it stops (`binding.dispose()`, or the cleanup its start returns: an editor unmounted or mounted again), and Yjs destroys an `UndoManager` when its document is destroyed. Yjs then keeps, until the document is reloaded, the items the destroyed manager's steps kept: a character deleted in a table cell can keep that cell, its row and its table. It does when the delete's step is the first thing to keep the cell (Yjs's `keepItem` stops at an ancestor already kept); where the binding already kept the cell for one of its own format items (D88) before the delete, the step keeps only the character, and the binding's release at stop lets the cell, row and table go. When it does, a table a collaborator deletes afterwards stays in that client's document, and in any state it encodes: 108 to 175 bytes per table where one character was deleted and one word bolded in one cell of a 1x1 to a 4x4 table, more where more was deleted or more cells were edited (about 1.3 KB for one character deleted in every cell of a 4x4 table). Binding the same document again does not free it. Clearing the history (`binding.undoManager?.clear()`, while the binding is started) before stopping and before the table is deleted prevents it, where no other `UndoManager` on the document still needs what it kept (a clear releases that too), except for content an undo or redo restored in the cell (D99); a table deleted while the history still held such a step stays after a later clear too, as releasing a keep collects nothing (D88). Reloading the document, or loading its encoded state into a new document with garbage collection on (a server or relay), compacts it. | Yjs's `UndoManager#destroy` does not clear, and its keep is one flag set on an item and its parents; any editor that destroys a Yjs `UndoManager` without clearing it first keeps the same items. Measured over 50 cycles per table size (an edit in a cell, the binding stopped, the table deleted by a collaborator): the same bytes on the release before D88's own-format keep, with that keep turned off and on the current release, and none when the history was cleared before stopping. Clearing at stop in the binding was weighed and not taken: Yjs's clear releases with that one flag, so it would also release what another `UndoManager` on the document (an app's, or a second binding's) still keeps for its own undo, and a later collection could drop what that undo restores (the hazard D88 records for a clear). | `ACCEPTED LIMIT` | 11-01 | [#9698], [limits] | -| D99 | **An undo or redo that restores content inside a container can keep that container from garbage collection until reload.** Yjs's `UndoManager` keeps what a step deletes, walking up its parents (a table cell, its row and its table). An undo or redo that restores that content takes the step off the stacks, and Yjs's `clear()` releases only the steps still on them, so clearing the history before the binding stops does not release the cell, row and table. It does when that step's deletion is the first thing to keep the cell (Yjs's `keepItem` stops at an ancestor already kept): a cell the binding already kept for one of its own format items (D88) is let go when the binding stops, and a deletion in the same cell that is still on a stack is released by the clear, parents included. When it does, a table a collaborator deletes afterwards stays in that client's document, and in any state it encodes, until it reloads: 139 to 151 bytes for a one-cell table with nine characters, more with more content. Reloading the document, or loading its encoded state into a new document with garbage collection on (a server or relay), compacts it. Nothing reaches other clients. | Yjs behaviour, reproduced with plain `UndoManager` calls and no editor, and on every binding release. A release in the binding would have to know which keeps came from a step an undo or redo took off the stacks; Yjs's keep is one flag on each item, shared by every `UndoManager` on the document, so the binding cannot release it without risking what another undo manager still keeps for its own undo (the hazard D88 and D98 record). The fix belongs in Yjs: reported as [yjs#833]. | `ACCEPTED LIMIT` | 11-02 | [#9721], [#9723], [yjs#833], [limits] | -| D100 | **A format a collaborator applies starting right where a run of that format ends can be lost when a client other than the run's author changes part of the run at the same time:** with `Kilo` bolded by one client, another unbolds `il` or types plain text inside `Kilo` while the first or a third client bolds ` bb`; with some client id orders ` bb` stays plain on every client (12 of 24 cases in a test of both edits, every order of three client ids and gc on and off, on every version tested). No text is lost, and when the client that changed the run undoes its change, ` bb` is bold again. | The change inside the run writes a null right after the run's end, so that the run's value does not run on onto text a collaborator types there, and the other client's opener of ` bb` has the same neighbours; Yjs orders the two by client id, so with the writer's id higher its null lands between that opener and ` bb`. Writing the null anywhere else gives up that protection. A repair after the fact, or stored provenance, could avoid it; neither is worth its cost now. D52 records the same race for the client that formatted the run. | `ACCEPTED LIMIT` | 11-02 | [#9589], [limits] | +| D98 | **A Yjs `UndoManager` destroyed without a clear keeps what its steps protected.** The binding destroys its undo manager without clearing it when it stops (`binding.dispose()`, or the cleanup its start returns: an editor unmounted or mounted again), and Yjs destroys an `UndoManager` when its document is destroyed. Yjs then keeps, until the document is reloaded, the items the destroyed manager's steps kept: a character deleted in a table cell can keep that cell, its row and its table. It does when the delete's step is the first thing to keep the cell (Yjs's `keepItem` stops at an ancestor already kept); where the binding already kept the cell for one of its own format items (D88) before the delete, the step keeps only the character, and the binding's release at stop lets the cell, row and table go. When it does, a table a collaborator deletes afterwards stays in that client's document, and in any state it encodes: 108 to 175 bytes per table where one character was deleted and one word bolded in one cell of a 1x1 to a 4x4 table, more where more was deleted or more cells were edited (about 1.3 KB for one character deleted in every cell of a 4x4 table). Binding the same document again does not free it. Clearing the history (`binding.undoManager?.clear()`, while the binding is started) before stopping and before the table is deleted prevents it, where no other `UndoManager` on the document still needs what it kept (a clear releases that too), except where an undo or redo restored content in the cell and that kept it (D99); a table deleted while the history still held such a step stays after a later clear too, as releasing a keep collects nothing (D88). Reloading the document, or loading its encoded state into a new document with garbage collection on (a server or relay), compacts it. | Yjs's `UndoManager#destroy` does not clear, and its keep is one flag set on an item and its parents; any editor that destroys a Yjs `UndoManager` without clearing it first keeps the same items. Measured over 50 cycles per table size (an edit in a cell, the binding stopped, the table deleted by a collaborator): the same bytes on the release before D88's own-format keep, with that keep turned off and on the current release, and none when the history was cleared before stopping. Clearing at stop in the binding was weighed and not taken: Yjs's clear releases with that one flag, so it would also release what another `UndoManager` on the document (an app's, or a second binding's) still keeps for its own undo, and a later collection could drop what that undo restores (the hazard D88 records for a clear). | `ACCEPTED LIMIT` | 11-01 | [#9698], [limits] | +| D99 | **An undo or redo that restores content inside a container can keep that container from garbage collection until reload.** Yjs's `UndoManager` keeps what a step deletes, walking up its parents (a table cell, its row and its table). An undo or redo that restores that content takes the step off the stacks, and Yjs's `clear()` releases only the steps still on them, so clearing the history before the binding stops does not release the cell, row and table. It does when that step's deletion is the first thing to keep the table, walking up from the cell (Yjs's `keepItem` stops at an ancestor already kept): a cell the binding already kept for one of its own format items (D88) is let go when the binding stops, and a deletion elsewhere in the table that is still on a stack is released by the clear, row and table included (a deleted table is collected with its cells). When it does, a table a collaborator deletes afterwards stays in that client's document, and in any state it encodes, until it reloads: 139 to 151 bytes for a one-cell table with nine characters, more with more content. Reloading the document, or loading its encoded state into a new document with garbage collection on (a server or relay), compacts it. Nothing reaches other clients. | Yjs behaviour, reproduced with plain `UndoManager` calls and no editor, and on every binding release. A release in the binding would have to know which keeps came from a step an undo or redo took off the stacks; Yjs's keep is one flag on each item, shared by every `UndoManager` on the document, so the binding cannot release it without risking what another undo manager still keeps for its own undo (the hazard D88 and D98 record). The fix belongs in Yjs: reported as [yjs#833]. | `ACCEPTED LIMIT` | 11-02 | [#9721], [#9723], [yjs#833], [limits] | +| D100 | **A format a collaborator applies starting right where a run of that format ends can be lost when a client other than the run's author changes part of the run at the same time:** with `Kilo` bolded by one client, another unbolds `il` or types plain text inside `Kilo` while the first or a third client bolds ` bb`; with some client id orders ` bb` stays plain on every client (12 of 24 cases in a test of both edits, every order of three client ids and gc on and off, on every version tested). No text is lost, and when the client that changed the run undoes its change, ` bb` is bold again. | The change inside the run writes a null right after the run's end, so that the run's value does not run on onto text a collaborator types there, and the other client's opener of ` bb` has the same neighbours; Yjs orders the two by client id, so with the writer's id higher its null lands between that opener and ` bb`. Writing the null anywhere else gives up that protection. A repair after the fact, or stored provenance, could avoid it; neither is worth its cost now. | `ACCEPTED LIMIT` | 11-02 | [#9589], [limits] | ## Open diff --git a/packages/lexical-y/src/__tests__/unit/regressions/FormatAtRunEndRace.test.ts b/packages/lexical-y/src/__tests__/unit/regressions/FormatAtRunEndRace.test.ts new file mode 100644 index 00000000000..00682d140b7 --- /dev/null +++ b/packages/lexical-y/src/__tests__/unit/regressions/FormatAtRunEndRace.test.ts @@ -0,0 +1,83 @@ +/** + * 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 + +// D100 (accepted limit, #9589): B bolds "bcde" of "abcdefgh". Offline, A +// removes bold from "bc" (or types plain "X" inside the run) while C bolds +// "fg", starting where B's bold ends. A's write ends the run's value right +// after its end, and C's opener has the same neighbours; Yjs orders the two +// by client id, so with A's id above C's, "fg" loses C's bold on every +// client. No text is lost. `test.fails` marks the orders that lose it. +import {$getRoot, $setSelection, type TextNode} from 'lexical'; +import {expect, test} from 'vitest'; + +import {$p, $selectIn, expectConverged, setup} from '../../utils/harness'; + +const IDS = [ + [1, 2, 3], + [3, 2, 1], + [2, 3, 1], + [1, 3, 2], + [2, 1, 3], + [3, 1, 2], +]; + +for (const how of ['remove', 'type'] as const) { + for (const ids of IDS) { + for (const gc of [false, true]) { + // ids are [A, B, C] + const loses = ids[0] > ids[2]; + (loses ? test.fails : test)( + `C's bold after B's run, A ${how === 'remove' ? 'unbolds part of the run' : 'types plain text in the run'} (ids ${ids}, gc ${gc ? 'on' : 'off'})`, + async () => { + const t = await setup(() => $getRoot().append($p('abcdefgh')), { + gc, + ids, + }); + try { + const [a, b, c] = t.peers; + b.update(() => { + $selectIn([0], 1, 5).formatText('bold'); + $setSelection(null); + }); + await t.sync(); + t.offline(); + a.update(() => { + if (how === 'remove') { + const s = $selectIn([0], 1, 3); + s.format = 1; + s.formatText('bold'); + } else { + const s = $selectIn([0], 3, 3); + s.format = 0; + s.insertText('X'); + } + $setSelection(null); + }); + c.update(() => { + $selectIn([0], 5, 7).formatText('bold'); + $setSelection(null); + }); + await t.sync(); + await expectConverged(t.peers); + const fg = a.read(() => + $getRoot() + .getAllTextNodes() + .filter((n: TextNode) => /[fg]/.test(n.getTextContent())), + ); + expect(fg.length).toBeGreaterThan(0); + expect(a.read(() => fg.every(n => n.hasFormat('bold')))).toBe(true); + } finally { + t.peers.forEach(p => p.stop()); + } + }, + ); + } + } +} diff --git a/packages/lexical-y/src/__tests__/unit/regressions/UndoRedoContainerKeeps.test.ts b/packages/lexical-y/src/__tests__/unit/regressions/UndoRedoContainerKeeps.test.ts index a8de647fdb2..304af5387ed 100644 --- a/packages/lexical-y/src/__tests__/unit/regressions/UndoRedoContainerKeeps.test.ts +++ b/packages/lexical-y/src/__tests__/unit/regressions/UndoRedoContainerKeeps.test.ts @@ -16,9 +16,10 @@ // content takes that step off the stacks, and Yjs's clear() releases only // the steps still on them, so a clear before the binding stops does not // release the cell. It does when the step's deletion was the first thing to -// keep the cell: a cell the binding already kept for one of its own format -// items (D88) is let go when the binding stops, and a deletion in the same -// cell still on a stack is released by the clear, parents included. If a +// keep the table: a cell the binding already kept for one of its own format +// items (D88) is let go when the binding stops, and a deletion elsewhere in +// the table still on a stack is released by the clear, row and table +// included (a deleted table is collected with its cells). If a // collaborator then deletes the table, the deleted table, row and cell stay // in this client's document until it reloads (compacted when its state is // loaded into a fresh document with gc on). Destroying an UndoManager @@ -213,6 +214,72 @@ for (const [name, steps, texts, kept] of CASES) { }); } +// A deletion in another cell of the table that is still on the undo stack is +// released by the clear, with the row and the table: B's delete collects the +// table with its cells. +test('a table a collaborator deletes is collected when A deleted in two cells and undid the second (gc on)', async () => { + const t = await setup( + () => { + $getRoot().append( + $createParagraphNode().append($createTextNode('before')), + $createTableNode().append( + $createTableRowNode().append( + $createTableCellNode(0).append( + $createParagraphNode().append($createTextNode('one')), + ), + $createTableCellNode(0).append( + $createParagraphNode().append($createTextNode('two')), + ), + ), + ), + $createParagraphNode().append($createTextNode('after')), + ); + }, + {gc: true, ids: [1, 2]}, + ); + try { + const [A, B] = t.peers; + const $deleteSecondChar = (cell: number) => { + const row = ( + $getRoot().getChildAtIndex(1) as ElementNode + ).getFirstChild() as ElementNode; + const text = ( + row.getChildAtIndex(cell) as ElementNode + ).getFirstDescendant() as TextNode; + const s = $createRangeSelection(); + s.anchor.set(text.getKey(), 1, 'text'); + s.focus.set(text.getKey(), 2, 'text'); + $setSelection(s); + s.removeText(); + $setSelection(null); + }; + A.update(() => $deleteSecondChar(1)); + A.update(() => $deleteSecondChar(0)); + await t.sync(); + A.undo(); + await t.sync(); + expect(A.read(() => $getRoot().getChildAtIndex(1)!.getTextContent())).toBe( + 'one\n\nto', + ); + A.binding.undoManager!.clear(); + A.stop(); + + const beforeDelete = liveContainers(A.doc); + B.update( + () => void ($getRoot().getChildAtIndex(1) as ElementNode).remove(), + ); + await t.sync(); + const afterDelete = liveContainers(A.doc); + // B's delete reached A: the table, its row and its two cells. + expect([...beforeDelete].filter(id => !afterDelete.has(id))).toHaveLength( + 4, + ); + expect(deletedContainers(A.doc)).toEqual([]); + } finally { + t.peers.forEach(p => p.stop()); + } +}); + const plainTable = () => { const doc = new Y.Doc({gc: true}); const root = doc.get('root'); From 8bbab361ed55274d48ddf34e4554d92660052c68 Mon Sep 17 00:00:00 2001 From: zurfyx Date: Mon, 2 Nov 2026 12:50:02 -0500 Subject: [PATCH 3/6] Address review: D99 releases include later deletions; link D52 and D100 - D99, y.md and the test header: a clear releases every step still on a stack that deleted in the table, before or after the undo; only the binding's own format keep depends on order. New case: delete, undo, delete again leaves 0. - D52 and D100 point to each other. - FormatAtRunEndRace asserts that no text is lost. --- .../lexical-website/docs/collaboration/y.md | 10 +++++----- packages/lexical-y/DECISIONS.md | 6 +++--- .../regressions/FormatAtRunEndRace.test.ts | 4 ++++ .../regressions/UndoRedoContainerKeeps.test.ts | 18 +++++++++++++----- 4 files changed, 25 insertions(+), 13 deletions(-) diff --git a/packages/lexical-website/docs/collaboration/y.md b/packages/lexical-website/docs/collaboration/y.md index 6f3bbfc0bbe..bc965c29484 100644 --- a/packages/lexical-website/docs/collaboration/y.md +++ b/packages/lexical-website/docs/collaboration/y.md @@ -1215,11 +1215,11 @@ not merge as a user might expect: content in a table cell can keep the cell, its row and its table after its undo history is cleared and the binding stops: Yjs keeps what an undo step deleted, parents included, and a clear releases only the steps still on - the stacks, not the one the undo or redo took off them. It does when that - step was the first thing to keep the table; a cell the binding already - kept for a format of its own is let go when it stops, and a deletion - elsewhere in the table that is still on a stack is released by the clear, - row and table included. A table + the stacks, not the one the undo or redo took off them. It does unless + something releases the table afterwards: a clear releases every step + still on a stack that deleted in the table, before or after the undo, row + and table included, and the binding lets go a cell it kept for a format of + its own before that step. A table a collaborator deletes afterwards then stays in that client's document, and in any state it encodes, until it reloads: 139 to 151 bytes for a one-cell table with nine characters, more with more content. A reload, diff --git a/packages/lexical-y/DECISIONS.md b/packages/lexical-y/DECISIONS.md index b2a32b2d123..0b39a4ca71d 100644 --- a/packages/lexical-y/DECISIONS.md +++ b/packages/lexical-y/DECISIONS.md @@ -116,7 +116,7 @@ issues and docs that carry it. | D49 | **Delete wins:** text typed into, or a block moved into, a container a collaborator deletes at the same time is deleted with it. | Yjs deletes a parent's children; anything else would resurrect deleted content. | `ACCEPTED LIMIT` | 10-25 | [limits] | | D50 | **Two clients removing the same text and both undoing show it twice.** | Plain `UndoManager` behaviour; avoiding it needs a layout feature. | `ACCEPTED LIMIT` | 10-23 | [limits] | | D51 | **Rare undo and marker-cleanup timings could spread a comment or format onto untouched words** (two writes of one client both undone while a collaborator collapses markers; three authors in [#9575]). | Rare, no text lost. A provenance marker would need a layout version bump and about a week. Replaced by D86 (10-31): a delete no longer collapses markers, and the [#9575] cells pass. | `DECIDED` | 10-31 | [#9562], [#9575] | -| D52 | **Unbolding inside your own bold while a collaborator bolds text starting at its end** can leave the new text plain (when the unbolding client's id is higher). | Rare and cosmetic, no text lost. The repair is tracked in [#9589]; nothing more happens now. | `ACCEPTED LIMIT` | 10-30 | [#9562], [#9589] | +| D52 | **Unbolding inside your own bold while a collaborator bolds text starting at its end** can leave the new text plain (when the unbolding client's id is higher). | Rare and cosmetic, no text lost. The repair is tracked in [#9589]; nothing more happens now. On main the author's variant keeps the format (only [#9562]'s parked rule lost it); the other-client variant is D100. | `ACCEPTED LIMIT` | 10-30 | [#9562], [#9589] | | D53 | **Removing a format while a collaborator types inside it** brings the format back on the rest of the range, up to its end. | The collaborator's write restores the format it saw; formats are ranges in one sequence (D16). On Yjs 13 it runs to the end of the paragraph. | `ACCEPTED LIMIT` | 10-27 | [#9400], [limits] | | D54 | **Text changed without a record of the edit** (`setTextContent`, DOM text changes) racing a collaborator puts their typing at the start of the new text. | The remaining cases of D28; recording intent at the source shrinks them. | `ACCEPTED LIMIT` | 10-27 | [#9483], [y.md: Edits made by code][y-code] | | D55 | **Ragged tables stay ragged until the next table command**, which pads them; two clients padding at once add an extra column. | D20: no client repairs remote shapes. | `ACCEPTED LIMIT` | 10-25 | [#9453], [limits] | @@ -129,8 +129,8 @@ issues and docs that carry it. | D91 | **Overlapping formats of one key from two writers can cancel:** two collaborators applying the same format (bold, the same comment id, a link) to overlapping ranges at once can leave part of the text without it. Since [#9601] this also reaches a link's edge and its first word (D82). | A Yjs text gives each place one value per key ([yjs#382]): a writer's end of its range ends the other writer's run there too. Avoiding it needs a marker per writer, a storage change. Formatting only, no text lost; [#9601] is kept. | `ACCEPTED LIMIT` | 10-31 | [#9626], [#9601], [limits] | | D93 | **A comment on a word a collaborator rewrites at the same moment can land on the new word:** with `setTextContent` for one client id order, and with a block rewrite (the line's children replaced by one new text node, as an agent writes it) for one order when the comment ends at the rewritten word and for both when it continues past it; words of the range the rewrite kept keep the comment. | The same race as text typed at the start of a range a collaborator formats (y.md, Other races): the new word is inserted where the commented word starts, and Yjs orders the two writes by client id. Before [#9598] a block rewrite dropped the comment from the whole range instead. Formatting only, no text lost. | `ACCEPTED LIMIT` | 10-31 | [#9610], [limits] | | D98 | **A Yjs `UndoManager` destroyed without a clear keeps what its steps protected.** The binding destroys its undo manager without clearing it when it stops (`binding.dispose()`, or the cleanup its start returns: an editor unmounted or mounted again), and Yjs destroys an `UndoManager` when its document is destroyed. Yjs then keeps, until the document is reloaded, the items the destroyed manager's steps kept: a character deleted in a table cell can keep that cell, its row and its table. It does when the delete's step is the first thing to keep the cell (Yjs's `keepItem` stops at an ancestor already kept); where the binding already kept the cell for one of its own format items (D88) before the delete, the step keeps only the character, and the binding's release at stop lets the cell, row and table go. When it does, a table a collaborator deletes afterwards stays in that client's document, and in any state it encodes: 108 to 175 bytes per table where one character was deleted and one word bolded in one cell of a 1x1 to a 4x4 table, more where more was deleted or more cells were edited (about 1.3 KB for one character deleted in every cell of a 4x4 table). Binding the same document again does not free it. Clearing the history (`binding.undoManager?.clear()`, while the binding is started) before stopping and before the table is deleted prevents it, where no other `UndoManager` on the document still needs what it kept (a clear releases that too), except where an undo or redo restored content in the cell and that kept it (D99); a table deleted while the history still held such a step stays after a later clear too, as releasing a keep collects nothing (D88). Reloading the document, or loading its encoded state into a new document with garbage collection on (a server or relay), compacts it. | Yjs's `UndoManager#destroy` does not clear, and its keep is one flag set on an item and its parents; any editor that destroys a Yjs `UndoManager` without clearing it first keeps the same items. Measured over 50 cycles per table size (an edit in a cell, the binding stopped, the table deleted by a collaborator): the same bytes on the release before D88's own-format keep, with that keep turned off and on the current release, and none when the history was cleared before stopping. Clearing at stop in the binding was weighed and not taken: Yjs's clear releases with that one flag, so it would also release what another `UndoManager` on the document (an app's, or a second binding's) still keeps for its own undo, and a later collection could drop what that undo restores (the hazard D88 records for a clear). | `ACCEPTED LIMIT` | 11-01 | [#9698], [limits] | -| D99 | **An undo or redo that restores content inside a container can keep that container from garbage collection until reload.** Yjs's `UndoManager` keeps what a step deletes, walking up its parents (a table cell, its row and its table). An undo or redo that restores that content takes the step off the stacks, and Yjs's `clear()` releases only the steps still on them, so clearing the history before the binding stops does not release the cell, row and table. It does when that step's deletion is the first thing to keep the table, walking up from the cell (Yjs's `keepItem` stops at an ancestor already kept): a cell the binding already kept for one of its own format items (D88) is let go when the binding stops, and a deletion elsewhere in the table that is still on a stack is released by the clear, row and table included (a deleted table is collected with its cells). When it does, a table a collaborator deletes afterwards stays in that client's document, and in any state it encodes, until it reloads: 139 to 151 bytes for a one-cell table with nine characters, more with more content. Reloading the document, or loading its encoded state into a new document with garbage collection on (a server or relay), compacts it. Nothing reaches other clients. | Yjs behaviour, reproduced with plain `UndoManager` calls and no editor, and on every binding release. A release in the binding would have to know which keeps came from a step an undo or redo took off the stacks; Yjs's keep is one flag on each item, shared by every `UndoManager` on the document, so the binding cannot release it without risking what another undo manager still keeps for its own undo (the hazard D88 and D98 record). The fix belongs in Yjs: reported as [yjs#833]. | `ACCEPTED LIMIT` | 11-02 | [#9721], [#9723], [yjs#833], [limits] | -| D100 | **A format a collaborator applies starting right where a run of that format ends can be lost when a client other than the run's author changes part of the run at the same time:** with `Kilo` bolded by one client, another unbolds `il` or types plain text inside `Kilo` while the first or a third client bolds ` bb`; with some client id orders ` bb` stays plain on every client (12 of 24 cases in a test of both edits, every order of three client ids and gc on and off, on every version tested). No text is lost, and when the client that changed the run undoes its change, ` bb` is bold again. | The change inside the run writes a null right after the run's end, so that the run's value does not run on onto text a collaborator types there, and the other client's opener of ` bb` has the same neighbours; Yjs orders the two by client id, so with the writer's id higher its null lands between that opener and ` bb`. Writing the null anywhere else gives up that protection. A repair after the fact, or stored provenance, could avoid it; neither is worth its cost now. | `ACCEPTED LIMIT` | 11-02 | [#9589], [limits] | +| D99 | **An undo or redo that restores content inside a container can keep that container from garbage collection until reload.** Yjs's `UndoManager` keeps what a step deletes, walking up its parents (a table cell, its row and its table). An undo or redo that restores that content takes the step off the stacks, and Yjs's `clear()` releases only the steps still on them, so clearing the history before the binding stops does not release the cell, row and table. It does unless something releases the table afterwards: a clear releases every step still on a stack that deleted in the table, before or after the undo, row and table included (Yjs's `keepItem(item, false)` walks up while `keep` is set; a deleted table is collected with its cells), and the binding lets go a cell it kept for a format of its own (D88) before that step (that step's `keepItem` stopped at the kept cell). When it does, a table a collaborator deletes afterwards stays in that client's document, and in any state it encodes, until it reloads: 139 to 151 bytes for a one-cell table with nine characters, more with more content. Reloading the document, or loading its encoded state into a new document with garbage collection on (a server or relay), compacts it. Nothing reaches other clients. | Yjs behaviour, reproduced with plain `UndoManager` calls and no editor, and on every binding release. A release in the binding would have to know which keeps came from a step an undo or redo took off the stacks; Yjs's keep is one flag on each item, shared by every `UndoManager` on the document, so the binding cannot release it without risking what another undo manager still keeps for its own undo (the hazard D88 and D98 record). The fix belongs in Yjs: reported as [yjs#833]. | `ACCEPTED LIMIT` | 11-02 | [#9721], [#9723], [yjs#833], [limits] | +| D100 | **A format a collaborator applies starting right where a run of that format ends can be lost when a client other than the run's author changes part of the run at the same time:** with `Kilo` bolded by one client, another unbolds `il` or types plain text inside `Kilo` while the first or a third client bolds ` bb`; with some client id orders ` bb` stays plain on every client (12 of 24 cases in a test of both edits, every order of three client ids and gc on and off, on every version tested). No text is lost, and when the client that changed the run undoes its change, ` bb` is bold again. | The change inside the run writes a null right after the run's end, so that the run's value does not run on onto text a collaborator types there, and the other client's opener of ` bb` has the same neighbours; Yjs orders the two by client id, so with the writer's id higher its null lands between that opener and ` bb`. Writing the null anywhere else gives up that protection. A repair after the fact, or stored provenance, could avoid it; neither is worth its cost now. The author's own variant is D52. | `ACCEPTED LIMIT` | 11-02 | [#9589], [limits] | ## Open diff --git a/packages/lexical-y/src/__tests__/unit/regressions/FormatAtRunEndRace.test.ts b/packages/lexical-y/src/__tests__/unit/regressions/FormatAtRunEndRace.test.ts index 00682d140b7..0f342c1e960 100644 --- a/packages/lexical-y/src/__tests__/unit/regressions/FormatAtRunEndRace.test.ts +++ b/packages/lexical-y/src/__tests__/unit/regressions/FormatAtRunEndRace.test.ts @@ -66,6 +66,10 @@ for (const how of ['remove', 'type'] as const) { }); await t.sync(); await expectConverged(t.peers); + // No text is lost. + expect(a.read(() => $getRoot().getTextContent())).toBe( + how === 'remove' ? 'abcdefgh' : 'abcXdefgh', + ); const fg = a.read(() => $getRoot() .getAllTextNodes() diff --git a/packages/lexical-y/src/__tests__/unit/regressions/UndoRedoContainerKeeps.test.ts b/packages/lexical-y/src/__tests__/unit/regressions/UndoRedoContainerKeeps.test.ts index 304af5387ed..27106bbdffb 100644 --- a/packages/lexical-y/src/__tests__/unit/regressions/UndoRedoContainerKeeps.test.ts +++ b/packages/lexical-y/src/__tests__/unit/regressions/UndoRedoContainerKeeps.test.ts @@ -15,11 +15,11 @@ // stops at an ancestor already kept). The undo or redo that restores the // content takes that step off the stacks, and Yjs's clear() releases only // the steps still on them, so a clear before the binding stops does not -// release the cell. It does when the step's deletion was the first thing to -// keep the table: a cell the binding already kept for one of its own format -// items (D88) is let go when the binding stops, and a deletion elsewhere in -// the table still on a stack is released by the clear, row and table -// included (a deleted table is collected with its cells). If a +// release the cell. It does unless something releases the table afterwards: +// a clear releases every step still on a stack that deleted in the table, +// before or after the undo, row and table included (a deleted table is +// collected with its cells), and the binding lets go a cell it kept for one +// of its own format items (D88) before that step. If a // collaborator then deletes the table, the deleted table, row and cell stay // in this client's document until it reloads (compacted when its state is // loaded into a fresh document with gc on). Destroying an UndoManager @@ -127,6 +127,14 @@ const CASES: [string, Step[], string[], number][] = [ ['cll text', 'cl text', 'cll text'], 0, ], + // A deletion made after the undo is on the stack too, and the clear + // releases it the same way. + [ + 'is collected when A deleted, undid and deleted again', + ['delete', 'undo', 'delete again'], + ['cll text', 'cell text', 'cll text'], + 0, + ], ]; for (const [name, steps, texts, kept] of CASES) { From c3ee11c0a5d347441c3b4d3ac1554299b69f2e53 Mon Sep 17 00:00:00 2001 From: zurfyx Date: Mon, 2 Nov 2026 13:03:18 -0500 Subject: [PATCH 4/6] D99 and y.md: link the proposed Yjs fix (yjs/yjs#834, #835) --- packages/lexical-website/docs/collaboration/y.md | 3 ++- packages/lexical-y/DECISIONS.md | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/lexical-website/docs/collaboration/y.md b/packages/lexical-website/docs/collaboration/y.md index bc965c29484..32c351071d7 100644 --- a/packages/lexical-website/docs/collaboration/y.md +++ b/packages/lexical-website/docs/collaboration/y.md @@ -1226,7 +1226,8 @@ not merge as a user might expect: or a server or relay that loads the state with garbage collection on, compacts it. Any editor that uses a Yjs `UndoManager` behaves the same ([#9721](https://github.com/meta/lexical/issues/9721); reported to Yjs as - [yjs/yjs#833](https://github.com/yjs/yjs/issues/833)). + [yjs/yjs#833](https://github.com/yjs/yjs/issues/833), fix proposed in + [yjs/yjs#834](https://github.com/yjs/yjs/pull/834)). - **Named slots.** A slot holds one node: two collaborators setting the same slot at once keep one writer's node, and text typed in a slot that a collaborator replaces or removes at the same time is lost (as with delete diff --git a/packages/lexical-y/DECISIONS.md b/packages/lexical-y/DECISIONS.md index 0b39a4ca71d..5ebcc5ed458 100644 --- a/packages/lexical-y/DECISIONS.md +++ b/packages/lexical-y/DECISIONS.md @@ -129,7 +129,7 @@ issues and docs that carry it. | D91 | **Overlapping formats of one key from two writers can cancel:** two collaborators applying the same format (bold, the same comment id, a link) to overlapping ranges at once can leave part of the text without it. Since [#9601] this also reaches a link's edge and its first word (D82). | A Yjs text gives each place one value per key ([yjs#382]): a writer's end of its range ends the other writer's run there too. Avoiding it needs a marker per writer, a storage change. Formatting only, no text lost; [#9601] is kept. | `ACCEPTED LIMIT` | 10-31 | [#9626], [#9601], [limits] | | D93 | **A comment on a word a collaborator rewrites at the same moment can land on the new word:** with `setTextContent` for one client id order, and with a block rewrite (the line's children replaced by one new text node, as an agent writes it) for one order when the comment ends at the rewritten word and for both when it continues past it; words of the range the rewrite kept keep the comment. | The same race as text typed at the start of a range a collaborator formats (y.md, Other races): the new word is inserted where the commented word starts, and Yjs orders the two writes by client id. Before [#9598] a block rewrite dropped the comment from the whole range instead. Formatting only, no text lost. | `ACCEPTED LIMIT` | 10-31 | [#9610], [limits] | | D98 | **A Yjs `UndoManager` destroyed without a clear keeps what its steps protected.** The binding destroys its undo manager without clearing it when it stops (`binding.dispose()`, or the cleanup its start returns: an editor unmounted or mounted again), and Yjs destroys an `UndoManager` when its document is destroyed. Yjs then keeps, until the document is reloaded, the items the destroyed manager's steps kept: a character deleted in a table cell can keep that cell, its row and its table. It does when the delete's step is the first thing to keep the cell (Yjs's `keepItem` stops at an ancestor already kept); where the binding already kept the cell for one of its own format items (D88) before the delete, the step keeps only the character, and the binding's release at stop lets the cell, row and table go. When it does, a table a collaborator deletes afterwards stays in that client's document, and in any state it encodes: 108 to 175 bytes per table where one character was deleted and one word bolded in one cell of a 1x1 to a 4x4 table, more where more was deleted or more cells were edited (about 1.3 KB for one character deleted in every cell of a 4x4 table). Binding the same document again does not free it. Clearing the history (`binding.undoManager?.clear()`, while the binding is started) before stopping and before the table is deleted prevents it, where no other `UndoManager` on the document still needs what it kept (a clear releases that too), except where an undo or redo restored content in the cell and that kept it (D99); a table deleted while the history still held such a step stays after a later clear too, as releasing a keep collects nothing (D88). Reloading the document, or loading its encoded state into a new document with garbage collection on (a server or relay), compacts it. | Yjs's `UndoManager#destroy` does not clear, and its keep is one flag set on an item and its parents; any editor that destroys a Yjs `UndoManager` without clearing it first keeps the same items. Measured over 50 cycles per table size (an edit in a cell, the binding stopped, the table deleted by a collaborator): the same bytes on the release before D88's own-format keep, with that keep turned off and on the current release, and none when the history was cleared before stopping. Clearing at stop in the binding was weighed and not taken: Yjs's clear releases with that one flag, so it would also release what another `UndoManager` on the document (an app's, or a second binding's) still keeps for its own undo, and a later collection could drop what that undo restores (the hazard D88 records for a clear). | `ACCEPTED LIMIT` | 11-01 | [#9698], [limits] | -| D99 | **An undo or redo that restores content inside a container can keep that container from garbage collection until reload.** Yjs's `UndoManager` keeps what a step deletes, walking up its parents (a table cell, its row and its table). An undo or redo that restores that content takes the step off the stacks, and Yjs's `clear()` releases only the steps still on them, so clearing the history before the binding stops does not release the cell, row and table. It does unless something releases the table afterwards: a clear releases every step still on a stack that deleted in the table, before or after the undo, row and table included (Yjs's `keepItem(item, false)` walks up while `keep` is set; a deleted table is collected with its cells), and the binding lets go a cell it kept for a format of its own (D88) before that step (that step's `keepItem` stopped at the kept cell). When it does, a table a collaborator deletes afterwards stays in that client's document, and in any state it encodes, until it reloads: 139 to 151 bytes for a one-cell table with nine characters, more with more content. Reloading the document, or loading its encoded state into a new document with garbage collection on (a server or relay), compacts it. Nothing reaches other clients. | Yjs behaviour, reproduced with plain `UndoManager` calls and no editor, and on every binding release. A release in the binding would have to know which keeps came from a step an undo or redo took off the stacks; Yjs's keep is one flag on each item, shared by every `UndoManager` on the document, so the binding cannot release it without risking what another undo manager still keeps for its own undo (the hazard D88 and D98 record). The fix belongs in Yjs: reported as [yjs#833]. | `ACCEPTED LIMIT` | 11-02 | [#9721], [#9723], [yjs#833], [limits] | +| D99 | **An undo or redo that restores content inside a container can keep that container from garbage collection until reload.** Yjs's `UndoManager` keeps what a step deletes, walking up its parents (a table cell, its row and its table). An undo or redo that restores that content takes the step off the stacks, and Yjs's `clear()` releases only the steps still on them, so clearing the history before the binding stops does not release the cell, row and table. It does unless something releases the table afterwards: a clear releases every step still on a stack that deleted in the table, before or after the undo, row and table included (Yjs's `keepItem(item, false)` walks up while `keep` is set; a deleted table is collected with its cells), and the binding lets go a cell it kept for a format of its own (D88) before that step (that step's `keepItem` stopped at the kept cell). When it does, a table a collaborator deletes afterwards stays in that client's document, and in any state it encodes, until it reloads: 139 to 151 bytes for a one-cell table with nine characters, more with more content. Reloading the document, or loading its encoded state into a new document with garbage collection on (a server or relay), compacts it. Nothing reaches other clients. | Yjs behaviour, reproduced with plain `UndoManager` calls and no editor, and on every binding release. A release in the binding would have to know which keeps came from a step an undo or redo took off the stacks; Yjs's keep is one flag on each item, shared by every `UndoManager` on the document, so the binding cannot release it without risking what another undo manager still keeps for its own undo (the hazard D88 and D98 record). The fix belongs in Yjs: reported as [yjs#833], fix proposed in [yjs#834] (v14) and [yjs#835] (v13). | `ACCEPTED LIMIT` | 11-02 | [#9721], [#9723], [yjs#833], [limits] | | D100 | **A format a collaborator applies starting right where a run of that format ends can be lost when a client other than the run's author changes part of the run at the same time:** with `Kilo` bolded by one client, another unbolds `il` or types plain text inside `Kilo` while the first or a third client bolds ` bb`; with some client id orders ` bb` stays plain on every client (12 of 24 cases in a test of both edits, every order of three client ids and gc on and off, on every version tested). No text is lost, and when the client that changed the run undoes its change, ` bb` is bold again. | The change inside the run writes a null right after the run's end, so that the run's value does not run on onto text a collaborator types there, and the other client's opener of ` bb` has the same neighbours; Yjs orders the two by client id, so with the writer's id higher its null lands between that opener and ` bb`. Writing the null anywhere else gives up that protection. A repair after the fact, or stored provenance, could avoid it; neither is worth its cost now. The author's own variant is D52. | `ACCEPTED LIMIT` | 11-02 | [#9589], [limits] | ## Open @@ -240,6 +240,8 @@ issues and docs that carry it. [#9690]: https://github.com/meta/lexical/issues/9690 [yjs#382]: https://github.com/yjs/yjs/issues/382 [yjs#833]: https://github.com/yjs/yjs/issues/833 +[yjs#834]: https://github.com/yjs/yjs/pull/834 +[yjs#835]: https://github.com/yjs/yjs/pull/835 [#9679]: https://github.com/meta/lexical/pull/9679 [#9694]: https://github.com/meta/lexical/issues/9694 [#9697]: https://github.com/meta/lexical/pull/9697 From d059e5f72a6692752066e115dfebb65dae242745 Mon Sep 17 00:00:00 2001 From: zurfyx Date: Mon, 2 Nov 2026 13:14:10 -0500 Subject: [PATCH 5/6] D52: replaced, not a limit on main (the author's variant keeps the format) --- packages/lexical-y/DECISIONS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lexical-y/DECISIONS.md b/packages/lexical-y/DECISIONS.md index 5ebcc5ed458..ca534fc5614 100644 --- a/packages/lexical-y/DECISIONS.md +++ b/packages/lexical-y/DECISIONS.md @@ -116,7 +116,7 @@ issues and docs that carry it. | D49 | **Delete wins:** text typed into, or a block moved into, a container a collaborator deletes at the same time is deleted with it. | Yjs deletes a parent's children; anything else would resurrect deleted content. | `ACCEPTED LIMIT` | 10-25 | [limits] | | D50 | **Two clients removing the same text and both undoing show it twice.** | Plain `UndoManager` behaviour; avoiding it needs a layout feature. | `ACCEPTED LIMIT` | 10-23 | [limits] | | D51 | **Rare undo and marker-cleanup timings could spread a comment or format onto untouched words** (two writes of one client both undone while a collaborator collapses markers; three authors in [#9575]). | Rare, no text lost. A provenance marker would need a layout version bump and about a week. Replaced by D86 (10-31): a delete no longer collapses markers, and the [#9575] cells pass. | `DECIDED` | 10-31 | [#9562], [#9575] | -| D52 | **Unbolding inside your own bold while a collaborator bolds text starting at its end** can leave the new text plain (when the unbolding client's id is higher). | Rare and cosmetic, no text lost. The repair is tracked in [#9589]; nothing more happens now. On main the author's variant keeps the format (only [#9562]'s parked rule lost it); the other-client variant is D100. | `ACCEPTED LIMIT` | 10-30 | [#9562], [#9589] | +| D52 | **Unbolding inside your own bold while a collaborator bolds text starting at its end** could leave the new text plain (when the unbolding client's id is higher). Replaced: not a limit on main. | Only [#9562]'s parked rule lost the format. On main the rule for a run's end skips the run's own author, and the case keeps the collaborator's bold in all 12 client id orders, gc on and off (checked 11-02). The variant where another client changes the run is D100. | `DECIDED` | 11-02 | [#9562], [#9589] | | D53 | **Removing a format while a collaborator types inside it** brings the format back on the rest of the range, up to its end. | The collaborator's write restores the format it saw; formats are ranges in one sequence (D16). On Yjs 13 it runs to the end of the paragraph. | `ACCEPTED LIMIT` | 10-27 | [#9400], [limits] | | D54 | **Text changed without a record of the edit** (`setTextContent`, DOM text changes) racing a collaborator puts their typing at the start of the new text. | The remaining cases of D28; recording intent at the source shrinks them. | `ACCEPTED LIMIT` | 10-27 | [#9483], [y.md: Edits made by code][y-code] | | D55 | **Ragged tables stay ragged until the next table command**, which pads them; two clients padding at once add an extra column. | D20: no client repairs remote shapes. | `ACCEPTED LIMIT` | 10-25 | [#9453], [limits] | From c3ce36c4af80c056cafeeee9093e83a384cfd6bf Mon Sep 17 00:00:00 2001 From: zurfyx Date: Mon, 2 Nov 2026 13:37:54 -0500 Subject: [PATCH 6/6] Address review: test.fails wraps only the documented assertion; note #9725 in the changeset - FormatAtRunEndRace and the #9725 test: text and convergence are plain tests in every order; test.fails wraps only the bold or NodeState check. - The pending #9713 changeset names the #9725 exception. - y.md: say why the #9690 case is kept instead of a bare D95. --- .changeset/lexical-text-entity-node-state.md | 2 + ...egisterLexicalTextEntityLookbehind.test.ts | 120 ++++++++++-------- .../lexical-website/docs/collaboration/y.md | 3 +- .../regressions/FormatAtRunEndRace.test.ts | 97 +++++++------- 4 files changed, 123 insertions(+), 99 deletions(-) diff --git a/.changeset/lexical-text-entity-node-state.md b/.changeset/lexical-text-entity-node-state.md index aeba71d398e..e175e7b83b8 100644 --- a/.changeset/lexical-text-entity-node-state.md +++ b/.changeset/lexical-text-entity-node-state.md @@ -7,6 +7,8 @@ The NodeState also comes back when that text is made an entity again in the same update, and plain text keeps its NodeState when it becomes an entity (state that `createNode` gives the entity wins). A token or segmented entity that text follows keeps its NodeState as well: it is turned back into text with it, and after twice in one update (or in updates batched into one commit) it is kept (taking the text its match extends into only when that text would merge with it), instead of looping forever. Editing inside a segmented node (typing, or removing a range that starts or ends in it) keeps its NodeState and detail too. +One case still drops NodeState: typing into a run of repeated characters, when a matcher that looks back into the run makes the run's last existing character an entity (#9725). + Verify: put a comment (or any NodeState) on a hashtag, type right before it or delete its `#`, and check that its characters keep the comment. Verify also: type inside a segmented mention (or delete a range that starts in it) and check that the text left keeps its NodeState and `unmergeable` detail; paste ` #tag` right after a comment and check that the hashtag is not added to the comment. diff --git a/packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityLookbehind.test.ts b/packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityLookbehind.test.ts index 48fa221702f..d0b876172e8 100644 --- a/packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityLookbehind.test.ts +++ b/packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityLookbehind.test.ts @@ -53,61 +53,75 @@ const getMatch = (text: string) => { return m ? {end: m.index + m[0].length, start: m.index} : null; }; +// Types an "A" into "$AA" right after "$" and returns the characters with +// their NodeState. +const typeIntoRun = () => { + const editor = createEditor({ + nodes: [HashtagNode, Letter], + onError: error => { + throw error; + }, + }); + registerLexicalTextEntity( + editor, + HashtagExtension.config!.getHashtagMatch, + HashtagNode, + node => $createHashtagNode(node.getTextContent()), + ); + registerLexicalTextEntity(editor, getMatch, Letter, node => + $create(Letter).setTextContent(node.getTextContent()), + ); + editor.update( + () => { + $getRoot().append( + $createParagraphNode().append( + $createTextNode('y').setFormat('bold'), + $createTextNode('a'), + $createHashtagNode('#x'), + $setState($createTextNode('$AA'), note, 'c1'), + ), + ); + }, + {discrete: true}, + ); + editor.update( + () => { + $getRoot() + .getAllTextNodes() + .find(node => node.getTextContent() === '$AA')! + .select(1, 1) + .insertText('A'); + }, + {discrete: true}, + ); + const nodes = editor.read(() => + $getRoot() + .getAllTextNodes() + .map(node => ({ + note: $getState(node, note), + text: node.getTextContent(), + type: node.getType(), + })), + ); + const chars = nodes.flatMap(node => + node.text.split('').map(text => ({note: node.note, text})), + ); + return chars; +}; + +test('typing into a run of repeated characters keeps the text', () => { + // the typed A is at index 5 + expect( + typeIntoRun() + .map(c => c.text) + .join(''), + ).toBe('ya#x$AAA'); +}); + test.fails( 'typing into a run of repeated characters keeps NodeState on an existing character', () => { - const editor = createEditor({ - nodes: [HashtagNode, Letter], - onError: error => { - throw error; - }, - }); - registerLexicalTextEntity( - editor, - HashtagExtension.config!.getHashtagMatch, - HashtagNode, - node => $createHashtagNode(node.getTextContent()), - ); - registerLexicalTextEntity(editor, getMatch, Letter, node => - $create(Letter).setTextContent(node.getTextContent()), - ); - editor.update( - () => { - $getRoot().append( - $createParagraphNode().append( - $createTextNode('y').setFormat('bold'), - $createTextNode('a'), - $createHashtagNode('#x'), - $setState($createTextNode('$AA'), note, 'c1'), - ), - ); - }, - {discrete: true}, - ); - editor.update( - () => { - $getRoot() - .getAllTextNodes() - .find(node => node.getTextContent() === '$AA')! - .select(1, 1) - .insertText('A'); - }, - {discrete: true}, - ); - const nodes = editor.read(() => - $getRoot() - .getAllTextNodes() - .map(node => ({ - note: $getState(node, note), - text: node.getTextContent(), - type: node.getType(), - })), - ); - const chars = nodes.flatMap(node => - node.text.split('').map(text => ({note: node.note, text})), - ); - // "ya#x$AAA": the typed A is at index 5; the A that was last before typing is at index 7. - expect(chars.map(c => c.text).join('')).toBe('ya#x$AAA'); - expect(chars[7]).toEqual({note: 'c1', text: 'A'}); + // the A that was last before typing is at index 7 + expect(typeIntoRun()[7]).toEqual({note: 'c1', text: 'A'}); }, ); diff --git a/packages/lexical-website/docs/collaboration/y.md b/packages/lexical-website/docs/collaboration/y.md index 32c351071d7..f1af6fda559 100644 --- a/packages/lexical-website/docs/collaboration/y.md +++ b/packages/lexical-website/docs/collaboration/y.md @@ -1344,7 +1344,8 @@ not merge as a user might expect: A client's own undo can remove a format from a word that none of its undone steps touched: type plain text into a bold run, bold a range that ends at that text, type plain text later on, and undo the three steps - ([#9690](https://github.com/meta/lexical/issues/9690); kept, D95). + ([#9690](https://github.com/meta/lexical/issues/9690); kept, because + the format rules behind it fix many more cases than they break). Undoing the replacement of a block with a new one (`replace` without `includeChildren`, written as a removal and a new block) after a collaborator edited the new block brings the old block's text back and diff --git a/packages/lexical-y/src/__tests__/unit/regressions/FormatAtRunEndRace.test.ts b/packages/lexical-y/src/__tests__/unit/regressions/FormatAtRunEndRace.test.ts index 0f342c1e960..728d0e76bad 100644 --- a/packages/lexical-y/src/__tests__/unit/regressions/FormatAtRunEndRace.test.ts +++ b/packages/lexical-y/src/__tests__/unit/regressions/FormatAtRunEndRace.test.ts @@ -28,58 +28,65 @@ const IDS = [ [3, 1, 2], ]; +// Runs the race and returns A's text and whether all of "fg" is bold, after +// every client converged. +const race = async (how: 'remove' | 'type', ids: number[], gc: boolean) => { + const t = await setup(() => $getRoot().append($p('abcdefgh')), {gc, ids}); + try { + const [a, b, c] = t.peers; + b.update(() => { + $selectIn([0], 1, 5).formatText('bold'); + $setSelection(null); + }); + await t.sync(); + t.offline(); + a.update(() => { + if (how === 'remove') { + const s = $selectIn([0], 1, 3); + s.format = 1; + s.formatText('bold'); + } else { + const s = $selectIn([0], 3, 3); + s.format = 0; + s.insertText('X'); + } + $setSelection(null); + }); + c.update(() => { + $selectIn([0], 5, 7).formatText('bold'); + $setSelection(null); + }); + await t.sync(); + await expectConverged(t.peers); + return a.read(() => { + const fg = $getRoot() + .getAllTextNodes() + .filter((n: TextNode) => /[fg]/.test(n.getTextContent())); + return { + fgBold: fg.length > 0 && fg.every(n => n.hasFormat('bold')), + text: $getRoot().getTextContent(), + }; + }); + } finally { + t.peers.forEach(p => p.stop()); + } +}; + for (const how of ['remove', 'type'] as const) { for (const ids of IDS) { for (const gc of [false, true]) { // ids are [A, B, C] const loses = ids[0] > ids[2]; + const name = `A ${how === 'remove' ? 'unbolds part of the run' : 'types plain text in the run'} (ids ${ids}, gc ${gc ? 'on' : 'off'})`; + test(`no text is lost when C bolds after B's run and ${name}`, async () => { + expect((await race(how, ids, gc)).text).toBe( + how === 'remove' ? 'abcdefgh' : 'abcXdefgh', + ); + }); (loses ? test.fails : test)( - `C's bold after B's run, A ${how === 'remove' ? 'unbolds part of the run' : 'types plain text in the run'} (ids ${ids}, gc ${gc ? 'on' : 'off'})`, + `C's bold after B's run stays when ${name}`, async () => { - const t = await setup(() => $getRoot().append($p('abcdefgh')), { - gc, - ids, - }); - try { - const [a, b, c] = t.peers; - b.update(() => { - $selectIn([0], 1, 5).formatText('bold'); - $setSelection(null); - }); - await t.sync(); - t.offline(); - a.update(() => { - if (how === 'remove') { - const s = $selectIn([0], 1, 3); - s.format = 1; - s.formatText('bold'); - } else { - const s = $selectIn([0], 3, 3); - s.format = 0; - s.insertText('X'); - } - $setSelection(null); - }); - c.update(() => { - $selectIn([0], 5, 7).formatText('bold'); - $setSelection(null); - }); - await t.sync(); - await expectConverged(t.peers); - // No text is lost. - expect(a.read(() => $getRoot().getTextContent())).toBe( - how === 'remove' ? 'abcdefgh' : 'abcXdefgh', - ); - const fg = a.read(() => - $getRoot() - .getAllTextNodes() - .filter((n: TextNode) => /[fg]/.test(n.getTextContent())), - ); - expect(fg.length).toBeGreaterThan(0); - expect(a.read(() => fg.every(n => n.hasFormat('bold')))).toBe(true); - } finally { - t.peers.forEach(p => p.stop()); - } + expect((await race(how, ids, gc)).fgBold).toBe(true); }, ); }