Repository navigation
[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
Conversation
zurfyx
requested review from
acywatson,
etrepum,
ivailop7 and
potatowagon
as code owners
October 29, 2026 01:21
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
lexical
@lexical/a11y
@lexical/clipboard
@lexical/code
@lexical/code-core
@lexical/code-prism
@lexical/code-shiki
@lexical/compiler
@lexical/devtools-core
@lexical/dragon
@lexical/eslint-plugin
@lexical/extension
@lexical/fast-check
@lexical/file
@lexical/hashtag
@lexical/headless
@lexical/history
@lexical/html
@lexical/internal
@lexical/link
@lexical/list
@lexical/mark
@lexical/markdown
@lexical/mdast
@lexical/offset
@lexical/overflow
@lexical/plain-text
@lexical/react
@lexical/rich-text
@lexical/selection
@lexical/table
@lexical/tailwind
@lexical/text
@lexical/utils
@lexical/y
@lexical/yjs
commit: |
zurfyx
force-pushed
the
zurfyx/y-incremental-read-marks
branch
from
October 29, 2026 02:41
351618e to
7d8ec0c
Compare
This was referenced Oct 29, 2026
Merged
zurfyx
force-pushed
the
zurfyx/y-preserve-unknown-attrs
branch
from
October 29, 2026 05:53
7a4a983 to
7fcd687
Compare
zurfyx
force-pushed
the
zurfyx/y-incremental-read-marks
branch
from
October 29, 2026 05:53
7d8ec0c to
bb10bbe
Compare
This was referenced Oct 29, 2026
zurfyx
changed the base branch from
zurfyx/y-preserve-unknown-attrs
to
zurfyx/y-composition-holdback
October 29, 2026 05:53
vjeux
approved these changes
Oct 29, 2026
zurfyx
force-pushed
the
zurfyx/y-composition-holdback
branch
from
October 29, 2026 19:50
16a85fb to
005b397
Compare
…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
force-pushed
the
zurfyx/y-incremental-read-marks
branch
from
October 29, 2026 20:16
e19f324 to
459f552
Compare
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Note
Stack, part 10 of 10. Based on #9507 (
zurfyx/y-composition-holdback). Review only this PR's own commits.The
@lexical/yfixes 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
mainbefore merging it (git rebase --onto main <old base branch>).The stack
zurfyx/update-error-rollbackmainzurfyx/write-before-unread-changezurfyx/y-keep-chars-at-format-boundaryzurfyx/y-undo-member-closerzurfyx/y-initial-state-bindingzurfyx/y-reorder-intentzurfyx/y-preserve-unknown-attrszurfyx/y-move-keeps-unknown-fieldszurfyx/y-composition-holdbackzurfyx/y-incremental-read-marksFollow-ups for after the stack (not in it):
Unread(changes Yjs applied that the binding has not read) and [lexical-playground][lexical-website][lexical-y] Bug Fix: Hold back remote changes to the line being composed until the composition ends #9507'sHidden(changes held back while a line is composed) answer one question, and call sites combine them by hand. One owner module (isGone,isShown,attrOf/attrsOfand the write primitives), an item-level held overlay instead of thewas/nowmaps, and a lint or grep test that allows raw.deleted/getAttr(s)only in the Reader and in named provenance scans. [lexical-website][lexical-y] Bug Fix: Stop a format, comment or link from spreading to the end of the document after an undo or delete races an edit inside it #9505's run-opener scans (liveItemBefore,runOpener,restatedEnd) still read raw deletedness and move there too.$groupReference,$priorGroupItem, the nearest neighbour's marker before the write,priorGroupMarker), with separate predecessor stores for markers and nodes. One stored-predecessor lookup would replace 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.
schemaVersion: numberis required onYCollabExtension,YBindingOptions(new YBinding(editor, doc, {schemaVersion})) and the@lexical/y/headlessconfig. 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.YBindingand the headless helpers require it in their TypeScript and Flow types too.@lexical/yhas never been published, so no released API breaks. Every caller in the repo passes it: tests, the playground and the docs.lexical-y-schema:<n> = trueon the root, one key per version, grow-only like the layout record. The document's version is the highestn. Two versions that write at once both record theirs, and undo never removes one.initialize()of a new document,seedYStateoreditYState. 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.UNSUPPORTED_EDIT_WARNING). It reports the newUNSUPPORTED_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:editYStateorreadYStatethrows a'read-only'YHeadlessErrorcarrying the warning, and writes nothing.?collabYSchemaVersion=<n>overrides the playground's version..changeset/lexical-y-schema-version.md([feature], patch, withVerify:).Tests.
SchemaVersion.test.tshas 21 cases:dispose();seedYStateandeditYStaterecord the version, and an oldereditYStaterefuses;@ts-expect-errortype tests;Every other suite changes only by passing
schemaVersion. A collab e2e case covers the playground notice. Agpt-6.1-solreview 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):After:
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.createYDoc. Yjs cleans up formats by default (doc.cleanupFormatting). A client that applied a stored state to a plainnew 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/ytoo. It turns the cleanup off before anything is applied and returns a brandedYDoc.YCollabExtension'sdocandnew YBindingtake only aYDoc(TypeScript and Flow).UNSUPPORTED_CONFIG_WARNING(reason: 'cleanup-formatting').createYDoc.@lexical/yhas not been released, so the narrowerdoctype breaks no published API.RECOVERED_READ_WARNING) and reads the document again.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.UNSUPPORTED_EDIT_WARNING(reason: 'read-only'). Composition-time changes are read again when the composition ends.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).'active', and editable, while it read the document again, and repeated the cause's warning.setStatusnow stays'readonly'while a lasting cause holds: an unsupported configuration, node types it cannot build, or a change it cannot store.'active', andUNSUPPORTED_EDIT_WARNINGis reported once each time the binding goes read only.Merge order. #9501, #9503 and #9508 add tests that bind
new Y.Doc():TableOnOpen.test.ts,MarkStateBlockType.test.tsandregressions/InitialState.test.tsx. With this PR they needcreateYDoc()and theYDoctype.Restack (October 29)
Rebased onto #9507 (it was based on #9504). One conflict, with #9508 in y.md: both texts combined (
synced: trueinitializes as soon as the binding starts, andcreateYDoc(stored)is named). The first commit, which brands bound documents, now also moves the tests of PRs below it that bind a plainY.DoctocreateYDoc: #9523'sBatchedWrites.test.tsand #9508'sregressions/InitialState.test.tsx(N8).New commits, each with a regression test named after its finding:
showAgain, for bothfailClosedandrestoreShown: 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$onUpdatecallbacks. A local update after opening the room failed closed no longer throwssetEditorState: the editor state is emptyand no longer stays shown; this is the unhandled error that turned this PR's CI red (N3:inSchemaSkew.test.ts).state.unreadableis instaysReadOnly, 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:inSchemaSkew.test.ts).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 throughY.applyUpdate(R2-*toR6-*inSchemaSkew.test.tsandFocus.test.ts). Decision for Gerard: during a freeze nested in the app's owneditor.update, an app listener that throws on its reply's commit surfaces from thateditor.update(as an app's own queued update would), never fromY.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.UNSUPPORTED_EDIT_WARNINGrow 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:
$onUpdatecallbacks); it used to stay pending (R-1:inregressions/GuardedRead.test.ts).Outside this stack: [lexical-table][lexical-y] Bug Fix: Don't throw when a table exists before its DOM #9501's
TableOnOpen.test.tsand [lexical-code-prism][lexical-code-shiki][lexical-mark][lexical-website][lexical-y] Bug Fix: Keep comment ids and NodeState when a block change moves or re-creates text #9503'sMarkStateBlockType.test.tsbind a plainY.Doctoo. Whichever of those and this PR merges second needs the samecreateYDocchange in those files.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.mdThe 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.tsEditorDrift.test.ts, with and withoutBlockIdExtension:SKIP_COLLAB_TAG;Before
Abbreviated; full output is in each commit message.
On main, a client that applies the stored state to a plain
Y.Docand binds:UnknownNodeReads.test.tsat the first commit:EditorDrift.test.tsat the first commit never finishes (killed bytimeout 60, exit 124). A production build of the slot case never returns fromY.applyUpdate.At the second commit, the read-only tests fail:
After
The collab Y e2e specs (
Collaboration.spec.mjs,CollabY*.spec.mjs) pass in Chromium: 31 passed. Firefox and WebKit were not run.