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..9f086e7aac6 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,45 @@ 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'); + // 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, + }); + 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 +607,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..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, in Known open issues. +// 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/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..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 (in Known open issues; `@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 bcc5008d959..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, in Known open issues. +// the last content change). Open (#9572). import { buildEditorFromExtensions,