Skip to content

fix: eliminate _FlexFrameLayout anti-pattern from chat cell hierarchy#24589

Merged
dvargasfuertes merged 3 commits into
mainfrom
devin/1775761669-fix-flexframelayout-hang-user-messages
Apr 9, 2026
Merged

fix: eliminate _FlexFrameLayout anti-pattern from chat cell hierarchy#24589
dvargasfuertes merged 3 commits into
mainfrom
devin/1775761669-fix-flexframelayout-hang-user-messages

Conversation

@devin-ai-integration
Copy link
Copy Markdown
Contributor

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

Summary

Fixes a 35s+ main-thread hang after sending ~3 user messages and eliminates the _FlexFrameLayout anti-pattern from the 5 highest-impact shared chat files rendered inside LazyVStack cells.

.frame(maxWidth:) / .frame(maxHeight:) creates _FlexFrameLayout which recursively measures children and resolves explicitAlignment through the entire LazyVStack subtree — O(n × depth) per layout pass. .frame(width:) / .frame(height:) creates _FrameLayout — O(1), no alignment cascade. This is the same class of issue fixed in #24091, #24321, #24446, and #24563.

ChatBubble.swift (critical hang fix)

Replaces .frame(maxHeight:, alignment:) with .frame(height:, alignment:) in userMessageHeightWrapper (introduced in #24003, missed by prior cleanup). Uses a single view path (no if/else branching) so SwiftUI view identity is preserved and withAnimation still drives smooth height transitions. GeometryReader runs on all paths, keeping userMessageIntrinsicHeight up to date even while collapsed.

MarkdownRenderer.swift (6 deletions)

All .frame(maxWidth: .infinity, alignment: .leading) modifiers are redundant — parent VStack(alignment: .leading) already proposes full width.

ToolConfirmationBubble.swift (5 deletions)

Same rationale — parent VStacks and backgrounds handle sizing. adaptiveScrollFrame already uses safe .frame(height:).

InlineTableWidget.swift (7 deletions + 1 width change)

Removes 7 redundant .frame(maxWidth:) instances. Converts horizontalScrollableTable from .frame(maxWidth: maxTableViewportWidth) to .frame(width: maxTableViewportWidth) — a definite frame (O(1)) since TableColumnLayout.placeSubviews already proposes explicit column widths.

InlineSurfaceRouter.swift (custom WidthCapLayout + 6 replacements)

Introduces WidthCapLayout, a private Layout conformance that caps proposed width at a maximum without measuring children first — O(1) replacement for .frame(maxWidth:). Deletes the .frame(maxWidth: .infinity) path entirely and replaces finite caps (540, 400, 350) with .widthCap().

InlineImageEmbedView.swift (GeometryReader pattern + 1 deletion)

Replaces double FlexFrame (.frame(maxWidth: .infinity, maxHeight: 300)) with GeometryReader + ImageHeightKey PreferenceKey + .frame(height:) — same pattern as the ChatBubble fix. Removes redundant .frame(maxWidth: .infinity) from placeholder skeleton.

Review & Testing Checklist for Human

⚠️ CI skips macOS builds — all changes must be verified locally in Xcode.

  • Confirm the 35s hang is resolved — send 3+ long user messages in a conversation and verify the app stays responsive. Also test opening a new conversation after sending messages.
  • Test "Show more" / "Show less" toggle — verify expand/collapse animates smoothly (height transition, not cross-fade) and doesn't flash, jump, or lose scroll position.
  • Verify markdown rendering — assistant messages with headings, paragraphs, code blocks, lists, and horizontal rules should render at full width with leading alignment (no visual change from before).
  • Verify tool confirmation bubbles — command explanation banner, pending content, code preview blocks, and diff disclosure should render correctly with no width collapse.
  • Verify inline tables — tables should fill available width, horizontal scroll should work, resize handles should function, and the overflow hint gradient should appear at trailing edge.
  • Verify inline surface widgets — confirmation surfaces, app-created widgets, dynamic previews, document previews, and table surfaces should all respect their width caps (540, 400, 350pt respectively). Stripped/failed placeholders should also render correctly.
  • Verify inline images — images taller than 300pt should be clipped at 300pt. Placeholder skeleton should render at 120pt height. No visible flash of uncapped height on initial render.
  • Window resize while collapsed — resize the window or sidebar while a user message is collapsed. The message should un-collapse if content becomes shorter than 150pt.

Notes

  • WidthCapLayout is scoped private to InlineSurfaceRouter.swift. It uses the Layout protocol to cap proposed width without triggering _FlexFrameLayout's recursive measurement. The .widthCap(nil) path is a no-op passthrough.
  • InlineChatErrorAlert and MessageInspectorView also use .frame(maxHeight:) but are not inside LazyVStack cells, so they are unaffected.
  • The ImageHeightKey PreferenceKey starts at defaultValue: 0, so on first render imageIntrinsicHeight > 300 is false and .frame(height: nil) passes through — the image renders at natural height, then GeometryReader measures and caps if needed. Verify there is no visible flash for tall images.

Link to Devin session: https://app.devin.ai/sessions/bca2b6aae31f43afb41df0dae571ae1d
Requested by: @Jasonnnz


Open with Devin

…rapper to fix 35s hang

.frame(maxHeight:, alignment:) creates _FlexFrameLayout which recursively
measures children and resolves explicitAlignment through the entire LazyVStack
subtree — O(n × depth) per layout pass, causing 35s+ main-thread hangs after
just 3 messages.

Replace with a two-path conditional:
- Collapsed: .frame(height:) creates _FrameLayout — O(1), no alignment cascade
- Expanded/short: no frame modifier — avoids _FlexFrameLayout entirely

GeometryReader moves to only the expanded/short path since the intrinsic height
measurement is only needed once per cell lifecycle (@State persists it).

Co-Authored-By: Jason Zhou <jasonczhou3@gmail.com>
@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: 0cf524760a

ℹ️ 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".

Comment on lines +554 to +558
if needsCollapse {
// Collapsed: definite height — _FrameLayout, O(1), no alignment cascade.
content()
.frame(height: userMessageMaxCollapsedHeight, alignment: .top)
.clipped()
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 Recompute intrinsic height while user bubble is collapsed

This collapsed branch never updates userMessageIntrinsicHeight, so once a message is collapsed the collapsibility decision is based on stale height data. If layout later changes (for example window resize, sidebar width change, or typography changes), content can become shorter than the 150pt threshold but isCollapsible remains true, leaving an unnecessary fixed-height bubble and “Show more” control. The previous code continuously measured intrinsic height even while collapsed, so this is a behavioral regression in userMessageHeightWrapper.

Useful? React with 👍 / 👎.

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.

Addressed in 5f52c4b. Switched to a single-path approach: .frame(height: needsCollapse ? userMessageMaxCollapsedHeight : nil, alignment: .top).

.frame(height:alignment:) uses the definite-frame API which creates _FrameLayout (not _FlexFrameLayout) — O(1), no alignment cascade. When height is nil (expanded/short), _FrameLayout passes through the child's natural height.

The GeometryReader now runs on all paths, so userMessageIntrinsicHeight stays up to date even on window resize while collapsed.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration Bot and others added 2 commits April 9, 2026 19:17
…nd continuous measurement

Addresses review feedback:
- Codex: GeometryReader now runs on all paths, keeping userMessageIntrinsicHeight
  up to date even while collapsed (e.g. on window resize)
- Devin Review: Single view identity preserved (no _ConditionalContent), so
  withAnimation still drives a smooth height transition on expand/collapse

.frame(height:, alignment:) uses the definite-frame API which creates
_FrameLayout — O(1), no alignment cascade. When height is nil (expanded or
short content), _FrameLayout passes through the child's natural height.

Co-Authored-By: Jason Zhou <jasonczhou3@gmail.com>
Remove .frame(maxWidth:) / .frame(maxHeight:) modifiers that create
_FlexFrameLayout (O(n × depth) alignment cascade) inside LazyVStack
cells. These are the highest-impact remaining instances after the
ChatBubble fix.

MarkdownRenderer.swift: delete 6 redundant .frame(maxWidth: .infinity)
  — parent VStack(alignment: .leading) already proposes full width.

ToolConfirmationBubble.swift: delete 5 redundant .frame(maxWidth: .infinity)
  — parent VStacks + backgrounds handle sizing.

InlineTableWidget.swift: delete 7 instances, convert 1 maxWidth to
  definite .frame(width:) — TableColumnLayout.placeSubviews proposes
  explicit column widths so cells still fill correctly.

InlineSurfaceRouter.swift: replace 6 instances with WidthCapLayout
  (custom Layout, O(1)) for finite caps and delete the .infinity path
  entirely. WidthCapLayout caps proposed width without measuring
  children first, avoiding the FlexFrame alignment cascade.

InlineImageEmbedView.swift: replace double FlexFrame (maxWidth + maxHeight)
  with GeometryReader + PreferenceKey + ternary .frame(height:) pattern;
  delete redundant .frame(maxWidth: .infinity) from placeholder.

Co-Authored-By: Jason Zhou <jasonczhou3@gmail.com>
@devin-ai-integration devin-ai-integration Bot changed the title fix: replace _FlexFrameLayout with _FrameLayout in userMessageHeightWrapper to fix 35s hang fix: eliminate _FlexFrameLayout anti-pattern from chat cell hierarchy Apr 9, 2026
@dvargasfuertes dvargasfuertes merged commit 298173a into main Apr 9, 2026
7 checks passed
@dvargasfuertes dvargasfuertes deleted the devin/1775761669-fix-flexframelayout-hang-user-messages branch April 9, 2026 19:49
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 3 new potential issues.

View 5 additional findings in Devin Review.

Open in Devin Review

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.

🚩 Removed .frame(maxWidth: .infinity) from ToolConfirmationBubble may affect card fill behavior

Several .frame(maxWidth: .infinity, alignment: .leading) modifiers were removed from pendingContent, commandExplanationBanner, codePreviewBlock, and the diff view. These views render inside a tool confirmation card that has .background(RoundedRectangle(...)). Without .frame(maxWidth: .infinity), the VStack/HStack containers will be content-width rather than filling the available width. The card background fills the container's size, so the background may not span the full chat bubble width as before. However, the pendingContent VStack contains children that naturally span the proposed width: the confirmationDescription Text with .fixedSize(horizontal: false, vertical: true) wraps at the proposed width, and the button HStack with Spacer(minLength: VSpacing.md) fills the width. So in practice the card should still appear full-width. The commandExplanationBanner HStack contains a VStack with flexible Text elements that accept the proposed width. Worth verifying visually that the card and banner backgrounds fill correctly.

Open in Devin Review

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

}
)
.onPreferenceChange(ImageHeightKey.self) { imageIntrinsicHeight = $0 }
.frame(height: imageIntrinsicHeight > 300 ? 300 : nil)
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.

