Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
120 changes: 120 additions & 0 deletions packages/lexical-y/src/__tests__/unit/SchemaVersion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {afterEach, describe, expect, test, vi} from 'vitest';

import {
createYDoc,
RECOVERED_READ_WARNING,
UNSUPPORTED_EDIT_WARNING,
UNSUPPORTED_SCHEMA_WARNING,
YBinding,
Expand All @@ -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:';
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: the test just above ("a recovery that first writes many pending local updates still gates", lines 408-447) uses 20 listener updates, which its read commits in full before gating in before. It never reaches the recovery its name and its comment at lines 417-418 describe, and it keeps passing without the gate at Binding.ts:2530; with this scenario the read only gives up from 32 updates. Renaming it (for example "a read that first commits many pending local updates still gates") and fixing that comment would match what this comment says.

// 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');
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI: this test reaches schemaGatesWrite's reset at Binding.ts:1754, but it (and the rest of the file) still passes with that line removed, because the gateSchema() call that follows at Binding.ts:3236 clears schemaGateAfter too (line 1721). It guards the outcome, the next update refused, rather than the reset itself.

// 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', () => {
Expand Down
7 changes: 4 additions & 3 deletions packages/lexical-y/src/__tests__/unit/TableOnOpen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
4 changes: 2 additions & 2 deletions packages/lexical-y/src/__tests__/unit/UpdateErrors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading