[LUM-947] Cache sizeThatFits measurements in display text views#26170
Conversation
🤖 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 ✦
Solid performance optimization — mirrors the battle-tested pattern from #26054 (ComposerTextEditor) and extends it to all three display-path ensureLayout call sites. The approach is sound: skip-guards keyed on (textStorage.length, width) for instance-level sizeThatFits, plus a bounded FIFO cache for the static measureSize path.
What I verified:
Cache invalidation completeness:
SelectableTextView:applyAttributedStringandresetboth callinvalidateMeasurementCache()— these are the only two mutation paths for read-only display cells. ✅VCodeView(VCodeTextView):applyTextinvalidates on plain text set, and the async syntax-highlight completion invalidates again (font variants can change line height). Search highlights only modify.backgroundColorattributes, not text content, so they correctly don't invalidate. ✅HighlightedTextView(CodeTextView):updateNSViewtext replacement andtextDidChangeboth invalidate. ✅
Cache key safety:
textStorage.lengthas key: length-collision with different content is theoretically possible but moot — all content changes flow through methods that explicitly invalidate. These are read-only display views; users can't type into them.- Static
measureSizecache usesMeasurementKeywith customHashablethat falls through toNSAttributedString.isEqual(_:)for equality — hash collisions can't produce wrong heights. Good defensive design.
FIFO eviction in measureSize:
- Cliff eviction at 256 entries (removeAll then re-add). Simple, bounded memory, and the PR description correctly notes "Dropping is cheap and avoids per-entry bookkeeping; the next measurement rehydrates the working set." For a display cache in a scrolling list, the working set is small relative to the limit. Fine.
Width tracking upgrade:
- The old
cachedHeightinVCodeView/HighlightedTextViewonly invalidated on text change, not width change. This PR correctly keys on(length, width)— fixes silent degradation when proposed widths oscillate during streaming/resize. This was the actual bug the old cache missed.
Minor note (non-blocking):
- The
measurementSizeCacheisstaticonVSelectableTextView— it persists for the app lifetime. The 256-entry FIFO bound keeps this reasonable, but worth noting thatNSAttributedStringreferences are retained in the dictionary keys. For long conversations, after the cliff eviction the old strings become eligible for release, so no leak concern.
Clean extension of an established pattern to the remaining call sites. Ship it.
|
@devin review this PR |
1 similar comment
|
@devin review this PR |
…iews (LUM-947) Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
…tion Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
22038a7
22ef17e to
22038a7
Compare
There was a problem hiding this comment.
Re-approved after reviewing the three new commits. ✦
Post-review changes address Devin Review feedback cleanly:
- isEqual for cache keys — MeasurementKey uses NSAttributedString.isEqual instead of hash alone, eliminating collision risk for wrong-height cells.
- invalidateMeasurementCache() helper — replaces old cachedHeight=nil with atomic 3-field reset, consistent across all 3 files.
- Comment rephrasing — standalone descriptions, no PR iteration references.
Core approach unchanged and solid. All invalidation points verified across HighlightedTextView, SelectableTextView, and VCodeView. Ship it.
|
@devin review this PR |
…de lazy containers Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
9888fef
During the first LazyVStack layout pass, MessageListLayoutMetrics deliberately reports bubbleMaxWidth = 0 until GeometryReader resolves the chat column width. That zero flowed unchecked into MarkdownSegmentView.effectiveMaxWidth and VSelectableTextView.measureSize(maxWidth: 0), where ensureLayout returned a usedRect with height 0. Before the recent sizeThatFits-caching commit (#26170), the degenerate result self-healed on the next body pass; with the new caches, (0,0) was persisted at the attributedString + 0 + 4 key and the MarkdownSegmentView NSCache entry keyed on effectiveMaxWidth = 0, collapsing the cell and stacking every visible assistant message body at the same y. Two small, orthogonal guards: - VSelectableTextView.measureSize refuses maxWidth <= 0 or empty strings with an early .zero return (no cache write), and only writes non-zero heights into measurementSizeCache. - MarkdownSegmentView.resolveSelectableRunMeasurementResult skips the measuredTextCache insertion when size.height == 0. Both keep the perf wins from #26170 and #26242 (no revert). Adds a regression test that measures at maxContentWidth: 0 and asserts no cache poisoning. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…hes (#26316) During the first LazyVStack layout pass, MessageListLayoutMetrics deliberately reports bubbleMaxWidth = 0 until GeometryReader resolves the chat column width. That zero flowed unchecked into MarkdownSegmentView.effectiveMaxWidth and VSelectableTextView.measureSize(maxWidth: 0), where ensureLayout returned a usedRect with height 0. Before the recent sizeThatFits-caching commit (#26170), the degenerate result self-healed on the next body pass; with the new caches, (0,0) was persisted at the attributedString + 0 + 4 key and the MarkdownSegmentView NSCache entry keyed on effectiveMaxWidth = 0, collapsing the cell and stacking every visible assistant message body at the same y. Two small, orthogonal guards: - VSelectableTextView.measureSize refuses maxWidth <= 0 or empty strings with an early .zero return (no cache write), and only writes non-zero heights into measurementSizeCache. - MarkdownSegmentView.resolveSelectableRunMeasurementResult skips the measuredTextCache insertion when size.height == 0. Both keep the perf wins from #26170 and #26242 (no revert). Adds a regression test that measures at maxContentWidth: 0 and asserts no cache poisoning. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Memoizes the size measurements produced by three
NSViewRepresentabledisplay text views (VSelectableTextView,CodeTextView,VCodeTextView) so cells insideLazyVStackdon't rerunNSLayoutManager.ensureLayout(for:)on every scroll/resize pass.Why this is needed
NSLayoutManager.ensureLayout(for:)is O(n) in glyph count and must run on the main thread. SwiftUI callssizeThatFits(and the staticVSelectableTextView.measureSize) on every visible cell for everyLazyVStacklayout pass, which means scroll/resize cascades were re-computing the full TextKit layout for every bubble in a long conversation. With hundreds of messages and/or large code blocks the compounded cost stalls the main thread long enough that the sidebar and message list render feels sluggish or momentarily blank.Mirrors the cache landed for
ComposerTextEditorin #26054, adapted for these read-only display paths.Benefits
CodeTextView/VCodeTextView: the previous single-slotcachedHeightonly invalidated on text change. When SwiftUI proposed a new wrapping width with the same content the cached height was reused even though it no longer matched the proposal — this keyed cache catches that.Why it is safe
(content, width)pair always runs the real layout. The cache only short-circuits repeat queries with identical keys.applyAttributedString/reset(VSelectableTextView),updateNSViewtext replacement /textDidChange(CodeTextView),applyTextand the async syntax-highlight completion (VCodeTextView).VSelectableTextView.MeasurementKeyretains theNSAttributedStringsoDictionaryequality falls through toNSAttributedString.isEqual(_:), avoiding any risk that hash collisions surface as wrong-height cells. Memory is bounded by the 256-entry cap.ensureLayout+usedRectpair is elided.References
NSLayoutManager.ensureLayout(for:)NSViewRepresentable.sizeThatFits(_:nsView:context:)NSAttributedString.isEqual(_:)ComposerTextEditor).Alternatives considered and rejected
NSTextLayoutManager. More invasive, introduces a separate set of correctness risks (selection, link handling, line-break semantics), and would not by itself memoize repeat queries from SwiftUI. Caching measurements is orthogonal and composes with a future TextKit 2 migration.NSCacheacross the three views. Rejected because the cache keys and invalidation points are view-specific —VSelectableTextViewcan invalidate on attribute changes viaisEqual(_:), whileCodeTextView/VCodeTextViewrely on explicit invalidation at known mutation sites and use the cheaper(textStorage.length, width)key. Sharing would force the lowest-common-denominator key and make invalidation harder to reason about..frame(width:height:). Already supported inVSelectableTextViewviauseExternalSizing; the caller inMarkdownSegmentViewuses it. The instance-level cache is there to protect theuseExternalSizing = falsepath and the staticmeasureSizecall.DispatchWorkItem-based coalescer from fix(macos): coalesce ComposerTextEditor.measureHeight and skip redundant ensureLayout passes (LUM-827) #26054. Intentionally omitted:sizeThatFitsmust return a size synchronously, so debouncing into a later runloop turn would return stale or default sizes and break layout. Only the skip-guard half of the reference pattern applies here.Root cause analysis
How the code got here
VSelectableTextViewlanded with a shared measurementNSLayoutManagerbut no memoization. Its intended use viameasureSizewas to precompute a size from the SwiftUI side once per bubble — the caller inMarkdownSegmentViewdoes have its ownNSCache, which masked the missing internal cache. The instance-levelsizeThatFitspath (used when the caller does not opt intouseExternalSizing) had no cache at all.CodeTextView/VCodeTextViewdid have a single-slotcachedHeight, which worked while they lived in contexts that handed them a fixed width (file viewer panels). Once they were composed into chat message bubbles streaming inside aLazyVStack, proposed widths started oscillating (streaming layout, window resize, typing in the composer), and the cache silently stopped helping.Mistakes / decisions that led to it
NSViewRepresentablecontract that SwiftUI re-queries size on every layout pass.cachedHeightdid not key on width.ComposerTextEditorbut the fix was applied to one call site instead of generalized to the sibling display views.Warning signs we missed
ensureLayout(for:)call sites. There was no cross-reference.Prevention
NSViewRepresentable, grep for the same API in sibling representables before closing the ticket.cachedXfield without a composite key is a smell in anysizeThatFitspath.NSViewRepresentablethat hosts a TextKit stack and is placed inside a cell type (LazyVStack,LazyHStack,List,ScrollView),sizeThatFitsmust memoize.clients/AGENTS.mdguideline (committed in this PR)Added as a bullet under § "View Bodies and Rendering" and as a row in the pitfalls table so future changes to any TextKit-backed
NSViewRepresentableinherit the expectation:Test plan
main— verified locally (warnings limited to unrelatedVAvatarImage.swiftactor-isolation, no errors in the three modified files).VSelectableTextViewunchanged — onlyensureLayout+usedRectare elided on cache hits; the first measurement and every text-mutation path still re-runs layout.applyAttributedString/reset(VSelectableTextView),updateNSViewtext replacement /textDidChange(CodeTextView),applyTextplain apply + async highlight completion (VCodeTextView).Note on CI
macOS / iOS builds are skipped in CI for this repo. Xcode build verified locally against
mainbefore merging.Human review checklist
applyAttributedString,reset,updateNSView,textDidChange,applyText, and the async highlight completion are each correct for their view.MeasurementKeyequality viaisEqual(_:)is the right identity for attribute-sensitive paths (e.g. markdown styling changes).clients/AGENTS.mdaddition reads as a durable pattern-level rule, not a change note.Link to Devin session: https://app.devin.ai/sessions/97bf309bde854c228a2ff11fb059a459
Requested by: @ashleeradka