🔴 Strict > comparison creates infinite layout oscillation for images taller than 300pt

The height-capping logic in InlineImageEmbedView uses .frame(height: imageIntrinsicHeight > 300 ? 300 : nil) with a GeometryReader that measures the content's rendered height. This creates a feedback loop for any image whose natural height exceeds 300pt:

  1. imageIntrinsicHeight = 0frame(height: nil) → image renders at natural height (e.g., 500) → GeometryReader reports 500
  2. imageIntrinsicHeight = 500500 > 300frame(height: 300) → image constrained to 300 → GeometryReader reports 300
  3. imageIntrinsicHeight = 300300 > 300 is falseframe(height: nil) → image renders at 500 again → GeometryReader reports 500
  4. Back to step 2 — infinite oscillation

The > operator causes the value 300 to flip the constraint off, re-expanding the image, which re-measures above 300, re-enabling the constraint, ad infinitum. SwiftUI's loop detection may cap the number of re-renders but will log runtime warnings, waste CPU, and produce unpredictable visual height. Using >= stabilizes at step 3: 300 >= 300frame(height: 300) → reports 300 → no change.

Suggested change
.frame(height: imageIntrinsicHeight > 300 ? 300 : nil)
.frame(height: imageIntrinsicHeight >= 300 ? 300 : nil)
Open in Devin Review

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

}
.scrollDisabled(isResizingColumn)
.frame(maxWidth: maxTableViewportWidth, alignment: .leading)
.frame(width: maxTableViewportWidth)
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.

