Skip to content

fix(zero-swift): stop macOS virtual list rows laying out on every scroll frame - #202

Merged
tmm merged 3 commits into
mainfrom
fix/macos-virtual-list-scroll-frames
Nov 14, 2026
Merged

tmm merged 3 commits into
mainfrom
fix/macos-virtual-list-scroll-frames

Conversation

@tmm

@tmm tmm commented Nov 14, 2026

Copy link
Copy Markdown
Member

tmm saw the macOS zbugs issue list drop frames when scrolled fast. This PR removes the per-frame work that every kept row did while the list scrolled. It doesn't remove every stall: see What's left below.

Cause

I flung the issue list (155 open issues, SwiftUI layer) with scripted trackpad events: gesture phases plus momentum, about 36,000 pt per run. An injected display-link logger and a Time Profiler trace recorded each run.

  • Every kept row laid itself out on every frame. That was about 52 NSHostingView.layout() calls a frame, 75% of them off screen, while only 23–50 rows were kept. Each row's NSHostingController followed the safe area, which is the default on macOS. iOS already sets safeAreaRegions = []. So on every scroll frame, AppKit's update-constraints pass ran NSHostingView._willUpdateConstraintsForSubtree → SizeConstraints.update → minSize() → updateSafeArea() for each row, then the row's layout and render. That was about 17% of main-thread samples.
  • Rows were looked up by a linear search. RowsLayout.measure(_:) found each row near the screen with source.items.first(where:), every time LayoutHost.stack ran. That was 3–4% of the main thread.

Change

  • Rows take no safe area on either platform.
  • RowsLayout keeps each row's position by key, rebuilt with each snapshot.

Measurements

Release builds on a Mac satellite: 60 Hz virtual display, the same fling script, three runs alternating between the two builds.

before after
row layouts a frame 52 (0.87 ms) 0.5 (0.23 ms)
main thread busy > 4 ms 1177 / 1199 / 1177 349 / 351 / 381
busy > 8.3 ms (a 120 Hz frame) 166 / 171 / 158 117 / 135 / 146
busy > 16.7 ms 17 / 18 / 18 12 / 11 / 10
frames dropped at 60 Hz 12 / 9 / 11 12 / 9 / 7

Screenshots of the list and an issue's detail, before and after, match except for the menu bar clock.

What's left

The satellite only runs at 60 Hz, and there drops were already rare (under 1% of moving frames). Most of the remaining stalls over 8.3 ms, which would miss a frame at 120 Hz, fall into two kinds (from the after trace):

  • Measuring rows that come into reach (SwiftUI sizeThatFits, with ViewThatFits in IssueRow): 74 spans, 920 ms.
  • Page changes: ZeroVirtualizer.sync reopens its queries from didScroll inside the layout pass. 55 spans, 793 ms.

Fixing either one changes the virtualizer's design or the row, so neither is in this PR.

The AppKit layer has a separate cost: window-wide tracking-area and cursor-rect updates (including the search field's) and redraws of the rows' backing layers on each scroll. That's about 37% of its main thread. It's AppKit's own work, also not changed here.

Checks

  • swift format lint --strict passes. ZBugsApp builds for macOS and the iOS simulator.
  • TestHost on macOS, Xcode 27.3 beta:
    • FrameProbeUITests passes, both the chat and issues tests.
    • VirtualListUITests: the 4 tests that tap buttons time out, because the taps never land on this Xcode. Two of them fail the same way without this change. The other two use the same taps and weren't rerun without it. CI runs these suites on macos-26.

ampagent and others added 3 commits November 14, 2026 02:20
…oll frame

Flinging zbugs-swift's issue list on macOS, every row the list kept laid
itself out on every frame: about 52 NSHostingView layouts a frame, three
in four of them off screen. Each row's hosting controller followed the
safe area (the default on macOS; iOS already turned it off), so as the
list scrolled, AppKit's update-constraints pass ran each row's
SizeConstraints.update -> minSize() -> updateSafeArea(), then its layout
and render. In a Time Profiler trace of fast flings that was about 17% of
the main thread. Rows now take no safe area on either platform; they draw
the same (screenshots before and after match, but for the menu bar clock).

A layout pass also looked each row near the screen up with a linear
first(where:) over the snapshot's rows, every time it stacked them: 3-4%
of the main thread. RowsLayout now keeps each row's position by key.

Release builds on a Mac satellite (60 Hz display, 155 open issues), the
same scripted trackpad flings (about 36,000 pt), three alternating runs:
- row layouts a frame: 52 -> 0.5 (0.87 -> 0.23 ms a frame)
- main thread busy over 4 ms: 1177 / 1199 / 1177 -> 349 / 351 / 381
- over 8.3 ms (a 120 Hz frame): 166 / 171 / 158 -> 117 / 135 / 146
- over 16.7 ms: 17 / 18 / 18 -> 12 / 11 / 10
- frames dropped at 60 Hz: 12 / 9 / 11 -> 12 / 9 / 7

The stalls left over 8.3 ms are measuring rows that come into reach
(SwiftUI sizeThatFits) and page changes (the virtualizer reopening its
queries from didScroll inside the layout pass).

Amp-Thread-ID: https://ampcode.com/threads/T-02a95685-6d19-4dd4-b1de-8210ec5599df
Co-authored-by: tmm <tmm@tmm.dev>
@tmm
tmm merged commit 9f6b1cd into main Nov 14, 2026
27 of 32 checks passed
@tmm
tmm deleted the fix/macos-virtual-list-scroll-frames branch November 14, 2026 16:17
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.

2 participants