Skip to content

Fix ScrollView.frame(maxHeight:) anti-pattern in 5 LazyVStack cell files#24091

Merged
ashleeradka merged 5 commits into
mainfrom
devin/1775592446-fix-scrollview-maxheight-antipattern
Apr 7, 2026
Merged

Fix ScrollView.frame(maxHeight:) anti-pattern in 5 LazyVStack cell files#24091
ashleeradka merged 5 commits into
mainfrom
devin/1775592446-fix-scrollview-maxheight-antipattern

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Apr 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Eliminates all remaining .frame(maxHeight:) on ScrollViews inside LazyVStack cells — the anti-pattern that caused the 30–50 second main-thread hang fixed in #24019. .frame(maxHeight:) compiles to _FlexFrameLayout (recursive child measurement, O(n × depth)); .frame(height:) compiles to _FixedSizeLayout (O(1), never measures children).

All 5 files use a two-path pattern: long content gets a definite-height ScrollView; short content gets no ScrollView or natural-height rendering. A charThreshold guard (50k UTF-8 bytes) catches single-line mega-strings that would wrap into many visual lines.

File Threshold Capped height Notes
CodeBlockView (MarkdownSegmentView.swift) >25 lines 400pt Main culprit for 53s hang. Replaced unbounded height with capped two-path. Long path adds vertical scroll axis. Replaced .components(separatedBy:) with utf8.reduce.
CodePreviewView (ChatWidgetViews.swift) >7 lines 120pt Short path: plain Text, no ScrollView
GuardianDecisionBubble >7 lines 120pt Short path: plain Text, no ScrollView
InlineChatErrorAlert >10 lines 160pt Short path: plain Text, no ScrollView
VDiffView lines.count > Int(maxHeight / 16) caller-provided maxHeight Short path: horizontal-only scroll with .fixedSize(horizontal: false, vertical: true)

References: WWDC 2023 — Demystify SwiftUI performance, _FlexFrameLayout vs _FixedSizeLayout, SE-0373 — let in result builders

Review & Testing Checklist for Human

CI has no macOS build — all Swift changes are unverified by CI. Local Xcode build + manual testing required.

  • Build in Xcode — verify let bindings inside @ViewBuilder bodies compile (SE-0373). All 5 files use this pattern.
  • CodeBlockView (highest risk — biggest layout impact, main culprit for 53s hang) — paste a >25 line code block in chat → should cap at 400pt with both-axis scroll. Paste a <25 line block → should render at natural height with horizontal-only scroll.
  • VDiffView — long diff → renders at definite height. Short diff → natural content height, no empty space below.
  • CodePreviewView / GuardianDecisionBubble / InlineChatErrorAlert — verify long content caps at the expected height; short content renders at natural height without scroll chrome.

Notes

  • CodeBlockView's lineThreshold: 25 is approximate (derived from 400pt / codeLineHeight). The actual codeLineHeight is computed from NSLayoutManager font metrics for DMMono-Regular 13pt.
  • Each two-path pattern duplicates the Text view configuration across the if/else branches — if styling changes, both branches must be updated.
  • The adaptiveScrollFrame modifier and ToolCallChip.swift were already fixed in Fix LazyVStack cell hangs: definite scroll heights + cached coloredOutput #24019 — no changes needed.
  • Completes LUM-758.

Link to Devin session: https://app.devin.ai/sessions/38404b0c14494b9b97274a5fb6110b25
Requested by: @ashleeradka


Open with Devin

…remaining LazyVStack cells

Replace _FlexFrameLayout (recursive measurement, O(n × depth)) with
_FixedSizeLayout (O(1)) or no ScrollView for short content.

- CodePreviewView: two-path — >7 lines: ScrollView.frame(height: 120),
  otherwise plain Text
- InlineChatErrorAlert: two-path — >10 lines: ScrollView.frame(height: 160),
  otherwise plain Text
- GuardianDecisionBubble: two-path — >7 lines: ScrollView.frame(height: 120),
  otherwise plain Text
- VDiffView: frame(maxHeight:) → frame(height:) — callers pass definite values

Line counting uses zero-allocation utf8.reduce byte scan.

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

devin-ai-integration[bot]

This comment was marked as resolved.

When maxHeight is provided but content is short (lines.count <= maxHeight/16),
use the horizontal-only scroll with fixedSize instead of forcing a definite
height. Prevents empty space below short diffs in ToolConfirmationBubble (260pt)
and Gallery (120pt).

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
chatgpt-codex-connector[bot]

This comment was marked as resolved.

Catches mega-strings (e.g. minified JSON, base64 data) that are a single
line but exceed 50,000 UTF-8 bytes. Consistent with adaptiveScrollFrame's
charThreshold pattern from PR #24019.

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
ashleeradka
ashleeradka previously approved these changes Apr 7, 2026

