Skip to content

[lexical-y] Bug Fix: Keep a node Lexical keeps apart what it is when it is written next to a collaborator's node that is undone - #9578

Merged
zurfyx merged 5 commits into
mainfrom
zurfyx/y-tab-span-undo
Oct 30, 2026
Merged

zurfyx merged 5 commits into
mainfrom
zurfyx/y-tab-span-undo

Conversation

@zurfyx

@zurfyx zurfyx commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

Description

Text nodes that Lexical keeps apart from an equal neighbour (a tab, text in token or segmented mode, and every TextNode subclass such as a hashtag or a code highlight token) are stored as runs with a segment key, opened and closed by format items their writer adds. This PR fixes two cases where a tab was lost, and one where token or segmented text lost its mode:

  • A tab written right after a collaborator's tab, which the collaborator undoes. The new tab landed inside the other tab's run and wrote only its segment key, reading its type from the other tab's opening write. The collaborator's undo deleted that write, and the tab was read as a "\t" in the text before it, on every client. Now a tab written inside another client's tab run, whose own write opened the type, writes its own type item. The write is recognized whatever its size (a tab's opening write holds one item per comment id on it). Nothing else is owned: a link or format of the text around would come back where a collaborator removed it at the same time, and other types and modes follow the run they are written in, where a concurrent change of the line must reach them (a code line made a paragraph turns its highlighter's tokens into plain text). For hashtags, code highlight tokens and token or segmented text in that placement, see Bug: [lexical-y] A hashtag, code highlight token or token/segmented text written right after a collaborator's node of the same kind becomes plain text when the collaborator undoes theirs #9586. A tab written after a collaborator's second consecutive Tab press is not covered (the type then comes from their first press's write): see Bug: [lexical-y] A tab written after a collaborator's second consecutive Tab press becomes a "\t" in the text before it when the collaborator undoes both presses #9604.
  • Two clients' tab characters under one tab opener (left in stored documents by the case above through undo and redo) were read as one tab node: one tab was not shown, and the binding's self-check reported the line (text "code\t\txx" != "code\txx"). Each tab character is now read as a tab node of its own, and the reader keeps every node of a segment, so both keep their keys across remote edits.
  • Token or segmented text written right before a collaborator's node of the same kind could land inside that node's run: the cursor stepped past the items of the node's opening write other than its type and segment keys (its mode), and the collaborator's undo then took the mode from the new text. The cursor no longer steps past those items; it still steps over the write's wrapper items, so content written inside a link right before a node that opened it shares the link and loses it with the rest.

A tab written after a tab of the same client owns nothing (the client's own undo removes its later write first), so consecutive Tab presses store as many format items as on main.

Reported downstream (a collaborative notes app's randomized test with undo).

Changeset

.changeset/lexical-y-own-segment-run.md (@lexical/y patch, [fix] with Verify:).

Test plan

packages/lexical-y/src/__tests__/unit/TabLeak.test.ts:

pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/TabLeak.test.ts

Before

On main (e28f4dd), the tab, token, segmented, two-tab, link and many-ids cases fail (each commit's message lists its own before and after output).

After

 Test Files  1 passed (1)
      Tests  107 passed | 10 expected fail (117)
pnpm exec vitest run --project unit packages/lexical-y packages/lexical-code packages/lexical-hashtag packages/lexical-link packages/lexical-mark
      Tests  9361 passed | 56 expected fail | 5 skipped (9422)

The randomized notes-app test: 2000 of 2000 seeds pass. Fuzz.test.ts with
FUZZ_SEED_START=1 FUZZ_SEEDS=3000 and FUZZ_SEED_START=50000 FUZZ_SEEDS=2000: the same 8 seeds fail as on main, every other seed passes.
The recorded session of the previous commit: no self-check report; 150
sessions of that lane: the same counts of every flag kind as on main.
pnpm run ci-check passes. Eight Tab presses at the end of a line: 18 live
format items (18 on main, 26 on the previous commit).

Browsers were not exercised (headless and jsdom unit tests only).

…it is written next to a collaborator's node that is undone

## Description

Text nodes that Lexical keeps apart from an equal neighbour (a tab, text in
token or segmented mode, and every `TextNode` subclass such as a hashtag or
a code highlight token) are stored as runs with a segment key, opened and
closed by format items their writer adds. Two cases lost such a node:

- A node written right after a collaborator's node of the same kind landed
  inside that node's run and wrote only the items whose values differed (its
  segment key), reading its type and formats from the collaborator's opener.
  When the collaborator undid their node, the undo deleted that opener and
  its closers, and the node was read as something else: a tab as a `"\t"` in
  the text before it, a hashtag or a code highlight token as plain text.
  The writer now gives content that starts a segment other than the one in
  effect at the cursor items of its own for every key it has, so it never
  depends on another node's run. A node written before a collaborator's was
  already safe (the writer never steps past another node's type opener).
- Two clients' `"\t"` under one tab opener (left by the case above through
  undo and redo, in documents written before this fix) were read as one tab
  node: one tab was not shown, and the binding's self-check reported the
  line (`text "code\t\txx" != "code\txx"`). Each `"\t"` is now read as a tab
  node of its own.

Reported downstream (a collaborative notes app's randomized test with undo).

## Changeset

`.changeset/lexical-y-own-segment-run.md` (`@lexical/y` patch, `[fix]` with
`Verify:`).

## Test plan

New tests in `packages/lexical-y/src/__tests__/unit/TabLeak.test.ts`:

- "a node kept apart written next to another client's": B adds a node next
  to an existing one, A adds one right after (or before) B's, B undoes (or
  removes) its node, for a tab, token text, segmented text, bold token text
  and a hashtag in a paragraph and a tab and a code highlight token in a code
  block, with both client id orders. Every peer keeps A's node as written,
  no tab character is left outside a tab node, and the peers converge with a
  fresh reader.
- "tab characters under one tab opener": a second `"\t"` written inside a
  tab's run (paragraph and code block) reads as two tab nodes, and both
  peers still edit the line and converge.

```
pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/TabLeak.test.ts
```

### Before

```
     × paragraph: B adds a tab, A adds one after it, B undos (ids 1,2) 17ms
     × paragraph: B adds a tab, A adds one after it, B undos (ids 2,1) 14ms
     × paragraph: B adds a hashtag, A adds one after it, B undos (ids 1,2) 27ms
     × paragraph: B adds a hashtag, A adds one after it, B undos (ids 2,1) 20ms
     × code: B adds a tab, A adds one after it, B undos (ids 1,2) 18ms
     × code: B adds a tab, A adds one after it, B undos (ids 2,1) 16ms
     × code: B adds a code-highlight, A adds one after it, B undos (ids 1,2) 15ms
     × code: B adds a code-highlight, A adds one after it, B undos (ids 2,1) 15ms
     × paragraph: two "\t" in one run read as two tabs 8ms
     × code: two "\t" in one run read as two tabs 8ms
 Test Files  1 failed (1)
      Tests  10 failed | 79 passed (89)
```

A randomized three-peer test over a notes app's node set (lists, tables,
code blocks, links, tabs; typing, splits, merges, range edits, undo and
redo, reloads, self-check on), 2000 seeds: `Tests  5 failed | 1995 passed
(2000)` (seeds 1070, 1288, 1529, 1760, 1870, each a self-check warning
`text "code\t\txx" != "code\txx"`).

### After

```
 Test Files  1 passed (1)
      Tests  89 passed (89)
```

The randomized test: `Tests  2000 passed (2000)`.

```
pnpm exec vitest run --project unit packages/lexical-y packages/lexical-code packages/lexical-hashtag
 Test Files  188 passed | 2 skipped (190)
      Tests  7935 passed | 46 expected fail | 5 skipped (7986)

pnpm exec vitest run --project unit packages/lexical/ packages/lexical-mark packages/lexical-extension packages/lexical-html
 Test Files  157 passed | 1 skipped (158)
      Tests  4147 passed | 1 skipped (4148)

pnpm exec vitest run --project scripts-unit scripts/__tests__/unit/treeShakingSource.test.ts
      Tests  374 passed (374)
```

`Fuzz.test.ts` with `FUZZ_SEED_START=1 FUZZ_SEEDS=3000` and
`FUZZ_SEED_START=50000 FUZZ_SEEDS=2000`: the same 8 seeds fail with and
without this change (2277, 50180, 50231, 50372, 51439, 51507, 51972, 51976;
the same messages on main), and every other seed passes.

`pnpm run ci-check` passes. Browsers were not exercised (headless and jsdom
unit tests only).
@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 5:22pm UTC
lexical-playground Ready Ready Preview Oct 30, 2026 5:22pm 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
🔄 In progress (for maintainers only) Oct 30, 2026, 5:23 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@9578

@lexical/a11y

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

@lexical/clipboard

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

@lexical/code

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

@lexical/code-core

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

@lexical/code-prism

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

@lexical/code-shiki

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

@lexical/compiler

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

@lexical/devtools-core

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

@lexical/dragon

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

@lexical/eslint-plugin

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

@lexical/extension

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

@lexical/fast-check

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

@lexical/file

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

@lexical/hashtag

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

@lexical/headless

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

@lexical/history

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

@lexical/html

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

@lexical/internal

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

@lexical/link

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

@lexical/list

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

@lexical/mark

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

@lexical/markdown

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

@lexical/mdast

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

@lexical/offset

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

@lexical/overflow

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

@lexical/plain-text

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

@lexical/react

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

@lexical/rich-text

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

@lexical/selection

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

@lexical/table

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

@lexical/tailwind

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

@lexical/text

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

@lexical/utils

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

@lexical/y

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

@lexical/yjs

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

commit: f953cf4

@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 tracking this down. The undo case is real: against the base source the new TabLeak.test.ts fails exactly the 10 tests listed in the Test plan, and with this change they pass (a redo by B afterwards also comes back clean). A few things need attention before this can go in.

1. A concurrent link or format removal is reverted (please fix)

ownSegmentKeys (packages/lexical-y/src/internal/Cursor.ts:532-547) returns every key with a non-null value whenever the inserted content's segment differs from the cursor's, including when the cursor sits in plain text with no segment at all. insertFormats (lines 557-558) then writes an opener for each of those keys and records it in negated, so a restore of the surrounding run's value is written after the content. A tab typed inside a link is now stored as {w:link}{t:"tab"}{k}\t{w:link}{t:null}{k:null} (on main: {t:"tab"}{k}\t{t:null}{k:null}). A peer that removes the link (or bold) at the same time never sees those items, so every peer converges with the link back on the inserted node and on the text after it. With the existing harness:

test('a tab typed inside a link while a peer removes the link', async () => {
  const t = await setup(() =>
    $getRoot().append(
      $createParagraphNode().append(
        $createLinkNode('https://example.com').append($createTextNode('abcd')),
      ),
      $createParagraphNode().append($createTextNode('next line')),
    ),
  );
  t.offline();
  t.a.update(() => {
    const link = $block([0]).getFirstChild() as ElementNode;
    const [, right] = (link.getFirstChild() as TextNode).splitText(2);
    right.insertBefore($createTabNode());
  });
  t.b.update(() => {
    const link = $block([0]).getFirstChild() as ElementNode;
    for (const child of link.getChildren()) {
      link.insertBefore(child);
    }
    link.remove();
  });
  await t.sync();
  // main: paragraph("ab", tab, "cd"); this branch: paragraph("ab", link(tab, "cd"))
  for (const peer of t.peers) {
    expect(peer.read(() => $block([0]).getChildren().some($isLinkNode))).toBe(false);
  }
  await expectConverged(t.peers);
});

The same happens with a hashtag or token text instead of the tab, with a bold hashtag or bold token typed into bold text that a peer unbolds ("cd" comes back bold), and with A's tab right after B's tab inside a link. This is the situation the cursor's own invariants are written to prevent (the minimizeFormatChanges comment and rule 4 of format: a comment resolved while a peer typed came back). A narrower rule should keep your fix: own only the keys whose value at the cursor comes from the segment's own run (the items the segment author's write opened, for example this.sources entries from the same client and write as the k source, as oneFormatWrite checks), return null when the cursor has no segment, and never own an inherited wrapper key. Please add the link and bold cases as tests.

2. The token and segmented cases do not exercise the bug

In TabLeak.test.ts the token, segmented and bold token variants also pass without the fix: B's node is inserted right before the setup's token PP (lines 405 and 414), B's write steps over PP's p:mode opener (only t and k openers are protected, Cursor.ts:466-470), and A's node then depends on the setup writer's opener, which B's undo never removes. If B's token opens a run of its own instead (for example B splits "ab cd" and inserts its token after "ab", A adds one right after it, B undoes), main loses A's token and this branch keeps it. Using that setup would make the token and segmented claim in the description and changeset tested.

3. Changeset Verify: wording

.changeset/lexical-y-own-segment-run.md ends with "Documents written before this fix keep how they read", but with the reader change a stored line whose two tabs had collapsed into one now shows both. CONTRIBUTING.md asks a fix that changes observable behaviour to say what to verify, so please mention that case.

Minor

  • With two "\t" under one opener the two tab nodes share a segment key, and takeText maps a segment to a single node (Read.ts:2372-2390), so each remote edit of that line recreates one of the tab nodes (the caret stays in place). Mapping a segment to a list of nodes would keep both keys.
  • Owned items add stored format items (eight Tab presses in one line: 18 before, 26 after); the narrower rule above should shrink that as well.

Comment on lines +540 to +546
const own = new Set<string>();
for (const key in formats) {
if (formats[key] !== null) {
own.add(key);
}
}
return own;

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 owns every key with a non-null value, including inherited ones such as a link (w:link) or f:bold, and also when the cursor has no segment at all (plain text). insertFormats then records each in negated, so a restore of the surrounding run's value is written after the content. If a peer removes that link or bold concurrently, the restore brings it back over the inserted node and the text after it (a tab typed into linked abcd while a peer unlinks it ends as "ab" link(tab "cd") on every peer; on main the link is gone). Could ownership be limited to the keys the segment author's own write opened, with null when the cursor has no segment?

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 0f080d9 and narrowed again in 904c01c: content that starts a segment inside another segment's run now owns only a tab's type, and only when that segment's own write opened it; never a wrapper or a format, and nothing when the cursor has no segment. Your link case and the bold case are tests in TabLeak.test.ts ("a node kept apart written where a peer removes the link or format around it"), plus A's tab right after B's tab inside a link while C removes the link.

let bKey = '';
t.b.update(() => {
const bs = $make(kind, 'BB');
$kindNodes(kind)[0].insertBefore(bs);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

For the token, segmented and bold token kinds these cases also pass without the fix: B's node lands right before the setup's token PP, B's write steps over PP's p:mode opener (only t/k openers are protected in minimizeFormatChanges), so A's node depends on the setup writer's opener, which B's undo never removes. Inserting B's token where it opens its own run (for example after "ab" in a plain "ab cd") makes these fail on main and pass here.

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.

Thanks: B's node now opens a run of its own (B inserts it inside plain ab cd), so the token, segmented and bold token cases fail on main. The "before" placement passes for every kind since 0f080d9 (the cursor no longer steps past any item of another node's opening write). The "after + undo" cases for hashtag, code highlight, token and segmented text are test.fails naming #9586: only a tab's type is owned (904c01c explains why: line-dependent types must follow their run).

Comment thread .changeset/lexical-y-own-segment-run.md Outdated

[fix] Keep a tab, a hashtag, a code highlight token or text in token or segmented mode what it is when it was written right next to a collaborator's node of the same kind and that collaborator undoes theirs

Verify: in a shared document, one person presses Tab right next to a tab another person added; when the other person undoes, the first person's tab is still a tab for everyone (not a tab character in the text before it). Documents written before this fix keep how they read.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

With the reader change, a stored line whose two tabs had collapsed into one now shows both, so "Documents written before this fix keep how they read" is not quite accurate. CONTRIBUTING.md asks a fix that changes observable behaviour to say what to verify; worth adding that case here.

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.

Updated: the Verify: paragraph now says a stored line where two tabs were shown as one shows both tabs, and that a link or format removed around a tab at the same time stays removed.

…t is written in, and keep each tab node of a run

## Description

Follow-up to the previous commit, from review:

- Content that starts a segment of its own inside another segment's run
  wrote items for every key it had, so it also restated a link or a format
  it had from the text around it, and a restore of that value after it. A
  collaborator who removed that link or format at the same time saw it come
  back on the node and on the text after it (a tab typed into a link while
  the link was removed ended inside the link on every peer). It now owns
  only the keys whose value at the cursor the other segment's own write
  opened (its type, and the formats and fields written with it), never a
  wrapper, and nothing where the cursor is not inside another segment's run.
- A node written right before another client's node kept apart could still
  land inside that node's run: the cursor stepped past the opener items of
  that node's write other than its type and segment keys (the mode of token
  or segmented text), and the node then read its mode from that write, which
  the other client's undo deletes. The cursor no longer steps past any item
  of the write that opens another node's run.
- With two tab characters under one tab opener read as two tab nodes, the
  reader's lookup of a node by segment key kept one node per segment, so a
  remote edit of the line recreated the other tab node. It now keeps every
  node of a segment.

The tests now open B's node as a run of its own (B adds it inside plain
text), so the token, segmented and bold token cases fail without the change,
and cover a tab, hashtag or token typed inside a link while a peer removes
the link, a bold one typed into bold text while a peer unbolds it, and A's
tab right after B's tab inside a link while C removes the link.

## Test plan

```
pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/TabLeak.test.ts
```

### Before

On main (`e28f4dd`):

```
     × paragraph: B adds a tab, A adds one after it, B undos (ids 1,2) 15ms
     × paragraph: B adds a tab, A adds one after it, B undos (ids 2,1) 13ms
     × paragraph: B adds a token, A adds one after it, B undos (ids 1,2) 15ms
     × paragraph: B adds a token, A adds one after it, B undos (ids 2,1) 18ms
     × paragraph: B adds a token, A adds one before it, B undos (ids 1,2) 19ms
     × paragraph: B adds a token, A adds one before it, B undos (ids 2,1) 19ms
     × paragraph: B adds a segmented, A adds one after it, B undos (ids 1,2) 20ms
     × paragraph: B adds a segmented, A adds one after it, B undos (ids 2,1) 24ms
     × paragraph: B adds a segmented, A adds one before it, B undos (ids 1,2) 11ms
     × paragraph: B adds a segmented, A adds one before it, B undos (ids 2,1) 12ms
     × paragraph: B adds a bold token, A adds one after it, B undos (ids 1,2) 12ms
     × paragraph: B adds a bold token, A adds one after it, B undos (ids 2,1) 12ms
     × paragraph: B adds a bold token, A adds one before it, B undos (ids 1,2) 12ms
     × paragraph: B adds a bold token, A adds one before it, B undos (ids 2,1) 12ms
     × paragraph: B adds a hashtag, A adds one after it, B undos (ids 1,2) 13ms
     × paragraph: B adds a hashtag, A adds one after it, B undos (ids 2,1) 12ms
     × code: B adds a tab, A adds one after it, B undos (ids 1,2) 22ms
     × code: B adds a tab, A adds one after it, B undos (ids 2,1) 18ms
     × code: B adds a code-highlight, A adds one after it, B undos (ids 1,2) 14ms
     × code: B adds a code-highlight, A adds one after it, B undos (ids 2,1) 12ms
     × paragraph: two "\t" in one run read as two tabs 8ms
     × code: two "\t" in one run read as two tabs 8ms
Failed Tests 22
 Test Files  1 failed (1)
      Tests  22 failed | 81 passed (103)
```

On the previous commit (`ee30f21`): the link and unbold cases, and the
tab nodes' keys after a remote edit:

```
     × paragraph: two "\t" in one run read as two tabs 17ms
     × code: two "\t" in one run read as two tabs 13ms
     × a tab typed inside a link while a peer removes the link (ids 1,2) 9ms
     × a bold tab typed into bold text while a peer unbolds it (ids 1,2) 6ms
     × a tab typed inside a link while a peer removes the link (ids 2,1) 7ms
     × a bold tab typed into bold text while a peer unbolds it (ids 2,1) 7ms
     × a hashtag typed inside a link while a peer removes the link (ids 1,2) 7ms
     × a bold hashtag typed into bold text while a peer unbolds it (ids 1,2) 7ms
     × a hashtag typed inside a link while a peer removes the link (ids 2,1) 7ms
     × a bold hashtag typed into bold text while a peer unbolds it (ids 2,1) 7ms
     × a token typed inside a link while a peer removes the link (ids 1,2) 7ms
     × a bold token typed into bold text while a peer unbolds it (ids 1,2) 6ms
     × a token typed inside a link while a peer removes the link (ids 2,1) 12ms
     × a bold token typed into bold text while a peer unbolds it (ids 2,1) 14ms
     × A's tab right after B's tab inside a link while C removes the link (ids 1,2,3) 19ms
     × A's tab right after B's tab inside a link while C removes the link (ids 3,2,1) 17ms
Failed Tests 16
 Test Files  1 failed (1)
      Tests  16 failed | 87 passed (103)
```

### After

```
 Test Files  1 passed (1)
      Tests  103 passed (103)
```

```
pnpm exec vitest run --project unit packages/lexical-y packages/lexical-code packages/lexical-hashtag packages/lexical-link
 Test Files  199 passed | 2 skipped (201)
      Tests  8176 passed | 46 expected fail | 5 skipped (8227)
```

The randomized three-peer test over a notes app's node set: 2000 of 2000
seeds pass. `Fuzz.test.ts` with `FUZZ_SEED_START=1 FUZZ_SEEDS=3000` and
`FUZZ_SEED_START=50000 FUZZ_SEEDS=2000`: the same 8 seeds fail as on main
(2277, 50180, 50231, 50372, 51439, 51507, 51972, 51976), every other seed
passes. `pnpm run ci-check` passes. Eight Tab presses at the end of a line
store 26 format items (18 on main): each tab next to the one before writes
its own type key and the ends of its run.
… so a concurrent change of the line reaches other types

## Description

The previous commit gave content that starts a segment inside another
segment's run items of its own for the keys that segment's write opened,
its type included. A code highlight token written that way kept its type
when a collaborator made the code line a paragraph at the same time (that
change turns the line's tokens into plain text, but did not know the new
token's own type item): the token stayed a code highlight token inside a
paragraph, the editor that made the change showed it as plain text merged
with its neighbours, a fresh read of its document showed it as a node of its
own, and the self-check reported the line. A randomized session run found
it.

Now only a tab's type is owned there. A tab is a tab in any line, and no
change of a range or a line removes that type. Other types and modes follow
the run they are written in, as on main: written right after a
collaborator's node of the same kind that the collaborator undoes, a
hashtag, a code highlight token or token or segmented text still becomes
plain text (#9586); those cases are kept in the tests as expected failures.
A node written right before a collaborator's node keeps its type and mode
for every kind (the cursor does not step into the other node's opening
write).

## Test plan

```
pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/TabLeak.test.ts
```

### Before

The recorded session (four clients, tab-heavy catalog documents, seed
2500572, 77 of 91 operations), on the previous commit: client D reports
`self-check (remote): container root[8].children: editor [...] != shared
[...]` in round 10, and D's editor differs from a fresh read of its document
(one text node `" t1003z = t35z t1014z t1021z t1010z t1027z; ..."` where the
document holds three). On main: no report.

### After

```
 Test Files  1 passed (1)
      Tests  93 passed | 10 expected fail (103)
```

The recorded session: no self-check report. 150 sessions of the same lane
(seeds 2500500 to 2500649): the same counts of every flag kind as on main.

```
pnpm exec vitest run --project unit packages/lexical-y packages/lexical-code packages/lexical-hashtag packages/lexical-link
      Tests  8166 passed | 56 expected fail | 5 skipped (8227)
```

The randomized notes-app test: 2000 of 2000 seeds pass. `Fuzz.test.ts` with
`FUZZ_SEED_START=1 FUZZ_SEEDS=3000` and `FUZZ_SEED_START=50000
FUZZ_SEEDS=2000`: the same 8 seeds fail as on main, every other seed passes.
`pnpm run ci-check` passes.

@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-ups. The fixes are well covered: against main, the new TabLeak.test.ts fails the 12 tab, token/segmented and two-tab cases, and they pass on this branch. Deferring the hashtag, code highlight and token/segmented "after" cases to #9586 as test.fails makes sense.

Please fix: wrapper items of another node's opening write are now owned

opensNodeRun (packages/lexical-y/src/internal/Cursor.ts:274-293) counts every item of the write that opened a node's run, wrapper items included, and minimizeFormatChanges (line 495) stops stepping over them. A node's write lists its wrapper items first (packages/lexical-y/src/internal/Inline.ts:181), so content inserted inside a link right before a hashtag, tab or token that opened that link in the same write gets a w:link item of its own. That item survives the link author's undo and a collaborator's concurrent unlink, so the link stays on the inserted content on every peer, where main removes it (three peers, both client id orders):

B inserts after ab A inserts inside the link, before B's first child Then main this branch
link(#BB " more") text XY B undoes no link link("XY")
link(#BB) text XY C unlinks no link link("XY")
link(tab "zz") a tab C unlinks no link link(tab)

This contradicts the changeset's Verify: line ("A link or format a collaborator removes around a tab at the same time stays removed"). Typing, Tab and paste at a link's start put the content before the link, so it takes a node-level insert such as insertBefore inside the link, but once written it persists. For a typed node (tab, hashtag, code highlight token), wrapper items are the only ones opensNodeRun newly protects, since its t item comes right after them and was already never stepped past, so letting the cursor step over wrapper items keeps the token/segmented fix:

isFormat(r) &&
  this.unitGives(r, formats) &&
  (keyOf(r).startsWith(WRAPPER_PREFIX) || !opensNodeRun(r))

With that change the three cases end without a link, as on main, and the tests in TabLeak.test.ts still pass. Please add the three cases to TabLeak.test.ts, for example:

t.b.update(() => {
  const [left] = ($block([0]).getFirstChild() as TextNode).splitText(2);
  left.insertAfter(
    $createLinkNode('https://example.com').append($createTabNode(), $t('zz')),
  );
});
await t.sync();
t.offline();
t.a.update(() => {
  const tab = $block([0]).getAllTextNodes().find(n => n.getType() === 'tab')!;
  tab.insertBefore($createTabNode());
});
t.c.update(() => {
  const link = $block([0]).getChildren().find($isLinkNode)!;
  for (const child of link.getChildren()) {
    link.insertBefore(child);
  }
  link.remove();
});
await t.sync();
// every peer: no link left in the line

Smaller suggestions

  • .changeset/lexical-y-own-segment-run.md:5: hashtags and code highlight tokens written right before a collaborator's node already kept their type on main (their write opens with the t item the cursor never stepped past, and their "before" cases pass on main); only token and segmented text lost their mode there. CONTRIBUTING.md asks for the changelog entry to be written for someone who uses Lexical, so naming token and segmented text only keeps it accurate.
  • The Description's opening still describes the first commit's rule ("items of its own for every key it has", "a node written before a collaborator's was already safe"). AGENTS.md asks the Description to say what the change makes the code do; folding the two follow-up sections into it keeps that accurate.
  • Consecutive Tab presses by one person now store two more format items each: eight presses at the end of a line store 26 live format items against 18 on main. A client's own undo removes its later write first, so its own run needs no owned type; returning null from ownSegmentKeys when the segment opener is the local client's (opener.id.client === this.tr.doc.clientID) brings that back to 18 while the new tests still pass.

isFormat(r) &&
this.unitGives(r, formats) &&
!isNodeTypeOpener(r.content as ContentFormat)
!opensNodeRun(r)

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 also stops the cursor from stepping over wrapper items (w:*) of another node's opening write. A node's write lists its wrapper items first (Inline.ts:181), so content inserted inside a link right before a hashtag, tab or token that opened the link in the same write gets a w:link item of its own, which survives the link author's undo and a collaborator's concurrent unlink: the link stays on that content on every peer, where main removes it. For a typed node the wrapper items are the only ones this newly protects (its t item comes right after them and was never stepped past), so stepping over them keeps the token/segmented fix:

(keyOf(r).startsWith(WRAPPER_PREFIX) || !opensNodeRun(r))

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 3418a1e: minimizeFormatChanges steps over wrapper items again (keyOf(r).startsWith(WRAPPER_PREFIX) || !opensNodeRun(r)), so content written inside a link right before a node that opened it shares the link. Your three cases are in TabLeak.test.ts ("content written inside a link right before a node that opened it", three clients, both orders) and pass, as do the token/segmented "before" cases. Also from this review: the changeset now names token and segmented text only for the "before" placement, the Description is rewritten to say what the PR does now, and a tab after a tab of the same client owns nothing (eight Tab presses store 18 format items again). The same commit drops the 64-item cap on recognizing a tab's opening write (a tab with 128 comment ids lost its type after its author's undo).

Comment thread .changeset/lexical-y-own-segment-run.md Outdated
'@lexical/y': patch
---

[fix] Keep a tab a tab when it was written right next to a collaborator's tab that they undo, and keep a hashtag, code highlight token or token or segmented text written right before a collaborator's node of the same kind what it is when they undo theirs

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hashtags and code highlight tokens written right before a collaborator's node already kept their type on main (their write opens with the t item the cursor never stepped past, and their before cases in TabLeak.test.ts pass on main); only token and segmented text lost their mode there. Since this line becomes the CHANGELOG.md entry (CONTRIBUTING.md), naming only token and segmented text keeps it accurate.

return null;
}
const opener = this.valueSource(SEGMENT_KEY);
if (opener === null) {

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: this also owns the type inside a run the local client opened, so consecutive Tab presses store two more format items each (eight presses: 26 live format items against 18 on main). A client's own undo removes its later write first, so returning null when opener.id.client === this.tr.doc.clientID keeps the fix for other clients' runs and the old size for one's own.

…e, and share a link with content written before a node that opened it

## Description

From review of the previous commits:

- A tab written right after another client's tab owns its type only when
  that type comes from the other tab's own write. Recognizing that write
  stopped after 64 format items, and a tab's opening write holds one item
  per comment id on it: with 128 comment ids on B's tab, A's tab after it
  did not own its type, and B's undo turned it into a `"\t"` in the text
  before it again. The write is now recognized whatever its size (the walk
  stops at the first item that is not one of that client's format items, so
  it costs at most the items of one write).
- The cursor no longer steps past any item of another node's opening write,
  so a node written right before it stays out of its run. That also kept it
  from stepping past the write's wrapper items: content written inside a
  link right before a hashtag, tab or token that opened that link in the
  same write got a link item of its own, and kept the link when its author
  undid it or a collaborator removed it. Wrapper items are stepped over
  again, so that content shares the link and loses it with the rest.
- A tab written after a tab of the same client owns nothing: the client's
  own undo removes its later write first. Eight Tab presses at the end of a
  line store 18 format items again, as on main.

## Test plan

New tests in `TabLeak.test.ts`: text written inside a link before a
hashtag that opened it, then B undoes or C unlinks; a tab written before a
tab that opened a link, then C unlinks (three clients, both orders); and a
tab written after a tab with 1, 16, 64 and 128 comment ids on it, then the
tab's author undoes.

```
pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/TabLeak.test.ts
```

### Before (`904c01c`)

```
     × text before a hashtag, B undoes (ids 1,2,3) 13ms
     × text before a hashtag, B undoes (ids 3,2,1) 14ms
     × text before a hashtag, C unlinks (ids 1,2,3) 14ms
     × text before a hashtag, C unlinks (ids 3,2,1) 14ms
     × a tab before a tab, C unlinks (ids 1,2,3) 15ms
     × a tab before a tab, C unlinks (ids 3,2,1) 14ms
     × 128 comment ids on B's tab (ids 1,2) 28ms
     × 128 comment ids on B's tab (ids 2,1) 28ms
Failed Tests 8
      Tests  8 failed | 99 passed | 10 expected fail (117)
```

### After

```
 Test Files  1 passed (1)
      Tests  107 passed | 10 expected fail (117)
```

```
pnpm exec vitest run --project unit packages/lexical-y packages/lexical-code packages/lexical-hashtag packages/lexical-link packages/lexical-mark
      Tests  9361 passed | 56 expected fail | 5 skipped (9422)
```

The randomized notes-app test: 2000 of 2000 seeds pass. `Fuzz.test.ts` with
`FUZZ_SEED_START=1 FUZZ_SEEDS=3000` and `FUZZ_SEED_START=50000
FUZZ_SEEDS=2000`: the same 8 seeds fail as on main, every other seed passes.
The recorded session of the previous commit: no self-check report; 150
sessions of that lane: the same counts of every flag kind as on main.
`pnpm run ci-check` passes. Eight Tab presses at the end of a line: 18 live
format items (18 on main, 26 on the previous commit).
zurfyx added a commit that referenced this pull request Oct 30, 2026
…part

## Description

The ownership scan cells now also cover the opening write of a node Lexical keeps apart. B writes a tab, a token, a segmented node or a hashtag before `Kilo`, types plain ` zz`, writes another of the same kind before `cc`, then undoes 0 to 3 of those writes while A, at the same time, types plain text right after or right before the first node, deletes the letter after it, or writes a node of the same kind and text right before it; both client id orders: 128 cells. Each asserts convergence on what a fresh reader shows, and that every character is what a model of the edits says: the kept-apart nodes stay what they are, and every other character is plain text, on the next line too.

On main, when A writes a token or segmented node right before B's first one and B undoes all three writes, A's node turns into plain text (4 cells): A's node shared the opener of B's, which B's undo removes. Those cells are `test.fails` naming #9578, which fixes it.

## Test plan

```
pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/matrix/OwnershipScans.test.ts
pnpm run ci-check
```

### Before

Without the `test.fails` marks, on main `cad9015`:

```
   × segmented, A write one and text before it, B undoes 3 (ids 1,2)
   × segmented, A write one and text before it, B undoes 3 (ids 2,1)
   × token, A write one and text before it, B undoes 3 (ids 1,2)
   × token, A write one and text before it, B undoes 3 (ids 2,1)
      Tests  4 failed | 220 passed (224)
```

### After

On main `cad9015`:

```
      Tests  220 passed | 4 expected fail (224)
```

With #9578 (`3418a1e`), the 4 marked cells pass:

```
      Tests  4 failed | 220 passed (224)    (the 4 are "Expect test to fail")
```

```
$ pnpm run ci-check
Found 0 errors
```
zurfyx added a commit that referenced this pull request Oct 30, 2026
… too

## Description

The 4 ownership scan cells marked for #9578 (A writes a token or segmented node and text right before B's first one; B undoes all three writes) also pass with #9561: on main with #9562 and #9561 applied they report as passing unexpectedly, and with #9562 alone they still fail. The comment over their `test.fails` now names both, so the marks are dropped by whichever of the two lands first.

## Test plan

```
pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/matrix/OwnershipScans.test.ts
```

### Before

Comment change only. On main `cad9015`:

```
      Tests  220 passed | 4 expected fail (224)
```

### After

On main `cad9015`, unchanged:

```
      Tests  220 passed | 4 expected fail (224)
```

On main with #9562 `5a34d5c` and #9561 `4724c9d`, the 4 marked cells pass:

```
      Tests  4 failed | 220 passed (224)    (the 4 are "Expect test to fail")
```

@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 to me. The fix is well targeted, and the new TabLeak.test.ts cases fail against main's source and pass on this branch, and the link and format cases raised in earlier rounds are covered by tests. A few optional, non-blocking suggestions:

1. Consecutive Tab presses are not covered yet (packages/lexical-y/src/internal/Cursor.ts:589)

A tab owns its type only when the type comes from the segment author's own write. When a collaborator presses Tab twice (two updates), their second tab writes only its segment key inside their first tab's run, so a tab written after that second tab still reads its type from the first press's write. If the collaborator then undoes both presses (one undo step inside the default 500 ms captureTimeout, or two undos), that tab becomes a "\t" in the text before it on every peer. This is the same on main, so it is not a regression. A test in the style of TabLeak.test.ts that shows it:

test("B's tab after A's second consecutive Tab press survives A's undo", async () => {
  const t = await setup(() =>
    $getRoot().append(
      $createParagraphNode().append($t('ab cd')),
      $createParagraphNode().append($t('next line')),
    ),
  );
  const $lastTab = () =>
    $block([0])
      .getChildren()
      .filter(n => n.getType() === 'tab')
      .pop()!;
  t.a.update(() => {
    const [left] = ($block([0]).getFirstChild() as TextNode).splitText(2);
    left.insertAfter($createTabNode());
  });
  t.a.update(() => $lastTab().insertAfter($createTabNode()));
  await t.sync();
  t.b.update(() => $lastTab().insertAfter($createTabNode()));
  await t.sync();
  t.a.undo();
  t.a.undo();
  await t.sync();
  for (const peer of t.peers) {
    // On this branch and on main the line reads as one text node "ab\t cd".
    expect(
      peer.read(() => $block([0]).getChildren().map(n => n.getType())),
    ).toEqual(['text', 'tab', 'text']);
  }
  await expectConverged(t.peers);
});

Owning a tab's type whenever its source item is another client's (source.id.client !== this.tr.doc.clientID in place of this.oneWrite(opener, source)) makes this test pass, while the rest of TabLeak.test.ts and the @lexical/y unit tests still pass; one client's eight Tab presses written after another client's tabs then store 19 format items instead of 16. If you prefer to keep the narrower rule, a test.fails next to the #9586 cases would record the gap.

2. Changeset wording (.changeset/lexical-y-own-segment-run.md:5)

The first line becomes the CHANGELOG entry (.changeset/README.md), and as written it also promises the consecutive-Tab case. If the rule stays as it is, consider narrowing it to a tab written right after a collaborator's single Tab press.

3. Comment ids after the tabs in the many-ids test (packages/lexical-y/src/__tests__/unit/TabLeak.test.ts:833)

With 128 comment ids on B's tab, after B's undo the text after the tabs (" two") carries all 128 of B's comment ids on every peer. This also happens on main, so it is not caused by this PR: A's restore of B's values gets no end of its own because changeEnd only looks 64 items ahead (Cursor.ts:153-173), and B's undo removes the only closers; widening that window removes the leak and keeps TabLeak.test.ts passing. Asserting the comment ids of " two" in this test would catch it; a follow-up issue seems like the right place for the fix.

return null;
}
const source = this.valueSource(TYPE_KEY);
return source !== null && this.oneWrite(opener, source)

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, non-blocking: this owns a tab's type only when it comes from the segment author's own write. A collaborator's second consecutive Tab press writes only its segment key inside their first tab's run, so a tab written after it still reads its type from the first press's write, and when the collaborator undoes both presses it becomes a "\t" in the text before it on every peer (the same on main). source.id.client !== this.tr.doc.clientID here covers that case while TabLeak.test.ts and the @lexical/y unit tests still pass; a test.fails beside the #9586 ones would also record it.

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.

Confirmed: after a collaborator's second consecutive Tab press the type comes from the first press's write, so the tab written after it does not own it. Tracked in #9604 with a repro; owning the type across writes of the same client needs its own proof first, so it is left out of this PR.

Comment thread .changeset/lexical-y-own-segment-run.md Outdated
'@lexical/y': patch
---

[fix] Keep a tab a tab when it was written right next to a collaborator's tab that they undo, and keep token or segmented text written right before a collaborator's node of the same kind in its mode when they undo theirs

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 line becomes the CHANGELOG entry (.changeset/README.md). As written it also covers a tab written after a collaborator's second consecutive Tab press, which still turns into a "\t" when they undo both presses (see Cursor.ts:589). If the rule stays as it is, consider narrowing it to a collaborator's single Tab press.

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.

Narrowed in f953cf4: the entry and the Verify: line now name a tab a collaborator added with one Tab press; the double press is #9604.

await t.sync();
await expectConverged(t.peers);
expect(t.a.read(() => $line().getTextContent())).toBe('one\t two');
expect(tabs(t.a)).toBe(1);

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: with 128 ids, after B's undo " two" carries all 128 of B's comment ids on every peer (also on main: changeEnd only looks 64 items ahead, so A's restore of B's values gets no end of its own). Asserting the comment ids of " two" here would catch it, probably in a follow-up.

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.

Pre-existing on main, as you say (changeEnd only looks 64 items ahead), so it is tracked in #9603 rather than asserted here.

…ten after a collaborator's single Tab press

## Description

The changeset said a tab written right next to a collaborator's tab stays a
tab when they undo. That holds after one Tab press of theirs; a tab written
after their second consecutive press still becomes a `"\t"` in the text
before it when they undo both presses (the second press writes only its
segment key inside the first press's run, so the type comes from the first
press's write). The entry and its `Verify:` line now name a single press;
the double press is tracked in #9604.

## Test plan

```
pnpm run ci-check
```

### Before

The changeset entry and `Verify:` line covered any tab of a collaborator.

### After

The entry and `Verify:` line name a tab a collaborator added with one Tab
press. `pnpm run ci-check` passes. No code change.
@zurfyx
zurfyx added this pull request to the merge queue Oct 30, 2026
zurfyx added a commit that referenced this pull request Oct 30, 2026
…and count each id's holders in the HTML import check

## Description

#9540 and #9584 merged, so the cells marked `test.fails` for them pass on main and report as passing unexpectedly: letters composed past the end of a marked run (26 cells in `MarksIME.test.ts`) and the `@lexical/yjs` v2 table row cell of #9581 (`C6Settles.test.ts`, `yjs-v2, table edits, follow-ups none, repair none, seed 11`). They are plain tests again. The marks for #9553, #9568, #9578 (and #9561) and #9606 stay.

The import check of `C7Html.test.ts` collected the imported blocks' ids in a map, so two blocks holding the same id counted as one and "exactly one imported block" was never asserted. It now keeps every holder and requires exactly one, of the same type.

## Test plan

```
pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/matrix
pnpm run ci-check
```

### Before

On main `04e899d` with the marks:

```
 ❯ packages/lexical-y/src/__tests__/unit/matrix/MarksIME.test.ts (56 tests | 26 failed)
 ❯ packages/lexical-y/src/__tests__/unit/matrix/C6Settles.test.ts (360 tests | 1 failed)
```

### After

On main `04e899d`:

```
 Test Files  13 passed (13)
      Tests  2395 passed | 13 expected fail (2408)

$ pnpm run ci-check
Found 0 errors
```
Merged via the queue into main with commit c514ea7 Oct 30, 2026
64 of 65 checks passed
zurfyx added a commit that referenced this pull request Oct 30, 2026
…e, slot and edge, and drop the expected failures #9578 fixed

## Description

The structural command cells (`StructuralCommands.test.ts`) now cover the rest of the rule table of #9590:

- Commands: also `$wrapNodes` (wrapping in a quote), the `---` and ```` ``` ```` shortcuts (typed into an empty paragraph; `---` with the horizontal rule shortcut apps add), and the edge rows, Backspace at the start of a container's first block and Delete at the end of its last block.
- Contexts: also the downstream component's undeclared slot (its slot value is a shadow root) and a slot host's declared slot (the pull quote's `quote` slot holding a shadow root container).
- Bind modes: unbound with `NodeStructureExtension`, and bound with `@lexical/y` in each mode the catalog binds (`binding`, `extension`, `extension-then-sync`) and with a full reconcile after the command. A read-only document takes no commands, so it is not a mode here.

14 commands × 10 contexts × 5 modes = 700 cells. The model is the same: structural commands act inside a shadow root or a class declaring `children: 'block'`, and a container that declares nothing is one more block; only a shadow root is an editing edge, so Backspace and Delete cross the edges of every other container (on main and with #9590). On main the 135 cells where main does not follow the rule yet are `test.fails` naming #9590: every structural command in a declared container and in a declared container inside one (120), and list conversion in a box inside a box (15). Every cell also asserts that the editor reported no error.

#9578 merged, so the 4 ownership scan cells marked for it (a token or segmented node written right before another client's, all three writes undone) pass on main; they are plain tests again.

## Test plan

```
pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/matrix
pnpm run ci-check
```

### Before

Without the `test.fails` marks, on main `c514ea7`:

```
 ❯ packages/lexical-y/src/__tests__/unit/matrix/StructuralCommands.test.ts (700 tests | 135 failed)
```

### After

On main `c514ea7`:

```
 ✓ packages/lexical-y/src/__tests__/unit/matrix/StructuralCommands.test.ts (700 tests)
 Test Files  14 passed (14)
      Tests  2964 passed | 144 expected fail (3108)
```

On main with #9590, every structural command cell follows the rule; the 135 marked cells pass:

```
      Tests  135 failed | 565 passed (700)    (the 135 are "Expect test to fail")
```

```
$ pnpm run ci-check
Found 0 errors
```
zurfyx added a commit that referenced this pull request Oct 30, 2026
…ade-ends branch

## Description

#9585 changed `TextReplay#align`'s `sure` argument into an array of readings: the text that left, and also that text with the passed text in place. This branch had restructured the call that took it. The resolution:
- `align` keeps #9585's `sure` array and this branch's `made` flag;
- the set passes take no readings, as before;
- main's made pass, which `within()` adds between the line-wide common ends, takes #9585's two readings, `[{at, text: d}, {at: sureAt, text: was}]`;
- the line-wide pass takes `[]` with `made = true`.

## Test plan

### After

```
$ pnpm vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/PasteRemadeLine.test.ts packages/lexical-y/src/__tests__/unit/RemadeTextEnds.test.ts packages/lexical-y/src/__tests__/unit/matrix/C9RemadeText.test.ts
 Test Files  3 passed (3)
      Tests  391 passed | 3 expected fail (394)

$ pnpm vitest run --project unit packages/lexical-y
 Test Files  1 failed | 173 passed | 2 skipped (176)
      Tests  1 failed | 8156 passed | 59 expected fail | 2 skipped (8218)
```

The one failure is the R21-B timing threshold in `regressions/Scaling.test.ts` on a loaded machine (load average 30). It passes alone, twice:

```
$ pnpm vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/Scaling.test.ts
      Tests  1 passed (1)
```

This branch was successfully deployed

2 active deployments
Preview – lexical — f953cf4a Deployed Oct 30, 2026 by vercel[bot]
Preview – lexical-playground — f953cf4a 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