Repository navigation
[lexical-website] Documentation Update: Show agent edits by block id on a server that check their targets and edit text in place - #9545
Conversation
…on a server that check their targets and edit text in place
## Description
The docs give an agent two recipes that pull in opposite directions, and
neither covers the combination a server-side agent needs:
- `extensions/block-ids.md` "Addressing blocks" applies a model's edit with
`block.replace(node)`, while `collaboration/y.md` "Edits made by code" says
a replaced block is a delete and an insert that loses what a collaborator
types in it.
- `collaboration/y-server.md` tells an agent to run `editYState` on `seen`
(the state it read) and merge. On `seen` every block the agent read still
exists, so `$getBlockById` finds a block a collaborator deleted since, and
the agent's text then survives the merge without its block, glued to a
neighboring line. Nothing says so, and nothing says to check targets on
the latest state.
- The server config example says to register "every node type the clients
use", which reads as if `BlockIdExtension` (not a node type) may be left
out; a server config without it writes new blocks without ids.
This adds an "Edits by block id" section to `y-server.md`: read blocks with
ids from `seen`, look the answer's ids up on the latest state and send the
gone ones back to the agent (best effort: a delete that reaches the stored
state after the check still lands), apply the rest on `seen` with
`editYState`, splicing only what changed (one splice for one contiguous
change; blocks the sketch does not handle are reported back too, not
skipped silently), and merge. "One edit at a time per document" now says
that an agent's planned edit is the exception to running on the latest
state. The config comment
now names `BlockIdExtension` as part of the clients' extensions, and
`block-ids.md` says that under `@lexical/y` a replaced block loses
concurrent typing, with a link to the new section.
## Test plan
### Before
A scratch script on the published `0823b74` build (`seedYState` with
`RichTextExtension` and `BlockIdExtension`; the agent reads three blocks; a
person deletes the second block and types " Jumps!" at the end of the first;
the agent's answer rewrites both; `editYState` on `seen`, merged into the
latest state), applying the answer the way the docs show today
(`$getBlockById` on `seen`, whole-text replace):
```
unchecked [ 'The quick red fox.Rewritten. Jumps!', 'Last line.' ]
```
The rewrite of the deleted block is glued into the first paragraph, and
nothing reported it.
### After
The same scenario with the new section's code:
```
agent reads [
{ id: 'yV2Q4QYdu_-lckETHqioD', text: 'The quick brown fox.' },
{ id: 'Xsr0UCPa1MszTSHCeLISW', text: 'Delete me.' },
{ id: 'eZKtmH7okgYtxEmeV-OPP', text: 'Last line.' }
]
gone [ true ]
final [
[ 'yV2Q4QYdu_-lckETHqioD', 'The quick red fox. Jumps!' ],
[ 'eZKtmH7okgYtxEmeV-OPP', 'Last line.' ]
]
```
The op on the deleted block is reported back, and the in-place rewrite of
the first block keeps the person's concurrent " Jumps!". The section's code
block type-checks as written (`tsc --strict`, `moduleResolution: bundler`,
against the published typings).
```
$ pnpm run ci-check
Found 0 errors
```
Docs only; no browser checks apply.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
lexical
@lexical/a11y
@lexical/clipboard
@lexical/code
@lexical/code-core
@lexical/code-prism
@lexical/code-shiki
@lexical/compiler
@lexical/devtools-core
@lexical/dragon
@lexical/eslint-plugin
@lexical/extension
@lexical/fast-check
@lexical/file
@lexical/hashtag
@lexical/headless
@lexical/history
@lexical/html
@lexical/internal
@lexical/link
@lexical/list
@lexical/mark
@lexical/markdown
@lexical/mdast
@lexical/offset
@lexical/overflow
@lexical/plain-text
@lexical/react
@lexical/rich-text
@lexical/selection
@lexical/table
@lexical/tailwind
@lexical/text
@lexical/utils
@lexical/y
@lexical/yjs
commit: |
…es to concurrent typing
## Description
`y-server.md` ("Edits by block id" and "Edits by agents and tools") and `extensions/block-ids.md` said that with `@lexical/y` replacing a block deletes what a collaborator types in it at the same time. Tests show that in a line (a paragraph, also one inside a table cell) the typing survives, before the new text and with its own formatting; formatting of the replaced characters does not carry over; only a block stored as a node of its own (a table, a container) loses what was typed in it. All three places now say so, in one sentence shared with `y.md`'s "Edits made by code".
## Changeset
None: website only.
## Test plan
### Before
```
$ grep -n "deletes what a collaborator is typing\|it does not: a replaced block" packages/lexical-website/docs/collaboration/y-server.md packages/lexical-website/docs/extensions/block-ids.md
packages/lexical-website/docs/collaboration/y-server.md:375: deletes what a collaborator is typing in it. Splice only the characters
packages/lexical-website/docs/extensions/block-ids.md:110:[`@lexical/y`](/docs/collaboration/y) it does not: a replaced block is a
```
### After
```
$ npx prettier --check packages/lexical-website/docs/collaboration/y-server.md packages/lexical-website/docs/extensions/block-ids.md
All matched files use Prettier code style!
$ pnpm -C packages/lexical-website run build # onBrokenAnchors: 'throw'
[SUCCESS] Generated static files in "build".
```
…xt in the server page's agent edit sketches, so a tab or a token does not throw
## Description
The server page's "Edits by block id" sketch edited the only child of a block whenever it was a text node, with `spliceText`. Not every text node takes any text: a paragraph that holds only a tab threw (`TabNode does not support spliceText`) inside the agent's `editYState`, and a token-mode node, a hashtag, a code token or an app's own text node (a mention) had its text replaced as if it were plain text. A block with a named slot passed the check too, although the text the agent read for it (`getTextContent()`) includes the slot's text, which the splice would have copied into the child. The sketch in "Edits by agents and tools" splices the first text node of the document the same way and threw for a document that starts with a tab.
Both sketches now edit plain text only (`isSimpleText()`: a `TextNode` of its own type, in normal mode), and the block sketch also leaves blocks with named slots to the agent (`$plainTextOf` returns null; the op goes to `unsupported`). `ServerDocsAgentEdits.test.ts` keeps a copy of the sketch, checks that it matches the page, and runs it through `seedYState`/`readYState`/`editYState` over every shape: plain and bold text are edited in place; token and segmented text, a tab, a hashtag, a code token, an app's text node, text next to a tab, and a block with a named slot are refused, unchanged, and nothing throws.
## Test plan
### Before
The new test with the sketch's previous guard (`$isTextNode(text)` only):
```
$ vitest run --project unit packages/lexical-y/src/__tests__/unit/ServerDocsAgentEdits.test.ts
✓ the sketch is the same in the docs and the tests 2ms
× edits blocks by id: plain text in place, every other shape refused, nothing thrown 77ms
× a splice at the first text node skips a tab or a token there 33ms
FAIL ... > edits blocks by id: plain text in place, every other shape refused, nothing thrown
Error: TabNode does not support spliceText
❯ TabNode.spliceText packages/lexical/src/nodes/LexicalTabNode.ts:113:5
FAIL ... > a splice at the first text node skips a tab or a token there
AssertionError: token text: expected 'Hiext' to be 'Old text' // Object.is equality
Tests 2 failed | 1 passed (3)
```
### After
```
$ vitest run --project unit packages/lexical-y/src/__tests__/unit/ServerDocsAgentEdits.test.ts
✓ |unit| packages/lexical-y/src/__tests__/unit/ServerDocsAgentEdits.test.ts (3 tests) 150ms
Test Files 1 passed (1)
Tests 3 passed (3)
```
`pnpm run ci-check` passes.
There was a problem hiding this comment.
Thanks for the update: the deleted-block case now behaves exactly as the test plan shows, the new replace wording matches what happens to a paragraph and to a table, and ServerDocsAgentEdits.test.ts keeps the two helpers identical to the page. One blocking point and a few smaller ones on the new text.
1. A block a collaborator moved or replaced still passes the check (packages/lexical-website/docs/collaboration/y-server.md L367-376; steps 3-4 at L448-473)
A move keeps the block's id (extensions/block-ids.md, “How ids follow edits”: drag and drop, insertAfter of an existing node), and so does replace, but @lexical/y stores a move as a delete and a re-insert (collaboration/y.md, Limits, Moves). So $getBlockById(op.id) finds the id on latest, nothing goes to gone, and the splice applied on seen edits the deleted original. With the section's code: seed “First line.” / “Move me.” / “Last line.”, move the second block below the third in latest, and answer {id: <its id>, text: 'Move me now.'}. Whatever the client ids, gone is empty, the moved block still reads “Move me.”, and “ now” is glued to the start of the next line (“ nowLast line.”). A collaborator's replace of the target with “A slow brown dog.” gives “redA slow brown dog.” for the answer “The quick red fox.”. That is the outcome the bullet says the check prevents, while the caveat at L374-376 names only a delete that arrives after the check. Suggest naming moves and rewrites in the caveat, and catching moves, for example by also sending back a target whose parent or previous sibling differs between seen and latest (conservative: an insert or delete next to the target sends it back too).
2. collaboration/y.md still says the opposite of the new wording (packages/lexical-website/docs/collaboration/y.md L655-658)
“A replaced block is a delete and an insert, so what collaborators type into it at the same time is lost” contradicts the corrected sentences at y-server.md L318-322 and L379-383 and extensions/block-ids.md L110-115, and y-server.md L313-314 tells readers to follow that bullet. The new wording is the accurate one; giving y.md the same sentences would make the three pages agree.
3. Nit: typing at the end of a replaced line can stay after the new text (y-server.md L319 and L380, extensions/block-ids.md L112)
Replacing a paragraph while a collaborator types “ Jumps!” at its end gives “The quick red fox. Jumps!” or “ Jumps!The quick red fox.” depending on the client ids; typed at the start or in the middle, it does land before the new text. Something like “lands at the start of the new text (typed at its end, it can also stay at the end)” would match.
4. Nit: an op whose id exists only on latest is dropped silently (y-server.md L463-469)
For an id that appeared after the read, live.has(op.id) is true but $getBlockById(op.id) is null on seen, and else if (block !== null) skips it, so it is in neither gone nor unsupported. else if (live.has(op.id)) would report it, as the description intends.
5. Nit: the guard comment (y-server.md L347-348 and L424-426, repeated in the test)
It says a hashtag or a code token “does not take any text”, but only a tab rejects spliceText; a hashtag or a code token accepts the splice and keeps its type around the new text (#oldtag becomes a hashtag reading Hiag), which is the better reason to skip them.
Checked and fine: the new links and anchors resolve in the website build, and the code block type-checks once seen, latest, ops, config and agent are declared.
| agent instead of applying them. The check is best effort: a | ||
| collaborator's delete that reaches the stored state after the check (one | ||
| already on its way, or made in between) still lands this way. |
There was a problem hiding this comment.
A block a collaborator moved keeps its id (extensions/block-ids.md, “How ids follow edits”), and @lexical/y stores the move as a delete and a re-insert (collaboration/y.md, Limits, Moves), so the check finds the id on latest and the splice on seen edits the deleted original: moving “Move me.” below “Last line.” and answering “Move me now.” leaves “ nowLast line.” with nothing sent back. A collaborator's replace, which keeps the id, does the same (“redA slow brown dog.”). Could the caveat name moves and rewrites, and the check also send back a target whose parent or previous sibling differs between seen and latest?
There was a problem hiding this comment.
Fixed in 2223431: step 3 now compares where each target is ($placeOf: the path from the root, through the hosts of named slots, each node by its id or, without one, its type, and its slot name or index) and its text between seen and the latest state, and sends back the targets that are gone or differ, so a moved or replaced block goes back to the agent. The caveat names what still lands (a change after the check, a block replaced by one with the same text) and that the check is conservative. ServerDocsAgentEdits.test.ts covers move, replace, typing, delete, a block added after the read, a move to the next table cell, a move of the table, and a move to another named slot.
| delete and an insert. In a line such as a paragraph, what a collaborator | ||
| types at the same time survives but lands before the new text, with its | ||
| own formatting; formatting of the replaced characters does not carry over. | ||
| Replacing a block stored as a node of its own, such as a table, deletes | ||
| what was typed in it. |
There was a problem hiding this comment.
collaboration/y.md L655-658, which L313-314 tells readers to follow, still says what collaborators type into a replaced block “is lost”. This wording is the accurate one; giving y.md the same sentences would make the pages agree.
There was a problem hiding this comment.
Fixed in 2223431: collaboration/y.md now says the same as this page and extensions/block-ids.md (in a line, concurrent typing survives at the start of the new text; a block stored as a node of its own loses it).
| - **Edit the text in place.** Replacing the block (`block.replace(node)`, as | ||
| in the [single-editor example](/docs/extensions/block-ids#addressing-blocks)) | ||
| writes it anew. In a line such as a paragraph, what a collaborator types | ||
| at the same time survives but lands before the new text, with its own |
There was a problem hiding this comment.
Nit: typed at the end of the replaced line, the collaborator's text can also stay after the new text (“The quick red fox. Jumps!”), depending on the client ids; typed at the start or in the middle, it lands before. The same sentence is at L319 and in extensions/block-ids.md L112.
There was a problem hiding this comment.
Fixed in 2223431 on all three pages: "at the start of the new text (typed at the end of the line, it can also stay at the end)". Verified over 12 rounds of fresh writers (both client-id orders): typed at the start or in the middle it always lands before the new text; typed at the end, either way.
| const text = $plainTextOf(block); | ||
| if (text !== null) { | ||
| $setTextInPlace(text, op.text); | ||
| } else if (block !== null) { |
There was a problem hiding this comment.
Nit: an op whose id is live on latest but absent from seen (a block added after the read) gets block === null here and lands in neither gone nor unsupported. else if (live.has(op.id)) would report it.
There was a problem hiding this comment.
Fixed in 2223431: an id that exists only on the latest state has no place on seen, so the new check sends it back with the other changed targets.
…'s edit of a block a collaborator moved or replaced, and say where typing in a replaced line lands
## Description
The review of the previous commit found that the "Edits by block id" sketch on the server page still applied an edit to a block a collaborator had moved or replaced since the agent's read. A move and a `replace` keep the block's id, but `@lexical/y` stores either one as a delete and an insert, so the check (does the id exist on the latest state?) passed, the splice on `seen` edited the deleted original, and the agent's text was glued to the next line ("Move me now." answered for a block moved below "Last line." gave " nowLast line.").
- **The check** now compares each target on the latest state with what the agent read (`$placeOf`): where it is (the path from the root to it, through the hosts of named slots, each node on it by its id or, for one without, such as a table cell by default, by its type, and each by its place: the slot it fills or its index in its parent) and its text. A target that is gone or differs goes back to the agent, to plan again from the latest state. The bullet says the check is conservative (a block a collaborator typed in, or one after an inserted or deleted block in any of its containers, goes back too) and best effort (a change that reaches the stored state after the check, or a block replaced by one with the same text, still lands this way). An id that exists only on the latest state now goes back too, where it was dropped silently. A move between two table cells, whose cells have no ids, a move of the table the target is in, and a move from one named slot to another are caught too.
- **`collaboration/y.md`** said that what collaborators type into a replaced block is lost, which the server page and the block id page tell readers to follow; it now says what they say.
- **Where typing in a replaced line lands:** at the start of the new text, and, typed at the end of the line, it can also stay at the end, depending on the client ids. The three pages say so.
- **The guard comments** said a hashtag or a code token does not take text; only a tab rejects `spliceText`, and a hashtag or a code token would keep its type around the new text, which is why the sketches skip them.
`ServerDocsAgentEdits.test.ts` keeps `$placeOf` in sync with the page, runs steps 3 and 4 as written against a collaborator who leaves the target alone, moves it, replaces it, types in it or deletes it (and a block added after the read), checks the place of a target moved to the next table cell, of one in a table that was moved and of one moved to another named slot, and checks where a collaborator's concurrent typing lands when a line is replaced (12 rounds of fresh writers, so both client-id orders occur).
## Test plan
### Before
Step 3 as it was (the ids that exist on the latest state), in the new test:
```
$ vitest run --project unit packages/lexical-y/src/__tests__/unit/ServerDocsAgentEdits.test.ts -t 'step 3'
× step 3 sends back a target that is gone, moved, replaced, typed in or new since the read, and applies the rest 98ms
AssertionError: moved: expected [] to deeply equal [ '5n0kRzknaqxEhDr_w6dxp' ]
Tests 1 failed | 4 skipped (5)
```
### After
```
$ vitest run --project unit packages/lexical-y/src/__tests__/unit/ServerDocsAgentEdits.test.ts
✓ |unit| packages/lexical-y/src/__tests__/unit/ServerDocsAgentEdits.test.ts (7 tests) 504ms
Tests 7 passed (7)
$ prettier --check packages/lexical-website/docs/collaboration/y-server.md packages/lexical-website/docs/collaboration/y.md packages/lexical-website/docs/extensions/block-ids.md
All matched files use Prettier code style!
```
There was a problem hiding this comment.
Thanks, this looks good. Moved, replaced, typed-in and deleted targets now go back to the agent, ServerDocsAgentEdits.test.ts keeps the page's helpers in sync with the tests, and collaboration/y.md, collaboration/y-server.md and extensions/block-ids.md now describe a replaced block the same way. Everything below is optional and non-blocking.
1. A move that ends where it started still passes step 3 (packages/lexical-website/docs/collaboration/y-server.md L382-387, the $placeOf comment at L459-461, steps 3-4 at L490-521)
$placeOf compares the target's path and text, so a block that a collaborator moves down and back up, or moves below its neighbour while a block before it is deleted, gives the same string on seen and latest. @lexical/y has still written the move as a delete and a re-insert (collaboration/y.md, Limits, “Moves”), so the splice made on seen lands in the deleted copy. With the section's code, starting from “First line.” / “Move me.” / “Last line.” and the answer “Move me now.” for “Move me.”, the merged document reads “ nowMove me.” (moved down and back) or “ nowLast line.” (moved below “Last line.” while “First line.” is deleted), and gone stays empty.
Step 3 already requires every live target's text on latest to equal what the agent read, so step 4 could run on latest instead of seen: nothing typed in a target since the read is overwritten, the splice always reaches the live block, and these cases (and the same-text replace the caveat names) then give “Move me now.”. The parenthetical in “One edit at a time per document” would change with it. If you prefer to keep seen, naming this case in the caveat, and in the $placeOf comment, which says a move “changes the path”, would make both accurate; a case in ServerDocsAgentEdits.test.ts would pin either choice.
2. Nit: how the place is compared (y-server.md L378-380)
“each node on it by its id or, without one, by its type and place in its parent” reads as if the place counts only for nodes without an id, while $placeOf records every node's place (L473-476), which is what catches a plain move. “each node on it by its id (or, without one, its type) and its place in its parent” would match the code.
3. Nit: the test plan's After output
It comes from the first commit's existence-only check. On the current head the same scenario sends back both ops, since the first block was typed in, and leaves “The quick brown fox. Jumps!” / “Last line.”. AGENTS.md (Commits and Pull Requests) asks the Test plan to carry the real output of what was run, so the 7/7 run of ServerDocsAgentEdits.test.ts, or the script re-run on this head, would fit there.
Website and test changes need no changeset (AGENTS.md, Changesets; CONTRIBUTING.md, Changesets).
| state, instead of applying them. The check is conservative (a block a | ||
| collaborator typed in, or one after an inserted or deleted block in any | ||
| of its containers, goes back too) and best effort: a change that reaches | ||
| the stored state after the check (one already on its way, or made in | ||
| between) still lands this way, and so does a block replaced by one with | ||
| the same text. |
There was a problem hiding this comment.
Optional: a move that ends where it started still passes this check. A block that a collaborator moves down and back up, or moves below its neighbour while a block before it is deleted, gives the same $placeOf string on seen and latest, but @lexical/y wrote the move as a delete and a re-insert, so the splice on seen lands in the deleted copy: answering “Move me now.” for “Move me.” leaves “ nowMove me.” or “ nowLast line.”, and gone stays empty. Since step 3 already requires each live target's text on latest to equal what the agent read, running step 4 on latest gives “Move me now.” in these cases (and in the same-text replace named here); otherwise this list and the $placeOf comment (“changes the path”) could name the case.
| path from the root to it, each node on it by its id or, without one, by | ||
| its type and place in its parent; and its text), and send the ones that |
There was a problem hiding this comment.
Nit: this reads as if a node's place counts only when it has no id, but $placeOf records every node's place (L473-476), which is what catches a plain move. “each node on it by its id (or, without one, its type) and its place in its parent” would match the code.
Description
The docs give an agent two recipes that pull in opposite directions, and
neither covers the combination a server-side agent needs:
extensions/block-ids.md"Addressing blocks" applies a model's edit withblock.replace(node), whilecollaboration/y.md"Edits made by code" saysa replaced block is a delete and an insert that loses what a collaborator
types in it.
collaboration/y-server.mdtells an agent to runeditYStateonseen(the state it read) and merge. On
seenevery block the agent read stillexists, so
$getBlockByIdfinds a block a collaborator deleted since, andthe agent's text then survives the merge without its block, glued to a
neighboring line. Nothing says so, and nothing says to check targets on
the latest state.
use", which reads as if
BlockIdExtension(not a node type) may be leftout; a server config without it writes new blocks without ids.
This adds an "Edits by block id" section to
y-server.md: read blocks withids from
seen, look the answer's ids up on the latest state and send thegone ones back to the agent (best effort: a delete that reaches the stored
state after the check still lands), apply the rest on
seenwitheditYState, splicing only what changed (one splice for one contiguouschange; blocks the sketch does not handle are reported back too, not
skipped silently), and merge. "One edit at a time per document" now says
that an agent's planned edit is the exception to running on the latest
state. The config comment
now names
BlockIdExtensionas part of the clients' extensions, andblock-ids.mdsays that under@lexical/ya replaced block losesconcurrent typing, with a link to the new section.
Follow-ups
98f7fbc5: the sketches splice only plain text (a tab rejectsspliceText; a hashtag or a code token would keep its type around new text), andServerDocsAgentEdits.test.tsruns them over every kind of text node and block.22234311(review): the target check also sends back a block a collaborator moved or replaced since the read. A move and areplacekeep the id, but@lexical/ystores either as a delete and an insert, so the found block was the deleted original and the agent's text was glued to the next line ("Move me now." for a block moved below "Last line." gave " nowLast line.").$placeOfcompares where the target is (the path from the root, through the hosts of named slots, each node by its id or, without one, its type, and its slot name or index) and its text betweenseenand the latest state; a target that is gone or differs goes back (an id that exists only on the latest state too). The check is conservative and best effort, as the bullet says.collaboration/y.mdno longer says that typing in a replaced block is lost, and the three pages say that typing at the end of a replaced line can also stay at the end.Test plan
Before
A scratch script on the published
0823b74build (seedYStatewithRichTextExtensionandBlockIdExtension; the agent reads three blocks; aperson deletes the second block and types " Jumps!" at the end of the first;
the agent's answer rewrites both;
editYStateonseen, merged into thelatest state), applying the answer the way the docs show today
(
$getBlockByIdonseen, whole-text replace):The rewrite of the deleted block is glued into the first paragraph, and
nothing reported it.
After
The same scenario with the new section's code:
The op on the deleted block is reported back, and the in-place rewrite of
the first block keeps the person's concurrent " Jumps!". With the follow-ups,
ServerDocsAgentEdits.test.tspasses 7/7: steps 3 and 4 as written against a collaborator who leaves the target alone, moves it, replaces it, types in it or deletes it, a block added after the read, a target moved to the next table cell, one in a table that was moved and one moved to another named slot, and where concurrent typing lands when a line is replaced (12 rounds, both client-id orders). With step 3 as it was before the follow-up, the move case fails (moved: expected [] to deeply equal [ '<id>' ]). The section's codeblock type-checks as written (
tsc --strict,moduleResolution: bundler,against the published typings).
Docs only; no browser checks apply.