Skip to content

Stop the recordings accordions from wedging the main thread on iPad - #225

Merged
dprodger merged 1 commit into
mainfrom
fix/ipad-lazystack-layout-loop
Sep 13, 2026
Merged

dprodger merged 1 commit into
mainfrom
fix/ipad-lazystack-layout-loop

Conversation

@dprodger

Copy link
Copy Markdown
Owner

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.

sample put every main-thread sample inside SwiftUI's layout engine, with no app-code frames at all:

GraphHost.flushTransactions -> AG::Subgraph::update
  -> LazySubviewPlacements.updateValue
    -> LazyVStackLayout.finalPlacement
      -> LazyHStackLayout.initialPlacement

That "no app frames" detail matters: it rules out body re-evaluation, the shell+hydrate churn in SongDetailViewModel, and the scroll-offset KVO in DetailHeader. 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 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'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 a Button wrapping two Texts. The inner LazyHStack is 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):

Change Result
LazyVStack → VStack 0% CPU, zero layout frames
LazyHStack → HStack (outer left lazy) still pinned at 100%

So it's the outer stack, not the nested one.

Also worth recording: the approachnote://song/… and approachnote://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:

  • 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 in one carousel

Fast scrolling briefly uses ~1.5–2 cores for cover-art decode, with the main thread idle throughout, and returns to 0%.

PerformerRecordingsSection was 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

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>
@dprodger
dprodger merged commit f04c558 into main Sep 13, 2026
1 check passed
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