@ashleeradka ashleeradka left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Vex review: Ship it.

All 4 files correctly implement the two-path pattern from #24019:

  • CodePreviewView — 7-line threshold, 120pt definite height, utf8.reduce + 50k mega-string guard ✅
  • GuardianDecisionBubble — 7 lines / 120pt, textSelection preserved on both paths ✅
  • InlineChatErrorAlert — 10 lines / 160pt, outer padding unchanged ✅
  • VDiffView — dynamic threshold via Int(maxHeight / 16), short path preserves natural height with .fixedSize(horizontal: false, vertical: true)

Minor note: displayCode is a computed property, so it's evaluated twice (once for lineCount, once for Text). Acknowledged in the PR body as negligible for ≤30 lines — agreed.

Ready for local build + smoke test.

@ashleeradka

Copy link
Copy Markdown
Contributor

Additional Fix Required: CodeBlockView in MarkdownSegmentView.swift

The 4 files fixed in this PR are correct ✅, but QA testing revealed the biggest offender is missing: the CodeBlockView inside MarkdownSegmentView.swift — this is the component that renders markdown fenced code blocks in chat messages.

File

clients/macos/vellum-assistant/Features/Chat/MarkdownSegmentView.swift, line ~772

Current Code (problematic)

let codeLineCount = code.components(separatedBy: "\n").count
let codeBlockHeight = CGFloat(codeLineCount) * Self.codeLineHeight + VSpacing.sm * 2

ScrollView(.horizontal, showsIndicators: false) {
    Text(code)
        .font(.custom("DMMono-Regular", size: 13))
        .foregroundStyle(textColor)
        .textSelection(.enabled)
        .fixedSize(horizontal: true, vertical: true)
        .padding(VSpacing.sm)
}
.frame(height: codeBlockHeight)

Problem

codeBlockHeight grows linearly with no cap. A 500-line code block produces ~8000pt height. Inside a LazyVStack cell, this forces enormous cell measurement. Combined with .fixedSize(horizontal: true, vertical: true), the layout engine must measure the full intrinsic size.

Fix Direction

Apply the same two-path pattern used in the other 4 files:

private static let maxCodeBlockHeight: CGFloat = 400
private static let lineThreshold = 25 // ~25 lines * 16pt ≈ 400pt

// ...inside body...

let codeLineCount = code.utf8.reduce(0) { $0 + ($1 == UInt8(ascii: "\n") ? 1 : 0) } + 1

if codeLineCount > Self.lineThreshold {
    // Long code block: both horizontal AND vertical scroll, capped height
    ScrollView([.horizontal, .vertical], showsIndicators: true) {
        Text(code)
            .font(.custom("DMMono-Regular", size: 13))
            .foregroundStyle(textColor)
            .textSelection(.enabled)
            .fixedSize(horizontal: true, vertical: true)
            .padding(VSpacing.sm)
    }
    .frame(height: Self.maxCodeBlockHeight)  // definite height, O(1) layout
} else {
    // Short code block: horizontal scroll only, natural height
    let codeBlockHeight = CGFloat(codeLineCount) * Self.codeLineHeight + VSpacing.sm * 2
    ScrollView(.horizontal, showsIndicators: false) {
        Text(code)
            .font(.custom("DMMono-Regular", size: 13))
            .foregroundStyle(textColor)
            .textSelection(.enabled)
            .fixedSize(horizontal: true, vertical: true)
            .padding(VSpacing.sm)
    }
    .frame(height: codeBlockHeight)
}

Key Rules

  1. Use utf8.reduce for O(n) byte-scan line counting (matches the pattern in the other 4 files), not .components(separatedBy:) which allocates an array
  2. Long path: .frame(height: 400)definite height, never maxHeight
  3. Short path: keep the existing .frame(height: codeBlockHeight) — also definite, just natural size
  4. Long path needs both .horizontal and .vertical scroll axes since content exceeds the cap
  5. Keep the rest of the CodeBlockView (language header, copy button, background, etc.) unchanged

This is the component causing the 53-second hang in the spindump analysis. The other 4 files were correct but this was the main culprit for chat message rendering.

…yVStack cells

CodeBlockView previously grew linearly with no cap — a 500-line code block
produced ~8000pt height, forcing enormous cell measurement inside LazyVStack.

Two-path pattern:
- Long code (>25 lines or single-line >50k UTF-8 bytes): both horizontal and
  vertical scroll axes with .frame(height: 400) — definite _FixedSizeLayout, O(1)
- Short code: horizontal-only scroll at natural height (existing behavior)

Also replaces .components(separatedBy:) with zero-allocation utf8.reduce byte-scan
for line counting, consistent with the other 4 files in this PR.

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

