Skip to content

perf: eliminate FlexFrameLayout anti-patterns causing 2s+ LazyVStack hangs#25844

Merged
ashleeradka merged 5 commits into
mainfrom
devin/LUM-835-1776269239-fix-flexframe-antipatterns
Apr 15, 2026
Merged

perf: eliminate FlexFrameLayout anti-patterns causing 2s+ LazyVStack hangs#25844
ashleeradka merged 5 commits into
mainfrom
devin/LUM-835-1776269239-fix-flexframe-antipatterns

Conversation

@devin-ai-integration
Copy link
Copy Markdown
Contributor

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

Replaces _FlexFrameLayout patterns (.frame(maxWidth:), .frame(maxHeight:)) with definite-dimension _FrameLayout (.frame(width:, height:)) and HStack+Spacer centering inside LazyVStack cell views, eliminating the recursive explicitAlignment cascade that causes 2s+ main-thread hangs (Sentry: VELLUM-ASSISTANT-MACOS-9B).

Why needed

.frame(maxWidth:) / .frame(maxHeight:) compile to _FlexFrameLayout, whose default .center alignment triggers explicitAlignment queries that cascade O(n × depth) through every LazyVStack cell subtree. .frame(width:, height:) compiles to _FrameLayout, returning bounds.midX without querying children — O(1).

Confirmed via A/B spin dumps: main branch shows LazySubviewPrefetcher → sizeThatFits → explicitAlignment cascade (8+ levels deep through nested _HStackLayout); fix branch shows zero samples in this path.

Changes

  • InlineFilePreviewViewScrollView { }.frame(maxHeight: 400) → two-path pattern: content exceeding 25 lines or 50 KB gets ScrollView { }.frame(height: 400), short content renders directly without a ScrollView. loadingView/errorView centering changed from .frame(maxWidth: .infinity, alignment: .center) to HStack + Spacer. Also resets loadError on successful content load (bug fix).
  • ChatBubbleAttachmentContent — Extracted AspectFitImageView, a reusable struct that computes aspect-fit dimensions via computeFitSize (CGImage pixels ÷ displayScale, falling back to NSImage.size) and renders with definite frame(width:height:). Replaces ~60 lines of duplicated logic across InlineToolCallImageView and AttachmentImageGrid.singleImageContent. Placeholder rectangle collapsed from two-frame to single definite frame(width:, height:).
  • AGENTS.md — Added rule documenting the frame(maxHeight:) ScrollView anti-pattern and two-path alternative.

Why safe

Same pattern proven across 5+ cell views in PR #24091. The computeFitSize formula produces identical bounding boxes to the old min(nativeWidth, maxDim) / min(nativeHeight, maxDim) with .fit. The min(..., 1.0) scale clamp prevents upscaling small images.

Alternatives not taken

Approach Why rejected
Ancestor-level FlexFrame removal Too risky — broke EmptyView fallback sizing, pane collapse, and windowSize measurement previously
onGeometryChange + @State height Two-pass layout cycle with visible first-frame height jump
GeometryReader Known layout loop source in LazyVStack; discouraged in WWDC23: Demystify SwiftUI Performance
.containerRelativeFrame Can't express aspect-ratio-aware sizing from pixel dimensions
HStack+Spacer wrapping inner .frame(maxWidth:) Apple's Layout.explicitAlignment docs confirm default impl "merges the subviews' guides" — HStack recurses into children, does NOT block the cascade

References

Prompt / plan

Investigated LUM-835 by tracing Sentry stacktraces (9B, GJ, EP) to identify three hang mechanisms in LazyVStack cells. Fixed by replacing _FlexFrameLayout with _FrameLayout barriers at the cell level and extracting duplicated aspect-fit logic into AspectFitImageView.

Test plan

  • CI passes (macOS builds skipped in CI — requires local Xcode build)
  • Image aspect ratios preserved for single and grid layouts; small images stay at natural size
  • Short file previews render at natural height; long ones cap at 400pt with scrolling
  • Loading/error states remain centered
  • File preview retry clears previous error state

