Skip to content

perf: apply single-step inline edits directly to the existing LoroText - #86

Open
joepio wants to merge 2 commits into
loro-dev:mainfrom
joepio:perf/inline-typing-fast-path
Open

joepio wants to merge 2 commits into
loro-dev:mainfrom
joepio:perf/inline-typing-fast-path

Conversation

@joepio

@joepio joepio commented Sep 15, 2026

Copy link
Copy Markdown

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 ten toDelta calls.

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 ReplaceStep whose 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 one applyDelta (delete + insert with the inserted text's marks, including explicit null for 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 via step.toJSON().stepType rather than instanceof, 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 from eqLoroTextNodes, computeChildEqualityFactor and updateLoroText. The cache is created per updateLoroToPmState call, 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 retain that crosses a surrogate pair in applyDelta ([{retain: 3}, {retain: 4, attributes}] on a😀boldz marks boldz). 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 toDelta calls 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 (zero toDelta calls, 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) and pnpm build pass.

We have been running this in production in atomic-server as a pnpm patch and would love to drop the patch.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant