Skip to content

Replace LWW inline styling with native CRDT operations#151

Merged
hackerwins merged 7 commits into
mainfrom
feat/native-inline-style
Apr 23, 2026
Merged

Replace LWW inline styling with native CRDT operations#151
hackerwins merged 7 commits into
mainfrom
feat/native-inline-style

Conversation

@hackerwins

@hackerwins hackerwins commented Apr 23, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Replace YorkieDocStore.applyStyle() from LWW block replacement to native CRDT splitLevel=1 + styleByPath
  • Concurrent text edits in the same block are now preserved during styling (previously lost to last-writer-wins)
  • Add comprehensive unit tests (8 cases) and concurrent integration tests (6 scenarios)
  • Update design doc (docs-intent-preserving-edits.md) Phase 2 status

Test plan

  • Unit tests: applyStyle 8 cases (middle, start, end, entire, multi-inline, after insert, toggle-off, styled insert)
  • Concurrent integration tests: text insert+style, non-overlapping styles, overlapping styles, text delete+style, style+block split
  • pnpm verify:fast passes
  • Concurrent tests converge on both clients

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • Improvements
    • Refactored inline text styling mechanism to use native CRDT-based operations. Inline formatting (bold, italic) now maintains proper consistency and correctly converges when multiple users make concurrent edits to the same document, delivering improved collaborative editing reliability with better synchronization and reduced formatting conflicts.

hackerwins and others added 6 commits April 23, 2026 09:22
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Use splitLevel=1 to split inline nodes at style boundaries and
styleByPath to apply attributes. This preserves concurrent text
edits that were previously lost to last-writer-wins block replacement.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Phase 2 now uses splitLevel=1 + styleByPath instead of LWW block
replacement. Remove the styleByPath limitation note since SDK 0.7.6
supports the split+style pattern for inline styling.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Cover: text insert+style, non-overlapping styles, overlapping styles,
text delete+style, and style+block split convergence.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Superseded by YorkieDocStore-level concurrent tests in
yorkie-doc-store-concurrent.integration.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@coderabbitai

coderabbitai Bot commented Apr 23, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@hackerwins has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 53 minutes and 12 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 53 minutes and 12 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5ee63913-f8e6-4aeb-be67-a5b91b8498ba

📥 Commits

Reviewing files that changed from the base of the PR and between cedd6db and d6c9970.

📒 Files selected for processing (4)
  • docs/tasks/README.md
  • docs/tasks/archive/20260423-inline-style-tests-todo.md
  • docs/tasks/archive/20260423-native-inline-style-todo.md
  • docs/tasks/archive/README.md
📝 Walkthrough

Walkthrough

The pull request implements native CRDT inline styling in YorkieDocStore by replacing LWW-based block replacement with a split+style strategy using Yorkie SDK 0.7.6's editByPath and styleByPath operations. Character offsets are resolved to tree paths, the range is isolated via splits at boundary offsets, and styles are applied to contained inline nodes before cleanup.

Changes

Cohort / File(s) Summary
Documentation & Design
docs/design/docs/docs-intent-preserving-edits.md, docs/tasks/active/20260423-native-inline-style-todo.md
Updated design documentation to specify native CRDT inline styling via split+style operations; added detailed implementation plan with test coverage strategy and phase tracking for SDK 0.7.6 approach.
Core Implementation
packages/frontend/src/app/docs/yorkie-doc-store.ts
Rewrote applyStyle method to resolve character offsets to tree paths, split the range at toOffset then fromOffset using editByPath(splitLevel=1), apply attributes via styleByPath to contained inlines, and remove empty nodes created by splits.
Unit Tests
packages/frontend/tests/app/docs/yorkie-doc-store.test.ts
Added comprehensive test suite for applyStyle covering range boundaries (start, middle, end, whole block), multi-inline scenarios, style inheritance after insert, and idempotent toggle behavior.
Concurrent Integration Tests
packages/frontend/tests/app/docs/yorkie-doc-store-concurrent.integration.ts
Added concurrent integration test suite with helper for canonical document representation; tests two clients applying overlapping inline styles and content edits, asserting convergence and inline structure consistency.

