Repository navigation
fix(zero-swift): stop macOS virtual list rows laying out on every scroll frame - #202
Merged
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
NSHostingView.layout()calls a frame, 75% of them off screen, while only 23–50 rows were kept. Each row'sNSHostingControllerfollowed the safe area, which is the default on macOS. iOS already setssafeAreaRegions = []. So on every scroll frame, AppKit's update-constraints pass ranNSHostingView._willUpdateConstraintsForSubtree → SizeConstraints.update → minSize() → updateSafeArea()for each row, then the row's layout and render. That was about 17% of main-thread samples.RowsLayout.measure(_:)found each row near the screen withsource.items.first(where:), every timeLayoutHost.stackran. That was 3–4% of the main thread.Change
RowsLayoutkeeps 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.
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):
sizeThatFits, withViewThatFitsinIssueRow): 74 spans, 920 ms.ZeroVirtualizer.syncreopens its queries fromdidScrollinside 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 --strictpasses. ZBugsApp builds for macOS and the iOS simulator.FrameProbeUITestspasses, 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.