🚩 Fixed-width horizontal scroll viewport won't shrink on very narrow windows

The change from .frame(maxWidth: maxTableViewportWidth, alignment: .leading) to .frame(width: maxTableViewportWidth) means the horizontal scroll container is always exactly 728pt wide (760 - 2*16). The old maxWidth allowed the container to shrink when the parent proposed less. With the main window's minWidth: 800 (clients/macos/vellum-assistant/Features/MainWindow/MainWindowView.swift:590), and sidebar potentially consuming 200+ pixels, the chat area could theoretically be narrower than 728pt when the sidebar is expanded. In that scenario, the table would overflow its parent. This is mitigated by the fact that chatBubbleMaxWidth (760) already assumes the full chat content width, and the outer widthCap(standardWidgetMaxWidth) would propose a smaller width — but the inner .frame(width:) ignores the proposal. This is consistent with the macOS AGENTS.md rule to prefer .frame(width:) over .frame(maxWidth:) inside LazyVStack cells, though using a computed min(containerWidth, maxTableViewportWidth) would be more robust.

Open in Devin Review

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

Jasonnnz pushed a commit that referenced this pull request Apr 10, 2026
Adds CI-enforced guard tests that scan Swift source files for three known
LazyVStack performance anti-patterns:

1. FlexFrameLayout (.frame(maxWidth:) / .frame(maxHeight:)) in cell hierarchy
2. motionVectors transitions (.transition(.move(edge:))) in cell hierarchy
3. withAnimation in scroll handlers (motionVectors cascade)

