Conversation
updateNodeOnLoroEvent replaced the whole document and only restored the selection from a setTimeout callback. Until that timer fired the caret sat at the end of the document, and a timer scheduled for an earlier event could overwrite a newer local selection. Set the selection on the replacing transaction itself, guarded against out-of-range cursor positions.
Every local transaction reconciled the whole ProseMirror tree against the Loro tree. For the paragraph being edited that means materializing its LoroText with toDelta()/toString() on every keystroke, which is proportional to the paragraph's accumulated formatting history: a paragraph with a few hundred historical mark ops took 100-200 ms per key. Handle the common case up front: a single closed ReplaceStep whose inserted content is text-only, inside a text-only textblock whose ancestors all still map to their Loro containers. Apply that replacement straight to the mapped LoroText with applyDelta, carrying the inserted text's marks, and refresh the mappings of the changed ancestors. Anything less regular falls back to the existing reconciliation unchanged. The fallback now shares one toDelta() per LoroText across the equality checks and the eventual update of a single reconciliation pass, and invalidates that cache before each mutation. Also bump the loro-crdt devDependency to 1.12.1: 1.10.2 mis-applies a retain that crosses a surrogate pair in applyDelta, which the new UTF-16 test exercises.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #85: the first commit is the selection fix from that PR, only the second commit is new here.
Problem
Every local transaction reconciles the whole ProseMirror tree against the Loro tree. For the paragraph being edited that means
toDelta()/toString()on its LoroText on every keystroke, and both are proportional to the paragraph's accumulated formatting history rather than its length. A real 667-character paragraph with ~1,700 historical mark ops took 175–200 ms per keystroke in an offline reproduction (Chrome input latency 205–239 ms), almost all of it in tentoDeltacalls.0.4.4 (#82) already stopped emitting redundant style ops, which keeps that history from growing further, but every keystroke still materializes the full delta.
Change
Fast path. When the transaction is a single closed
ReplaceStepwhose inserted content is text-only, inside a text-only textblock whose ancestors still map to their Loro containers, apply the replacement straight to the mapped LoroText with oneapplyDelta(delete + insert with the inserted text's marks, including explicitnullfor marks present in the block but absent on the insert). Mappings of the changed ancestors are refreshed and the LoroText keeps its identity. Anything less regular (structure changes, multiple steps, unmapped nodes, open slices, non-text content) falls back to the existing reconciliation unchanged. The step type is checked viastep.toJSON().stepTyperather thaninstanceof, since apps can end up with two copies of prosemirror-transform.Delta cache for the fallback. One reconciliation pass previously called
toDelta()on the same LoroText fromeqLoroTextNodes,computeChildEqualityFactorandupdateLoroText. The cache is created perupdateLoroToPmStatecall, passed explicitly, and invalidated before every mutation of a text, so nothing survives across transactions.loro-crdt devDependency 1.10.2 → 1.12.1. 1.10.2 mis-applies a
retainthat crosses a surrogate pair inapplyDelta([{retain: 3}, {retain: 4, attributes}]ona😀boldzmarksboldz). The new UTF-16 test hits this; it is unrelated to this change and reproduces with plain loro-crdt. The peer range already allows 1.12.Results
Synthetic fixture with 600 legacy mark ops: 20 plain keystrokes after a bold phrase make zero
toDeltacalls and add zero mark ops (220 calls before). On the real document above: ~8 ms for the first edit, then 0.3–1.2 ms.Tests
tests/loro-typing-history.test.ts: imported legacy history stays stable while typing a plain tail (zerotoDeltacalls, zero new mark ops), UTF-16 replacement at mark boundaries, mark-only and link changes, a closed composition ReplaceStep stays on the direct path, multi-step fallback materializes each edited LoroText once.tests/loro-typing-collaboration.test.ts: undo/redo and subsequent typing keep the existing LoroText, two editors converge after concurrent text and mark edits, duplicate paragraphs keep their own LoroText across local and remote changes, composition-tagged multi-step edits fall back and ordinary typing resumes the fast path.pnpm lint,pnpm test(22 tests) andpnpm buildpass.We have been running this in production in atomic-server as a pnpm patch and would love to drop the patch.