Stop the recordings accordions from wedging the main thread on iPad - #225
Merged
Merged
Conversation
Opening a song or artist with a long recordings list on iPad pinned the
main thread at 100% indefinitely: no touch was delivered anywhere, and
Featured Recordings cover art never loaded (the image requests timed out
after ~65s). `sample` put every main-thread sample inside SwiftUI's
layout engine, with no app frames at all:
GraphHost.flushTransactions -> AG::Subgraph::update
-> LazySubviewPlacements.updateValue
-> LazyVStackLayout.finalPlacement
-> LazyHStackLayout.initialPlacement
The cause is a lazy stack nested inside a lazy stack. Each accordion in
the outer LazyVStack holds, when open, a horizontal ScrollView wrapping a
LazyHStack of cards. The outer stack needs a height for each row to place
it; that height depends on what the inner lazy stack has materialized,
which depends on what it is offered — and past a certain list size the
two never reach a fixed point.
Making the outer stack eager settles it. Laziness bought nothing there
anyway: the stack holds one row per group, not one per recording, and a
collapsed group is just a Button wrapping two Texts. The inner LazyHStack
is unchanged and still does the real work.
Bisected on iPad (A16) / iPadOS 26.1 with "All of Me" (701 shell rows):
LazyVStack -> VStack drops it to 0% CPU with zero layout frames, while
LazyHStack -> HStack leaves it pinned at 100% — so the outer stack is the
culprit, not the nested one.
Verified after the change, all at 0% CPU and zero main-thread layout
frames:
- All of Me, Playable-only off (701 rows, 11 decade groups)
- All of Me, Sort: Name (238 artist groups, all built eagerly)
- Louis Armstrong (3231 recordings, 12 decade / 99 song groups)
- Louis Armstrong "Tiger Rag" shelf expanded (146 cards)
Fast scrolling briefly uses ~1.5-2 cores for cover-art decode, with the
main thread idle throughout, and returns to 0%.
iPhone never wedged — it spiked for ~10s and settled, because the
narrower viewport materializes fewer accordion rows at once.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
Opening a song or artist with a long recordings list on iPad pinned the main thread at 100% indefinitely. Symptoms: no touch delivered anywhere (Learn More buttons, decade rows, even the tab bar), Featured Recordings cover art never loading, and the image requests timing out after ~65s. The app's accessibility tree goes empty too — it stops answering entirely.
sampleput every main-thread sample inside SwiftUI's layout engine, with no app-code frames at all:That "no app frames" detail matters: it rules out
bodyre-evaluation, the shell+hydrate churn inSongDetailViewModel, and the scroll-offset KVO inDetailHeader. This is purely SwiftUI's lazy-layout pass failing to reach a fixed point.Cause
A lazy stack nested inside a lazy stack. Each accordion in the outer
LazyVStackholds, when open, a horizontalScrollViewwrapping aLazyHStackof cards. The outer stack needs a height for each row to place it; that height depends on what the inner lazy stack has materialized, which depends on what it's offered — and past a certain list size the two never settle.Fix
Make the outer stack eager (
VStack). Laziness bought nothing there anyway: it holds one row per group, not one per recording, and a collapsed group is just aButtonwrapping twoTexts. The innerLazyHStackis untouched and still does the real work.How it was narrowed
Bisected on iPad (A16) / iPadOS 26.1 with "All of Me" (701 shell rows):
LazyVStack→VStackLazyHStack→HStack(outer left lazy)So it's the outer stack, not the nested one.
Also worth recording: the
approachnote://song/…andapproachnote://artist/…deep links do not reproduce this — they present as a sheet. Only the pushed navigation wedges.Verification
All at 0% CPU with zero main-thread layout frames after the change:
Fast scrolling briefly uses ~1.5–2 cores for cover-art decode, with the main thread idle throughout, and returns to 0%.
PerformerRecordingsSectionwas confirmed broken on Louis Armstrong before being fixed (~100% CPU, 0 idle main-thread frames, 109 layout frames) — not assumed from the shared shape.Platform notes
iPhone never wedged: it spikes for ~10s and settles, because the narrower viewport materializes fewer accordion rows at once. The fix helps there too but wasn't re-measured on iPhone.
Not tested on a physical iPad — this was verified on the simulator only.
🤖 Generated with Claude Code