Repository navigation
[lexical-website][lexical-y] Documentation Update: Record the 10/31 decisions on undo presses, list numbering, overlapping formats, rewritten words and provider ordering - #9685
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
AI review status for this pull request.
|
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: |
e5cf779 to
705ccdf
Compare
705ccdf to
72c6d64
Compare
72c6d64 to
42d89e4
Compare
There was a problem hiding this comment.
This looks good. The five decisions are recorded consistently in DECISIONS.md and the collaboration page, and the new pins reproduce on the head. Everything below is optional and non-blocking.
Docs (packages/lexical-website/docs/collaboration/y.md, mirrored in packages/lexical-y/DECISIONS.md)
- D92 relay rule,
y.md:963-966andDECISIONS.md:105. "forwards each client's updates in the order it received them" describes per-sender order. #9645 describes its failing delivery in those same terms ("each client's own updates still in order"). A relay that queues or batches per sender can still send C an update from B that depends on A's before A's own update. A single relay is causal when it forwards every update, from any client, to each client in the one order it received them, as the playground relay described a few paragraphs earlier does. Suggested wording: "...and that forwards every update to each client in the order it received them, whichever client sent it, does this." - D90 refresh,
y.md:1188-1189andDECISIONS.md:125. "until that list is refreshed" doesn't say what refreshes the list. With #9632's setup, the author stays at[1, 1]while a fresh reader shows[1, 2]. That holds after an edit elsewhere, and also after the author or a collaborator retypes an item's text. Adding an item to the list, by either client, fixes it. Naming that would help integrators. - D91,
y.md:1337-1340andDECISIONS.md:126. "a comment" here means one comment id. Two writers addingc1to overlapping ranges lose part of it, whilec1andc2both stay whole (D39). Writing "the same comment (one id)" avoids suggesting that overlapping comments from different threads cancel.
Tests (packages/lexical-y/src/__tests__/unit/regressions/CommentOnRewrittenWord.test.ts)
:87and:134.test.failspasses on any throw, including a convergence failure. Every known cell actually converges with a fixed result: the kept words plust66z(oryz). Asserting that result with a plaintestwould still flag a change of the limit, and would also catch a regression that breaks convergence.:79. Both writes happen offline and onet.sync()delivers them, so 'rewrite first' and 'comment first' produce identical documents. The axis can go, or one write could be synced before the other is made.- D81 has no pin in the tree; its repro lived in #9630.
packages/lexical-y/AGENTS.md(Testing) asks for a repro and a baseline control behind any limit. Bringing #9630'sUndoInvisibleSteps.test.tsin as a pin would make a future change to that limit visible, as the D90 and D93 pins do.
|
|
||
| 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 each client's updates |
There was a problem hiding this comment.
Optional: "forwards each client's updates in the order it received them" describes per-sender order, which #9645 shows is not enough (its failing delivery keeps each client's own updates in order). A relay that queues or batches per sender can send C an update from B that depends on A's before A's own. Suggested wording: "...and that forwards every update to each client in the order it received them, whichever client sent it, does this." The same sentence is in DECISIONS.md D92.
| number from before them (1 where its document reads 2) until that list | ||
| is refreshed; an edit elsewhere does not do it. The binding stops |
There was a problem hiding this comment.
Optional: it would help to say what refreshes the list. With #9632's setup the author stays at [1, 1] (fresh reader [1, 2]) after an edit elsewhere, and also after the author or a collaborator retypes an item's text. Adding an item to the list, by either client, fixes it. For example: "until an item is added to that list, by anyone; typing in its items does not refresh it".
There was a problem hiding this comment.
Added (d85d7ff): adding an item to the list refreshes it; an edit elsewhere or retyping an item's text does not.
| can leave part unformatted, and so can the same format (bold, a comment, | ||
| a link) applied by two collaborators to overlapping ranges at once |
There was a problem hiding this comment.
Optional: for comments this holds only for one comment id. Two writers adding c1 to overlapping ranges lose part of it, while c1 and c2 both stay whole (D39). "the same comment (one id)" would avoid suggesting that overlapping comments from different threads cancel.
There was a problem hiding this comment.
Done (d85d7ff): "the same comment id" (different ids stay whole, D39).
| // D93: the comment reaches the new word for ids [1, 2], and for | ||
| // both orders where B's range ends after it. | ||
| const known = ids[0] === 1 || to === 't24z'; | ||
| (known ? test.fails : test)( |
There was a problem hiding this comment.
Optional: test.fails passes on any throw, including a convergence failure in expectConverged. Every known cell converges with a fixed result: the kept words plus t66z, for example ['t22z', 't66z']. Asserting that result with a plain test would still flag a change of the limit, and would also catch a regression that breaks convergence.
There was a problem hiding this comment.
Kept as test.fails: the package convention pins a known failure that way (AGENTS.md), and the unpinned run shows exactly these 9 cells fail with the comment on t66z (gc off and on). Asserting the limited result is a fair alternative; leaving it to a later pass over the limit pins.
| ['t23z', 't24z', ['t24z']], | ||
| ]; | ||
| for (const [from, to, kept] of ranges) { | ||
| for (const order of ['rewrite first', 'comment first']) { |
There was a problem hiding this comment.
Optional: both writes are made offline and one t.sync() delivers them, so 'rewrite first' and 'comment first' produce byte-identical documents (gc off and on). The axis can go, or one write could be synced before the other is made, to model a different race.
There was a problem hiding this comment.
Agreed that both orders give identical documents here (both writes offline, one sync). Kept as cheap coverage of the issue's two descriptions; no claim rests on them differing.
…ecisions on undo presses, list numbering, overlapping formats, rewritten words and provider ordering ## Description Records five maintainer decisions of 10/31 in DECISIONS.md and y.md: - **D81, accepted limit:** an undo press can show nothing when its step only restores or removes format items no reader shows, and several such steps take several presses, as with Yjs's `UndoManager` in any editor that uses it as it is. Skipping such steps (#9630, closed) needed a per-press digest of what readers show. y.md: a sentence in Limits, Undo. - **D90, accepted limit (#9632):** after four refused follow-ups in a row that the binding reads again, the author can show a derived list number from before them until that list is refreshed (adding an item does; an edit elsewhere or retyping an item does not); its fresh read, other clients and a reload are right. The stop on the fourth re-read is the backstop that ends reply loops (#9634, closed, showed the three requirements conflict). y.md: a Limits entry. The #9642 pins (`RefusedFollowUpRefresh`, `RefusedFollowUpFreshness`) now name D90 and the limit; they stay `test.fails`. - **D91, accepted limit (#9626):** overlapping formats of one key from two writers can cancel, because a Yjs text gives each place one value per key (yjs/yjs#382); since #9601 this also reaches a link's edge and first word. #9601 is kept. y.md: Other races says why. - **D93, accepted limit (#9610):** a comment on a word a collaborator rewrites at the same moment can land on the new word (`setTextContent`: one client id order; a block rewrite: one or both orders), the same race as text typed at the start of a range a collaborator formats. y.md: a sentence in Other races. New `regressions/CommentOnRewrittenWord.test.ts` pins the 9 cells where it happens as `test.fails` (12 block-rewrite cells and 2 `setTextContent` cells); #9610 stays open. D77 (the open question) says D93 replaced it. - **D92, decided (#9645):** providers deliver updates in causal order; a single relay that forwards every update to each client in the one order it received them does; a mesh or several relays need causal buffering that tracks dependencies, deletions included. y.md: Servers states the requirement. #9645 stays open. Also, from the review of #9684: D74 and D64 link #9681 and #9682, and y.md's Open issues lists #9681 and #9683. The agent-edit guidance is unchanged. ## Test plan ### Before ``` $ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/RefusedFollowUpRefresh.test.ts packages/lexical-y/src/__tests__/unit/regressions/RefusedFollowUpFreshness.test.ts Tests 27 passed | 30 expected fail (57) ``` ### After ``` $ pnpm exec prettier --check packages/lexical-y/DECISIONS.md packages/lexical-website/docs/collaboration/y.md packages/lexical-y/src/__tests__/unit/regressions/RefusedFollowUpRefresh.test.ts packages/lexical-y/src/__tests__/unit/regressions/RefusedFollowUpFreshness.test.ts All matched files use Prettier code style! $ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/RefusedFollowUpRefresh.test.ts packages/lexical-y/src/__tests__/unit/regressions/RefusedFollowUpFreshness.test.ts Tests 27 passed | 30 expected fail (57) $ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/CommentOnRewrittenWord.test.ts Tests 5 passed | 9 expected fail (14) $ LEXICAL_Y_GC=1 pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/CommentOnRewrittenWord.test.ts Tests 5 passed | 9 expected fail (14) ``` The two RefusedFollowUp files change only in comments; the same cells pass and fail as on main. With every `test.fails` in CommentOnRewrittenWord turned into `test`, exactly the 9 pinned cells fail (gc off and on).
42d89e4 to
d85d7ff
Compare
There was a problem hiding this comment.
Thanks, this looks good. The five 10/31 decisions read clearly, and the latest commit makes D90, D91 and D92 say exactly what the binding does: adding an item refreshes the stale list number while an edit elsewhere or retyping an item does not, only the same comment id cancels on overlapping ranges, and the D93 cells pinned in CommentOnRewrittenWord.test.ts converge with the comment on the new word for the documented orders, with gc off and on.
A few optional, non-blocking suggestions:
y.md, Servers (line 977): the existing "Deliver each client's updates in order." paragraph now follows the new causal-order requirement, which already includes each client's own order (#9645 quotes that 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 readers from taking it as a second, separate rule.- D93 wording (
y.mdline 1364,DECISIONS.mdD93): "for 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, and for one order when it ends at that word, as the test's comment at line 84 already says. - A pin for D81:
packages/lexical-y/AGENTS.mdasks for a repro and a baseline control before a behaviour is called a limit. #9630'sUndoInvisibleSteps.test.tshas both, but it closed unmerged; bringing it in as atest.failsnaming D81, the way D90 and D93 are pinned, would make a later change of that limit show up in the suite.
The test.fails shape and the order axis in the new test were already discussed and kept under the package's convention, so I'm not raising them again. No changeset is needed for website docs and tests (AGENTS.md).
| 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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Agreed; folded into the next docs PR (agent-edit guidance), which touches the same page, so this one can land as approved.
| 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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Agreed; the next docs PR names them: both orders when the range continues past the rewritten word, one order when it ends there.
| | 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] | |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Agreed; the next lexical-y PR brings a pinned repro naming D81 (from #9630's UndoInvisibleSteps, with its main control).
…dit on the current state, then apply it there ## Description y.md's "Edits made by code" told integrators to apply an agent's edit to the state it was computed from and merge the result into the live document. That always converges, but for an edit addressed by text or by block id it converges to text nobody meant whenever the document changed under the edit. Measured on `@lexical/y` with `seedYState`, `editYState` and `readYState` (15 cases: a person's or a second agent's edit lands between the agent's read and its edit): | Case | Merging the edit made on the read | Check, then apply to the current state | | --- | --- | --- | | a collaborator replaced the same words | `The slowswift red fox` | refused (words changed) | | a collaborator made the same change | `swiftswift` | refused | | two agents, different words | `at risblocked` | refused | | two agents, the same edit | `4.2.2%` | refused | | a collaborator typed elsewhere in the block | right | right | | the words now also appear in another block | right | right (checked in the read's block) | | the line was moved | new text where the line was; the moved line keeps the old text | right (block found by id) | | words cut and pasted elsewhere | `The swift redfox jumps.` | refused | | a block rewrite as a new node while a collaborator types in it | their text moved to the start | refused | | a block delete while a collaborator types in it | their sentence glued to the next line | refused | | a count outside the edited words | wrong | wrong (only the agent can catch it) | Merging was wrong in 10 of 15; the check was wrong in 1 and refused 2 edits that would have merged well. This is how text-matching edit tools behave (Anthropic's `str_replace` and OpenAI's `apply_patch` match the current text and fail on a miss; Claude Code refuses a write to a file changed since its read; the Google Docs API's `requiredRevisionId` and TipTap's server toolkit refuse a stale write so the caller re-reads). Now: - y.md, "Edits made by code": **Check what an edit names, then apply it to the current state**: a replacement checks its own words in the block where it read them, found there by id (still there, exactly once); a rewrite or a delete checks the whole block (where it is, and its type, text, formats, style, NodeState and children, compared serialized, not only its text); an insert checks its anchor block. Every edit of a batch is checked before any is applied, then again, in order, as it is applied on the state the earlier ones leave: an edit an earlier one changes (its words changed or now appear twice, its block deleted or rewritten, a block named twice) refuses the whole batch, a conservative rule. On a refusal, apply none, and return every failure (the reason, the block's content) and a new read. On a pass, apply to the current state (blocks found by id there) and store with a compare-and-set; on a lost race, drop the state and the update computed on the older state and run the whole check and edit again on the newer one (bounded), or refuse back to the agent with a read of the state that won the last race: an update computed on an older state is never stored or sent. **Merge from the read only where there is nothing to check** (an append, a suggestion a person accepts, an edit by position): the fork-and-merge recipe stays for those cases. - y-server.md, "Edits by agents and tools" and "Edits by block id": the sketches check on the latest stored state and run `editYState` there, through `applyChecked` (the check; the edit, which checks each part again as it applies it and can refuse; the compare-and-set; up to 3 attempts, then a fresh load), instead of editing `seen` and merging. The word sketch takes a batch of replacements. `$findWords` finds a replacement's words by the block's id and requires them exactly once in that block's text, in one plain text node; `$blockAt` compares a target as a whole (its path and its serialized subtree); an answer by id is checked op by op and applied whole or not at all. "One edit at a time" says a checked edit starts over on a lost race instead of merging. `ServerDocsAgentEdits.test.ts` runs the sketches as written (they live in `__tests__/utils/agentEditSketch.ts`, and the first test checks that the page holds the same code), including targets changed since the read, a batch with one failing op, a batch whose first edit changes what the second names, and a store that loses the race. - block-ids.md, "Handles and collaboration": the handle is resolved and the whole block checked against what the model read on the stored state, and the edit is applied there by id with `applyChecked`; `BlockHandles.test.ts` follows it (a block the person typed in now goes back as `changed`). - DECISIONS.md: D94 (decided); D71 says D94 replaced which state the edit runs on. Also, from the reviews of #9685: - y.md, Servers: the per-client order paragraph now opens "Causal order includes each client's own order", and the json-joy comparison is dropped (y.md and D92). - D93 and y.md name the orders: one order when the comment ends at the rewritten word, both when it continues past it. - `regressions/UndoInvisibleSteps.test.ts` (from #9630) pins D81: the 3 cells that assert a press also taking the next step are `test.fails`. They fail on main with gc off and on. ## Test plan ### Before The new cells against weaker sketches: no uniqueness check, a target compared by its path and text only, and an update computed before a lost race merged into the newer state (the docs' previous recipe): ``` $ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/ServerDocsAgentEdits.test.ts × the sketches are the same in the docs and the tests 13ms × a replacement of words refuses words that appear twice in the block or across nodes, and finds the block read by id 10ms × step 3 refuses the whole answer when a target is gone, moved, replaced, typed in, formatted or styled since the read 42ms × a lost race starts over on the newer state: applied when the check still holds there, refused when not, never the older edit 20ms ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 4 ⎯⎯⎯⎯⎯⎯⎯ AssertionError: expected '// Your storage of one document: its …' to be '// Your storage of one document: its …' // Object.is equality - Expected + Received AssertionError: expected undefined to deeply equal [ { …(3) } ] - Expected: + Received: AssertionError: formatted: expected [] to deeply equal [ [ 1, 'the block changed' ] ] - Expected + Received ``` An independent review of an earlier head of this branch composed the word check per edit into a batch: each check passed on the stored state, the first edit invalidated the second, and the batch was stored half done; after 3 lost races the read returned was the one that had lost: ``` FAIL > dependent word batch refuses as a whole when an earlier op invalidates a later target - "refused": true, "sent": 0, "stores": 0, "text": "red fox\n\nspacer" + "refused": false, "sent": 1, "stores": 1, "text": "blue fox\n\nspacer" FAIL > dependency that creates another occurrence cannot silently skip the second op - "text": "red fox and hen\n\nspacer" + "text": "red fox and red\n\nspacer" FAIL > retry exhaustion supplies a fresh read after the final known lost race AssertionError: expected 'red fox\n\nxxspacer' to be 'red fox\n\nxxxspacer' ``` Those cases are cells of `ServerDocsAgentEdits.test.ts` now. `UndoInvisibleSteps.test.ts` unpinned, on main: ``` $ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/UndoInvisibleSteps.test.ts × undoing a format on text a collaborator deleted takes the step before it too (1,2) × undoing a format on text a collaborator deleted takes the step before it too (2,1) × undoing a comment removal on deleted text takes the format change before it too AssertionError: expected 'one three X{0}[]' to be 'one three{0}[]' Tests 3 failed (3) ``` ### After ``` $ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/ServerDocsAgentEdits.test.ts packages/lexical-y/src/__tests__/unit/BlockHandles.test.ts packages/lexical-y/src/__tests__/unit/regressions/UndoInvisibleSteps.test.ts packages/lexical-html/src/__tests__/unit/BlockIdDocs.test.ts Tests 28 passed | 3 expected fail (31) $ LEXICAL_Y_GC=1 pnpm exec vitest run --project unit (same files) Tests 28 passed | 3 expected fail (31) $ LEXICAL_Y_GC=1 pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit packages/lexical-html/src/__tests__/unit/BlockIdDocs.test.ts Test Files 225 passed | 2 skipped (227) Tests 18549 passed | 306 expected fail | 130 skipped (18985) $ pnpm run ci-check Found 0 errors ``` With gc off, the same lexical-y run had one failure, `DisposeMemory.test.ts` ("40 editors bound, edited and disposed in a synchronous loop hold no document"), under 24 parallel workers; it passes 5 of 5 alone, and this change touches no code it runs (docs and tests only).
…dit on the current state, then apply it there ## Description y.md's "Edits made by code" told integrators to apply an agent's edit to the state it was computed from and merge the result into the live document. That always converges, but for an edit addressed by text or by block id it converges to text nobody meant whenever the document changed under the edit. Measured on `@lexical/y` with `seedYState`, `editYState` and `readYState` (15 cases: a person's or a second agent's edit lands between the agent's read and its edit): | Case | Merging the edit made on the read | Check, then apply to the current state | | --- | --- | --- | | a collaborator replaced the same words | `The slowswift red fox` | refused (words changed) | | a collaborator made the same change | `swiftswift` | refused | | two agents, different words | `at risblocked` | refused | | two agents, the same edit | `4.2.2%` | refused | | a collaborator typed elsewhere in the block | right | right | | the words now also appear in another block | right | right (checked in the read's block) | | the line was moved | new text where the line was; the moved line keeps the old text | right (block found by id) | | words cut and pasted elsewhere | `The swift redfox jumps.` | refused | | a block rewrite as a new node while a collaborator types in it | their text moved to the start | refused | | a block delete while a collaborator types in it | their sentence glued to the next line | refused | | a count outside the edited words | wrong | wrong (only the agent can catch it) | Merging was wrong in 10 of 15; the check was wrong in 1 and refused 2 edits that would have merged well. This is how text-matching edit tools behave (Anthropic's `str_replace` and OpenAI's `apply_patch` match the current text and fail on a miss; Claude Code refuses a write to a file changed since its read; the Google Docs API's `requiredRevisionId` and TipTap's server toolkit refuse a stale write so the caller re-reads). Now: - y.md, "Edits made by code": **Check what an edit names, then apply it to the current state**: a replacement checks its own words in the block where it read them, found there by id (still there, exactly once); a rewrite or a delete checks the whole block (where it is, and its type, text, formats, style, NodeState and children, compared serialized, not only its text); an insert checks its anchor block. Every edit of a batch is checked before any is applied, then again, in order, as it is applied on the state the earlier ones leave: an edit an earlier one changes (its words changed or now appear twice, its block deleted or rewritten, a block named twice) refuses the whole batch, a conservative rule. On a refusal, apply none, and return every failure (which edit, the reason, and the block's content in the latest stored state, not in the discarded edit; edits after an in-order failure are still checked) and a new read. On a pass, apply to the current state (blocks found by id there) and store with a compare-and-set; on a lost race, drop the state and the update computed on the older state and run the whole check and edit again on the newer one (bounded), or refuse back to the agent with a read of the state that won the last race: an update computed on an older state is never stored or sent. **Merge from the read only where there is nothing to check** (an append, a suggestion a person accepts, an edit by position): the fork-and-merge recipe stays for those cases. - y-server.md, "Edits by agents and tools" and "Edits by block id": the sketches check on the latest stored state and run `editYState` there, through `applyChecked` (the check; the edit, which checks each part again as it applies it and can refuse; the compare-and-set; up to 3 attempts, then a fresh load), instead of editing `seen` and merging. The word sketch takes a batch of replacements. `$findWords` finds a replacement's words by the block's id and requires them exactly once in that block's text, in one plain text node; `$blockAt` compares a target as a whole (its path and its serialized subtree); an answer by id is checked op by op and applied whole or not at all. "One edit at a time" says a checked edit starts over on a lost race instead of merging. `ServerDocsAgentEdits.test.ts` runs the sketches as written (they live in `__tests__/utils/agentEditSketch.ts`, and the first test checks that the page holds the same code), including targets changed since the read, a batch with one failing op, a batch whose first edit changes what the second (and third) names, refusals that report the stored content, and a store that loses the race. - block-ids.md, "Handles and collaboration": the handle is resolved and the whole block checked against what the model read on the stored state, and the edit is applied there by id with `applyChecked`; `BlockHandles.test.ts` follows it (a block the person typed in now goes back as `changed`). - DECISIONS.md: D94 (decided); D71 says D94 replaced which state the edit runs on. Also, from the reviews of #9685: - y.md, Servers: the per-client order paragraph now opens "Causal order includes each client's own order", and the json-joy comparison is dropped (y.md and D92). - D93 and y.md name the orders: one order when the comment ends at the rewritten word, both when it continues past it. - `regressions/UndoInvisibleSteps.test.ts` (from #9630) pins D81: the 3 cells that assert a press also taking the next step are `test.fails`. They fail on main with gc off and on. ## Test plan ### Before The new cells against weaker sketches: no uniqueness check, a target compared by its path and text only, and an update computed before a lost race merged into the newer state (the docs' previous recipe): ``` $ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/ServerDocsAgentEdits.test.ts × the sketches are the same in the docs and the tests 13ms × a replacement of words refuses words that appear twice in the block or across nodes, and finds the block read by id 10ms × step 3 refuses the whole answer when a target is gone, moved, replaced, typed in, formatted or styled since the read 42ms × a lost race starts over on the newer state: applied when the check still holds there, refused when not, never the older edit 20ms ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 4 ⎯⎯⎯⎯⎯⎯⎯ AssertionError: expected '// Your storage of one document: its …' to be '// Your storage of one document: its …' // Object.is equality - Expected + Received AssertionError: expected undefined to deeply equal [ { …(3) } ] - Expected: + Received: AssertionError: formatted: expected [] to deeply equal [ [ 1, 'the block changed' ] ] - Expected + Received ``` An independent review of an earlier head of this branch composed the word check per edit into a batch: each check passed on the stored state, the first edit invalidated the second, and the batch was stored half done; after 3 lost races the read returned was the one that had lost: ``` FAIL > dependent word batch refuses as a whole when an earlier op invalidates a later target - "refused": true, "sent": 0, "stores": 0, "text": "red fox\n\nspacer" + "refused": false, "sent": 1, "stores": 1, "text": "blue fox\n\nspacer" FAIL > dependency that creates another occurrence cannot silently skip the second op - "text": "red fox and hen\n\nspacer" + "text": "red fox and red\n\nspacer" FAIL > retry exhaustion supplies a fresh read after the final known lost race AssertionError: expected 'red fox\n\nxxspacer' to be 'red fox\n\nxxxspacer' ``` A second review found the refusal of such a batch reporting the block's text from the discarded edit (`blue fox and hen` where storage held `red fox and hen`), and only the first of two invalidated edits. Those cases are cells of `ServerDocsAgentEdits.test.ts` now. `UndoInvisibleSteps.test.ts` unpinned, on main: ``` $ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/UndoInvisibleSteps.test.ts × undoing a format on text a collaborator deleted takes the step before it too (1,2) × undoing a format on text a collaborator deleted takes the step before it too (2,1) × undoing a comment removal on deleted text takes the format change before it too AssertionError: expected 'one three X{0}[]' to be 'one three{0}[]' Tests 3 failed (3) ``` ### After ``` $ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/ServerDocsAgentEdits.test.ts packages/lexical-y/src/__tests__/unit/BlockHandles.test.ts packages/lexical-y/src/__tests__/unit/regressions/UndoInvisibleSteps.test.ts packages/lexical-html/src/__tests__/unit/BlockIdDocs.test.ts Tests 28 passed | 3 expected fail (31) $ LEXICAL_Y_GC=1 pnpm exec vitest run --project unit (same files) Tests 28 passed | 3 expected fail (31) $ LEXICAL_Y_GC=1 pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit packages/lexical-html/src/__tests__/unit/BlockIdDocs.test.ts Test Files 225 passed | 2 skipped (227) Tests 18549 passed | 306 expected fail | 130 skipped (18985) $ pnpm run ci-check Found 0 errors ``` With gc off, the same lexical-y run had one failure, `DisposeMemory.test.ts` ("40 editors bound, edited and disposed in a synchronous loop hold no document"), under 24 parallel workers; it passes 5 of 5 alone, and this change touches no code it runs (docs and tests only).
…dit on the current state, then apply it there ## Description y.md's "Edits made by code" told integrators to apply an agent's edit to the state it was computed from and merge the result into the live document. That always converges, but for an edit addressed by text or by block id it converges to text nobody meant whenever the document changed under the edit. Measured on `@lexical/y` with `seedYState`, `editYState` and `readYState` (15 cases: a person's or a second agent's edit lands between the agent's read and its edit): | Case | Merging the edit made on the read | Check, then apply to the current state | | --- | --- | --- | | a collaborator replaced the same words | `The slowswift red fox` | refused (words changed) | | a collaborator made the same change | `swiftswift` | refused | | two agents, different words | `at risblocked` | refused | | two agents, the same edit | `4.2.2%` | refused | | a collaborator typed elsewhere in the block | right | right | | the words now also appear in another block | right | right (checked in the read's block) | | the line was moved | new text where the line was; the moved line keeps the old text | right (block found by id) | | words cut and pasted elsewhere | `The swift redfox jumps.` | refused | | a block rewrite as a new node while a collaborator types in it | their text moved to the start | refused | | a block delete while a collaborator types in it | their sentence glued to the next line | refused | | a count outside the edited words | wrong | wrong (only the agent can catch it) | Merging was wrong in 10 of 15; the check was wrong in 1 and refused 2 edits that would have merged well. This is how text-matching edit tools behave (Anthropic's `str_replace` and OpenAI's `apply_patch` match the current text and fail on a miss; Claude Code refuses a write to a file changed since its read; the Google Docs API's `requiredRevisionId` and TipTap's server toolkit refuse a stale write so the caller re-reads). Now: - y.md, "Edits made by code": **Check what an edit names, then apply it to the current state**: a replacement checks its own words in the block where it read them, found there by id (still there, exactly once); a rewrite or a delete checks the whole block (where it is, and its type, text, formats, style, NodeState and children, compared serialized, not only its text, as the checking schema declares them: run the check on a schema that still declares every field stored versions may hold, or a change to a dropped field goes unnoticed); an insert checks its anchor block. Every edit of a batch is checked before any is applied, then again, in order, as it is applied on the state the earlier ones leave: an edit an earlier one changes (its words changed or now appear twice, its block deleted or rewritten, a block named twice) refuses the whole batch, a conservative rule. On a refusal, apply none, and return every failure (which edit, the reason, and the block's content in the latest stored state, not in the discarded edit; edits after an in-order failure are still checked) and a new read. On a pass, apply to the current state (blocks found by id there) and store with a compare-and-set; on a lost race, drop the state and the update computed on the older state and run the whole check and edit again on the newer one (bounded), or refuse back to the agent with a read of the state that won the last race: an update computed on an older state is never stored or sent. **Merge from the read only where there is nothing to check** (an append, a suggestion a person accepts, an edit by position): the fork-and-merge recipe stays for those cases. - y-server.md, "Edits by agents and tools" and "Edits by block id": the sketches check on the latest stored state and run `editYState` there, through `applyChecked` (the check; the edit, which checks each part again as it applies it and can refuse; the compare-and-set; up to 3 attempts, then a fresh load), instead of editing `seen` and merging. The word sketch takes a batch of replacements. `$findWords` finds a replacement's words by the block's id and requires them exactly once in that block's text, in one plain text node; `$blockAt` compares a target as a whole (its path and its serialized subtree); an answer by id is checked op by op and applied whole or not at all. "One edit at a time" says a checked edit starts over on a lost race instead of merging. `ServerDocsAgentEdits.test.ts` runs the sketches as written (they live in `__tests__/utils/agentEditSketch.ts`, and the first test checks that the page holds the same code), including targets changed since the read, a batch with one failing op, a batch whose first edit changes what the second (and third) names, refusals that report the stored content, and a store that loses the race. - block-ids.md, "Handles and collaboration": the handle is resolved and the whole block checked against what the model read on the stored state, and the edit is applied there by id with `applyChecked`; `BlockHandles.test.ts` follows it (a block the person typed in now goes back as `changed`). - DECISIONS.md: D94 (decided); D71 says D94 replaced which state the edit runs on. Also, from the reviews of #9685: - y.md, Servers: the per-client order paragraph now opens "Causal order includes each client's own order", and the json-joy comparison is dropped (y.md and D92). - D93 and y.md name the orders: one order when the comment ends at the rewritten word, both when it continues past it. - `regressions/UndoInvisibleSteps.test.ts` (from #9630) pins D81: the 3 cells that assert a press also taking the next step are `test.fails`. They fail on main with gc off and on. ## Test plan ### Before The new cells against weaker sketches: no uniqueness check, a target compared by its path and text only, and an update computed before a lost race merged into the newer state (the docs' previous recipe): ``` $ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/ServerDocsAgentEdits.test.ts × the sketches are the same in the docs and the tests 13ms × a replacement of words refuses words that appear twice in the block or across nodes, and finds the block read by id 10ms × step 3 refuses the whole answer when a target is gone, moved, replaced, typed in, formatted or styled since the read 42ms × a lost race starts over on the newer state: applied when the check still holds there, refused when not, never the older edit 20ms ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 4 ⎯⎯⎯⎯⎯⎯⎯ AssertionError: expected '// Your storage of one document: its …' to be '// Your storage of one document: its …' // Object.is equality - Expected + Received AssertionError: expected undefined to deeply equal [ { …(3) } ] - Expected: + Received: AssertionError: formatted: expected [] to deeply equal [ [ 1, 'the block changed' ] ] - Expected + Received ``` An independent review of an earlier head of this branch composed the word check per edit into a batch: each check passed on the stored state, the first edit invalidated the second, and the batch was stored half done; after 3 lost races the read returned was the one that had lost: ``` FAIL > dependent word batch refuses as a whole when an earlier op invalidates a later target - "refused": true, "sent": 0, "stores": 0, "text": "red fox\n\nspacer" + "refused": false, "sent": 1, "stores": 1, "text": "blue fox\n\nspacer" FAIL > dependency that creates another occurrence cannot silently skip the second op - "text": "red fox and hen\n\nspacer" + "text": "red fox and red\n\nspacer" FAIL > retry exhaustion supplies a fresh read after the final known lost race AssertionError: expected 'red fox\n\nxxspacer' to be 'red fox\n\nxxxspacer' ``` A second review found the refusal of such a batch reporting the block's text from the discarded edit (`blue fox and hen` where storage held `red fox and hen`), and only the first of two invalidated edits. Those cases are cells of `ServerDocsAgentEdits.test.ts` now, as is a third review's case, pinned as the documented scope: a field the server's schema dropped is not compared, so a rewrite passes over a collaborator's change to it. `UndoInvisibleSteps.test.ts` unpinned, on main: ``` $ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/UndoInvisibleSteps.test.ts × undoing a format on text a collaborator deleted takes the step before it too (1,2) × undoing a format on text a collaborator deleted takes the step before it too (2,1) × undoing a comment removal on deleted text takes the format change before it too AssertionError: expected 'one three X{0}[]' to be 'one three{0}[]' Tests 3 failed (3) ``` ### After ``` $ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/ServerDocsAgentEdits.test.ts packages/lexical-y/src/__tests__/unit/BlockHandles.test.ts packages/lexical-y/src/__tests__/unit/regressions/UndoInvisibleSteps.test.ts packages/lexical-html/src/__tests__/unit/BlockIdDocs.test.ts Tests 29 passed | 3 expected fail (32) $ LEXICAL_Y_GC=1 pnpm exec vitest run --project unit (same files) Tests 29 passed | 3 expected fail (32) $ LEXICAL_Y_GC=1 pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit packages/lexical-html/src/__tests__/unit/BlockIdDocs.test.ts Test Files 225 passed | 2 skipped (227) Tests 18549 passed | 306 expected fail | 130 skipped (18985) $ pnpm run ci-check Found 0 errors ``` With gc off, the same lexical-y run had one failure, `DisposeMemory.test.ts` ("40 editors bound, edited and disposed in a synchronous loop hold no document"), under 24 parallel workers; it passes 5 of 5 alone, and this change touches no code it runs (docs and tests only).
…dit on the current state, then apply it there ## Description y.md's "Edits made by code" told integrators to apply an agent's edit to the state it was computed from and merge the result into the live document. That always converges, but for an edit addressed by text or by block id it converges to text nobody meant whenever the document changed under the edit. Measured on `@lexical/y` with `seedYState`, `editYState` and `readYState` (15 cases: a person's or a second agent's edit lands between the agent's read and its edit): | Case | Merging the edit made on the read | Check, then apply to the current state | | --- | --- | --- | | a collaborator replaced the same words | `The slowswift red fox` | refused (words changed) | | a collaborator made the same change | `swiftswift` | refused | | two agents, different words | `at risblocked` | refused | | two agents, the same edit | `4.2.2%` | refused | | a collaborator typed elsewhere in the block | right | right | | the words now also appear in another block | right | right (checked in the read's block) | | the line was moved | new text where the line was; the moved line keeps the old text | right (block found by id) | | words cut and pasted elsewhere | `The swift redfox jumps.` | refused | | a block rewrite as a new node while a collaborator types in it | their text moved to the start | refused | | a block delete while a collaborator types in it | their sentence glued to the next line | refused | | a count outside the edited words | wrong | wrong (only the agent can catch it) | Merging was wrong in 10 of 15; the check was wrong in 1 and refused 2 edits that would have merged well. This is how text-matching edit tools behave (Anthropic's `str_replace` and OpenAI's `apply_patch` match the current text and fail on a miss; Claude Code refuses a write to a file changed since its read; the Google Docs API's `requiredRevisionId` and TipTap's server toolkit refuse a stale write so the caller re-reads). Now: - y.md, "Edits made by code": **Check what an edit names, then apply it to the current state**: a replacement checks its own words in the block where it read them, found there by id (still there, exactly once); a rewrite or a delete checks the whole block (where it is, and its type, text, formats, style, NodeState and children, compared serialized, not only its text, as the checking schema declares them: run the check on a schema that still declares every field stored versions may hold, or a change to a dropped field goes unnoticed); a format or a comment on words the edit names checks those words like a replacement and is applied where they are found on the current state, never by offsets from the read; an insert checks its anchor block. Every edit of a batch is checked before any is applied, then again, in order, as it is applied on the state the earlier ones leave: an edit an earlier one changes (its words changed or now appear twice, its block deleted or rewritten, a block named twice) refuses the whole batch, a conservative rule. On a refusal, apply none, and return every failure (which edit, the reason, and the block's content in the latest stored state, not in the discarded edit; edits after an in-order failure are still checked) and a new read. On a pass, apply to the current state (blocks found by id there) and store with a compare-and-set; on a lost race, drop the state and the update computed on the older state and run the whole check and edit again on the newer one (bounded), or refuse back to the agent with a read of the state that won the last race: an update computed on an older state is never stored or sent. **Merge from the read only where there is nothing to check** (an append, a suggestion a person accepts, an edit by position): the fork-and-merge recipe stays for those cases. - y-server.md, "Edits by agents and tools" and "Edits by block id": the sketches check on the latest stored state and run `editYState` there, through `applyChecked` (the check; the edit, which checks each part again as it applies it and can refuse; the compare-and-set; up to 3 attempts, then a fresh load), instead of editing `seen` and merging. The word sketch takes a batch of replacements. `$findWords` finds a replacement's words by the block's id and requires them exactly once in that block's text, in one plain text node; `$blockAt` compares a target as a whole (its path and its serialized subtree); an answer by id is checked op by op and applied whole or not at all. "One edit at a time" says a checked edit starts over on a lost race instead of merging. `ServerDocsAgentEdits.test.ts` runs the sketches as written (they live in `__tests__/utils/agentEditSketch.ts`, and the first test checks that the page holds the same code), including targets changed since the read, a batch with one failing op, a batch whose first edit changes what the second (and third) names, refusals that report the stored content, a format on named words applied where they are now, and a store that loses the race. - block-ids.md, "Handles and collaboration": the handle is resolved and the whole block checked against what the model read on the stored state, and the edit is applied there by id with `applyChecked`; `BlockHandles.test.ts` follows it (a block the person typed in now goes back as `changed`). - DECISIONS.md: D94 (decided); D71 says D94 replaced which state the edit runs on. Also, from the reviews of #9685: - y.md, Servers: the per-client order paragraph now opens "Causal order includes each client's own order", and the json-joy comparison is dropped (y.md and D92). - D93 and y.md name the orders: one order when the comment ends at the rewritten word, both when it continues past it. - `regressions/UndoInvisibleSteps.test.ts` (from #9630) pins D81: the 3 cells that assert a press also taking the next step are `test.fails`. They fail on main with gc off and on. ## Test plan ### Before The new cells against weaker sketches: no uniqueness check, a target compared by its path and text only, and an update computed before a lost race merged into the newer state (the docs' previous recipe): ``` $ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/ServerDocsAgentEdits.test.ts × the sketches are the same in the docs and the tests 13ms × a replacement of words refuses words that appear twice in the block or across nodes, and finds the block read by id 10ms × step 3 refuses the whole answer when a target is gone, moved, replaced, typed in, formatted or styled since the read 42ms × a lost race starts over on the newer state: applied when the check still holds there, refused when not, never the older edit 20ms ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 4 ⎯⎯⎯⎯⎯⎯⎯ AssertionError: expected '// Your storage of one document: its …' to be '// Your storage of one document: its …' // Object.is equality - Expected + Received AssertionError: expected undefined to deeply equal [ { …(3) } ] - Expected: + Received: AssertionError: formatted: expected [] to deeply equal [ [ 1, 'the block changed' ] ] - Expected + Received ``` An independent review of an earlier head of this branch composed the word check per edit into a batch: each check passed on the stored state, the first edit invalidated the second, and the batch was stored half done; after 3 lost races the read returned was the one that had lost: ``` FAIL > dependent word batch refuses as a whole when an earlier op invalidates a later target - "refused": true, "sent": 0, "stores": 0, "text": "red fox\n\nspacer" + "refused": false, "sent": 1, "stores": 1, "text": "blue fox\n\nspacer" FAIL > dependency that creates another occurrence cannot silently skip the second op - "text": "red fox and hen\n\nspacer" + "text": "red fox and red\n\nspacer" FAIL > retry exhaustion supplies a fresh read after the final known lost race AssertionError: expected 'red fox\n\nxxspacer' to be 'red fox\n\nxxxspacer' ``` A second review found the refusal of such a batch reporting the block's text from the discarded edit (`blue fox and hen` where storage held `red fox and hen`), and only the first of two invalidated edits. Those cases are cells of `ServerDocsAgentEdits.test.ts` now, as is a third review's case, pinned as the documented scope: a field the server's schema dropped is not compared, so a rewrite passes over a collaborator's change to it. `UndoInvisibleSteps.test.ts` unpinned, on main: ``` $ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/UndoInvisibleSteps.test.ts × undoing a format on text a collaborator deleted takes the step before it too (1,2) × undoing a format on text a collaborator deleted takes the step before it too (2,1) × undoing a comment removal on deleted text takes the format change before it too AssertionError: expected 'one three X{0}[]' to be 'one three{0}[]' Tests 3 failed (3) ``` ### After ``` $ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/ServerDocsAgentEdits.test.ts packages/lexical-y/src/__tests__/unit/BlockHandles.test.ts packages/lexical-y/src/__tests__/unit/regressions/UndoInvisibleSteps.test.ts packages/lexical-html/src/__tests__/unit/BlockIdDocs.test.ts Tests 30 passed | 3 expected fail (33) $ LEXICAL_Y_GC=1 pnpm exec vitest run --project unit (same files) Tests 30 passed | 3 expected fail (33) $ LEXICAL_Y_GC=1 pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit packages/lexical-html/src/__tests__/unit/BlockIdDocs.test.ts Test Files 225 passed | 2 skipped (227) Tests 18549 passed | 306 expected fail | 130 skipped (18985) $ pnpm run ci-check Found 0 errors ``` With gc off, the same lexical-y run had one failure, `DisposeMemory.test.ts` ("40 editors bound, edited and disposed in a synchronous loop hold no document"), under 24 parallel workers; it passes 5 of 5 alone, and this change touches no code it runs (docs and tests only).
…9694 (an editor stopped matching its own document in long sessions) Reverts #9679 whole, on top of current `main` (keeps #9684 and #9685). With #9679, a downstream app's long-note sessions end with an editor that no longer matches its own Yjs document (15 of 133 sessions; 0 of 132 on the commit before #9679). Reverting only part of it does not work: the delete rule (D86) alone is worse on a recorded session (2810), and the divergence was traced to one of its hunks (the end a restated run gets at a range's end, which could be linked into another container; #9693 fixes that at the source). This PR is the fallback if #9693 is not taken. Closes #9694 if merged instead of #9693. What this brings back, as before #9679: - A delete collapses the run of deleted content and format items it leaves to a bounded number per format key (D24's bound): the cases it fixed (#9575, #9609, a #9620 cell, #9640's concurrent restore cells) fail again and are pinned with their issues. - No format items kept from garbage collection: with gc on, an undo can again put a value back past its end (#9623). The harness keeps its `LEXICAL_Y_GC` switch (added by #9679), and the cells that fail only with gc on are pinned as `test.fails` with gc on, naming #9623. - The restore rules for #9671 and the restore scan for #9599 (D78) are gone: those cases are open again. `DECISIONS.md` keeps D86 and D88 (now `REVERTED`, with the reason) and reopens D78, rather than rewriting them. `y.md` goes back to the behavior before #9679. The two changesets of #9679 are removed: it was not part of a release, so there is nothing to announce. `UndoSpreadSession2810.test.ts` (added by #9679) is kept: it passes without D86 and D88 as well. `main` 60022ae: the downstream app's long-note sessions diverge in 15 of 133. ``` $ LEXICAL_Y_GC=0 vitest --run --project unit Test Files 1 failed | 648 passed | 4 skipped (653) Tests 1 failed | 28071 passed | 481 expected fail | 135 skipped (28688) $ LEXICAL_Y_GC=1 vitest --run --project unit Test Files 2 failed | 647 passed | 4 skipped (653) Tests 2 failed | 28055 passed | 496 expected fail | 135 skipped (28688) $ pnpm run ci-check exit 0 ``` The failures are load flakes with 40 workers: `DisposeMemory.test.ts` (heap bound; gc off and on) and `MarkdownEscaping.test.ts` (5 s timeout, gc on). Each passes alone (two runs each).
Description
Records five maintainer decisions of 10/31 in DECISIONS.md and y.md:
only restores or removes format items no reader shows, and several such
steps take several presses, as with Yjs's
UndoManagerin any editorthat uses it as it is. Skipping such steps ([lexical-y][lexical-website] Bug Fix: Undo behaviour change: every undo or redo press changes what the document shows, or empties its stack #9630, closed)
needed a per-press digest of what readers show. y.md: a sentence in
Limits, Undo.
that the binding reads again, the author can show a derived list number
from before them until that list is refreshed (adding an item does; an
edit elsewhere or retyping an item does not); its fresh read, other
clients and a reload are right. The stop on the fourth re-read is
the backstop that ends reply loops ([lexical-y] Bug Fix: Keep list numbering right after several follow-ups in a row that are read again #9634, closed, showed the three
requirements conflict). y.md: a Limits entry. The [lexical-y] Chore: Pin #9632 and the follow-up reply loops in regression tests #9642 pins
(
RefusedFollowUpRefresh,RefusedFollowUpFreshness) now name D90 andthe limit; they stay
test.fails.writers can cancel, because a Yjs text gives each place one value per
key (Syncing text with overlapping formatting of the same type results in a lost information yjs/yjs#382); since [lexical][lexical-clipboard][lexical-website][lexical-y] Bug Fix: Keep a link a collaborator removes removed when another collaborator formats text covering it #9601 this also reaches a link's edge
and first word. [lexical][lexical-clipboard][lexical-website][lexical-y] Bug Fix: Keep a link a collaborator removes removed when another collaborator formats text covering it #9601 is kept. y.md: Other races says why.
rewrites at the same moment can land on the new word (
setTextContent:one client id order; a block rewrite: one or both orders), the same race
as text typed at the start of a range a collaborator formats. y.md: a
sentence in Other races. New
regressions/CommentOnRewrittenWord.test.tspins the 9 cells where it happens as
test.fails(12 block-rewrite cellsand 2
setTextContentcells); Bug: [lexical-y] A comment on a word that a collaborator rewrites at the same time lands on the new word for one client id order #9610 stays open. D77 (the open question)says D93 replaced it.
a single relay that forwards every update to each client in the one
order it received them does; a mesh or several relays need causal buffering that
tracks dependencies, deletions included. y.md: Servers states
the requirement. Bug: [lexical-y] With updates delivered out of causal order, a client's edit makes a collaborator's format spread permanently onto the text after it #9645 stays open.
Also, from the review of #9684: D74 and D64 link #9681 and #9682, and
y.md's Open issues lists #9681 and #9683.
The agent-edit guidance is unchanged.
Test plan
Before
After
The two RefusedFollowUp files change only in comments; the same cells pass
and fail as on main. With every
test.failsin CommentOnRewrittenWordturned into
test, exactly the 9 pinned cells fail (gc off and on).