Skip to content

Use granular tree edits for header/footer blocks#141

Merged
hackerwins merged 6 commits into
mainfrom
header-footer-granular-edits
Apr 19, 2026
Merged

Use granular tree edits for header/footer blocks#141
hackerwins merged 6 commits into
mainfrom
header-footer-granular-edits

Conversation

@hackerwins

@hackerwins hackerwins commented Apr 19, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Header/footer block mutations (insertText, deleteText, updateBlock, applyStyle, deleteBlock, splitBlock, mergeBlock) now use character-level editByPath instead of writeFullDocument
  • setHeader/setFooter now insert/replace/remove only the wrapper node instead of rewriting the entire tree
  • Added resolveBlockTreePath, getTreeBlockNode, resolveBlockNodeOffset helpers for unified path resolution across header/body/footer regions
  • Removed dead code: commitHeaderFooterChange, findHeaderFooterBlock, resolveTreeOffset

Why

The old pattern called writeFullDocument() for every header/footer edit, which deleted ALL tree children and re-inserted them. In CRDT, each delete creates a tombstone node. A single character edit in the header generated ~8,000 tombstone nodes. After ~100 edits, documents grew to 120MB (2.2GB in memory), crashing production Yorkie servers.

Test plan

  • TypeScript build passes (pnpm tsc --noEmit)
  • pnpm verify:fast passes (lint + all unit tests)
  • Manual test: header text insert, delete, split, merge, style all work
  • Verified via Yorkie admin API: header editing produces ~92KB GC data vs 120MB before (1,300x reduction)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Refactor
    • Optimized document block management for improved performance and efficiency. Enhanced caching and targeted updates for header, body, and footer regions.

hackerwins and others added 5 commits April 19, 2026 16:05
All 7 mutation methods (insertText, deleteText, updateBlock,
applyStyle, deleteBlock, splitBlock, mergeBlock) previously called
commitHeaderFooterChange → writeFullDocument for header/footer
blocks, rewriting the entire document tree. A single character
edit in the header generated ~8,000 CRDT tombstones.

Now all methods use resolveBlockTreePath + getTreeBlockNode to
compute the correct tree path for any region, applying the same
granular editByPath operations used for body blocks.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
commitHeaderFooterChange, findHeaderFooterBlock, and resolveTreeOffset
had no remaining callers after the header/footer and tree-offset
refactors. resolveBlockNodeOffset covers the use cases previously
handled by resolveTreeOffset.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Instead of rewriting the entire document tree, insert/replace/remove
only the header or footer wrapper node with a single editByPath call.
@coderabbitai

coderabbitai Bot commented Apr 19, 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 49 minutes and 56 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 49 minutes and 56 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: 171b709b-ac21-4095-86e0-e45a83b784ce

📥 Commits

Reviewing files that changed from the base of the PR and between c9d801b and e82c7a8.

📒 Files selected for processing (1)
  • packages/frontend/src/app/docs/yorkie-doc-store.ts
📝 Walkthrough

Walkthrough

This PR refactors YorkieDocStore to replace full-document rewrites with targeted Yorkie tree edits. It introduces region-aware block path resolution, new helpers for delete boundary computation, and region-specific cache updates for insertText, deleteText, applyStyle, setHeader, setFooter, and block operations.

Changes

Cohort / File(s) Summary
YorkieDocStore Core Refactoring
packages/frontend/src/app/docs/yorkie-doc-store.ts
Removed resolveDeleteRange and resolveTreeOffset; added region-aware helpers (resolveBlockTreePath, getTreeBlockNode, resolveBlockNodeOffset, getBlockByRegion, setBlockByRegion). Refactored setHeader/setFooter to use targeted tree edits instead of full rewrites. Updated insertText, deleteText, applyStyle, deleteBlock, splitBlock, and mergeBlock to leverage region-aware caching and resolved block paths for targeted mutations.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • PR #68: Implements the main DocStore mutation and delegation framework that this PR builds upon with region-aware edits and targeted cache updates.
  • PR #126: Modifies splitBlock/mergeBlock tree edit logic and adjusts editByPath usage, directly overlapping with this PR's refactoring of those methods.
  • PR #133: Replaces tree path resolution logic (resolveTreeOffset variants) with new block-node path helpers, mirroring the path resolution changes introduced here.

Poem

🐰 Scattered rewrites fade away,
Targeted edits light the day,
Regions dance in cached arrays,
Yorkie trees in smarter ways!
Efficiency hops with glee! 🌟

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Use granular tree edits for header/footer blocks' directly and clearly summarizes the main change: replacing full-document rewrites with granular editByPath operations for header/footer mutations.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ 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 header-footer-granular-edits

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 19, 2026

Copy link
Copy Markdown
Contributor

Verification: verify:self

Result: ✅ PASS in 117.2s

