Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
3c1859d
[lexical-y] Bug Fix: Keep a link a collaborator removes removed when …
zurfyx Oct 30, 2026
52ec612
[lexical-y] Bug Fix: Keep a removed link removed also when its text s…
zurfyx Oct 30, 2026
4072e4f
[lexical][lexical-website][lexical-y] Bug Fix: Give a link whose text…
zurfyx Oct 30, 2026
ecf1f41
[lexical][lexical-y] Bug Fix: Keep serializing inline elements nested…
zurfyx Oct 30, 2026
37ed6f4
[lexical] Bug Fix: Keep serializing nested inline elements linear ins…
zurfyx Oct 30, 2026
432157a
[lexical][lexical-y] Bug Fix: Cache whether an element holds text onl…
zurfyx Oct 31, 2026
368deb0
[lexical][lexical-clipboard] Bug Fix: Keep a copy linear after a chan…
zurfyx Oct 31, 2026
bf0e800
[lexical] Chore: Declare INTERNAL_$withExportMemo in Lexical's Flow t…
zurfyx Oct 31, 2026
4457a2f
[lexical-y] Bug Fix: Show a wrapper's stored text fields once a colla…
zurfyx Oct 31, 2026
7c1b49f
[lexical-y] Bug Fix: Show a wrapper's stored text fields after a clie…
zurfyx Oct 31, 2026
703e7cb
[lexical-y] Bug Fix: Settle a wrapper's text fields inside the client…
zurfyx Oct 31, 2026
2b9d341
[lexical-y] Chore: Merge main and make #9625's #9597 cells plain tests
zurfyx Oct 31, 2026
6a80b33
[lexical-y] Bug Fix: Keep text fields an update sets on a wrapper it …
zurfyx Oct 31, 2026
4861932
[lexical][lexical-y] Chore: Name an app's own inline element in the c…
zurfyx Oct 31, 2026
8dcf4b3
[lexical-y] Chore: Merge main (f59f74d7)
zurfyx Oct 31, 2026
0e66653
[lexical-y] Chore: Pin the overlapping-bold races that a link no long…
zurfyx Oct 31, 2026
15bb1d0
[lexical-website][lexical-y] Documentation Update: Say what a link's …
zurfyx Oct 31, 2026
b415918
[lexical-y] Bug Fix: Show the stored text fields on a link whose own …
zurfyx Oct 31, 2026
fd54ca3
[lexical-y] Bug Fix: Keep a link's stored text fields when it gets te…
zurfyx Oct 31, 2026
524365a
[lexical-y] Chore: Merge main (c0c7e426)
zurfyx Oct 31, 2026
f12eb51
[lexical-clipboard][lexical-y] Chore: Name @lexical/clipboard in the …
zurfyx Oct 31, 2026
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
10 changes: 10 additions & 0 deletions .changeset/lexical-inline-element-text-fields.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'lexical': patch
'@lexical/clipboard': patch
---

[fix] Stop serializing the textFormat and textStyle of an inline element whose text sits in another element inside it

