Skip to content

perf: move frame(maxWidth:) outside ScrollView to fix 134s LazyVStack hang (LUM-740)#24321

Merged
ashleeradka merged 1 commit into
mainfrom
devin/1775666576-fix-lum-740-lazyvstack-flexframe-hang
Apr 8, 2026
Merged

perf: move frame(maxWidth:) outside ScrollView to fix 134s LazyVStack hang (LUM-740)#24321
ashleeradka merged 1 commit into
mainfrom
devin/1775666576-fix-lum-740-lazyvstack-flexframe-hang

Conversation

@devin-ai-integration
Copy link
Copy Markdown
Contributor

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

Moves .frame(maxWidth: VSpacing.chatColumnMaxWidth) and .frame(maxWidth: .infinity) from the LazyVStack scroll content to the ScrollView container itself, eliminating _FlexFrameLayout from the scroll content measurement path and fixing the biggest app hang (648+ Sentry events, 68 users).

When these modifiers sit between ScrollView and LazyVStack, message insertions trigger initialPlacement → motionVectors → sizeThatFits through both _FlexFrameLayout layers, which calls measureEstimates over every ForEach item — defeating laziness entirely (O(n), 134s hang with complex cells). Outside the ScrollView, the FlexFrame constrains the ScrollView's external frame without participating in content measurement. Same class of fix as PR #24019 and #24091 (_FlexFrameLayout anti-pattern), but on the width axis at the scroll-content level rather than inside individual cells.

Summary

  • MessageListContentView.swift: Remove .frame(maxWidth: chatColumnMaxWidth) and .frame(maxWidth: .infinity) from LazyVStack modifier chain. Padding and .environment(\.bubbleMaxWidth) remain unchanged.
  • MessageListView.swift: Add the same two .frame(maxWidth:) modifiers to the ScrollView, after .scrollDisabled() and before .defaultScrollAnchor().

Visual alignment math is preserved: chatColumnMaxWidth (808pt) on the ScrollView minus internal padding (2 × xl = 48pt) still yields chatBubbleMaxWidth (760pt) for bubble content. Sibling views (banners, composer) are unaffected — they apply their own frame constraints independently.

Review & Testing Checklist for Human

  • Local Xcode build: CI skips macOS builds entirely — this change cannot be validated without building locally. Confirm it compiles.
  • Visual verification: Confirm message column width, centering, and padding are identical before/after. Check both wide windows (where chatColumnMaxWidth constrains) and narrow windows (where content should shrink below the max). Verify banner alignment (CreditsExhausted, MissingApiKey) matches the message column.
  • Scroll behavior: In a long conversation, verify auto-follow at bottom, free browsing when scrolled up, and scroll-to-latest overlay. The .frame() modifiers now sit between .scrollDisabled() and .defaultScrollAnchor() — confirm scroll anchoring is unaffected.
  • Performance: Open a conversation with 10+ tool call messages, send a new message that triggers multiple tool calls, and confirm no freeze/hang during message insertion.

Notes

  • Alternatives considered and rejected: animation suppression (breaks message transitions), GeometryReader + definite .frame(width:) (unnecessary complexity, prevents shrinking below max), List replacement (wrong target — LazyVStack is correct when FlexFrame isn't in its path). See session for full analysis.
  • This is a 2-line move with no new logic, but SwiftUI layout is sensitive to modifier ordering — the visual and scroll verification items above are the highest-risk areas.

Requested by: @ashleeradka


Open with Devin

…fix 134s hang (LUM-740)

Move .frame(maxWidth: VSpacing.chatColumnMaxWidth) and .frame(maxWidth: .infinity)
from inside the ScrollView content (MessageListContentView) to the ScrollView
itself (MessageListView).

These modifiers compile to _FlexFrameLayout which calls sizeThatFits on children.
When placed between ScrollView and LazyVStack, new message insertions trigger
initialPlacement → motionVectors → measureEstimates over ALL ForEach items,
defeating laziness entirely (O(n) where each cell is complex).

Moving them outside the ScrollView removes _FlexFrameLayout from the scroll
content measurement path. The ScrollView is a greedy container that accepts
its proposed size without measuring content, so width constraints on its
frame work correctly without triggering child measurement.

Padding stays inside scroll content for correct insets. Visual alignment
is preserved: chatColumnMaxWidth = chatBubbleMaxWidth + 2*xl, and the
internal padding of xl on each side still yields chatBubbleMaxWidth for
bubble content.

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
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: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 2 additional findings.

Open in Devin Review

@ashleeradka ashleeradka merged commit 53bf9bb into main Apr 8, 2026
6 checks passed
@ashleeradka ashleeradka deleted the devin/1775666576-fix-lum-740-lazyvstack-flexframe-hang branch April 8, 2026 17:27
noanflaherty pushed a commit that referenced this pull request Apr 8, 2026
…fix 134s hang (LUM-740) (#24321)

Move .frame(maxWidth: VSpacing.chatColumnMaxWidth) and .frame(maxWidth: .infinity)
from inside the ScrollView content (MessageListContentView) to the ScrollView
itself (MessageListView).

These modifiers compile to _FlexFrameLayout which calls sizeThatFits on children.
When placed between ScrollView and LazyVStack, new message insertions trigger
initialPlacement → motionVectors → measureEstimates over ALL ForEach items,
defeating laziness entirely (O(n) where each cell is complex).

Moving them outside the ScrollView removes _FlexFrameLayout from the scroll
content measurement path. The ScrollView is a greedy container that accepts
its proposed size without measuring content, so width constraints on its
frame work correctly without triggering child measurement.

Padding stays inside scroll content for correct insets. Visual alignment
is preserved: chatColumnMaxWidth = chatBubbleMaxWidth + 2*xl, and the
internal padding of xl on each side still yields chatBubbleMaxWidth for
bubble content.

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: ashlee@vellum.ai <ashlee@vellum.ai>
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>
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