Summary
PR #369 added a session-only playlist track sort (Default / Title / Artist / Duration) to the Playlist Detail view. The feature works, but placing the sort control in the header action row (same row as Play / Play Next / … — see screenshot) introduces a noticeable UI performance regression: scrolling the track list becomes janky and row-hover highlighting lags ~100–200 ms. Closing the PR so a maintainer can decide the preferred placement/approach — there's no rush.
Root cause (bisected)
headerButtons uses ViewThatFits(in: .horizontal) (two candidates: buttons with/without titles, for responsive collapse). Putting that ViewThatFits in an HStack next to a flexible sibling and the sort control gives it an indeterminate horizontal proposal, so it re-measures both candidates on every layout pass. Because the header lives inside the track ScrollView, scrolling drives continuous layout passes → main-thread hitches on scroll, and the same tracking churn throttles mouse-moved delivery to the rows (the hover-highlight lag).
On main (no sort control) headerButtons is the sole full-width child and gets a stable proposal, so ViewThatFits resolves once — smooth.
Evidence (each step rebuilt + A/B feel-tested, identical adhoc builds)
| Layout |
Result |
Bare headerButtons (no control) — i.e. main |
✅ smooth |
HStack { headerButtons; Spacer; sortControl } |
❌ janky |
Menu swapped for a plain Button |
❌ still janky (not the Menu/NSMenu) |
Removed .help() + .fixedSize() |
❌ still janky |
Spacer replaced with .frame(maxWidth: .infinity) |
❌ still janky (still flexible) |
Sort control on its own row (not sharing the HStack with ViewThatFits) |
✅ smooth |
Conclusion: any flexible layout that shares an HStack with the ViewThatFits button group re-triggers its measurement under scroll. The only smooth configurations either drop the control or move it off that row.
What we tried
We tried to keep the sort control on the same row as the action buttons (the layout in the screenshot). Every same-row variant that preserved the buttons' responsive collapse was janky. A separate right-aligned row above the list is smooth but changes the visual design — a call we'd rather leave to a maintainer.
Also found (independent, not the jank cause)
PlaylistDetailViewModel.displayedTrackRows was a computed getter that rebuilt an occurrence map + re-allocated identities + re-sorted on every read, and the view read it twice per body eval → up to ~4 O(n·log n) passes per paired eval on the main actor. Worth memoizing into a stored property regardless of the placement decision.
Options for the maintainer
- Sort control on its own right-aligned row above the list (smooth, proven).
- Same row, but give
headerButtons a fixed/ideal width (loses responsive title collapse).
- Move the sort control into the toolbar / outside the
ScrollView.
- Rework the header action row so it doesn't rely on
ViewThatFits next to a flexible sibling.
Branch pr/playlist-sort retains the full implementation + tests for reference.
Screenshot — intended same-row placement (this is the layout that caused the jank)

Summary
PR #369 added a session-only playlist track sort (Default / Title / Artist / Duration) to the Playlist Detail view. The feature works, but placing the sort control in the header action row (same row as Play / Play Next / … — see screenshot) introduces a noticeable UI performance regression: scrolling the track list becomes janky and row-hover highlighting lags ~100–200 ms. Closing the PR so a maintainer can decide the preferred placement/approach — there's no rush.
Root cause (bisected)
headerButtonsusesViewThatFits(in: .horizontal)(two candidates: buttons with/without titles, for responsive collapse). Putting thatViewThatFitsin anHStacknext to a flexible sibling and the sort control gives it an indeterminate horizontal proposal, so it re-measures both candidates on every layout pass. Because the header lives inside the trackScrollView, scrolling drives continuous layout passes → main-thread hitches on scroll, and the same tracking churn throttlesmouse-moveddelivery to the rows (the hover-highlight lag).On
main(no sort control)headerButtonsis the sole full-width child and gets a stable proposal, soViewThatFitsresolves once — smooth.Evidence (each step rebuilt + A/B feel-tested, identical adhoc builds)
headerButtons(no control) — i.e.mainHStack { headerButtons; Spacer; sortControl }Menuswapped for a plainButton.help()+.fixedSize()Spacerreplaced with.frame(maxWidth: .infinity)ViewThatFits)Conclusion: any flexible layout that shares an
HStackwith theViewThatFitsbutton group re-triggers its measurement under scroll. The only smooth configurations either drop the control or move it off that row.What we tried
We tried to keep the sort control on the same row as the action buttons (the layout in the screenshot). Every same-row variant that preserved the buttons' responsive collapse was janky. A separate right-aligned row above the list is smooth but changes the visual design — a call we'd rather leave to a maintainer.
Also found (independent, not the jank cause)
PlaylistDetailViewModel.displayedTrackRowswas a computed getter that rebuilt an occurrence map + re-allocated identities + re-sorted on every read, and the view read it twice per body eval → up to ~4O(n·log n)passes per paired eval on the main actor. Worth memoizing into a stored property regardless of the placement decision.Options for the maintainer
headerButtonsa fixed/ideal width (loses responsive title collapse).ScrollView.ViewThatFitsnext to a flexible sibling.Branch
pr/playlist-sortretains the full implementation + tests for reference.Screenshot — intended same-row placement (this is the layout that caused the jank)