Prevents regression of fixes from PRs #24321, #24375, #24411, #24446,
#24530, #24570, #24589.

Part of #24613.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Jasonnnz added a commit that referenced this pull request Apr 10, 2026
* test: add SwiftUI performance guard tests for LazyVStack anti-patterns

Adds CI-enforced guard tests that scan Swift source files for three known
LazyVStack performance anti-patterns:

1. FlexFrameLayout (.frame(maxWidth:) / .frame(maxHeight:)) in cell hierarchy
2. motionVectors transitions (.transition(.move(edge:))) in cell hierarchy
3. withAnimation in scroll handlers (motionVectors cascade)

Prevents regression of fixes from PRs #24321, #24375, #24411, #24446,
#24530, #24570, #24589.

Part of #24613.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add missing cell files to LAZY_VSTACK_CELL_FILES and remove PR references

Adds the 10 allowlisted file basenames to the cell hierarchy list so the
allowlist is actually consulted. Removes historical PR numbers from the
file header comment per AGENTS.md guidance.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: use grep -E for portable ERE alternation in FlexFrame guard

BSD grep (macOS default) doesn't support \| in BRE mode. Switch to
grep -E with | for alternation so the guard works for local dev too.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: add SubagentEventsReader to cell files and escape dots in transition grep

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Vellum Assistant <assistant@vellum.ai>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
devin-ai-integration Bot added a commit that referenced this pull request Apr 14, 2026
…ed vertical stretching

The .frame(maxWidth: .infinity, maxHeight: .infinity) modifier caused the
document preview card to expand to fill all available vertical space proposed
by the parent chat cell during document writing/streaming.

Remove the entire frame modifier:
- maxHeight: .infinity was the direct cause of the stretching bug
- maxWidth: .infinity was redundant (HStack already contains a Spacer())
- Both created _FlexFrameLayout, the anti-pattern PR #24589 eliminated
  from 5 other files but missed here