⚠️ Human review checklist

  • AspectFitImageView calls cgImage(forProposedRect:) twice (once in computeFitSize, once in body). NSImage caches internally — verify no visible perf regression with many images.
  • Single-image placeholder uses fixed width: VSpacing.chatBubbleMaxWidth — verify correct when bubble container is narrower.
  • Two-path contentArea branching relies on cachedContent being set-once — if this invariant changes, the if/else could cause view identity thrash.

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


Open with Devin

…hangs

Replace _FlexFrameLayout patterns with _FrameLayout (definite dimensions) in
two files to stop alignment cascade and recursive measurement inside LazyVStack
cells.

InlineFilePreviewView:
- Replace ScrollView.frame(maxHeight: 400) with two-path pattern: definite-
  height ScrollView for long content (>25 lines or >50KB), no ScrollView for
  short content
- Replace .frame(maxWidth: .infinity, alignment: .center) in loadingView and
  errorView with HStack+Spacer centering
- Fix loadError not being reset on successful content load
- Extract markdownContent into a shared computed property

ChatBubbleAttachmentContent:
- Replace .frame(maxWidth:, maxHeight:) with computed definite .frame(width:,
  height:) using aspect-fit calculation in InlineToolCallImageView.imageContent
  and AttachmentImageGrid.singleImageContent
- Replace .frame(maxWidth:) fallback with .frame(width:) for images without
  CGImage representation
- Replace two-frame placeholder pattern with single definite .frame(width:,
  height:) for single-image placeholders
- Guard against nativeWidth == 0 to prevent division by zero

Fixes LUM-835

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

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b23f8af2d8

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

maxWidth: min(nativeWidth, maxDim),
maxHeight: min(nativeHeight, maxDim)
)
.frame(width: fitWidth, height: fitHeight)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Clamp image frame width to available bubble width

Using a fixed frame(width: fitWidth, height: fitHeight) here removes the parent-driven shrinking behavior that maxWidth previously provided. In narrow chat layouts (where the transcript computes a smaller bubble width than VSpacing.chatBubbleMaxWidth), single-image attachments can now render wider than their bubble and appear clipped/overflowed. This should be clamped to the effective bubble width (for example via the bubbleMaxWidth environment value) before applying a fixed frame.

Useful? React with 👍 / 👎.