Fixed the CodeBlockView in 2563c5d. Now uses the two-path pattern:

  • Long code (>25 lines or single-line >50k UTF-8 bytes): ScrollView([.horizontal, .vertical]).frame(height: 400) — definite _FixedSizeLayout, O(1)
  • Short code: horizontal-only scroll at natural height (existing .frame(height: codeBlockHeight) behavior preserved)

Also replaced .components(separatedBy:) with zero-allocation utf8.reduce byte-scan for line counting.

@devin-ai-integration devin-ai-integration Bot changed the title Fix ScrollView.frame(maxHeight:) anti-pattern in 4 remaining LazyVStack cell files Fix ScrollView.frame(maxHeight:) anti-pattern in 5 LazyVStack cell files Apr 7, 2026
ashleeradka
ashleeradka previously approved these changes Apr 7, 2026

@ashleeradka ashleeradka left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed after CodeBlockView addition. All 5 files now correctly implement the two-path pattern:

CodeBlockView (MarkdownSegmentView.swift) — the big one:

  • lineThreshold=25 (derived from 400pt / codeLineHeight), maxCodeBlockHeight=400
  • Long path: ScrollView([.horizontal, .vertical]).frame(height: 400) — definite height, O(1) via _FixedSizeLayout
  • Short path: horizontal-only ScrollView.frame(height: codeBlockHeight) — still definite (lineCount * lineHeight + padding) ✅
  • Line counting switched from .components(separatedBy:) to utf8.reduce byte-scan ✅
  • 50k mega-string guard on single-line ✅

CodePreviewView — 7 lines / 120pt ✅
GuardianDecisionBubble — 7 lines / 120pt ✅
InlineChatErrorAlert — 10 lines / 160pt ✅
VDiffView — dynamic Int(maxHeight/16) threshold, short path .fixedSize(horizontal: false, vertical: true)

No .frame(maxHeight:) remains in any ScrollView. Ship it.

devin-ai-integration[bot]

This comment was marked as resolved.

…ockView threshold from font metrics

VDiffView: changed divisor from 16 to 18 to avoid boundary-case overshoot
where short-path content could slightly exceed maxHeight (~12pt at 260pt).

CodeBlockView: lineThreshold is now computed as Int(maxCodeBlockHeight / codeLineHeight)
instead of hardcoded 25, so it stays correct if the mono font changes.

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
@ashleeradka ashleeradka merged commit 39ccf4c into main Apr 7, 2026
7 checks passed
@ashleeradka ashleeradka deleted the devin/1775592446-fix-scrollview-maxheight-antipattern branch April 7, 2026 21:07

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 10 additional findings in Devin Review.

Open in Devin Review


/// Line threshold derived from maxCodeBlockHeight / codeLineHeight.
/// Content above this count takes the capped-height ScrollView path.
private static let lineThreshold: Int = Int(maxCodeBlockHeight / codeLineHeight)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 CodeBlockView lineThreshold ignores padding, causing short-path height to exceed maxCodeBlockHeight

The lineThreshold at MarkdownSegmentView.swift:788 is computed as Int(maxCodeBlockHeight / codeLineHeight) but doesn't account for the VSpacing.sm * 2 (16pt) padding added in the short-path height formula at line 835: CGFloat(codeLineCount) * Self.codeLineHeight + VSpacing.sm * 2. For code blocks at exactly lineThreshold lines, the short-path height is lineThreshold * codeLineHeight + 16, which exceeds maxCodeBlockHeight (400pt) by up to 16pt. For example, if codeLineHeight = 16pt, then lineThreshold = 25 and a 25-line block renders at 416pt (short path), while a 26-line block renders at 400pt (long path) — a visual discontinuity where adding one line makes the view shorter.

Suggested change
private static let lineThreshold: Int = Int(maxCodeBlockHeight / codeLineHeight)
private static let lineThreshold: Int = Int((maxCodeBlockHeight - VSpacing.sm * 2) / codeLineHeight)
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

Devin is archived and cannot be woken up. Please unarchive Devin if you want to continue using it.

ashleeradka added a commit that referenced this pull request Apr 22, 2026
…ssions (#27554)

Adds a fast ripgrep-based guard that fails CI when a new `.frame(maxWidth:)`
or `.frame(maxHeight:)` is introduced inside `Features/Chat/` or
`Features/MainWindow/`. These modifiers create `_FlexFrameLayout`, which
cascades `explicitAlignment` queries through descendants and has caused
multi-second hangs in LazyVStack-backed chat surfaces 9+ times
(PRs #24019, #24091, #24584, #24589, #25844, #25947, #26007, #26053,
#26092, #26220). The manual audit process missed regressions twice — this
lint enforces the AGENTS.md:277-286 rule mechanically. Tracked in LUM-1116.

Content-hash allowlist (`clients/scripts/flexframe-allowlist.txt`) seeded
with the 170 existing occurrences so the check passes on current main.
Entries are keyed on `<path>|<trimmed-line>` so unrelated line drift
doesn't break them.
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