Co-Authored-By: Jason Zhou <jasonczhou3@gmail.com>
Jasonnnz added a commit that referenced this pull request Apr 15, 2026
…ed vertical stretching (#25643)

The .frame(maxWidth: .infinity, maxHeight: .infinity) modifier caused the
document preview card to expand to fill all available vertical space proposed
by the parent chat cell during document writing/streaming.

Remove the entire frame modifier:
- maxHeight: .infinity was the direct cause of the stretching bug
- maxWidth: .infinity was redundant (HStack already contains a Spacer())
- Both created _FlexFrameLayout, the anti-pattern PR #24589 eliminated
  from 5 other files but missed here

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Jason Zhou <jasonczhou3@gmail.com>
Jasonnnz added a commit that referenced this pull request Apr 15, 2026
…ed vertical stretching (#25643)

The .frame(maxWidth: .infinity, maxHeight: .infinity) modifier caused the
document preview card to expand to fill all available vertical space proposed
by the parent chat cell during document writing/streaming.

Remove the entire frame modifier:
- maxHeight: .infinity was the direct cause of the stretching bug
- maxWidth: .infinity was redundant (HStack already contains a Spacer())
- Both created _FlexFrameLayout, the anti-pattern PR #24589 eliminated
  from 5 other files but missed here

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Jason Zhou <jasonczhou3@gmail.com>
ashleeradka added a commit that referenced this pull request Apr 22, 2026
…UM-1108)

Thread content visibly twitches/shifts left-to-right on initial load when
switching to a thread containing a collapsible user message. The cause is a
geometry-observation feedback loop in userMessageHeightWrapper:

- .frame(height: 150) hard-proposes 150pt to its child (_FrameLayout).
- onGeometryChange on the same subtree writes the clamped height back into
  @State userMessageIntrinsicHeight.
- On thread switch, .id(conversationId) destroys the ScrollView, wiping
  @State. The first frame uses the NSString.boundingRect estimate as a
  fallback; subsequent frames re-observe the clamped height. The disagreement
  between the two drives the isCollapsible decision across two renders,
  flipping whether RoundedRectangle(.fill(surfaceLift)) background is applied.
  That background appearing/disappearing as container width resolves is the
  visible twitch.

This is the SwiftUI anti-pattern Apple warns about in the onGeometryChange
docs: geometry observations should not drive state that changes the observed
layout.

Fix: drive isCollapsible from a deterministic estimate of text + attachment
heights, computed from the model. No geometry observation, no @State, no
feedback loop. .frame(height:) stays (needed to avoid the _FlexFrameLayout
hang from PR #24589).

Worst case if the estimate under-shoots: no Show more button and content
renders at natural height — identical to existing non-collapsible user
messages. No clipping regression.

Estimate covers text height (VFont.nsChat = 16pt DM Sans, matching
MarkdownSegmentView), per-attachment heights (single image ~200pt, grid
tiles 120pt, video ~200pt, audio ~80pt, file chip ~40pt), bubble chrome
vertical padding (2 * VSpacing.md), and inter-section VStack spacing.

Also fixes the Show less button disappearing on click (same root cause,
originally diagnosed in closed PR #26131).

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
vex-assistant-bot Bot pushed a commit that referenced this pull request Apr 22, 2026
…geometry (LUM-1108)

Thread content visibly twitches/shifts left-to-right on initial load when
switching to a thread containing a collapsible user message. The cause is a
geometry-observation feedback loop in userMessageHeightWrapper:

- .frame(height: 150) hard-proposes 150pt to its child (_FrameLayout).
- onGeometryChange on the same subtree writes the clamped height back into
  @State userMessageIntrinsicHeight.
- On thread switch, .id(conversationId) destroys the ScrollView, wiping
  @State. The first frame uses the NSString.boundingRect estimate as a
  fallback; subsequent frames re-observe the clamped height. The disagreement
  between the two drives the isCollapsible decision across two renders,
  flipping whether RoundedRectangle(.fill(surfaceLift)) background is applied.
  That background appearing/disappearing as container width resolves is the
  visible twitch.

This is the SwiftUI anti-pattern Apple warns about in the onGeometryChange
docs: geometry observations should not drive state that changes the observed
layout.

Fix: drive isCollapsible from a deterministic estimate of text + attachment
heights, computed from the model. No geometry observation, no @State, no
feedback loop. .frame(height:) stays (needed to avoid the _FlexFrameLayout
hang from PR #24589).

Worst case if the estimate under-shoots: no Show more button and content
renders at natural height — identical to existing non-collapsible user
messages. No clipping regression.

Estimate covers text height (VFont.nsChat = 16pt DM Sans, matching
MarkdownSegmentView), per-attachment heights (single image ~200pt, grid
tiles 120pt, video ~200pt, audio ~80pt, file chip ~40pt), bubble chrome
vertical padding (2 * VSpacing.md), and inter-section VStack spacing.

Also fixes the Show less button disappearing on click (same root cause,
originally diagnosed in closed PR #26131).

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

2 participants