Skip to content

[lexical-playground][lexical-website][lexical-y] Feature: Keep a bound editor showing what the document holds, and open documents a newer app version wrote read only - #9510

Merged
zurfyx merged 13 commits into
mainfrom
zurfyx/y-incremental-read-marks
Oct 29, 2026

Conversation

@zurfyx

@zurfyx zurfyx commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

Note

Stack, part 10 of 10. Based on #9507 (zurfyx/y-composition-holdback). Review only this PR's own commits.

The @lexical/y fixes touch the same writer, cursor, reader and binding internals, so they are one linear stack: each PR is based on the one below it, each conflict is resolved once, in the PR whose change causes it, and every CI run tests the real combination.

Merge order: bottom first, #9509 → #9523 → #9522 → #9505 → #9508 → #9502 → #9504 → #9527 → #9507 → #9510. With squash merges, rebase the next PR onto main before merging it (git rebase --onto main <old base branch>).

The stack
# PR Branch Base
1 #9509 zurfyx/update-error-rollback main
2 #9523 zurfyx/write-before-unread-change #9509
3 #9522 zurfyx/y-keep-chars-at-format-boundary #9523
4 #9505 zurfyx/y-undo-member-closer #9522
5 #9508 zurfyx/y-initial-state-binding #9505
6 #9502 zurfyx/y-reorder-intent #9508
7 #9504 zurfyx/y-preserve-unknown-attrs #9502
8 #9527 zurfyx/y-move-keeps-unknown-fields #9504
9 #9507 zurfyx/y-composition-holdback #9527
10 #9510 (this PR) zurfyx/y-incremental-read-marks #9507

Follow-ups for after the stack (not in it):

Schema version gate (new, October 29)

Important

New on top of this PR: commit e19f32430. It sits on the same read-only and fail-closed path as the commits below, so it lands here rather than as its own PR. It changes the public API of the unreleased @lexical/y.

What it does. An app declares the version of its document schema. The document records the highest version that ever wrote to it, and a client older than that record opens the document read only, with a typed warning (the "Google Docs" rule). Before, an older client kept editing with fields and node types it does not know. #9504's preservation of unknown fields stays as defense in depth, for fields a newer version removes and for apps that do not bump.

  • API change: schemaVersion: number is required on YCollabExtension, YBindingOptions (new YBinding(editor, doc, {schemaVersion})) and the @lexical/y/headless config. A config key cannot be required by type, so the extension throws without one, with the fix in the message: YCollabExtension needs schemaVersion (got none): start at 1 and increase it whenever a node's stored fields or node types change. YBinding and the headless helpers require it in their TypeScript and Flow types too. @lexical/y has never been published, so no released API breaks. Every caller in the repo passes it: tests, the playground and the docs.
  • Record. lexical-y-schema:<n> = true on the root, one key per version, grow-only like the layout record. The document's version is the highest n. Two versions that write at once both record theirs, and undo never removes one.
  • Write. A client records its version in the same transaction as its first write: a local edit, initialize() of a new document, seedYState or editYState. Opening records nothing. Follow-ups (block id naming, refreshes) record nothing. A write the app's own transaction makes to another shared type does not count.
  • Gate. A client older than the record goes read only through the sticky read-only path. Its local edits are refused and read again (UNSUPPORTED_EDIT_WARNING). It reports the new UNSUPPORTED_SCHEMA_WARNING (@lexical/y/unsupported-schema) with {reason: 'newer', found, supported}, or 'malformed'. This also happens mid-session, when a newer client's first write arrives:
    • Local updates made before the change was read are written first. That covers updates that were pending and the update that was running when the change arrived.
    • An update made on the newer record is refused. That covers updates from the app's Yjs observers and updates after a read that recovered.
    • An older client never initializes a document a newer one recorded.
    • Headless: an older editYState or readYState throws a 'read-only' YHeadlessError carrying the warning, and writes nothing.
  • Equal or higher version edits as before and keeps fields it does not declare. That is what lets a newer version remove a field.
  • Docs and playground. y.md has a new section, "Versioning your schema": when to bump, what older clients see, and how to render the warning. The option and the warning are in y.md's tables. The "Older app versions" limit (including the split case) now applies only between equal versions. y-server.md and the README's wire format describe the record. The playground's read-only notice shows the new reason ("edited with a newer version of the playground. Reload the page"), and ?collabYSchemaVersion=<n> overrides the playground's version.
  • Changeset: .changeset/lexical-y-schema-version.md ([feature], patch, with Verify:).

Tests. SchemaVersion.test.ts has 21 cases:

  • the gate on open and its warning data, and refused local edits;
  • opening writes nothing, and the first write carries the record in the same update;
  • mid-session gating that keeps what is shown, and a pending local edit written before the gate;
  • concurrent versions: the max wins on every client;
  • undo and redo never remove the record;
  • malformed records, and no initialize under a newer record;
  • a recovery after 20 queued listener updates still gates;
  • a record that arrives alone, both inside a running update and with nothing pending;
  • an observer's update on the newer record is refused, with or without a pending local update;
  • a deferred gate is dropped on dispose();
  • writes in the app's own transaction;
  • headless: seedYState and editYState record the version, and an older editYState refuses;
  • the required config, both at runtime and as @ts-expect-error type tests;
  • a newer client editing a field it no longer declares.

Every other suite changes only by passing schemaVersion. A collab e2e case covers the playground notice. A gpt-6.1-sol review found 7 issues over two rounds, all fixed with regression tests above.

Before / after

