Use granular tree edits for header/footer blocks#141
Conversation
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.
|
Warning Rate limit exceeded
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 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 configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughThis 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 Changes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
Verification: verify:selfResult: ✅ PASS in 117.2s
Verification: verify:integrationResult: ✅ PASS |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (4)
packages/frontend/src/app/docs/yorkie-doc-store.ts (4)
621-644: Edge case:resolveBlockNodeOffsetwith zero inline children returns{ inlineIndex: 0, charOffset: 0 }.When
inlineChildren.length === 0,lastIdx = -1,inlineChildren[-1]?.childrenisundefined, 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-breakhave no inlines and callers gate on block type before reaching here, and the code path that callsresolveBlockNodeOffsetisinsertText/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:setFooterreadschildCountbeforegetRootTreeNode()can reflect any header just inserted in this callback — but it currently doesn't matter.Just a heads-up for future edits:
setFooterreadschildCountinside the update, which is correct as long as no earlier mutation in the samedoc.updatechanged the child count. TodaysetFooteris called in isolation, so this is fine. If later refactors batch header + footer into onedoc.update, thechildCount-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
footerwhenhadFooteris true, or computing the footer index from a single pass overtreeRoot.childrenthat explicitly finds thefooterchild. 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:updateBlockno longer verifies that the passedblock.idmatchesid.
resolveBlockTreePath(id, ...)locates the target slot by the providedid, but then you overwrite that slot withblock(which may have a differentblock.id). Given the region-aware cache update, that would leave a block indoc.header.blocks/doc.blocks/doc.footer.blockswhoseidno longer matches, and subsequentresolveBlockTreePathcalls for the old id would throw while calls for the new id might collide with existing blocks.Probably worth either validating
block.id === idup front, or forcingblock.id = idbefore 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) andresolveTableTreePath(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:
resolveBlockTreePathdoes not descend into tables, so calling any ofupdateBlock/insertText/deleteText/applyStyle/splitBlock/mergeBlock/deleteBlockwith a blockId that lives inside a table cell will throwBlock not found: ….resolveTableTreePathonly searchesdoc.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
📒 Files selected for processing (1)
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.
Summary
insertText,deleteText,updateBlock,applyStyle,deleteBlock,splitBlock,mergeBlock) now use character-leveleditByPathinstead ofwriteFullDocumentsetHeader/setFooternow insert/replace/remove only the wrapper node instead of rewriting the entire treeresolveBlockTreePath,getTreeBlockNode,resolveBlockNodeOffsethelpers for unified path resolution across header/body/footer regionscommitHeaderFooterChange,findHeaderFooterBlock,resolveTreeOffsetWhy
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
pnpm tsc --noEmit)pnpm verify:fastpasses (lint + all unit tests)🤖 Generated with Claude Code
Summary by CodeRabbit