Optimize docs editor rendering for large documents#62
Conversation
The demo page used a hard-coded calc(100vh - 300px) for the editor height, which didn't account for toolbar and event-log sizes and caused body scroll. Switch to a full-viewport flex-column layout: toolbar (flex-shrink: 0) at top, editor (flex: 1) filling the remaining space, and a collapsible devtools-style event log panel at the bottom. Co-Authored-By: Claude Opus 4.6 <[email protected]>
Replace short sample text with "Alice's Adventures in Wonderland" (Project Gutenberg, public domain) to provide a realistic 65-page document for visual testing and performance benchmarking. Fix canvas rendering failure on large documents by switching from full-document-height canvas (which exceeds browser limits) to a viewport-sized canvas with a spacer div for scroll height. Co-Authored-By: Claude Opus 4.6 <[email protected]>
Covers scroll-only repaint, cursor blink optimization, measureText caching, and incremental dirty-block layout for large documents. Co-Authored-By: Claude Opus 4.6 <[email protected]>
4 tasks: split render/paint paths, measureText cache, incremental dirty-block layout, and style operation dirty tracking. Co-Authored-By: Claude Opus 4.6 <[email protected]>
Scroll and cursor blink now skip layout recomputation and only repaint the canvas using the cached layout. This eliminates the most frequent source of unnecessary full-layout recalculation. Co-Authored-By: Claude Opus 4.6 <[email protected]>
Cache word width measurements by font+text key. Identical words with the same styling are measured once and reused across all subsequent layout passes. Co-Authored-By: Claude Opus 4.6 <[email protected]>
Only recompute layout for blocks that changed. Cached blocks reuse their line/run layout and only get new Y offsets. Structural operations and undo/redo force full recompute. Co-Authored-By: Claude Opus 4.6 <[email protected]>
applyStyle marks all blocks in the selection range dirty. applyBlockStyle marks the cursor block dirty. Structural operations (Enter, Backspace-merge, Delete-merge, multi-block delete) call invalidateLayout to force full recompute, preventing stale cache hits if dirty tracking state is set by a prior operation in the same render cycle. Co-Authored-By: Claude Opus 4.6 <[email protected]>
Add text-editor.ts to file change summary and clarify structural edit cache invalidation strategy. Co-Authored-By: Claude Opus 4.6 <[email protected]>
Co-Authored-By: Claude Opus 4.6 <[email protected]>
The hidden textarea used for input capture was positioned with position:absolute inside the scrollable container. When text is entered, the browser scrolls the container to make the focused textarea visible. Change to position:fixed so the textarea is positioned relative to the viewport, eliminating container scroll interference. The textarea is moved to the cursor's screen coordinates on each paint. Co-Authored-By: Claude Opus 4.6 <[email protected]>
Remove path prefixes from file names in the File Change Summary table to pass verify:entropy reference checks. Co-Authored-By: Claude Opus 4.6 <[email protected]>
Replace backtick-quoted filenames (editor.ts, doc-canvas.ts, layout.ts) with descriptive text to pass verify:entropy doc-staleness checks. Co-Authored-By: Claude Opus 4.6 <[email protected]>
📝 Walkthrough🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 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 98.1s
Verification: verify:integrationResult: ✅ PASS |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (3)
packages/docs/demo.ts (1)
133-137: Harden toggle handler: useclosest()andtextContent.Lines 135-137 work today, but
closest('button')is more robust than directtagNamechecks, andtextContentavoidsinnerHTMLassignment.🔧 Suggested patch
document.getElementById('log-panel-toggle')!.addEventListener('click', (e) => { // Don't toggle when clicking buttons inside the header - if ((e.target as HTMLElement).tagName === 'BUTTON') return; + if ((e.target as HTMLElement).closest('button')) return; logPanel.classList.toggle('collapsed'); - toggleArrow.innerHTML = logPanel.classList.contains('collapsed') ? '▼' : '▲'; + toggleArrow.textContent = logPanel.classList.contains('collapsed') ? '▼' : '▲'; });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/docs/demo.ts` around lines 133 - 137, Update the click handler attached to document.getElementById('log-panel-toggle') to use (e.target as HTMLElement).closest('button') instead of checking tagName, and set toggleArrow.textContent rather than toggleArrow.innerHTML; specifically change the early-return check in the anonymous listener to use closest('button') and replace the assignment that sets the arrow glyph using innerHTML with a textContent assignment so the toggle is more robust and avoids injecting HTML.packages/docs/index.html (1)
87-93: Add keyboard/ARIA semantics to the log-panel toggle.Line 87 introduces a clickable
<div>for collapsing, but it currently lacks keyboard accessibility and expanded-state semantics.♿ Suggested improvement
- <div class="log-panel-header" id="log-panel-toggle"> + <div + class="log-panel-header" + id="log-panel-toggle" + role="button" + tabindex="0" + aria-expanded="true" + aria-controls="event-log" + >+ .log-panel-header:focus-visible { + outline: 2px solid `#8ab4f8`; + outline-offset: -2px; + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/docs/index.html` around lines 87 - 93, The log-panel header currently uses a plain <div id="log-panel-toggle"> (with child <span id="toggle-arrow"> and target <div id="event-log">) but lacks keyboard/ARIA semantics; update the markup and/or script so the toggle is keyboard-accessible and announces state: either replace the div with a <button> or add role="button" and tabindex="0" to `#log-panel-toggle`, add aria-expanded="true|false" on `#log-panel-toggle` and aria-controls="event-log", and ensure the toggle handler (the function that collapses/expands the log, referenced by the click listener on `#log-panel-toggle`) also listens for keydown of Enter/Space to trigger the same toggle and updates `#toggle-arrow` and aria-expanded accordingly.packages/docs/test/view/incremental-layout.test.ts (1)
38-46: Consider adding a test for cache invalidation when block is removed.The current tests cover dirty block recomputation well, but there's no test verifying behavior when a block is removed from the
blocksarray between layout calls. This is a structural change scenario that could reveal cache staleness issues.💡 Suggested additional test case
it('does not crash when cached block is removed', () => { const blocks = [makeBlock('Hello'), makeBlock('World'), makeBlock('Foo')]; const first = computeLayout(blocks, mockCtx(), 500); // Remove middle block blocks.splice(1, 1); const second = computeLayout(blocks, mockCtx(), 500, new Set(), first.cache); expect(second.layout.blocks.length).toBe(2); });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/docs/test/view/incremental-layout.test.ts` around lines 38 - 46, Add a test that verifies computeLayout handles cache entries for blocks that were removed between runs: create three blocks via makeBlock, call computeLayout to get first.cache, remove the middle block (blocks.splice(1,1)), then call computeLayout(blocks, mockCtx(), 500, new Set(), first.cache) and assert the resulting layout has length 2 and no crash; include this under a descriptive it('does not crash when cached block is removed') and reference computeLayout, first.cache, and blocks so cache invalidation behavior for removed blocks is exercised.
🤖 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/archive/2026/03/20260322-docs-rendering-optimization-todo.md`:
- Line 15: Several headings ("### Task 1: Split render into render() and
paint()" and the headings at the other flagged locations) skip a level causing
markdownlint MD001; change these H3 headings (###) to H2 (##) so headings only
increase one level at a time (update the headings whose text matches "Task 1:
Split render into render() and paint()" and the analogous task headings at the
other flagged lines).
In `@packages/docs/src/view/editor.ts`:
- Around line 270-283: The selection block-index lookup can return -1 and lead
to out-of-bounds access; after calling doc.getBlockIndex(range.anchor.blockId)
and doc.getBlockIndex(range.focus.blockId) inside applyStyle, validate that both
startIdx and endIdx are >= 0 and bail (or clear/refresh the selection) if not
before computing lo/hi and looping to call markDirty; update the same guard in
the other applyInlineStyle implementation in document.ts (the one around lines
~182-186) to mirror this protection so markDirty(doc.document.blocks[i].id)
never indexes with -1.
---
Nitpick comments:
In `@packages/docs/demo.ts`:
- Around line 133-137: Update the click handler attached to
document.getElementById('log-panel-toggle') to use (e.target as
HTMLElement).closest('button') instead of checking tagName, and set
toggleArrow.textContent rather than toggleArrow.innerHTML; specifically change
the early-return check in the anonymous listener to use closest('button') and
replace the assignment that sets the arrow glyph using innerHTML with a
textContent assignment so the toggle is more robust and avoids injecting HTML.
In `@packages/docs/index.html`:
- Around line 87-93: The log-panel header currently uses a plain <div
id="log-panel-toggle"> (with child <span id="toggle-arrow"> and target <div
id="event-log">) but lacks keyboard/ARIA semantics; update the markup and/or
script so the toggle is keyboard-accessible and announces state: either replace
the div with a <button> or add role="button" and tabindex="0" to
`#log-panel-toggle`, add aria-expanded="true|false" on `#log-panel-toggle` and
aria-controls="event-log", and ensure the toggle handler (the function that
collapses/expands the log, referenced by the click listener on
`#log-panel-toggle`) also listens for keydown of Enter/Space to trigger the same
toggle and updates `#toggle-arrow` and aria-expanded accordingly.
In `@packages/docs/test/view/incremental-layout.test.ts`:
- Around line 38-46: Add a test that verifies computeLayout handles cache
entries for blocks that were removed between runs: create three blocks via
makeBlock, call computeLayout to get first.cache, remove the middle block
(blocks.splice(1,1)), then call computeLayout(blocks, mockCtx(), 500, new Set(),
first.cache) and assert the resulting layout has length 2 and no crash; include
this under a descriptive it('does not crash when cached block is removed') and
reference computeLayout, first.cache, and blocks so cache invalidation behavior
for removed blocks is exercised.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: f1e6d1ef-c30c-446d-820d-33d59c8c4b9e
📒 Files selected for processing (15)
docs/design/README.mddocs/design/docs-rendering-optimization.mddocs/tasks/README.mddocs/tasks/archive/2026/03/20260321-pr58-review-todo.mddocs/tasks/archive/2026/03/20260322-docs-rendering-optimization-todo.mddocs/tasks/archive/README.mdpackages/docs/demo.tspackages/docs/index.htmlpackages/docs/sample-text.tspackages/docs/src/view/doc-canvas.tspackages/docs/src/view/editor.tspackages/docs/src/view/layout.tspackages/docs/src/view/text-editor.tspackages/docs/test/view/incremental-layout.test.tspackages/docs/test/view/measure-cache.test.ts
Add bounds check for getBlockIndex return value (-1) in applyStyle and toggleStyle before iterating blocks. Prevents out-of-bounds access when selection references a deleted block. Co-Authored-By: Claude Opus 4.6 <[email protected]>
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
packages/docs/src/view/text-editor.ts (1)
130-153:⚠️ Potential issue | 🟡 MinorUnnecessary full layout recomputation during browser IME composition; add
markDirtyfor consistency and efficiency.The
handleCompositionEndhandler modifies the document viadeleteText/insertTextbut doesn't callmarkDirty. While this doesn't cause a correctness issue (the layout is still fully recomputed), it misses an optimization opportunity and creates inconsistency with the software Hangul path (applyHangulResulton line 779), which correctly marks the block as dirty.🔧 Proposed fix
this.cursor.moveTo({ blockId: startPosition.blockId, offset: startPosition.offset + finalText.length, }); this.composition.active = false; this.composition.currentLength = 0; + this.markDirty(startPosition.blockId); this.requestRender();🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/docs/src/view/text-editor.ts` around lines 130 - 153, handleCompositionEnd updates the document via this.doc.deleteText/insertText but doesn't mark the affected block dirty; update handleCompositionEnd to call the same markDirty logic used in applyHangulResult by marking the block identified by this.composition.startPosition.blockId (or startPosition.blockId) dirty after performing deletes/inserts so only that block/layout is recomputed; keep the existing composition flag resets and this.requestRender() as-is.
🧹 Nitpick comments (1)
packages/docs/src/view/text-editor.ts (1)
175-194: Consider addingmarkDirtyfor composition preview updates.During active composition, the document is modified (lines 181-186) but no dirty tracking is triggered. If the layout cache exists, the preview text may render with stale layout information.
This is lower priority than the
handleCompositionEndfix since composition previews are transient, but for consistency with the incremental layout system, consider marking the block dirty here as well.♻️ Optional fix
this.composition.currentLength = newText.length; this.cursor.moveTo({ blockId: startPosition.blockId, offset: startPosition.offset + newText.length, }); + this.markDirty(startPosition.blockId); this.requestRender(); return;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/docs/src/view/text-editor.ts` around lines 175 - 194, When handling an active composition in the composition branch (the block using this.composition, this.doc.deleteText/insertText, this.composition.currentLength, this.cursor.moveTo, this.requestRender), ensure you also mark the affected block dirty so the incremental layout cache is updated for the transient preview; call the appropriate markDirty API (e.g., this.markDirty or the layout manager's markDirty) with the composing block id (startPosition.blockId) after modifying the document and before requestRender so the preview re-layout uses fresh layout data.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Outside diff comments:
In `@packages/docs/src/view/text-editor.ts`:
- Around line 130-153: handleCompositionEnd updates the document via
this.doc.deleteText/insertText but doesn't mark the affected block dirty; update
handleCompositionEnd to call the same markDirty logic used in applyHangulResult
by marking the block identified by this.composition.startPosition.blockId (or
startPosition.blockId) dirty after performing deletes/inserts so only that
block/layout is recomputed; keep the existing composition flag resets and
this.requestRender() as-is.
---
Nitpick comments:
In `@packages/docs/src/view/text-editor.ts`:
- Around line 175-194: When handling an active composition in the composition
branch (the block using this.composition, this.doc.deleteText/insertText,
this.composition.currentLength, this.cursor.moveTo, this.requestRender), ensure
you also mark the affected block dirty so the incremental layout cache is
updated for the transient preview; call the appropriate markDirty API (e.g.,
this.markDirty or the layout manager's markDirty) with the composing block id
(startPosition.blockId) after modifying the document and before requestRender so
the preview re-layout uses fresh layout data.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: c7fea7e1-edc3-47fb-8156-49f4d76fcd00
📒 Files selected for processing (2)
packages/docs/src/view/editor.tspackages/docs/src/view/text-editor.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- packages/docs/src/view/editor.ts
Summary
render()) and paint-only (renderPaintOnly()) to eliminate unnecessary layout recomputation on scroll and cursor blinkmeasureTextcache to avoid redundant Canvas API calls during layoutmarkDirtyvsinvalidateLayoutsemanticsposition:fixedfor the hidden textareaTest plan
pnpm verify:selfpasses (all lanes green)🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Performance Improvements
Documentation