Before (the new test file run on this PR's previous head, 66bb2a79e):

$ npx vitest run --project unit packages/lexical-y/src/__tests__/unit/SchemaVersion.test.ts
 FAIL  |unit| packages/lexical-y/src/__tests__/unit/SchemaVersion.test.ts > schema version gate > an older client opens a newer document read only, with the warning
AssertionError: expected 'active' to be 'readonly' // Object.is equality
 Test Files  1 failed (1)
      Tests  20 failed | 1 passed (21)

After:

$ npx vitest run --project unit packages/lexical-y/src/__tests__/unit/SchemaVersion.test.ts
 Test Files  1 passed (1)
      Tests  21 passed (21)

$ npx vitest run --project unit packages/lexical-y packages/lexical/
 Test Files  265 passed | 2 skipped (267)
      Tests  9824 passed | 46 expected fail | 4 skipped (9874)

$ pnpm run ci-check
Changeset check passed: 20 pending changeset(s) valid.
Found 0 errors
(exit 0: tsc, Flow, Prettier, ESLint)

The collab e2e specs could not run locally. The machine's sandbox lets Chromium start only with --single-process, which cannot open the second browser context the collab specs need. The existing layout-gate spec fails the same way there. CI runs them.

Description

Every read here goes through #9504's guardedRead, and the read-only rules build on its fail-closed state. Five commits keep a client's bound editor showing what a fresh read of the document shows. Each commit message carries its own description and before/after output.

  1. Bind only documents from createYDoc. Yjs cleans up formats by default (doc.cleanupFormatting). A client that applied a stored state to a plain new Y.Doc() before binding had already deleted format items the binding keeps. An edit typed inside a commented word that another client deleted then showed without the comment on that client (paragraph("015")) and with it everywhere else (paragraph("015"[c3])).
    • createYDoc(state?, options?) is now exported by @lexical/y too. It turns the cleanup off before anything is applied and returns a branded YDoc.
    • YCollabExtension's doc and new YBinding take only a YDoc (TypeScript and Flow).
    • At runtime, a document with the cleanup on makes the binding read only, with UNSUPPORTED_CONFIG_WARNING (reason: 'cleanup-formatting').
    • The docs and the playground create bound documents with createYDoc.
    • @lexical/y has not been released, so the narrower doc type breaks no published API.
  2. Place what a remote read brings by the blocks around it, never inside a node it moved.
    • A client read only for an unregistered node type showed a paragraph added after such a node at the top of its editor, and kept a line merged away next to one.
    • A remote read into an editor showing an unwritten update could also place a node inside itself, as a child or as a named slot's value. Lexical's walk up the tree then never ended, and the tab hung.
    • The region read now anchors on the node before the old content when the read leaves it in place, and otherwise on the nearest unit before the region that the container still holds.
    • Every placement is checked first. A read that would make a node its own ancestor throws instead, and the binding warns (RECOVERED_READ_WARNING) and reads the document again.
  3. Read again what a local update changes while the binding is read only.
    • Code calling editor.update() on a read-only bound editor left the change on screen, unwritten. A remote read into that editor could then misplace blocks or hang.
    • The binding now reads the changed containers again and warns with the new UNSUPPORTED_EDIT_WARNING (reason: 'read-only'). Composition-time changes are read again when the composition ends.
    • Separate updates keep their own re-read budget.
    • Left as before: updates tagged SKIP_COLLAB_TAG, updates while the binding is pending, and updates after the binding went read only to keep showing a change it cannot store (an unstorable slot).
  4. Keep a binding read only for good, and warn once about local edits.
    • A change to the layout record made a read-only binding 'active', and editable, while it read the document again, and repeated the cause's warning. setStatus now stays 'readonly' while a lasting cause holds: an unsupported configuration, node types it cannot build, or a change it cannot store.
    • The refusal applies to any status but 'active', and UNSUPPORTED_EDIT_WARNING is reported once each time the binding goes read only.
  5. After the binding failed closed ([lexical-website][lexical-y] Bug Fix: An older client keeps the fields and content it cannot read #9504), a local update goes back to what the editor showed.
    • The binding reads nothing more then, so it cannot read the update's change again. Before this change, the editor kept showing it; before reads were guarded, the failing re-read left the editor empty.
    • It now shows the earlier state again, reading nothing, and warns once. Failing closed also keeps the binding read only for good.

Merge order. #9501, #9503 and #9508 add tests that bind new Y.Doc(): TableOnOpen.test.ts, MarkStateBlockType.test.ts and regressions/InitialState.test.tsx. With this PR they need createYDoc() and the YDoc type.

  • If they land first, this PR rebases after them and switches those tests (a mechanical change, verified on the combination).
  • If this PR lands first, they switch on their rebase.

Restack (October 29)

Rebased onto #9507 (it was based on #9504). One conflict, with #9508 in y.md: both texts combined (synced: true initializes as soon as the binding starts, and createYDoc(stored) is named). The first commit, which brands bound documents, now also moves the tests of PRs below it that bind a plain Y.Doc to createYDoc: #9523's BatchedWrites.test.ts and #9508's regressions/InitialState.test.tsx (N8).

New commits, each with a regression test named after its finding:

  • N3: one restore operation, showAgain, for both failClosed and restoreShown: it sets the earlier state whole, or clears the root where that state is empty, and commits it when it runs from a commit's $onUpdate callbacks. A local update after opening the room failed closed no longer throws setEditorState: the editor state is empty and no longer stays shown; this is the unhandled error that turned this PR's CI red (N3: in SchemaSkew.test.ts).
  • N7: read only is sticky for a class that throws on stored content too (state.unreadable is in staysReadOnly, the one read-only predicate), and a binding becomes active only at the end of its first full read, once that read found nothing that keeps it read only. Before, a layout-record change flipped such a binding to active and back, and a first open published active before the read (N7: in SchemaSkew.test.ts).
  • App updates during a status change (review findings R2-2 to R6-1; one commit): the binding becomes active right after the first read's commit, so nothing an app does when the editor becomes editable (an editable listener's update or command) runs inside the read; a focused editor still gets the read's caret, set ahead of the app's queued replies and not at all where the app set an editor state of its own. One owner, notifyApart, notifies the app apart from the binding's own updates: updates its listeners queue meanwhile are handed back to Lexical's queue right after the binding's update commits, in order, after what the app queued before them and ahead of what it does next, and commit on their own, written or refused like any local edit; dispose() hands them back to the editor; a listener's error is rethrown in a microtask. Failing closed shows the earlier state again before going read only. Before, opening a room such an edit was shown but never stored, a frozen editor kept it for good, and a throwing listener lost it or let its error out through Y.applyUpdate (R2-* to R6-* in SchemaSkew.test.ts and Focus.test.ts). Decision for Gerard: during a freeze nested in the app's own editor.update, an app listener that throws on its reply's commit surfaces from that editor.update (as an app's own queued update would), never from Y.applyUpdate; keeping the reply out of the app's update instead would reopen the ordering problem above. These fixes use Lexical internals (_updates, _updating, _pendingEditorState, _deferred); a Lexical hook for running an update apart from the one in progress would remove that coupling.
  • Docs: the UNSUPPORTED_EDIT_WARNING row says that once the document cannot be read, the editor shows again what it showed; the setup notes say the editor is read only until the document has been read and found writable.

Fixed after a randomized soak test of the whole stack:

Changeset

  • .changeset/lexical-y-schema-version.md ([feature], the schema version gate above)
  • .changeset/lexical-y-create-ydoc.md
  • .changeset/lexical-y-region-placement.md
  • .changeset/lexical-y-read-only-edits.md

The other three are [fix] patches to @lexical/y.

Test plan

New tests:

  • FormatCleanup.test.ts: a stored state loaded before binding, plus a randomized lane against a fresh read.
  • UnknownNodeReads.test.ts
  • EditorDrift.test.ts, with and without BlockIdExtension:
    • reads into an editor that shows an update tagged SKIP_COLLAB_TAG;
    • local updates on a read-only client, including list numbering, composition, and an unstorable slot.

Before

Abbreviated; full output is in each commit message.

On main, a client that applies the stored state to a plain Y.Doc and binds:

bound: paragraph("015")
fresh: paragraph("015"[c3])
status: active, warnings: 0

UnknownNodeReads.test.ts at the first commit:

Expected: "paragraph("one") / paragraph("two") / paragraph("after")"
Received: "paragraph("after") / paragraph("one") / paragraph("two")"
      Tests  3 failed | 1 passed (4)

EditorDrift.test.ts at the first commit never finishes (killed by timeout 60, exit 124). A production build of the slot case never returns from Y.applyUpdate.

At the second commit, the read-only tests fail:

Expected: "paragraph("one") / quote(paragraph("inner"))"
Received: "paragraph("one" br "x") / paragraph("inner") / quote()"
AssertionError: expected [] to deeply equal [ { reason: 'read-only' } ]
AssertionError: expected [ [ 100, 2 ], [ 101, 2 ], …(4) ] to deeply equal [ [ 1, 2 ], [ 1, 2 ], [ 1, 2 ], …(3) ]
Tests  8 failed | 10 passed (18)

After

FormatCleanup.test.ts        Tests  35 passed (35)   (STORED_STATE_FUZZ_SEEDS=300: 300 passed)
UnknownNodeReads.test.ts     4 passed
EditorDrift.test.ts          Tests  22 passed (22)
$ ./node_modules/.bin/vitest --run --project unit packages/lexical-y
 Test Files  151 passed | 2 skipped (153)
      Tests  5030 passed | 18 expected fail | 2 skipped (5050)
$ pnpm run ci-check
Found 0 errors
$ ./node_modules/.bin/vitest --run --project scripts-unit scripts/__tests__/unit/treeShakingSource.test.ts scripts/__tests__/unit/changesets.test.ts
      Tests  406 passed (406)

The collab Y e2e specs (Collaboration.spec.mjs, CollabY*.spec.mjs) pass in Chromium: 31 passed. Firefox and WebKit were not run.

@vercel

vercel Bot commented Oct 29, 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 29, 2026 8:18pm UTC
lexical-playground Ready Ready Preview Oct 29, 2026 8:18pm 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 29, 2026
@pkg-pr-new

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

Copy link
Copy Markdown
lexical

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

@lexical/a11y

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

@lexical/clipboard

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

@lexical/code

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

@lexical/code-core

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

@lexical/code-prism

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

@lexical/code-shiki

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

@lexical/compiler

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

@lexical/devtools-core

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

@lexical/dragon

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

@lexical/eslint-plugin

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

@lexical/extension

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

@lexical/fast-check

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

@lexical/file

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

@lexical/hashtag

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

@lexical/headless

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

@lexical/history

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

@lexical/html

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

@lexical/internal

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

@lexical/link

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

@lexical/list

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

@lexical/mark

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

@lexical/markdown

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

@lexical/mdast

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

@lexical/offset

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

@lexical/overflow

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

@lexical/plain-text

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

@lexical/react

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

@lexical/rich-text

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

@lexical/selection

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

@lexical/table

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

@lexical/tailwind

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

@lexical/text

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

@lexical/utils

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

@lexical/y

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

@lexical/yjs

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

commit: 459f552

@zurfyx
zurfyx force-pushed the zurfyx/y-incremental-read-marks branch from 351618e to 7d8ec0c Compare October 29, 2026 02:41
@zurfyx
zurfyx changed the base branch from main to zurfyx/y-preserve-unknown-attrs October 29, 2026 02:41
@zurfyx
zurfyx force-pushed the zurfyx/y-preserve-unknown-attrs branch from 7a4a983 to 7fcd687 Compare October 29, 2026 05:53
@zurfyx
zurfyx force-pushed the zurfyx/y-incremental-read-marks branch from 7d8ec0c to bb10bbe Compare October 29, 2026 05:53
@zurfyx
zurfyx changed the base branch from zurfyx/y-preserve-unknown-attrs to zurfyx/y-composition-holdback October 29, 2026 05:53
@zurfyx zurfyx changed the title [lexical-playground][lexical-website][lexical-y] Bug Fix: Keep a bound editor showing what the document holds [lexical-playground][lexical-website][lexical-y] Feature: Keep a bound editor showing what the document holds, and open documents a newer app version wrote read only Oct 29, 2026
@zurfyx
zurfyx force-pushed the zurfyx/y-composition-holdback branch from 16a85fb to 005b397 Compare October 29, 2026 19:50
zurfyx and others added 13 commits October 29, 2026 16:11
…ocuments from createYDoc, so that loading a stored state never cleans up formats

A client that applies a stored state to a plain `new Y.Doc()` and then binds the editor can show text without a format that every other client shows. Yjs cleans up formats by default (`doc.cleanupFormatting`): after a transaction, it deletes format items that only wrap deleted text. The binding keeps those items, because text a collaborator types into concurrently deleted text keeps the formats it was typed with only while they exist. Once bound, the binding opts its containers out of the cleanup, but a document that applied a stored state before it was bound has already run it. For example, after an edit typed inside a commented word that another client deleted, that client showed `paragraph("015")` while a fresh read of the same updates showed `paragraph("015"[c3])`, comment included. The deletes the cleanup makes are also a write of that client's own, which syncs to everyone.

This change makes such a document hard to bind, and loud when bound anyway:

- `createYDoc(state?, options?)` is now exported by `@lexical/y` as well as `@lexical/y/headless`. It turns the cleanup off before anything is applied and returns a `YDoc`, a branded `Y.Doc`. Yjs's `Doc` options (`gc`, `guid`, `gcFilter`, ...) pass through, typed as `YDocOptions` in TypeScript and Flow.
- `YCollabExtension`'s `doc` and `new YBinding(editor, doc)` take a `YDoc`, so a plain `Y.Doc` is a type error in TypeScript and Flow.
- At runtime, a document whose `cleanupFormatting` is not `false` (a JavaScript caller, a cast, or the flag turned back on) makes the binding read only, with `UNSUPPORTED_CONFIG_WARNING` and `reason: 'cleanup-formatting'`. It fails closed: such a document may already have lost items, so the binding writes nothing from it.
- The docs, the playground and the tests create bound documents with `createYDoc`.

`@lexical/y` has not been released yet, so the narrower `doc` type breaks no published API.

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

- a document from `createYDoc(state)` shows what a fresh reader shows, comment included;
- a plain `Y.Doc` that applied the state is refused: read only, a typed warning, nothing written;
- an empty plain `Y.Doc` is not initialized;
- a plain `Y.Doc` is a type error (`@ts-expect-error`, and `flow/__flowtests__/createYDoc.js.flow`, which also checks the options);
- a randomized lane: a stored state with concurrent edits, loaded with `createYDoc` and bound, compared with a fresh read (30 seeds by default, more with `STORED_STATE_FUZZ_SEEDS`).

`./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/FormatCleanup.test.ts`

On main, where `createYDoc` is only in `@lexical/y/headless`, a client that applies the stored state to a plain `Y.Doc` and binds:

```
bound: paragraph("015")
fresh: paragraph("015"[c3])
status: active, warnings: 0
```

The randomized lane, binding a plain `Y.Doc` as apps do on main (seeds 13 and 22 of 30 fail):

```
seed 13
Expected: "paragraph("t9zt" "4"{2}[c3]) / paragraph("t1z t2" "z t3"{1} "z")"
Received: "paragraph("t9z" "t"{1} "4"{3}[c3]) / paragraph("t1z t2z t3"{1} "z")"
seed 22
Expected: "paragraph("t9zt4z t5z" " t6z"[c2]) / paragraph("t1z "{2} "t2"{2}[c3] "z t"[c3] "3"[c1,c3] "z"[c3])"
Received: "paragraph("t9z" "t4z t5z"{2} " t6z"{2}[c2]) / paragraph("t1z "{2} "t2"{2}[c3] "z t"[c3] "3"[c1,c3] "z"[c3])"
```

```
✓ a remote format change in a table this client deleted: no format cleanup, nothing written 128ms
✓ a document that loads a stored state before the binding (S7-14) > a document from createYDoc(state) shows what a fresh reader shows, the comment on the insert included 58ms
✓ a document that loads a stored state before the binding (S7-14) > a plain Y.Doc that applied the state has deleted the comment there: the binding refuses it, read only, with a typed warning 41ms
✓ a document that loads a stored state before the binding (S7-14) > an empty plain Y.Doc is not initialized 4ms
✓ a document that loads a stored state before the binding (S7-14) > the binding takes only a document createYDoc made, as a type too 5ms
      Tests  35 passed (35)
```

With `STORED_STATE_FUZZ_SEEDS=300 ... -t seed`:

```
      Tests  300 passed | 5 skipped (305)
```
…ound it, and never inside a node it moved

## Description

Two ways a remote read placed content wrongly:

- A client that is read only because the document holds a node type it does not register holds no Lexical node for such a node. When a collaborator added a paragraph after one, that client showed the paragraph at the top of its editor ("after / one / two" where a fresh read shows "one / two / after"). It also kept showing a line that a remote change merged away next to one.
- A remote change read into an editor that shows an update the binding did not write (one tagged `SKIP_COLLAB_TAG`, or a local update on a read-only client, see the next commit) could hang the tab. `Reader#$readRegion` placed what it read after the node that preceded it, but the read could have just moved that node into one of the nodes it places. For example, a paragraph stored in a quote's line, which the unwritten update had moved out of the quote, was taken back into the quote by the read of a block inserted before the quote, and the quote was then placed after that paragraph, inside itself. Restoring a named slot could do the same: a slot's value that such an update had made hold its host was put back as that host's slot. Lexical's next walk up the tree (`isAttached`, its garbage collection at commit, BlockIdExtension's settling) never ended. In development builds Lexical's own check caught the slot case; production builds hung.

With this change:

- A region read takes the Lexical children of the content it replaces from the first unit that covers a child to the last (a unit with no Lexical node covers none).
- It places what it reads after the node that preceded those children if the container still holds it once the read is done and the read does not place it; otherwise after the last child of the nearest unit before the region that the container holds, or first.
- Before placing a node as a child or as a slot's value, the read checks that the node is not, and does not hold, where it goes, through children or slots. A read that would break that throws instead, and the binding reports it (`RECOVERED_READ_WARNING`) and reads the document again, as it does for any read that throws.

## Test plan

New tests:

- `packages/lexical-y/src/__tests__/unit/UnknownNodeReads.test.ts`: a client read only for a node type it does not register, against a fresh read with the same nodes: a paragraph added after such a node, paragraphs added between and after two of them, one added after one at the start, and a line merged away right after one.
- `packages/lexical-y/src/__tests__/unit/EditorDrift.test.ts`, with and without `BlockIdExtension`, each after an update tagged `SKIP_COLLAB_TAG`:
  - a block inserted before a quote whose paragraph that update moved out of it;
  - a block inserted before a box that the update made hold the box storing it, which reaches the check: the client warns (`RECOVERED_READ_WARNING`, `reason: 'remote-change'`) and shows what the document holds;
  - a block inserted after a run of two equations whose last one the update moved elsewhere: it shows after the equation that stays;
  - a slot set on a host that the update moved into its other slot's value: the client warns and shows what the document holds.

`./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/UnknownNodeReads.test.ts packages/lexical-y/src/__tests__/unit/EditorDrift.test.ts`

### Before

`UnknownNodeReads.test.ts`:

```
× a paragraph added after such a node shows after the content before it 112ms
× paragraphs added between and after two such nodes 30ms
✓ a paragraph added after such a node at the start 22ms
× a line merged away right after such a node, read with it, is gone 25ms
Expected: "paragraph("one") / paragraph("two") / paragraph("after")"
Received: "paragraph("after") / paragraph("one") / paragraph("two")"
Expected: "paragraph("one") / paragraph("between") / paragraph("last")"
Received: "paragraph("between") / paragraph("last") / paragraph("one")"
Expected: "paragraph("one") / paragraph("new") / paragraph("twothree")"
Received: "paragraph("one") / paragraph("new") / paragraph("twothree") / paragraph("two")"
      Tests  3 failed | 1 passed (4)
```

`EditorDrift.test.ts` never finishes: the quote and box tests spin in Lexical's walk up the tree, and `timeout 60` kills the run.

```
$ time timeout 60 ./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/EditorDrift.test.ts
 RUN  v5.0.1
real	1m0.080s
user	1m0.328s
exit 124
$ time timeout 60 ./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/EditorDrift.test.ts -t box
real	1m0.073s
user	1m0.649s
exit 124
```

The other two, alone (`-t "run whose|slot restored"`): the run test passes, and the slot test is caught only by Lexical's development check, not by the binding:

```
✓ a block inserted after a run whose last node a skipped update moved: placed after what stays 138ms
× a slot restored on a host that a skipped update moved into that slot's value: read again, with a warning 51ms
AssertionError: expected [ [ 'remote-change', false ] ] to deeply equal [ [ 'remote-change', true ] ]
```

The same slot scenario with `__DEV__` false (a production build) never returns from `Y.applyUpdate`:

```
S3_APPLY_BEGIN 68 bytes, sender finished before receiver invocation
S3_EXTERNAL_EXIT=124
```

### After

```
✓ UnknownNodeReads.test.ts > a client read only for a node type it does not register (S7-18) > a paragraph added after such a node shows after the content before it 174ms
✓ UnknownNodeReads.test.ts > a client read only for a node type it does not register (S7-18) > paragraphs added between and after two such nodes 40ms
✓ UnknownNodeReads.test.ts > a client read only for a node type it does not register (S7-18) > a paragraph added after such a node at the start 35ms
✓ UnknownNodeReads.test.ts > a client read only for a node type it does not register (S7-18) > a line merged away right after such a node, read with it, is gone 41ms
✓ EditorDrift.test.ts > blockIds false > a block inserted before a quote whose paragraph a skipped update moved out (S7-25) 178ms
✓ EditorDrift.test.ts > blockIds false > a block inserted before a box that a skipped update made hold the box storing it: read again, with a warning 56ms
✓ EditorDrift.test.ts > blockIds false > a block inserted after a run whose last node a skipped update moved: placed after what stays 40ms
✓ EditorDrift.test.ts > blockIds false > a slot restored on a host that a skipped update moved into that slot's value: read again, with a warning 36ms
✓ EditorDrift.test.ts > blockIds true > a block inserted before a quote whose paragraph a skipped update moved out (S7-25) 53ms
✓ EditorDrift.test.ts > blockIds true > a block inserted before a box that a skipped update made hold the box storing it: read again, with a warning 45ms
✓ EditorDrift.test.ts > blockIds true > a block inserted after a run whose last node a skipped update moved: placed after what stays 30ms
✓ EditorDrift.test.ts > blockIds true > a slot restored on a host that a skipped update moved into that slot's value: read again, with a warning 32ms
```

The production slot scenario now returns, with the editor's tree whole:

```
S3_APPLY_BEGIN 68 bytes, sender finished before receiver invocation
S3_APPLY_RETURNED
S3_FINAL_VALID [["root","16","child"],["16","17","slot:body"],["16","20","slot:title"],["17","18","child"],["18","19","child"],["20","21","child"],["21","22","child"]] warnings ["a remote change could not be read: @lexical/y: a read would place a node inside itself"] errors []
```
…changes while the binding is read only

## Description

A binding goes read only when it cannot write the document safely (a node type the editor does not register, a newer layout, an unsupported configuration). Its editor is then not editable, but code can still call `editor.update()`. The binding wrote nothing of such an update and left its change in the editor, which then showed content the document does not hold, until a reload. A remote change read into that editor could misplace blocks: a client read only for a node type it does not register, running such code, hung on a remote change (the previous commit keeps such a read from placing a node inside itself).

With this change, while the binding is read only, what a local update changes is read again from the document after its commit, so the editor keeps showing the document:

- A user's update also warns with the new `UNSUPPORTED_EDIT_WARNING` (`reason: 'read-only'`), so the app finds out. Lexical's update for a newly registered transform and BlockIdExtension's naming are read again without a warning.
- What changes during a composition is read again once the composition ends, after what was held back from it.
- Each such update starts a new run of re-reads (`MAX_REREADS`), as a written one does, so separate updates keep their derived fields (list numbering) refreshed.
- An update tagged `SKIP_COLLAB_TAG` is left alone, as when the binding writes. So are updates made while the binding is still pending (they are the content it writes when it initializes an empty document), updates on a document it has not read (an empty one it refuses), and updates once the binding went read only to keep showing a change it cannot store (a slot it cannot store, which reading the block again would drop).

The binding reads the changed containers again instead of making the editor discard such updates: Lexical's `update()` has no read-only mode, and adding one would change `update()` for every editor and stop the binding's own reads.

## Test plan

New tests in `packages/lexical-y/src/__tests__/unit/EditorDrift.test.ts`, with and without `BlockIdExtension`:

- on a client read only for a node type it does not register:
  - a local update (a quote's paragraph moved out of it, a paragraph added) is read again, warns with `UNSUPPORTED_EDIT_WARNING`, and writes nothing;
  - after such an update, remote changes (a node of that type inserted before the quote, a paragraph after it) show as a fresh reader shows them;
  - six local updates of a list item's value, each in its own task, are each read again with the list's numbering;
  - a local update during a composition, into which a collaborator typed, is read again once the composition ends;
- a change the binding cannot store (a slot on a paragraph) stays shown, and so does a later local update.

`ListenerUpdates.test.ts` (read only with `repairTransforms: true`) now expects the warning for its local update.

`./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/EditorDrift.test.ts`

### Before

```
✓ EditorDrift.test.ts > blockIds false > a block inserted before a quote whose paragraph a skipped update moved out (S7-25) 123ms
✓ EditorDrift.test.ts > blockIds false > a block inserted before a box that a skipped update made hold the box storing it: read again, with a warning 38ms
✓ EditorDrift.test.ts > blockIds false > a block inserted after a run whose last node a skipped update moved: placed after what stays 39ms
✓ EditorDrift.test.ts > blockIds false > a slot restored on a host that a skipped update moved into that slot's value: read again, with a warning 41ms
× EditorDrift.test.ts > blockIds false > a client read only for a node type it does not register > a local update is read again, with a warning, and nothing is written 33ms
× EditorDrift.test.ts > blockIds false > a client read only for a node type it does not register > remote changes after a local update show as a fresh reader shows them (S7-25) 35ms
× EditorDrift.test.ts > blockIds false > a client read only for a node type it does not register > local updates in separate tasks are each read again, list numbering included 34ms
× EditorDrift.test.ts > blockIds false > a client read only for a node type it does not register > a local update during a composition is read again once it ends 35ms
✓ EditorDrift.test.ts > blockIds false > a client read only for a node type it does not register > a change the binding cannot store stays, and so do local updates after it 16ms
(the same for blockIds true)
AssertionError: expected 'paragraph("one" br "x") / paragraph("…' to be 'paragraph("one") / quote(paragraph("i…' // Object.is equality
Expected: "paragraph("one") / quote(paragraph("inner"))"
Received: "paragraph("one" br "x") / paragraph("inner") / quote()"
AssertionError: expected [] to deeply equal [ { reason: 'read-only' } ]
AssertionError: expected [ [ 100, 2 ], [ 101, 2 ], …(4) ] to deeply equal [ [ 1, 2 ], [ 1, 2 ], [ 1, 2 ], …(3) ]
Expected: "paragraph("abcX") / paragraph("other")"
Received: "paragraph("abcX") / paragraph("other") / paragraph("refused")"
Tests  8 failed | 10 passed (18)
```

### After

```
✓ EditorDrift.test.ts > blockIds false > a client read only for a node type it does not register > a local update is read again, with a warning, and nothing is written 37ms
✓ EditorDrift.test.ts > blockIds false > a client read only for a node type it does not register > remote changes after a local update show as a fresh reader shows them (S7-25) 39ms
✓ EditorDrift.test.ts > blockIds false > a client read only for a node type it does not register > local updates in separate tasks are each read again, list numbering included 66ms
✓ EditorDrift.test.ts > blockIds false > a client read only for a node type it does not register > a local update during a composition is read again once it ends 43ms
✓ EditorDrift.test.ts > blockIds false > a client read only for a node type it does not register > a change the binding cannot store stays, and so do local updates after it 16ms
✓ EditorDrift.test.ts > blockIds true > a client read only for a node type it does not register > a local update is read again, with a warning, and nothing is written 29ms
✓ EditorDrift.test.ts > blockIds true > a client read only for a node type it does not register > remote changes after a local update show as a fresh reader shows them (S7-25) 57ms
✓ EditorDrift.test.ts > blockIds true > a client read only for a node type it does not register > local updates in separate tasks are each read again, list numbering included 36ms
✓ EditorDrift.test.ts > blockIds true > a client read only for a node type it does not register > a local update during a composition is read again once it ends 30ms
✓ EditorDrift.test.ts > blockIds true > a client read only for a node type it does not register > a change the binding cannot store stays, and so do local updates after it 16ms
Tests  18 passed (18)
```

With all three commits:

```
$ ./node_modules/.bin/vitest --run --project unit packages/lexical-y
 Test Files  150 passed | 2 skipped (152)
      Tests  4969 passed | 16 expected fail | 2 skipped (4987)
$ pnpm run ci-check
Found 0 errors
$ ./node_modules/.bin/vitest --run --project scripts-unit scripts/__tests__/unit/treeShakingSource.test.ts scripts/__tests__/unit/changesets.test.ts
      Tests  406 passed (406)
```

The collab Y e2e specs (`Collaboration.spec.mjs`, `CollabY*.spec.mjs`) pass in Chromium: 31 passed. Firefox and WebKit were not run.
…od, and warn once about local edits

## Description

Two follow-ups to the read-only refusal in the previous commit:

- **Read only did not stick.** Any change to the layout record (a peer recording a layout version) has a read-only binding check the layout again. It set the status to `'active'` and read the document, and only then went back to `'readonly'` because the node types it cannot build were still there. For that moment the editor was editable and the binding refused nothing, so an app update made then stayed in the editor unwritten and unwarned. The cause's warning was also reported again.
  - Now `setStatus` stays `'readonly'` while something keeps the binding read only for good: a configuration it does not support (the existing rule), node types it cannot build or slots it cannot read, or a change it cannot store that it keeps showing.
  - The refusal keys on any status but `'active'`. While pending nothing is mapped yet, so it reads nothing again there.
- **`UNSUPPORTED_EDIT_WARNING` was reported for every refused update.** An app that keeps updating a read-only editor would flood `onWarn`. It is now reported once each time the binding goes read only.

## Test plan

In `packages/lexical-y/src/__tests__/unit/EditorDrift.test.ts`, with and without `BlockIdExtension`:
- new: a peer records a layout version while the client is read only for a node type it does not register; the client stays read only, is never made editable, and is not warned again;
- updated: six refused local updates, each in its own task, warn once.

`./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/EditorDrift.test.ts -t "layout record|separate tasks"`

### Before

```
× EditorDrift.test.ts > blockIds false > a client read only for a node type it does not register > local updates in separate tasks are each read again, list numbering included 139ms
× EditorDrift.test.ts > blockIds false > a client read only for a node type it does not register > a change to the layout record leaves the client read only, never editable 28ms
× EditorDrift.test.ts > blockIds true > a client read only for a node type it does not register > local updates in separate tasks are each read again, list numbering included 61ms
× EditorDrift.test.ts > blockIds true > a client read only for a node type it does not register > a change to the layout record leaves the client read only, never editable 32ms
AssertionError: expected [ { reason: 'read-only' }, …(5) ] to deeply equal [ { reason: 'read-only' } ]
AssertionError: expected [ true, false ] to deeply equal []
Tests  4 failed | 16 skipped (20)
```

### After

```
✓ EditorDrift.test.ts > blockIds false > a client read only for a node type it does not register > local updates in separate tasks are each read again, list numbering included 109ms
✓ EditorDrift.test.ts > blockIds false > a client read only for a node type it does not register > a change to the layout record leaves the client read only, never editable 25ms
✓ EditorDrift.test.ts > blockIds true > a client read only for a node type it does not register > local updates in separate tasks are each read again, list numbering included 56ms
✓ EditorDrift.test.ts > blockIds true > a client read only for a node type it does not register > a change to the layout record leaves the client read only, never editable 22ms
Tests  4 passed | 16 skipped (20)
$ ./node_modules/.bin/vitest --run --project unit packages/lexical-y
 Test Files  150 passed | 2 skipped (152)
      Tests  4971 passed | 16 expected fail | 2 skipped (4989)
$ pnpm run ci-check
Found 0 errors
```
…ed when a local update comes after the binding failed closed

## Description

Once the binding fails closed, it reads nothing more. That happens when a read throws and so does the fresh read after it (`UNKNOWN_NODE_TYPES_WARNING`, `reason: 'unreadable'`). A local update made after that could not be read again: the refusal queued a re-read, which `guardedRead` skips, so the editor kept showing the update while the document did not hold it. Before reads were guarded, the failing re-read rebuilt the editor from a cleared root and left it empty.

With this change, while the binding has failed closed:

- It shows what the editor showed before such an update again (`setEditorState`, tagged as a remote change) and reads nothing.
- It warns once with `UNSUPPORTED_EDIT_WARNING`, without saying that it reads anything again.
- Failing closed keeps the binding read only for good, like the other lasting causes.

Every read of the binding goes through `guardedRead`. That includes the re-read of a refused update and the reads that check that a node is never placed inside itself.

## Test plan

New test in `packages/lexical-y/src/__tests__/unit/EditorDrift.test.ts`, with and without `BlockIdExtension`: every read throws, so the client fails closed. A local update then goes back to what the editor showed, writes nothing, and warns once.

`./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/EditorDrift.test.ts -t "cannot be read"`

### Before

```
× EditorDrift.test.ts > blockIds false > a client read only for a node type it does not register > once the document cannot be read, a local update goes back to what the editor showed 102ms
× EditorDrift.test.ts > blockIds true > a client read only for a node type it does not register > once the document cannot be read, a local update goes back to what the editor showed 36ms
AssertionError: expected 'paragraph("hello") / paragraph("local…' to be 'paragraph("hello")' // Object.is equality
Expected: "paragraph("hello")"
Received: "paragraph("hello") / paragraph("local")"
Tests  2 failed | 20 skipped (22)
```

### After

```
✓ EditorDrift.test.ts > blockIds false > a client read only for a node type it does not register > once the document cannot be read, a local update goes back to what the editor showed 129ms
✓ EditorDrift.test.ts > blockIds true > a client read only for a node type it does not register > once the document cannot be read, a local update goes back to what the editor showed 42ms
Tests  2 passed | 20 skipped (22)
$ ./node_modules/.bin/vitest --run --project unit packages/lexical-y
 Test Files  151 passed | 2 skipped (153)
      Tests  5030 passed | 18 expected fail | 2 skipped (5050)
$ pnpm run ci-check
Found 0 errors
$ ./node_modules/.bin/vitest --run --project scripts-unit scripts/__tests__/unit/treeShakingSource.test.ts scripts/__tests__/unit/changesets.test.ts
      Tests  406 passed (406)
```
…omes after opening the room failed closed

## Description

When the first read of a room throws, and so does the fresh read after it, the binding fails closed before the editor showed anything. A local update made after that (code calling `editor.update()` on the read-only editor) is refused, and the binding shows what the editor showed before it again. It did so with `setEditorState`, which throws on an empty state: `setEditorState: the editor state is empty` escaped from the binding's pending work, and the refused update stayed in the editor, which then showed content the document does not hold. `regressions/SchemaSkew.test.ts` ("a reader that throws null fails closed too") reported it as an unhandled error.

With this change, one operation shows a state the editor showed before again, without reading the document: it sets the state whole, or, for an empty state, clears the root, both tagged as a remote change. Failing closed and the restore after a refused local update both use it, so an empty editor goes back to empty, and a non-empty one is still restored without any read. Where it runs from a commit's `$onUpdate` callbacks (failing closed after a read nested in an update), where nothing commits what it sets, it queues an empty update that does.

## Test plan

New test in `packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts`, for an editor that showed nothing (opening the room failed) and for one that showed what it read before: every read throws, the client fails closed, and a local update then goes back to what the editor showed, throws nothing, reads nothing, writes nothing and warns once.

`./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts -t "N3"`

### Before

```
× N3: a local update goes back to what the editor showed (nothing, as opening the room failed), reading nothing 56ms
✓ N3: a local update goes back to what the editor showed (what it read before), reading nothing 26ms
FAIL  |unit| packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts > S7-21: a read that throws on every attempt fails closed > N3: a local update goes back to what the editor showed (nothing, as opening the room failed), reading nothing
AssertionError: expected [ …(1) ] to deeply equal []
- []
+ [
+   Error {
+     "message": "setEditorState: the editor state is empty. Ensure the editor state's root node never becomes empty.",
+   },
+ ]
Tests  1 failed | 1 passed | 111 skipped (113)
```

The whole file, without the new test:

```
⎯⎯⎯⎯⎯ Uncaught Exception ⎯⎯⎯⎯⎯
Error: setEditorState: the editor state is empty. Ensure the editor state's root node never becomes empty.
 ❯ LexicalEditor.setEditorState packages/lexical/src/LexicalEditor.ts:1857:5
 ❯ YBinding.restoreShown packages/lexical-y/src/internal/Binding.ts:3929:20
The last test to run before this error was "a reader that throws null fails closed too".
Tests  107 passed | 4 expected fail (111)
Errors  1 error
```

### After

```
✓ SchemaSkew.test.ts > S7-21: a read that throws on every attempt fails closed > N3: a local update goes back to what the editor showed (nothing, as opening the room failed), reading nothing 48ms
✓ SchemaSkew.test.ts > S7-21: a read that throws on every attempt fails closed > N3: a local update goes back to what the editor showed (what it read before), reading nothing 16ms
Tests  2 passed | 111 skipped (113)
```

```
$ ./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts
Tests  109 passed | 4 expected fail (113)
$ pnpm exec vitest run --project unit --maxWorkers=24 packages/lexical-y/ packages/lexical/src
Test Files  1 failed | 248 passed | 2 skipped (251)
Tests  4 failed | 9488 passed | 42 expected fail | 4 skipped (9538)
$ pnpm run ci-check
Found 0 errors
```

The 4 failures are the `regressions/Composition.test.ts` tests "A holds nothing of it and stores it once after Enter", which fail the same way without this change. No unhandled errors.
…e binding cannot write

## Description

Two ways the editor still became editable, for a moment, while the binding could not write the document:

- **Stored content its classes throw on did not keep the binding read only.** That cause (an older class rejecting a value a newer client stored) was not one of the lasting causes `setStatus` checks. A change to the layout record (a peer recording a layout version) had the binding check the layout again: it went active, read the document, and went read only again. For that moment the editor was editable and the binding refused nothing, so an app update made then stayed in the editor and was not written, and the warning was reported again.
- **Opening a room went active before reading it.** With a supported layout the binding set its status to `'active'`, read the document, and only then went read only for node types the editor does not register or content its classes throw on. Editable listeners saw `true`, then `false`.

With this change:

- Stored content the editor's classes throw on keeps the binding read only, like node types it cannot build.
- A binding that is not active yet, and that nothing keeps read only, becomes active at the end of its read of the whole document, and only where that read found nothing that keeps it read only. It still does so inside the read's update, so the editor is editable for that commit (a focused editor gets the restored caret on the DOM, as before), and an update an update listener makes in reply to the read is written, as before.

Which reads run, what is written and what is compared stay as they were; no public API changes.

## Test plan

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

- opening a room holding content an older class throws on, or a node type the editor does not register: read only, and editable listeners never see `true`; a room the editor reads (control) becomes active with one `true`;
- on a client read only for content its class throws on, a peer records a layout version: editable listeners never see `true`, an update made when the editor becomes editable is not shown, nothing is written, and the warning is not repeated.

`./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts -t "N7"`

### Before

```
× N7: opening a room holding content its class throws on makes the editor editable only once the read validated 61ms
× N7: opening a room holding a node type it does not register makes the editor editable only once the read validated 11ms
✓ N7: opening a room holding content it reads (control) makes the editor editable only once the read validated 9ms
× N7: a change to the layout record leaves it read only, with no local update shown and no warning again 11ms
AssertionError: expected [ true, false ] to deeply equal []
- []
+ [
+   true,
+   false,
+ ]
Tests  3 failed | 1 passed | 113 skipped (117)
```

In the layout record test, past that first assertion, the client also showed the update made while it was editable and warned twice:

```
shown: paragraph("first") / test_required(paragraph("inside")) / paragraph("last") / paragraph("leak")
warnings: ["stored content the editor's classes cannot read: test_required","stored content the editor's classes cannot read: test_required"]
```

### After

```
✓ SchemaSkew.test.ts > N7: a room the binding cannot write is never editable > N7: opening a room holding content its class throws on makes the editor editable only once the read validated 51ms
✓ SchemaSkew.test.ts > N7: a room the binding cannot write is never editable > N7: opening a room holding a node type it does not register makes the editor editable only once the read validated 16ms
✓ SchemaSkew.test.ts > N7: a room the binding cannot write is never editable > N7: opening a room holding content it reads (control) makes the editor editable only once the read validated 8ms
✓ SchemaSkew.test.ts > N7: a room the binding cannot write is never editable > N7: a change to the layout record leaves it read only, with no local update shown and no warning again 10ms
Tests  4 passed | 113 skipped (117)
```

```
$ pnpm exec vitest run --project unit --maxWorkers=24 packages/lexical-y/ packages/lexical/src
Test Files  1 failed | 248 passed | 2 skipped (251)
Tests  4 failed | 9492 passed | 42 expected fail | 4 skipped (9542)
$ pnpm run ci-check
Found 0 errors
```

The 4 failures are the `regressions/Composition.test.ts` tests "A holds nothing of it and stores it once after Enter", which fail the same way without this change. No unhandled errors.
…hows once the document cannot be read

## Description

The warnings table said of `UNSUPPORTED_EDIT_WARNING` that what a local update changed "is read again". Once the document cannot be read (the binding failed closed), nothing is read again: the editor shows again the last state it showed before the update. The row now says both. The setup notes said the editor stays read only until the layout record has been read; it stays read only until the document has been read and found writable.

## Test plan

Documentation only.

### Before

Table row: "Not written; what it changed is read again."

### After

Table row: "Not written; what it changed is read again, or, once the document cannot be read, the editor shows again the last state it showed before the update."

```
$ pnpm run ci-check
Found 0 errors
```
…changes its status on its own, so it is written or refused

The binding changes its status from inside its own updates: it becomes
active once its first read of the document is done, and goes read only when
it fails closed. Lexical notifies editable listeners right then, and an
update an app makes in reply (an editable listener calling `editor.update()`
or `editor.dispatchCommand()`) joined the update in progress, which is the
binding's own and tagged as a remote change: it was neither written nor
refused (see `onUpdate`).

- Opening a room, the editor showed an edit the document never got, and a
  fresh reader did not show it.
- Failing closed, the read-only editor kept that edit for good.
- Where the listener threw after queueing, or a listener of the next commit
  threw, the update was lost or the error escaped through `Y.applyUpdate`.

With this change:

- The binding becomes active right after the read's commit, and only after a
  successful one (from the read's `$onUpdate` callbacks), so nothing an app
  does when the editor becomes editable runs inside the read. A focused
  editor still gets the read's caret on the DOM: the binding sets it again
  ahead of the updates the app queued in reply to the read, and not at all
  where the app set an editor state of its own, so what the app selects or
  sets stands.
- One operation, `notifyApart`, notifies the app apart from the binding's
  updates. Updates the app's listeners queue meanwhile are held and handed
  back to Lexical's queue right after the binding's update commits, in their
  order, after the updates the app queued before them and ahead of anything
  it does next, so they commit on their own and are written, or refused
  while read only, like any local update. Where a throwing listener skipped that point, the binding
  commits them first among its pending work, or before its next update.
  `dispose()` hands them back to the editor without writing them to the
  document. What a listener throws is rethrown in a microtask (an uncaught
  error, as for the app's other updates) and never fails the binding's work.
- Failing closed shows the earlier state again before it goes read only, so
  an edit made in reply to going read only is refused against what the
  editor showed.

```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/Focus.test.ts packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts -t "R2-|R3-|R4-|R5-|R6-"
 ✓ R3-2: an app update queued in reply to the read that replaces the selected text and selects the end of the new text keeps its selection
 ✓ R3-2: an app update queued in reply to the read that moves the caret keeps its selection
 ✓ R3-2: an app update queued in reply to the read that adds a paragraph, the caret of the initial content staying keeps its selection
 ✓ R4-1: an editor state an app sets in reply to the read stands, with its selection
 ✓ R2-2: a focused editor gets the caret of its initial content on the DOM once the binding is active
 × R2-2: an edit made when the editor becomes editable is stored, and the editor shows the document
 × R3-3: opening a room, the edit is written and the error is rethrown apart
 ✓ R4-2: opening a room: the edit runs once, and nothing escapes
 × R4-2: failing closed, a remote change applied on its own: the edit runs once and is refused, and nothing escapes
 × R4-2: failing closed, a remote change applied inside an editor update: the edit runs once and is refused, and nothing escapes
 × R6-1: an update queued in reply to the read before the editor became editable commits before the edit
 × R5-1: a command an editable listener dispatches is written
 ✓ R5-2: an update the app makes right after opening commits after the edit
 ✓ R5-3: the binding disposed right after opening: the edit still runs on the editor, and the document has nothing of it
 × R2-3: a remote change applied on its own: the editor goes back to what it showed, and the edit is refused
 × R2-3: a remote change applied inside an editor update: the editor goes back to what it showed, and the edit is refused
      Tests  8 failed | 8 passed | 128 skipped (144)
```

The passing ones guard the caret, the app's selection and editor state, the
order of the app's updates and disposal, which earlier versions of this fix
broke.

```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/Focus.test.ts packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts -t "R2-|R3-|R4-|R5-|R6-"
      Tests  16 passed | 128 skipped (144)
```
…n if an app update listener throws on the restore

When the document cannot be read, `failClosed` marks the binding unreadable,
shows again what the editor showed (`showAgain`), and then opens read only.
`showAgain` commits, so the app's update listeners run; one that threw left
`failClosed` before it opened read only. The binding read nothing more but
stayed active, so the next local edit was written from a view of the
document it no longer read, and could delete a collaborator's text on every
client.

Now a throw from the restore is the app's: it is rethrown in a microtask,
and the binding opens read only all the same, whatever the restore's
listeners do. The order is kept (shown again first, so that an update the
app makes when the editor stops being editable commits after it, and is
refused).

New `R-1` case in
`packages/lexical-y/src/__tests__/unit/regressions/GuardedRead.test.ts`: every
read of B throws while B reads A's insert, and B's update listener throws on
every commit. Requires B to end read only, the app's error to be rethrown in
a microtask, B's next edit to write nothing, and A and a fresh reader to keep
A's text.

```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/GuardedRead.test.ts
     × R-1: a document B cannot read, and an update listener of B's that throws on every commit 28ms

AssertionError: expected 'active' to be 'readonly' // Object.is equality
Expected: "readonly"
Received: "active"

      Tests  1 failed | 10 passed (11)
```

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

$ pnpm exec vitest run --project unit --maxWorkers=8 packages/lexical-y/ packages/lexical/src
 Test Files  251 passed | 2 skipped (253)
      Tests  9536 passed | 42 expected fail | 4 skipped (9582)
```

`pnpm run ci-check` passes.
…'s sync even when an app update listener throws on the first read

## Description

A binding opened by the provider's first sync (no `initialize()`) reads the
whole document and becomes active right after that read's commit, in an
`$onUpdate` callback, so that nothing the app does when the editor becomes
editable runs inside the read. When an update listener of the app's threw
on that commit, Lexical skipped the commit's `$onUpdate` callbacks: the read
stood, but the binding stayed `pending`, the editor stayed not editable, and
a local edit made later was not written.

Now the activation is owed once per read of the whole document and paid by
whichever runs first: the `$onUpdate` callback, or what follows the read
once it committed (`after`, which runs where the read stands after a
listener's throw). It still runs after the read's commit, never inside it.

## Test plan

New `R-1` case in
`packages/lexical-y/src/__tests__/unit/regressions/GuardedRead.test.ts`: a
reader whose update listener throws once is opened only by applying the
author's state (the provider's sync). Requires nothing thrown into the sync,
the binding active and the editor editable, showing the document, and the
reader's next edit stored where the author reads it.

### Before

```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/GuardedRead.test.ts
     × R-1: opened through the provider's sync, an update listener that throws on the first read's commit: the binding becomes active and the edit is written 13ms

AssertionError: expected 'pending' to be 'active' // Object.is equality
Expected: "active"
Received: "pending"

      Tests  1 failed | 15 passed (16)
```

### After

```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/GuardedRead.test.ts packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts packages/lexical-y/src/__tests__/unit/Focus.test.ts
      Tests  156 passed | 4 expected fail (160)

$ pnpm exec vitest run --project unit --maxWorkers=24 packages/lexical-y/ packages/lexical/src
 Test Files  251 passed | 2 skipped (253)
      Tests  9549 passed | 42 expected fail | 4 skipped (9595)
```

`pnpm run ci-check` passes.
…ment read only in a client older than the newest app schema version that wrote to it

## Description

An older client used to keep editing a document a newer version of the app
had written, with fields and node types it does not know (#9504 keeps the
fields it cannot read). Now the app declares the version of its document
schema, the document records the highest version that ever wrote to it,
and a client older than that record opens the document read only, with a
typed warning (the "Google Docs" rule).

- **Config.** `schemaVersion: number` is required on `YCollabExtension`,
  on `YBindingOptions` (`new YBinding(editor, doc, {schemaVersion})`) and
  on the `@lexical/y/headless` config. A config key cannot be required by
  type, so the extension throws without one, with the fix in the message:
  `YCollabExtension needs schemaVersion (got none): start at 1 and increase
  it whenever a node's stored fields or node types change`. `YBinding` and
  the headless helpers throw the same way and require it in their types
  (TypeScript and Flow). `@lexical/y` has not been released, so this breaks
  no published API; every caller in the repo (tests, playground, docs)
  passes it.
- **Record.** `lexical-y-schema:<n> = true` on the root, one per version
  that wrote, grow-only like the layout record: the document's version is
  the highest `n`, concurrent writers of two versions both record theirs,
  and undo never removes one (the layout record's undo exclusion).
- **Write.** A client records its version in the same transaction as its
  first write to the document: a local edit (and the recovery rewrite of
  one), `initialize()` of a new document, `seedYState` and `editYState`.
  Opening records nothing, follow-ups (block id naming, refreshes) record
  nothing, and a write the app's own transaction makes to another shared
  type does not count.
- **Gate.** A client whose `schemaVersion` is lower than the record opens
  read only, through the existing sticky read-only path (`staysReadOnly`;
  local edits refused and read again, `UNSUPPORTED_EDIT_WARNING`), and
  warns `UNSUPPORTED_SCHEMA_WARNING` (`@lexical/y/unsupported-schema`) with
  `{reason: 'newer', found, supported}` (or `'malformed'` for a record that
  is not `true` under an integer key). The message is a short developer
  note. It also happens mid-session when a newer client's first write
  arrives: local updates made before the change was read (pending, or the
  update running when the change was applied) are written first, then the
  binding turns read only and keeps reading what the newer clients write.
  An update made on the newer record (by the app's Yjs observers, or after
  a read that recovered) is refused. An older client never initializes a
  document a newer one recorded. Headless: an older `editYState` (and
  `readYState`) throws a `'read-only'` `YHeadlessError` whose `warnings`
  hold the schema warning, and writes nothing.
- **Equal or higher version** edits as before; fields it does not declare
  are kept (#9504), which is what lets a newer version remove a field.
- **Docs.** y.md has a "Versioning your schema" section (when to bump,
  what older clients see, how to render the warning), the option and the
  warning in its tables, and the "Older app versions" limit now applies
  only between clients of the same version. y-server.md and the README's
  wire format describe the record. The playground passes a schema version
  (`?collabYSchemaVersion=<n>` overrides it) and its read-only notice says
  "edited with a newer version of the playground. Reload the page" for the
  new reason, preferring it over the node type warnings that follow it.

## Test plan

New `packages/lexical-y/src/__tests__/unit/SchemaVersion.test.ts` (21
cases): the gate on open and its warning data, refused local edits,
opening writes nothing, the first write carries the record in the same
update, mid-session gating that keeps what is shown, a pending local edit
written before the gate, concurrent versions (max wins on every client),
undo and redo never remove the record, malformed records, no initialize
under a newer record, a recovery after 20 queued listener updates still
gating, a record arriving alone (inside a running update, and with nothing
pending), an update made by an app's Yjs observer refused (also with a
local update pending, which is written), a gate deferred to a running
update dropped on `dispose()`, writes in the app's own transaction,
headless seed/edit recording and the older `editYState` refusal, required
config (runtime and `@ts-expect-error` type tests), and a newer client
editing a field it no longer declares. Every other suite changes only by
passing `schemaVersion`. Added a collab e2e case for the playground notice
(`CollabYSync.spec.mjs`, "a client older than a version that wrote the
document is read only").

### Before

```
$ npx vitest run --project unit packages/lexical-y/src/__tests__/unit/SchemaVersion.test.ts
 FAIL  |unit| packages/lexical-y/src/__tests__/unit/SchemaVersion.test.ts > schema version gate > an older client opens a newer document read only, with the warning
AssertionError: expected 'active' to be 'readonly' // Object.is equality
 Test Files  1 failed (1)
      Tests  20 failed | 1 passed (21)
```

### After

```
$ npx vitest run --project unit packages/lexical-y/src/__tests__/unit/SchemaVersion.test.ts
 Test Files  1 passed (1)
      Tests  21 passed (21)

$ npx vitest run --project unit packages/lexical-y packages/lexical/
 Test Files  265 passed | 2 skipped (267)
      Tests  9824 passed | 46 expected fail | 4 skipped (9874)

$ pnpm run ci-check
Changeset check passed: 20 pending changeset(s) valid.
Found 0 errors
(exit 0: tsc, Flow, Prettier, ESLint)
```

The collab e2e specs could not run on this machine: its sandbox lets
Chromium start only with `--single-process`, which cannot open the second
browser context every collab spec needs (the existing layout gate spec
fails the same way there). CI runs them.
…maVersion

## Description

Rebasing this PR onto `main` brings in two tests that landed after it was
written and bind a plain `Y.Doc` without a `schemaVersion`, which this PR's
`YCollabExtension` and `@lexical/y/headless` config no longer accept:

- `TableOnOpen.test.ts` (#9501): the document is made with `createYDoc` from
  the seeded state, and the seed and the extension get `schemaVersion: 1`.
- `MarkStateBlockType.test.ts` (#9503): both documents are made with
  `createYDoc`, and the extension gets `schemaVersion: 1`.

## Test plan

### Before

```
$ npx tsc --noEmit -p tsconfig.json
packages/lexical-y/src/__tests__/unit/MarkStateBlockType.test.ts(54,44): error TS2741: Property '[createdByCreateYDoc]' is missing in type 'Doc' but required in type 'YDoc'.
packages/lexical-y/src/__tests__/unit/TableOnOpen.test.ts(58,9): error TS2345: Argument of type '{ extensions: ... }' is not assignable to parameter of type 'HeadlessYConfig'.
packages/lexical-y/src/__tests__/unit/TableOnOpen.test.ts(62,55): error TS2741: Property '[createdByCreateYDoc]' is missing in type 'Doc' but required in type 'YDoc'.
```

### After

```
$ pnpm run ci-check
Found 0 errors
$ npx vitest run --project unit
 Test Files  563 passed | 3 skipped (566)
      Tests  14641 passed | 46 expected fail | 6 skipped (14693)
```
@zurfyx
zurfyx changed the base branch from zurfyx/y-composition-holdback to main October 29, 2026 20:15
@zurfyx
zurfyx force-pushed the zurfyx/y-incremental-read-marks branch from e19f324 to 459f552 Compare October 29, 2026 20:16
@zurfyx
zurfyx added this pull request to the merge queue Oct 29, 2026
Merged via the queue into main with commit 0823b74 Oct 29, 2026
53 checks passed
zurfyx added a commit that referenced this pull request Oct 29, 2026
…ill holds

## Description

An update tagged `SKIP_COLLAB_TAG` can leave the editor holding a stored node somewhere else than the document stores it. #9513: client A moves box `d` out of box `c` and `c` into `d`, unwritten; B inserts a paragraph after `d`, inside `c` as the document has it. The self-check reads `c` again, which places `d` inside `c`. `$setChildren` takes `c` out of `d` first so as not to make a cycle, expecting the read of the container that stores `c` to place it again, but no such read runs: A showed only the first paragraph until it reloaded.

The reader now keeps one rule for every node a read takes out of where the editor held it (`$setChildren`, the removal of a region's old nodes): at the end of the read, a node still out of the tree whose Y.Node the document still holds was held elsewhere than the document stores it, and the read throws instead of dropping it, which reads the whole document again (`recoverRead`), with a warning. Concurrent moves are unaffected: they delete the old item, or the read places the node again before it ends. A stored inline element the read leaves out on purpose (emptied by collaborators, and unable to be empty, as Lexical would remove it) is no loss and is excluded.

The insert-before variant of #9513 (a read that would place a node inside itself) already recovered this way since #9510.

Closes #9513

## Changeset

`.changeset/lexical-y-read-keeps-stored-nodes.md` (`[fix]`).

## Test plan

`EditorDrift.test.ts`: a block inserted after a box that a skipped update made hold the box storing it, with the self-check on (A shows every block, read again with a warning) and off (A keeps its unwritten nesting, with the insert in it); then A's next edit reaches B with nothing missing. An emptied stored inline element that cannot be empty is left out without a recovery (a raw Yjs delete, and a peer deleting its text).

### Before

```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/EditorDrift.test.ts -t 'nothing goes missing'
     × a block inserted after a box that a skipped update made hold the box storing it (self-check true): nothing goes missing 376ms
     ✓ a block inserted after a box that a skipped update made hold the box storing it (self-check false): nothing goes missing 16ms
     × a block inserted after a box that a skipped update made hold the box storing it (self-check true): nothing goes missing 84ms
     ✓ a block inserted after a box that a skipped update made hold the box storing it (self-check false): nothing goes missing 251ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯
Expected: "paragraph("top") / test_box[c](paragraph("c1") test_box[d](paragraph("d1")) paragraph("new") paragraph("c2"))"
Received: "paragraph("top")"
      Tests  2 failed | 2 passed | 22 skipped (26)
```

### After

```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/EditorDrift.test.ts -t 'nothing goes missing'
 ✓ |unit| packages/lexical-y/src/__tests__/unit/EditorDrift.test.ts (26 tests | 22 skipped) 351ms
      Tests  4 passed | 22 skipped (26)
```

- `@lexical/y` unit suite: 155 files, all pass (7244 passed, 42 expected fail).
- Fuzz (a randomized test, not committed: random `SKIP_COLLAB_TAG` moves of boxes and paragraphs on A, random inserts and deletes on B; oracle: A shows every paragraph B shows), 1,500 sessions each: self-check on, main 242 failures, this change 182; self-check off, main 20, this change 16. Every failure left is a paragraph (a line, not a stored node) that the unwritten update moved into the content read; it is tracked separately.
zurfyx added a commit that referenced this pull request Oct 29, 2026
## Description

An update tagged `SKIP_COLLAB_TAG` can leave the editor holding a stored node somewhere else than the document stores it. #9513: client A moves box `d` out of box `c` and `c` into `d`, unwritten; B inserts a paragraph after `d`, inside `c` as the document has it. The self-check reads `c` again, which places `d` inside `c`. `$setChildren` takes `c` out of `d` first so as not to make a cycle, expecting the read of the container that stores `c` to place it again, but no such read runs: A showed only the first paragraph until it reloaded.

The reader now keeps one rule for every node a read takes out of where the editor held it (`$setChildren`, the removal of a region's old nodes): at the end of the read, a node still out of the tree whose Y.Node the document still holds was held elsewhere than the document stores it, and the read throws instead of dropping it, which reads the whole document again (`recoverRead`), with a warning. Concurrent moves are unaffected: they delete the old item, or the read places the node again before it ends. A stored inline element the read leaves out on purpose (emptied by collaborators, and unable to be empty, as Lexical would remove it) is no loss and is excluded.

The same happened to a line: A moves paragraph t1 before t0, unwritten; B removes t1. The read of B's change took out every node between the first and the last node of the lines it read, assuming the editor holds them in the document's order; t0 was among them, and A lost it until it reloaded (without the self-check). A region read now takes out only each old unit's own nodes; a node an unwritten update moved between them is not theirs and stays.

The insert-before variant of #9513 (a read that would place a node inside itself) already recovered this way since #9510.

Closes #9513

## Changeset

`.changeset/lexical-y-read-keeps-stored-nodes.md` (`[fix]`).

## Test plan

`EditorDrift.test.ts`: a line removed after a skipped update moved it before another (the other stays, without the self-check); a block inserted after a box that a skipped update made hold the box storing it, with the self-check on (A shows every block, read again with a warning) and off (A keeps its unwritten nesting, with the insert in it); then A's next edit reaches B with nothing missing. An emptied stored inline element that cannot be empty is left out without a recovery (a raw Yjs delete, and a peer deleting its text).

### Before

```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/EditorDrift.test.ts -t 'nothing goes missing'
     × a block inserted after a box that a skipped update made hold the box storing it (self-check true): nothing goes missing 376ms
     ✓ a block inserted after a box that a skipped update made hold the box storing it (self-check false): nothing goes missing 16ms
     × a block inserted after a box that a skipped update made hold the box storing it (self-check true): nothing goes missing 84ms
     ✓ a block inserted after a box that a skipped update made hold the box storing it (self-check false): nothing goes missing 251ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯
Expected: "paragraph("top") / test_box[c](paragraph("c1") test_box[d](paragraph("d1")) paragraph("new") paragraph("c2"))"
Received: "paragraph("top")"
      Tests  2 failed | 2 passed | 22 skipped (26)
```

### After

```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/EditorDrift.test.ts -t 'nothing goes missing'
 ✓ |unit| packages/lexical-y/src/__tests__/unit/EditorDrift.test.ts (26 tests | 22 skipped) 351ms
      Tests  4 passed | 22 skipped (26)
```

- `@lexical/y` unit suite: 155 files, all pass (7244 passed, 42 expected fail).
- Fuzz (a randomized test, not committed: random `SKIP_COLLAB_TAG` moves of boxes and paragraphs on A, random inserts and deletes on B; oracle: A shows every paragraph B shows), 1,500 sessions each: self-check off (the default), main 20 failures, this change 0; self-check on, main 242, this change 182. Every failure left with the self-check on is a paragraph that the unwritten update moved into a container the self-check then reads again in full (`$readFull`, which sets the container's children to what the document stores there); that path is opt-in diagnostics and is left for a follow-up.
- In-repo fuzz (`Fuzz.test.ts`, seeds 1..3000, gc 1..600): same result as main (seed 2277 only, a documented limit).

This branch was successfully deployed

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

2 participants