Sequence Diagram

sequenceDiagram
    participant Client as Client Code
    participant Store as YorkieDocStore
    participant Tree as Yorkie Tree
    
    Client->>Store: applyStyle(blockId, fromOffset, toOffset, attrs)
    
    Store->>Tree: Resolve blockId to block path
    Store->>Tree: Query inline nodes in range
    
    Store->>Tree: editByPath(toOffset, splitLevel=1)
    Note over Tree: Split at toOffset
    Tree-->>Store: Path updated
    
    Store->>Tree: editByPath(fromOffset, splitLevel=1)
    Note over Tree: Split at fromOffset
    Tree-->>Store: Range isolated
    
    Store->>Tree: styleByPath(inline1, attrs)
    Store->>Tree: styleByPath(inline2, attrs)
    Note over Tree: Apply to contained inlines
    
    Store->>Tree: Remove empty inlines
    Tree-->>Store: Cleanup complete
    
    Store-->>Client: Style applied
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly Related PRs

Poem

🐰 Hops with glee!

No more blocks replaced in haste,
Native splits now save the day—
Inline styles, born anew, won't waste,
Tree paths guide the CRDT way.
Bold and italic, unified at last! 🎨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title 'Replace LWW inline styling with native CRDT operations' directly and clearly summarizes the main change: transitioning from last-writer-wins block replacement to native CRDT operations using split+style approach.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/native-inline-style

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

github-actions Bot commented Apr 23, 2026

Copy link
Copy Markdown
Contributor

Verification: verify:self

Result: ✅ PASS in 115.0s

Lane Status Duration
sheets:build ✅ pass 12.6s
docs:build ✅ pass 7.7s
verify:fast ✅ pass 57.4s
frontend:build ✅ pass 15.0s
verify:frontend:chunks ✅ pass 0.3s
backend:build ✅ pass 4.6s
cli:build ✅ pass 1.7s
verify:entropy ✅ pass 15.7s

Verification: verify:integration

Result: ✅ PASS

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/frontend/src/app/docs/yorkie-doc-store.ts (1)

819-927: ⚠️ Potential issue | 🟡 Minor

Guard zero-width ranges to avoid spurious inline splits + cache/tree divergence.

When fromOffset === toOffset and the offset lands strictly inside an inline (e.g., fromOffset = toOffset = 3 on 'Hello'), the toOffset split at Line 843 fires because 0 < charOffset < inlineTextLen is true. The zero-width guard at Line 883 then correctly skips the styling loop, and the cleanup at Line 915 only removes fully empty inlines, so the extra split persists in the CRDT tree. Meanwhile the cache is rebuilt via applyInlineStyleHelper, which for a zero-width range typically produces the unsplit block — so cachedDoc and the underlying tree diverge until the next remote change invalidates the cache.

An early return at the top of applyStyle would avoid both the spurious CRDT op and the divergence:

🛡️ Proposed early exit
   applyStyle(
     blockId: string,
     fromOffset: number,
     toOffset: number,
     style: Partial<InlineStyle>,
   ): void {
+    if (fromOffset >= toOffset) return;
     const currentDoc = this.getDocument();

This also lets you drop the zero-width branch at Lines 883-886.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/frontend/src/app/docs/yorkie-doc-store.ts` around lines 819 - 927,
The code performs splits even for zero-width ranges, causing spurious CRDT edits
and cache/tree divergence; add an early return at the start of the applyStyle
(or caller that receives fromOffset and toOffset, e.g., applyInlineStyleHelper
invocation) that checks if fromOffset === toOffset and returns without mutating
the tree or cache. Ensure the guard runs before executing the toOffset split
logic (the block that calculates toPos/toInline and calls tree.editByPath) so no
editByPath is invoked for zero-width ranges, and remove or keep the existing
zero-width branch in the loop as desired.
🧹 Nitpick comments (1)
packages/frontend/tests/app/docs/yorkie-doc-store.test.ts (1)

431-546: Consider one tree-level readback test + a zero-width case.

All 8 cases assert via getBlock/getDocument, which returns cachedDoc populated from applyInlineStyleHelper (not by re-parsing the Yorkie tree). That means cache/tree divergence (e.g., the zero-width split side-effect noted on yorkie-doc-store.ts) can't be detected here.

Recommended additions (mirrors the pattern already used by should preserve bold attr in Yorkie Tree after split (not just cache) at lines 600-618):

it('should persist bold attr on the Yorkie Tree after applyStyle (not just cache)', () => {
  const block = makeBlock('HelloWorld');
  store.setDocument({ blocks: [block] });
  store.applyStyle(block.id, 3, 8, { bold: true });
  const tree = doc.getRoot().content;
  const blockNode = tree.getRootTreeNode().children[0];
  const boldInlines = blockNode.children.filter(
    (c: { attributes?: Record<string, string> }) => c.attributes?.bold === 'true',
  );
  assert.equal(boldInlines.length, 1);
});

it('should be a no-op for a zero-width range', () => {
  const block = makeBlock('Hello');
  store.setDocument({ blocks: [block] });
  store.applyStyle(block.id, 3, 3, { bold: true });
  // Invalidate cache and re-parse from tree to catch cache/tree divergence
  // `@ts-expect-error` accessing private field for test
  store.dirty = true;
  // `@ts-expect-error` accessing private field for test
  store.cachedDoc = null;
  const result = store.getBlock(block.id)!;
  assert.equal(result.inlines.length, 1);
  assert.equal(result.inlines[0].text, 'Hello');
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/frontend/tests/app/docs/yorkie-doc-store.test.ts` around lines 431 -
546, Add two tests to detect cache/tree divergence after applyStyle: (1) a
Yorkie-tree-level assertion that after store.applyStyle(block.id, 3, 8, { bold:
true }) the underlying doc tree contains exactly one inline with attributes.bold
=== 'true' (use doc.getRoot().content.getRootTreeNode().children to locate block
node and inspect children attributes), and (2) a zero-width no-op test that
calls store.applyStyle(block.id, 3, 3, { bold: true }), then force reparse by
setting store.dirty = true and store.cachedDoc = null before calling
store.getBlock(block.id) and asserting the block remains a single inline with
text 'Hello'; these mirror the existing pattern used in the other tree-level
test and will catch the split/caching bug in applyInlineStyleHelper/applyStyle
in yorkie-doc-store.ts.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/tasks/active/20260423-native-inline-style-todo.md`:
- Around line 222-260: The snippet contains a dead first loop that computes
startIdx/endIdx over inlines and is immediately discarded when both are reset to
-1; remove that first loop (the block that computes pos, startIdx, endIdx up to
the "Fallback" lines) or replace it with the single-loop shipped algorithm that
walks inlines once to derive startIdx and endIdx based on fromOffset and
toOffset; ensure the implementation consistently uses the same logic over
inlines, fromOffset, toOffset and only computes startIdx/endIdx once (refer to
the startIdx/endIdx/inlines variables in the diff).

---

Outside diff comments:
In `@packages/frontend/src/app/docs/yorkie-doc-store.ts`:
- Around line 819-927: The code performs splits even for zero-width ranges,
causing spurious CRDT edits and cache/tree divergence; add an early return at
the start of the applyStyle (or caller that receives fromOffset and toOffset,
e.g., applyInlineStyleHelper invocation) that checks if fromOffset === toOffset
and returns without mutating the tree or cache. Ensure the guard runs before
executing the toOffset split logic (the block that calculates toPos/toInline and
calls tree.editByPath) so no editByPath is invoked for zero-width ranges, and
remove or keep the existing zero-width branch in the loop as desired.

---

Nitpick comments:
In `@packages/frontend/tests/app/docs/yorkie-doc-store.test.ts`:
- Around line 431-546: Add two tests to detect cache/tree divergence after
applyStyle: (1) a Yorkie-tree-level assertion that after
store.applyStyle(block.id, 3, 8, { bold: true }) the underlying doc tree
contains exactly one inline with attributes.bold === 'true' (use
doc.getRoot().content.getRootTreeNode().children to locate block node and
inspect children attributes), and (2) a zero-width no-op test that calls
store.applyStyle(block.id, 3, 3, { bold: true }), then force reparse by setting
store.dirty = true and store.cachedDoc = null before calling
store.getBlock(block.id) and asserting the block remains a single inline with
text 'Hello'; these mirror the existing pattern used in the other tree-level
test and will catch the split/caching bug in applyInlineStyleHelper/applyStyle
in yorkie-doc-store.ts.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 18013d6d-601f-4934-ba56-77c4beb1061c

📥 Commits

Reviewing files that changed from the base of the PR and between c46652e and cedd6db.

📒 Files selected for processing (5)
  • docs/design/docs/docs-intent-preserving-edits.md
  • docs/tasks/active/20260423-native-inline-style-todo.md
  • packages/frontend/src/app/docs/yorkie-doc-store.ts
  • packages/frontend/tests/app/docs/yorkie-doc-store-concurrent.integration.ts
  • packages/frontend/tests/app/docs/yorkie-doc-store.test.ts

Comment on lines +222 to +260
// Walk inlines to find the range matching [fromOffset, toOffset)
let pos = 0;
let startIdx = -1;
let endIdx = -1;
for (let i = 0; i < inlines.length; i++) {
const len = (inlines[i].children ?? [])
.filter((c): c is { type: 'text'; value: string } => c.type === 'text')
.reduce((s, t) => s + t.value.length, 0);
if (startIdx === -1 && pos + len > fromOffset) {
startIdx = i;
}
if (pos + len <= toOffset && pos >= fromOffset) {
endIdx = i;
} else if (startIdx !== -1 && pos < toOffset) {
endIdx = i;
}
pos += len;
}

// Fallback: if range covers from start
if (startIdx === -1) startIdx = 0;
if (endIdx === -1) endIdx = startIdx;

// Walk inlines: find first that starts at fromOffset, last that ends at toOffset
pos = 0;
startIdx = -1;
endIdx = -1;
for (let i = 0; i < inlines.length; i++) {
const len = (inlines[i].children ?? [])
.filter((c): c is { type: 'text'; value: string } => c.type === 'text')
.reduce((s, t) => s + t.value.length, 0);
if (pos >= fromOffset && pos + len <= toOffset) {
if (startIdx === -1) startIdx = i;
endIdx = i;
}
pos += len;
}
if (startIdx === -1) startIdx = 0;
if (endIdx === -1) endIdx = startIdx;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Plan-doc snippet contains a dead first loop that contradicts the shipped implementation.

Lines 222-243 compute startIdx/endIdx and then Lines 246-260 immediately reset both to -1 and recompute with a different algorithm, so the first loop is dead code within the snippet. The actual shipped code in packages/frontend/src/app/docs/yorkie-doc-store.ts (Lines 875-897) uses a single, different algorithm. Either drop the first loop from the snippet or replace the block with the shipped logic to avoid misleading future readers who use this plan as reference.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/tasks/active/20260423-native-inline-style-todo.md` around lines 222 -
260, The snippet contains a dead first loop that computes startIdx/endIdx over
inlines and is immediately discarded when both are reset to -1; remove that
first loop (the block that computes pos, startIdx, endIdx up to the "Fallback"
lines) or replace it with the single-loop shipped algorithm that walks inlines
once to derive startIdx and endIdx based on fromOffset and toOffset; ensure the
implementation consistently uses the same logic over inlines, fromOffset,
toOffset and only computes startIdx/endIdx once (refer to the
startIdx/endIdx/inlines variables in the diff).

@codecov

codecov Bot commented Apr 23, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@hackerwins
hackerwins merged commit 9477100 into main Apr 23, 2026
4 checks passed
@hackerwins
hackerwins deleted the feat/native-inline-style branch April 23, 2026 00:56
@hackerwins hackerwins mentioned this pull request May 1, 2026
3 tasks
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