An element persists its textFormat and textStyle only when it has no text to derive them from (#7968), and a reconcile derives them from its first text at any depth. The JSON of an inline element (a link, an autolink, a mark) now follows the same rule: a link whose text sits in a mark no longer writes them, as a link with text of its own already did not. JSON that holds them still reads the same, and a block keeps the direct-children check, so the JSON of a paragraph whose text is all inside links, and of every other node, is unchanged.

Verify: JSON snapshots of an inline element (a `link`, `autolink` or `mark`, or your own inline `ElementNode`) whose children are all elements no longer hold `textFormat` or `textStyle`.
9 changes: 9 additions & 0 deletions .changeset/lexical-y-link-textformat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@lexical/y': patch
---

[fix] Keep a link a collaborator removes removed when another collaborator formats text covering it at the same time

A format change over a link also sets the link's textFormat (`RangeSelection#formatText`), and the binding stored a link's textFormat and textStyle in the value its text carries: the format change wrote the link's whole value again over its text, and that write brought a concurrently removed link back for everyone. A wrapper's textFormat and textStyle are now stored only when Lexical serializes them (`shouldSerializeTextStyles`): while it holds no text, of its own or inside another element in it (a mark in a link). With text, they follow it, and the author's JSON and a fresh reader's agree.

Verify: with two clients on a paragraph that holds a link, remove the link on one while the other makes a range covering the whole link code, sync, and check that the link stays removed on both.
11 changes: 7 additions & 4 deletions packages/lexical-clipboard/src/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import {
getDOMSelection,
getDOMSelectionPoints,
getEditorPropertyFromDOMNode,
INTERNAL_$withExportMemo,
isHTMLElement,
isLexicalEditor,
isSelectionWithinEditor,
Expand Down Expand Up @@ -703,10 +704,12 @@ export function $generateJSONFromSelectedNodes<
const topLevelChildren = (
$isElementNode(slotFrame) ? slotFrame : root
).getChildren();
for (let i = 0; i < topLevelChildren.length; i++) {
const topLevelNode = topLevelChildren[i];
$appendNodesToJSON(editor, selection, topLevelNode, nodes);
}
INTERNAL_$withExportMemo(() => {
for (let i = 0; i < topLevelChildren.length; i++) {
const topLevelNode = topLevelChildren[i];
$appendNodesToJSON(editor, selection, topLevelNode, nodes);
}
});
return {
namespace: editor._config.namespace,
nodes,
Expand Down
23 changes: 11 additions & 12 deletions packages/lexical-website/docs/collaboration/y.md
Original file line number Diff line number Diff line change
Expand Up @@ -1205,9 +1205,11 @@ not merge as a user might expect:
whether you saw it before you undo or it arrives as you undo: bold
`one two`, let a collaborator type plain ` mid` after `one`, then undo
the bold, and ` two` stays bold. Undoing a link
while a collaborator formats text inside it keeps the link on its text:
formatting text in a link also sets the link's text format, which writes
the link there again. Where a collaborator overwrote a step of yours
while a collaborator formats text inside it removes the link on every
client: a format writes no link while the link holds text. A link with no
text (only an inline decorator or a line break) still stores its text
format, and stays on its content:
[#9627](https://github.com/meta/lexical/issues/9627). Where a collaborator overwrote a step of yours
entirely, the undo manager skips that step as changing nothing, and the
same undo also reverts your step before it. When you undo a format change
while a collaborator removes that format and undoes the removal, the text
Expand Down Expand Up @@ -1286,7 +1288,12 @@ not merge as a user might expect:
after a Backspace it takes the type of the line before (a paragraph after
a heading becomes a heading), after undoing a split it keeps its own.
Bold ‖ bold on adjacent ranges of one line
can leave part unformatted. A format removed from a range while a
can leave part unformatted, and so can bold ‖ bold on overlapping ranges
([#9626](https://github.com/meta/lexical/issues/9626)). A link behaves
like a comment there: a range that crosses a link's edge can lose part
of its bold for either client id order, and a bold reaching into a
link's first word can lose it there when a collaborator one update
behind types inside the old bold range. A format removed from a range while a
collaborator types inside it without that format (unbolding a word as
they type into it, resolving a comment as they paste into it) comes back
on the part of the range after their text: their write restores the
Expand Down Expand Up @@ -1327,9 +1334,6 @@ reproduces it:
- An update listener that throws stops the listeners registered after it,
so when it was registered before the binding, that commit is never
written: [#9514](https://github.com/meta/lexical/issues/9514).
- Undoing a link while a collaborator formats text over the link's start
keeps the link on its own text (see Undo above):
[#9521](https://github.com/meta/lexical/issues/9521).
- A line without a marker joins the start of a paragraph a collaborator
appends at the same time:
[#9524](https://github.com/meta/lexical/issues/9524).
Expand All @@ -1339,11 +1343,6 @@ reproduces it:
- A comment can spread onto later words when a client types uncommented
text inside it offline while a collaborator's deletes there drop the item
that ended it: [#9564](https://github.com/meta/lexical/issues/9564).
- A link a collaborator removes comes back when another collaborator
formats a range covering it at the same time; an update that makes a
line's text nodes again without the link while a stale edit formats a
range reaching past the link's text shows the same:
[#9597](https://github.com/meta/lexical/issues/9597).
- Turning a paragraph inside a container that is itself inside a
container (both not shadow roots) into a list removes the outer
container and makes no list, in every `@lexical/y` editor (it runs
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -250,8 +250,9 @@ off the common path, so an element with nothing to persist never calls it. The
predicate must be pure and take no arguments: the walk calls it once per
property that names it, while generated code hoists one that several properties
share and calls it once. This is how `ElementNode` persists `textFormat` and
`textStyle` only for an element with no `TextNode` child, without either
property leaving the direct-field path.
`textStyle` only for an element with no text to derive them from (no
`TextNode` child, and for an inline element such as a link no `TextNode`
anywhere inside it), without either property leaving the direct-field path.

`withField(schema, {field, when})` declares the same for a property that is the
field in both directions. Either way the gate belongs to the export direction,
Expand Down
5 changes: 5 additions & 0 deletions packages/lexical-y/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ issues and docs that carry it.
| D30 | **Diffed rewrites snap to whole words**; keystrokes and IME composition keep the exact diff. Scripts without spaces treat each character as a boundary. **A diffed insertion or deletion that no caret places (none, or one at the end of the node, past the edit or in a later node) and that the diff would put inside a word goes to the first place in the run of equal text it could be in where both its ends are at word edges** (a word deleted with the separator before it); a caret's placement, and one already at word edges, stay. | Concurrent rewrites otherwise split words. Real word segmentation is a follow-up ([#9467]). Without a caret a diff takes the common start first and put deletions inside words, so a collaborator's concurrent delete of the same word took a letter of the next one ([#9636]); the first edge keeps a word typed after a deleted word apart from the next one, as typing goes (a space, then a word). | `DECIDED` | 10-30 | [#9400], [#9467], [#9636] |
| D31 | **A list item's nested list gets an item of its own.** `NodeStructureExtension`'s list rule prevents an item holding a nested list next to other content when it is written; the binding does not repair it afterwards. | That shape lost text in outdents, Backspaces and offline merges. Preventing it at write time follows D20. | `DECIDED` | 10-26 | [#9452], [y.md: Limits][limits] |
| D32 | **List merging ignores `start`.** | Existing Lexical behaviour, intended. | `DECIDED` | 10-23 | [#9452] |
| D82 | **An inline element's textFormat and textStyle are persisted only while it holds no text at any depth, and the binding stores a wrapper's exactly when Lexical serializes them.** Lexical's `shouldSerializeTextStyles` looks through the elements inside an inline element (a mark in a link), as a reconcile already derives the two from its first text there; a block keeps the direct-children check. Exported JSON changes only for a link, autolink or mark whose children are all elements: those two fields are no longer written (they are still read, into the same state). | Stored while the wrapper holds text, a format change over a link writes the link's value again, and a collaborator's concurrent removal of the link is lost ([#9597]); left unstored while Lexical serializes them, the author's JSON differs from a fresh reader's. One rule, owned by core, keeps both, and the JSON of blocks and of every other node is unchanged (D01). A link with no text at all (only an inline decorator or a line break) still stores them and still comes back ([#9627]). While a wrapper holds text, its value without text fields does not remove ones stored there, so a link that gets text keeps a concurrent removal or new URL. A link whose stored value already holds them (written by an earlier build after a format over it) can come back once, until its next write. Accepted with it: a link behaves like a mark in overlapping same-format races ([#9626]), which now reach a link's edge for either client id order and a link's first word; formatting only, no text lost. | `DECIDED` | 10-30 | [#9601] |
| D70 | **No virtual structure.** The editor does not show repairs that are not stored (padded cells for a short table row, two stored lists drawn as one). The one existing case stays: text left without a line marker shows as a paragraph. | It adds another structure on top of Yjs and the Lexical tree, and the complexity that comes with it. | `DECIDED` | 10-25 | [#9400] |
| D72 | **The binding starts at one fixed point, with no further deferral or special-casing.** It starts in `YCollabExtension`'s `afterRegistration`, once every extension has registered and the initial state is applied. An initialization-order bug (a table that exists before its DOM) is fixed in the extension that has it, as [#9501] did. | Deferring the first read further changes when the binding's state exists and still misses code that registers later (legacy table observers, React plugins). | `DECIDED` | 10-28 | [#9501], [#9537] |
| D73 | **`@lexical/y` first publishes with its whole launch stack** (the binding, the server API, the playground and per-member sets), with real moves after (D27). The launch stack was chosen on 10-23; moves were moved after it on 10-25. | The first published format should already include what the launch needs, such as per-member sets. | `DECIDED` | 10-25 | [#9400], [#9437], [#9438], [#9439] |
Expand Down Expand Up @@ -194,13 +195,17 @@ issues and docs that carry it.
[#9593]: https://github.com/meta/lexical/issues/9593
[#9598]: https://github.com/meta/lexical/pull/9598
[#9599]: https://github.com/meta/lexical/issues/9599
[#9601]: https://github.com/meta/lexical/pull/9601
[#9603]: https://github.com/meta/lexical/issues/9603
[#9605]: https://github.com/meta/lexical/pull/9605
[#9607]: https://github.com/meta/lexical/pull/9607
[#9608]: https://github.com/meta/lexical/issues/9608
[#9609]: https://github.com/meta/lexical/issues/9609
[#9610]: https://github.com/meta/lexical/issues/9610
[#9621]: https://github.com/meta/lexical/issues/9621
[#9626]: https://github.com/meta/lexical/issues/9626
[#9627]: https://github.com/meta/lexical/issues/9627
[#9635]: https://github.com/meta/lexical/issues/9635
[#9636]: https://github.com/meta/lexical/issues/9636
[#9597]: https://github.com/meta/lexical/issues/9597
[#9606]: https://github.com/meta/lexical/issues/9606
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/**
* 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

// A removes a link while B, at the same time, formats a range covering the
// whole link (code). B's format sets the link's textFormat too
// (RangeSelection#formatText); with the link's textFormat stored in its
// value, B wrote the link's value again over its text, and that write
// brought the removed link back for everyone. The link stays removed.

import {buildEditorFromExtensions} from '@lexical/extension';
import {
$createLinkNode,
$isLinkNode,
$toggleLink,
LinkExtension,
} from '@lexical/link';
import {RichTextExtension} from '@lexical/rich-text';
import * as Y from '@y/y';
import {
$createParagraphNode,
$createRangeSelection,
$createTextNode,
$getRoot,
$isElementNode,
$setSelection,
configExtension,
defineExtension,
type LexicalEditor,
type TextNode,
} from 'lexical';
import {expect, test} from 'vitest';

import {YCollabExtension} from '../..';
import {createYDoc, seedYState} from '../../headless';

const extensions = [RichTextExtension, LinkExtension];
const config = {extensions, onWarn: () => {}, schemaVersion: 1};

// "t28z " + link(https://c.test/100: "t29z t30z") + " t31z"
function $build() {
$getRoot()
.clear()
.append(
$createParagraphNode().append(
$createTextNode('t28z '),
$createLinkNode('https://c.test/100').append(
$createTextNode('t29z t30z'),
),
$createTextNode(' t31z'),
),
);
}

function client(seed: Uint8Array, id: number) {
const doc = createYDoc(seed);
doc.clientID = id;
const editor = buildEditorFromExtensions(
defineExtension({
$initialEditorState: null,
dependencies: [
...extensions,
configExtension(YCollabExtension, {doc, schemaVersion: 1}),
],
name: 'client' + id,
}),
);
return {doc, editor};
}

function $select(from: string, to: string) {
const texts = $getRoot().getAllTextNodes();
const find = (word: string, end: boolean): [TextNode, number] => {
for (const t of texts) {
const i = t.getTextContent().indexOf(word);
if (i >= 0) return [t, end ? i + word.length : i];
}
throw new Error(word);
};
const [a, ao] = find(from, false);
const [b, bo] = find(to, true);
const s = $createRangeSelection();
s.anchor.set(a.getKey(), ao, 'text');
s.focus.set(b.getKey(), bo, 'text');
$setSelection(s);
return s;
}

const links = (editor: LexicalEditor) =>
editor.getEditorState().read(() =>
$getRoot()
.getChildren()
.flatMap(p => ($isElementNode(p) ? p.getChildren() : []))
.filter($isLinkNode)
.map(l => l.getTextContent()),
);

for (const ids of [
[1, 2],
[2, 1],
]) {
test(`a removed link stays removed when a collaborator formats over it at the same time (ids ${ids})`, async () => {
const seed = seedYState(config, $build);
const a = client(seed, ids[0]);
const b = client(seed, ids[1]);
await Promise.resolve();
// A removes the link; B, at the same time, makes "t28z ... t30z" code
a.editor.update(
() => {
$select('t29z', 't30z');
$toggleLink(null);
},
{discrete: true},
);
b.editor.update(
() => {
$select('t28z', 't30z').formatText('code');
},
{discrete: true},
);
const ua = Y.encodeStateAsUpdate(a.doc);
const ub = Y.encodeStateAsUpdate(b.doc);
Y.applyUpdate(a.doc, ub);
Y.applyUpdate(b.doc, ua);
await Promise.resolve();
expect(links(a.editor)).toEqual([]);
expect(links(b.editor)).toEqual([]);
});
}
Loading
Loading