Repository navigation
[lexical-website][lexical-y] Documentation Update: Say which list conversions are moves, and add an undo race and a join race to the @lexical/y limits - #9569
Conversation
…versions are moves, and add an undo race and a join race to the @lexical/y limits
## Description
Three sentences in the Limits section of the `@lexical/y` docs did not match what the binding does.
- **Moves.** The docs listed "turning a paragraph into a list inside a container" as an operation that moves content. That holds only for a paragraph that holds text, in a container that is not a shadow root: there `$insertList` replaces the whole container with the list, and the container's content moves into it, so text a collaborator types in it at the same time is lost and two concurrent conversions show it twice. In a table cell or another shadow root, the paragraph becomes a list item in place and keeps the typing, as at the root. The bullet now says so.
- **Undo.** Undoing a block replacement (`replace`) after a collaborator edited the new block brings the old block's text back and keeps their edit next to it. Text they typed there, or the text of a block they put in its place, shows after the old text (twice, where they put the same text back). This happens with one undo and no concurrency, so the "both undo" sentence did not cover it. The Undo bullet now does.
- **Other races.** A join (Backspace at the start of a line, or undoing a split) while a collaborator inserts or moves a block to the place of the join joins the line before to that block instead: its text becomes part of the block, which keeps its own type (a paragraph joined into a code block becomes code), and the line after stays apart. This was not documented. Old main `0cd7448` behaves the same.
Each case gets a case in `Races.test.ts`, run in both client id orders with causal and per-sender delivery.
## Test plan
### Before
What the binding does, from probes on main (A converts, undoes or joins; B edits; then sync):
```
list conversion ‖ B types X at offset 1
CT root | before paragraph("abc") | final list[bullet](listitem("aXbc"))
CT table | before table(tablerow(tablecell(paragraph("abc")))) | final table(tablerow(tablecell(list[bullet](listitem("aXbc")))))
CT shadow | before test_col(test_col_title("t") test_col_content(paragraph("abc"))) | final test_col(test_col_title("t") test_col_content(list[bullet](listitem("aXbc"))))
CT box | before test_box[b](paragraph("abc")) | final list[bullet](listitem("abc"))
A replaces block 1 with an equal paragraph; B edits it; A undoes
UR B types into it | mid paragraph("top") / paragraph("oneX two") / paragraph("end") | final paragraph("top") / paragraph("one twoX") / paragraph("end")
UR B replaces again | mid paragraph("top") / paragraph("one two") / paragraph("end") | final paragraph("top") / paragraph("one twoone two") / paragraph("end")
B undoes a split (or joins) ‖ A moves or inserts a block at the join point
S810 undo split | before paragraph("t38z t") / paragraph("39z t40z") / paragraph("mid") / code("let x = 1") | final code("t38z tlet x = 1") / paragraph("39z t40z") / paragraph("mid")
S810 insert para ‖ join | before paragraph("t38z t") / paragraph("39z t40z") / paragraph("mid") / code("let x = 1") | final paragraph("t38z tnew para") / paragraph("39z t40z") / paragraph("mid") / code("let x = 1")
```
### After
```
$ vitest --run --project unit --reporter=verbose packages/lexical-y/src/__tests__/unit/Races.test.ts -t "list in|undo of a block replacement|inserted at a join"
✓ ... @lexical/y races, A=1 B=2 causal > list in a table cell ‖ type
✓ ... @lexical/y races, A=1 B=2 causal > list in a container that is not a shadow root ‖ type
✓ ... @lexical/y races, A=1 B=2 causal > undo of a block replacement after a collaborator edits the new block
✓ ... @lexical/y races, A=1 B=2 causal > block inserted at a join ‖ Backspace join
(the same four in A=2 B=1 causal, A=1 B=2 per-sender, A=2 B=1 per-sender)
Tests 20 passed | 68 skipped (88)
$ vitest --run --project unit packages/lexical-y/src/__tests__/unit/Races.test.ts
Tests 88 passed (88)
$ pnpm run ci-check
Changeset check passed: 31 pending changeset(s) valid.
Found 0 errors
```
|
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: |
… format keeps it after plain text a collaborator typed inside its range, seen or not
## Description
The Undo limit said that undoing a format change does not undo the value a collaborator's later change restored after itself, and that "the same holds for text a collaborator types or pastes inside the range as you undo". "As you undo" reads as concurrent typing only. It also happens when you saw that text before undoing: bold `one two`, a collaborator types plain ` mid` after `one`, you undo the bold, and ` two` stays bold, because the collaborator's write restored the bold after ` mid` and the undo removes only your own items. The limit now says so, with that example.
New `Races.test.ts` cases (AUDIT-U1, both client id orders): the concurrent case pins the documented outcome; the case where the undoing client had seen the collaborator's text is a `test.fails` asserting the format goes everywhere, since that client holds the collaborator's restore when it undoes and could end the value past it.
## Test plan
### Before
Bold `one two`, plain ` mid` typed after `one` by a collaborator, then the bold undone, on `main`, for typing seen before the undo and typing that arrives as the undo is made:
```
AUDIT live ids=1,2: A="one mid" " two"B "tail" | F="one mid" " two"B "tail" | fresh="one mid" " two"B "tail"
AUDIT concurrent ids=1,2: A="one mid" " two"B "tail" | F="one mid" " two"B "tail" | fresh="one mid" " two"B "tail"
AUDIT live ids=2,1: A="one mid" " two"B "tail" | F="one mid" " two"B "tail" | fresh="one mid" " two"B "tail"
AUDIT concurrent ids=2,1: A="one mid" " two"B "tail" | F="one mid" " two"B "tail" | fresh="one mid" " two"B "tail"
```
The seen case run as a plain test:
```
Expected: "paragraph("one mid two") / paragraph("tail")"
Received: "paragraph("one mid" " two"{1}) / paragraph("tail")"
```
### After
```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/Races.test.ts
Tests 90 passed | 2 expected fail (92)
$ prettier --check packages/lexical-website/docs/collaboration/y.md packages/lexical-y/src/__tests__/unit/Races.test.ts
All matched files use Prettier code style!
$ tsc -p tsconfig.test.json --noEmit
(exit 0)
```
… cells as a move, and say what undoing a wrap does after a collaborator typed in it
## Description
Two more `@lexical/y` limits that the docs did not name:
- Merging table cells moves the content of the cells merged away into the first cell. Like any move, it loses what a collaborator types in a merged-away cell at the same time. The Moves bullet now lists it.
- Undoing a move restores the block. When the move put the block into a container it made (wrapping a paragraph in a box) and a collaborator typed in it since, the container keeps the block whole, as an undo keeps any container a collaborator put content in. The restored block then shows next to it, so the text shows twice. The Moves bullet now says so.
Each gets a case in `Races.test.ts`.
## Test plan
### Before
Probes on main (both client id orders):
```
MERGE | A alone table(tablerow(tablecell(paragraph("left") paragraph("right")))) | final table(tablerow(tablecell(paragraph("left") paragraph("right")))) (B typed X after "right" in the cell merged away)
WRAP | wrapped paragraph("top") / test_box[b](paragraph("moved para")) / paragraph("end") | final paragraph("top") / test_box[b](paragraph("movedX para")) / paragraph("moved para") / paragraph("end")
```
### After
```
$ vitest --run --project unit packages/lexical-y/src/__tests__/unit/Races.test.ts
Tests 98 passed | 2 expected fail (100)
✓ @lexical/y races, A=1 B=2 causal > merge cells ‖ type in the cell merged away
✓ @lexical/y races, A=1 B=2 causal > undo of a box wrap after a collaborator types in the wrapped block
(the same two in A=2 B=1 causal, A=1 B=2 per-sender, A=2 B=1 per-sender)
$ pnpm run ci-check
Changeset check passed: 31 pending changeset(s) valid.
Found 0 errors
```
…d inside or at the end of a range formatted at the same time takes the format
## Description
The Other races bullet said only that text typed at the start of a range a collaborator formats at the same time can take that format. Text typed inside the range always takes it, and text typed at the end of the range can take it too, by the order Yjs gives the two inserts. The same holds when the range comes from an older read, as an agent's format does. The bullet now says so, and a `Races.test.ts` case pins the inside case.
## Test plan
### Before
Probe on main: A bolds `bbb ccc` in `aaa bbb ccc ddd` while B types `X` (both client id orders):
```
INS 1,2,3 at4 | aaa |Xbbb ccc[b]| ddd
INS 2,1,3 at4 | aaa X|bbb ccc[b]| ddd
INS 1,2,3 at6 | aaa |bbXb ccc[b]| ddd
INS 2,1,3 at6 | aaa |bbXb ccc[b]| ddd
INS 1,2,3 at11 | aaa |bbb ccc[b]|X ddd
INS 2,1,3 at11 | aaa |bbb cccX[b]| ddd
```
### After
```
$ vitest --run --project unit packages/lexical-y/src/__tests__/unit/Races.test.ts
Tests 102 passed | 2 expected fail (104)
$ pnpm run ci-check
Changeset check passed: 31 pending changeset(s) valid.
Found 0 errors
```
There was a problem hiding this comment.
Thanks for tightening the @lexical/y Limits section. The list-conversion clarification, merged cells, the wrap and replacement undos and the format-undo example all match what the binding does at the PR head, and each has a case in Races.test.ts. One sentence needs a fix, plus a few smaller suggestions.
1. Join race: "the line after stays as it was" does not hold when the two lines have different types (packages/lexical-website/docs/collaboration/y.md, lines 1019–1025)
After a Backspace join, the line after takes the type of the line before. Here is the new block inserted at a join ‖ Backspace join case with a heading before the paragraph; it passes in all four orders at the PR head:
{
a: () => $block([0]).insertAfter($p('New.')),
b: () => $selectIn([1], 0).deleteCharacter(true),
expected: 'paragraph("Revenue grew 4%New.") / heading[h1](" in Q3.")',
id: 'block inserted at a join ‖ Backspace join of a heading and a paragraph',
init: () =>
$getRoot().append(
$createHeadingNode('h1').append($createTextNode('Revenue grew 4%')),
$p(' in Q3.'),
),
}, in Q3. was a paragraph and ends up a heading. A paragraph before a heading turns the heading into a paragraph, and a code block moved to the join does the same (code("Titlelet x = 1") / heading[h1]("body")). That fits the page's own storage rule (How content is stored: a block that holds text is its text followed by a ¶ marker carrying its type): after the join the joined line ends at the line after's marker, and the join gives that marker the line before's type. Undoing a split keeps the line after's type, so only the Backspace form is affected. Suggested wording: "…, and the line after stays apart; after a Backspace it takes the type of the line before (a paragraph after a heading becomes a heading)." Adding the case above would pin it; the current case joins two paragraphs, so it cannot see either type claim.
2. Name the replace form the undo sentence covers (y.md, lines 957–961)
replace(node, true), which $setBlocksType calls, keeps the children and undoes without doubling anything: turn Revenue grew 4% in Q3. into a heading with $setBlocksType, let a collaborator type by , undo, and the result is paragraph("Revenue grew by 4% in Q3."). Wording such as "replacing a block with a new one (replace without includeChildren, …)" keeps readers who change block types that way from expecting doubled text.
3. Also assert the seen case of AUDIT-U1 (packages/lexical-y/src/__tests__/unit/Races.test.ts, line 491)
The test.fails passes for any outcome other than paragraph("one mid two"), a throw during setup included, so the documented " two stays bold" is not asserted for the seen case. It currently gives paragraph("one mid" " two"{1}) / paragraph("tail"), as the docs say; a regular test asserting that next to the test.fails keeps the limit pinned and still flags the fix.
4. Test plan. AGENTS.md (Commits and Pull Requests) asks for the real After output of the command run. The pasted run (Tests 88 passed (88)) predates the last three commits; at the head the file runs 104 tests (102 passed, 2 expected fail), including the merge-cells, box-wrap, format-inside and AUDIT-U1 cases.
No changeset is needed for website and test changes (CONTRIBUTING.md, Changesets).
| first half the splitter's type. A join (Backspace at the start of a line, | ||
| or undoing a split) while a collaborator inserts or moves a block to the | ||
| place of the join joins the line before to that block instead: its text | ||
| becomes part of the block, which keeps its own type (a paragraph joined | ||
| into a code block that way becomes code), and the line after stays as it | ||
| was. Bold ‖ bold on adjacent ranges of one line |
There was a problem hiding this comment.
After a Backspace join of lines of different types the line after takes the type of the line before, so "stays as it was" holds only when both lines share a type. A heading Revenue grew 4% followed by a paragraph in Q3., joined while a collaborator inserts paragraph New. at the join, ends paragraph("Revenue grew 4%New.") / heading[h1](" in Q3.") in all four orders; undoing a split keeps the line after's type. Suggested: "…, and the line after stays apart; after a Backspace it takes the type of the line before (a paragraph after a heading becomes a heading)."
There was a problem hiding this comment.
Fixed in 21194a3. The bullet now says the line after stays apart: 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. Your case is in Races.test.ts (block inserted at a join ‖ Backspace join of a heading and a paragraph, paragraph("Revenue grew 4%New.") / heading[h1](" in Q3.") in all four orders), with a second one for the undo form where a collaborator made the line after a heading (block inserted at a join ‖ undo of a split, the line after a heading: paragraph("Revenue grew 4%New.") / heading[h2](" in Q3.")).
| Undoing a block replacement (`replace`, written as a removal and a new | ||
| block) after a collaborator edited the new block brings the old block's |
There was a problem hiding this comment.
replace(node, true) (what $setBlocksType calls) keeps the children and undoes without doubling: a heading made with $setBlocksType, typed in by a collaborator and undone, gives paragraph("Revenue grew by 4% in Q3."). Consider naming the form: "replacing a block with a new one (replace without includeChildren, …)".
There was a problem hiding this comment.
Done in 21194a3: the sentence names replace without includeChildren, and adds that replace(node, true), which $setBlocksType uses, keeps the block's children, so undoing it keeps the collaborator's text once. New case undo of $setBlocksType after a collaborator edits the block asserts paragraph("Revenue grew by 4% in Q3.") in all four orders.
| 'paragraph("one mid" " two"{1}) / paragraph("tail")', | ||
| ); | ||
| }); | ||
| test.fails( |
There was a problem hiding this comment.
This passes for any outcome other than paragraph("one mid two"), so the documented " two stays bold" isn't asserted for the seen case. It currently gives paragraph("one mid" " two"{1}) / paragraph("tail"); a regular test asserting that, next to this one, would pin the limit while this one still flags the fix.
There was a problem hiding this comment.
Added in 21194a3: AUDIT-U1 seen before the undo: the text after the typing keeps the format asserts paragraph("one mid" " two"{1}) / paragraph("tail") for both id orders, next to the test.fails, which still flags the fix.
… line after a join race takes, and which replace the undo limit covers
## Description
Two sentences of the `@lexical/y` Limits section were too broad.
- **Other races, join.** After a join raced by a block inserted or moved to the place of the join, the docs said the line after "stays as it was". It stays apart, but after a Backspace it takes the type of the line before: a heading `Revenue grew 4%` followed by a paragraph ` in Q3.`, joined while a collaborator inserts the paragraph `New.` at the join, ends `paragraph("Revenue grew 4%New.") / heading[h1](" in Q3.")`. After undoing a split it keeps its own type. The bullet now says both, and `Races.test.ts` has a case for each with lines of different types (the existing case joined two paragraphs, so it could see neither).
- **Undo, block replacement.** The sentence about undoing a block replacement covers `replace` without `includeChildren`. `replace(node, true)`, which `$setBlocksType` uses, keeps the block's children, and undoing it after a collaborator typed in the block keeps their text once (`paragraph("Revenue grew by 4% in Q3.")`). The bullet now names the form and says what the other one does, with a case in `Races.test.ts`.
The seen case of the format undo (AUDIT-U1) was only covered by a `test.fails`, which passes for any outcome but the fix. A regular test next to it now asserts the documented result, `paragraph("one mid" " two"{1}) / paragraph("tail")`.
## Test plan
```
pnpm exec vitest run --project unit --reporter=verbose packages/lexical-y/src/__tests__/unit/Races.test.ts -t "heading and a paragraph|undo of a split, the line after|setBlocksType|AUDIT-U1 seen"
pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/Races.test.ts
pnpm run ci-check
```
### Before
```
$ vitest --run --project unit packages/lexical-y/src/__tests__/unit/Races.test.ts
Tests 102 passed | 2 expected fail (104)
```
### After
```
$ vitest --run --project unit --reporter=verbose packages/lexical-y/src/__tests__/unit/Races.test.ts -t "heading and a paragraph|undo of a split, the line after|setBlocksType|AUDIT-U1 seen"
✓ ... @lexical/y races, A=1 B=2 causal > undo of $setBlocksType after a collaborator edits the block
✓ ... @lexical/y races, A=1 B=2 causal > block inserted at a join ‖ Backspace join of a heading and a paragraph
✓ ... @lexical/y races, A=1 B=2 causal > block inserted at a join ‖ undo of a split, the line after a heading
(the same three in A=2 B=1 causal, A=1 B=2 per-sender, A=2 B=1 per-sender)
✓ ... undo of a format ‖ plain text typed inside its range > AUDIT-U1 seen before the undo: the text after the typing keeps the format (1,2,3)
✓ ... undo of a format ‖ plain text typed inside its range > AUDIT-U1 seen before the undo: the undo removes the format everywhere (1,2,3)
✓ ... undo of a format ‖ plain text typed inside its range > AUDIT-U1 seen before the undo: the text after the typing keeps the format (2,1,3)
✓ ... undo of a format ‖ plain text typed inside its range > AUDIT-U1 seen before the undo: the undo removes the format everywhere (2,1,3)
Tests 14 passed | 2 expected fail | 102 skipped (118)
$ vitest --run --project unit packages/lexical-y/src/__tests__/unit/Races.test.ts
Tests 116 passed | 2 expected fail (118)
$ pnpm run ci-check
Found 0 errors
```
There was a problem hiding this comment.
Thanks for the follow-up. The join-race sentence now says the line after takes the line before's type after a Backspace and keeps its own after undoing a split, the undo sentence names replace without includeChildren (with the $setBlocksType case beside it), and AUDIT-U1's seen case is asserted next to its test.fails. Each new sentence in the Limits section matches what @lexical/y does at the PR head, in both client-id orders with causal and per-sender delivery. Two optional suggestions:
1. The line after also takes the line before's alignment and indent (packages/lexical-website/docs/collaboration/y.md, lines 1026–1028)
After a Backspace, the line after takes the line before's whole block marker, not only its type. With a centered, indented heading before the paragraph, the paragraph becomes a centered, indented heading:
heading[h1, center, indent 1]("Revenue grew 4%") / paragraph(" in Q3.")
A inserts paragraph("New.") after line 1 ‖ B: Backspace at the start of line 2
→ paragraph("Revenue grew 4%New.") / heading[h1, center, indent 1](" in Q3.")
A forward Delete at the end of line 1 gives the same result as the Backspace. That fits the page's own storage rule (How content is stored: the ¶ marker carries the block's type and fields). Suggested wording: "after a Backspace or Delete it takes the type, alignment and indent of the line before (a paragraph after a centered heading becomes a centered heading)".
2. Pin the two outcomes that have no case yet (packages/lexical-y/src/__tests__/unit/Races.test.ts)
Every other new sentence has a Documented (…) case. The move form of the join race ("a paragraph joined into a code block that way becomes code") holds today without one; this case passes in all four orders at the head (it needs $createCodeNode from @lexical/code):
{
a: () => $block([0]).insertAfter($block([3])),
b: () => $selectIn([1], 0).deleteCharacter(true),
expected:
'code("Titlelet x = 1") / heading[h1]("body") / paragraph("mid")',
// Documented (Other races): the line before joins the moved block,
// which keeps its own type.
id: 'block moved to a join ‖ Backspace join',
init: () =>
$getRoot().append(
$createHeadingNode('h1').append($createTextNode('Title')),
$p('body'),
$p('mid'),
$createCodeNode().append($createTextNode('let x = 1')),
),
},The same goes for "twice, where they put the same text back" (lines 960–962): replace one two with an equal paragraph, let B replace it again, undo, and the line reads one twoone two. Cases for both keep those sentences from drifting when the binding changes.
No changeset is needed for website and test changes (CONTRIBUTING.md, Changesets).
| place of the join joins the line before to that block instead: its text | ||
| becomes part of the block, which keeps its own type (a paragraph joined | ||
| into a code block that way becomes code), and the line after stays apart: | ||
| after a Backspace it takes the type of the line before (a paragraph after |
There was a problem hiding this comment.
Nit: the line after takes the line before's alignment and indent too, not only its type. With a centered, indented h1 before the paragraph, the race ends paragraph("Revenue grew 4%New.") / heading[h1, center, indent 1](" in Q3.") in all four orders, and a forward Delete at the end of line 1 does the same as the Backspace. Suggested: "after a Backspace or Delete it takes the type, alignment and indent of the line before (a paragraph after a centered heading becomes a centered heading)".
| expected: 'paragraph("Revenue grew 4%New.") / paragraph(" in Q3.")', | ||
| // Documented (Other races): the line before joins the inserted block, | ||
| // and the line after stays apart. | ||
| id: 'block inserted at a join ‖ Backspace join', |
There was a problem hiding this comment.
Consider a fourth join case for the move form the docs describe ("a paragraph joined into a code block that way becomes code"): all three join cases here insert a new block. On heading("Title") / paragraph("body") / paragraph("mid") / code("let x = 1"), A's $block([0]).insertAfter($block([3])) ‖ B's Backspace at the start of body ends code("Titlelet x = 1") / heading[h1]("body") / paragraph("mid") in all four orders today.
… or format can run on past its end when writes inside it are undone while another client deletes across its end ## Description A rare race is now a documented limit of `@lexical/y` (#9575). Writes inside a comment or a format are undone (two collaborators' writes; with #9562, also one client's two writes) while another client, which has not seen the undos, deletes text across the comment's end. Each write ended the run before its text and restored it after, leaving a null of its writer right after the run's end. The delete collapses the deleted content there, keeping those nulls but not the run's own end, and the undos then remove them, so the run carries on over the following words and into the next line. No text is lost. The Undo bullet of the Limits section now says so. `Races.test.ts` gets #9575's repro (four clients, both id orders) as a regular test asserting today's outcome and a `test.fails` asserting the fix, which passes once #9575 is fixed. Checked on main for a comment (`aa |Kilome cc dd[c1]` / `tail line[c1]`) and the same steps with bold instead of a comment (`aa |Kilome cc dd[b]` / `tail line[b]`), and on #9562's head for one client's two writes (its S9-12b: `aa Kilome[c1] cc[c1] dd[c1]` / `tail line[c1]`). ## Test plan ``` pnpm exec vitest run --project unit --reporter=verbose packages/lexical-y/src/__tests__/unit/Races.test.ts -t 9575 pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/Races.test.ts pnpm run ci-check ``` ### Before ``` $ vitest --run --project unit packages/lexical-y/src/__tests__/unit/Races.test.ts Tests 116 passed | 2 expected fail (118) ``` ### After ``` $ vitest --run --project unit --reporter=verbose packages/lexical-y/src/__tests__/unit/Races.test.ts -t 9575 ✓ ... undo of writes inside a comment ‖ delete across its end > #9575 as documented: the comment runs on over the next words and line (1,2,3,4) ✓ ... undo of writes inside a comment ‖ delete across its end > #9575: the comment ends where it ended (1,2,3,4) ✓ ... undo of writes inside a comment ‖ delete across its end > #9575 as documented: the comment runs on over the next words and line (4,3,2,1) ✓ ... undo of writes inside a comment ‖ delete across its end > #9575: the comment ends where it ended (4,3,2,1) Tests 2 passed | 2 expected fail | 118 skipped (122) $ vitest --run --project unit packages/lexical-y/src/__tests__/unit/Races.test.ts Tests 118 passed | 4 expected fail (122) $ pnpm run ci-check Found 0 errors ```
…rator's format starting where a run ends can be lost when another client writes inside the run ## Description Another rare race is now a documented limit of `@lexical/y`. A collaborator applies a format to text starting right where a run of that format ends (bolds ` bb` right after the bold `Kilo`) while another client, at the same time, removes the format from part of the run (unbolds `il`) or types inside it without the format. The writing client puts a null right after the run's end, and the collaborator opens its format right after the same end; Yjs orders the two by client id, and with some orders the null lands between the collaborator's opener and its text, so the text stays without the format on every peer. No text is lost. The Undo bullet of the Limits section now says so, next to the sentence about a comment or format running on. Checked with the steps of the review on #9562, ids `[1, 2]` and `[2, 1]` (A = first id, B = second): | | main | #9562 head `83df52d` | |---|---|---| | A bolds `Kilo`, then unbolds `il`; B bolds ` bb` | ` bb` bold in both orders | ` bb` plain with `[2, 1]` | | A bolds `Kilo`, then types plain `X` inside it; B bolds ` bb` | ` bb` bold in both orders | ` bb` plain with `[2, 1]` | | B bolds `Kilo`; A unbolds `il` while B bolds ` bb` | ` bb` plain with `[2, 1]` | (not run) | | B bolds `Kilo`; A types plain `X` inside it while B bolds ` bb` | ` bb` plain with `[2, 1]` | ` bb` plain with `[2, 1]` | So main loses the format when the client writing inside the run is not the one that formatted it, and #9562 also when it is; the pinned test for the #9562 case lives with #9562. ## Test plan ``` pnpm run ci-check ``` ### Before ``` $ pnpm run ci-check Found 0 errors ``` ### After ``` $ pnpm run ci-check Changeset check passed: 31 pending changeset(s) valid. Found 0 errors ```
There was a problem hiding this comment.
This looks good. The new Limits sentences in packages/lexical-website/docs/collaboration/y.md match what @lexical/y does at the PR head (the list conversion inside a container, merged cells, the wrap and replacement undos, the format undo, the join race, typing in a range formatted at the same time, and the #9575 run-on, which reproduces for a comment and for bold), and the new cases in Races.test.ts assert the document every peer and a fresh reader converge on. A few optional, non-blocking suggestions:
1. The one-client trigger for the run-on is ahead of main (packages/lexical-website/docs/collaboration/y.md, lines 979–980)
The sentence lists "(one client's two writes, or two clients' writes)". At the PR head, a single client typing X and Y inside the comment and undoing both, while D deletes ter and bb as in #9575, leaves the comment ending where it ended (aa |Kilome[c1]| cc dd / tail line), whether that client is the commenter or another one, for a comment and for bold, in both client id orders. The PR description also says this form needs #9562. Since the Merge order section says this PR documents main, consider "when several writes inside it, by more than one client, are undone", and letting #9562 add the one-client form when it lands.
2. The #9589 sentence fits Other races better than Undo (y.md, lines 982–987)
That race has no undo in it: an unbold or a plain write inside a run racing a collaborator's format that starts where the run ends. In the Limits list it is the sibling of "Bold ‖ bold on adjacent ranges of one line can leave part unformatted" under Other races, which is where someone whose collaborator's bold vanished would look.
3. Pin the #9589 limit the way #9575 is pinned (packages/lexical-y/src/__tests__/unit/Races.test.ts)
The PR description says each case gets a case in Races.test.ts; #9589 is the only new limit without one. Main shows it when another client bolded the run (B bolds Kilo, then A unbolds il while B bolds bb: with ids [2, 1, 3] bb ends plain on every peer, with [1, 2, 3] it keeps the bold). This passes today ($bold is the helper from #9589's repro; it needs $setSelection from lexical):
const $bold = (from: number, to: number) => () => {
const selection = $selectIn([0], from, to);
selection.format = (selection.anchor.getNode() as TextNode).getFormat();
selection.formatText('bold');
$setSelection(null);
};
for (const ids of [
[1, 2, 3],
[2, 1, 3],
]) {
test(`#9589 as documented: a collaborator's bold where its own run ends (${ids})`, async () => {
const t = await setup(
() => $getRoot().append($p('aa Kilo bb cc'), $p('tail line')),
{ids},
);
t.b.update($bold(3, 7)); // B bolds "Kilo"
await t.sync();
t.offline();
t.a.update($bold(4, 6)); // A unbolds "il"
t.b.update($bold(7, 10)); // B bolds " bb"
await t.sync();
await expectConverged(t.peers);
expect(t.a.describe()).toBe(
ids[0] === 2
? 'paragraph("aa " "K"{1} "il" "o"{1} " bb cc") / paragraph("tail line")'
: 'paragraph("aa " "K"{1} "il" "o bb"{1} " cc") / paragraph("tail line")',
);
});
}The #9575 block could also run once with bold over Kilometer, since the sentence says "a comment or format" and bold runs on the same way (aa |Kilome cc dd[b] / tail line[b]).
The earlier optional notes still stand: after a raced Backspace join the line after also takes the line before's alignment and indent (lines 1036–1037), and the code-block move form of the join race and the "twice, where they put the same text back" outcome have no case yet.
No changeset is needed for website and test changes (CONTRIBUTING.md, Changesets).
| format can run on past its end when writes inside it are undone (one | ||
| client's two writes, or two clients' writes) while another client deletes |
There was a problem hiding this comment.
Optional: the one-client form doesn't happen at this revision. With #9575's steps but one client typing X and Y inside the comment and undoing both (while D deletes ter and bb), the comment ends where it ended, aa |Kilome[c1]| cc dd / tail line, whether that client is the commenter or another one, for a comment and for bold, in both client id orders; the PR description also says this form needs #9562. Since this PR documents main, consider "when several writes inside it, by more than one client, are undone", and letting #9562 add the one-client form.
| into the next line. No text is lost. A format a collaborator applies | ||
| starting right where a run of that format ends can be lost on their text | ||
| when another client, at the same time, removes the format from part of | ||
| the run or types inside it without the format: with `Kilo` bold, one | ||
| client unbolds `il` as another bolds ` bb`, and with some client id | ||
| orders ` bb` stays plain. No text is lost. |
There was a problem hiding this comment.
Optional: this race has no undo in it (an unbold or a plain write inside the run racing a collaborator's format that starts where the run ends), so it fits Other races, next to "Bold ‖ bold on adjacent ranges of one line can leave part unformatted", better than the Undo bullet.
| expect(await run(ids)).toEqual(['aa |Kilome[c1]| cc dd', 'tail line']); | ||
| }); | ||
| } | ||
| }); |
There was a problem hiding this comment.
Optional: the #9589 sentence in y.md is the only new limit without a case here. Main shows it when another client bolded the run (B bolds Kilo; A unbolds il while B bolds bb): bb ends plain on every peer with ids [2, 1, 3] and keeps the bold with [1, 2, 3]. The review comment has a case for it that passes today.
…can stay on text after a collaborator's typing when both undo at once ## Description A rare race of undos is now a documented limit of `@lexical/y` (#9593). A client bolds `one two`; a collaborator types plain ` mid` after `one` and undoes it while the client, which has not seen the typing, undoes the bold. With #9561, the collaborator's undo keeps its own run ends (text typed next to ` mid` would rely on them), and the client's undo removes the bold's opener and end, so the restore after ` mid` gives ` two` the bold on every client. No text is lost. Settling it needs a write after both undos are seen, which no client may make while it applies an update. The Undo bullet of the Limits section now says so, next to the other undo limits. #9561 pins the case as a `test.fails` naming #9593. Checked in all six client-id orders: | | main `e28f4dd` | #9561 head `08d68c9` | |---|---|---| | the collaborator types ` mid` offline and undoes it while the client undoes the bold | `paragraph("one two")` | `paragraph("one" " two"{1})` | | the collaborator types ` mid`, everyone syncs, then both undo at once | `paragraph("one two")` | `paragraph("one two")` | So this holds once #9561 lands; main does not have it. ## Test plan ``` pnpm run ci-check ``` ### Before ``` $ pnpm run ci-check Found 0 errors ``` ### After ``` $ pnpm run ci-check Changeset check passed: 31 pending changeset(s) valid. Found 0 errors ```
…sts of the same type in adjacent list items merge ## Description Another documented limit of `@lexical/y` (#9591). The document stores a list item as a line marker that carries its list's type and start, and the wrapper item and list of each outer level, with no identity for a nested list or its wrapper item. Two nested lists of the same type held by adjacent list items are stored as two lines with equal markers, so every reader builds one list, which keeps the first list's start; the bound editor keeps its own state in the shape readers build. So turning a nested bullet list into a number list right after another item's nested number list makes it part of that list in a bound editor, while an editor without collaboration keeps the two lists apart. Peers converge. The paragraph about adjacent lists of the same type merging now says so. `Races.test.ts` pins #9591's repro as a `test.fails`: `$insertList('number')` in a bound editor and in an editor without collaboration, from the same state, should give the same result. ## Test plan ``` pnpm exec vitest run --project unit --reporter=verbose packages/lexical-y/src/__tests__/unit/Races.test.ts -t 9591 pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/Races.test.ts pnpm run ci-check ``` ### Before ``` $ vitest --run --project unit packages/lexical-y/src/__tests__/unit/Races.test.ts Tests 118 passed | 4 expected fail (122) ``` ### After ``` $ vitest --run --project unit --reporter=verbose packages/lexical-y/src/__tests__/unit/Races.test.ts -t 9591 ✓ |unit| packages/lexical-y/src/__tests__/unit/Races.test.ts > #9591: $insertList gives the same result in a bound editor as in an unbound one Tests 1 expected fail | 122 skipped (123) $ vitest --run --project unit packages/lexical-y/src/__tests__/unit/Races.test.ts Tests 118 passed | 5 expected fail (123) $ pnpm run ci-check Found 0 errors ``` As a plain test it fails at the second comparison, after `$insertList`: the two editors load the same state and differ only after the command.
…9562 only: drop the concurrent-undo limit, and extend the run-end format limit to a format applied from inside the run ## Description #9561 is not landing with this, so the Limits section describes main, with #9562 for the sentences that say so. - **Undo: both undo at once.** The sentence added for #9593 (a format staying on text after a collaborator's typing when both undo at once) describes a case that only #9561 produces; main gives `paragraph("one two")`. It is removed. The format-undo sentence that main has (the text after a collaborator's typing inside the range keeps the format, whether the undoing client saw that text or not) and its `Races.test.ts` cases are unchanged and still describe main. - **A collaborator's format after a run's end (#9589, #9603).** The sentence now also covers a format applied from inside the run past its end, and says that the format comes back when the edit inside the run is undone: with `Kilo` bold, one client types plain text inside `Kilo` (or unbolds `il`) while another bolds `lo bb`, and with some client id orders ` bb` stays plain. No text is lost. Checked with `aa Kilo bb cc dd`, A bolds `Kilo` and syncs, then concurrently A types plain `X` after `Ki` (or unbolds `il`) while B bolds the range named below; then A undoes. Ids `[1, 2]` and `[2, 1]` (A = first id): | B bolds | main `cad9015` | main + #9562 `ac2c143` | after A's undo | |---|---|---|---| | ` bb` (7-10), A types `X` | ` bb` bold | ` bb` plain with `[2, 1]` | ` bb` bold everywhere | | ` bb` (7-10), A unbolds `il` | ` bb` bold | ` bb` plain with `[2, 1]` | ` bb` bold everywhere | | `lo bb` (5-10), A types `X` | ` bb` bold | ` bb` plain with `[2, 1]` | ` bb` bold everywhere | | `ilo bb` (4-10), A types `X` | ` bb` bold | ` bb` plain with `[2, 1]` | ` bb` bold everywhere | | `o bb cc` (6-13), A types `X` | ` bb cc` bold | ` bb cc` plain with `[2, 1]` | ` bb cc` bold everywhere | ## Test plan ``` pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/Races.test.ts pnpm run ci-check ``` ### Before ``` $ vitest --run --project unit packages/lexical-y/src/__tests__/unit/Races.test.ts Tests 118 passed | 5 expected fail (123) ``` ### After ``` $ vitest --run --project unit packages/lexical-y/src/__tests__/unit/Races.test.ts Tests 118 passed | 5 expected fail (123) $ pnpm run ci-check Changeset check passed: 31 pending changeset(s) valid. Found 0 errors ```
There was a problem hiding this comment.
This looks good. The new Limits sentences in packages/lexical-website/docs/collaboration/y.md match what @lexical/y does at this head (the list conversion inside a container that is not a shadow root, merged cells, the wrap and $setBlocksType undos, the format undo after a collaborator's plain typing, the #9575 run-on for a comment and for bold, the #9591 nested-list merge and the join race), and the new Races.test.ts cases assert the document every peer and a fresh reader converge on. A few optional, non-blocking suggestions:
-
The one-client run-on trigger is ahead of main (
y.md:982-983). With #9575's steps, one client's two writes inside the comment (its own comment or another client's), undone while D deletesterandbb, leave the comment ending afterKilome, and so do two clients writing once each; it runs on with three writes by two clients (as in #9575) or one write each by three clients. Since the one-client form comes with #9562, consider "when several writes inside it, by more than one client, are undone", and letting #9562 add the rest when it lands. -
What doubles on undo is new content, not the missing flag (
y.md:960-966). AreplacewithoutincludeChildrenthat first moves the old text nodes into the new block (h.append(...p.getChildren()); p.replace(h), as$wrapNodesdoes) undoes after a collaborator typesbytoparagraph("Revenue grew by 4% in Q3."), the text once, likereplace(h, true); only a new block holding a new text node givesparagraph("Revenue grew 4% in Q3.by "). Consider: "Undoing the replacement of a block with a new one holding new content (not the old block's children, whichreplace(node, true)and$setBlocksTypekeep) …". -
Pin #9591's merged shape (
Races.test.ts:672). Thetest.failspasses for any difference between the two editors after$insertList, or a throw. A regular test next to it asserting today's shape (the converted list inside the previous item's nested number list, keeping its start of 0), as the AUDIT-U1 and #9575 blocks do, would pin whaty.md:601-604documents. -
Earlier suggestions that still apply at this head: the run-end format sentence (
y.md:985-992) has no case on main, where it reproduces when another client bolded the run (bbends plain whenever the bolding client's id is lower than the writer's, and is bold again after the writer's undo); the line after a raced join also takes the line before's alignment and indent, and a forward Delete at the end of the line before joins the same way (y.md:1041); and the move form of the join race has no case.
| Undoing the replacement of a block with a new one (`replace` without | ||
| `includeChildren`, written as a removal and a new block) after a |
There was a problem hiding this comment.
Optional: what doubles on undo is new content rather than the missing flag. A replace without includeChildren that first moves the old text nodes into the new block (h.append(...p.getChildren()); p.replace(h), as $wrapNodes does) undoes after a collaborator types by to paragraph("Revenue grew by 4% in Q3."), the text once, the same as replace(h, true); only a new block holding a new text node gives paragraph("Revenue grew 4% in Q3.by "). Suggested: "Undoing the replacement of a block with a new one holding new content (not the old block's children, which replace(node, true) and $setBlocksType keep) …".
| // one list (y.md, Adjacent equal links merge), so a bound editor merges a | ||
| // nested list turned into that type into the previous item's nested list, | ||
| // where an editor without collaboration keeps them apart. | ||
| test.fails( |
There was a problem hiding this comment.
Optional: this test.fails passes for any difference between the two editors after $insertList, or a throw, so it does not pin the merged shape y.md now documents. Today the bound editor, and a fresh bound reader of the doc, show the converted list inside the previous item's nested number list with that list's start: list[number,1](listitem(list[check,99](listitem(list[number,0](listitem() listitem("t5043z")))))). A regular test asserting that next to this one, as the AUDIT-U1 and #9575 blocks do, would pin the limit while this one still flags the fix.
…out #9562: the run-on case needs two collaborators' writes, and the lost format a run another client formatted ## Description #9562 is parked, so the Limits text must describe `main` without it. Two sentences held only with #9562, which extended rule 5 to runs the writing client opened itself: - A comment or format running on past its end when writes inside it are undone while another client deletes across its end: on `main` one client's two writes inside a comment (S9-12b, the author's own; or a collaborator's two writes) end the comment where it ended, and only two collaborators' writes run on (#9575, `Races.test.ts`). The sentence now names two collaborators' writes. - A format a collaborator applies at a run's end lost while another client edits inside the run (#9589): on `main` that happens where the run was formatted by a client other than the one editing inside it (bolded by one client, edited inside by a second, ` bb` bolded by the first or a third), in some client id orders, as with #9562; where the editing client formatted the run itself, `main` keeps ` bb` bold. The sentence now says so. ## Test plan Checked on `main` (2fe8056) with the package's harness: two writes of one collaborator inside a comment, undone while another client deletes `ter` and ` bb`, leave `aa |Kilome[c1]| cc dd` / `tail line` in all six client id orders, and #9575's `Races.test.ts` cells keep the documented run-on (both orders). With `Kilo` bolded by B and A typing `X` inside it (or unbolding `il`) while B or C bolds ` bb` (or `lo bb`), ` bb` ends plain for ids `[2,1,3]`, `[3,1,2]` and `[3,2,1]` (B) or `[2,3,1]`, `[3,1,2]`, `[3,2,1]` (C), and is bold again after A's undo; with A bolding `Kilo` itself, ` bb` stays bold in every order. ``` $ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/Races.test.ts Tests 118 passed | 5 expected fail (123) ``` `pnpm run prettier` passes.
…alCommands cells that waited for this change plain tests ## Description Merges main (8e89481), which brought #9588's StructuralCommands matrix and #9569's y.md Moves wording. The matrix marked 135 cells `test.fails` until this change (declared and declared-in-declared containers for every structural command and mode, and list conversion in a box inside a box); with the rule they pass, so they are plain tests now and the model only describes the rule. The y.md Moves conflict keeps #9569's table cell merge and this change's declared containers and #9570 fallback. ## Test plan ### Before On main the 135 cells were `test.fails`. ### After ``` $ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/matrix/StructuralCommands.test.ts Tests 700 passed (700) $ pnpm exec vitest run --project unit packages/lexical/src packages/lexical-extension packages/lexical-list packages/lexical-selection packages/lexical-mdast packages/lexical-markdown packages/lexical-utils Tests 5137 passed | 4 skipped (5141) $ pnpm exec vitest run --project unit packages/lexical-y Tests 11150 passed | 71 expected fail | 2 skipped (11223) $ pnpm run ci-check Found 0 errors ```
Description
The Limits section of the
@lexical/ydocs described some cases wrongly and left others out.$insertListreplaces the whole container with the list, and the container's content moves into it, so text a collaborator types in it at the same time is lost and two concurrent conversions show it twice. In a table cell or another shadow root, the paragraph becomes a list item in place and keeps the typing, as at the root. The bullet now says so.replacewithoutincludeChildren) after a collaborator edited the new block brings the old block's text back and keeps their edit next to it. Text they typed there, or the text of a block they put in its place, shows after the old text (twice, where they put the same text back). This happens with one undo and no concurrency, so the "both undo" sentence did not cover it. The Undo bullet now does, and says thatreplace(node, true), which$setBlocksTypeuses, keeps the block's children, so undoing it keeps their text once (paragraph("Revenue grew by 4% in Q3.")).one two, a collaborator types plainmidafterone, undo the bold, andtwostays bold (the collaborator's write restored the bold aftermid; the undo removes only its own items). The bullet now says both, with that example.Races.test.tsAUDIT-U1 pins the concurrent case (a limit: the undoing client cannot see the restore), asserts today's result for the seen case, and has atest.failsfor the seen case that passes once it is fixed (the undoing client holds the restore and can end the value past it).Races.test.tshas Bug: [lexical-y] A comment spreads onto later words when two collaborators undo text they typed inside it while a third deletes past its end #9575's repro as a regular test of today's outcome plus atest.failsfor the fix. Checked on main for a comment and for bold, and on [lexical-y] Bug Fix: End a restored comment or format run with the writer's own null also where the writer opened the run itself #9562's head for one client's two writes (its S9-12b).bb, orlo bb, after the boldKilo), while another client removes the format from part of the run (unboldsil) or types inside it without the format. Both put an item right after the run's end, Yjs orders them by client id, and with some orders the writer's null separates the collaborator's opener from its text, sobbstays plain on every peer. No text is lost, and when that client undoes its edit,bbis bold again. Accepted as a documented limit; the Undo bullet now says so. Checked in both id orders on maincad9015and on main + [lexical-y] Bug Fix: End a restored comment or format run with the writer's own null also where the writer opened the run itself #9562ac2c143: main loses the format only when the client writing inside the run is not the one that formatted it; with [lexical-y] Bug Fix: End a restored comment or format run with the writer's own null also where the writer opened the run itself #9562 also when it is (bbplain with ids[2, 1]), forbb,lo bb,ilo bbando bb cc; after the undobbis bold everywhere. [lexical-y] Bug Fix: End a restored comment or format run with the writer's own null also where the writer opened the run itself #9562 pins its case.Races.test.tspins Bug: [lexical-y] $insertList in a bound editor merges the new list into the previous nested list, unlike the same command without collaboration #9591's repro as atest.fails(as a plain test it fails at the comparison after$insertList).Races.test.ts: 118 passed + 5 expected fail (123).0cd7448behaves the same.Each case gets a case in
Races.test.ts, run in both client id orders with causal and per-sender delivery.Test plan
Before
What the binding does, from probes on main (A converts, undoes or joins; B edits; then sync):
After