Skip to content

[lexical-y] Chore: Test contracts over catalogs of node shapes, block id formats and configurations - #9588

Merged
zurfyx merged 11 commits into
mainfrom
zurfyx/y-test-catalogs
Oct 30, 2026
Merged

zurfyx merged 11 commits into
mainfrom
zurfyx/y-test-catalogs

Conversation

@zurfyx

@zurfyx zurfyx commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

Description

The @lexical/y tests checked the shapes their authors thought of. Three bugs got through that way: list items numbered 1 in a freshly bound editor when the document holds a block with named slots, the documented $setBlockText throwing on a TabNode, and block handles that were long and typo-prone for ids that share a prefix. Each was one member of an input class nobody enumerated: blocks with slots, TextNode subclasses, id formats.

This adds catalogs of the input space under packages/lexical-y/src/__tests__/catalog/, as data that unit tests, fuzz and soak harnesses import, and contract tests that run over them in unit/matrix/:

  • nodes.ts: 61 node shapes. Every LexicalNode subclass exported by lexical and the @lexical/* packages, every class in the playground's node list and the nodes its extensions register, in each mode that matters (token, segmented, tab, code highlight, hashtag, mention, emoji, keyword, ruby), inline elements that refuse text at their edges, inline and block decorators (with NodeState fields, nested editors, no text), named-slot hosts with declared and undeclared slots, every list type with a custom start and nesting, tables with merged cells and lists in cells, collapsibles, layouts and pages. downstream.ts adds stand-ins for shapes apps register that no package has: a component whose undeclared slots hold shadow roots of blocks, and inline atoms whose text comes from their state.
  • NodeCatalog.test.ts fails when a package or the playground exports or declares a node class that no shape builds, and when a shape's tags (lists, slots, tables) do not say what it builds, since tests select cells by them.
  • ids.ts: block id formats: random, positional, @lexical/y derived ids with their suffixes, duplicates awaiting repair, UUIDv7, counters, a legacy b + base 36 format, and mixed documents.
  • configs.ts: the configuration dimensions (BlockIdExtension enabled, disabled or absent; bind mode; headless or jsdom; createYDoc or a plain Y.Doc; schema versions; read-only causes; marks; IME; batched updates; onError; HTML export) and a sampled product that keeps every value.
  • bind.ts, peers.ts, ops.ts: seeding and binding the ways apps do; peers bound with @lexical/y, @lexical/yjs v1 and v2 with a delivery queue, app follow-up updates and an owner-gated table row repair; and edits from the core packages.

The contracts:

  • C1: a bound editor shows what a fresh readYState of its own document shows, and the derived fields (list value, start, <li value>, element textFormat/textStyle) an editor without collab derives, at the first read with nothing awaited, after microtasks, a task, a remote change and once settled; also with an app keeping the editor busy, across full reconciles, for read-only documents (every cause, with the documented warning and nothing written), with marks and mark flavors, batched updates, IME, onError that logs and a newer schema version; text content listeners get every change of the text.
  • C6: concurrent edits from the core packages on three peers settle (no update ping-pong) and converge, for each binding, also with app follow-ups and a row repair; concurrent table row and column inserts, enumerated, leave no duplicated cells.
  • HTML export and import with block ids, for every shape; marks under an input method in every container.
  • Ownership scans (OwnershipScans.test.ts, third to fifth commits, 224 cells): one client gives a range a value (bold, code, a comment, a link) or writes a node Lexical keeps apart (a tab, a token, a segmented node, a hashtag), types plain text elsewhere, writes the same again, and undoes 0 to 3 of those writes while a collaborator edits next to or inside the first one; every character is checked against a model of the edits (text nobody gave the value never has it, kept-apart nodes stay what they are), so a scan that takes a later write's item of the same client for its own write fails here.
  • Structural commands in block containers (StructuralCommands.test.ts, 700 cells): list conversion of each type, Enter in an empty last list item, pasting two paragraphs, a horizontal rule at the nearest root, $wrapNodes, the markdown block shortcuts, and Backspace and Delete at a container's edges, in the root, a container that declares nothing, one that declares children: 'block', a shadow root, each nested, a shadow root in a named slot, the downstream component's undeclared slot and a slot host's declared slot; unbound with NodeStructureExtension and bound in every bind mode, also with a full reconcile. Checked against a model of the rule (structural commands act inside shadow roots and declared containers; only shadow roots are editing edges).
  • CatalogSoakFuzz.test.ts: a seeded multi-peer soak over the catalog, every document holding a list next to a named-slot host.

Cells that fail on main because of a known bug are test.fails, selected exactly, with a comment naming the pull request or issue that fixes it, so each must pass once its fix lands (the table below). The catalog README says how to add a node class, id format or option, that new exports land with their catalog entry and contract, and what is not covered yet.

The default run takes about a minute; MATRIX_FULL=1 runs the full products.

Expected failures

Each is selected by an exact predicate in its test file, with a comment naming the fix. Cells marked for fixes that have merged since (#9573, #9540, #9584, #9578) are plain tests again.

Test file Cells Fixed by
C1FullReconcile.test.ts bound documents whose first text is formatted (4) #9553
C1FullReconcile.test.ts the unbound paragraph with a formatted first text (1) #9568
C7Html.test.ts a list item that only wraps a nested list (bullet, number, check) and a page, export on (4) #9606
StructuralCommands.test.ts every structural command in a declared container and in one inside another, and list conversion in a box inside a box (135) #9590

MATRIX_FULL=1 runs the full products (about 11,700 cells) to explore. Cells only the full run reaches are not marked, so it can fail on open issues (more seeds of #9581, #9583, the row repair races of #9582).

Not covered yet

  • The read-only cause where a read throws and the binding stops reading (readOnly: 'frozen').
  • Editors made with createEditor and a node list instead of extensions; the package's own tests bind those.
  • Contracts for open pull requests' new exports live with those pull requests until they merge.

Rule

A new node class, option or export lands with its catalog entry and a contract over it. NodeCatalog.test.ts enforces the node classes; the catalog README says how to add each kind.

Test plan

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

Before

The contracts on main, before marking the cells of known bugs:

 ❯ packages/lexical-y/src/__tests__/unit/matrix/C1ReadEqualsFresh.test.ts (435 tests | 139 failed)
 ❯ packages/lexical-y/src/__tests__/unit/matrix/C1Busy.test.ts (240 tests | 16 failed)
 ❯ packages/lexical-y/src/__tests__/unit/matrix/C1FullReconcile.test.ts (183 tests | 5 failed)
 ❯ packages/lexical-y/src/__tests__/unit/matrix/MarksIME.test.ts (56 tests | 26 failed)
 ❯ packages/lexical-y/src/__tests__/unit/matrix/CatalogSoakFuzz.test.ts (24 tests | 24 failed)
 ❯ packages/lexical-y/src/__tests__/unit/matrix/C6Settles.test.ts (360 tests | 1 failed)
   first read: numbering {"starts":[1,1],"values":[1,1,1,1,1,1]} != fresh {"starts":[1,1],"values":[1,2,3,1,2,3]}
   a full reconcile wrote to the document
   composed char 2 "e" ids [], expected ["c1"]
   B differs from A at /root/children/0/children/5/children/0

After

On main c514ea7, at the head:

$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/matrix
 Test Files  14 passed (14)
      Tests  2964 passed | 144 expected fail (3108)

$ pnpm run ci-check
Changeset check passed: 31 pending changeset(s) valid.
Found 0 errors

… id formats and configurations

## Description

The `@lexical/y` tests checked the shapes their authors thought of. Three bugs got through that way: list items numbered 1 in a freshly bound editor when the document holds a block with named slots, the documented `$setBlockText` throwing on a `TabNode`, and block handles that were long and typo-prone for ids that share a prefix. Each was one member of an input class nobody enumerated: blocks with slots, `TextNode` subclasses, id formats.

This adds catalogs of the input space under `packages/lexical-y/src/__tests__/catalog/`, as data that unit tests, fuzz and soak harnesses import, and contract tests that run over them in `unit/matrix/`:

- `nodes.ts`: 61 node shapes. Every `LexicalNode` subclass exported by `lexical` and the `@lexical/*` packages, every class in the playground's node list and the nodes its extensions register, in each mode that matters (token, segmented, tab, code highlight, hashtag, mention, emoji, keyword, ruby), inline elements that refuse text at their edges, inline and block decorators (with NodeState fields, nested editors, no text), named-slot hosts with declared and undeclared slots, every list type with a custom `start` and nesting, tables with merged cells and lists in cells, collapsibles, layouts and pages. `downstream.ts` adds stand-ins for shapes apps register that no package has: a component whose undeclared slots hold shadow roots of blocks, and inline atoms whose text comes from their state.
- `NodeCatalog.test.ts` fails when a package or the playground exports or declares a node class that no shape builds, and when a shape's tags (lists, slots, tables) do not say what it builds, since tests select cells by them.
- `ids.ts`: block id formats: random, positional, `@lexical/y` derived ids with their suffixes, duplicates awaiting repair, UUIDv7, counters, a legacy `b` + base 36 format, and mixed documents.
- `configs.ts`: the configuration dimensions (BlockIdExtension enabled, disabled or absent; bind mode; headless or jsdom; `createYDoc` or a plain `Y.Doc`; schema versions; read-only causes; marks; IME; batched updates; `onError`; HTML export) and a sampled product that keeps every value.
- `bind.ts`, `peers.ts`, `ops.ts`: seeding and binding the ways apps do; peers bound with `@lexical/y`, `@lexical/yjs` v1 and v2 with a delivery queue, app follow-up updates and an owner-gated table row repair; and edits from the core packages.

The contracts:

- C1: a bound editor shows what a fresh `readYState` of its own document shows, and the derived fields (list `value`, `start`, `<li value>`, element `textFormat`/`textStyle`) an editor without collab derives, at the first read with nothing awaited, after microtasks, a task, a remote change and once settled; also with an app keeping the editor busy, across full reconciles, for read-only documents (every cause, with the documented warning and nothing written), with marks and mark flavors, batched updates, IME, `onError` that logs and a newer schema version; text content listeners get every change of the text.
- C6: concurrent edits from the core packages on three peers settle (no update ping-pong) and converge, for each binding, also with app follow-ups and a row repair; concurrent table row and column inserts, enumerated, leave no duplicated cells.
- HTML export and import with block ids, for every shape; marks under an input method in every container.
- `CatalogSoakFuzz.test.ts`: a seeded multi-peer soak over the catalog, every document holding a list next to a named-slot host.

Cells that fail on main because of a known bug are `test.fails`, selected exactly, with a comment naming the pull request or issue that fixes it, so each must pass once its fix lands: the slot-block list numbering (#9573), derived text fields written by a full reconcile (#9553) or depending on render history (#9568), letters composed past the end of a marked run (#9540), and a `@lexical/yjs` v2 editor that shows a table row without the cells its own document has after concurrent row and column inserts (#9581). The catalog README says how to add a node class, id format or option, that new exports land with their catalog entry and contract, and what is not covered yet.

The default run takes about a minute; `MATRIX_FULL=1` runs the full products.

## Test plan

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

### Before

The contracts on main, before marking the cells of known bugs:

```
 ❯ packages/lexical-y/src/__tests__/unit/matrix/C1ReadEqualsFresh.test.ts (435 tests | 139 failed)
 ❯ packages/lexical-y/src/__tests__/unit/matrix/C1Busy.test.ts (240 tests | 16 failed)
 ❯ packages/lexical-y/src/__tests__/unit/matrix/C1FullReconcile.test.ts (183 tests | 5 failed)
 ❯ packages/lexical-y/src/__tests__/unit/matrix/MarksIME.test.ts (56 tests | 26 failed)
 ❯ packages/lexical-y/src/__tests__/unit/matrix/CatalogSoakFuzz.test.ts (24 tests | 24 failed)
 ❯ packages/lexical-y/src/__tests__/unit/matrix/C6Settles.test.ts (360 tests | 1 failed)
   first read: numbering {"starts":[1,1],"values":[1,1,1,1,1,1]} != fresh {"starts":[1,1],"values":[1,2,3,1,2,3]}
   a full reconcile wrote to the document
   composed char 2 "e" ids [], expected ["c1"]
   B differs from A at /root/children/0/children/5/children/0
```

### After

```
$ pnpm run ci-check
Changeset check passed: 31 pending changeset(s) valid.
Found 0 errors

$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/matrix
 ✓ |unit| packages/lexical-y/src/__tests__/unit/matrix/MarksIME.test.ts (56 tests)
 ✓ |unit| packages/lexical-y/src/__tests__/unit/matrix/CatalogSoakFuzz.test.ts (24 tests)
 ✓ |unit| packages/lexical-y/src/__tests__/unit/matrix/C1ReadOnly.test.ts (28 tests)
 ✓ |unit| packages/lexical-y/src/__tests__/unit/matrix/NodeCatalog.test.ts (7 tests)
 ✓ |unit| packages/lexical-y/src/__tests__/unit/matrix/C1FullReconcile.test.ts (183 tests)
 ✓ |unit| packages/lexical-y/src/__tests__/unit/matrix/C1Configs.test.ts (60 tests)
 ✓ |unit| packages/lexical-y/src/__tests__/unit/matrix/C7Html.test.ts (183 tests)
 ✓ |unit| packages/lexical-y/src/__tests__/unit/matrix/C6Tables.test.ts (720 tests)
 ✓ |unit| packages/lexical-y/src/__tests__/unit/matrix/C1TextContent.test.ts (32 tests)
 ✓ |unit| packages/lexical-y/src/__tests__/unit/matrix/C1Busy.test.ts (96 tests)
 ✓ |unit| packages/lexical-y/src/__tests__/unit/matrix/C6Settles.test.ts (360 tests)
 ✓ |unit| packages/lexical-y/src/__tests__/unit/matrix/C1ReadEqualsFresh.test.ts (435 tests)
      Tests  1973 passed | 211 expected fail (2184)
```

With #9573 applied, the 179 slot-block list cells report as passing unexpectedly, so its merge flips their marks.
@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:29pm UTC
lexical-playground Ready Ready Preview Oct 30, 2026 6:29pm 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:29 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@9588

@lexical/a11y

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

@lexical/clipboard

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

@lexical/code

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

@lexical/code-core

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

@lexical/code-prism

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

@lexical/code-shiki

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

@lexical/compiler

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

@lexical/devtools-core

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

@lexical/dragon

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

@lexical/eslint-plugin

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

@lexical/extension

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

@lexical/fast-check

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

@lexical/file

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

@lexical/hashtag

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

@lexical/headless

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

@lexical/history

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

@lexical/html

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

@lexical/internal

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

@lexical/link

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

@lexical/list

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

@lexical/mark

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

@lexical/markdown

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

@lexical/mdast

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

@lexical/offset

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

@lexical/overflow

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

@lexical/plain-text

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

@lexical/react

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

@lexical/rich-text

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

@lexical/selection

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

@lexical/table

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

@lexical/tailwind

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

@lexical/text

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

@lexical/utils

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

@lexical/y

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

@lexical/yjs

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

commit: b96a07e

…s that #9573 fixed

## Description

#9573 merged, so the slot-block list numbering cells pass on main: the 179 cells marked `test.fails` for it (139 in `C1ReadEqualsFresh.test.ts`, 16 in `C1Busy.test.ts`, the 24 seeds of `CatalogSoakFuzz.test.ts`) now report as passing unexpectedly. They are plain tests again. The other expected failures (#9553, #9568, #9540, #9581) stay.

## Test plan

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

### Before

On main `cad9015` with the contracts as they were:

```
 ❯ packages/lexical-y/src/__tests__/unit/matrix/CatalogSoakFuzz.test.ts (24 tests | 24 failed)
 ❯ packages/lexical-y/src/__tests__/unit/matrix/C1Busy.test.ts (96 tests | 16 failed)
 ❯ packages/lexical-y/src/__tests__/unit/matrix/C1ReadEqualsFresh.test.ts (435 tests | 139 failed)
      Tests  179 failed | 1973 passed | 32 expected fail (2184)
```

### After

On main `cad9015`:

```
 Test Files  12 passed (12)
      Tests  2152 passed | 32 expected fail (2184)

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

This looks good. Catalogs of node shapes, id formats and configurations, with contracts run over them, are a solid way to keep @lexical/y honest across shapes and bindings, and every expected failure names the open pull request or issue behind it. All the suggestions below are optional and non-blocking.

Suggestions

  1. C7Html.test.ts checks less than its header says (lines 9-14 against lines 106 and 139-143).
    • The export check fails only when no id is written, and the import check only when every imported id is new.
    • So two non-decorator shapes lose their data-block-id on export without a failure: the list item that wraps a nested list (list/*-nested) and the PageNode (page/content).
    • The reader is built from catalogAppExtension, which registers the nodes but not the DOMImportExtension rules their extensions add (for example RichTextExtension's). In that reader, headings, quotes and code blocks import as paragraphs, containers and slot hosts are flattened, and the YouTube, Figma and image decorators import as nothing.
    • Consider giving the reader those import rules and asserting both the exported ids and the imported top-level types, with explicit exceptions for what is intended. Anything that is a known bug can be test.fails, per the rule in catalog/README.md.
  2. C6Settles.test.ts never runs the table-transform follow-up its header describes (lines 15-18). No entry of VARIANTS (lines 150-156) sets followUp: 'tables', which catalog/peers.ts:333-344 implements. With those variants added, most seeded cells fail to settle within LIMIT under all three bindings, which is the ping-pong reported in #9582. catalog/README.md says a cell that fails because of a known bug is test.fails, selected exactly. Consider adding the variants with an exact predicate for #9582 so they flip when it is fixed, or dropping the sentence from the header.
  3. C1ReadOnly.test.ts promises checks it does not run (lines 12-17): a writable control and a derived-field comparison. An equal-schema cell that asserts status === 'active' and a changed state vector would show that the "nothing written" check can fail.
  4. Log noise. catalogAppExtension (catalog/bind.ts:67) uses TableExtension's default hasHorizontalScroll: true with no theme. Every rendered table therefore logs TableNode: hasHorizontalScroll is active but theme.tableScrollableWrapper is not defined., about 11k lines in each unit job log. configExtension(TableExtension, {hasHorizontalScroll: false}) would quiet it.

Nits

  • NodeCatalog.test.ts will now fail for anyone who adds a node class anywhere in the repo. An assertion message that points to catalog/nodes.ts and NODE_EXCLUSIONS, and perhaps a line in the testing section of AGENTS.md, would help them. Its "every shape builds" test (lines 286-292) also skips slot hosts; walking slots, as the tags test does, would cover them.
  • AGENTS.md asks for // @vitest-environment node in tests that need no DOM. NodeCatalog, C6Tables and C6Settles never touch it.
  • catalog/README.md:48-55 lists C2-C5 as standing contracts, but C4 and C5 have no matrix test yet and ID_FORMATS is not used by any test.
  • catalog/ids.ts:16-17 refers to a $positional helper that does not exist.
  • C1Configs.test.ts:11-12 mentions an update listener, but the code throws from a node transform.
  • The PR body's Before shows C1Busy.test.ts (240 tests | 16 failed), which is the MATRIX_FULL count; the default run has 96 cells.

A test-only change needs no changeset (CONTRIBUTING.md).

const missing = ids.filter(id => !written.includes(id));
// Blocks inside a host whose HTML export leaves them out
// (decorators that export no children) are not written.
if (missing.length > 0 && written.length === 0 && ids.length > 0) {

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 fails only when no id is written, so partial loss passes. The list item that wraps a nested list (list/*-nested) and the PageNode (page/content) both lose their data-block-id today without failing. Consider checking missing against an explicit allowlist of the decorators that export no children, and marking anything that is a known bug test.fails, per catalog/README.md.

return out;
}

const VARIANTS: Variant[] = [

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 header (lines 15-18) promises a table-transform follow-up, but no variant here sets followUp: 'tables' (catalog/peers.ts:333-344). With it added, most seeded cells fail to settle within LIMIT under every binding, which is the ping-pong reported in #9582. Per catalog/README.md, consider adding the variant with an exact test.fails predicate so it flips when #9582 is fixed.

ListExtension,
// Table transforms (row padding, merge normalization), as apps with
// tables register them.
TableExtension,

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: with the default hasHorizontalScroll: true and no theme, every rendered table logs TableNode: hasHorizontalScroll is active but theme.tableScrollableWrapper is not defined., about 11k lines per unit job log. configExtension(TableExtension, {hasHorizontalScroll: false}) keeps the transforms and drops the noise.

Comment on lines +16 to +17
// The same documents opened by a reader that can write (equal schema)
// stay editable, as a control.

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 file has no such control. Every cell is read only, and only numbering and text are compared, not the derived fields. An equal-schema cell asserting status === 'active' and a changed state vector would show that the "nothing written" check can fail.

… of one client

## Description

Parts of the binding decide that an item belongs to a write or a client: a run's own end, a hole a write opened, the run ends an undo keeps. A change to one of them passed every gate while it took a later write's item of the same client for part of the write it was scanning, so a format ran over the rest of the document. The catalog had no cells where the neighbouring items come from the same client but different writes.

`OwnershipScans.test.ts` adds them: B gives `Kilo` a value, types plain ` zz` elsewhere, gives `cc` the same value, then undoes 0 to 3 of those writes while A edits inside `Kilo` at the same time (types plain text, removes the value from `il`, or deletes a letter), for bold, code, a comment (set-valued NodeState) and a link, in both client id orders: 96 cells. Each asserts that the peers converge on what a fresh reader of the document shows, and checks every character against a model of the edits, not against another tree: text nobody gave the value never has it, text a live write gave it keeps it unless A removed it there, and text typed plain stays plain. Text inside a range whose write was undone is left free, since an undo racing a collaborator's edit inside it may leave the value there (a documented limit).

## Test plan

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

### Before

New cells. On a change that took a later write's item of the same client for its own (the pull request that introduced it, at the head the review caught):

```
 ❯ packages/lexical-y/src/__tests__/unit/matrix/OwnershipScans.test.ts (96 tests | 14 failed)
   × bold, A type plain inside the first run, B undoes 3 (ids 1,2)
     " " at 8 has the bold, "b" at 9 has the bold, ... (the bold runs to the end of the line)
      Tests  14 failed | 82 passed (96)
```

### After

On main `cad9015` and at this head:

```
 ✓ packages/lexical-y/src/__tests__/unit/matrix/OwnershipScans.test.ts (96 tests)
      Tests  96 passed (96)

$ pnpm run ci-check
Found 0 errors
```
…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
```
… 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")
```
zurfyx added a commit that referenced this pull request Oct 30, 2026
…s included, and drop the hole scan

## Description

Follow-up to the previous commits. They generalized rule 5 (a restore's
own null after the end of the run it relies on) beyond main's: the end of
any write in the run's chain counted (`runChain`), and a scan of the
writing client's later structs (`opensHole`, `followsText`) told a hole's
null from a real end. Each fix to that scan opened another shape: a
randomized two-client run-sequence fuzz (a client formats a run, types
plain text, formats a second run or its own just-typed word, then undoes
one to three of those writes while a collaborator types plain text inside
the first run) failed 187 of 1,000 seeds on the previous head, against 9
on `main`, and a word outside both runs took the format.

Rule 5 is now `main`'s with one change, the case this PR is for (S9-5): a
restore inside a run its own client opened is no longer left out. The run
is the one `runOpener` finds and its end is that opener's change end, as
on `main`; `runChain`, `opensHole` and `followsText` are gone. One own
null per end per write (`endedInThisWrite`) stays.

What this gives up from the previous commits, now pinned as expected
failures and also failing on `main`: a comment written with uncommented
text inside it, whose run ends with its restore's end rather than its
opener's ("written with a hole in it"), and the hole case with 64 comment
ids on the run (the one-id cases pass). S9-12b and the #9589 tie stay
pinned as before.

## Test plan

`RunSequenceUndo.test.ts` (new): the run-sequence fuzz, 200 seeds; only
seed 87 fails, which also fails on `main` (#9599). It covers the soak's
minimal shapes (seed 4: the second run on the writer's own just-typed
word, one undo; seed 8: three undos, the last after the collaborator's
text arrived). `OwnRunRestoreEnd.test.ts`: the review's three cases pass
in both orders; the two cases above are pinned.

### Before

```
$ RS_N=1000 pnpm exec vitest run --project unit <run-sequence fuzz, 1,000 seeds>
HITS 187/1000
```

### After

```
$ RS_N=1000 pnpm exec vitest run --project unit <run-sequence fuzz, 1,000 seeds>
HITS 9/1000            (main: 9/1000, the same seeds)
$ pnpm exec vitest run --project unit packages/lexical-y packages/lexical-mark
 Test Files  182 passed | 2 skipped (184)
      Tests  8656 passed | 53 expected fail | 4 skipped (8713)
```

The catalog's ownership cells (`OwnershipScans`, #9588) all pass.
`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.

This looks good to me. It adds catalogs and contract tests only, nothing that ships, and it is a real step up for @lexical/y coverage. With every test.fails marker removed, exactly the 36 marked cells fail, each with the symptom its comment names, so the predicates are exact. Everything below is optional and non-blocking.

  1. Marker hand-off to the open fixes. #9553, #9540, #9578 and #9561 predate these files. Whichever of them lands after this one has to delete its markers in the same change, or the unit job on main fails on cells that now pass. The markers are at C1FullReconcile.test.ts:166-180, MarksIME.test.ts:189-194 and OwnershipScans.test.ts:303-307; C6Settles.test.ts:166-171 waits on #9581. That is the rule the catalog README states (README.md:56-58), and it may be worth noting in those PRs.
  2. C7Html.test.ts:106 checks less than the header (lines 9-14) promises. It fails only when export-on writes no data-block-id at all. BlockIdExtension also identifies list items and table-cell paragraphs, so losing their ids while keeping the top-level one would pass. Requiring missing to be empty, except for the shapes whose export omits children, would close that gap.
  3. Some headers describe cells that don't exist.
    • C1ReadOnly.test.ts:16-17 describes an equal-schema control that stays editable, but all 28 cells expect readonly.
    • C6Settles.test.ts:15-18 describes a 'tables' follow-up that no VARIANTS entry uses.
    • C1Configs.test.ts:11-12 says an update listener throws. The cell actually registers a throwing transform, and never checks that onError received the error.
  4. The README's standing contracts. README.md:48-55 lists C2–C5, but nothing here implements C4 (handles over every id format) or C5 (linear cost), and nothing yet consumes ID_FORMATS in ids.ts or configProduct's limit/filter. Listing them under "Not covered yet" until their tests land would keep the rule accurate.
  5. State the node-catalog rule in AGENTS.md. Under NodeCatalog.test.ts, a new node class in any package or in the playground fails the @lexical/y suite until it gets a shape. AGENTS.md is where contributors and agents look for repository conventions. A line there, plus a pointer to the catalog README in the failure message, would tell them before CI does.
  6. Small ones.
    • NodeCatalog.test.ts:287-292 skips the class check for slot-host shapes, though the walker at lines 205-217 already visits slots.
    • C1TextContent.test.ts:86-90 waits out the playground collapsible's toggle listener, which throws on a removed node (CollapsibleContainerNode.ts:86-89). The README rule asks for a marked cell for a known bug instead.
    • The branch predates #9573, so on its own it fails the cells the second commit un-marked; merged with main it is green. A rebase would make the branch green by itself.

const missing = ids.filter(id => !written.includes(id));
// Blocks inside a host whose HTML export leaves them out
// (decorators that export no children) are not written.
if (missing.length > 0 && written.length === 0 && ids.length > 0) {

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 fails only when export-on writes no data-block-id at all. BlockIdExtension also identifies list items and table-cell paragraphs ($defaultIsIdentifiedBlock), so dropping their ids while keeping the top-level one would pass. Requiring missing to be empty, except for the shapes whose export omits children, would match the header at lines 9-14.

Comment on lines +16 to +17
// The same documents opened by a reader that can write (equal schema)
// stay editable, as a control.

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 file has no equal-schema control. All 28 cells expect readonly, so a binding that opened every document read only would still pass here. Either add the control (same documents, a reader that can write, status === 'active', and a local edit that reaches the doc) or drop the sentence.

Comment on lines +150 to +156
const VARIANTS: Variant[] = [
{followUp: 'none', repair: 'none', tables: false},
{followUp: 'empty', repair: 'none', tables: false},
{followUp: 'none', repair: 'owner-rows', tables: false},
{followUp: 'none', repair: 'none', tables: true},
{followUp: 'none', repair: 'owner-rows', tables: true},
];

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 header (lines 15-18) and CatalogPeer.followUp describe a 'tables' follow-up, but no variant here uses it, in the default or the MATRIX_FULL run. Either add e.g. {followUp: 'tables', repair: 'none', tables: true} or drop it from the header.

expect(
types.has(klass.getType()) ||
// Slot values are not children (they ride a separate map).
shape.kind === 'slot-host',

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 skips the class check for every class of a slot-host shape. The walker in the tags test (lines 205-217) already descends into $getSlotNames, so reusing it here would also verify SlotContainerNode and DownstreamComponentSlotNode.

Comment on lines +48 to +55
- Every new or changed public export ships with a matrix test in
`unit/matrix/` that runs its contract over the catalogs that apply: every
node shape, id format and configuration at least once. The standing
contracts: a bound editor shows what a fresh `readYState` of its document
shows (C1), documented input never throws (C2), untrusted input is refused
rather than thrown on (C3), handles stay short and typo safe for every id
format (C4), and cost stays linear with every extension enabled and
disabled (C5).

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: nothing in this PR implements C4 or C5, and nothing consumes ID_FORMATS yet. Listing them under "Not covered yet" until their tests land would keep this rule accurate for contributors. The node-catalog rule above may also deserve a line in AGENTS.md, where contributors look for repository conventions.

Comment on lines +86 to +90
// Rendered elements' own events (a <details> element's toggle) run
// before the next edit, as they do between a user's edits: the
// playground's collapsible reads its node in that listener, and
// throws if the node was removed in the meantime.
await new Promise(resolve => setTimeout(resolve, 0));

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 waits out a playground bug. The collapsible's toggle listener reads this.getOpen() on a node that may have been removed (CollapsibleContainerNode.ts:86-89, the <details> path). Per the rule at README.md:56-58, a marked cell naming an issue would keep it visible instead of hidden behind the await.

…p the table follow-up claim

## Description

Review found that the HTML contract (`C7Html.test.ts`) said every identified block's id is exported once, but only failed when no id at all was written, and its import check passed when only some blocks kept their ids.

- Export on: every block id must now be written exactly once, and no other id, except ids inside the slots of a host whose HTML leaves its slots out (the downstream component stand-in).
- Import: every written id must come back on exactly one imported block of the same type, wherever import makes blocks of that type (it makes none for nodes without an importer, such as the playground's containers and decorators).

The stricter check finds 4 cells on main: a list item that only wraps a nested list (bullet, number and check) and a page write no id, since neither has an element of its own in exported HTML (Lexical writes a nested list inside the previous item's element, and a page writes only its content), so the round trip gives them new ids. They are `test.fails` naming #9606.

`C6Settles.test.ts` described a variant in which every peer runs the table transforms after each remote change, which it did not run: that plugin does not settle (#9582, pinned by its own repro). The comment and the variant type now say so. The catalog registers `TableExtension` without a horizontal scroll wrapper, which needs a theme class the catalog does not define, so the table cells no longer log a warning each (about 2.4 MB of output in a default run).

## Test plan

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

### Before

On main `cad9015`, with the export check requiring every id:

```
   × list/bullet-nested, export on    id ... written 0 times
   × list/number-nested, export on
   × list/check-nested, export on
   × page/content, export on
```

### After

On main `cad9015`:

```
 ✓ |unit| packages/lexical-y/src/__tests__/unit/matrix/C7Html.test.ts (183 tests)
 Test Files  13 passed (13)
      Tests  2368 passed | 40 expected fail (2408)
```

Output of the default run: 22 KB, no table warnings.

```
$ pnpm run ci-check
Found 0 errors
```
…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
```
… containers

## Description

The catalog contracts read, sync and export documents holding block containers, but never ran the structural commands in them, so a change to where those commands act (#9590) was not covered. `StructuralCommands.test.ts` adds the cells: list conversion of each type (bullet, number, check), Enter in an empty last list item, pasting two paragraphs mid-text (`insertNodes`), a horizontal rule at the nearest root (`$insertNodeToNearestRoot`), and the markdown shortcuts `# `, `- ` and `> `, in the root, a container that declares nothing, one that declares `children: 'block'`, a shadow root, each nested in one of its own kind, and a shadow root held in a named slot of a block decorator; in an unbound editor with `NodeStructureExtension` and in an editor bound with `@lexical/y` (which always enforces structure): 144 cells.

Each cell's expected outcome comes from a model of the rule, checked with predicates on the result rather than another tree's output: where structure is enforced, the command acts inside a shadow root or a class declaring `children: 'block'` (the list is in the container next to the other paragraph, Enter leaves the list for a new paragraph in the container, the pasted paragraphs stay apart in it, the rule is inside it, the shortcut converts in it, and the container count does not change), while a container that declares nothing is one more block; and a list conversion in a box inside a box acts in place (#9570).

On main, the 42 cells where main does not follow the rule yet are `test.fails` naming #9590: every command in a declared container and in a declared container inside one (36), and list conversion in a box inside a box (6).

## Test plan

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

### Before

New cells. Without the `test.fails` marks, on main `04e899d`:

```
      Tests  42 failed | 102 passed (144)
```

### After

On main `04e899d`:

```
      Tests  102 passed | 42 expected fail (144)
```

With #9590 (`8a7471f`), every cell follows the rule; the 42 marked cells pass:

```
      Tests  42 failed | 102 passed (144)    (the 42 are "Expect test to fail")
```

```
$ pnpm run ci-check
Found 0 errors
```
…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
```
…tructural edge rows

## Description

Review found that the Backspace and Delete rows of `StructuralCommands.test.ts` counted any result other than the exact blocked shape as joining across the edge, so a Backspace that deleted the paragraph instead of joining it would pass. Each edge row now checks every paragraph of the document: blocked means every paragraph is as it was and the one at the edge is still in the container; joined means the two paragraphs at the edge became one (`beforeabc` for Backspace, `defafter` for Delete) and the others are as they were. Any other result fails with the paragraphs it left.

## Test plan

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

### Before

The edge rows passed for a Backspace that deletes the paragraph at the edge (paragraphs `["before", "def", "after"]`).

### After

That result now fails (`paragraphs ["before","def","after"]`). On main `c514ea7`:

```
      Tests  565 passed | 135 expected fail (700)
```

On main with #9590:

```
      Tests  135 failed | 565 passed (700)    (the 135 are "Expect test to fail"; no edge row among them)
```

```
$ pnpm run ci-check
Found 0 errors
```
…run next to another run of the same key

## Description

Review of a change to the binding's run ends found a case the ownership scan cells missed: a comment with one plain write inside it and a second comment right after it; the writer undoes the write while a collaborator who saw it deletes past the comment's end, so the deleted gap holds the second comment's items. The change spread the first comment to the end of the document; main keeps it where it was.

`OwnershipScans.test.ts` gets a family for that shape: a run (a comment, a link, or a bold run) with a neighbouring run of the same key right after it (a second comment, another link, an italic run), one write of plain text inside the first run (at its start, middle or end), then the writer's undo while the collaborator deletes past the run's end (1, 2 or 3 characters, or its last character and then the next word), with and without `BlockIdExtension`, in both client id orders: 144 cells. Each asserts convergence on what a fresh reader shows and checks every character against a model of the edits (the collaborator deletes from the text with the write, the undo takes the write out): the first run keeps its value, the neighbour keeps its own, and nothing else gains or loses one, on the next line too.

All pass on main. On the change the review caught, the comment cells with the write at the run's end and a delete of 2 or more characters fail (12 cells, both id orders, with and without block ids).

## Test plan

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

### Before

New cells. On the change the review caught:

```
   × comment, write at 6, B deletes 2 characters (ids 1,2)
   × comment, write at 6, B deletes 3 characters (ids 1,2)
   × comment, write at 6, B deletes the last character, then the next word (ids 1,2)
   (each also with ids 2,1 and with block ids)
```

### After

On main `c514ea7`:

```
 ✓ packages/lexical-y/src/__tests__/unit/matrix/OwnershipScans.test.ts (368 tests)

$ pnpm run ci-check
Found 0 errors
```
@zurfyx
zurfyx added this pull request to the merge queue Oct 30, 2026
Merged via the queue into main with commit 35981ee Oct 30, 2026
53 checks passed

This branch was successfully deployed

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