fix(macos): coalesce ComposerTextEditor.measureHeight and skip redundant ensureLayout passes (LUM-827)#26054
Conversation
…ant ensureLayout passes `Coordinator.measureHeight(_:)` called `NSLayoutManager.ensureLayout(for:)` synchronously on five hot paths (frame/bounds notifications, textDidChange, updateNSView, initial dispatch after makeNSView). For large text the first layout pass is O(n) in glyph count and can block the main thread for 2 s+; upstream SwiftUI state cascades repeatedly stacked these calls. Add a `DispatchWorkItem`-based coalescer so rapid successive requests collapse into one layout pass on the next main-queue turn, and add a storage-hash / container-width skip-guard inside `measureHeight` so re-renders without layout-relevant changes are free. Typography updates (font, line spacing, text color) in `updateNSView` explicitly invalidate the cache. `dismantleNSView` cancels any pending work. All layout work stays on the main thread (`NSLayoutManager` is not thread-safe), the TextKit 1 stack and `IntrinsicScrollView.contentHeight` one-way flow are untouched, and `ensureLayout` is still invoked when inputs change so `usedRect` never returns stale values. Fixes LUM-827. Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
There was a problem hiding this comment.
✅ Approved — Clean coalescing fix for composer main-thread hang
LUM-827 — 2s+ hang from uncoalesced NSLayoutManager.ensureLayout in ComposerTextEditor.measureHeight.
Approach validation
- No circular fixes — git history shows no add/revert cycles on measurement coalescing
- No competing in-flight work — PR #25597 (cursor flicker) is complementary, not conflicting
- PR #24401 (bridge refactor) was reverted in #24417 — PR description correctly notes it's not reintroduced
- Sentry evidence (VELLUM-ASSISTANT-MACOS-EH) confirms the hang
What this does
1. DispatchWorkItem coalescing — All 5 measureHeight call sites now go through scheduleMeasureHeight, which cancels any pending work item before scheduling a new one via DispatchQueue.main.async. Rapid bursts (upstream SwiftUI cascades, frame+bounds notifications, per-keystroke textDidChange) collapse to a single ensureLayout pass on the next runloop turn.
2. Skip-guard — measureHeight now early-returns when (textStorage.hash, containerSize.width) haven't changed. This makes the unconditional scheduleMeasureHeight at the end of updateNSView safe — previously guarded by if textWasExternallyReplaced || fontChanged, the skip-guard is a more comprehensive check that catches ALL no-op cases.
3. invalidateMeasureHeightCache() — Called in the fontChanged || colorChanged branch of updateNSView as a belt-and-braces fallback for typography mutations that may not alter NSAttributedString.hash deterministically.
4. Lifecycle cleanup — dismantleNSView calls cancelPendingMeasureHeight() so deferred work never fires against a torn-down view.
Review notes
- Unconditional
scheduleMeasureHeightinupdateNSViewreplaces the oldif textWasExternallyReplaced || fontChangedguard. This is correct — the skip-guard insidemeasureHeighthandles the no-op case more comprehensively (hash+width vs. just text/font change detection). The deferred call is cheap when skipped (one hash comparison). DispatchWorkItemcancellation means during high-frequency streaming re-evals, only the last scheduled measurement fires. No accumulation of queued work items.NSAttributedString.hashis content-and-attribute based per Foundation — reliable for detecting text changes. Width check catches resize without text change.- TextKit 1 stays on main thread —
ensureLayoutis deferred but never moved off-main. Correct per Apple docs forNSLayoutManager. IntrinsicScrollView.contentHeight0.5pt delta smoothing is preserved — deferred measurement should land within the same render frame.
QA focus
- Composer height tracking on: type, paste, resize, split-view, window resize, font/theme change
- No visible height pop on mount or send (text → "")
- First paste of large content should not hang
Follow-up candidates (noted in PR)
HighlightedTextView.swift:320— same uncoalescedensureLayoutpatternVCodeView.swift:336— same patternSelectableTextView.swift:107,186— same pattern
Adopt the callback-based focus / cursor-position wiring and per-property\nchange guards introduced by #25597, and keep the measureHeight coalescer\n+ skip-guard from this branch. The guarded `if textWasExternallyReplaced\n|| fontChanged` re-measure in updateNSView now routes through\n`scheduleMeasureHeight` so it coalesces with frame/bounds notifications\nthat the same event will fire. Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
Routes every
measureHeightcall site in the chat composer through aDispatchWorkItem-based scheduler and adds a(textStorage.hash, textContainer.containerSize.width)skip-guard insidemeasureHeight, so upstream SwiftUI state cascades, scroll/resize notifications, and per-keystroketextDidChangeno longer stack O(n) synchronousNSLayoutManager.ensureLayoutpasses that were blocking the main thread for 2s+ on large composer content (LUM-827, Sentry VELLUM-ASSISTANT-MACOS-EH). All layout work stays on the main thread (NSLayoutManageris not thread-safe), the TextKit 1 stack andIntrinsicScrollView.contentHeightone-way flow are untouched,ensureLayoutis still invoked whenever inputs change sousedRectnever returns stale values, anddismantleNSViewcancels any pending work.What changed
Coordinator.scheduleMeasureHeight(_:)cancels any pendingDispatchWorkItemand enqueues a new one viaDispatchQueue.main.async— rapid bursts collapse into oneensureLayoutpass on the next main-queue turn.measureHeightearly-returns when(textStorage.hash, containerSize.width)match the last measurement.NSAttributedString.hashis content-and-attribute based (Foundation), so it invalidates on any edit;containerSize.width(underwidthTracksTextView = true) captures window/split-view resize.invalidateMeasureHeightCache()is called in thefontChanged || colorChangedbranch ofupdateNSViewas a belt-and-braces fallback for typography mutations that may not produce a deterministic hash delta.makeNSViewinitial measurement,textDidChange, and thetextWasExternallyReplaced || fontChanged-guarded re-measure inupdateNSView) go throughscheduleMeasureHeightso they coalesce across a single event.dismantleNSViewcallscancelPendingMeasureHeight()— no deferred layout pass fires against a torn-down view.Builds on top of #25597 / LUM-832, which already converted the focus/cursor bindings to callbacks and guarded
updateNSViewbehind change checks. That PR reduced the frequency ofmeasureHeightcalls; this one makes each call cheap when nothing layout-relevant changed.Reviewer focus (highest risk first)
NSAttributedString.hashchanging whenever content or attributes do. The typography-change fallback is defensive; worth sanity-checking that font/theme changes actually re-flow the composer.updateNSViewre-measure after external text replacement) now lands oneDispatchQueue.main.asynchop later.IntrinsicScrollView.contentHeightalready smooths < 0.5pt deltas, so this should land within the same render frame — confirm no visible pop on mount, send (text → ""), or first paste of large content.DispatchWorkItemcaptures[weak self, weak textView];dismantleNSViewcancels pending work. No retain cycle and no layout against a torn-down view.Alternatives considered and rejected
Documented so future work doesn't revisit dead ends:
ensureLayoutoff-main —NSLayoutManageris not thread-safe.ensureLayoutentirely — staleusedRectcauses composer jump/clip (regresses LUM-622).ensureLayout(forGlyphRange:in:)— doesn't yield total content height.measureHeightforisVerticallyResizable+frameDidChangeNotificationonly —NSTextViewresizes after layout, but SwiftUI needs the height at the start of the render cycle.Related context
EquatableonComposerView/ComposerSectionto reduce the frequency ofupdateNSView; complementary to this PR.IntrinsicScrollView.contentHeightone-way flow — preserved here.Similar
ensureLayoutpatterns inHighlightedTextView.swift,VCodeView.swift, andSelectableTextView.swiftare out of scope here and candidates for a follow-up with the same coalescing shape.Test plan
Link to Devin session: https://app.devin.ai/sessions/823de84dc8854081aea1f926200f39e5
Requested by: @ashleeradka