Concurrent editing
What people see when they delete, undo, split, merge and move the same content at the same time.
Edytor merges concurrent edits without conflicts, and it follows one principle when two people touch the same content: keep what nobody removed, and never duplicate what both did. This page lists the rules users run into, each with a short Alice and Bob example. “At the same time” means before either has seen the other’s edit, whether because of latency or because one of them was offline.
Undo is your own
Undo and redo revert only your own edits. When Alice deletes a block, Bob edits another one, and Alice undoes, her block comes back and Bob’s edit stays. See History for undo steps in the editor.
Concurrent deletes and undo
Each person’s delete of a piece of text is recorded as theirs. A character is visible only while no one’s delete of it is in effect.
| Alice | Bob | |
|---|---|---|
| 1 | Deletes “ world” from “hello world”. | Deletes “ world” from “hello world”. |
| 2 | Undoes. The text stays deleted: Bob’s delete still holds. | |
| 3 | Undoes. “ world” comes back, once. |
Both see hello after step 2 and hello world after step 3, never hello world world.
- A restoration made before a peer’s concurrent delete arrived is hidden as soon as that delete arrives. It becomes part of the peer’s delete, and the peer’s undo brings it back.
- Text typed and deleted within one undo step leaves no trace; no undo can bring it back.
Deleting a block keeps its children
Deleting a block removes only that block. A block selection holds exactly the blocks selected: clicking a block’s handle selects that block, not its children.
| Alice | Bob | |
|---|---|---|
| 1 | Selects the “Tasks” block by its handle and deletes it. | Types in “Buy milk”, a child of “Tasks”. |
| 2 | “Buy milk” and its sibling take the place of “Tasks”, with their own children. | Bob’s typing is kept in “Buy milk”. |
| 3 | Undoes: “Tasks” returns with both children nested under it. |
The same holds for whatever Bob puts under “Tasks” without having seen the delete: nothing that Alice did not delete is hidden with it.
| Bob, at the same time | Result after both sync |
|---|---|
| Presses Enter in the middle of “Buy milk”, which has children | Both halves of “Buy milk” take the place of “Tasks”, in order; the children stay under the second half, as the split put them. |
| Types in the second half after that Enter | The text is kept. |
| Adds a new child under “Tasks”, or moves a block into it | It takes the place of “Tasks” too, among the children, in the order Bob placed it. |
| Deletes “Buy milk” (keeping its children) | Both deletes hold; the children of “Buy milk” take the place of “Tasks”. |
Alice’s undo puts all of it back under “Tasks”.
To delete a whole subtree from code, pass keepChildren: false. Every block in the subtree is deleted; a block Bob adds inside it at the same time takes the subtree’s place:
document.facade.deleteBlock('tasks', { keepChildren: false });
Undoing a new block keeps a peer’s text
Undoing the creation of a block (inserting it, pasting it, pressing Enter at the end of a line) removes your part only. The block stays while it holds someone else’s text or a child.
| Alice | Bob | |
|---|---|---|
| 1 | Presses Enter at the end of “Agenda”, creating an empty line. | |
| 2 | Types “Budget review” in the new line. | |
| 3 | Undoes. The line stays, with Bob’s “Budget review”. |
This holds even when Bob’s text arrives after Alice’s undo. If Bob later deletes his text, the empty line goes away; Alice’s redo brings her part back beside Bob’s.
The exception: undoing a split. When a line is split in two, the second half is text that moved, not text that was typed, so undoing the split joins the halves again, whoever typed in the second half:
| Alice | Bob | |
|---|---|---|
| 1 | Splits “hello world” after “hello”. | |
| 2 | Types “!” at the end of “ world”. | |
| 3 | Undoes. One line: “hello world!”. |
An explicit delete wins over concurrent typing
| Alice | Bob | |
|---|---|---|
| 1 | Deletes the “Draft” block. | Types “ v2” in “Draft”, without having seen the delete. |
| 2 | “Draft” is gone. | “Draft” is gone, with “ v2”; Bob’s caret moves to where the block was. |
This applies to text in the deleted block. A block Bob adds under it, or splits off inside it, is kept in its place (see Deleting a block keeps its children). It also applies to deletes only: undoing a block’s creation is not a delete (see above).
An emptied document shows a virtual paragraph
A document can end up with no blocks, for example when two people delete its last two blocks at the same time. Each view then shows one empty paragraph with the caret in it. Nothing is written for it.
| Alice | Bob | |
|---|---|---|
| 1 | Deletes block A (a block selection). | Deletes block B, the only other block. |
| 2 | Sees an empty paragraph; types “Hi”. | Sees an empty paragraph; types “Hello”. |
| 3 | Two lines: “Hi” and “Hello”. | The same two lines. |
The first edit in the virtual paragraph (typing, pasting, Enter, turning it into a heading) creates a real block in the same update. Nobody writes a placeholder block for having seen an empty document, so no empty paragraphs are duplicated. A read-only view shows the virtual paragraph but cannot create it. In a view, edytor.facade.virtual() names it while the document is empty.
Deleting everything keeps the first block
Selecting all the text and deleting it keeps the first block, emptied, with its id, type and data: a heading stays a heading.
| Alice | Bob | |
|---|---|---|
| 1 | Selects all text and deletes. | Selects all text and deletes. |
| 2 | One empty block. | The same one empty block. |
Two people clearing the document converge on one block, not two. Typing that Bob did in the first block at the same time survives in it; typing in any other block goes with that block. Each person’s delete holds on its own: the first undo leaves the other’s delete in effect, the second brings the document back.
Moves, splits and merges keep identity
A block keeps its id and its CRDT identity when it is moved, nested, split or merged. Edits made concurrently to it land where the block, or its text, ends up.
| Alice | Bob | Both see | |
|---|---|---|---|
| Move | Moves “Notes” to the end. | Types “!” in “Notes”. | “Notes!” at the end. |
| Same block moved twice | Nests “Notes” under “Ideas”. | Moves “Notes” to the top. | One placement, chosen the same way on every replica. “Notes” is never duplicated. |
| Split | Splits “hello world” after “hello”. | Types “!” after “world”. | “hello” and “ world!“. |
| Merge | Joins “world” into the line above, “hello”. | Types “!” after “world”. | “helloworld!”. |
| Same split twice | Splits “hello world” after “hello”. | Splits it at the same place. | Both new lines exist; the text goes to one and the other stays empty. |
| Two splits | Splits “hello world” after “hello”, or pastes several lines of text there. | Splits it after “he”. | “he”, “llo” and “ world” (with the pasted lines after “hello”), in that order, whatever the client ids. The same when Alice first typed or deleted text before her split point. |
| Split and lift | Splits “hello world”, the paragraph above a list, after “hello”. | Lifts the list’s first item “a” out (Backspace at its start, or Turn into). | “hello”, “ world”, then “a”, whatever the client ids. |
| Two outdents | Outdents “x”, a nested line of “item” (Shift+Tab). | Outdents “y”, the nested line after it. | “x” before “y”, whatever the client ids. |
| Merge into a deleted line | Joins “item”, which has nested lines, into “hello” above (Backspace at its start). | Deletes “hello”. | “item” is back in its place, its former nested lines right after it, in order. |
mergeFrom into a deleted block |
Calls hello.mergeFrom(item): the nested lines of “item” become the last children of “hello”. |
Deletes “hello”. | “item” is back in its place, with its nested lines still under it. |
Moving a block into its own subtree is refused, and concurrent moves that would form a cycle are resolved the same way on every replica: no block becomes unreachable.
Which races keep the text order
The rows above that say “whatever the client ids”, and the list races below, hold when each person makes one of these gestures between two syncs: Enter inside a line, a paste of several lines of text into a line, Backspace at the start of a list’s first item or of a block with nested lines, Turn into on one list item, or Shift+Tab on one line or on adjacent lines. Text typed or deleted before the caret’s position does not count as another one; text typed or deleted after it, before the sync, can reverse the pieces (Alice types “ again” at the end of “hello world” and presses Enter after “hello wo” while Bob presses Enter after “hello”: “rld again” shows before “ wo”; Alice deletes “rld” and presses Enter after “hello” while Bob presses Enter after “hello w”: “o” shows before “ w”). Tests run each race on up to 240 pairs of client ids and check the order on every one.
Other races converge too: both people see the same document and no text is lost. But the order of the new or moved blocks can depend on the client ids in these cases:
- A new block next to a line. Enter at the start or end of a line adds a new block beside it instead of splitting it (at the end of a line with nested lines that is no toggle, callout or quote, it splits), and so do Duplicate and a kind picked from the handle’s + button (the + alone adds nothing), and a paste at the start of a line whose first pasted line is a list, a code block, a divider or an image. So does Enter inside the first line of a toggle that is open, or of a callout or quote with nested lines: the text after the caret becomes a new first nested line; and so does a paste at the end of that line. Alice presses Enter at the end of “hello world” and types “foo” while Bob presses Enter after “hello”: “foo” can show before “ world”.
- A paste of whole blocks, or over selected blocks. Blocks copied with a block selection paste (or drop) after the caret’s line, and a paste (or typing) over selected blocks takes their place; both add new blocks beside a line. Alice pastes two copied blocks with her caret in “hello wo|rld” while Bob presses Enter after “hello”: Bob’s “ world” can show between Alice’s two blocks.
- Several gestures before a sync. Alice presses Enter twice, or adds a line after a block or duplicates it and then presses Enter inside it, while Bob splits or lifts next to it: one of Alice’s lines can show out of order. Turn into over several blocks at once counts as one gesture per block: Alice turns the list items “a” and “b” into headings while Bob outdents “c”, the item after them, and “b” can show after “c”. Shift+Tab over several items counts as one gesture per group of adjacent items: over “a” and “b” together it keeps the order, but over “a” and “c” (with “b” not selected) while Bob outdents “d”, “d” can show before “b”.
- Moves. A drag, the block handle’s Alt+↑, Alt+↓ and Alt+→, Mod+Shift+↑/↓, the block menu’s Move up and Move down, and Tab, against another person’s edit next to the moved block. (The handle’s Alt+← is the outdent, which keeps the order like Shift+Tab.) Alice presses Tab on the line after “x” while Bob presses Enter at the end of the last line nested under “x”: Bob’s new line can show after Alice’s.
Conflicting type changes
When two people change the same block’s type at the same time, one change wins, chosen the same way on every replica. Undo never leaves a block without a type.
| Alice | Bob | |
|---|---|---|
| 1 | Turns the paragraph “Plan” into a heading. | Turns it into a quote. |
| 2 | Both see the same one: say Alice’s heading won. | |
| 3 | Undoes. “Plan” is a paragraph again, the type Alice replaced, for both. | |
| 4 | Redoes. “Plan” is a heading again. |
If Bob’s quote had won, Alice’s undo changes nothing: the quote stays. The same holds for block data (a to-do’s checked, a heading’s level) and for an inline atom’s data. lastChangedBy follows the same rule: it returns to the author before Alice.
Void blocks hold no children
Turning a block into a void kind (an image, a divider) moves its nested blocks out, right after it, in order: nothing renders a void block’s children. Undo puts them back under the block.
The same holds for whatever Bob puts under the block without having seen the change: a void block never shows children.
| Alice | Bob | |
|---|---|---|
| 1 | Turns “Plan”, which has a child “Step 1”, into a divider. | Presses Enter in the middle of “Step 1”, and nests “Notes” under “Plan”. |
| 2 | After both sync: the divider, then “Step 1”, its second half, and “Notes”, in that order, for both. | |
| 3 | Undoes. “Plan” is a paragraph again, with the three blocks nested under it. |
The rule reads the kind’s role from the document’s semantics, so every replica needs the same roles: a document with no roles for a kind treats it as any other block and shows its children.
Island blocks
An island block (the code plugin’s code block, for example) seals its content: range deletions and merges never cross its boundary, and blocks cannot be moved into or out of it. See the document model.
Deleting or merging away an island turns its children into ordinary blocks: each takes the default child type of its new parent (a paragraph at the top level), or a paragraph where that type shows no text (a code block’s lines left directly in a columns layout are paragraphs, never columns). A line Bob adds to the island without having seen the change does the same.
| Alice | Bob | |
|---|---|---|
| 1 | Deletes a code block with one line, “let a”. | Adds a line “let b” to the code block. |
| 2 | After both sync: “let a” and “let b” are paragraphs where the code block was, for both. | |
| 3 | Turns “let b” into a heading, moves it, or presses Enter or Tab in it. It stays a heading or a paragraph, never a code line outside a code block. | |
| 4 | Undoes back to the delete. The code block is back with both lines as code lines. |
A code line holds no nested blocks, and a code block holds only code lines: the code kind declares lines: true (block options). Other islands, such as a table of rows of cells, keep their nesting. When Alice’s undo brings a code block back while Bob has changed one of its former lines, both rules keep Bob’s work visible:
| Bob, after the delete and before Alice’s undo | After Alice’s undo |
|---|---|
| Nests the paragraph below under “let a” (Tab) | The paragraph shows right after the code block, where it can be edited and moved. |
| Turns “let a” into a heading | “let a” shows as a code line in the code block. If Alice redoes, it is a heading again. |
If someone later deletes “let a”, the paragraph Bob nested under it stays right after the code block; it never moves into the code block as a code line. And when Bob turns a code block into a paragraph while Alice adds a line to it, both lines show as paragraphs under it, whether or not another code block exists in the document. Only a code block’s lines change kind this way: a table turned into another kind keeps its rows as rows, and so does a row Alice adds meanwhile.
Nothing merges into a code block from outside it: mergeFrom refuses the merge and canMerge answers false. Its first line does not merge into it either, because a code block shows no text of its own. The rule holds for every block that renders no content: the first item of a list and the first cell of a table row never merge into their container, and a list or a row never merges as a whole. Instead, Delete at the end of the block above a list pulls the first item’s text up and leaves the rest of the list in place, and Backspace (mergeBackward) at the start of a list’s first item moves it out of the list as a paragraph (unless it stays inside an outer list of that kind: the first item of a nested list stays a list item of its parent item). A first cell stays where it is. A list holds only list items: a paragraph a merge, delete, outdent or paste would leave in it becomes a list item, a move of one into it is refused, and Tab after a list nests under its last item. Any other block (an image, a code block, a heading, a to-do) keeps its kind and data where a merge, delete or paste leaves it in a list, and an outdent or move that would place one directly in a list is refused. A list that loses its last item is removed. If a collaborator adds an item to a list while you delete, lift or move its only item, the new item stays, shown as a paragraph where the list was; undoing your edit puts it back in the list. The other way round, a paragraph a collaborator splits under an item you delete shows in the list as an item. A text selection that starts above a list keeps the list’s remaining items, and Shift+Tab on a middle item splits the list around it: the list keeps the items after it, so an item Bob appends meanwhile (Enter at the end of the last item) stays at the end of it. An item Bob adds among the items before the outdented one meanwhile ends up after it. When both outdent, lift (Backspace at the start of the first item) or turn into another kind items of one list at once, one item each, the text keeps its order whatever their client ids (which races keep it), and a list left empty is removed by the next key next to it. Two races are exceptions. In a list nested directly in a list, if Alice moves an item of the inner list into the outer one (Shift+Tab) or lifts it out of both lists while Bob lifts a later item of the inner list out of both (Turn into a heading, a divider or a code block), Alice’s item can end up after Bob’s. And a divider or code block Alice inserts after an item (Turn into on an item with text) can show above that item when Bob outdents or turns into another kind a later item of the same list and his client id wins it; the text keeps its order.
Splits have residuals of their own. A block Alice creates by pressing Enter in a block, or by pasting several lines into it, stays where she made it, beside that block, even when Bob meanwhile moves the block elsewhere: Bob outdents or lifts it, splits its list at a later item, presses Enter in its parent, which moves it under the new line, or outdents an earlier nested line of its parent (Shift+Tab), which takes it along as a nested line. Alice’s new line then shows before or after the block’s text instead of right after it. A line Bob joins into a block (Backspace at its start) while Alice splits that block shows after the split’s first piece: Bob joins “world” into “hello” while Alice presses Enter after “he”, and both see “heworld”, then “llo”. And the pieces of two splits of one block are ordered by the text after each split point, so text Alice typed or deleted after her own split point before it reached Bob can reverse them: Alice types “ again” at the end of “hello world” and presses Enter after “hello wo” while Bob presses Enter after “hello”, and both see “hello”, “rld again”, “ wo”. Whatever the race (Alice outdents a middle item while Bob lifts or outdents the one above it, or undoes a Turn into), a paragraph never shows directly in a list: on every replica it shows as a list item, and it leaves the list as a paragraph like any other item. One race can leave another kind there: if Bob turns the item above into a heading or a to-do (Turn into, the slash menu, # ) while Alice outdents, the item may stay in the list Alice’s outdent splits off. It keeps its kind, data and place, as a heading a merge leaves in a list does. The editor, a headless document and the room’s transact apply the same rules.
Mixed versions
Clients must run compatible edytor versions. A client of another document generation is refused by the providers and the room. Older clients of the same generation ignore the per-person delete and undo records described above and fall back to plain undo; see Limitations.