Stop docs cursor movement from re-measuring the whole document#444
Conversation
Arrow keys and Home/End routed through renderWithScroll -> render() -> recomputeLayout(), which re-laid-out and re-measured every block because dirtyBlockIds is undefined on a pure caret move (disabling the incremental layout cache). On a large body this made cursor movement scale with the document length; measureText/measureWidth dominated profiles. Pure caret navigation changes no document content, so it can repaint from the cached layout. Add a paint-only renderCursorMove (sharing the cursor side effects via afterCursorRender) wired as requestCursorRender, and route the non-mutating paths (non-table arrows, Home/End, Doc start/end) through it. Table navigation stays on the full render because its table-exit branch can call ensureBlockAfter, which mutates the document. A single ArrowRight on a 40-block document drops from 1991 measureText calls to under 40. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_015EPywatWADMR2iYrwauVNL
Word widths were already cached (measureSegments -> cachedMeasureText), but computeCharOffsets re-measured every character prefix uncached on every full re-layout. Full re-layouts still fire on structural edits (Enter, paste, multi-block delete), remote collaborative edits, undo/redo, and resize, so this was the remaining measureText hotspot as the body grows. Memoise the whole offsets array per (measurer, font, text), keyed like cachedMeasureText and drained by the same clearMeasureCache path. Offsets depend only on font and text, not layout width, so the cache also survives resizes. Re-laying-out unchanged content now costs zero canvas measurements; a full recompute of a warm two-paragraph layout drops from 63 measureWidth calls to 0. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_015EPywatWADMR2iYrwauVNL
The caret- and selection-pixel resolvers re-measured run.text.slice(0, n) on every paint frame, even though each run already carries charOffsets (charOffsets[i] = width of the first i+1 chars). During scroll and cursor blink this measured on every frame and re-resolved the font, which can drift from the layout's own seg.font. Add caretOffsetX(run, localOffset, measurer): it reads charOffsets and only consults the measurer on a fallback path (a run lacking a matching offset), preserving prior correctness. Route all eight offset->x callsites through it (body + table caret in peer-cursor, body selection in selection, and the header/footer/table caret and selection-rect sites in editor). Image runs keep their own display-width handling; measurer stays in the signatures for the fallback, so no API churn. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_015EPywatWADMR2iYrwauVNL
The width and char-offset caches key by (font, text) with no load-state key, so the initial layout of a not-yet-loaded web font pins run positions and caret offsets to the fallback face's metrics. Docs, unlike slides, never cleared the cache when the real face arrived, so layout and caret drifted from the painted glyphs until a reload. Caching computeCharOffsets made the existing word-width staleness reach the caret too. Subscribe initialize() to document.fonts 'loadingdone' and clear the cache, invalidate layout, and re-render on fire (mirrors the slides editor); remove the listener on dispose. clearMeasureCache already drains the offset cache via the shared knownCaches, so one wiring fixes both the word-width and char-offset staleness. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_015EPywatWADMR2iYrwauVNL
|
Warning Review limit reached
Next review available in: 22 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (7)
📝 WalkthroughWalkthroughAdds a per-measurer character-offset cache and ChangesCaret rendering performance changes
Estimated code review effort: 3 (Moderate) | ~30 minutes Sequence Diagram(s)sequenceDiagram
participant User
participant TextEditor
participant Editor
participant Canvas
User->>TextEditor: ArrowRight keydown
TextEditor->>TextEditor: requestCaretRender()
TextEditor->>Editor: requestCursorRender()
Editor->>Editor: renderCursorMove()
Editor->>Canvas: renderPaintOnly()
Editor->>Editor: afterCursorRender()
sequenceDiagram
participant FontsAPI as document.fonts
participant Editor
participant MeasureCache
participant Canvas
FontsAPI->>Editor: loadingdone event
Editor->>MeasureCache: clearMeasureCache()
Editor->>Editor: invalidate layout
Editor->>Canvas: re-render
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ 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 325.5s
Verification: verify:integrationResult: ✅ PASS |
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Actionable comments posted: 2
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/layout.ts (1)
37-61: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift
knownCachesshould not hold dead per-measurer Maps forever
perMeasurerCache/perMeasurerOffsetCacheare weakly keyed, but both createdMaps are also added to the module-levelknownCachesSet.clearMeasureCache()only empties those maps; it never removes them fromknownCaches, andeditor.dispose()doesn’t release them either. That keeps each disposed editor’s cache objects alive for the lifetime of the module.Consider adding a way to unregister a measurer’s caches on dispose, or switching to a cache registry that doesn’t retain strong references.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/docs/src/view/layout.ts` around lines 37 - 61, The per-measurer caches created in cacheFor and offsetCacheFor are being kept alive by the module-level knownCaches Set even after the associated editor is disposed. Update the layout cache lifecycle so each measurer’s Map is unregistered from knownCaches when it is no longer needed, ideally from editor.dispose or a dedicated cleanup path, and ensure clearMeasureCache still works without retaining dead cache objects.
🧹 Nitpick comments (2)
packages/docs/test/view/font-load-invalidation.test.ts (1)
21-56: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueExtract the shared canvas/RAF test setup
installCanvasShimand the RAF-to-microtask shim are repeated across these docs view tests; moving that shared setup intopackages/docs/test/view/test-utils.tswould reduce copy/paste drift. Keep thedocument.fontsstub local to this font-load case.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/docs/test/view/font-load-invalidation.test.ts` around lines 21 - 56, The shared canvas and RAF test setup is duplicated in this docs view test, so move the common shim logic from installCanvasShim and the RAF-to-microtask setup into packages/docs/test/view/test-utils.ts and reuse it from the view tests. Keep the document.fonts stub specific to the font-load invalidation case, and update the existing test helpers/imports so installCanvasShim remains only the local font-loading customization point.packages/docs/src/view/layout.ts (1)
416-472: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winSolid caching design; consider extracting the repeated image/text branch into a shared helper.
computeCharOffsets/caretOffsetXare well-documented and correctly memoized/fallback-tested. However, the patternrun.imageHeight !== undefined ? (n > 0 ? run.width : 0) : caretOffsetX(run, n, measurer)is duplicated verbatim acrosseditor.ts(4 sites),peer-cursor.ts(2 sites), andselection.ts(1 site). Exporting a singlerunOffsetX(run, localOffset, measurer)helper from this module (mirroring the localrunOffsetXalready inlined ineditor.ts'scomputeHFTableCellSelectionRects) would remove ~7 duplicated conditionals and reduce the risk of future divergence.♻️ Proposed shared helper
+export function runOffsetX( + run: LayoutRun, + localOffset: number, + measurer: TextMeasurer, +): number { + if (run.imageHeight !== undefined) { + return localOffset > 0 ? run.width : 0; + } + return caretOffsetX(run, localOffset, measurer); +}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/docs/src/view/layout.ts` around lines 416 - 472, The duplicated image/text offset branching should be centralized: add a shared helper in layout.ts (for example, a runOffsetX-style wrapper around caretOffsetX) that handles `run.imageHeight !== undefined ? (localOffset > 0 ? run.width : 0) : caretOffsetX(...)`, then update the repeated call sites in editor.ts, peer-cursor.ts, and selection.ts to use it. Keep the existing `computeCharOffsets` and `caretOffsetX` behavior unchanged, and preserve the special-case handling for image runs versus text runs in one place.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/tasks/active/20260705-docs-cursor-move-relayout-todo.md`:
- Around line 145-149: Remove the stale “Still open” entry for `#2` in the task
log, since it contradicts the earlier completed-fix section. Update the markdown
in this document so the `computeCharOffsets` / `cachedMeasureText` item is no
longer listed under `Still open (separate fixes)`, leaving only the genuinely
unresolved item(s) such as the remote-edit / undo-redo / resize full re-layout
note.
In `@packages/docs/src/view/editor.ts`:
- Around line 2436-2449: Add a document.fonts.ready fallback in the editor
font-loading logic because loadingdone may not fire reliably on WebKit. Update
the font re-layout path around the fontsTarget/handleFontsLoaded setup in
editor.ts so the same clearMeasureCache, invalidateLayout, and render sequence
also runs when document.fonts.ready resolves, while keeping the existing
loadingdone listener for browsers that support it.
---
Outside diff comments:
In `@packages/docs/src/view/layout.ts`:
- Around line 37-61: The per-measurer caches created in cacheFor and
offsetCacheFor are being kept alive by the module-level knownCaches Set even
after the associated editor is disposed. Update the layout cache lifecycle so
each measurer’s Map is unregistered from knownCaches when it is no longer
needed, ideally from editor.dispose or a dedicated cleanup path, and ensure
clearMeasureCache still works without retaining dead cache objects.
---
Nitpick comments:
In `@packages/docs/src/view/layout.ts`:
- Around line 416-472: The duplicated image/text offset branching should be
centralized: add a shared helper in layout.ts (for example, a runOffsetX-style
wrapper around caretOffsetX) that handles `run.imageHeight !== undefined ?
(localOffset > 0 ? run.width : 0) : caretOffsetX(...)`, then update the repeated
call sites in editor.ts, peer-cursor.ts, and selection.ts to use it. Keep the
existing `computeCharOffsets` and `caretOffsetX` behavior unchanged, and
preserve the special-case handling for image runs versus text runs in one place.
In `@packages/docs/test/view/font-load-invalidation.test.ts`:
- Around line 21-56: The shared canvas and RAF test setup is duplicated in this
docs view test, so move the common shim logic from installCanvasShim and the
RAF-to-microtask setup into packages/docs/test/view/test-utils.ts and reuse it
from the view tests. Keep the document.fonts stub specific to the font-load
invalidation case, and update the existing test helpers/imports so
installCanvasShim remains only the local font-loading customization point.
🪄 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: 8bd13745-6363-4cb3-a020-c7cbf5a57e5d
📒 Files selected for processing (11)
docs/design/docs/docs-rendering-optimization.mddocs/tasks/active/20260705-docs-cursor-move-relayout-todo.mdpackages/docs/src/view/editor.tspackages/docs/src/view/layout.tspackages/docs/src/view/peer-cursor.tspackages/docs/src/view/selection.tspackages/docs/src/view/text-editor.tspackages/docs/test/view/caret-offset-x.test.tspackages/docs/test/view/char-offsets-cache.test.tspackages/docs/test/view/cursor-move-no-relayout.test.tspackages/docs/test/view/font-load-invalidation.test.ts
Three findings from the PR review: - knownCaches strongly retained every per-measurer cache map for the module lifetime (one leaked pair per disposed editor), defeating the WeakMaps. Add disposeMeasureCache(measurer) and call it from editor.dispose(); export it from the package index. - loadingdone is unreliable on WebKit/Safari, so also settle via the Promise-based document.fonts.ready when fonts are still loading at mount (guarded on dispose). Mid-session Safari picks still rely on loadingdone. - Remove a stale 'Still open' task-log entry for the already-completed #2. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_015EPywatWADMR2iYrwauVNL
|
Addressed the remaining review items in 0ab5168 (no inline anchor for these):
|
Summary
As the docs body grows, cursor movement and scrolling got progressively slower; profiling a large document showed
measureText/measureWidthdominating (~57%). The cost was text measurement at layout time, which viewport/partial rendering can't help — paint is already viewport-culled, but layout measures all blocks regardless of what's visible. Root causes and fixes:Caret moves forced a full re-layout. Arrow keys / Home / End routed through
render() → recomputeLayout()withdirtyBlockIdsundefined, disabling the incremental cache and re-measuring every block. Now pure caret navigation repaints from the cached layout via a paint-onlyrenderCursorMove(requestCursorRender). Table navigation stays on the full render because its table-exit branch can callensureBlockAfter(a mutation). → ArrowRight on a 40-block doc: 1991 → <40measureTextcalls.Full re-layouts re-measured char offsets uncached. Word widths were already cached, but
computeCharOffsetsmeasured every character prefix on every full re-layout (still fired by Enter/paste, remote edits, undo/redo, resize). Now memoised per(measurer, font, text). → Full recompute of a warm layout: 63 → 0measureTextcalls.Caret/selection resolvers re-measured per frame. They re-measured
run.text.slice(0, n)each paint instead of reading the precomputedLayoutRun.charOffsets. Now read via a sharedcaretOffsetX()helper (measurer kept only as a defensive fallback, so no API churn).Font-load staleness (from code review). The caches key by
(font, text)with no load-state key, so layout/caret stayed on fallback-font metrics after an async web-font load — a pre-existing docs gap that caching char offsets made reach the caret.initialize()now subscribes todocument.fontsloadingdone→clearMeasureCache()+invalidateLayout()+render()(mirrors slides), removed ondispose().Net: the
measureTexthotspot now only fires on the first layout of content that actually changed.Known limitation
The char-offset cache shares the exact lifecycle of the pre-existing word-width cache, whose unbounded growth
docs-rendering-optimization.mdexplicitly deferred (LRU "if needed"); the new font-load handler drains both. Adding LRU to one but not its twin would be inconsistent — deferred to a joint follow-up if profiling shows pressure.Test plan
cursor-move-no-relayout.test.ts— ArrowRight/Down don't re-measure the body.char-offsets-cache.test.ts— warm full re-layout does zero measurement.caret-offset-x.test.ts— helper readscharOffsets, falls back correctly.font-load-invalidation.test.ts—loadingdonere-measures; listener removed on dispose.pnpm --filter @wafflebase/docs test— 1071 pass, typecheck clean.pnpm verify:fast— green (pre-commit hook on every commit).pnpm dev: cursor movement, shift-selection, and cross-page scrolling on a long document confirmed by the author.docs/design/docs/docs-rendering-optimization.md.🤖 Generated with Claude Code
https://claude.ai/code/session_015EPywatWADMR2iYrwauVNL
Summary by CodeRabbit
New Features
Bug Fixes