Repository navigation
[lexical-website][lexical-y] Bug Fix: Place an insertion or a deletion of text set without a caret at a word edge - #9641
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
AI review status for this pull request.
|
lexical
@lexical/a11y
@lexical/clipboard
@lexical/code
@lexical/code-core
@lexical/code-prism
@lexical/code-shiki
@lexical/compiler
@lexical/devtools-core
@lexical/dragon
@lexical/eslint-plugin
@lexical/extension
@lexical/fast-check
@lexical/file
@lexical/hashtag
@lexical/headless
@lexical/history
@lexical/html
@lexical/internal
@lexical/link
@lexical/list
@lexical/mark
@lexical/markdown
@lexical/mdast
@lexical/offset
@lexical/overflow
@lexical/plain-text
@lexical/react
@lexical/rich-text
@lexical/selection
@lexical/table
@lexical/tailwind
@lexical/text
@lexical/utils
@lexical/y
@lexical/yjs
commit: |
There was a problem hiding this comment.
Thanks for tracking this down. The caret-less placement fixes #9636 as described: the new SetTextWordEdges test fails on main and passes here, and every client and a fresh reader agree.
Requested change
Caret-anchored edits are re-placed too (packages/lexical-y/src/internal/Inline.ts, setTextEnds line 769, with placeAtWordEdge lines 726-734). caretPlaced is true only when the caret stops the common start short. When the caret sits at the far end of a run of equal characters (typing . after word., a second space, a after a), diff.prefix === hi, so the edit slides to the first word edge although a caret placed it. Text the browser inserts natively reaches the binding this way: Lexical reads it back with setTextContent and the caret after the new character. With the repository's own test harness, A sets the text with the caret after its typed character while B edits concurrently:
| A's edit (caret) ‖ B's concurrent edit | main |
this PR |
|---|---|---|
word. → word.. (caret 6) ‖ B types s after word |
words.. |
word.s. (ids 1,2) |
1.. → 1. (caret 2) ‖ B types x between the dots |
1.x |
1x. (both id orders) |
a → aa (caret 2) ‖ B types b at the line start |
baa |
aba (ids 1,2) |
40 ., - or spaces typed after a word, one key per update |
+63 bytes stored | +570 bytes stored |
Each repeated mark is inserted in front of the previous one, so Yjs cannot merge the keystrokes. This goes beyond the changeset's "text set without a caret" and the description's "unless the caret placed it, which keeps priority as before". A possible fix is to skip placeAtWordEdge in setTextEnds when the caret anchors the diff, using diffStrings' own condition (cursor >= 0 && cursor <= b.length && cursor - delta >= 0 && b.substring(cursor) === a.substring(cursor - delta)). With that condition these cases match main and the 22 new tests still pass. A regression cell that keeps a collapsed caret, rather than calling $setSelection(null), would guard it.
Optional, non-blocking
packages/lexical-y/AGENTS.mdasks for accepted limits to also be listed in y.md's Limits section. The new glue limit is described only in the Edits made by code bullet and in D30.packages/lexical-y/DECISIONS.mdD30: "a diff takes the common start first and put deletions inside words" should read "puts".- The property test draws only ASCII
t<n>zwords with single spaces.packages/lexical-y/AGENTS.mdasks tests to cover the input space, and punctuation runs, repeated spaces and surrogate pairs are casesplaceAtWordEdgetreats specially.
| diff.prefix + diff.suffix === Math.min(a.length, b.length) && | ||
| a.length !== b.length | ||
| ) { | ||
| return composing ? diff : (placeAtWordEdge(a, b, diff, true) ?? diff); |
There was a problem hiding this comment.
This also re-places edits a caret anchored. placeAtWordEdge treats the caret as placing the edit only when diff.prefix < hi (line 726). A caret at the far end of a run of equal characters gives diff.prefix === hi, which is what natively typed text looks like (word. → word.. with the caret at 6), so the edit moves to the first word edge. In the harness a concurrent s typed after word then lands as word.s. instead of words.., and 40 typed . store +570 bytes instead of +63. Skipping placeAtWordEdge when the caret anchors the diff (the diffStrings condition: cursor >= 0 && cursor <= b.length && cursor - delta >= 0 && b.substring(cursor) === a.substring(cursor - delta)) restores main's placement for these cases and keeps the 22 new tests passing.
There was a problem hiding this comment.
Fixed in 862ecca (first in e6e85c6): setTextEnds now asks whether the caret anchors the edit, with the diffStrings condition itself (shared as caretAnchors), and keeps the caret's placement whenever it does; an edit moves only when no caret anchors it and the diff puts it inside a word. On 862ecca your cases match main: word. → word.. (caret 6) ‖ s after word gives words.., 1.. → 1. (caret 2) ‖ x between the dots gives 1.x, a → aa (caret 2) ‖ b at the start gives baa, in both id orders, and 40 . typed one per update store +63 bytes, as on main. The "a caret keeps placing the edit" cells in SetTextWordEdges.test.ts pin it.
| t.offline(); | ||
| const $set = () => { | ||
| $node().setTextContent(set); | ||
| $setSelection(null); |
There was a problem hiding this comment.
Optional: every cell clears the selection here (and at line 193), so the caret path that setTextEnds also changes has no coverage. A cell that keeps a collapsed caret at the end of a run of equal characters (1.. → 1. with the caret at 2, while the peer types x between the dots, expecting 1.x) would guard it.
There was a problem hiding this comment.
The "a caret keeps placing the edit" cells added in 862ecca cover this path: a collapsed caret at the far end of a run of equal characters (a b → a b with the caret at 4) while the peer types between the old characters, expecting a Q b, both recorded and through the writer's own diff, in both id orders, plus setTextEnds against the caret-anchored diff for spaces, repeated letters, emoji, Han and punctuation with the caret at the far end. Your 1.. → 1. case also gives 1.x on 862ecca (checked in both id orders); I will add it as a cell with the next change to this branch rather than push only for it.
…n of text set without a caret at a word edge Text set whole without a caret (`setTextContent` from code, a node's text set again) is diffed against the old text. For an insertion or a deletion, the diff took the common start first, so where equal letters allowed it put the edit inside words: `t1z t2z t3z` set to `t1z t3z` deleted `2z t`. A collaborator's concurrent delete of `t2z` then left `t1z 3z` on every client (a letter of the untouched `t3z` lost), and a word they typed after `t2z` split `t3z` (`t1z t q9z3z`). An insertion or a deletion of set text now goes to the first word edge of the run of equal text it could be in (where it changes no word it does not add or remove), unless the caret placed it, which keeps priority as before. The first edge deletes a word with the space before it (` t2z`), so a word a collaborator types after it at the same time, as typing goes (a space, then the word), keeps the space after it and stays a word of its own. The placement is the one text made again already used (#9598), shared now as `placeAtWordEdge`; set text keeps every other character as before (no whole-word widening), and replacements still snap to whole words (D30). What remains is documented in y.md: a word a collaborator types right against the deleted word on the side of the space deleted with it (before it; after a line's first word, which has no space before it) is glued to the word beyond. D30 records the placement. Closes #9636 `regressions/SetTextWordEdges.test.ts`: fixed cases (both delete `t2z`; the collaborator types ` q9z` after `t2z`; both delete the third of four words; the set adds a word while the collaborator deletes the first), concurrent and unread, both id orders; and a property over random lines of words that share letters, set with one word deleted or inserted while a collaborator deletes a word or types one after or before a word, 150 seeds per cell: every word whole, deleted words stay deleted, added words once, every client and a fresh reader agree; the documented glue counted apart. On main (551fe4a): ``` $ vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/SetTextWordEdges.test.ts × both delete t2z, concurrent (ids 1,2) × the peer types " q9z" after t2z, concurrent (ids 1,2) × both delete t3z of four, concurrent (ids 1,2) ... (12 fixed cells) × concurrent: one word deleted or inserted by setTextContent ‖ the peer deletes a word × concurrent: one word deleted or inserted by setTextContent ‖ the peer type afters a word × concurrent: one word deleted or inserted by setTextContent ‖ the peer type befores a word ... (6 property cells: 69 sessions fail on delete, 27 on typing after, 38 on typing before, of 300 each) Tests 18 failed | 4 passed (22) AssertionError: expected [ 't1z 3z', 't1z 3z', 't1z 3z' ] to deeply equal [ 't1z t3z', 't1z t3z', 't1z t3z' ] ``` ``` $ LEXICAL_Y_VERBOSE=1 vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/SetTextWordEdges.test.ts Tests 22 passed (22) glued: delete 0 + 0, type after 5 + 3, type before 11 + 16 (of 150 per cell; documented) $ pnpm run ci-check EXIT 0 $ vitest --run --project unit --project scripts-unit Test Files 637 passed | 4 skipped (641) Tests 27180 passed | 166 expected fail | 135 skipped (27481) $ MATRIX_FULL=1 vitest --run --project unit packages/lexical-y/src/__tests__/unit/matrix Tests 11 failed | 15639 passed | 5 expected fail | 128 skipped (15783) (the same 11 cells as main) $ FUZZ_SEED_START=1 FUZZ_SEEDS=3000 vitest --run --project unit packages/lexical-y/src/__tests__/unit/Fuzz.test.ts Tests 1 failed | 123 passed (124) (seed 2277: dup, as on main) $ FUZZ_SEED_START=50000 FUZZ_SEEDS=2000 vitest --run --project unit packages/lexical-y/src/__tests__/unit/Fuzz.test.ts Tests 7 failed | 77 passed (84) (seeds 50180, 50231, 50372, 51439, 51507, 51972, 51976, as on main) ``` A randomized session generator (remade lines with concurrent comments, formats, typing and deletes, undo; agents writing with `setTextContent`), 500 sessions of 5 rounds, this change vs main: lost words 0 vs 12, mangled words 0 vs 19, split attributes 0 vs 1; no class higher than on main.
09b721f to
e6e85c6
Compare
…here the diff puts it inside a word, only between word edges, and never past a caret ## Description Follow-up from review of 09b721f. Whether the caret placed an insertion or a deletion in set text was inferred from where the diff stopped its common start (before the furthest place it could reach). A caret at the rightmost place of the run of equal text stops nothing, so its placement was taken for a guess and the edit moved to the first word edge: typing a space after two spaces (`a b` to `a b`, caret at 4) inserted it before them, and a collaborator's concurrent `Q` between the old spaces showed as `a Q b` instead of `a Q b`. `setTextEnds` now asks whether the caret anchors the edit (the text after it is unchanged: the condition diffStrings already uses, now one function, `caretAnchors`), and keeps the diff's placement whenever it does; only an edit no caret anchors goes to the first word edge. Second, an edit moved from where the diff put it was accepted with only one of its ends at a word edge, so it could take a piece of a word that stays: `a ba c` set to `a c` deleted `a b` (keeping the `a` of `ba` as the first word), and a collaborator's concurrent delete of `ba` left ` c`; `one someone end` set to `one end` lost `one` the same way, and the insertion mirror (`a c` set to `a ba c` while a collaborator deletes the old `a`) left `a b c`. A moved edit of set text must now start and end at word edges of the longer text (` ba` here); where no place does, the diff's placement stays. Text made again (#9598, #9625) keeps its own rule. Third, an edit already at word edges where the diff put it was moved too, to the first edge: an edit and a set in one update are composed without a caret, so a space added to two spaces (`Xa b` to `Xa b`) went before them, and a collaborator's `Q` between the old spaces showed after them. An edit now moves only where the diff puts it inside a word; everything else keeps main's placement. y.md and D30 say so, and that the glue left after a word deleted next to concurrent typing happens with any separator (a space, a tab, a comma, an emoji), as on main on the other side. ## Test plan New cells in `SetTextWordEdges.test.ts`: `setTextEnds` with a valid caret equals the caret-anchored diff (spaces, repeated letters, emoji, Han, punctuation, a word); a space typed after two spaces with the caret while a collaborator types between them, recorded and diffed by the writer, both id orders; a word set away (or added) next to a word with the same ending while a collaborator deletes one of them (`a ba c`, `one someone end`, `x ax ab`, and the insertions), both id orders; a letter typed and the text set in one update with a space added to two spaces while a collaborator types between them. Rebased on main with #9625. ### Before On 09b721f: ``` $ vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/SetTextWordEdges.test.ts -t caret × "a b" set to "a b" with the caret at 4 × "aaa" set to "aaaa" with the caret at 4 × "😀😀" set to "😀😀😀" with the caret at 6 × "哈哈" set to "哈哈哈" with the caret at 3 × "a...b" set to "a....b" with the caret at 5 × a space typed after two spaces ‖ the peer types between them, recorded (ids 1,2) × a space typed after two spaces ‖ the peer types between them, diffed by the writer (ids 1,2) × a space typed after two spaces ‖ the peer types between them, recorded (ids 2,1) × a space typed after two spaces ‖ the peer types between them, diffed by the writer (ids 2,1) Tests 9 failed | 1 passed | 34 skipped (44) $ vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/SetTextWordEdges.test.ts -t 'same ending' Tests 10 failed | 34 skipped (44) $ vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/SetTextWordEdges.test.ts -t 'letter typed' Tests 2 failed | 42 skipped (44) ``` On main (551fe4a) the same-ending cells fail 2 of 10 (`x ax ab` set to `x ab` while a collaborator deletes `ax` leaves `x b`, the original #9636 shape). ### After ``` $ vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/SetTextWordEdges.test.ts Tests 44 passed (44) $ pnpm run ci-check EXIT 0 $ vitest --run --project unit --project scripts-unit Test Files 644 passed | 4 skipped (648) Tests 27729 passed | 204 expected fail | 135 skipped (28068) $ MATRIX_FULL=1 vitest --run --project unit packages/lexical-y/src/__tests__/unit/matrix Tests 11 failed | 15998 passed | 5 expected fail | 128 skipped (16142) (the same 11 cells as main) $ FUZZ_SEED_START=1 FUZZ_SEEDS=3000 vitest --run --project unit packages/lexical-y/src/__tests__/unit/Fuzz.test.ts Tests 1 failed | 123 passed (124) (seed 2277: dup, as on main) $ FUZZ_SEED_START=50000 FUZZ_SEEDS=2000 vitest --run --project unit packages/lexical-y/src/__tests__/unit/Fuzz.test.ts Tests 7 failed | 77 passed (84) (seeds 50180, 50231, 50372, 51439, 51507, 51972, 51976, as on main)
e6e85c6 to
862ecca
Compare
There was a problem hiding this comment.
This looks good. Moving an insertion or deletion of set text that no caret places to word edges fixes the #9636 cases: t1z t2z t3z set to t1z t3z while a collaborator deletes t2z now converges to t1z t3z on every client, and q9z typed after t2z stays a word of its own. keptEnds keeps its behaviour through the shared placeAtWordEdge, and the regression test, the changeset with its Verify: paragraph, y.md and D30 all come with it. Everything below is optional and non-blocking.
1. A caret past the edit still turns the placement off (packages/lexical-y/src/internal/Inline.ts:794). caretAnchors only checks that the text after the caret is unchanged, which is always true with the caret at the end of the node or anywhere after the edit. So node.setTextContent('t1z t3z'); node.selectEnd(); while a collaborator deletes t2z still merges to t1z 3z, as on main (and q9z typed after t2z to t1z t q9z3z). In the writer's own diff, a caret in any later node is passed as cursor = newText.length (Write.ts:3139-3140), so it also happens with no caret in the node, which the changeset's Verify: paragraph excludes. Counting the caret as placing the edit only when its bound lies inside the run of equal text keeps the rightmost-place case your second commit fixed:
const placedByCaret =
caretAnchors(a, b, cursor) &&
cursor - Math.max(0, b.length - a.length) <= diffStrings(a, b, -1).prefix;With that in place of caretAnchors(a, b, cursor), the 44 cells of SetTextWordEdges.test.ts and the rest of the @lexical/y unit tests still pass. Since packages/lexical-y/AGENTS.md asks for a regression test with each fix, a cell that calls selectEnd() after setTextContent would pin it; the sets in the new file either clear the selection (for example line 101) or put the caret at the edit (line 163).
2. The property's glue exemption accepts both sides (SetTextWordEdges.test.ts:360). glued excuses a typed word stuck to either end of a neighbour, so the property would not notice the glue moving back to the after side. Limiting it to the side y.md documents (before the deleted word, or after it for a line's first word) would let it guard the choice D30 makes.
3. The new limit is not listed under y.md's Limits. packages/lexical-y/AGENTS.md asks that accepted limits also be documented in y.md's Limits, and the test comment at SetTextWordEdges.test.ts:278 says it is, but the glue case is described only in the Edits made by code section. A short Limits bullet, or a corrected comment, would line them up.
4. On the trade-off itself, no change requested. Text typed right before a word the set deletes now joins the previous word (q9z typed before t2z gives t1zq9z t3z, where main gave t1z q9z t3z). That is documented and is milder than main's result on the other side. D30's reason (keeping a word typed after the deleted one apart) only applies where the diff lands inside a word, though: the cat dog set to the dog still deletes cat and glues text typed after cat, as before.
5. Tokens with punctuation inside are still split, because word edges are letter and digit edges: price 3.50 3.75 set to price 3.75 while a collaborator deletes 3.50 gives price 75, as on main. This fits the word-segmentation follow-up D30 already names (#9467); naming numbers, versions and times there could help.
6. Nit: with first, placeAtWordEdge tests every position of a run with no word edge (one long word such as aaaa…), about 9 ms for 10,000 characters and 170 ms for 200,000. An early exit when the run holds no non-letter character would bound it.
| diff.prefix + diff.suffix === Math.min(a.length, b.length) && | ||
| a.length !== b.length | ||
| ) { | ||
| return composing || caretAnchors(a, b, cursor) |
There was a problem hiding this comment.
Optional: caretAnchors is true for any caret whose following text is unchanged, including one at the end of the node (cursor === b.length) or anywhere after the edit, so setTextContent('t1z t3z') followed by selectEnd() still merges with a concurrent delete of t2z as t1z 3z. Counting the caret only when its bound falls inside the run keeps the rightmost-place case and fixes this:
const placedByCaret =
caretAnchors(a, b, cursor) &&
cursor - Math.max(0, b.length - a.length) <= diffStrings(a, b, -1).prefix;| (victim === gone || | ||
| words[words.indexOf(gone) - 1] === victim || | ||
| words[words.indexOf(gone) + 1] === victim) && | ||
| (w.startsWith(typed) || w.endsWith(typed)) && |
There was a problem hiding this comment.
Optional: this excuses a typed word glued to either end of a neighbour, so the property would not notice the glue moving back to the after side. Limiting it to the side y.md documents (before the deleted word, or after it for a line's first word) would let the property guard D30's choice.
## Description Merges main at f59f74d (#9631, #9642 and #9641). There were no conflicts. ## Test plan ### Before The branch was on main 2e2acb7. ### After ``` $ pnpm exec vitest run --project unit --project scripts-unit Tests 30437 passed | 282 expected fail | 135 skipped (30854) $ MATRIX_FULL=1 pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/WrapperTextFields.test.ts Tests 9062 passed | 206 expected fail (9268) $ pnpm run ci-check Found 0 errors ```
…e caret is past it ## 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 (862ecca): ``` $ 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) ```
Description
Text set whole without a caret (
setTextContentfrom code, a node's text set again) is diffed against the old text. For an insertion or a deletion, the diff took the common start first, so where equal letters allowed it put the edit inside words:t1z t2z t3zset tot1z t3zdeleted2z t. A collaborator's concurrent delete oft2zthen leftt1z 3zon every client (a letter of the untouchedt3zlost), and a word they typed aftert2zsplitt3z(t1z t q9z3z).An insertion or a deletion of set text that no caret anchors (the text after the caret unchanged) and that the diff puts inside a word now moves to the first place of the run of equal text it could be in where both its ends are at word edges of the longer text (
t2zhere), so it neither splits a word nor takes or gives a piece of a word that stays. A caret's placement, and a diff's placement already at word edges, stay as on main. The first such place deletes a word with the separator before it, so a word a collaborator types after it at the same time, as typing goes (a space, then the word), keeps the separator after it and stays a word of its own. The placement helper is shared with text made again (#9598) asplaceAtWordEdge; set text keeps every other character as before (no whole-word widening), and replacements still snap to whole words (D30).What remains is documented in y.md: a word a collaborator types right against the deleted word on the side of the separator deleted with it (a space, a tab, a comma, an emoji; before it, or after a line's first word) is glued to the word beyond, as it is on main on the other side. D30 records the placement.
Closes #9636
Changeset
.changeset/lexical-y-set-text-word-edges.md(@lexical/ypatch,[fix], with aVerify:paragraph).Review follow-ups (second commit)
From review of 09b721f: a caret at the rightmost place of the run of equal text was taken for a guess (now the caret is asked directly,
caretAnchors); a moved edit was accepted with only one end at a word edge (a ba cset toa cdeleteda b; now both ends); an edit already at word edges was moved too (a space added to two spaces in a composed update; now only edits the diff puts inside a word move). Rebased on main with #9625 and #9631 (its expected failures still fail); text made again keeps #9625's rule (only set text uses the both-ends rule). #9642's expected failures also still fail on this head.Test plan
regressions/SetTextWordEdges.test.ts: fixed cases (both deletet2z; the collaborator typesq9zaftert2z; both delete the third of four words; the set adds a word while the collaborator deletes the first), concurrent and unread, both id orders; a caret keeps placing the edit (spaces, repeated letters, emoji, Han, punctuation; recorded and diffed by the writer; an edit and a set composed in one update); words with the same ending (a ba c,one someone end,x ax ab, insertions); and a property over random lines of words that share letters, set with one word deleted or inserted while a collaborator deletes a word or types one after or before a word, 150 seeds per cell: every word whole, deleted words stay deleted, added words once, every client and a fresh reader agree; the documented glue counted apart.Before
On main (551fe4a):
After
After the rebase on #9631:
vitest --run --project unit packages/lexical-y: 15043 passed | 204 expected fail | 130 skipped, 0 failed.A randomized session generator (remade lines with concurrent comments, formats, typing and deletes, undo; agents writing with
setTextContent), 500 sessions of 5 rounds, this change vs main: lost words 0 vs 12, mangled words 0 vs 19, split attributes 0 vs 1; no class higher than on main.An adversarial review and downstream gates are pending and will be posted here.