Исправление бесконечного рендер-цикла SearchView и поиск по публичному ключу
- SearchViewModel: заменён @Observable на ObservableObject + @Published (устранён infinite body loop SearchView → 99% CPU фриз после логина) - SearchView: @State → @StateObject, RecentSection: @ObservedObject - Добавлен клиентский поиск по публичному ключу (сервер ищет только по нику) - ChatDetailView: убран @State на DialogRepository singleton - ChatListView: замена closure на @Binding, убран DispatchQueue.main.async - MainTabView: убран пустой onChange, замена closure на @Binding - SettingsViewModel: конвертирован в ObservableObject - Добавлены debug-принты для отладки рендер-циклов Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +1,40 @@
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Navigation State (survives parent re-renders)
|
||||
|
||||
@MainActor
|
||||
final class ChatListNavigationState: ObservableObject {
|
||||
@Published var path: [ChatRoute] = []
|
||||
}
|
||||
|
||||
// MARK: - ChatListView
|
||||
|
||||
/// The root chat list screen.
|
||||
///
|
||||
/// **IMPORTANT:** This view's `body` must NOT read any `@Observable` singleton
|
||||
/// (`ProtocolManager`, `DialogRepository`, `AccountManager`, `SessionManager`)
|
||||
/// directly. Such reads create implicit Observation tracking, causing the
|
||||
/// NavigationStack to rebuild on every property change (e.g. during handshake)
|
||||
/// and triggering "Update NavigationRequestObserver tried to update multiple
|
||||
/// times per frame" → app freeze.
|
||||
///
|
||||
/// All `@Observable` access is isolated in dedicated child views:
|
||||
/// - `DeviceVerificationBannersContainer` → `ProtocolManager`
|
||||
/// - `ToolbarStoriesAvatar` → `AccountManager` / `SessionManager`
|
||||
/// - `ChatListDialogContent` → `DialogRepository` (via ViewModel)
|
||||
struct ChatListView: View {
|
||||
@Binding var isSearchActive: Bool
|
||||
var onChatDetailVisibilityChange: ((Bool) -> Void)? = nil
|
||||
@Binding var isDetailPresented: Bool
|
||||
@StateObject private var viewModel = ChatListViewModel()
|
||||
@StateObject private var navigationState = ChatListNavigationState()
|
||||
@State private var searchText = ""
|
||||
@State private var navigationPath: [ChatRoute] = []
|
||||
|
||||
@MainActor static var _bodyCount = 0
|
||||
var body: some View {
|
||||
NavigationStack(path: $navigationPath) {
|
||||
let _ = Self._bodyCount += 1
|
||||
let _ = print("🟡 ChatListView.body #\(Self._bodyCount)")
|
||||
NavigationStack(path: $navigationState.path) {
|
||||
ZStack {
|
||||
RosettaColors.Adaptive.background
|
||||
.ignoresSafeArea()
|
||||
@@ -23,7 +47,7 @@ struct ChatListView: View {
|
||||
onOpenDialog: { route in
|
||||
isSearchActive = false
|
||||
searchText = ""
|
||||
navigationPath.append(route)
|
||||
navigationState.path.append(route)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
@@ -46,16 +70,16 @@ struct ChatListView: View {
|
||||
.navigationDestination(for: ChatRoute.self) { route in
|
||||
ChatDetailView(
|
||||
route: route,
|
||||
onPresentedChange: { isPresented in
|
||||
onChatDetailVisibilityChange?(isPresented)
|
||||
onPresentedChange: { presented in
|
||||
isDetailPresented = presented
|
||||
}
|
||||
)
|
||||
}
|
||||
.onAppear {
|
||||
onChatDetailVisibilityChange?(!navigationPath.isEmpty)
|
||||
isDetailPresented = !navigationState.path.isEmpty
|
||||
}
|
||||
.onChange(of: navigationPath) { _, newPath in
|
||||
onChatDetailVisibilityChange?(!newPath.isEmpty)
|
||||
.onChange(of: navigationState.path) { _, newPath in
|
||||
isDetailPresented = !newPath.isEmpty
|
||||
}
|
||||
}
|
||||
.tint(RosettaColors.figmaBlue)
|
||||
@@ -68,36 +92,129 @@ private extension ChatListView {
|
||||
@ViewBuilder
|
||||
var normalContent: some View {
|
||||
VStack(spacing: 0) {
|
||||
deviceVerificationBanners
|
||||
// Isolated view — reads ProtocolManager (@Observable) without
|
||||
// polluting ChatListView's observation scope.
|
||||
DeviceVerificationBannersContainer()
|
||||
|
||||
if viewModel.filteredDialogs.isEmpty && !viewModel.isLoading {
|
||||
ChatEmptyStateView(searchText: "")
|
||||
} else {
|
||||
dialogList
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var deviceVerificationBanners: some View {
|
||||
let protocol_ = ProtocolManager.shared
|
||||
|
||||
// Banner 1: THIS device needs approval from another device
|
||||
if protocol_.connectionState == .deviceVerificationRequired {
|
||||
DeviceWaitingApprovalBanner()
|
||||
}
|
||||
|
||||
// Banner 2: ANOTHER device needs approval from THIS device
|
||||
if let pendingDevice = protocol_.pendingDeviceVerification {
|
||||
DeviceApprovalBanner(
|
||||
device: pendingDevice,
|
||||
onAccept: { protocol_.acceptDevice(pendingDevice.deviceId) },
|
||||
onDecline: { protocol_.declineDevice(pendingDevice.deviceId) }
|
||||
// Isolated view — reads DialogRepository (@Observable) via viewModel
|
||||
// without polluting ChatListView's observation scope.
|
||||
ChatListDialogContent(
|
||||
viewModel: viewModel,
|
||||
navigationState: navigationState
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var dialogList: some View {
|
||||
// MARK: - Toolbar
|
||||
|
||||
private extension ChatListView {
|
||||
@ToolbarContentBuilder
|
||||
var toolbarContent: some ToolbarContent {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button { } label: {
|
||||
Text("Edit")
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .principal) {
|
||||
HStack(spacing: 4) {
|
||||
// Isolated view — reads AccountManager & SessionManager (@Observable)
|
||||
// without polluting ChatListView's observation scope.
|
||||
ToolbarStoriesAvatar()
|
||||
Text("Chats")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItemGroup(placement: .navigationBarTrailing) {
|
||||
HStack(spacing: 8) {
|
||||
Button { } label: {
|
||||
Image(systemName: "camera")
|
||||
.font(.system(size: 16, weight: .regular))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
}
|
||||
.accessibilityLabel("Camera")
|
||||
Button { } label: {
|
||||
Image(systemName: "square.and.pencil")
|
||||
.font(.system(size: 17, weight: .regular))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
}
|
||||
.padding(.bottom, 2)
|
||||
.accessibilityLabel("New chat")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Toolbar Stories Avatar (observation-isolated)
|
||||
|
||||
/// Reads `AccountManager` and `SessionManager` in its own observation scope.
|
||||
/// Changes to these `@Observable` singletons only re-render this small view,
|
||||
/// not the parent ChatListView / NavigationStack.
|
||||
private struct ToolbarStoriesAvatar: View {
|
||||
@MainActor static var _bodyCount = 0
|
||||
var body: some View {
|
||||
let _ = Self._bodyCount += 1
|
||||
let _ = print("🟣 ToolbarStoriesAvatar.body #\(Self._bodyCount)")
|
||||
let pk = AccountManager.shared.currentAccount?.publicKey ?? ""
|
||||
let initials = RosettaColors.initials(
|
||||
name: SessionManager.shared.displayName, publicKey: pk
|
||||
)
|
||||
let colorIdx = RosettaColors.avatarColorIndex(for: pk)
|
||||
ZStack { AvatarView(initials: initials, colorIndex: colorIdx, size: 28) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Device Verification Banners (observation-isolated)
|
||||
|
||||
/// Reads `ProtocolManager` in its own observation scope.
|
||||
/// During handshake, `connectionState` changes 4+ times rapidly — this view
|
||||
/// absorbs those re-renders instead of cascading them to the NavigationStack.
|
||||
private struct DeviceVerificationBannersContainer: View {
|
||||
@MainActor static var _bodyCount = 0
|
||||
var body: some View {
|
||||
let _ = Self._bodyCount += 1
|
||||
let _ = print("⚪ DeviceVerificationBanners.body #\(Self._bodyCount)")
|
||||
let proto = ProtocolManager.shared
|
||||
|
||||
if proto.connectionState == .deviceVerificationRequired {
|
||||
DeviceWaitingApprovalBanner()
|
||||
}
|
||||
|
||||
if let pendingDevice = proto.pendingDeviceVerification {
|
||||
DeviceApprovalBanner(
|
||||
device: pendingDevice,
|
||||
onAccept: { proto.acceptDevice(pendingDevice.deviceId) },
|
||||
onDecline: { proto.declineDevice(pendingDevice.deviceId) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Dialog Content (observation-isolated)
|
||||
|
||||
/// Reads `DialogRepository` (via ViewModel) in its own observation scope.
|
||||
/// Changes to dialogs only re-render this list, not the NavigationStack.
|
||||
private struct ChatListDialogContent: View {
|
||||
@ObservedObject var viewModel: ChatListViewModel
|
||||
@ObservedObject var navigationState: ChatListNavigationState
|
||||
@MainActor static var _bodyCount = 0
|
||||
|
||||
var body: some View {
|
||||
let _ = Self._bodyCount += 1
|
||||
let _ = print("🔶 ChatListDialogContent.body #\(Self._bodyCount)")
|
||||
if viewModel.filteredDialogs.isEmpty && !viewModel.isLoading {
|
||||
ChatEmptyStateView(searchText: "")
|
||||
} else {
|
||||
dialogList
|
||||
}
|
||||
}
|
||||
|
||||
private var dialogList: some View {
|
||||
List {
|
||||
if viewModel.isLoading {
|
||||
ForEach(0..<8, id: \.self) { _ in
|
||||
@@ -128,9 +245,9 @@ private extension ChatListView {
|
||||
.scrollDismissesKeyboard(.immediately)
|
||||
}
|
||||
|
||||
func chatRow(_ dialog: Dialog) -> some View {
|
||||
private func chatRow(_ dialog: Dialog) -> some View {
|
||||
Button {
|
||||
navigationPath.append(ChatRoute(dialog: dialog))
|
||||
navigationState.path.append(ChatRoute(dialog: dialog))
|
||||
} label: {
|
||||
ChatRowView(dialog: dialog)
|
||||
}
|
||||
@@ -172,58 +289,6 @@ private extension ChatListView {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Toolbar
|
||||
|
||||
private extension ChatListView {
|
||||
@ToolbarContentBuilder
|
||||
var toolbarContent: some ToolbarContent {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button { } label: {
|
||||
Text("Edit")
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .principal) {
|
||||
HStack(spacing: 4) {
|
||||
storiesAvatars
|
||||
Text("Chats")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItemGroup(placement: .navigationBarTrailing) {
|
||||
HStack(spacing: 8) {
|
||||
Button { } label: {
|
||||
Image(systemName: "camera")
|
||||
.font(.system(size: 16, weight: .regular))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
}
|
||||
.accessibilityLabel("Camera")
|
||||
Button { } label: {
|
||||
Image(systemName: "square.and.pencil")
|
||||
.font(.system(size: 17, weight: .regular))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
}
|
||||
.padding(.bottom, 2)
|
||||
.accessibilityLabel("New chat")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var storiesAvatars: some View {
|
||||
let pk = AccountManager.shared.currentAccount?.publicKey ?? ""
|
||||
let initials = RosettaColors.initials(
|
||||
name: SessionManager.shared.displayName, publicKey: pk
|
||||
)
|
||||
let colorIdx = RosettaColors.avatarColorIndex(for: pk)
|
||||
ZStack { AvatarView(initials: initials, colorIndex: colorIdx, size: 28) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Device Waiting Approval Banner
|
||||
|
||||
/// Shown when THIS device needs approval from another Rosetta device.
|
||||
@@ -303,4 +368,4 @@ private struct DeviceApprovalBanner: View {
|
||||
}
|
||||
}
|
||||
|
||||
#Preview { ChatListView(isSearchActive: .constant(false), onChatDetailVisibilityChange: nil) }
|
||||
#Preview { ChatListView(isSearchActive: .constant(false), isDetailPresented: .constant(false)) }
|
||||
|
||||
Reference in New Issue
Block a user