Skip to content

Optimize docs editor rendering for large documents#62

Merged
hackerwins merged 14 commits into
mainfrom
stabilize-docs-demo-layout
Mar 22, 2026
Merged

Optimize docs editor rendering for large documents#62
hackerwins merged 14 commits into
mainfrom
stabilize-docs-demo-layout

Conversation

@hackerwins

@hackerwins hackerwins commented Mar 22, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Add full-length book demo (Alice's Adventures in Wonderland, ~812 blocks, ~65 pages) to the docs editor
  • Fix canvas sizing for large documents by using viewport-sized canvas + spacer div instead of full-height canvas (which exceeded browser limits at 141K+ px)
  • Split render path into full layout (render()) and paint-only (renderPaintOnly()) to eliminate unnecessary layout recomputation on scroll and cursor blink
  • Add measureText cache to avoid redundant Canvas API calls during layout
  • Implement incremental (dirty-block) layout so typing recomputes only the changed block instead of all 812 blocks
  • Wire all text/style/structural operations to dirty tracking with proper markDirty vs invalidateLayout semantics
  • Fix scroll jumping to top when typing on lower pages by using position:fixed for the hidden textarea

Test plan

  • Unit tests for measureText cache (4 tests)
  • Unit tests for incremental layout (5 tests)
  • pnpm verify:self passes (all lanes green)
  • Manual: open demo, scroll to page 30+, type text — no scroll jump
  • Manual: cursor blink doesn't cause layout recomputation (smooth on large docs)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Collapsible event log panel and toggle UI in the demo
    • Demo populated with realistic book content and styled chapter/title lines
  • Performance Improvements

    • Rendering pipeline optimized for large documents for faster scroll and cursor responsiveness
    • Viewport-based canvas rendering and incremental layout to reduce unnecessary recomputation
  • Documentation

    • Added design and task documentation detailing the rendering optimization plan

hackerwins and others added 13 commits March 22, 2026 08:18
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]>
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]>
@coderabbitai

coderabbitai Bot commented Mar 22, 2026

Copy link
Copy Markdown
📝 Walkthrough
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely summarizes the main objective of the PR: optimizing the docs editor rendering pipeline for large documents, which is the central theme across all changes.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch stabilize-docs-demo-layout

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 Mar 22, 2026

Copy link
Copy Markdown
Contributor

Verification: verify:self

Result: ✅ PASS in 98.1s

Lane Status Duration
sheets:build ✅ pass 13.4s
verify:fast ✅ pass 48.5s
frontend:build ✅ pass 13.8s
verify:frontend:chunks ✅ pass 0.4s
backend:build ✅ pass 4.4s
cli:build ✅ pass 1.8s
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: 2

🧹 Nitpick comments (3)
packages/docs/demo.ts (1)

133-137: Harden toggle handler: use closest() and textContent.

Lines 135-137 work today, but closest('button') is more robust than direct tagName checks, and textContent avoids innerHTML assignment.

🔧 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') ? '&#9660;' : '&#9650;';
+  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 blocks array 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

📥 Commits

Reviewing files that changed from the base of the PR and between 53587ab and 4749c98.

📒 Files selected for processing (15)
  • docs/design/README.md
  • docs/design/docs-rendering-optimization.md
  • docs/tasks/README.md
  • docs/tasks/archive/2026/03/20260321-pr58-review-todo.md
  • docs/tasks/archive/2026/03/20260322-docs-rendering-optimization-todo.md
  • docs/tasks/archive/README.md
  • packages/docs/demo.ts
  • packages/docs/index.html
  • packages/docs/sample-text.ts
  • packages/docs/src/view/doc-canvas.ts
  • packages/docs/src/view/editor.ts
  • packages/docs/src/view/layout.ts
  • packages/docs/src/view/text-editor.ts
  • packages/docs/test/view/incremental-layout.test.ts
  • packages/docs/test/view/measure-cache.test.ts

Comment thread packages/docs/src/view/editor.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]>

@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.

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 | 🟡 Minor

Unnecessary full layout recomputation during browser IME composition; add markDirty for consistency and efficiency.

The handleCompositionEnd handler modifies the document via deleteText/insertText but doesn't call markDirty. 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 (applyHangulResult on 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 adding markDirty for 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 handleCompositionEnd fix 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

📥 Commits

Reviewing files that changed from the base of the PR and between 4749c98 and 546dec5.

📒 Files selected for processing (2)
  • packages/docs/src/view/editor.ts
  • packages/docs/src/view/text-editor.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/docs/src/view/editor.ts

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