Skip to content
7 changes: 7 additions & 0 deletions .changeset/y-shadow-root-link-over-blocks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@lexical/y': patch
---

[fix] A link run another writer left open over a shadow-root quote's paragraph no longer makes a delete of the quote before it rewrite that paragraph

Verify: with a plain Yjs client, format the root sequence with a link from just before one shadow-root quote's paragraph to past the next quote's paragraph; delete the first quote while a collaborator types in the second: the collaborator's text stays, and every client shows the quotes' paragraphs as paragraphs.
2 changes: 2 additions & 0 deletions packages/lexical-y/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ issues and docs that carry it.
| D76 | **A code-token-aware trim for remade text.** A stale replace of a word can take the first letter of the word a rewrite made from it, where a node boundary cut the old word. | Writing a changed word whole fixes it, but also rewrites highlighter tokens glued to a pasted-over token in a code line. A follow-up would apply the rule everywhere except at code highlight token boundaries (D29). | `OPEN` | 10-30 | [#9608] |
| D77 | **A stale comment on a word a collaborator rewrites** lands on the new word for one client id order only. | Likely the documented race where text typed at the start of a range a collaborator formats takes that format, with a comment instead of a format. Either the docs name comments and whole-word replacements, or a fix makes both orders agree. | `OPEN` | 10-30 | [#9610], [limits] |
| D78 | **A format spreading after one client's triple undo:** a client formats a run, types plain text, formats again and undoes all three while a collaborator types plain text inside the first run. | Reproduced on main in both client id orders; no design chosen. | `OPEN` | 10-30 | [#9599] |
| D89 | **Inline elements around blocks, in a shadow root, read as the blocks out of them (proposed).** Another writer's link run left open over a shadow-root quote's paragraph read as `link(paragraph)` inside a paragraph of its own, a shape no editor keeps; now the paragraph shows on its own, at any depth of such elements, and inline content they also hold stays in them, in a paragraph of its own. The write self-check does not count a stored wrapper over a block in a shadow root (only there). | A delete of the quote before it rewrote the next quote's paragraph (deleted and inserted it again), losing a collaborator's concurrent typing there ([#9571]). It extends how a shadow root already shows its content (inline runs in paragraphs of their own, `$inShadowRoot`), so it is a second case of D70's "text left without a line marker shows as a paragraph": the read writes nothing, and the stored link stays until a local edit writes that quote's content again: Enter in its paragraph or a paragraph appended to it stores a null of the link before what it writes, which closes the run there (nothing is lost, and every reader shows the same). `@lexical/y`'s own writers leave no such runs. For Gerard against D70. | `OPEN` | 10-30 | [#9571] |

[root-agents]: ../../AGENTS.md
[limits]: ../lexical-website/docs/collaboration/y.md#limits
Expand Down Expand Up @@ -204,3 +205,4 @@ issues and docs that carry it.
[#9635]: https://github.com/meta/lexical/issues/9635
[#9636]: https://github.com/meta/lexical/issues/9636
[#9606]: https://github.com/meta/lexical/issues/9606
[#9571]: https://github.com/meta/lexical/issues/9571
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,13 @@ import {expectConverged, freshReader, setup} from '../../utils/harness';
// paragraph and closes after the next quote's paragraph. A client then
// 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 (#9571; `@lexical/y`'s own writers do
// not leave such runs); the run inside the first quote is a control.
// concurrent typing there was lost and its delete undone (#9571): the link
// run was read around the quotes' paragraphs (link(paragraph) in a
// paragraph of its own), a shape the editor could not keep. A shadow root
// holds blocks only, so an inline element around blocks only is read as
// those blocks (see $inShadowRoot), and the quotes show their paragraphs
// without the link. (`@lexical/y`'s own writers do not leave such runs; the
// run inside the first quote, and the title's link, are controls.)
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 All @@ -42,105 +46,109 @@ describe('S4-8: an outside link run over two quotes, then a client deletes the f
for (const run of runs) {
for (const peerEdit of peerEdits) {
const open = run !== 'first quote paragraph only';
(open ? test.fails : test)(
`S4-8 run: ${run}; peer: ${peerEdit}: only the first quote's text is deleted`,
async () => {
const t = await setup(() => {
$getRoot().append(
$createParagraphNode().append($createTextNode('Title')),
$createQuoteNode({shadowRoot: true}).append(
$createParagraphNode().append($createTextNode('q1 one')),
),
$createQuoteNode({shadowRoot: true}).append(
$createParagraphNode().append($createTextNode('q2 two')),
),
$createParagraphNode().append($createTextNode('end')),
);
});
const outside = new Y.Doc();
outside.cleanupFormatting = false;
Y.applyUpdate(outside, Y.encodeStateAsUpdate(t.a.doc));
const sv = Y.encodeStateVector(outside);
const root = outside.get('lexical-y');
// The indices of the quotes' paragraph items in the root sequence.
const paragraphs: number[] = [];
let index = 0;
for (let item = root._start; item !== null; item = item.right) {
if (item.deleted || !item.countable) {
continue;
}
if (
item.content instanceof Y.ContentType &&
String(item.content.type.name).startsWith('i:')
) {
paragraphs.push(index);
}
index += item.length;
}
const from = run === 'title to second quote end' ? 2 : paragraphs[0];
const to = (open ? paragraphs[1] : paragraphs[0]) + 1;
outside.transact(() =>
root.format(from, to - from, {
'w:link': {url: 'https://o.example'},
}),
test(`S4-8 run: ${run}; peer: ${peerEdit}: only the first quote's text is deleted`, async () => {
const t = await setup(() => {
$getRoot().append(
$createParagraphNode().append($createTextNode('Title')),
$createQuoteNode({shadowRoot: true}).append(
$createParagraphNode().append($createTextNode('q1 one')),
),
$createQuoteNode({shadowRoot: true}).append(
$createParagraphNode().append($createTextNode('q2 two')),
),
$createParagraphNode().append($createTextNode('end')),
);
const update = Y.encodeStateAsUpdate(outside, sv);
Y.applyUpdate(t.a.doc, update, 'net');
Y.applyUpdate(t.b.doc, update, 'net');
await t.sync();
});
const outside = new Y.Doc();
outside.cleanupFormatting = false;
Y.applyUpdate(outside, Y.encodeStateAsUpdate(t.a.doc));
const sv = Y.encodeStateVector(outside);
const root = outside.get('lexical-y');
// The indices of the quotes' paragraph items in the root sequence.
const paragraphs: number[] = [];
let index = 0;
for (let item = root._start; item !== null; item = item.right) {
if (item.deleted || !item.countable) {
continue;
}
if (
item.content instanceof Y.ContentType &&
String(item.content.type.name).startsWith('i:')
) {
paragraphs.push(index);
}
index += item.length;
}
const from = run === 'title to second quote end' ? 2 : paragraphs[0];
const to = (open ? paragraphs[1] : paragraphs[0]) + 1;
outside.transact(() =>
root.format(from, to - from, {
'w:link': {url: 'https://o.example'},
}),
);
const update = Y.encodeStateAsUpdate(outside, sv);
Y.applyUpdate(t.a.doc, update, 'net');
Y.applyUpdate(t.b.doc, update, 'net');
await t.sync();
// Every reader shows the quotes' paragraphs as paragraphs.
for (const p of [t.a, t.b]) {
expect(p.describe().split(' / ').slice(1, 3)).toEqual([
'quote(paragraph("q1 one"))',
'quote(paragraph("q2 two"))',
]);
}

const wrote = {del: '', ins: ''};
t.b.doc.on('afterTransaction', (tr: Y.Transaction) => {
if (!tr.local) {
return;
const wrote = {del: '', ins: ''};
t.b.doc.on('afterTransaction', (tr: Y.Transaction) => {
if (!tr.local) {
return;
}
Y.iterateStructsByIdSet(tr, tr.insertSet, s => {
if (s instanceof Y.Item && s.content instanceof Y.ContentString) {
wrote.ins += s.content.str;
}
});
Y.iterateStructsByIdSet(tr, tr.deleteSet, s => {
if (s instanceof Y.Item && s.content instanceof Y.ContentString) {
wrote.del += s.content.str;
}
Y.iterateStructsByIdSet(tr, tr.insertSet, s => {
if (s instanceof Y.Item && s.content instanceof Y.ContentString) {
wrote.ins += s.content.str;
}
});
Y.iterateStructsByIdSet(tr, tr.deleteSet, s => {
if (s instanceof Y.Item && s.content instanceof Y.ContentString) {
wrote.del += s.content.str;
}
});
});
t.offline();
t.b.update(() => {
$getRoot().getChildAtIndex(1)!.remove();
});
t.offline();
t.b.update(() => {
$getRoot().getChildAtIndex(1)!.remove();
$setSelection(null);
});
const $q2 = () =>
(
$getRoot().getChildAtIndex(2) as ElementNode
).getFirstChild() as ElementNode;
if (peerEdit === 'empties q2') {
t.a.update(() => {
$q2().clear();
$setSelection(null);
});
const $q2 = () =>
(
$getRoot().getChildAtIndex(2) as ElementNode
).getFirstChild() as ElementNode;
if (peerEdit === 'empties q2') {
t.a.update(() => {
$q2().clear();
$setSelection(null);
});
} else if (peerEdit === 'types in q2') {
t.a.update(() => {
$q2().selectEnd().insertText(' X');
});
}
await t.sync();
expect(wrote).toEqual({del: 'q1 one', ins: ''});
const expected =
peerEdit === 'empties q2'
? ''
: peerEdit === 'types in q2'
? 'q2 two X'
: 'q2 two';
const fresh = await freshReader(t.a);
for (const p of [t.a, t.b, fresh]) {
expect(
p.read(() => $getRoot().getChildAtIndex(1)!.getTextContent()),
).toBe(expected);
}
await expectConverged(t.peers);
},
);
} else if (peerEdit === 'types in q2') {
t.a.update(() => {
$q2().selectEnd().insertText(' X');
});
}
await t.sync();
expect(wrote).toEqual({del: 'q1 one', ins: ''});
const expected =
peerEdit === 'empties q2'
? ''
: peerEdit === 'types in q2'
? 'q2 two X'
: 'q2 two';
const fresh = await freshReader(t.a);
for (const p of [t.a, t.b, fresh]) {
expect(
p.read(() => $getRoot().getChildAtIndex(1)!.getTextContent()),
).toBe(expected);
}
await expectConverged(t.peers);
});
}
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
// @vitest-environment node
// #9571: every catalog shape as a quote's block under an outside link run
// keeps its payload when another quote is deleted.
import {$createQuoteNode} from '@lexical/rich-text';
import * as Y from '@y/y';
import {$getRoot} from 'lexical';
import {expect, onTestFinished, test} from 'vitest';

import {CATALOG_NODES, NODE_SHAPES} from '../../catalog/nodes';
import {setup} from '../../utils/harness';
import {deep, equalFresh, p} from './Review9654Fixtures';

const mask = (value: unknown) =>
JSON.parse(JSON.stringify(value), (k, v) =>
k === 'editorState' ? 'nested-editor' : v,
);
for (const shape of NODE_SHAPES)
for (const gc of [false, true])
for (const ids of [
[1, 2],
[2, 1],
]) {
test(`catalog ${shape.name} gc=${gc} ids=${ids}`, async () => {
const t = await setup(
() =>
$getRoot().append(
p('Title'),
$createQuoteNode({shadowRoot: true}).append(p('q1 one')),
$createQuoteNode({shadowRoot: true}).append(shape.$block()),
p('end'),
),
{
ids,
nodes: [...CATALOG_NODES],
withBlockIds: shape.tags.includes('nested-editor'),
},
);
onTestFinished(() =>
t.peers.forEach(q => {
q.stop();
q.doc.destroy();
}),
);
t.peers.forEach(q => (q.doc.gc = gc));
const before = mask(
t.a.read(() => deep($getRoot().getChildAtIndex(2)!)),
);
const d = new Y.Doc({gc});
d.cleanupFormatting = false;
onTestFinished(() => d.destroy());
Y.applyUpdate(d, Y.encodeStateAsUpdate(t.a.doc));
const sv = Y.encodeStateVector(d);
const root = d.get('lexical-y');
const atoms: number[] = [];
let at = 0;
for (let it = root._start; it; it = it.right) {
if (it.deleted || !it.countable) continue;
if (
it.content instanceof Y.ContentType &&
String(it.content.type.name).startsWith('i:')
)
atoms.push(at);
at += it.length;
}
expect(atoms.length).toBeGreaterThanOrEqual(2);
d.transact(() =>
root.format(atoms[0], atoms[atoms.length - 1] + 1 - atoms[0], {
'w:link': {url: 'https://outside.example'},
}),
);
const update = Y.encodeStateAsUpdate(d, sv);
for (const q of t.peers) Y.applyUpdate(q.doc, update, 'net');
await t.sync();
for (const q of t.peers)
expect(
mask(q.read(() => deep($getRoot().getChildAtIndex(2)!))),
).toEqual(before);
// Removing only the other quote cannot change any payload in this one.
t.b.update(() => $getRoot().getChildAtIndex(1)!.remove());
await t.sync();
for (const q of t.peers) {
expect(
mask(q.read(() => deep($getRoot().getChildAtIndex(1)!))),
).toEqual(before);
if (!shape.tags.includes('nested-editor')) equalFresh(q);
expect(
q.warnings.filter(
w => w.includes('self-check') && !w.includes('.editorState.'),
),
).toEqual([]);
}
});
}
Loading
Loading