Skip to content

Playlist track sort: header sort-control placement causes scroll/hover jank (ViewThatFits re-measure) #375

Description

@tsibog

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

  1. Sort control on its own right-aligned row above the list (smooth, proven).
  2. Same row, but give headerButtons a fixed/ideal width (loses responsive title collapse).
  3. Move the sort control into the toolbar / outside the ScrollView.
  4. 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)

Image

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions