Skip to content

fix: only emit style ops for ranges where marks actually changed - #82

Open
frioux wants to merge 1 commit into
loro-dev:mainfrom
frioux:minimal-style-ops
Open

fix: only emit style ops for ranges where marks actually changed#82
frioux wants to merge 1 commit into
loro-dev:mainfrom
frioux:minimal-style-ops

Conversation

@frioux

@frioux frioux commented Jul 31, 2026

Copy link
Copy Markdown

Summary

On any text change — a single typed character — updateLoroText re-applies every run's mark attributes over the entire text length via applyDelta, including explicit null unmarks over every plain range for every mark key that appears anywhere in the text.

Loro records each of those mark/unmark ops even when they don't change the visible styles (and it can't do otherwise: a redundant-looking unmark still has meaning under concurrent editing). So a keystroke of styled typing produced 3 CRDT ops instead of 1, and every one of those redundant style ops leaves a permanent style anchor in the container state that every later styled read (toDelta) has to pay for. On heavily edited paragraphs we measured styled reads degrading from ~5µs to multiple milliseconds — felt as per-keystroke typing lag — plus snapshots bloated by the accumulated anchors (135KB for 4KB of visible text on a real page).

Fix

After the (unchanged) minimal text diff, diff the styles the LoroText now holds against the ones ProseMirror wants, run by run, and apply marks only over the ranges that differ:

  • typing, deleting, and plain pastes emit no style ops at all — inserts already inherit the styles configured through configTextStyle;
  • genuine mark changes (toggle, autolink, ...) emit ops covering just the changed ranges, with unmarks only where the mark being removed was actually present.

Validation

Added tests/update-loro-text.test.ts, which asserts the exact op stream via exportJsonUpdates:

  • typing at the end of a styled run → a single insert op
  • deleting styled text → a single delete op
  • unchanged text → no ops
  • removing a mark → one unmark op over just the range that had it
  • marks the text diff can't carry over (e.g. an expand-none mark extended by PM) still get repaired, over just the inserted range

pnpm test, pnpm lint, and pnpm check-format all pass.

Complementary to #81: that PR stops PM and Loro from disagreeing about default-inclusive mark boundaries; this one stops the binding from emitting redundant style ops whether or not they disagree. Either merges cleanly without the other.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KQw7nfh6KaUhQy6ZQUZwMm

updateLoroText re-applied every run's mark attributes over the entire
text on every change, including explicit null unmarks for every mark
key that appeared anywhere in the text. Loro records each of those
mark/unmark ops even when they don't change the visible styles (they
still carry meaning under concurrent editing), so a single keystroke of
plain typing grew the document by two style ops, and the style anchors
they leave behind permanently slow down every styled read of the
container.

Diff the styles the LoroText holds after the text edit against the ones
ProseMirror wants and only apply marks over the ranges that differ.
Typing, deleting, and plain pastes now emit no style ops at all; mark
changes emit ops covering just the changed ranges.
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