From 05ca7c9ea4ecf8d199327843b5f32f9b08bccd3e Mon Sep 17 00:00:00 2001 From: Gerard Rovira Date: Fri, 30 Oct 2026 20:25:04 -0700 Subject: [PATCH 1/2] [lexical-y] Bug Fix: Place an edit of set text at a word edge when the caret is past it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Follow-up to #9641 (#9636), from its review. `setTextEnds` kept the diff's placement whenever the caret anchored it, and a caret anchors the diff whenever the text after it is unchanged: always true for a caret at the end of the node, anywhere past the edit, or in a later node (for which the writer anchors the diff at the end of the node). So the usual `node.setTextContent('t1z t3z'); node.selectEnd();` was placed as on main: `2z t` deleted, and a collaborator's concurrent delete of `t2z` left `t1z 3z`. A caret now places the edit only where its bound lies in the run of equal text the edit could be in, the furthest place included (`caretPlaces`): a caret right after typed text still places it (a space typed after two spaces stays after them), and a caret past the edit places nothing. The property's allowance for glue also narrows to the side y.md documents (a word typed before the deleted word, glued to the word before it; typed after a line's first word, glued to the word after it), so it guards D30's choice. ## Test plan New cells in `SetTextWordEdges.test.ts`: `setTextEnds` with the caret at the end; a set with the caret at the end of the node or in the next node while a collaborator deletes `t2z` or types ` q9z` after it, recorded and diffed by the writer, both id orders; `1..` set to `1.` with the caret at 2 while a collaborator types between the dots. ### Before On #9641's head (862ecca7): ``` $ vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/SetTextWordEdges.test.ts -t 'caret past' × places nothing: `t1z t2z t3z` set to `t1z t3z` with the caret at the end × set with the caret at the end ‖ the peer deletes t2z, recorded (ids 1,2) × set with the caret at the end ‖ the peer types " q9z" after t2z, recorded (ids 1,2) × set with the caret at the end ‖ the peer deletes t2z, diffed by the writer (ids 1,2) × set with the caret in the next node ‖ the peer deletes t2z, diffed by the writer (ids 1,2) × set with the caret at the end ‖ the peer types " q9z" after t2z, diffed by the writer (ids 1,2) × set with the caret in the next node ‖ the peer types " q9z" after t2z, diffed by the writer (ids 1,2) ... (the same 6 for ids 2,1) Tests 13 failed | 6 passed | 44 skipped (63) ``` ### After ``` $ LEXICAL_Y_SEEDS=600 vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/SetTextWordEdges.test.ts Tests 63 passed (63) $ pnpm run ci-check EXIT 0 $ vitest --run --project unit --project scripts-unit Test Files 644 passed | 4 skipped (648) Tests 27748 passed | 204 expected fail | 135 skipped (28087) ``` --- .../lexical-y-set-text-caret-past-edit.md | 7 ++ .../unit/regressions/SetTextWordEdges.test.ts | 111 ++++++++++++++++-- packages/lexical-y/src/internal/Inline.ts | 19 ++- 3 files changed, 123 insertions(+), 14 deletions(-) create mode 100644 .changeset/lexical-y-set-text-caret-past-edit.md diff --git a/.changeset/lexical-y-set-text-caret-past-edit.md b/.changeset/lexical-y-set-text-caret-past-edit.md new file mode 100644 index 00000000000..d86c3334cf6 --- /dev/null +++ b/.changeset/lexical-y-set-text-caret-past-edit.md @@ -0,0 +1,7 @@ +--- +'@lexical/y': patch +--- + +[fix] Place an insertion or a deletion of text set with the caret at the end of the node, past the edit or in a later node at a word edge, as without a caret + +Verify: `node.setTextContent('t1z t3z'); node.selectEnd();` on a node holding `t1z t2z t3z`, while a collaborator deletes `t2z`, shows `t1z t3z` on every client (not `t1z 3z`), and a word the collaborator types after `t2z` stays a word of its own. diff --git a/packages/lexical-y/src/__tests__/unit/regressions/SetTextWordEdges.test.ts b/packages/lexical-y/src/__tests__/unit/regressions/SetTextWordEdges.test.ts index d3784012d4f..bb4fd3ce70d 100644 --- a/packages/lexical-y/src/__tests__/unit/regressions/SetTextWordEdges.test.ts +++ b/packages/lexical-y/src/__tests__/unit/regressions/SetTextWordEdges.test.ts @@ -211,6 +211,97 @@ describe('#9636: a caret keeps placing the edit', () => { } }); +// A caret at the end of the node, or anywhere past the edit, anchors every +// place of the run of equal text, so it places nothing: `setTextContent` +// followed by `selectEnd()` (what code that sets a node's text usually does) +// is placed as without a caret, and so is a set with the caret in a later +// node, for which the writer anchors the diff at the end of the node. +describe('#9636: a caret past the edit', () => { + test('places nothing: `t1z t2z t3z` set to `t1z t3z` with the caret at the end', () => { + expect(setTextEnds('t1z t2z t3z', 't1z t3z', 7, false)).toEqual({ + prefix: 3, + suffix: 4, + }); + }); + for (const ids of IDS) { + for (const record of [true, false]) { + const how = record ? 'recorded' : 'diffed by the writer'; + for (const [peer, want] of [ + ['deletes t2z', 't1z t3z'], + ['types " q9z" after t2z', 't1z q9z t3z'], + ] as const) { + for (const caret of ['at the end', 'in the next node'] as const) { + test(`set with the caret ${caret} ‖ the peer ${peer}, ${how} (ids ${ids})`, async () => { + const t = await setup( + () => { + const line = $createParagraphNode().append( + $createTextNode('t1z t2z t3z'), + ); + if (caret === 'in the next node') { + line.append($createTextNode('!').toggleFormat('bold')); + } + $getRoot().append(line); + }, + {ids}, + ); + const recording = t.a.editor._recordTextEdits; + try { + t.a.editor._recordTextEdits = record; + t.offline(); + t.a.update(() => { + $node().setTextContent('t1z t3z'); + if (caret === 'at the end') { + $node().selectEnd(); + } else { + ($node().getNextSibling() as TextNode).select(1, 1); + } + }); + t.b.update( + peer === 'deletes t2z' + ? $splice(4, 3, '') + : $splice(7, 0, ' q9z'), + ); + await t.sync(); + const end = caret === 'in the next node' ? '!' : ''; + const fresh = await freshReader(t.a); + expect( + [...t.peers, fresh].map(p => + p.read(() => $getRoot().getTextContent()), + ), + ).toEqual([want + end, want + end, want + end]); + await expectConverged(t.peers); + } finally { + t.a.editor._recordTextEdits = recording; + } + }); + } + } + } + test(`\`1..\` set to \`1.\` with the caret at 2 ‖ the peer types between the dots (ids ${ids})`, async () => { + const t = await setup( + () => { + $getRoot().append( + $createParagraphNode().append($createTextNode('1..')), + ); + }, + {ids}, + ); + t.a.editor.update(() => $node().select(3, 3), {discrete: true}); + t.offline(); + t.a.update(() => { + $node().setTextContent('1.'); + $node().select(2, 2); + }); + t.b.update($splice(2, 0, 'x')); + await t.sync(); + expect( + t.peers.map(p => p.read(() => $getRoot().getTextContent())), + ).toEqual(['1.x', '1.x']); + await expectConverged(t.peers); + }); + } +}); + // A word set away next to a word that ends or starts like it: the edit is // not moved onto a piece of a word that stays (`a ba c` set to `a c` // deletes ` ba`, not `a b`, which would keep the `a` of `ba` as the first @@ -350,19 +441,15 @@ describe('#9636: set text and a collaborator edit keep every word whole', () => if (new Set(texts).size > 1) { problems.push(`diverged ${JSON.stringify(texts)}`); } - // A word typed against the deleted word, glued to its neighbour. - const gone = insertSet ? null : words[target]; + // A word typed against the deleted word, glued to its neighbour + // on the side y.md documents: typed before it, to the word before + // it; typed after a line's first word, to the word after it. + const gone = insertSet || victim !== words[target] ? -1 : target; const glued = (w: string) => - gone !== null && - (victim === gone || - words[words.indexOf(gone) - 1] === victim || - words[words.indexOf(gone) + 1] === victim) && - (w.startsWith(typed) || w.endsWith(typed)) && - next.includes( - w.startsWith(typed) - ? w.slice(typed.length) - : w.slice(0, -typed.length), - ); + gone >= 0 && + (edit === 'type before' + ? gone > 0 && w === words[gone - 1] + typed + : edit === 'type after' && gone === 0 && w === typed + words[1]); for (const w of got.filter(glued)) { got.splice( got.indexOf(w), diff --git a/packages/lexical-y/src/internal/Inline.ts b/packages/lexical-y/src/internal/Inline.ts index 7929deab9b3..e4d302f6ff0 100644 --- a/packages/lexical-y/src/internal/Inline.ts +++ b/packages/lexical-y/src/internal/Inline.ts @@ -767,12 +767,27 @@ function placeAtWordEdge( return null; } +/** + * Whether the caret at `cursor` of `b` placed the insertion or deletion that + * makes `b` of `a`: it anchors the diff (caretAnchors) and its bound is in + * the run of equal text the edit could be in, the furthest place included. + * A caret at the end of the node or past the edit (and the end of the node, + * which the writer passes for a caret in a later node) anchors every place + * of the run and places nothing. + */ +function caretPlaces(a: string, b: string, cursor: number): boolean { + return ( + caretAnchors(a, b, cursor) && + cursor - Math.max(0, b.length - a.length) <= diffStrings(a, b, -1).prefix + ); +} + /** * The common start and end that text set whole (`setTextContent`, a node's * text made again, the DOM's text) keeps of the text it replaces, anchored * at `cursor` (see diffStrings): a replacement keeps whole words * (snapToWords); an insertion or a deletion keeps every other character, - * where the caret placed it (diffStrings), or where the diff put it if both + * where the caret placed it (caretPlaces), or where the diff put it if both * its ends are at word edges, else at the first place in the run of equal * text it could be in where they are (placeAtWordEdge), else where the diff * put it. The first: @@ -791,7 +806,7 @@ export function setTextEnds( diff.prefix + diff.suffix === Math.min(a.length, b.length) && a.length !== b.length ) { - return composing || caretAnchors(a, b, cursor) + return composing || caretPlaces(a, b, cursor) ? diff : (placeAtWordEdge(a, b, diff, true) ?? diff); } From 965945279d25735764690b59aab26fb5ccf2340d Mon Sep 17 00:00:00 2001 From: Gerard Rovira Date: Fri, 30 Oct 2026 21:25:54 -0700 Subject: [PATCH 2/2] [lexical-website][lexical-y] Documentation Update: Say that a caret past the edit places nothing, in y.md and D30, and pin both sides of the trade Review follow-up on this pull request. y.md ("Edits made by code") and D30 said the word-edge placement applies to a set "without a caret"; it now also applies with a caret that places nothing (at the end of the node, past the edit, or in a later node), and both say so. `caretPlaces`' comment says a caret past the furthest place of the run places nothing (one at the furthest place still places the edit). New cells: with the caret at the end, a collaborator typing `q9z ` right before `t2z` now gets `t1zq9z t3z` (y.md's documented glue; main gave `t1z q9z t3z` while the caret pinned the diff); and `setTextEnds` with a caret in the run keeps the caret's placement (`t1z t2z t3z` set to `t1z t3z` with the caret at 5 deletes `2z t`; `ab ab` set to `ab abz ab` with the caret at 9 inserts at the end), so a change that dropped the caret branch fails. ``` $ vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/SetTextWordEdges.test.ts Tests 73 passed (73) ``` --- .../lexical-website/docs/collaboration/y.md | 6 +++-- packages/lexical-y/DECISIONS.md | 2 +- .../unit/regressions/SetTextWordEdges.test.ts | 25 +++++++++++++------ packages/lexical-y/src/internal/Inline.ts | 6 ++--- 4 files changed, 25 insertions(+), 14 deletions(-) diff --git a/packages/lexical-website/docs/collaboration/y.md b/packages/lexical-website/docs/collaboration/y.md index 0c34b3f3d82..cc48fe4076a 100644 --- a/packages/lexical-website/docs/collaboration/y.md +++ b/packages/lexical-website/docs/collaboration/y.md @@ -834,8 +834,10 @@ person's: (and a change the browser makes to the DOM text, as an IME composition does) keeps the start and end its text shares with the node's old text, anchored at the caret, which cannot tell where an edit next to equal text - was made without one. Without a caret, an insertion or a deletion found - that way stays where the diff puts it if both its ends are at word edges; + was made without one. Without a caret that places it (none, or one at + the end of the node, past the edit or in a later node), an insertion or + a deletion found that way stays where the diff puts it if both its ends + are at word edges; where that is inside a word, it goes to the first place where both are, so it changes no word it does not add or remove (`t1z t2z t3z` set to `t1z t3z` deletes ` t2z`, not `2z t`, and a collaborator's concurrent diff --git a/packages/lexical-y/DECISIONS.md b/packages/lexical-y/DECISIONS.md index c764ffcc48d..0a35770aa4a 100644 --- a/packages/lexical-y/DECISIONS.md +++ b/packages/lexical-y/DECISIONS.md @@ -61,7 +61,7 @@ issues and docs that carry it. | 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] | | D28 | **Text identity is fixed at the source.** While a binding is attached, core records text edits (`spliceText`, insert and remove, split and merge), and text entity plugins record a grow as a move and a remake as a retype. Text only `setTextContent` or the DOM changed is diffed. The binding adds no per-case heuristics. | No binding can recover intent an API dropped; special-case rules were fragile and regressed neighbouring cases. | `DECIDED` | 10-27 | [#9483], [y.md: Edits made by code][y-code] | | D29 | **Remade text keeps its common start and end by character**, whatever the node boundaries (y-prosemirror's `updateYText` rule), **and between two changes every run of unchanged whole words** (the longest common run of words, by a linear-space shortest edit script over the words both texts have, up to 2,048 edits, words only added or only removed between a shared start and end taking none, linear in the line's length; past it, only the start and end; where a word repeats next to a change, which occurrence is kept follows that edit script (`aa bb` made again as `aa aa bb` keeps the first `aa`); a word the update moved is written again; a word changed only in part is written whole, so a collaborator's undone delete of its unchanged part shows twice, [#9635]), and never brings back a collaborator's delete. It replaces the word matching of [#9565]. | It is the industry-standard behaviour, and word matching glued words together and merged numbers oddly. Keeping only the start and end wrote everything between two changed words again, so a collaborator's delete there came back, and showed twice after the collaborator's undo ([#9621]). | `DECIDED` | 10-30 | [#9565], [#9598], [#9621] | -| D30 | **Diffed rewrites snap to whole words**; keystrokes and IME composition keep the exact diff. Scripts without spaces treat each character as a boundary. **A diffed insertion or deletion without a caret that the diff would put inside a word goes to the first place in the run of equal text it could be in where both its ends are at word edges** (a word deleted with the separator before it); a caret's placement, and one already at word edges, stay. | Concurrent rewrites otherwise split words. Real word segmentation is a follow-up ([#9467]). Without a caret a diff takes the common start first and put deletions inside words, so a collaborator's concurrent delete of the same word took a letter of the next one ([#9636]); the first edge keeps a word typed after a deleted word apart from the next one, as typing goes (a space, then a word). | `DECIDED` | 10-30 | [#9400], [#9467], [#9636] | +| D30 | **Diffed rewrites snap to whole words**; keystrokes and IME composition keep the exact diff. Scripts without spaces treat each character as a boundary. **A diffed insertion or deletion that no caret places (none, or one at the end of the node, past the edit or in a later node) and that the diff would put inside a word goes to the first place in the run of equal text it could be in where both its ends are at word edges** (a word deleted with the separator before it); a caret's placement, and one already at word edges, stay. | Concurrent rewrites otherwise split words. Real word segmentation is a follow-up ([#9467]). Without a caret a diff takes the common start first and put deletions inside words, so a collaborator's concurrent delete of the same word took a letter of the next one ([#9636]); the first edge keeps a word typed after a deleted word apart from the next one, as typing goes (a space, then a word). | `DECIDED` | 10-30 | [#9400], [#9467], [#9636] | | D31 | **A list item's nested list gets an item of its own.** `NodeStructureExtension`'s list rule prevents an item holding a nested list next to other content when it is written; the binding does not repair it afterwards. | That shape lost text in outdents, Backspaces and offline merges. Preventing it at write time follows D20. | `DECIDED` | 10-26 | [#9452], [y.md: Limits][limits] | | D32 | **List merging ignores `start`.** | Existing Lexical behaviour, intended. | `DECIDED` | 10-23 | [#9452] | | D70 | **No virtual structure.** The editor does not show repairs that are not stored (padded cells for a short table row, two stored lists drawn as one). The one existing case stays: text left without a line marker shows as a paragraph. | It adds another structure on top of Yjs and the Lexical tree, and the complexity that comes with it. | `DECIDED` | 10-25 | [#9400] | diff --git a/packages/lexical-y/src/__tests__/unit/regressions/SetTextWordEdges.test.ts b/packages/lexical-y/src/__tests__/unit/regressions/SetTextWordEdges.test.ts index bb4fd3ce70d..3ab1ecff3a6 100644 --- a/packages/lexical-y/src/__tests__/unit/regressions/SetTextWordEdges.test.ts +++ b/packages/lexical-y/src/__tests__/unit/regressions/SetTextWordEdges.test.ts @@ -223,12 +223,25 @@ describe('#9636: a caret past the edit', () => { suffix: 4, }); }); + // A caret in the run, the furthest place included, still places it. + for (const [a, b, cursor, want] of [ + ['t1z t2z t3z', 't1z t3z', 5, {prefix: 5, suffix: 2}], + ['ab ab', 'ab abz ab', 9, {prefix: 5, suffix: 0}], + ] as const) { + test(`a caret in the run places it: ${JSON.stringify(a)} set to ${JSON.stringify(b)} with the caret at ${cursor}`, () => { + expect(setTextEnds(a, b, cursor, false)).toEqual(want); + }); + } for (const ids of IDS) { for (const record of [true, false]) { const how = record ? 'recorded' : 'diffed by the writer'; - for (const [peer, want] of [ - ['deletes t2z', 't1z t3z'], - ['types " q9z" after t2z', 't1z q9z t3z'], + // Typed right before `t2z`, the peer's word is glued to the word + // before it, as y.md documents for a set without a caret (it was + // `t1z q9z t3z` while a caret at the end pinned the diff). + for (const [peer, splice, want] of [ + ['deletes t2z', [4, 3, ''], 't1z t3z'], + ['types " q9z" after t2z', [7, 0, ' q9z'], 't1z q9z t3z'], + ['types "q9z " before t2z', [4, 0, 'q9z '], 't1zq9z t3z'], ] as const) { for (const caret of ['at the end', 'in the next node'] as const) { test(`set with the caret ${caret} ‖ the peer ${peer}, ${how} (ids ${ids})`, async () => { @@ -256,11 +269,7 @@ describe('#9636: a caret past the edit', () => { ($node().getNextSibling() as TextNode).select(1, 1); } }); - t.b.update( - peer === 'deletes t2z' - ? $splice(4, 3, '') - : $splice(7, 0, ' q9z'), - ); + t.b.update($splice(splice[0], splice[1], splice[2])); await t.sync(); const end = caret === 'in the next node' ? '!' : ''; const fresh = await freshReader(t.a); diff --git a/packages/lexical-y/src/internal/Inline.ts b/packages/lexical-y/src/internal/Inline.ts index e4d302f6ff0..0c1d46fbce5 100644 --- a/packages/lexical-y/src/internal/Inline.ts +++ b/packages/lexical-y/src/internal/Inline.ts @@ -771,9 +771,9 @@ function placeAtWordEdge( * Whether the caret at `cursor` of `b` placed the insertion or deletion that * makes `b` of `a`: it anchors the diff (caretAnchors) and its bound is in * the run of equal text the edit could be in, the furthest place included. - * A caret at the end of the node or past the edit (and the end of the node, - * which the writer passes for a caret in a later node) anchors every place - * of the run and places nothing. + * A caret past the furthest place (at the end of the node or past the edit, + * and the end of the node, which the writer passes for a caret in a later + * node) anchors every place of the run and places nothing. */ function caretPlaces(a: string, b: string, cursor: number): boolean { return (