Lane Status Duration
sheets:build ✅ pass 13.3s
docs:build ✅ pass 7.8s
verify:fast ✅ pass 59.1s
frontend:build ✅ pass 15.3s
verify:frontend:chunks ✅ pass 0.3s
backend:build ✅ pass 4.7s
cli:build ✅ pass 1.8s
verify:entropy ✅ pass 14.8s

Verification: verify:integration

Result: ✅ PASS

@codecov

codecov Bot commented Apr 19, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@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

🧹 Nitpick comments (4)
packages/frontend/src/app/docs/yorkie-doc-store.ts (4)

621-644: Edge case: resolveBlockNodeOffset with zero inline children returns { inlineIndex: 0, charOffset: 0 }.

When inlineChildren.length === 0, lastIdx = -1, inlineChildren[-1]?.children is undefined, and the function returns { inlineIndex: Math.max(0, -1), charOffset: 0 } = { inlineIndex: 0, charOffset: 0 }. Callers then build paths like [...blockPath, 0, 0] which don't correspond to a real tree node.

In practice, blocks like horizontal-rule / page-break have no inlines and callers gate on block type before reaching here, and the code path that calls resolveBlockNodeOffset is insertText/deleteText/splitBlock, which shouldn't target empty-inline block types. So this is latent rather than exploitable today.

Worth either returning an explicit sentinel / throwing for the empty-inlines case, or documenting the precondition that callers must not invoke this for blocks without inline children.

🤖 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 621 - 644,
The function resolveBlockNodeOffset currently assumes there is at least one
inline child; detect the case when inlineChildren.length === 0 and handle it
explicitly (either throw a clear error or return a sentinel) instead of
returning { inlineIndex: 0, charOffset: 0 }; update resolveBlockNodeOffset to
validate blockNode has inline children and throw e.g. "No inline children in
block" (or return a documented sentinel) so callers (insertText, deleteText,
splitBlock) won't build invalid paths—ensure the error message references the
blockNode/type to aid debugging.

512-545: setFooter reads childCount before getRootTreeNode() can reflect any header just inserted in this callback — but it currently doesn't matter.

Just a heads-up for future edits: setFooter reads childCount inside the update, which is correct as long as no earlier mutation in the same doc.update changed the child count. Today setFooter is called in isolation, so this is fine. If later refactors batch header + footer into one doc.update, the childCount-based positioning here ([childCount - 1] for replace, [childCount] for append) assumes the footer is the last child and will break silently if a header insertion happens in the same callback before this block runs.

Consider asserting the last child is actually a footer when hadFooter is true, or computing the footer index from a single pass over treeRoot.children that explicitly finds the footer child. Not blocking.

🤖 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 512 - 545,
setFooter assumes the footer is the last child by using childCount and
[childCount-1]/[childCount] which will break if another mutation in the same
doc.update changes child order; change the logic in setFooter to compute the
footer position by scanning treeRoot.children for a node with type === 'footer'
(or assert that the last child has type 'footer' when hadFooter is true), then
use that explicit index for tree.editByPath replace/remove operations (and use
children.length only when appending a new footer); reference symbols: setFooter,
treeRoot, childCount, hadFooter, tree.editByPath.

680-696: updateBlock no longer verifies that the passed block.id matches id.

resolveBlockTreePath(id, ...) locates the target slot by the provided id, but then you overwrite that slot with block (which may have a different block.id). Given the region-aware cache update, that would leave a block in doc.header.blocks / doc.blocks / doc.footer.blocks whose id no longer matches, and subsequent resolveBlockTreePath calls for the old id would throw while calls for the new id might collide with existing blocks.

Probably worth either validating block.id === id up front, or forcing block.id = id before writing. Not new to this PR, but now that all three regions flow through the same helper it's easier to trip.

🤖 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 680 - 696,
The updateBlock method uses resolveBlockTreePath(id, ...) to find the slot but
then writes the provided Block which may have a different block.id; fix
updateBlock by ensuring the incoming block's id matches the target id before
mutating the document — either validate and throw if block.id !== id or assign
block.id = id (do this immediately in updateBlock, prior to calling
this.doc.update and before calling setBlockByRegion); reference updateBlock,
resolveBlockTreePath, buildBlockNode and setBlockByRegion when making the change
so the tree edit and cache update remain consistent.

582-602: Two separate path-resolution helpers now coexist — worth consolidating eventually.

resolveBlockTreePath (new, for flat header/body/footer blocks) and resolveTableTreePath (existing, for nested table blocks) both walk the document to find a block by id, but don't share code and don't look into each other's regions. Consequences:

  • resolveBlockTreePath does not descend into tables, so calling any of updateBlock / insertText / deleteText / applyStyle / splitBlock / mergeBlock / deleteBlock with a blockId that lives inside a table cell will throw Block not found: ….
  • resolveTableTreePath only searches doc.blocks (the body) and won't find tables placed in the header or footer, even though the schema allows them and the backend supports nested tables in these regions.

Not a blocker for this PR, but a follow-up worth considering: unify into a single region-aware resolver that returns a path + region and optionally descends into tables.