Comment on lines +144 to +145
let lineCount = content.utf8.reduce(1) { $0 + ($1 == 0x0A ? 1 : 0) }
if lineCount > Self.lineThreshold || content.utf8.count > Self.charThreshold {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid rescanning attachment text on every view recompute

This computes newline and byte counts directly inside contentArea, so every SwiftUI body re-evaluation rescans the full file text. Expanded previews can re-render frequently while chat state updates, and large attachments will pay repeated O(n) UTF-8 scans on the main thread, causing avoidable jank. These thresholds should be computed once when cachedContent changes and stored in state.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

@ashleeradka ashleeradka left a comment

Choose a reason for hiding this comment

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

Approve with one fix needed.

InlineFilePreviewView changes are clean — two-path pattern with extracted markdownContent, HStack+Spacer centering, loadError reset on success, well-documented thresholds. The cgImage path in ChatBubbleAttachmentContent is correct with the aspect-fit scale-clamping math.

Issue: NSImage fallback paths (lines ~144 and ~385) upscale small images.

The old code used .frame(maxWidth: chatBubbleMaxWidth) which let small images stay at their natural size. The new .frame(width: chatBubbleMaxWidth) forces ALL fallback images to exactly 760pt wide. With .resizable().aspectRatio(contentMode: .fit), a small icon or thumbnail gets upscaled to fill 760pt — blurry and wrong.

The cgImage path correctly prevents upscaling with min(..., 1.0) in the scale factor, but the fallback paths don't have equivalent protection.

Fix: Replace .frame(width: VSpacing.chatBubbleMaxWidth) in the fallback paths with the HStack+Spacer pattern:

HStack(spacing: 0) {
    Image(nsImage: renderImage)
        .resizable()
        .aspectRatio(contentMode: .fit)
        .frame(maxWidth: VSpacing.chatBubbleMaxWidth) // keep maxWidth here — no cgImage dims to compute definite size
        .clipShape(RoundedRectangle(cornerRadius: VRadius.md))
    Spacer(minLength: 0)
}

This avoids the FlexFrame alignment cascade (HStack+Spacer handles leading alignment without triggering explicitAlignment queries on children) while preserving the natural sizing behavior for small images.

Reviewed by Vex ✦

…age upscaling

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
@devin-ai-integration
Copy link
Copy Markdown
Contributor Author

Re: NSImage fallback paths upscaling small images (@ashleeradka)

Fixed in 4923c43. Both fallback paths now use HStack(spacing: 0) { Image(...).frame(maxWidth: VSpacing.chatBubbleMaxWidth); Spacer(minLength: 0) } — preserves natural sizing for small images while the HStack+Spacer handles leading alignment without triggering explicitAlignment queries.

@devin-ai-integration
Copy link
Copy Markdown
Contributor Author

Re: Codex P1 — Clamp image frame width to available bubble width

The cgImage path now uses definite frame(width: fitWidth, height: fitHeight) where the aspect-fit formula already clamps to chatBubbleMaxWidth. The else fallback (no cgImage) was updated in 4923c43 to use HStack + Spacer with frame(maxWidth:), which preserves parent-driven shrinking.

Re: Codex P2 — Avoid rescanning attachment text on every view recompute

cachedContent is set once on expand and never mutates — the utf8.reduce byte-scan only runs when the content area re-renders while expanded (infrequent). The scan is zero-allocation with excellent cache locality; for the 50KB threshold it completes in microseconds. The cost is negligible compared to the layout cascade this PR eliminates.

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

This comment was marked as resolved.

HStack+Spacer does NOT fully block the _FlexFrameLayout alignment
cascade — Apple's Layout protocol default explicitAlignment merges
subviews' guides, recursing into children. Use NSImage.size to compute
definite frame(width:height:) instead, matching the cgImage path and
producing _FrameLayout (O(1), no alignment queries).

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
Copy link
Copy Markdown
Contributor Author

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

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 6 additional findings in Devin Review.

Open in Devin Review

Comment on lines +145 to 152
if lineCount > Self.lineThreshold || content.utf8.count > Self.charThreshold {
ScrollView {
markdownContent
}
.frame(height: Self.maxContentHeight)
} else {
markdownContent
}
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.

🚩 Nested vertical ScrollViews for long non-markdown files

For non-markdown files exceeding 25 lines, InlineFilePreviewView wraps markdownContent in a ScrollView with height: 400 (InlineFilePreviewView.swift:146-149). The markdownContent renders a MarkdownSegmentView which produces a CodeBlockView. CodeBlockView has its own vertical+horizontal ScrollView with height: 400 for long code (MarkdownSegmentView.swift:889-897). This creates nested vertical ScrollViews with the same height cap. The outer ScrollView is slightly scrollable (inner is 400pt + header + padding > 400pt), which is a suboptimal UX. However, this is a pre-existing issue — the old code always wrapped in ScrollView { ... }.frame(maxHeight: 400), producing the same nesting. The new code is actually better for medium-length content (23-25 lines) where the outer ScrollView is now omitted entirely.

Open in Devin Review

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

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.

Agreed — this is a pre-existing issue as the review notes. The old code always wrapped in ScrollView { }.frame(maxHeight: 400), producing the same nesting. This PR improves the situation for medium-length content by omitting the outer ScrollView entirely. The nested ScrollView case for long non-markdown files could be addressed in a follow-up but is orthogonal to the FlexFrame fixes here.

ashleeradka
ashleeradka previously approved these changes Apr 15, 2026
Copy link
Copy Markdown
Contributor

@ashleeradka ashleeradka left a comment

Choose a reason for hiding this comment

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

All feedback addressed. NSImage fallback now uses renderImage.size (point dimensions) with the same scale-clamping math as the cgImage path — no upscaling, correct aspect ratio, zero-dimension guard. Placeholder width change is fine since it's a transient loading state.

InlineFilePreviewView two-path pattern is clean. AGENTS.md rule addition is good for regression prevention.

Ready for Boss QA. Test plan:

  1. File preview with short content (<25 lines) — should render at natural height, no ScrollView
  2. File preview with long content (>25 lines) — should cap at 400pt with scrolling
  3. Images: small images should stay at natural size (no upscaling), large images should scale down to fit 760pt
  4. Image placeholders during loading — should show correctly
  5. File preview retry after error — should clear error state on success

Vex ✦

…rendering

Extract the duplicated aspect-fit sizing logic and image rendering pattern
from InlineToolCallImageView.imageContent and AttachmentImageGrid.singleImageContent
into a reusable AspectFitImageView struct.

The new view encapsulates:
- CGImage pixel → point conversion (divided by displayScale)
- NSImage.size fallback for point dimensions
- Aspect-fit scale computation with min(..., 1.0) upscale clamp
- Definite frame(width:height:) sizing (FrameLayout, not FlexFrameLayout)
- Rounded corner clipping

Both call sites now use a single AspectFitImageView initializer instead of
30+ lines of duplicated logic each.

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
@ashleeradka ashleeradka merged commit 6e8fc49 into main Apr 15, 2026
9 checks passed
@ashleeradka ashleeradka deleted the devin/LUM-835-1776269239-fix-flexframe-antipatterns branch April 15, 2026 19:23
@vellum-automation
Copy link
Copy Markdown
Contributor

🖥️ macOS app artifact for this PR is ready for download: Download vellum-assistant-pr-25844.dmg

To run this build locally:

vel up macos --pr 25844

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.
ashleeradka added a commit that referenced this pull request Apr 22, 2026
… (LUM-1117)

Chat images, inline document previews, and thinking blocks all computed their
width from the static VSpacing.chatBubbleMaxWidth (760pt) and fed it into a
definite .frame(width:). At non-fullscreen window widths the content stayed
760pt wide and overflowed the actual chat column.

ChatBubble already threads a container-aware bubbleMaxWidth via Environment,
but these peer views never read it. Swap the static reference for an
@Environment(\.bubbleMaxWidth) read in each site, falling back to the 760
default only when the env is 0 (first layout pass, per MessageListLayoutMetrics)
or unset (non-chat contexts).

Sites:
- AnimatedImageView — markdown inline images.
- InlineToolCallImageView — tool-generated images in the message flow.
- AttachmentImageGrid — single-image attachments and pre-decode placeholder.
  Decode targetSize kept static so window resize doesn't re-fire the decode
  task on every frame.
- InlineFilePreviewView — markdown budget for the expanded document card.
- ThinkingBlockView — same pattern, inherited the same static cap.

Shape stays .frame(width: computed) throughout; no .frame(maxWidth:) is
introduced, so the _FlexFrameLayout hang patterns fixed in #25844, #26053,
and #26092 stay out.

Adds a durable AGENTS.md bullet describing the env-vs-token contract so the
next editor doesn't reach for the static token by default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ashleeradka added a commit that referenced this pull request Apr 22, 2026
… (LUM-1117) (#27557)

Chat images, inline document previews, and thinking blocks all computed their
width from the static VSpacing.chatBubbleMaxWidth (760pt) and fed it into a
definite .frame(width:). At non-fullscreen window widths the content stayed
760pt wide and overflowed the actual chat column.

ChatBubble already threads a container-aware bubbleMaxWidth via Environment,
but these peer views never read it. Swap the static reference for an
@Environment(\.bubbleMaxWidth) read in each site, falling back to the 760
default only when the env is 0 (first layout pass, per MessageListLayoutMetrics)
or unset (non-chat contexts).

Sites:
- AnimatedImageView — markdown inline images.
- InlineToolCallImageView — tool-generated images in the message flow.
- AttachmentImageGrid — single-image attachments and pre-decode placeholder.
  Decode targetSize kept static so window resize doesn't re-fire the decode
  task on every frame.
- InlineFilePreviewView — markdown budget for the expanded document card.
- ThinkingBlockView — same pattern, inherited the same static cap.

Shape stays .frame(width: computed) throughout; no .frame(maxWidth:) is
introduced, so the _FlexFrameLayout hang patterns fixed in #25844, #26053,
and #26092 stay out.

Adds a durable AGENTS.md bullet describing the env-vs-token contract so the
next editor doesn't reach for the static token by default.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant