Repository navigation
[lexical-website][lexical-y] Bug Fix: An older client keeps the fields and content it cannot read - #9504
Merged
Conversation
zurfyx
requested review from
acywatson,
etrepum,
ivailop7 and
potatowagon
as code owners
October 28, 2026 23:45
|
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
added a commit
that referenced
this pull request
Oct 29, 2026
… and keep held ones on both halves of a split Two defects found in the composition hold-back by a review across PRs: - **MF-1:** the hold captured every marker attribute a collaborator's change set, including keys the client does not own, such as a field a newer version of a class declares. When the composing client split the line, it wrote such a key onto both halves; without a composition, it stays on one. A change of only such keys also created a hold that holds nothing the client shows. The hold now captures only the keys the client owns, by Schema's ownership rule (`ownsMarkerAttr`: the binding's own keys, NodeState, and the fields its classes declare, from `fieldsOfTypes` over `ownedFieldsOf`). Ownership is judged over the types the change goes from and to, and the type the editor shows, so a change of type owns the fields of both. A change of keys the client does not own creates no hold and needs no read. - **B-2:** a held attribute was lost on one half of a split made while composing. When a collaborator centered the line and the composing client pressed Enter mid-line, only one half stayed centered, and the client's write deleted `center` from the original marker. Paragraph Enter inherits the alignment, so both halves keep it now. The hold records every marker that stores the held attributes: the composed line's, and the old marker that the new line holding the rest of it ends at. A line on one of them keeps a held value its write left as the editor shows it. A line the composed line was joined into keeps its own values, as does the empty line Enter makes at the end of the line. Only an explicit change of the composed line itself ends the hold of an attribute, and the release reads every recorded marker. New in `packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts`: - B-2: both halves keep a held alignment or type and the lines around do not get it, plus a local explicit change of the alignment, which wins. - MF-1: a field the client does not know, alone or with an owned alignment. Nothing of it is held, and after Enter it is stored on one marker. The matrix in `__tests__/utils/compositionHoldBack.ts` now checks the composed line itself and the lines around it, instead of any line. `./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts -t 'B-2|MF-1'` On the previous commit, before the restack onto #9504 (6bc5caf): ``` × both halves keep it, the other lines do not get it (center, ids 1,2) 142ms × both halves keep it, the other lines do not get it (heading, ids 1,2) 57ms ✓ both halves keep it, the other lines do not get it (center, then A aligns it left, ids 1,2) 49ms × both halves keep it, the other lines do not get it (center, ids 2,1) 40ms × both halves keep it, the other lines do not get it (heading, ids 2,1) 47ms ✓ both halves keep it, the other lines do not get it (center, then A aligns it left, ids 2,1) 50ms × A holds nothing of it and stores it once after Enter (alone, ids 1,2) 21ms × A holds nothing of it and stores it once after Enter (with an alignment, ids 1,2) 18ms × A holds nothing of it and stores it once after Enter (alone, ids 2,1) 19ms × A holds nothing of it and stores it once after Enter (with an alignment, ids 2,1) 20ms AssertionError: expected [ 'paragraph::pre', …(3) ] to deeply equal [ 'paragraph::pre', …(3) ] AssertionError: expected [ 'p:future' ] to deeply equal [] AssertionError: expected [ 'p:future', 'p:format' ] to deeply equal [ 'p:format' ] Tests 8 failed | 2 passed | 43 skipped (53) ``` ``` ✓ |unit| packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts (53 tests | 43 skipped) 546ms Tests 10 passed | 43 skipped (53) $ ./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/CompositionHoldBack.test.ts packages/lexical-y/src/__tests__/unit/CompositionHoldBackBlockIds.test.ts packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts packages/lexical-y/src/__tests__/unit/Composition.test.ts ✓ |unit| packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts (53 tests) 2261ms ✓ |unit| packages/lexical-y/src/__tests__/unit/Composition.test.ts (63 tests) 6976ms ✓ |unit| packages/lexical-y/src/__tests__/unit/CompositionHoldBackBlockIds.test.ts (169 tests) 10316ms ✓ |unit| packages/lexical-y/src/__tests__/unit/CompositionHoldBack.test.ts (1764 tests | 2 skipped) 86242ms Test Files 4 passed (4) Tests 2047 passed | 2 skipped (2049) $ pnpm run ci-check Changeset check passed: 5 pending changeset(s) valid. Found 0 errors ```
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-preserve-unknown-attrs
branch
from
October 29, 2026 10:47
f0e50c0 to
f58c9a3
Compare
zurfyx
force-pushed
the
zurfyx/y-reorder-intent
branch
from
October 29, 2026 10:47
a1675b3 to
43df673
Compare
vjeux
approved these changes
Oct 29, 2026
zurfyx
added a commit
that referenced
this pull request
Oct 29, 2026
…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.
zurfyx
force-pushed
the
zurfyx/y-reorder-intent
branch
from
October 29, 2026 18:49
43df673 to
5e40824
Compare
…'s classes do not declare
During a rollout, clients of two app versions edit the same room. A client
whose class lacks a field that a newer version of the class added deleted
that field for every client as soon as it wrote the node: a block
container's or decorator's field when it changed the node, a list's field
when it changed the list (group fields, stored on every item marker and in
the outer levels of nested lists), a link's field when it called `setURL` or
typed inside the link, and a TextNode subclass's field when it applied bold
or typed in it. Only plain line attributes survived. A server agent with the
older config (`editYState`) did the same.
Each write path decided on its own which stored names it could delete. This
replaces those checks with one rule, in `Schema.ts`: a client writes and
deletes only the stored names it owns, and keeps every other name as stored.
It owns:
- the binding's own keys, and NodeState (Lexical keeps the NodeState keys it
does not know, so a key a node no longer holds was removed);
- the text formats its Lexical knows;
- the fields its classes declare in `$config` for the location's type. Where
the write changes the type, or leaves none (a line that is no group item),
every field: no node there takes another type's fields. A class that writes
its own `exportJSON` owns every field of its type, since which ones it
writes cannot be known.
The same predicates (`ownsNodeAttr`, `ownsMarkerAttr`, `ownsFormat`,
`ownsMember`) serve the node, marker, text format and wrapper writes, the
self-check, and the reader, which reads only what it owns, so an unknown name
never reads as a change. A new item of a group takes the group fields it does
not own from the item next to it; inserted text keeps the formats it does not
own of the text it continues; a link's value keeps the members it does not
own. A field a client knows and clears is still deleted.
S7-11: a link (or another inline element stored as a format) of a type the
client does not register was dropped from what it showed, and the client
stayed editable, so wrapping that paragraph in a container deleted the link
for everyone. The reader now records the type as unknown, as it does for
every other stored kind: the client opens the room read only with
`UNKNOWN_NODE_TYPES_WARNING` (`reason: 'unregistered'`). This covers an
editor that does not register `LinkNode` at all, and `editYState` refuses such
a document with a `YHeadlessError` that carries the typed warning.
Stored data stays layout 1, and sessions without version skew store the same
bytes as before: over the whole `@lexical/y` unit suite plus 800 fuzz seeds
(5,362 deterministic sessions, 47,402 document states), every stored document
is byte-identical to `main`'s.
Limits, documented in y.md: a block an older client moves is written anew, so
the moved block loses the fields that client does not know (a `test.fails`
case); a class that writes its own `exportJSON` owns every field of its type.
New `packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts`.
Each S7-3 case runs one edit made by a client with the older classes and the
same edit made by a client with the newer ones, and requires the same stored
document; three clients (old, new, a fresh reader) converge each time.
On `main` with this test file, `./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts`:
```
❯ packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts (38 tests | 26 failed)
❯ S7-3: an older client keeps the fields a newer class added (24)
× a block container: its format, and a field the old class knows
× a block decorator: a field the old class knows
× an inline decorator: a field the old class knows
✓ a line: its format (as before)
✓ a line whose type changes takes no field of the old type
× a group: its key fields
× a group: an item added before its first item
× a group: an item added after its last item
× a group holding a nested group: the outer key fields
× an inline element stored as a format: its URL
× an inline element stored as a format: typing inside it
× a text subclass: bold over part of it
× a text subclass: typing inside it
× NodeState the old app does not know, on a node, a line and text
× a set-valued NodeState the old app does not know
× a named slot the old client's class does not use
✓ a root attribute the old client does not know
❯ a field the client knows and clears is deleted (4)
× a decorator
× a group
× an inline element stored as a format
× a text subclass
✓ a group's field left on a line that is no item is deleted, as before
✓ a class that writes its JSON by hand owns every field of its type
✓ limit: a block an older client moves keeps the fields it does not know
❯ S7-3: which stored names a client owns (3)
× a node or marker attribute
× a text format and a member of a value
× a client's fields at a location: its class's, or every field where the type changes
❯ S7-11: content of a type a client does not register opens the room read only (9)
✓ a line
✓ a group
× an inline element stored as a format
× a link, by an editor without LinkNode
✓ a text subclass
✓ a block node
✓ an inline node
✓ a named slot
× the warning is typed
❯ S7-3, S7-11: a server agent with older classes (2)
× S7-3: its edit keeps the fields its classes do not declare
× S7-11: it refuses a document with a link type it does not register
Tests 26 failed | 11 passed | 1 expected fail (38)
```
For example:
```
FAIL |unit| packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts > S7-3: an older client keeps the fields a newer class added > a block container: its format, and a field the old class knows
AssertionError: expected { root: { children: [ …(2) ], …(5) } } to deeply equal { root: { children: [ …(2) ], …(5) } }
- Expected
+ Received
@@ -26,11 +26,11 @@
],
"direction": null,
"format": "center",
"indent": 0,
"label": "Renamed",
- "tone": "warm",
+ "tone": "",
"type": "test_skew_card",
"version": 1,
},
FAIL |unit| packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts > S7-11: content of a type a client does not register opens the room read only > an inline element stored as a format
FAIL |unit| packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts > S7-11: content of a type a client does not register opens the room read only > a link, by an editor without LinkNode
AssertionError: expected 'active' to be 'readonly' // Object.is equality
FAIL |unit| packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts > S7-3, S7-11: a server agent with older classes > S7-3: its edit keeps the fields its classes do not declare
AssertionError: expected [ 'center', '' ] to deeply equal [ 'center', 'warm' ]
```
```
$ ./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts
Tests 37 passed | 1 expected fail (38)
$ ./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/
Tests 4711 passed | 13 expected fail | 2 skipped (4726)
```
`pnpm run ci-check` passes. Not run: browser and e2e suites (the change is in
the binding's storage logic, covered by the unit suites above).
…a node class throws on its stored content
## Description
An older client's class can throw on a value only a newer version writes,
for example an `updateFromJSON` that rejects it. The binding warned
`RECOVERED_READ_WARNING`, threw again on the rebuild that followed, and then
stayed active with an empty editor: the whole document was missing from its
view. The error also escaped into the provider's `Y.applyUpdate`, and opening
such a room made `initialize()` throw with no warning.
Any failure to read stored content now lands in the path unknown types use:
- Each place where stored values reach class code (`importJSON`,
`updateFromJSON`, a NodeState parse) runs through `Reader#$readStored`.
Where the class throws, the type is noted as unreadable and a fallback
stands in (the node as it was, its defaults, plain text, or a paragraph).
After the read the binding goes read only and warns
`UNKNOWN_NODE_TYPES_WARNING` with the new reason `'unreadable'`, with
`types` and `error`. The editor keeps showing the rest of the document.
- A read that still throws on the fresh read of the document that follows (a
reader bug, not a class) fails closed too: read only, the same warning, the
editor shows what it showed before, and the binding reads nothing more.
Nothing reaches Yjs or the provider.
- A read-only client writes nothing. `editYState` and the other headless
helpers refuse such a document with `YHeadlessError` (`'read-only'`) and its
typed warnings.
`YUnknownNodeTypesData` becomes a union of its two shapes, in TypeScript and
Flow, so `data.reason === 'unreadable'` narrows to the one with `error`.
## Test plan
New cases in `packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts`:
a line and a node of its own, each live (a value a newer client sets) and on
opening a room; a read that throws on every attempt, live and on opening;
and `editYState`. Each checks that the client is read only, warns once, throws
nothing, and writes nothing afterwards.
### Before
On the previous commit with this test file (the describe blocks that pass
are left out), `./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts`:
```
❯ packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts (45 tests | 7 failed)
❯ S7-21: stored content an older class throws on opens the room read only (4)
× a line: a value a newer client sets
× a node of its own: a value a newer client sets
× a line: opening a room that holds it
× a node of its own: opening a room that holds it
❯ S7-21: a read that throws on every attempt fails closed (2)
× a remote change: the editor keeps what it showed
× opening the room: nothing is thrown, and nothing is shown
❯ S7-3, S7-11, S7-21: a server agent with older classes (3)
✓ S7-3: its edit keeps the fields its classes do not declare
✓ S7-11: it refuses a document with a link type it does not register
× S7-21: it refuses a document its classes throw on
Tests 7 failed | 37 passed | 1 expected fail (45)
```
For example:
```
FAIL |unit| packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts > S7-21: stored content an older class throws on opens the room read only > a line: a value a newer client sets
FAIL |unit| packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts > S7-21: stored content an older class throws on opens the room read only > a node of its own: a value a newer client sets
AssertionError: expected [ Error: unknown mode v2only ] to deeply equal []
FAIL |unit| packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts > S7-21: a read that throws on every attempt fails closed > opening the room: nothing is thrown, and nothing is shown
AssertionError: expected [Function] to not throw an error but 'Error: simulated reader failure' was thrown
FAIL |unit| packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts > S7-3, S7-11, S7-21: a server agent with older classes > S7-21: it refuses a document its classes throw on
AssertionError: expected Error: unknown mode v2only to be an instance of YHeadlessError
```
### After
```
$ ./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts
Tests 44 passed | 1 expected fail (45)
$ ./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/
Tests 4718 passed | 13 expected fail | 2 skipped (4733)
```
`pnpm run ci-check` passes. Not run: browser and e2e suites.
…p, and undo what a class changed before it threw
## Description
Follow-up to the two version-skew fixes, from an independent review of them.
- **A nested group's outer levels are matched by group.** A line's marker
keeps the members of its outer levels (`o`) that the client does not own.
It matched them by depth, so an edit that removed an outer group (lifting
the middle of three nested lists out of the outer one) gave the remaining
level the removed group's fields. Each level now takes the members stored
for the same group, found from the groups that held the line before the
update; a level whose group cannot be matched takes none.
- **A run of new group items finds its reference item in linear time.**
Prepending many items to a list scanned forward to the same stored item
from each new one (quadratic); the forward walk is now noted like the
backward one.
- **What a class changed before it threw is undone.** A class's
`updateFromJSON` may change a node and then throw; the read kept those
changes, and where Lexical could not commit them the error still escaped
into the provider. The node now gets back what it held before. A fallback
node is built by the class's constructor instead of its `importJSON`, which
may reject the minimal JSON the fallback gave it, and a read that throws
while committing fails closed too.
- **Any thrown value fails closed.** A thrown `null` was taken for "no
error", and a thrown value that cannot be turned into a string made the
warning itself throw.
- **`YUnknownNodeTypesData` stays an interface.** The new `'unreadable'`
reason has its own `YUnreadableContentData` (TypeScript and Flow), so an
app's `interface ... extends YUnknownNodeTypesData` still compiles.
- y.md names one more limit, with a `test.fails` case: a link an older client
replaces with another link of the same type over the same text keeps the
fields it does not know of the replaced link.
## Test plan
New cases in `packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts`,
one per review finding, plus indent and outdent across levels of distinct
fields, and a type-level check that the warning data can be extended.
### Before
On the previous commit with this test file (the describe blocks that pass
are left out), `./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts`:
```
❯ packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts (55 tests | 7 failed)
❯ S7-3: an older client restructuring a nested group (5)
× an outer group removed: each level keeps its own group's fields
✓ outdent, levels of distinct fields
✓ indent, levels of distinct fields
× a run of new items finds the item their group fields come from in linear time
✓ limit: a link an older client replaces with another keeps the fields it does not know
❯ S7-21: what an older class does before it throws (5)
× a fallback node does not go through the importJSON that rejected it
× what it throws need not be printable
× a node it updates: what it changed is undone
× a node it updates: a change it made that Lexical cannot commit is undone
× a reader that throws null fails closed too
Tests 7 failed | 46 passed | 2 expected fail (55)
```
For example:
```
FAIL |unit| packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts > S7-3: an older client restructuring a nested group > an outer group removed: each level keeps its own group's fields
AssertionError: expected { root: { …(6) } } to deeply equal { root: { …(6) } }
- Expected
+ Received
@@ -50,11 +50,11 @@
"format": "",
"indent": 0,
"listType": "bullet",
"start": 1,
"tag": "ul",
- "tone": "middle",
+ "tone": "outer",
"type": "test_skew_list",
"version": 1,
},
],
FAIL |unit| packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts > S7-3: an older client restructuring a nested group > a run of new items finds the item their group fields come from in linear time
AssertionError: expected 33151 to be less than 8944
FAIL |unit| packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts > S7-21: what an older class does before it throws > what it throws need not be printable
AssertionError: expected [Function] to not throw an error but 'TypeError: Cannot convert object to p…' was thrown
FAIL |unit| packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts > S7-21: what an older class does before it throws > a node it updates: what it changed is undone
AssertionError: expected 'right' to be '' // Object.is equality
FAIL |unit| packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts > S7-21: what an older class does before it throws > a node it updates: a change it made that Lexical cannot commit is undone
AssertionError: expected 'active' to be 'readonly' // Object.is equality
FAIL |unit| packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts > S7-21: what an older class does before it throws > a reader that throws null fails closed too
AssertionError: expected 'active' to be 'readonly' // Object.is equality
```
`tsc -p tsconfig.test.json` on the previous commit with this test file:
```
packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts(1737,50): error TS2312: An interface can only extend an object type or intersection of object types with statically known members.
```
### After
```
$ ./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts
Tests 53 passed | 2 expected fail (55)
$ ./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/
Tests 4727 passed | 14 expected fail | 2 skipped (4743)
```
`pnpm run ci-check` passes. Sessions without version skew still store the
same bytes as `main`: over the whole `@lexical/y`
unit suite plus 800 fuzz seeds, every deterministic session is
byte-identical (the only test that differs draws random client ids and
differs between two runs of `main` too). Not run: browser and e2e suites.
…oint
## Description
Follow-up from a cross-review of the open `@lexical/y` pull requests.
- **One guarded read.** Each read of the binding wrapped its update in the
same try/update/try/catch so that it could recover or fail closed, in
about five copies. Releasing what a composition held back (`flushHidden`)
had none: a reader throw reached the editor's `onError` and left the held
text missing from an active editor. The re-read of what a follow-up
changed (`rereadRefused`) had no catch for a throw while committing. `YBinding#guardedRead` is now the one entry point every read
goes through:
- it reads nothing once the binding failed closed;
- a throw, of the read or of committing it, goes to the read's recovery
(`recoverRead`, or `failClosed` for a read of the whole document) with
what the editor showed before, and never reaches Yjs, a provider or
`initialize()`;
- what follows the read runs after its commit, also where the read is
nested in another update.
A release of a hold still clears the hold before its read, so the read
shows what it held; a recovery reads the whole document again.
- **`errorText` never throws.** Its fallback, `Object.prototype.toString`,
can throw too (a throwing `Symbol.toStringTag`, a revoked proxy); it is a
fixed text now.
- **The ownership rule in two clauses.** A write changes the value a stored
name gives content only where its client owns the name; format items that
keep every name's value around a write (negations and restores around
inserted content, run ends, the copies an undo brings back) carry any
name. The README's wire format says the same.
## Test plan
New cases in `packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts`:
a reader throw while releasing what a composition held back, a re-read of
what a follow-up changed that throws once and on every read, and a thrown
value whose text cannot be read.
### Before
On the previous commit with this test file (the describe blocks that pass
are left out), `./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts`:
```
❯ packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts (59 tests | 2 failed)
❯ S7-21 (MF-2): every read goes through the one fail-closed path (4)
× releasing what a composition held back
✓ a re-read of what a follow-up changed that throws once: read again
✓ a re-read of what a follow-up changed that throws on every read: kept as shown, read only
× a thrown value whose text cannot be read
Tests 2 failed | 55 passed | 2 expected fail (59)
```
```
FAIL |unit| packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts > S7-21 (MF-2): every read goes through the one fail-closed path > releasing what a composition held back
AssertionError: expected [ Error: simulated reader failure ] to deeply equal []
FAIL |unit| packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts > S7-21 (MF-2): every read goes through the one fail-closed path > a thrown value whose text cannot be read
AssertionError: expected [Function] to not throw an error but 'Error: no tag' was thrown
```
The two re-read cases pass there too: they keep what the earlier code did.
### After
```
$ ./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts
Tests 57 passed | 2 expected fail (59)
$ ./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/
Tests 4731 passed | 14 expected fail | 2 skipped (4747)
```
`pnpm run ci-check` passes. Sessions without version skew still store the
same bytes as `main`: over the whole `@lexical/y` unit suite plus 800 fuzz
seeds, every deterministic session is byte-identical. Not run: browser and
e2e suites.
… fields in the document the editor shows when a remote change waits to be read
## Description
A local update made before a remote change the binding has not read yet is
written on the document as its editor shows it (see `Unread`). Two reads
this PR adds to the writer still read raw Yjs:
- A new item of a group takes the group fields its client does not own from
the item next to it (`Writer#storedItemAttrs`). When the update waited for
an unread remote change, the new item copied the change's value, so the
room ended differently from the same edits written before the change
arrived.
- `$writeNodeAttrs` decides which fields the client owns from the node's
stored type (`ownedFields`). Read raw, a remote retype the binding had not
read looked like a type change of this write, which owns every field, so
the write deleted the fields of the new type the client does not declare.
Both now read through the unread view: `storedItemAttrs` with `isGone`,
`attrOf` and `attrsOf`, and `$writeNodeAttrs` with `storedType`, as the rest
of the writer does.
## Test plan
### Before
```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts -t N2
× a card a remote change retypes, centered meanwhile
✓ an item appended to it, in a list a remote change retones
× an item added before its first item, in a list a remote change retones
(the waiting run deletes the card's "p:tone"; the waiting list item stores "tone":"cool", the concurrent run "tone":"warm")
```
### After
```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts
Test Files 1 passed (1)
```
…pplyUpdate, when a pending local update throws as it commits
## Description
Before a remote read the binding commits the local updates still pending, so
that the read's update commits alone. That commit ran outside the read's
fail-closed entry point (`YBinding#guardedRead`): when an update or an update
listener of the app's threw on it, the error escaped into the provider's
`Y.applyUpdate`, the remote change was never read, and the editor kept
showing the old text while the binding stayed active. The next local edit was
then written at offsets computed without the remote change, so the clients
diverged for good.
Now `guardedRead` commits the pending updates itself, for every read that is
not nested in an update. A throw from that commit is the app's error: it is
rethrown in a microtask (an uncaught error, as a throwing `onWarn` handler's
is), the remaining pending updates still commit, and the read goes on. The
three call sites that committed before calling `guardedRead` no longer do;
what the remote read takes from the editor once those updates committed (the
selection to keep, whether it re-reads a naming update) moves into the read's
new `before` step. A read of the whole document still takes over a pending
update instead of committing it (`replacesPending`), as before: that is how
an initial state set before opening a document with content is replaced.
## Test plan
New `packages/lexical-y/src/__tests__/unit/regressions/GuardedRead.test.ts`,
`N4` cases: A deletes a word while B has a formatting update pending; B's
commit of it throws (an app update listener, with `onError` rethrowing and
reporting, and an update an app listener queues that throws). Each requires
that nothing is thrown into the sync, B reads A's change, the app's error is
rethrown in a microtask, and B's next edit lands where B made it on every
client.
### Before
```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/GuardedRead.test.ts
❯ |unit| packages/lexical-y/src/__tests__/unit/regressions/GuardedRead.test.ts (3 tests | 3 failed) 132ms
× N4: an update listener of the app throws on that commit 98ms
× N4: an update listener of the app throws on that commit 17ms
× N4: an update an app listener queues throws, and onError rethrows 15ms
FAIL |unit| ... > onError rethrows > N4: an update listener of the app throws on that commit
FAIL |unit| ... > onError reports > N4: an update listener of the app throws on that commit
AssertionError: promise rejected "Error: app failure" instead of resolving
FAIL |unit| ... > N4: an update an app listener queues throws, and onError rethrows
AssertionError: promise rejected "Error: app failure" instead of resolving
Tests 3 failed (3)
```
### After
```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/GuardedRead.test.ts
✓ |unit| packages/lexical-y/src/__tests__/unit/regressions/GuardedRead.test.ts (3 tests) 194ms
Tests 3 passed (3)
```
The whole `@lexical/y` unit suite passes with this commit
(`pnpm exec vitest run --project unit packages/lexical-y/`); the full suite
and `pnpm run ci-check` results are on the last commit of this series.
…n, and fail closed when it fails, for a read nested in an update
## Description
A remote read that throws is recovered from by rebuilding the editor from the
document (`recoverRead`), and where that rebuild throws too, the binding fails
closed (`failClosed`). Where the read was nested in an update (`Y.applyUpdate`
or `binding.undo()` called inside `editor.update()`), the rebuild could only be
queued, so `recoverRead` took the queuing for success: it warned
`RECOVERED_READ_WARNING` before anything was rebuilt. When the queued rebuild
then threw, its error escaped into the app's update and the provider, and the
editor ended empty and active, with no read-only status.
Now the recovery settles on the rebuild itself. `recoverRead` hands the
rebuild to `guardedRead` with the warning as what follows a committed rebuild
and `failClosed` (with what the editor showed before the failed read) as
where its throw goes; `guardedRead` runs either once the rebuild has run,
nested or not. The separate `rebuildFromDocument`, whose only job was to
rethrow, is gone. `failClosed`, which can now run from a commit's `$onUpdate`
callbacks, commits the state it restores there too: `setEditorState` alone
leaves it pending in that phase.
## Test plan
New `N5` cases in
`packages/lexical-y/src/__tests__/unit/regressions/GuardedRead.test.ts`: a
remote change and an undo read inside an app update while every read throws
(nothing thrown, read only, the editor shows what it showed, one
`'unreadable'` warning), and a remote change read inside an app update whose
first read throws (the recovered warning arrives once the editor shows the
rebuilt document).
### Before
```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/GuardedRead.test.ts
× N5: a remote change read inside an app update, whose recovery fails too, fails closed 17ms
× N5: an undo read inside an app update, whose recovery fails too, fails closed 11ms
× N5: a remote change read inside an app update, recovered: warned once the rebuild committed 12ms
FAIL |unit| ... > N5: a remote change read inside an app update, whose recovery fails too, fails closed
FAIL |unit| ... > N5: an undo read inside an app update, whose recovery fails too, fails closed
AssertionError: expected [Function] to not throw an error but 'Error: reader failure' was thrown
FAIL |unit| ... > N5: a remote change read inside an app update, recovered: warned once the rebuild committed
AssertionError: expected [ Array(1) ] to deeply equal [ Array(1) ]
[
- "a remote change could not be read: reader failure: paragraph(\"hello remote\")",
+ "a remote change could not be read: reader failure: paragraph(\"hello\")",
]
Tests 3 failed | 3 passed (6)
```
### After
```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/GuardedRead.test.ts
✓ |unit| packages/lexical-y/src/__tests__/unit/regressions/GuardedRead.test.ts (6 tests) 236ms
Tests 6 passed (6)
$ pnpm exec vitest run --project unit packages/lexical-y/
Test Files 143 passed | 2 skipped (145)
Tests 5020 passed | 40 expected fail | 2 skipped (5062)
```
…so for a read asked for before it
## Description
`YBinding#guardedRead` reads nothing once the binding failed closed, but it
checked that only when a read was asked for. A read nested in an update is
only queued then, and runs later: when the binding failed closed in between
(a read of the whole document that throws settles to `failClosed`, and an
update listener applies a remote change before that), the queued read still
ran, and changed what the frozen editor showed.
Now `guardedRead` checks where the read actually runs, inside its update, and
again before what follows it (its recovery or its follow-up): a read that
runs after the binding failed closed reads, recovers and follows up nothing.
That covers updates queued behind the one that failed and those drained
while the editor's earlier state is restored.
## Test plan
New `N6` case in
`packages/lexical-y/src/__tests__/unit/regressions/GuardedRead.test.ts`: a
read-only reader (a type it does not register) reads a layout record inside
an app update; that read of the whole document throws, and an update listener
applies a content change before it settles. Requires the binding to fail
closed and keep showing what it showed, without the content change.
### Before
```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/GuardedRead.test.ts
× N6: a remote change an update listener applies while a failing read of the whole document waits 28ms
FAIL |unit| ... > N6: a remote change an update listener applies while a failing read of the whole document waits
AssertionError: expected 'paragraph("hello remote")' to be 'paragraph("hello")' // Object.is equality
Tests 1 failed | 6 passed (7)
```
### After
```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/GuardedRead.test.ts
✓ |unit| packages/lexical-y/src/__tests__/unit/regressions/GuardedRead.test.ts (7 tests) 256ms
Tests 7 passed (7)
$ pnpm exec vitest run --project unit packages/lexical-y/
Test Files 143 passed | 2 skipped (145)
Tests 5021 passed | 40 expected fail | 2 skipped (5063)
```
…re a remote read, also when that listener throws
## Description
Before a remote read the binding commits the local updates still pending
(`commitPending`), so that the read's update commits alone. It looked only at
the editor's pending state. When an app update listener queued an update and
then threw, Lexical never ran the queued update (it runs queued updates at
the end of the commit the listener aborted), so there was no pending state,
only a queued update: the binding went on to the read, Lexical ran the queued
update inside the read's update, and it committed under the read's
`COLLABORATION_TAG` and was never written. The editor showed it while no
other client stored it, with the binding active and no warning.
Now `commitPending` commits until neither a pending state nor a queued update
is left, and says whether it got there. Where listeners keep making local
updates past the commits a read makes first (`MAX_PENDING_COMMITS`, 16 as
before), `guardedRead` does not run the read, which would take them over
unwritten: it goes to the read's recovery, as a read that throws does. A
remote read is then rebuilt from the document once those updates have
committed (warning `RECOVERED_READ_WARNING`), or fails closed if they still
do not stop.
## Test plan
New `R2-1` cases in
`packages/lexical-y/src/__tests__/unit/regressions/GuardedRead.test.ts`: A
deletes the first character while B has a bold edit pending, and B's update
listener queues a paragraph and throws, once, or on each of 20 commits in a
row (past the 16 a read commits first). Each requires B to match A and a
fresh reader, with every queued paragraph stored, the binding active, and the
recovery warned in the second case.
### Before
```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/GuardedRead.test.ts
× R2-1: an update listener queues a local update, then throws 30ms
× R2-1: listeners that queue local updates past the commits a read makes first: recovered, every update written 24ms
FAIL |unit| ... > R2-1: an update listener queues a local update, then throws
Expected: "paragraph("lpha " "beta"{1} " gamma") / paragraph("tail")"
Received: "paragraph("lpha " "beta"{1} " gamma") / paragraph("tail") / paragraph("queued edit")"
FAIL |unit| ... > R2-1: listeners that queue local updates past the commits a read makes first: recovered, every update written
Expected: "paragraph("lpha " "beta"{1} " gamma") / paragraph("tail")"
Received: "paragraph("lpha " "beta"{1} " gamma") / paragraph("tail") / paragraph("queued 20")"
Tests 2 failed | 7 passed (9)
```
(Expected is what A and a fresh reader store; Received is what B shows.)
### After
```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/GuardedRead.test.ts
✓ |unit| packages/lexical-y/src/__tests__/unit/regressions/GuardedRead.test.ts (9 tests)
Tests 9 passed (9)
$ pnpm exec vitest run --project unit --maxWorkers=24 packages/lexical-y/ packages/lexical/src
Test Files 245 passed | 2 skipped (247)
Tests 7408 passed | 40 expected fail | 2 skipped (7450)
```
`pnpm run ci-check` passes.
…e document the editor shows, not on a remote change it has not read
## Description
When listeners keep making local updates past the commits a read makes
first, `YBinding#guardedRead` skips the read and recovers from it instead.
It still ran the read's `before` step, though, and a remote read's `before`
switches the binding to reading the document as it is (the remote change
included). The recovery then committed the remaining local updates in that
mode, while the editor did not show the remote change yet: their offsets
were taken on the wrong text. With a remote `!` inserted at the start and 20
local deletions of the first character, every client stored `tuvwxyz`
instead of `!uvwxyz`: the remote insert was deleted and a character meant to
be deleted survived.
Now `before` runs only where the read runs. The switch to reading the
document as it is has one owner, `startReading`, which a remote read's
`before` and the recovery's rebuild both call once the local updates still
pending are written, on the document the editor shows (see `Unread`). It
changes nothing outside a transaction being read.
## Test plan
New `R3-1` case in
`packages/lexical-y/src/__tests__/unit/regressions/GuardedRead.test.ts`: A
inserts `!` at the start while B deletes its first character 20 times (one
pending deletion, then a listener that queues each next one and throws, past
the 16 commits a read makes first). Requires every client to store what the
edits give when they meet concurrently, `!uvwxyz`, with B active and the
recovery warned once.
### Before
```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/GuardedRead.test.ts
× R3-1: listeners that queue local deletions past the commits a read makes first, while a remote insert waits 48ms
FAIL |unit| ... > R3-1: listeners that queue local deletions past the commits a read makes first, while a remote insert waits
AssertionError: expected 'paragraph("tuvwxyz")' to be 'paragraph("!uvwxyz")' // Object.is equality
Expected: "paragraph("!uvwxyz")"
Received: "paragraph("tuvwxyz")"
Tests 1 failed | 9 passed (10)
```
### After
```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/GuardedRead.test.ts
✓ |unit| packages/lexical-y/src/__tests__/unit/regressions/GuardedRead.test.ts (10 tests)
Tests 10 passed (10)
$ pnpm exec vitest run --project unit --maxWorkers=24 packages/lexical-y/ packages/lexical/src
Test Files 245 passed | 2 skipped (247)
Tests 7409 passed | 40 expected fail | 2 skipped (7451)
```
`pnpm run ci-check` passes.
…hrows on its commit, instead of rebuilding or opening read only
## Description
`YBinding#guardedRead` sent any error thrown by the read's `editor.update`
to the read's recovery, as one thrown committing what was read. That
included an error thrown by an update listener of the app's, which Lexical
runs once the read is committed and the editor shows it. The read was then
rebuilt from the document (warning that a remote change could not be read),
and when the listener threw on the rebuild's commit too, the binding failed
closed: "the document cannot be read", read only, showing the editor as it
was before the collaborator's change.
Now an error thrown once the editor's state is the read's (Lexical committed
it and was running listeners) is the app's: it is rethrown in a microtask,
as one from committing the updates pending before a read already is, and
the read stands, with what follows it. Only an error from the read itself,
or from its commit before the editor shows it, goes to recovery. The
recovery's rebuild goes through the same entry point, so a listener that
throws on the rebuild's commit no longer fails it either. The microtask
rethrow has one helper, `rethrowLater`.
## Test plan
New `R-1` cases in
`packages/lexical-y/src/__tests__/unit/regressions/GuardedRead.test.ts`: B's
update listener throws on the first 1, 2, 3 or every commit while B reads A's
insert. Each requires B to show A's insert with no warning and stay active,
and the next edits of A and B to converge with A's word kept on every client.
### Before
```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/GuardedRead.test.ts
× R-1: it throws on 1 commits while B reads A's insert 35ms
× R-1: it throws on 2 commits while B reads A's insert 21ms
× R-1: it throws on 3 commits while B reads A's insert 24ms
× R-1: it throws on Infinity commits while B reads A's insert 20ms
FAIL |unit| ... > R-1: it throws on 1 commits while B reads A's insert
AssertionError: expected [ Array(1) ] to deeply equal []
+ "a remote change could not be read: app failure",
FAIL |unit| ... > R-1: it throws on Infinity commits while B reads A's insert
AssertionError: expected [ Array(1) ] to deeply equal []
+ "the document cannot be read: app failure",
Tests 4 failed | 10 passed (14)
```
(The 2 and 3 cases fail with the same "the document cannot be read" warning.)
### After
```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/GuardedRead.test.ts
Tests 14 passed (14)
$ pnpm exec vitest run --project unit --maxWorkers=8 packages/lexical-y/ packages/lexical/src
Test Files 245 passed | 2 skipped (247)
Tests 7413 passed | 40 expected fail | 2 skipped (7455)
```
`pnpm run ci-check` passes.
…what a remote read brings
## Description
`YBinding#guardedRead` keeps a read that an app update listener threw on,
and recovers from one whose commit threw otherwise. It told the two apart by
the editor's state being the read's. Lexical gives the editor the new state
before it reconciles the DOM, though, so a class that throws rendering
content the read brought (`createDOM`) also left the editor's state the
read's: the binding kept the read and stayed active with no warning, on a
document this editor cannot render. Before the app listener case was told
apart, it failed closed ("the document cannot be read").
Now the phase decides: Lexical marks the read's state read only once it has
reconciled it, before it runs the commit's listeners. A throw once the state
is marked is a listener's (the app's): rethrown in a microtask, and the read
stands. A throw before it, rendering what was read, goes to recovery as
before: the document is rebuilt, and where that throws too, the binding
fails closed. So does a throw on the commit of an update queued behind the
read (the editor's state is no longer the read's), where the throw can have
skipped writing that update. This does not depend on the order the app's
listeners and the binding's were registered in.
## Test plan
- New `packages/lexical-y/src/__tests__/unit/regressions/GuardedReadRender.test.ts`
(jsdom, the reader's editor attached to a DOM root): A appends a block whose
class throws in `createDOM`; B must open read only with one "the document
cannot be read" warning.
- `R-1` in `regressions/GuardedRead.test.ts` now runs each throw count with
the throwing listener registered after the binding's and before it (an app
that registers its listeners before the binding starts); all keep the read.
### Before
```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/GuardedRead.test.ts packages/lexical-y/src/__tests__/unit/regressions/GuardedReadRender.test.ts
× R-1: a class that throws rendering stored content still fails closed 160ms
AssertionError: expected 'active' to be 'readonly' // Object.is equality
Tests 1 failed | 18 passed (19)
```
### After
```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/GuardedRead.test.ts packages/lexical-y/src/__tests__/unit/regressions/GuardedReadRender.test.ts
Tests 19 passed (19)
$ pnpm exec vitest run --project unit --maxWorkers=24 packages/lexical-y/ packages/lexical/src
Test Files 246 passed | 2 skipped (248)
Tests 7426 passed | 40 expected fail | 2 skipped (7468)
```
`pnpm run ci-check` passes.
zurfyx
force-pushed
the
zurfyx/y-preserve-unknown-attrs
branch
from
October 29, 2026 19:14
f58c9a3 to
81f87e8
Compare
zurfyx
added a commit
that referenced
this pull request
Oct 29, 2026
… does not know in a block it moves ## Description An older client of an app (one whose class lacks a field that a newer version of the class added) keeps that field when it edits a node (#9504). A block it moves was different: Yjs has no moves, so the writer deletes the moved block and writes it anew from what the client shows (#9502 chooses which block). The new copy started empty, so the moved block lost every field the client does not own, for every client and for a fresh reader, with no concurrency involved: - a line's fields on its marker (a note's `tone`), and the fields of its list and outer lists on the marker of every item; - a block node's or decorator's fields on its Y.Node, and those of everything it holds (lines, lists, inline nodes, slots), which are written anew too; - the same when the client splits such a line. With this change, a unit written anew takes what its node stored before the write: - **Lines.** A marker the write makes for a line that had one takes the fields the old marker stored that the client does not own (`$markerBefore`, read with `attrsBefore`: the attributes as they were before the transaction, which Yjs still holds for a marker the transaction deleted). A line that holds lines (stored as parts) reads each part's old marker, or the whole line's for a new part. Where a diff of a container gives a moved line the marker of another line there, the marker takes the moved line's fields and drops the other line's. - **Groups.** A list item new to its list, or written anew, takes the list's fields (`g:`) from the item next to it that kept its marker and was in the list before (#9504's rule, which now also requires the second part), so an item that joins another list takes that list's fields. Where no such item exists, the list moved with the item or lost all its items: the item takes the fields the list stored before, from its own old marker if it was in the list, else from another item's, else from an item the list had then (`$priorGroupItem`). So a list moved with an item added, swapped list items, and two lists an older client shows as one all keep their own fields. An outer level takes the fields of its item (`@`) from where that item's were stored, so a nested list moved into a new item takes none of the old item's. - **Nodes.** A node given new storage takes the fields its old Y.Node stored that the client does not own (`nodeBefore` in `$fillNode`). What each lookup finds is kept for the whole write, so a node written twice in one write (moved into a container that is itself written anew) still takes what it had before the first. Only names the client does not own are copied; it writes and deletes its own as before. Ownership now also counts a flat NodeState with no schema (a plain `parse` function) where the client writes it as a field: on a node stored as its own Y.Node, and on text (`jsonFieldsOf`). With #9504, such a state reset to its default was never deleted there, so every other client and a fresh reader kept the old value. On `main` it was deleted. Clients of one version store what they stored before, with one exception, in names that no class of theirs declares on a line (a list item's `checked` left on a paragraph): those now stay with the line when a diff gives it another line's marker, where before they stayed with the marker. Remaining limits, as `test.fails` and in y.md: - The text of a moved block is still written from what the client shows, so it loses the text formats, text-class fields and link fields the client does not know. Keeping them needs the old characters' formats carried into the new runs, character by character. - A nested list whose item moves into another list does not take that list's fields: the outer level would need them from the items of the list it joins. Not changed here: an item that keeps its marker while its list changes (an indent or outdent of a list's first item) keeps the list fields of its old place, as with #9504. ## Changeset `.changeset/lexical-y-moved-blocks-keep-fields.md`: `[fix]` for `@lexical/y`, with a `Verify:` paragraph. ## Test plan `regressions/SchemaSkew.test.ts`: #9504's `test.fails('limit: a block an older client moves keeps the fields it does not know')` is now a passing test. A new F2 group compares each move made by an older client with the same move made by a newer one: lines, lists, block nodes, decorators, nested content, nested lists and inline nodes, moved down, up and into a new container; splits; slots; items moved within and between lists; swapped list items; lists shown as one; lists moved with new items; nested lists moved into new or other items; moves into containers that move too; a line that gets another line's marker; a line holding lines. Another group checks a flat NodeState with no schema, reset in place and on a move. `./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts` ### Before On the merge of #9502 and #9504: ``` FAIL |unit| packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts > F2: an older client that moves a block keeps the fields it does not know > a block node moved down past a paragraph AssertionError: expected { root: { children: [ …(2) ], …(5) } } to deeply equal { root: { children: [ …(2) ], …(5) } } - "tone": "warm", + "tone": "", FAIL |unit| packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts > S7-3, F2: a flat NodeState with no schema > on a block node, reset, stays reset + "test_skew_memo": "old", Test Files 1 failed (1) Tests 41 failed | 66 passed | 4 expected fail (111) ``` ### After ``` ✓ |unit| packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts (111 tests) Test Files 1 passed (1) Tests 107 passed | 4 expected fail (111) ``` - `@lexical/y` unit tests: 151 files passed and 2 skipped; 5,144 tests passed, 44 expected fail, 2 skipped (on the merge: 151 files passed and 2 skipped; 5,094 tests passed, 42 expected fail, 2 skipped). - Golden corpus: the fuzz's five lanes (no moves, block ids, gc, moves, block ids with moves), 250 seeds each, compared with the merge for every peer's Yjs state bytes, editor JSON, warnings, fuzz findings, node keys and selection. Editor JSON, warnings, findings, keys and selection are identical on all 1,250 seeds. Bytes differ on 3 seeds in each lane without moves and 2 in each lane with moves, all from the exception above. - `pnpm run ci-check` passes.
zurfyx
added a commit
that referenced
this pull request
Oct 29, 2026
… and keep held ones on both halves of a split Two defects found in the composition hold-back by a review across PRs: - **MF-1:** the hold captured every marker attribute a collaborator's change set, including keys the client does not own, such as a field a newer version of a class declares. When the composing client split the line, it wrote such a key onto both halves; without a composition, it stays on one. A change of only such keys also created a hold that holds nothing the client shows. The hold now captures only the keys the client owns, by Schema's ownership rule (`ownsMarkerAttr`: the binding's own keys, NodeState, and the fields its classes declare, from `fieldsOfTypes` over `ownedFieldsOf`). Ownership is judged over the types the change goes from and to, and the type the editor shows, so a change of type owns the fields of both. A change of keys the client does not own creates no hold and needs no read. - **B-2:** a held attribute was lost on one half of a split made while composing. When a collaborator centered the line and the composing client pressed Enter mid-line, only one half stayed centered, and the client's write deleted `center` from the original marker. Paragraph Enter inherits the alignment, so both halves keep it now. The hold records every marker that stores the held attributes: the composed line's, and the old marker that the new line holding the rest of it ends at. A line on one of them keeps a held value its write left as the editor shows it. A line the composed line was joined into keeps its own values, as does the empty line Enter makes at the end of the line. Only an explicit change of the composed line itself ends the hold of an attribute, and the release reads every recorded marker. New in `packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts`: - B-2: both halves keep a held alignment or type and the lines around do not get it, plus a local explicit change of the alignment, which wins. - MF-1: a field the client does not know, alone or with an owned alignment. Nothing of it is held, and after Enter it is stored on one marker. The matrix in `__tests__/utils/compositionHoldBack.ts` now checks the composed line itself and the lines around it, instead of any line. `./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts -t 'B-2|MF-1'` On the previous commit, before the restack onto #9504 (6bc5caf): ``` × both halves keep it, the other lines do not get it (center, ids 1,2) 142ms × both halves keep it, the other lines do not get it (heading, ids 1,2) 57ms ✓ both halves keep it, the other lines do not get it (center, then A aligns it left, ids 1,2) 49ms × both halves keep it, the other lines do not get it (center, ids 2,1) 40ms × both halves keep it, the other lines do not get it (heading, ids 2,1) 47ms ✓ both halves keep it, the other lines do not get it (center, then A aligns it left, ids 2,1) 50ms × A holds nothing of it and stores it once after Enter (alone, ids 1,2) 21ms × A holds nothing of it and stores it once after Enter (with an alignment, ids 1,2) 18ms × A holds nothing of it and stores it once after Enter (alone, ids 2,1) 19ms × A holds nothing of it and stores it once after Enter (with an alignment, ids 2,1) 20ms AssertionError: expected [ 'paragraph::pre', …(3) ] to deeply equal [ 'paragraph::pre', …(3) ] AssertionError: expected [ 'p:future' ] to deeply equal [] AssertionError: expected [ 'p:future', 'p:format' ] to deeply equal [ 'p:format' ] Tests 8 failed | 2 passed | 43 skipped (53) ``` ``` ✓ |unit| packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts (53 tests | 43 skipped) 546ms Tests 10 passed | 43 skipped (53) $ ./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/CompositionHoldBack.test.ts packages/lexical-y/src/__tests__/unit/CompositionHoldBackBlockIds.test.ts packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts packages/lexical-y/src/__tests__/unit/Composition.test.ts ✓ |unit| packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts (53 tests) 2261ms ✓ |unit| packages/lexical-y/src/__tests__/unit/Composition.test.ts (63 tests) 6976ms ✓ |unit| packages/lexical-y/src/__tests__/unit/CompositionHoldBackBlockIds.test.ts (169 tests) 10316ms ✓ |unit| packages/lexical-y/src/__tests__/unit/CompositionHoldBack.test.ts (1764 tests | 2 skipped) 86242ms Test Files 4 passed (4) Tests 2047 passed | 2 skipped (2049) $ pnpm run ci-check Changeset check passed: 5 pending changeset(s) valid. Found 0 errors ```
zurfyx
added a commit
that referenced
this pull request
Oct 29, 2026
…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.
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 7 of 10. Based on #9502 (
zurfyx/y-reorder-intent). 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.Description
During a rollout, clients of two app versions edit the same room. Three ways an older client destroyed or misread what it does not understand, fixed in the first two commits; the third addresses a review of them, and the fourth a cross-review of the open
@lexical/yPRs (see Review below).1. Fields a newer class added (S7-3). A client whose class lacks a field that a newer version of the class added deleted that field for every client as soon as it wrote the node: a block container's or decorator's field when it changed the node, a list's field when it changed the list (group fields on every item marker, and the outer levels of nested lists), a link's field when it called
setURLor typed inside it, and a TextNode subclass's field when it applied bold or typed in it. A server agent with the older config (editYState) did the same.One rule now decides every write, in
Schema.ts: a client writes and deletes only the stored names it owns, and keeps every other name as stored. It owns the binding's own keys and NodeState (Lexical keeps NodeState keys it does not know), the text formats its Lexical knows, and the fields its classes declare in$configfor the location's type: every field where the write changes the type or leaves none, and every field of a class that writes its ownexportJSON. The same predicates (ownsNodeAttr,ownsMarkerAttr,ownsFormat,ownsMember) serve the node, marker, text format and wrapper writes, the self-check, and the reader. A field a client knows and clears is still deleted.2. Link types a client does not register (S7-11). A link (or another inline element stored as a format) of a type the client does not register was dropped from its view while the client stayed editable, so wrapping that paragraph in a container deleted the link for everyone. It now goes through the unknown-type path like every other stored kind: read only,
UNKNOWN_NODE_TYPES_WARNINGwithreason: 'unregistered'. This covers an editor withoutLinkNodetoo, andeditYStaterefuses such a document.3. Content a class throws on (S7-21). A class whose
updateFromJSONrejects a value a newer version writes left the client active with an empty editor, and the error escaped into the provider'sY.applyUpdate(orinitialize()). Content a class throws on is now unreadable, as an unregistered type is: read only,UNKNOWN_NODE_TYPES_WARNINGwith the new reason'unreadable'(types,error), the rest of the document still shown, nothing thrown, nothing written. A read that throws again on a fresh read (a reader bug) fails closed the same way and keeps what the editor showed.flowchart LR W[write of a node, marker, text run or link] --> O{name owned?} O -- yes --> S[set or delete it] O -- no --> K[keep it as stored] R[read] --> U{type registered and class reads it?} U -- yes --> E[editable] U -- no --> RO[read only + UNKNOWN_NODE_TYPES_WARNING]Stored data stays layout 1, and sessions without version skew store the same bytes as before: over the whole
@lexical/yunit suite plus 800 fuzz seeds (5,362 deterministic sessions, 47,402 document states), every stored document is byte-identical tomain's.Limits, documented in y.md, each with a
test.failscase or a test: a block an older client moves is written anew, so the moved block loses the fields that client does not know; a link it replaces with another link of the same type over the same text keeps the fields it does not know of the replaced link (it cannot tell a new link from the old one edited); a class that writes its ownexportJSONowns every field of its type.Restack (October 29)
Rebased onto #9502 (it was based on a
mainone commit older). One conflict, with #9523 inWrite.ts($writeMarker,$writeNodeAttrs): resolved with #9523's view-awaresetAttr/attrsOfand this PR's values and owned fields.New commits, each with a regression test named after its finding:
storedItemAttrs, withisGone,attrOf,attrsOf) and the stored type that decides which fields the client owns ($writeNodeAttrs, withstoredType). Before, a new item an older client added while a remote change waited to be read copied the unread value, and a remote retype the binding had not read made the write own, and delete, the fields of the new type that the client does not declare (N2:inSchemaSkew.test.ts).Y.applyUpdate.guardedReadcommits pending work itself, andcommitPendingrethrows an app's error in a microtask and keeps committing until neither a pending state nor a queued update is left, so the remote change is still read, a local update an app listener queued before the read is written on its own commit, and the peers do not diverge (N4:andR2-1:inregressions/GuardedRead.test.ts). Listeners that still queue updates after 16 commits (MAX_PENDING_COMMITS) send the read to recovery instead of merging their work into it, and a chain that never ends fails closed; the recovery commits that work on the document the editor shows, and only its rebuild reads the remote change (startReading,R3-1:).Y.applyUpdateorbinding.undoinsideeditor.update) reportsRECOVERED_READonly once its rebuild committed, and a rebuild that fails fails closed with what the editor showed before the read.rebuildFromDocumentis gone;recoverReadruns the rebuild throughguardedRead.guardedReadalso checks the frozen state where a queued read actually runs, so a read asked for before the binding failed closed reads nothing after it.Fixed after a randomized soak test of the whole stack:
R-1:inregressions/GuardedRead.test.ts, throwing on 1, 2, 3 and every commit, with the listener registered before and after the binding's, and inregressions/GuardedReadRender.test.ts). Follow-ups: the binding's own write commits (refresh, normalize) still let an app listener's error escape; and, independent of this fix, an app update listener registered before the binding that throws on a local commit makes Lexical skip the binding's listener, so that edit is shown but not written (seen at the top of this stack, not yet checked onmain).For review: listeners that keep queueing local updates past 16 commits now leave the binding recovering, or frozen read only if the chain never ends; raise
MAX_PENDING_COMMITSto tolerate longer chains. With N4, an error an app's update or listener throws while the binding commits a pending update reachesonErrorand then an uncaught microtask error, whereinitialize()and the transform wrapper used to throw it synchronously. Unchanged gap: areadAllthat a layout-record change triggers still takes over a pending local edit, so that edit is not written.Changeset
.changeset/lexical-y-older-clients-keep-fields.mdand.changeset/lexical-y-unreadable-content.md(@lexical/y: patch,[fix]).Test plan
New
packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts, cases named after their findings. Each S7-3 case runs one edit made by a client with the older classes and the same edit made by a client with the newer ones, and requires the same stored document; three clients (old, new, a fresh reader) converge each time. S7-11 covers each stored kind; S7-21 covers a line and a node of its own, live and on opening a room, a read that throws on every attempt, andeditYState. Each commit message has the full before and after output.Before
On
mainwith the first commit's test file:On the first commit with the second commit's test file:
On the second commit with the third commit's test file:
On the third commit with the fourth commit's test file:
After
pnpm run ci-checkpasses. Not run: browser and e2e suites (the change is in the binding's storage logic, covered by the unit suites).Review
An independent review of the first two commits, by a second AI model working from the patch alone, reported three blockers, four should-fix items and two nits, each with a reproduction. The third commit fixes all of them except one nit:
o) were matched by depth, so removing an outer group gave the remaining level that group's fieldsupdateFromJSONchanged before it threw stayed, and a change Lexical could not commit escaped intoY.applyUpdateYUnknownNodeTypesDatafrom an interface to a union brokeinterface X extends YUnknownNodeTypesData'unreadable'has its ownYUnreadableContentDataimportJSONwith minimal JSON, which a class may rejectnullread as "no error"; a thrown value that cannot become a string made the warning throw'unreadable'doc described only the last-resort pathVerify:paragraph@lexical/yhas not been released yet, so there is no earlier behavior to verifyEach fix has a regression test named after its finding in
SchemaSkew.test.ts.A cross-review of all open
@lexical/yPRs then asked for three changes here, made in the fourth commit:YBinding#guardedReadreplaces about five copies of the same try/update/try/catch. It also covers the two reads that lacked it: releasing what a composition held back (flushHidden, where a reader throw reachedonErrorand left held text missing) and the re-read of what a follow-up changed (rereadRefused, which had no catch for a throw while committing). Other open PRs that add reads build on it.errorTextnever throws: its fallback is a fixed text.