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
57 changes: 51 additions & 6 deletions packages/lexical-website/docs/collaboration/y.md
Original file line number Diff line number Diff line change
Expand Up @@ -895,8 +895,8 @@ merge the `update` it returns into the live document (see
## Servers

A Yjs 14 (`@y/y`) server or relay can host the documents when it meets
the requirements below (format cleanup off, updates delivered in order, and
for yrs, its version and client ids). Clients of
the requirements below (format cleanup off, updates delivered in causal
order, and for yrs, its version and client ids). Clients of
`@lexical/yjs` write to other roots (`root`, `root-v2`), never to
`DEFAULT_ROOT_NAME` (`'lexical-y'`), so they cannot change a `@lexical/y`
document's content and the server needs no check to keep them out.
Expand Down Expand Up @@ -960,6 +960,20 @@ the document's client id when it is created:
doc.clientID = crypto.getRandomValues(new Uint32Array(1))[0] || 1;
```

Deliver updates in causal order: each update after every update it depends
on, from any client (as json-joy also requires). A single relay that every
client sends to and receives from, and that forwards every update to each
client in the one order it received them, whichever client sent it, does
this. A mesh (such as y-webrtc), or
several relays that forward updates on their own, needs causal buffering:
hold an update until every update it depends on has been applied, deletions
included (a state vector alone cannot tell: an update that only deletes
leaves it unchanged). Out of causal order, a
client can see a collaborator's format run on to the end of a line until the
missing update arrives, and an edit it makes in that moment makes the
spread permanent, on every client
([#9645](https://github.com/meta/lexical/issues/9645)).

Deliver each client's updates in order. With Yjs 14.0.0-rc.29, a client

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: this paragraph now follows the causal-order requirement above, which already includes each client's own order (#9645 quotes this sentence as the rule that was not enough). Opening it with something like "Causal order includes each client's own order: with Yjs 14.0.0-rc.29, ..." would keep it from reading as a second, separate rule.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed; folded into the next docs PR (agent-edit guidance), which touches the same page, so this one can land as approved.

that receives an update past a gap in another client's clocks (a relay that
reorders and batches updates) can show a character that a document loaded
Expand Down Expand Up @@ -1169,6 +1183,15 @@ not merge as a user might expect:
- **List type change ‖ new item.** Changing a list's type while a
collaborator adds an item to it (Enter in the list) splits the list: the
new item keeps the old type, in a list of its own between the two halves.
- **List numbering after refused follow-ups.** After four follow-ups in a
row that the binding does not write and reads again (such as
`BlockIdExtension`'s naming updates), the author's editor can show a list
number from before them (1 where its document reads 2) until that list
is refreshed: adding an item to it does, an edit elsewhere or retyping an
item's text does not. The binding stops
refreshing derived numbers on the fourth such re-read in a row, so that an
app listener that answers every refresh cannot loop forever. Other
clients, the stored document and a reload show the right number.
- **Blocks that arrive without an id.** With `BlockIdExtension`, a block
stored without an id (written by a client without the extension), or with
an id an earlier block also stores or shows (a copy of the JSON of a block
Expand Down Expand Up @@ -1235,7 +1258,12 @@ not merge as a user might expect:
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
same undo also reverts your step before it. An undo press can show
nothing: when its step only restores or removes format items no reader
shows (a format on text a collaborator then deleted), the press takes that
step alone, and several such steps in a row take several presses before
one shows a change, as with Yjs's `UndoManager` in any editor that uses it
as it is. When you undo a format change
while a collaborator removes that format and undoes the removal, the text
keeps the format: the collaborator's undo brings it back. A format a collaborator applies
starting right where a run of that format ends, or from inside the run
Expand Down Expand Up @@ -1308,8 +1336,13 @@ 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, and so can bold ‖ bold on overlapping ranges
([#9626](https://github.com/meta/lexical/issues/9626)). A link behaves
can leave part unformatted, and so can the same format (bold, the same
comment id, a link) applied by two collaborators to overlapping ranges at
once
([#9626](https://github.com/meta/lexical/issues/9626)): a Yjs text gives
each place one value per key
([yjs/yjs#382](https://github.com/yjs/yjs/issues/382)), so one writer's
end of its range can end the other's run there too. 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
Expand All @@ -1324,7 +1357,13 @@ not merge as a user might expect:
takes that format (also when the range comes from an older read, as an
agent's does), and text typed at the start or the end of the range can
take it, by the order Yjs gives the two inserts (at the start the same on
Yjs 13, and it keeps the format when that range is then deleted). A
Yjs 13, and it keeps the format when that range is then deleted). In the
same way, a comment on a word a collaborator rewrites at the same moment
can land on the new word: with `setTextContent` for one client id order,
and with a rewrite of the whole line (its text nodes replaced by a new
one, as an agent writes it) for one or both orders; the words of the range

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: "one or both orders" could say which. A block rewrite moves the comment for both client id orders when the commented range continues past the rewritten word (t23z..t24z), and for one order when it ends at that word (t23z, t22z..t23z), as CommentOnRewrittenWord.test.ts:84-85 says.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed; the next docs PR names them: both orders when the range continues past the rewritten word, one order when it ends there.

the rewrite kept keep the comment
([#9610](https://github.com/meta/lexical/issues/9610)). A
node whose `isInline()` changes is written again in its new form, so
text typed inside it at the same time lands next to it, in an inline
element of its old form. Where text was deleted, the format items it
Expand Down Expand Up @@ -1366,6 +1405,12 @@ reproduces it:
container and makes no list, in every `@lexical/y` editor (it runs
`NodeStructureExtension`):
[#9570](https://github.com/meta/lexical/issues/9570).
- Undoing plain text typed into a format run gives the run's format (or
comment, or style) to plain text a collaborator typed right next to it:
[#9681](https://github.com/meta/lexical/issues/9681).
- With Yjs garbage collection on, undoing a format removal while a
collaborator applies the same format again can take it off the next
character: [#9683](https://github.com/meta/lexical/issues/9683).

For `MarkStateExtension` comments, see the
[comments limitations](./comments.md#limitations).
Expand Down
19 changes: 16 additions & 3 deletions packages/lexical-y/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ issues and docs that carry it.
| D45 | **`editYState` requires a `writer`** (`createYWriter`, carrying an author) or `null`. A writer keeps one client id per document while each call continues from its last update; when that is not safe (an older state, a call inside a call) it takes a new id and warns. A caller never picks a client id. | Every server edit decides who writes it. A client id reused by two writers corrupts silently, and a new id per edit bloats the document. | `DECIDED` | 10-28 | [#9437], [y-server.md][y-server] |
| D71 | **Server-side edits stay callbacks** (`editYState`'s `$edit`), run on the state the edit was computed from, with guidance added on writing them. | The edit is ordinary Lexical code with every Lexical API available; guidance covers what the callback cannot enforce. | `DECIDED` | 10-25 | [#9437], [y.md: Edits made by code][y-code] |
| D46 | **Products own server policy; Lexical gives the pieces.** How an agent's edit applies (at once, as a suggestion, with an undo) and what a refused client does are the product's choice; the framework dictates no extra steps. Relay checks are defence in depth for authorized writers, not a permission boundary. | Products differ, and a server cannot judge a peer's intent from a Yjs update. | `DECIDED` | 10-29 | |
| D92 | **Providers deliver updates in causal order:** each update after every update it depends on, from any client, as json-joy also requires. A single relay that every client sends to and receives from, and that forwards every update to each client in the one order it received them, whichever client sent it, does this; a mesh (y-webrtc) or several relays need causal buffering that tracks each update's dependencies, deletions included (a state vector does not change for an update that only deletes). | Yjs holds back what it cannot place yet: out of causal order, a collaborator's format can run on to the end of a line until the missing update arrives, and an edit made in that moment makes the spread permanent ([#9645]). Ordering is the transport's job; the binding cannot write while applying remote updates. | `DECIDED` | 10-31 | [#9645], [y.md: Servers][y-servers] |

## Accepted limits

Expand All @@ -120,6 +121,10 @@ issues and docs that carry it.
| D57 | **Lone surrogates** reach other clients as U+FFFD. | Yjs encodes text as UTF-8; Yjs 13 does the same. Malformed text such as a broken emoji is the author's own input, not an editor problem, so there is no code follow-up. | `ACCEPTED LIMIT` | 10-30 | [limits] |
| D58 | **Inline elements with their own content** (`MarkNode`, `AutoLinkNode`) move text into them when they wrap it, so concurrent typing there stays outside. | Use NodeState marks (D37). | `ACCEPTED LIMIT` | 10-20 | [limits] |
| D79 | **The Yjs guard (D14) cannot tell every other Yjs apart:** `@y/y` exposes no version at run time, so the guard checks shape and the peer dependency enforces the exact version; a `@y/y` with the same exports, members and events passes (`14.0.0-rc.25` to `rc.28` do), as does one whose functions behave differently with the same shape, and a provider that bundles its own copy of Yjs and applies updates to the app's document after the binding started is not caught. | `@y/y` exposes no version at run time, and checking updates would cost on every one. The exact peer pin and the docs (install the named version once; `npm ls @y/y`) cover them. | `ACCEPTED LIMIT` | 10-30 | [#9607], [limits] |
| D81 | **An undo press can show nothing:** when the step it takes only restores or removes format items no reader shows (a format on text a collaborator then deleted), the press takes that step alone; several such steps in a row take several presses before one shows a change. | Yjs's `UndoManager` skips only a step that changes no item, and an editor that uses it as it is behaves the same. Skipping such steps too ([#9630]) needed a digest of what readers show before and after every step: a cost on every press, and a digest that collides would skip a step that does change something. | `ACCEPTED LIMIT` | 10-31 | [#9630], [limits] |

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: D90 and D93 are pinned by test.fails cells in src/__tests__/unit/regressions/, but D81's repro (#9630's UndoInvisibleSteps.test.ts) closed unmerged. packages/lexical-y/AGENTS.md asks for a repro and a baseline control behind a limit; bringing that file in as a pin naming D81 would make a later change of the limit show up in the suite.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed; the next lexical-y PR brings a pinned repro naming D81 (from #9630's UndoInvisibleSteps, with its main control).

| D90 | **List numbering can lag after refused follow-ups:** after four follow-ups in a row that the binding does not write and reads again (such as `BlockIdExtension`'s naming updates), the author can show a derived list number from before them (1 where its document reads 2) until that list is refreshed (adding an item to it does; an edit elsewhere, or retyping an item's text, does not); its own fresh read, other clients and a reload show the right number. | The stop on the fourth re-read in a row is the backstop that ends an app's reply loops: [#9634] showed that fresh derived values after every re-read, bounded reply chains and app replies that read the derived value cannot all hold. Display only, the author's own editor, no stored change. | `ACCEPTED LIMIT` | 10-31 | [#9632], [#9634], [#9642], [limits] |
| D91 | **Overlapping formats of one key from two writers can cancel:** two collaborators applying the same format (bold, the same comment id, a link) to overlapping ranges at once can leave part of the text without it. Since [#9601] this also reaches a link's edge and its first word (D82). | A Yjs text gives each place one value per key ([yjs#382]): a writer's end of its range ends the other writer's run there too. Avoiding it needs a marker per writer, a storage change. Formatting only, no text lost; [#9601] is kept. | `ACCEPTED LIMIT` | 10-31 | [#9626], [#9601], [limits] |
| D93 | **A comment on a word a collaborator rewrites at the same moment can land on the new word:** with `setTextContent` for one client id order, and with a block rewrite (the line's children replaced by one new text node, as an agent writes it) for one or both orders; words of the range the rewrite kept keep the comment. | The same race as text typed at the start of a range a collaborator formats (y.md, Other races): the new word is inserted where the commented word starts, and Yjs orders the two writes by client id. Before [#9598] a block rewrite dropped the comment from the whole range instead. Formatting only, no text lost. | `ACCEPTED LIMIT` | 10-31 | [#9610], [limits] |

## Open

Expand All @@ -130,16 +135,16 @@ issues and docs that carry it.
| D61 | **Two concurrent joins into one line** both write its type; Yjs keeps one by client id. | Inherent to the marker layout; a real trade-off. | `OPEN` | 10-30 | [#9511] |
| D62 | **Two app versions that store a type differently insert its first node at once.** | Needs a storage-kind record protocol; `schemaVersion` (D08) covers apps that bump it. | `OPEN` | 10-30 | [#9512] |
| D63 | **A line without a marker joins a paragraph a collaborator appends.** | Every reader rule considered changes how stored documents read. | `OPEN` | 10-30 | [#9524] |
| D64 | **Undoing a format while a collaborator edits inside its range.** | Yjs format items cannot be conditional on another item; the remaining option is a core or storage change. | `OPEN` | 10-30 | [#9560], [#9521] |
| D64 | **Undoing a format while a collaborator edits inside its range.** | Yjs format items cannot be conditional on another item; the remaining option is a core or storage change. | `OPEN` | 10-30 | [#9560], [#9521], [#9682] |
| D65 | **A comment spreading after an offline client types plain text into it.** | Since D86 a delete keeps every format item it leaves, so the [#9609] cells where a collaborator's delete took the run's end pass; the cells where the restore's run has no end of its own (`OwnRunRestoreEnd.test.ts`) still fail. [#9562], which extended the restore's end rule to runs the writer opened itself, is parked: each attempt regressed a case main handles. The case is tracked in [#9609], with the tests moving to main in [#9605]. A restore in a run whose format change wrote more than 64 items gets no end, because the end scan stops there ([#9603]). | `OPEN` | 10-30 | [#9564], [#9609], [#9562], [#9605], [#9603] |
| D66 | **Server identity:** claim a client id on first use, or server-issued leases. | Claim on first use is the recommendation; not decided. | `OPEN` | 10-29 | |
| D67 | **The rest of the server-defence proposals:** a relay gate judging version records against a published ceiling, budgets, recovery, an author map, agent write policies. | Researched and proposed; nothing decided beyond D46. | `OPEN` | 10-29 | |
| D68 | **Splice-based text edit helpers exported from Lexical** for agents that rewrite text, and named APIs for intent that `setTextContent` drops. | Agents should edit text in place; the API shape is undecided. | `OPEN` | 10-27 | [#9483] |
| D69 | **Comments on block decorators** (an image), as in Google Docs. | Not today, same as `MarkNode`; keep the door open. | `OPEN` | 10-23 | [#9380] |
| D74 | **Keeping the run ends of text an undo removes from inside a format run.** | [#9561] keeps them, but regressed a concurrent undo ([#9593]) and is parked. Option C: settle the kept ends inside the undo that makes them stale, where the undoing client has seen them; the truly concurrent case would stay a documented limit. Its tests move to main in [#9605]. | `OPEN` | 10-30 | [#9561], [#9593], [#9605] |
| D74 | **Keeping the run ends of text an undo removes from inside a format run.** | [#9561] keeps them, but regressed a concurrent undo ([#9593]) and is parked. Option C: settle the kept ends inside the undo that makes them stale, where the undoing client has seen them; the truly concurrent case would stay a documented limit. Its tests move to main in [#9605]. | `OPEN` | 10-30 | [#9561], [#9593], [#9605], [#9681] |
| D75 | **The table padding contract under collaboration.** When every client re-runs the table transforms after remote changes, concurrent pads add cells and the table never settles. | Padding that converges needs a rule: only the client that created a row pads it, or a width that is a function of the document instead of each client's write. Meanwhile the open [#9539] would pad, when an edit leaves the table's rows in place, only the rows that lost cells. Two clients padding at once is the accepted limit D55. | `OPEN` | 10-30 | [#9582], [#9539] |
| 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] |
| 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. Replaced by D93 (10-31): documented as a limit. | `DECIDED` | 10-31 | [#9610], [limits] |

[root-agents]: ../../AGENTS.md
[limits]: ../lexical-website/docs/collaboration/y.md#limits
Expand Down Expand Up @@ -213,3 +218,11 @@ issues and docs that carry it.
[#9597]: https://github.com/meta/lexical/issues/9597
[#9606]: https://github.com/meta/lexical/issues/9606
[#9666]: https://github.com/meta/lexical/issues/9666
[#9630]: https://github.com/meta/lexical/pull/9630
[#9632]: https://github.com/meta/lexical/issues/9632
[#9634]: https://github.com/meta/lexical/pull/9634
[#9642]: https://github.com/meta/lexical/pull/9642
[#9645]: https://github.com/meta/lexical/issues/9645
[#9681]: https://github.com/meta/lexical/issues/9681
[#9682]: https://github.com/meta/lexical/issues/9682
[yjs#382]: https://github.com/yjs/yjs/issues/382
Loading
Loading