🤖 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 582 - 602,
resolveBlockTreePath currently only checks header/body/footer top-level blocks
and never descends into table cells, while resolveTableTreePath only searches
body blocks and misses tables placed in header/footer; unify or update the
resolvers so both are region-aware and search nested table contents: modify
resolveBlockTreePath and/or resolveTableTreePath to (1) search header, body, and
footer consistently, (2) when encountering a table block descend into its
rows/cells to look for blockId, and (3) return a consistent result shape { path:
number[]; region: 'header'|'body'|'footer' } used by
updateBlock/insertText/deleteText/applyStyle/splitBlock/mergeBlock/deleteBlock
so operations on blocks inside table cells or tables in header/footer do not
throw "Block not found". Ensure you reference resolveBlockTreePath and
resolveTableTreePath when updating and keep existing return contract (path +
region).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/frontend/src/app/docs/yorkie-doc-store.ts`:
- Around line 480-545: setHeader and setFooter currently early-return from the
doc.update callback when root.content isn't a Tree (check using
tree.getRootTreeNode) but still overwrite this.cachedDoc and set this.dirty =
false, causing a stale local cache; fix by detecting that condition in
setHeader/setFooter and either call this.writeFullDocument({...doc, header}) /
this.writeFullDocument({...doc, footer}) to initialize the Tree CRDT (mirroring
writeFullDocument's fallback) or else skip mutating doc.header/doc.footer,
this.cachedDoc and this.dirty when the guard trips (i.e., only update the
cache/dirty after a successful tree.editByPath or after writeFullDocument
completes), keeping references to setHeader, setFooter, writeFullDocument,
getDocument, and the doc.update/tree.getRootTreeNode checks to locate the
changes.

---

Nitpick comments:
In `@packages/frontend/src/app/docs/yorkie-doc-store.ts`:
- Around line 621-644: The function resolveBlockNodeOffset currently assumes
there is at least one inline child; detect the case when inlineChildren.length
=== 0 and handle it explicitly (either throw a clear error or return a sentinel)
instead of returning { inlineIndex: 0, charOffset: 0 }; update
resolveBlockNodeOffset to validate blockNode has inline children and throw e.g.
"No inline children in block" (or return a documented sentinel) so callers
(insertText, deleteText, splitBlock) won't build invalid paths—ensure the error
message references the blockNode/type to aid debugging.
- Around line 512-545: setFooter assumes the footer is the last child by using
childCount and [childCount-1]/[childCount] which will break if another mutation
in the same doc.update changes child order; change the logic in setFooter to
compute the footer position by scanning treeRoot.children for a node with type
=== 'footer' (or assert that the last child has type 'footer' when hadFooter is
true), then use that explicit index for tree.editByPath replace/remove
operations (and use children.length only when appending a new footer); reference
symbols: setFooter, treeRoot, childCount, hadFooter, tree.editByPath.
- Around line 680-696: The updateBlock method uses resolveBlockTreePath(id, ...)
to find the slot but then writes the provided Block which may have a different
block.id; fix updateBlock by ensuring the incoming block's id matches the target
id before mutating the document — either validate and throw if block.id !== id
or assign block.id = id (do this immediately in updateBlock, prior to calling
this.doc.update and before calling setBlockByRegion); reference updateBlock,
resolveBlockTreePath, buildBlockNode and setBlockByRegion when making the change
so the tree edit and cache update remain consistent.
- Around line 582-602: resolveBlockTreePath currently only checks
header/body/footer top-level blocks and never descends into table cells, while
resolveTableTreePath only searches body blocks and misses tables placed in
header/footer; unify or update the resolvers so both are region-aware and search
nested table contents: modify resolveBlockTreePath and/or resolveTableTreePath
to (1) search header, body, and footer consistently, (2) when encountering a
table block descend into its rows/cells to look for blockId, and (3) return a
consistent result shape { path: number[]; region: 'header'|'body'|'footer' }
used by
updateBlock/insertText/deleteText/applyStyle/splitBlock/mergeBlock/deleteBlock
so operations on blocks inside table cells or tables in header/footer do not
throw "Block not found". Ensure you reference resolveBlockTreePath and
resolveTableTreePath when updating and keep existing return contract (path +
region).
🪄 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: cb1039f0-107d-47ff-9861-36e279a9de5f

📥 Commits

Reviewing files that changed from the base of the PR and between 0db208c and c9d801b.

📒 Files selected for processing (1)
  • packages/frontend/src/app/docs/yorkie-doc-store.ts

Comment thread packages/frontend/src/app/docs/yorkie-doc-store.ts
setHeader/setFooter assumed the tree was already initialized. When
called before the Tree CRDT exists, the update callback returned
early but the cache was still mutated, causing stale state.
@hackerwins
hackerwins merged commit ed41fb9 into main Apr 19, 2026
1 check passed
@hackerwins
hackerwins deleted the header-footer-granular-edits branch April 19, 2026 07:53
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