Skip to content

[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

Merged
zurfyx merged 11 commits into
mainfrom
zurfyx/audit-limits-1030
Oct 30, 2026
Merged

zurfyx merged 11 commits into
mainfrom
zurfyx/audit-limits-1030

Conversation

@zurfyx

@zurfyx zurfyx commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

Description

The Limits section of the @lexical/y docs described some cases wrongly and left others out.

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|setBlocksType|inserted at a join|AUDIT-U1|9575"
 ✓ ... @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 > undo of $setBlocksType after a collaborator edits the block
 ✓ ... @lexical/y races, A=1 B=2 causal > block inserted at a join ‖ Backspace join
 ✓ ... @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 in A=2 B=1 causal, A=1 B=2 per-sender, A=2 B=1 per-sender)
 ✓ ... AUDIT-U1 arriving as the undo is made: the text after the typing keeps the format (1,2,3), (2,1,3)
 ✓ ... AUDIT-U1 seen before the undo: the text after the typing keeps the format (1,2,3), (2,1,3)
 ✓ ... AUDIT-U1 seen before the undo: the undo removes the format everywhere (1,2,3), (2,1,3) (test.fails)
 ✓ ... #9575 as documented: the comment runs on over the next words and line (1,2,3,4), (4,3,2,1)
 ✓ ... #9575: the comment ends where it ended (1,2,3,4), (4,3,2,1) (test.fails)
      Tests  38 passed | 4 expected fail | 80 skipped (122)

$ 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

…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
```
@vercel

vercel Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
lexical Ready Ready Preview Oct 30, 2026 6:32pm UTC
lexical-playground Ready Ready Preview Oct 30, 2026 6:32pm UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 30, 2026
@meta-code-review

meta-code-review Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
⏳ Queued (for maintainers only) Oct 30, 2026, 6:32 PM UTC

@pkg-pr-new

pkg-pr-new Bot commented Oct 30, 2026 •

Copy link
Copy Markdown
lexical

npm i https://pkg.pr.new/lexical@9569

@lexical/a11y

npm i https://pkg.pr.new/@lexical/a11y@9569

@lexical/clipboard

npm i https://pkg.pr.new/@lexical/clipboard@9569

@lexical/code

npm i https://pkg.pr.new/@lexical/code@9569

@lexical/code-core

npm i https://pkg.pr.new/@lexical/code-core@9569

@lexical/code-prism

npm i https://pkg.pr.new/@lexical/code-prism@9569

@lexical/code-shiki

npm i https://pkg.pr.new/@lexical/code-shiki@9569

@lexical/compiler

npm i https://pkg.pr.new/@lexical/compiler@9569

@lexical/devtools-core

npm i https://pkg.pr.new/@lexical/devtools-core@9569

@lexical/dragon

npm i https://pkg.pr.new/@lexical/dragon@9569

@lexical/eslint-plugin

npm i https://pkg.pr.new/@lexical/eslint-plugin@9569

@lexical/extension

npm i https://pkg.pr.new/@lexical/extension@9569

@lexical/fast-check

npm i https://pkg.pr.new/@lexical/fast-check@9569

@lexical/file

npm i https://pkg.pr.new/@lexical/file@9569

@lexical/hashtag

npm i https://pkg.pr.new/@lexical/hashtag@9569

@lexical/headless

npm i https://pkg.pr.new/@lexical/headless@9569

@lexical/history

npm i https://pkg.pr.new/@lexical/history@9569

@lexical/html

npm i https://pkg.pr.new/@lexical/html@9569

@lexical/internal

npm i https://pkg.pr.new/@lexical/internal@9569

@lexical/link

npm i https://pkg.pr.new/@lexical/link@9569

@lexical/list

npm i https://pkg.pr.new/@lexical/list@9569

@lexical/mark

npm i https://pkg.pr.new/@lexical/mark@9569

@lexical/markdown

npm i https://pkg.pr.new/@lexical/markdown@9569

@lexical/mdast

npm i https://pkg.pr.new/@lexical/mdast@9569

@lexical/offset

npm i https://pkg.pr.new/@lexical/offset@9569

@lexical/overflow

npm i https://pkg.pr.new/@lexical/overflow@9569

@lexical/plain-text

npm i https://pkg.pr.new/@lexical/plain-text@9569

@lexical/react

npm i https://pkg.pr.new/@lexical/react@9569

@lexical/rich-text

npm i https://pkg.pr.new/@lexical/rich-text@9569

@lexical/selection

npm i https://pkg.pr.new/@lexical/selection@9569

@lexical/table

npm i https://pkg.pr.new/@lexical/table@9569

@lexical/tailwind

npm i https://pkg.pr.new/@lexical/tailwind@9569

@lexical/text

npm i https://pkg.pr.new/@lexical/text@9569

@lexical/utils

npm i https://pkg.pr.new/@lexical/utils@9569

@lexical/y

npm i https://pkg.pr.new/@lexical/y@9569

@lexical/yjs

npm i https://pkg.pr.new/@lexical/yjs@9569

commit: d8fb2ff

… 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
```

@meta-code-review meta-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment on lines +1020 to +1025
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)."

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.")).

Comment on lines +957 to +958
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, …)".

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
```

@meta-code-review meta-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
```

@meta-code-review meta-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment on lines +979 to +980
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +982 to +987
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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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']);
});
}
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
```

@meta-code-review meta-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. 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 deletes ter and bb, leave the comment ending after Kilome, 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.

  2. What doubles on undo is new content, not the missing flag (y.md:960-966). 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, like replace(h, true); only a new block holding a new text node gives paragraph("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, which replace(node, true) and $setBlocksType keep) …".

  3. Pin #9591's merged shape (Races.test.ts:672). The test.fails passes 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 what y.md:601-604 documents.

  4. 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 ( bb ends 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.

Comment on lines +960 to +961
Undoing the replacement of a block with a new one (`replace` without
`includeChildren`, written as a removal and a new block) after a

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@zurfyx
zurfyx added this pull request to the merge queue Oct 30, 2026
Merged via the queue into main with commit 8e89481 Oct 30, 2026
53 checks passed
zurfyx added a commit that referenced this pull request Oct 30, 2026
…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
```

This branch was successfully deployed

2 active deployments
Preview – lexical — d8fb2ff4 Deployed Oct 30, 2026 by vercel[bot]
Preview – lexical-playground — d8fb2ff4 Deployed Oct 30, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant