From 9789aeca06638d8ccb17b3f8f53c26043ad4ed10 Mon Sep 17 00:00:00 2001 From: Gerard Rovira Date: Thu, 29 Oct 2026 18:19:13 -0400 Subject: [PATCH 1/3] [lexical-y] Chore: Cover the schema version gate's remaining paths, and update stale test comments ## Description An audit of the `@lexical/y` tests after the stack merged, with a v8 coverage run of `packages/lexical-y` and `packages/lexical-mark` (lines 94.8%, branches 90.4%). New tests in `SchemaVersion.test.ts`, for the schema gate paths no test reached, and the read-only status the gate holds: - A newer record that arrives while a local update runs, and that update then throws: the gate waited for a commit that never came, and the next update is refused (`schemaGatesWrite` dropping `schemaGateAfter`). - A newer record in a remote change while the app's listeners keep queuing local updates past the read's commit limit: the read gives up before its `before` step can gate, and only the recovery gates (the `recover` path of the remote read). Removing that gate makes this test fail with the binding still `'active'`. - A newer record in a remote change whose read throws: the editor is rebuilt from the document with the newer client's edit, and the binding is read only (gated before the read; the recovery gates again). - A binding the gate holds read only stays read only, never editable, and does not warn again when another client records a new layout. Comments that no longer matched the code: - `TableOnOpen.test.ts` said the binding reads the document when it registers; it starts in `YCollabExtension`'s `afterRegistration`, once every extension has registered. - Four tests pointed at "Known open issues" or "Known limits", sections of the #9400 PR description that the repository does not have; they now name #9400, the y.md limit, or the issue filed since (#9514). Every `test.fails` still fails on `main` (46 expected failures in the full unit run), and no test is skipped beyond the environment-gated perf and replay suites. ## Changeset None: tests only. ## Test plan ### Before ``` $ pnpm exec vitest --project unit --project scripts-unit --no-watch Test Files 585 passed | 3 skipped (588) Tests 18134 passed | 46 expected fail | 6 skipped (18186) ``` Coverage of `Binding.ts` before: `schemaGatesWrite`'s reset of `schemaGateAfter` (line 1754) and the remote read's recovery gate (line 2530) never ran. ### After ``` $ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/SchemaVersion.test.ts Tests 25 passed (25) ``` --- .../src/__tests__/unit/FollowUpFuzz.test.ts | 2 +- .../src/__tests__/unit/SchemaVersion.test.ts | 115 ++++++++++++++++++ .../src/__tests__/unit/TableOnOpen.test.ts | 7 +- .../src/__tests__/unit/UpdateErrors.test.ts | 4 +- .../unit/regressions/FollowUps.test.ts | 2 +- .../unit/regressions/Formats.test.ts | 4 +- .../unit/regressions/QuotesAndSlots.test.ts | 4 +- .../__tests__/unit/regressions/Undo.test.ts | 2 +- 8 files changed, 128 insertions(+), 12 deletions(-) diff --git a/packages/lexical-y/src/__tests__/unit/FollowUpFuzz.test.ts b/packages/lexical-y/src/__tests__/unit/FollowUpFuzz.test.ts index 8d01fab096a..f20fd9a6499 100644 --- a/packages/lexical-y/src/__tests__/unit/FollowUpFuzz.test.ts +++ b/packages/lexical-y/src/__tests__/unit/FollowUpFuzz.test.ts @@ -96,7 +96,7 @@ function $followUp(r: () => number, k: number): string { return `textStyle ${e.getType()}`; } // No update stores a link's own alignment, indent and direction, nor a - // list's own indent (Known limits): not set there. + // list's own indent (Known limits in #9400): not set there. const blocky = elements.filter(n => !n.isInline()); if (op === 2 && blocky.length > 0) { const e = pick(blocky); diff --git a/packages/lexical-y/src/__tests__/unit/SchemaVersion.test.ts b/packages/lexical-y/src/__tests__/unit/SchemaVersion.test.ts index e3970f67fb6..ddee91e4fc8 100644 --- a/packages/lexical-y/src/__tests__/unit/SchemaVersion.test.ts +++ b/packages/lexical-y/src/__tests__/unit/SchemaVersion.test.ts @@ -35,6 +35,7 @@ import {afterEach, describe, expect, test, vi} from 'vitest'; import { createYDoc, + RECOVERED_READ_WARNING, UNSUPPORTED_EDIT_WARNING, UNSUPPORTED_SCHEMA_WARNING, YBinding, @@ -49,6 +50,7 @@ import { seedYState, YHeadlessError, } from '../../headless'; +import {Reader} from '../../internal/Read'; import {$p, NODES} from '../utils/harness'; const SCHEMA = 'lexical-y-schema:'; @@ -444,6 +446,40 @@ describe('schema version gate: timing', () => { expect(Y.encodeStateVector(older.doc)).toEqual(before); }); + test('local updates still pending past the commit limit: the read does not run, and the recovery gates', async () => { + const caught = catchMicrotasks(); + const older = seeded(1, 'ab'); + const newer = client(2); + sync(older, newer); + newer.binding.initialize(); + type(newer, 'd'); + await new Promise(resolve => setTimeout(resolve, 0)); + // More pending commits than the read waits for: it gives up before it + // runs (its `before` never gates), and only the recovery can. + let left = 40; + older.editor.registerUpdateListener(({tags}) => { + if (left > 0 && !tags.has('collaboration')) { + const n = left--; + older.editor.update(() => void $getRoot().append($p(`q${n}`))); + throw new Error(`app failure ${n}`); + } + }); + older.editor.update(() => void $getRoot().append($p('first'))); + Y.applyUpdate( + older.doc, + Y.encodeStateAsUpdate(newer.doc, Y.encodeStateVector(older.doc)), + ); + await Promise.resolve(); + expect(caught.length).toBeGreaterThan(0); + expect(older.binding.status).toBe('readonly'); + expect(codes(older)).toContain(UNSUPPORTED_SCHEMA_WARNING); + const before = Y.encodeStateVector(older.doc); + older.editor.update(() => void $getRoot().append($p('late')), { + discrete: true, + }); + expect(Y.encodeStateVector(older.doc)).toEqual(before); + }); + test('a record that arrives alone while an update runs: the update is written, then read only', () => { const older = seeded(1, 'ab'); const update = recordOnly(older.doc, SCHEMA + '2'); @@ -566,6 +602,85 @@ describe('schema version gate: timing', () => { }); expect(records(v2.doc)).toEqual([SCHEMA + '1', SCHEMA + '2']); }); + + test('a record that arrives while an update runs that then throws: later updates are refused', () => { + const older = seeded(1, 'ab'); + const update = recordOnly(older.doc, SCHEMA + '2'); + expect(() => + older.editor.update( + () => { + const t = $getRoot().getFirstDescendant() as TextNode; + t.setTextContent('X' + t.getTextContent()); + Y.applyUpdate(older.doc, update); + throw new Error('app failure'); + }, + {discrete: true}, + ), + ).toThrow('app failure'); + // The update the gate waited for never committed: the next one is not + // written, and the binding is read only. + const before = Y.encodeStateVector(older.doc); + type(older, 'c'); + expect(Y.encodeStateVector(older.doc)).toEqual(before); + expect(older.binding.status).toBe('readonly'); + expect(codes(older)).toContain(UNSUPPORTED_SCHEMA_WARNING); + expect(text(older)).toBe('ab'); + }); + + test('a binding the schema gate holds stays read only when the layout record changes', () => { + const older = seeded(1, 'ab'); + Y.applyUpdate(older.doc, recordOnly(older.doc, SCHEMA + '2')); + expect(older.binding.status).toBe('readonly'); + const editable: boolean[] = []; + older.editor.registerEditableListener(value => { + editable.push(value); + }); + // A newer client records layout 2 (its first set-valued member does). + Y.applyUpdate(older.doc, recordOnly(older.doc, 'lexical-y:2')); + expect(older.binding.status).toBe('readonly'); + expect(editable).toEqual([]); + expect(older.editor.isEditable()).toBe(false); + // Warned once, when the gate closed. + expect(codes(older)).toEqual([UNSUPPORTED_SCHEMA_WARNING]); + const before = Y.encodeStateVector(older.doc); + type(older, 'c'); + expect(Y.encodeStateVector(older.doc)).toEqual(before); + }); + + test('a newer record in a change whose read throws: the recovery gates', () => { + const older = seeded(1, 'ab'); + const newer = client(2); + sync(older, newer); + newer.binding.initialize(); + type(newer, 'd'); + const proto = Reader.prototype as unknown as { + $apply: (...args: unknown[]) => void; + }; + const apply = proto.$apply; + proto.$apply = function () { + throw new Error('simulated reader failure'); + }; + try { + Y.applyUpdate( + older.doc, + Y.encodeStateAsUpdate(newer.doc, Y.encodeStateVector(older.doc)), + ); + } finally { + proto.$apply = apply; + } + // Gated before the read; the recovery gates again (a no-op here). + expect(codes(older)).toEqual([ + UNSUPPORTED_SCHEMA_WARNING, + RECOVERED_READ_WARNING, + ]); + expect(older.binding.status).toBe('readonly'); + // Rebuilt from the document, with the newer client's edit. + expect(text(older)).toBe('abd'); + const before = Y.encodeStateVector(older.doc); + type(older, 'e'); + expect(Y.encodeStateVector(older.doc)).toEqual(before); + expect(text(older)).toBe('abd'); + }); }); describe('schema version: headless', () => { diff --git a/packages/lexical-y/src/__tests__/unit/TableOnOpen.test.ts b/packages/lexical-y/src/__tests__/unit/TableOnOpen.test.ts index 03d37d2d543..ac7accff5db 100644 --- a/packages/lexical-y/src/__tests__/unit/TableOnOpen.test.ts +++ b/packages/lexical-y/src/__tests__/unit/TableOnOpen.test.ts @@ -10,9 +10,10 @@ // An editor bound to a document that already holds a table opens with the // table, wherever YCollabExtension and TableExtension sit in its -// dependencies. The binding reads the document when it registers, so with -// YCollabExtension listed first the table is in the editor state before -// TableExtension registers, and before there is a root element. +// dependencies. The binding starts, and reads the document, in +// YCollabExtension's afterRegistration, once every extension has registered, +// so in either order TableExtension's transforms are registered before the +// table is read, and there is no root element yet. import {buildEditorFromExtensions} from '@lexical/extension'; import {RichTextExtension} from '@lexical/rich-text'; diff --git a/packages/lexical-y/src/__tests__/unit/UpdateErrors.test.ts b/packages/lexical-y/src/__tests__/unit/UpdateErrors.test.ts index 3fc8bed4a78..8c0645f4da4 100644 --- a/packages/lexical-y/src/__tests__/unit/UpdateErrors.test.ts +++ b/packages/lexical-y/src/__tests__/unit/UpdateErrors.test.ts @@ -316,12 +316,12 @@ describe('the binding writes every committed change', () => { ); }); - // A known limit, not caused by an update that throws: Lexical stops + // Open, #9514 (not caused by an update that throws): Lexical stops // notifying a commit's update listeners when one of them throws, so a // binding registered after the listener that threw never writes that // commit. test.fails( - "a commit whose update listeners stopped before the binding's is not written (known limit)", + "a commit whose update listeners stopped before the binding's is not written (#9514)", async () => { let armed = false; const t = await setup($doc, { diff --git a/packages/lexical-y/src/__tests__/unit/regressions/FollowUps.test.ts b/packages/lexical-y/src/__tests__/unit/regressions/FollowUps.test.ts index d3bba9909ec..16ce35ec600 100644 --- a/packages/lexical-y/src/__tests__/unit/regressions/FollowUps.test.ts +++ b/packages/lexical-y/src/__tests__/unit/regressions/FollowUps.test.ts @@ -80,7 +80,7 @@ function client(doc: YDoc, attached: boolean) { // taken by the follow-up check for a change of the derived field: refused, // and its container (the whole root) read again. Not a divergence (the // re-read gives the same document); a needless whole-container re-read. -// Open, in Known open issues. +// Open, listed under Known open issues in #9400. describe('R16A-2: a naming follow-up on a paragraph whose derived textFormat differs from the stored one', () => { test.fails( 'R16A-2 a DOM-attached reader renames the paragraph: the follow-up is neither refused nor read again', diff --git a/packages/lexical-y/src/__tests__/unit/regressions/Formats.test.ts b/packages/lexical-y/src/__tests__/unit/regressions/Formats.test.ts index bbc6371f7ec..c2a94e1efa3 100644 --- a/packages/lexical-y/src/__tests__/unit/regressions/Formats.test.ts +++ b/packages/lexical-y/src/__tests__/unit/regressions/Formats.test.ts @@ -296,8 +296,8 @@ describe('U23A-2: two concurrent deletes next to a character nobody touched', () // redundant, so the typist's restore of the format after its text had no // end and ran on over the rest of the line and, in the flat layout, over // the following lines. The remover now keeps the closer (see -// Cursor#format): what follows the range keeps its formats. Still open (in -// Known limits): the part of the range after the typist's text gets the +// Cursor#format): what follows the range keeps its formats. Still open (y.md, +// Limits, Other races): the part of the range after the typist's text gets the // format back, from that restore, which the remover's write never saw. describe('U23A-1: a format removed while a peer types inside it', () => { const cases = [ diff --git a/packages/lexical-y/src/__tests__/unit/regressions/QuotesAndSlots.test.ts b/packages/lexical-y/src/__tests__/unit/regressions/QuotesAndSlots.test.ts index d34f1b12aec..0689c135dc5 100644 --- a/packages/lexical-y/src/__tests__/unit/regressions/QuotesAndSlots.test.ts +++ b/packages/lexical-y/src/__tests__/unit/regressions/QuotesAndSlots.test.ts @@ -30,8 +30,8 @@ import {expectConverged, freshReader, setup} from '../../utils/harness'; // deletes the first quote: its write must delete only that quote's text. It // deleted and inserted the second quote's paragraph again, so a peer's // concurrent typing there was lost and its delete undone. Open for the -// runs that reach the second quote (in Known open issues; `@lexical/y`'s own -// writers do not leave such runs); the run inside the first quote is a +// runs that reach the second quote (listed under Known open issues in +// #9400; `@lexical/y`'s own writers do not leave such runs); the run inside the first quote is a // control. describe('S4-8: an outside link run over two quotes, then a client deletes the first quote', () => { const runs = [ diff --git a/packages/lexical-y/src/__tests__/unit/regressions/Undo.test.ts b/packages/lexical-y/src/__tests__/unit/regressions/Undo.test.ts index bcc5008d959..96b8696dee6 100644 --- a/packages/lexical-y/src/__tests__/unit/regressions/Undo.test.ts +++ b/packages/lexical-y/src/__tests__/unit/regressions/Undo.test.ts @@ -12,7 +12,7 @@ // selection from before the step, but a step that started from a node // selection (a selected image or chip) or a table selection comes back as a // caret. HistoryExtension does the same (its snapshot holds the selection of -// the last content change). Open, in Known open issues. +// the last content change). Open, listed under Known open issues in #9400. import { buildEditorFromExtensions, From d6de7bd5396aceb9f8f0524ba3e784d399f03735 Mon Sep 17 00:00:00 2001 From: Gerard Rovira Date: Fri, 30 Oct 2026 07:36:43 -0400 Subject: [PATCH 2/3] [lexical-y] Chore: Name the issues filed for three open expected failures ## Description Three expected failures pointed at the list of known open issues in #9400's description. Each now has an issue, which the comment names: S4-8 (#9571), ProdUndoNodeSelection (#9572), and R16A-2, whose cause is #9568. Comments only. ## Test plan ### Before ``` $ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/FollowUps.test.ts packages/lexical-y/src/__tests__/unit/regressions/QuotesAndSlots.test.ts packages/lexical-y/src/__tests__/unit/regressions/Undo.test.ts Test Files 3 passed (3) Tests 8 passed | 8 expected fail (16) ``` ### After ``` $ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/FollowUps.test.ts packages/lexical-y/src/__tests__/unit/regressions/QuotesAndSlots.test.ts packages/lexical-y/src/__tests__/unit/regressions/Undo.test.ts Test Files 3 passed (3) Tests 8 passed | 8 expected fail (16) ``` --- .../src/__tests__/unit/regressions/FollowUps.test.ts | 2 +- .../src/__tests__/unit/regressions/QuotesAndSlots.test.ts | 5 ++--- .../lexical-y/src/__tests__/unit/regressions/Undo.test.ts | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/lexical-y/src/__tests__/unit/regressions/FollowUps.test.ts b/packages/lexical-y/src/__tests__/unit/regressions/FollowUps.test.ts index 16ce35ec600..0fe82cad860 100644 --- a/packages/lexical-y/src/__tests__/unit/regressions/FollowUps.test.ts +++ b/packages/lexical-y/src/__tests__/unit/regressions/FollowUps.test.ts @@ -80,7 +80,7 @@ function client(doc: YDoc, attached: boolean) { // taken by the follow-up check for a change of the derived field: refused, // and its container (the whole root) read again. Not a divergence (the // re-read gives the same document); a needless whole-container re-read. -// Open, listed under Known open issues in #9400. +// Open (#9568, and listed under Known open issues in #9400). describe('R16A-2: a naming follow-up on a paragraph whose derived textFormat differs from the stored one', () => { test.fails( 'R16A-2 a DOM-attached reader renames the paragraph: the follow-up is neither refused nor read again', diff --git a/packages/lexical-y/src/__tests__/unit/regressions/QuotesAndSlots.test.ts b/packages/lexical-y/src/__tests__/unit/regressions/QuotesAndSlots.test.ts index 0689c135dc5..531eb9885b0 100644 --- a/packages/lexical-y/src/__tests__/unit/regressions/QuotesAndSlots.test.ts +++ b/packages/lexical-y/src/__tests__/unit/regressions/QuotesAndSlots.test.ts @@ -30,9 +30,8 @@ import {expectConverged, freshReader, setup} from '../../utils/harness'; // deletes the first quote: its write must delete only that quote's text. It // deleted and inserted the second quote's paragraph again, so a peer's // concurrent typing there was lost and its delete undone. Open for the -// runs that reach the second quote (listed under Known open issues in -// #9400; `@lexical/y`'s own writers do not leave such runs); the run inside the first quote is a -// control. +// runs that reach the second quote (#9571; `@lexical/y`'s own writers do +// not leave such runs); the run inside the first quote is a control. describe('S4-8: an outside link run over two quotes, then a client deletes the first quote', () => { const runs = [ 'title to second quote end', diff --git a/packages/lexical-y/src/__tests__/unit/regressions/Undo.test.ts b/packages/lexical-y/src/__tests__/unit/regressions/Undo.test.ts index 96b8696dee6..b45a997a92e 100644 --- a/packages/lexical-y/src/__tests__/unit/regressions/Undo.test.ts +++ b/packages/lexical-y/src/__tests__/unit/regressions/Undo.test.ts @@ -12,7 +12,7 @@ // selection from before the step, but a step that started from a node // selection (a selected image or chip) or a table selection comes back as a // caret. HistoryExtension does the same (its snapshot holds the selection of -// the last content change). Open, listed under Known open issues in #9400. +// the last content change). Open (#9572). import { buildEditorFromExtensions, From 1c1aab01ddf56c7da4840ae553834e12dc7a7a99 Mon Sep 17 00:00:00 2001 From: Gerard Rovira Date: Fri, 30 Oct 2026 13:33:19 -0400 Subject: [PATCH 3/3] [lexical-y] Chore: Pin the recovery path in the schema gate's pending-commits case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description The schema gate case "local updates still pending past the commit limit: the read does not run, and the recovery gates" checked only that the binding ends read only with the unsupported-schema warning. A fail-closed path ends there too, so the case could pass without the recovery read it claims to cover (with 48 pending updates instead of 40 it already does, on the unknown-node-types path). It now asserts the exact warnings of the recovery: the recovered read, then the unsupported schema. Reported in review. ## Test plan ``` $ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/SchemaVersion.test.ts Tests 25 passed (25) ``` With 48 pending updates instead of 40 (the fail-closed path) the case now fails: ``` AssertionError: expected [ …(2) ] to deeply equal [ '@lexical/y/recovered-read', …(1) ] ``` (the warnings there are `@lexical/y/unknown-node-types` and `@lexical/y/unsupported-schema`). `pnpm run ci-check` passes. --- .../lexical-y/src/__tests__/unit/SchemaVersion.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/lexical-y/src/__tests__/unit/SchemaVersion.test.ts b/packages/lexical-y/src/__tests__/unit/SchemaVersion.test.ts index ddee91e4fc8..9f086e7aac6 100644 --- a/packages/lexical-y/src/__tests__/unit/SchemaVersion.test.ts +++ b/packages/lexical-y/src/__tests__/unit/SchemaVersion.test.ts @@ -472,7 +472,12 @@ describe('schema version gate: timing', () => { await Promise.resolve(); expect(caught.length).toBeGreaterThan(0); expect(older.binding.status).toBe('readonly'); - expect(codes(older)).toContain(UNSUPPORTED_SCHEMA_WARNING); + // The recovery read gated, not a fail-closed path (which warns about + // what it cannot read instead of a recovered read). + expect(codes(older)).toEqual([ + RECOVERED_READ_WARNING, + UNSUPPORTED_SCHEMA_WARNING, + ]); const before = Y.encodeStateVector(older.doc); older.editor.update(() => void $getRoot().append($p('late')), { discrete: true,