From 0066b663c0ee3998d68abb9891b28d78db7a298b Mon Sep 17 00:00:00 2001 From: David Rodger Date: Sun, 13 Sep 2026 19:12:59 -0400 Subject: [PATCH] Stop the recordings accordions from wedging the main thread on iPad MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/iOS/Components/PerformerRecordingsSection.swift | 11 ++++++++++- apps/iOS/Components/RecordingsSection.swift | 12 +++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/apps/iOS/Components/PerformerRecordingsSection.swift b/apps/iOS/Components/PerformerRecordingsSection.swift index 0286ed9..a50cb6d 100644 --- a/apps/iOS/Components/PerformerRecordingsSection.swift +++ b/apps/iOS/Components/PerformerRecordingsSection.swift @@ -41,7 +41,16 @@ struct PerformerRecordingsSection: View { controlsBar .padding(.horizontal, ApproachNoteTheme.spacingXL) - LazyVStack(alignment: .leading, spacing: ApproachNoteTheme.spacingSM) { + // Eager VStack, not LazyVStack — same reason as the song-detail + // RecordingsSection. A lazy outer stack whose children each hold a + // horizontal ScrollView + LazyHStack makes SwiftUI's lazy-layout + // pass fail to settle on long lists: on iPad, Louis Armstrong + // (3231 recordings) pinned the main thread at 100% indefinitely + // inside LazyVStackLayout.finalPlacement -> + // LazyHStackLayout.initialPlacement. This stack holds one row per + // group (decade or song title), not one per recording, so building + // it eagerly is cheap. + VStack(alignment: .leading, spacing: ApproachNoteTheme.spacingSM) { if !filteredRecordings.isEmpty { ForEach(groupedRecordings, id: \.groupKey) { group in groupAccordion(group: group) diff --git a/apps/iOS/Components/RecordingsSection.swift b/apps/iOS/Components/RecordingsSection.swift index 0da15b2..9b5ca08 100644 --- a/apps/iOS/Components/RecordingsSection.swift +++ b/apps/iOS/Components/RecordingsSection.swift @@ -76,7 +76,17 @@ struct RecordingsSection: View { .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, ApproachNoteTheme.spacingXL) - LazyVStack(alignment: .leading, spacing: ApproachNoteTheme.spacingSM) { + // Deliberately an eager VStack, not a LazyVStack. Each child is a + // collapsed accordion header (a Button with two Texts) and, when + // open, a horizontal ScrollView + LazyHStack of cards. Nesting that + // inside a lazy stack makes SwiftUI's lazy-layout pass fail to reach + // a fixed point on long lists: on iPad, "All of Me" (701 shell rows) + // spun the main thread at 100% forever inside + // LazyVStackLayout.finalPlacement -> LazyHStackLayout.initialPlacement, + // so no touch was delivered and cover art never loaded. Making the + // outer stack eager settles it, and costs little — this stack holds + // one row per group, not one per recording. + VStack(alignment: .leading, spacing: ApproachNoteTheme.spacingSM) { if !filteredRecordings.isEmpty { ForEach(groupedRecordings, id: \.groupKey) { group in groupAccordion(group: group)