From d85d7ffabb6efaf3999e4a474f8eff16b5db05d1 Mon Sep 17 00:00:00 2001 From: Gerard Rovira Date: Sat, 31 Oct 2026 21:15:08 -0400 Subject: [PATCH] [lexical-website][lexical-y] Documentation Update: Record the 10/31 decisions on undo presses, list numbering, overlapping formats, rewritten words and provider ordering ## Description Records five maintainer decisions of 10/31 in DECISIONS.md and y.md: - **D81, accepted limit:** an undo press can show nothing when its step only restores or removes format items no reader shows, and several such steps take several presses, as with Yjs's `UndoManager` in any editor that uses it as it is. Skipping such steps (#9630, closed) needed a per-press digest of what readers show. y.md: a sentence in Limits, Undo. - **D90, accepted limit (#9632):** after four refused follow-ups in a row that the binding reads again, the author can show a derived list number from before them until that list is refreshed (adding an item does; an edit elsewhere or retyping an item does not); its fresh read, other clients and a reload are right. The stop on the fourth re-read is the backstop that ends reply loops (#9634, closed, showed the three requirements conflict). y.md: a Limits entry. The #9642 pins (`RefusedFollowUpRefresh`, `RefusedFollowUpFreshness`) now name D90 and the limit; they stay `test.fails`. - **D91, accepted limit (#9626):** overlapping formats of one key from two writers can cancel, because a Yjs text gives each place one value per key (yjs/yjs#382); since #9601 this also reaches a link's edge and first word. #9601 is kept. y.md: Other races says why. - **D93, accepted limit (#9610):** a comment on a word a collaborator rewrites at the same moment can land on the new word (`setTextContent`: one client id order; a block rewrite: one or both orders), the same race as text typed at the start of a range a collaborator formats. y.md: a sentence in Other races. New `regressions/CommentOnRewrittenWord.test.ts` pins the 9 cells where it happens as `test.fails` (12 block-rewrite cells and 2 `setTextContent` cells); #9610 stays open. D77 (the open question) says D93 replaced it. - **D92, decided (#9645):** providers deliver updates in causal order; a single relay that forwards every update to each client in the one order it received them does; a mesh or several relays need causal buffering that tracks dependencies, deletions included. y.md: Servers states the requirement. #9645 stays open. Also, from the review of #9684: D74 and D64 link #9681 and #9682, and y.md's Open issues lists #9681 and #9683. The agent-edit guidance is unchanged. ## Test plan ### Before ``` $ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/RefusedFollowUpRefresh.test.ts packages/lexical-y/src/__tests__/unit/regressions/RefusedFollowUpFreshness.test.ts Tests 27 passed | 30 expected fail (57) ``` ### After ``` $ pnpm exec prettier --check packages/lexical-y/DECISIONS.md packages/lexical-website/docs/collaboration/y.md packages/lexical-y/src/__tests__/unit/regressions/RefusedFollowUpRefresh.test.ts packages/lexical-y/src/__tests__/unit/regressions/RefusedFollowUpFreshness.test.ts All matched files use Prettier code style! $ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/RefusedFollowUpRefresh.test.ts packages/lexical-y/src/__tests__/unit/regressions/RefusedFollowUpFreshness.test.ts Tests 27 passed | 30 expected fail (57) $ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/CommentOnRewrittenWord.test.ts Tests 5 passed | 9 expected fail (14) $ LEXICAL_Y_GC=1 pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/CommentOnRewrittenWord.test.ts Tests 5 passed | 9 expected fail (14) ``` The two RefusedFollowUp files change only in comments; the same cells pass and fail as on main. With every `test.fails` in CommentOnRewrittenWord turned into `test`, exactly the 9 pinned cells fail (gc off and on). --- .../lexical-website/docs/collaboration/y.md | 57 ++++++- packages/lexical-y/DECISIONS.md | 19 ++- .../CommentOnRewrittenWord.test.ts | 157 ++++++++++++++++++ .../RefusedFollowUpFreshness.test.ts | 6 +- .../RefusedFollowUpRefresh.test.ts | 7 +- 5 files changed, 232 insertions(+), 14 deletions(-) create mode 100644 packages/lexical-y/src/__tests__/unit/regressions/CommentOnRewrittenWord.test.ts diff --git a/packages/lexical-website/docs/collaboration/y.md b/packages/lexical-website/docs/collaboration/y.md index 4be8fa02df2..37ceb32153c 100644 --- a/packages/lexical-website/docs/collaboration/y.md +++ b/packages/lexical-website/docs/collaboration/y.md @@ -895,8 +895,8 @@ merge the `update` it returns into the live document (see ## Servers A Yjs 14 (`@y/y`) server or relay can host the documents when it meets -the requirements below (format cleanup off, updates delivered in order, and -for yrs, its version and client ids). Clients of +the requirements below (format cleanup off, updates delivered in causal +order, and for yrs, its version and client ids). Clients of `@lexical/yjs` write to other roots (`root`, `root-v2`), never to `DEFAULT_ROOT_NAME` (`'lexical-y'`), so they cannot change a `@lexical/y` document's content and the server needs no check to keep them out. @@ -960,6 +960,20 @@ the document's client id when it is created: doc.clientID = crypto.getRandomValues(new Uint32Array(1))[0] || 1; ``` +Deliver updates in causal order: each update after every update it depends +on, from any client (as json-joy also requires). A single relay that every +client sends to and receives from, and that forwards every update to each +client in the one order it received them, whichever client sent it, does +this. A mesh (such as y-webrtc), or +several relays that forward updates on their own, needs causal buffering: +hold an update until every update it depends on has been applied, deletions +included (a state vector alone cannot tell: an update that only deletes +leaves it unchanged). Out of causal order, a +client can see a collaborator's format run on to the end of a line until the +missing update arrives, and an edit it makes in that moment makes the +spread permanent, on every client +([#9645](https://github.com/meta/lexical/issues/9645)). + Deliver each client's updates in order. With Yjs 14.0.0-rc.29, a client that receives an update past a gap in another client's clocks (a relay that reorders and batches updates) can show a character that a document loaded @@ -1169,6 +1183,15 @@ not merge as a user might expect: - **List type change ‖ new item.** Changing a list's type while a collaborator adds an item to it (Enter in the list) splits the list: the new item keeps the old type, in a list of its own between the two halves. +- **List numbering 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's editor can show a 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. The binding stops + refreshing derived numbers on the fourth such re-read in a row, so that an + app listener that answers every refresh cannot loop forever. Other + clients, the stored document and a reload show the right number. - **Blocks that arrive without an id.** With `BlockIdExtension`, a block stored without an id (written by a client without the extension), or with an id an earlier block also stores or shows (a copy of the JSON of a block @@ -1235,7 +1258,12 @@ not merge as a user might expect: format, and stays on its content: [#9627](https://github.com/meta/lexical/issues/9627). Where a collaborator overwrote a step of yours entirely, the undo manager skips that step as changing nothing, and the - same undo also reverts your step before it. When you undo a format change + same undo also reverts your step before it. An undo press can show + nothing: when its step only restores or removes format items no reader + shows (a format on text a collaborator then deleted), the press takes that + step alone, and several such steps in a row take several presses before + one shows a change, as with Yjs's `UndoManager` in any editor that uses it + as it is. When you undo a format change while a collaborator removes that format and undoes the removal, the text keeps the format: the collaborator's undo brings it back. A format a collaborator applies starting right where a run of that format ends, or from inside the run @@ -1308,8 +1336,13 @@ not merge as a user might expect: after a Backspace it takes the type of the line before (a paragraph after a heading becomes a heading), after undoing a split it keeps its own. Bold ‖ bold on adjacent ranges of one line - can leave part unformatted, and so can bold ‖ bold on overlapping ranges - ([#9626](https://github.com/meta/lexical/issues/9626)). A link behaves + can leave part unformatted, and so can the same format (bold, the same + comment id, a link) applied by two collaborators to overlapping ranges at + once + ([#9626](https://github.com/meta/lexical/issues/9626)): a Yjs text gives + each place one value per key + ([yjs/yjs#382](https://github.com/yjs/yjs/issues/382)), so one writer's + end of its range can end the other's run there too. A link behaves like a comment there: a range that crosses a link's edge can lose part of its bold for either client id order, and a bold reaching into a link's first word can lose it there when a collaborator one update @@ -1324,7 +1357,13 @@ not merge as a user might expect: takes that format (also when the range comes from an older read, as an agent's does), and text typed at the start or the end of the range can take it, by the order Yjs gives the two inserts (at the start the same on - Yjs 13, and it keeps the format when that range is then deleted). A + Yjs 13, and it keeps the format when that range is then deleted). In the + same way, 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 rewrite of the whole line (its text nodes replaced by a new + one, as an agent writes it) for one or both orders; the words of the range + the rewrite kept keep the comment + ([#9610](https://github.com/meta/lexical/issues/9610)). A node whose `isInline()` changes is written again in its new form, so text typed inside it at the same time lands next to it, in an inline element of its old form. Where text was deleted, the format items it @@ -1366,6 +1405,12 @@ reproduces it: container and makes no list, in every `@lexical/y` editor (it runs `NodeStructureExtension`): [#9570](https://github.com/meta/lexical/issues/9570). +- Undoing plain text typed into a format run gives the run's format (or + comment, or style) to plain text a collaborator typed right next to it: + [#9681](https://github.com/meta/lexical/issues/9681). +- With Yjs garbage collection on, undoing a format removal while a + collaborator applies the same format again can take it off the next + character: [#9683](https://github.com/meta/lexical/issues/9683). For `MarkStateExtension` comments, see the [comments limitations](./comments.md#limitations). diff --git a/packages/lexical-y/DECISIONS.md b/packages/lexical-y/DECISIONS.md index 98d9df9b4a8..8922684bc31 100644 --- a/packages/lexical-y/DECISIONS.md +++ b/packages/lexical-y/DECISIONS.md @@ -102,6 +102,7 @@ issues and docs that carry it. | D45 | **`editYState` requires a `writer`** (`createYWriter`, carrying an author) or `null`. A writer keeps one client id per document while each call continues from its last update; when that is not safe (an older state, a call inside a call) it takes a new id and warns. A caller never picks a client id. | Every server edit decides who writes it. A client id reused by two writers corrupts silently, and a new id per edit bloats the document. | `DECIDED` | 10-28 | [#9437], [y-server.md][y-server] | | D71 | **Server-side edits stay callbacks** (`editYState`'s `$edit`), run on the state the edit was computed from, with guidance added on writing them. | The edit is ordinary Lexical code with every Lexical API available; guidance covers what the callback cannot enforce. | `DECIDED` | 10-25 | [#9437], [y.md: Edits made by code][y-code] | | D46 | **Products own server policy; Lexical gives the pieces.** How an agent's edit applies (at once, as a suggestion, with an undo) and what a refused client does are the product's choice; the framework dictates no extra steps. Relay checks are defence in depth for authorized writers, not a permission boundary. | Products differ, and a server cannot judge a peer's intent from a Yjs update. | `DECIDED` | 10-29 | | +| D92 | **Providers deliver updates in causal order:** each update after every update it depends on, from any client, as json-joy also requires. A single relay that every client sends to and receives from, and that forwards every update to each client in the one order it received them, whichever client sent it, does this; a mesh (y-webrtc) or several relays need causal buffering that tracks each update's dependencies, deletions included (a state vector does not change for an update that only deletes). | Yjs holds back what it cannot place yet: out of causal order, a collaborator's format can run on to the end of a line until the missing update arrives, and an edit made in that moment makes the spread permanent ([#9645]). Ordering is the transport's job; the binding cannot write while applying remote updates. | `DECIDED` | 10-31 | [#9645], [y.md: Servers][y-servers] | ## Accepted limits @@ -120,6 +121,10 @@ issues and docs that carry it. | D57 | **Lone surrogates** reach other clients as U+FFFD. | Yjs encodes text as UTF-8; Yjs 13 does the same. Malformed text such as a broken emoji is the author's own input, not an editor problem, so there is no code follow-up. | `ACCEPTED LIMIT` | 10-30 | [limits] | | D58 | **Inline elements with their own content** (`MarkNode`, `AutoLinkNode`) move text into them when they wrap it, so concurrent typing there stays outside. | Use NodeState marks (D37). | `ACCEPTED LIMIT` | 10-20 | [limits] | | D79 | **The Yjs guard (D14) cannot tell every other Yjs apart:** `@y/y` exposes no version at run time, so the guard checks shape and the peer dependency enforces the exact version; a `@y/y` with the same exports, members and events passes (`14.0.0-rc.25` to `rc.28` do), as does one whose functions behave differently with the same shape, and a provider that bundles its own copy of Yjs and applies updates to the app's document after the binding started is not caught. | `@y/y` exposes no version at run time, and checking updates would cost on every one. The exact peer pin and the docs (install the named version once; `npm ls @y/y`) cover them. | `ACCEPTED LIMIT` | 10-30 | [#9607], [limits] | +| D81 | **An undo press can show nothing:** when the step it takes only restores or removes format items no reader shows (a format on text a collaborator then deleted), the press takes that step alone; several such steps in a row take several presses before one shows a change. | Yjs's `UndoManager` skips only a step that changes no item, and an editor that uses it as it is behaves the same. Skipping such steps too ([#9630]) needed a digest of what readers show before and after every step: a cost on every press, and a digest that collides would skip a step that does change something. | `ACCEPTED LIMIT` | 10-31 | [#9630], [limits] | +| 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 or both orders; 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] | ## Open @@ -130,16 +135,16 @@ issues and docs that carry it. | D61 | **Two concurrent joins into one line** both write its type; Yjs keeps one by client id. | Inherent to the marker layout; a real trade-off. | `OPEN` | 10-30 | [#9511] | | D62 | **Two app versions that store a type differently insert its first node at once.** | Needs a storage-kind record protocol; `schemaVersion` (D08) covers apps that bump it. | `OPEN` | 10-30 | [#9512] | | 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] | +| 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]). | `OPEN` | 10-30 | [#9564], [#9609], [#9562], [#9605], [#9603] | | 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] | | D69 | **Comments on block decorators** (an image), as in Google Docs. | Not today, same as `MarkNode`; keep the door open. | `OPEN` | 10-23 | [#9380] | -| D74 | **Keeping the run ends of text an undo removes from inside a format run.** | [#9561] keeps them, but regressed a concurrent undo ([#9593]) and is parked. Option C: settle the kept ends inside the undo that makes them stale, where the undoing client has seen them; the truly concurrent case would stay a documented limit. Its tests move to main in [#9605]. | `OPEN` | 10-30 | [#9561], [#9593], [#9605] | +| D74 | **Keeping the run ends of text an undo removes from inside a format run.** | [#9561] keeps them, but regressed a concurrent undo ([#9593]) and is parked. Option C: settle the kept ends inside the undo that makes them stale, where the undoing client has seen them; the truly concurrent case would stay a documented limit. Its tests move to main in [#9605]. | `OPEN` | 10-30 | [#9561], [#9593], [#9605], [#9681] | | D75 | **The table padding contract under collaboration.** When every client re-runs the table transforms after remote changes, concurrent pads add cells and the table never settles. | Padding that converges needs a rule: only the client that created a row pads it, or a width that is a function of the document instead of each client's write. Meanwhile the open [#9539] would pad, when an edit leaves the table's rows in place, only the rows that lost cells. Two clients padding at once is the accepted limit D55. | `OPEN` | 10-30 | [#9582], [#9539] | | D76 | **A code-token-aware trim for remade text.** A stale replace of a word can take the first letter of the word a rewrite made from it, where a node boundary cut the old word. | Writing a changed word whole fixes it, but also rewrites highlighter tokens glued to a pasted-over token in a code line. A follow-up would apply the rule everywhere except at code highlight token boundaries (D29). | `OPEN` | 10-30 | [#9608] | -| D77 | **A stale comment on a word a collaborator rewrites** lands on the new word for one client id order only. | Likely the documented race where text typed at the start of a range a collaborator formats takes that format, with a comment instead of a format. Either the docs name comments and whole-word replacements, or a fix makes both orders agree. | `OPEN` | 10-30 | [#9610], [limits] | +| D77 | **A stale comment on a word a collaborator rewrites** lands on the new word for one client id order only. | Likely the documented race where text typed at the start of a range a collaborator formats takes that format, with a comment instead of a format. Either the docs name comments and whole-word replacements, or a fix makes both orders agree. Replaced by D93 (10-31): documented as a limit. | `DECIDED` | 10-31 | [#9610], [limits] | [root-agents]: ../../AGENTS.md [limits]: ../lexical-website/docs/collaboration/y.md#limits @@ -213,3 +218,11 @@ issues and docs that carry it. [#9597]: https://github.com/meta/lexical/issues/9597 [#9606]: https://github.com/meta/lexical/issues/9606 [#9666]: https://github.com/meta/lexical/issues/9666 +[#9630]: https://github.com/meta/lexical/pull/9630 +[#9632]: https://github.com/meta/lexical/issues/9632 +[#9634]: https://github.com/meta/lexical/pull/9634 +[#9642]: https://github.com/meta/lexical/pull/9642 +[#9645]: https://github.com/meta/lexical/issues/9645 +[#9681]: https://github.com/meta/lexical/issues/9681 +[#9682]: https://github.com/meta/lexical/issues/9682 +[yjs#382]: https://github.com/yjs/yjs/issues/382 diff --git a/packages/lexical-y/src/__tests__/unit/regressions/CommentOnRewrittenWord.test.ts b/packages/lexical-y/src/__tests__/unit/regressions/CommentOnRewrittenWord.test.ts new file mode 100644 index 00000000000..1e7039e55e8 --- /dev/null +++ b/packages/lexical-y/src/__tests__/unit/regressions/CommentOnRewrittenWord.test.ts @@ -0,0 +1,157 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +// @vitest-environment node + +// A comment on a word a collaborator rewrites at the same moment (#9610), +// an accepted limit (DECISIONS.md D93, y.md's Limits, Other races): B, who +// has not seen A's rewrite, comments the old word, and the comment can land +// on the new word A wrote in its place. It is the race of text typed at the +// start of a range a collaborator formats: the new word is inserted where +// the commented word starts, and Yjs orders the two writes by client id. +// The cells where the comment reaches the new word are `test.fails`, so that +// a change of the limit shows here. Words of B's range that A kept keep the +// comment in every cell. + +import {$addMarkID, $getNodeMarkIDs} from '@lexical/mark'; +import { + $createParagraphNode, + $createRangeSelection, + $createTextNode, + $getRoot, + $setSelection, + type ElementNode, + type LexicalNode, + type TextNode, +} from 'lexical'; +import {describe, expect, test} from 'vitest'; + +import {expectConverged, type Peer, setup} from '../../utils/harness'; +import {prepareMarkState as prepare} from '../../utils/sizeOps'; + +/** The words of every text node holding comment `id`. */ +const commented = (peer: Peer, id: string) => + peer.read(() => + ($getRoot().getAllTextNodes() as TextNode[]) + .filter(n => $getNodeMarkIDs(n as LexicalNode).includes(id)) + .flatMap(n => n.getTextContent().split(/\s+/).filter(Boolean)), + ); + +/** A range selection from the start of word `a` to the end of word `b` of line 1. */ +function $selectWords(a: string, b: string) { + const block = $getRoot().getChildAtIndex(1) as ElementNode; + const text = block.getTextContent(); + const start = text.indexOf(a); + const end = text.indexOf(b) + b.length; + const point = (offset: number, isStart: boolean): [string, number] => { + let at = 0; + for (const leaf of block.getAllTextNodes()) { + const size = leaf.getTextContentSize(); + if (isStart ? offset < at + size : offset <= at + size) { + return [leaf.getKey(), offset - at]; + } + at += size; + } + throw new Error('no text node at offset ' + offset); + }; + const [anchorKey, anchorOffset] = point(start, true); + const [focusKey, focusOffset] = point(end, false); + const selection = $createRangeSelection(); + selection.anchor.set(anchorKey, anchorOffset, 'text'); + selection.focus.set(focusKey, focusOffset, 'text'); + $setSelection(selection); + return selection; +} + +describe('a comment on a word a collaborator rewrites in a block rewrite', () => { + // A rewrites line 1 as an agent does (its children removed, one new text + // node with t23z changed to t66z) while B comments c9. + const ranges: [string, string, string[]][] = [ + ['t23z', 't23z', []], + ['t22z', 't23z', ['t22z']], + ['t23z', 't24z', ['t24z']], + ]; + for (const [from, to, kept] of ranges) { + for (const order of ['rewrite first', 'comment first']) { + for (const ids of [ + [1, 2], + [2, 1], + ]) { + // D93: the comment reaches the new word for ids [1, 2], and for + // both orders where B's range ends after it. + const known = ids[0] === 1 || to === 't24z'; + (known ? test.fails : test)( + `c9 on ${from}..${to}, ${order}, ids ${ids}`, + async () => { + const t = await setup( + () => + $getRoot().append( + $createParagraphNode().append($createTextNode('head')), + $createParagraphNode().append( + $createTextNode('t21z t22z t23z t24z t25z'), + ), + ), + {ids, prepare}, + ); + t.offline(); + const $rewrite = () => { + const block = $getRoot().getChildAtIndex(1) as ElementNode; + const next = block.getTextContent().replace('t23z', 't66z'); + for (const child of block.getChildren()) { + child.remove(); + } + block.append($createTextNode(next)); + }; + const $comment = () => + void $addMarkID('c9', $selectWords(from, to)); + if (order === 'rewrite first') { + t.a.update($rewrite); + t.b.update($comment); + } else { + t.b.update($comment); + t.a.update($rewrite); + } + await t.sync(); + await expectConverged(t.peers); + expect(commented(t.a, 'c9')).toEqual(kept); + }, + ); + } + } + } +}); + +describe('a comment on a word a collaborator rewrites with setTextContent', () => { + for (const ids of [ + [1, 2], + [2, 1], + ]) { + // D93: the comment reaches the new word for ids [1, 2]. + (ids[0] === 1 ? test.fails : test)( + `A rewrites "wx" to "yz" while B comments "wx" (ids ${ids})`, + async () => { + const t = await setup( + () => + $getRoot().append( + $createParagraphNode().append($createTextNode('head')), + $createParagraphNode().append($createTextNode('aa wx bb')), + ), + {ids, prepare}, + ); + t.offline(); + t.a.update(() => { + const block = $getRoot().getChildAtIndex(1) as ElementNode; + (block.getFirstChildOrThrow() as TextNode).setTextContent('aa yz bb'); + }); + t.b.update(() => void $addMarkID('c1', $selectWords('wx', 'wx'))); + await t.sync(); + await expectConverged(t.peers); + expect(commented(t.a, 'c1')).toEqual([]); + }, + ); + } +}); diff --git a/packages/lexical-y/src/__tests__/unit/regressions/RefusedFollowUpFreshness.test.ts b/packages/lexical-y/src/__tests__/unit/regressions/RefusedFollowUpFreshness.test.ts index 1e73447e7db..e321304855f 100644 --- a/packages/lexical-y/src/__tests__/unit/regressions/RefusedFollowUpFreshness.test.ts +++ b/packages/lexical-y/src/__tests__/unit/regressions/RefusedFollowUpFreshness.test.ts @@ -13,7 +13,8 @@ // starting at 1 and at 7: right after each update (before anything is // awaited) the author shows the list numbering a fresh reader shows. Three // or more follow-ups before reach the fourth re-read in a row, which runs no -// refresh (#9632): `test.fails` until it is decided. +// refresh (#9632, an accepted limit: DECISIONS.md D90, y.md's Limits): +// `test.fails`, so that a change of the limit shows here. import { $createListItemNode, $createListNode, @@ -45,7 +46,8 @@ for (const start of [1, 7]) for (const before of [0, 3, 6, 12]) for (const batch of [false, true]) for (const blockIds of [false, true]) - // #9632: the fourth re-read in a row runs no refresh. + // #9632 (D90, accepted limit): the fourth re-read in a row runs no + // refresh. (before >= 3 ? test.fails : test)( `sync derived start=${start} before=${before} batch=${batch} idsExtension=${blockIds}`, async () => { diff --git a/packages/lexical-y/src/__tests__/unit/regressions/RefusedFollowUpRefresh.test.ts b/packages/lexical-y/src/__tests__/unit/regressions/RefusedFollowUpRefresh.test.ts index 8841fe9f394..57f4b38c8d9 100644 --- a/packages/lexical-y/src/__tests__/unit/regressions/RefusedFollowUpRefresh.test.ts +++ b/packages/lexical-y/src/__tests__/unit/regressions/RefusedFollowUpRefresh.test.ts @@ -13,8 +13,8 @@ // that rewrites the same content on every run, an app's listener that // answers every refresh with such a follow-up), the fourth re-read in a row // runs no refresh. That leaves derived values stale after four follow-ups in -// a row (#9632): those cells are `test.fails` until it is decided (see the -// pull request that fixes #9632). +// a row (#9632), an accepted limit (DECISIONS.md D90, y.md's Limits): those +// cells are `test.fails`, so that a change of the limit shows here. import { $createListItemNode, @@ -84,7 +84,8 @@ describe('list numbering after follow-ups in a row that are read again', () => { [1, 2], [2, 1], ]) { - // #9632: the fourth re-read in a row runs no refresh. + // #9632 (D90, accepted limit): the fourth re-read in a row runs no + // refresh. const known = tag === NAMING && before >= 3; (known ? test.fails : test)( `${before} before, tag ${tag ?? 'none'}, ids ${ids}`,