Files
hapi/ios/HapiTests/SessionListCoveredRefreshTests.swift
T
weishu da5dffd8ec perf(ios): stop main-thread stalls from the session list on large hubs
Profiling a 2,745-session hub while scrolling a chat showed the two
longest main-thread stalls (~80 ms) came from the session list, not the
transcript:

- The home list, covered by the pushed chat, still rebuilt and diffed
  every row on each session change. A covered list now keeps its last
  rows and refreshes once on return; a side-by-side split stays live.
- DiskCache encoded the 2 MB session snapshot on the main actor after
  every burst of changes. Encoding now runs with the file write, off it.

After the change the trace has no session-list or snapshot work on the
main thread and no stall over 50 ms (max 38 ms, from Markdown cells
entering the screen).
2026-11-01 20:29:37 +08:00

88 lines
3.5 KiB
Swift

import HapiClient
import HapiUI
import Observation
import SwiftUI
import UIKit
import XCTest
@testable import Hapi
@testable import HapiProtocol
/// A pushed chat covers the list: on a large hub, streaming sessions must
/// not rebuild a list nobody sees, and returning must show the latest rows.
@MainActor
final class SessionListCoveredRefreshTests: XCTestCase {
@MainActor @Observable
final class Sessions: SessionListStoring {
var sessions: [SessionSummary]
init(_ sessions: [SessionSummary]) { self.sessions = sessions }
func refresh() async throws {}
func scheduleRefresh() {}
func fullResync() async throws {}
func applySessionEvent(_ event: SyncEvent) {}
func setPinMode(sessionId: String, mode: SessionPinMode) async throws {}
func archiveSession(sessionId: String) async throws {}
func renameSession(sessionId: String, name: String) async throws {}
func deleteSession(sessionId: String) async throws {}
func reopenSession(sessionId: String) async throws -> ReopenSessionResponse {
ReopenSessionResponse(ok: true, sessionId: sessionId, resumed: true)
}
}
@MainActor @Observable
final class Navigation {
var path: [String] = []
}
private struct Harness: View {
let model: SessionListModel
@Bindable var navigation: Navigation
var body: some View {
NavigationStack(path: $navigation.path) {
SessionListView(model: model) { navigation.path.append($0) }
.navigationDestination(for: String.self) { Text($0) }
}
.hapiTheme(.light)
}
}
func testCoveredListKeepsItsRowsAndRefreshesOnReturn() async throws {
let sessions = Sessions((0..<3).map { HomeFilterTestData.summary("s\($0)", machine: "mac") })
let model = SessionListModel(sessionStore: sessions, machineStore: HomeFilterTestMachines(),
lastSeenStore: LastSeenStore(), hubUrl: "test")
let navigation = Navigation()
let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
let window = UIWindow(windowScene: scene)
window.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
window.rootViewController = UIHostingController(rootView: Harness(model: model, navigation: navigation))
window.makeKeyAndVisible()
defer { window.isHidden = true }
try await settle()
let list = try XCTUnwrap(findCollection(window))
XCTAssertEqual(itemCount(list), 3)
navigation.path = ["s0"]
try await settle()
sessions.sessions += (3..<5).map { HomeFilterTestData.summary("s\($0)", machine: "mac") }
try await settle()
XCTAssertEqual(itemCount(list), 3, "A covered list must not rebuild for session changes")
navigation.path = []
try await settle()
XCTAssertEqual(itemCount(try XCTUnwrap(findCollection(window))), 5, "Returning shows the latest rows")
}
private func itemCount(_ list: UICollectionView) -> Int {
(0..<list.numberOfSections).reduce(0) { $0 + list.numberOfItems(inSection: $1) }
}
private func findCollection(_ view: UIView) -> UICollectionView? {
if let list = view as? UICollectionView { return list }
return view.subviews.lazy.compactMap(findCollection).first
}
private func settle() async throws {
try await Task.sleep(for: .milliseconds(600))
}
}