From 619121a36c87b3d13c7e8ea19da4febd73844868 Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Sat, 3 Oct 2026 03:38:07 +0800 Subject: [PATCH] fix(route): decouple sidebar selection write-back from route publish The same-value guard only stopped same-value selection re-syncs. After an external navigation (add menu, rule editor dismissal, manage-servers/show-runtime actions) the sidebar List still held its stale cached selection and wrote it back into the custom selection binding during a view update; the resulting publish warned and would bounce the navigation back. Route the sidebar selection through a SwiftUI-managed @State with onChange hops in both directions (selection -> navigate, route -> backfill), anchor the seed on WorkspaceRoute.initialDestination, and pin the alignment with a regression test. --- ShadowsocksX-NG2/App/MainWindowView.swift | 29 ++++++++++++------- ShadowsocksX-NG2/App/WorkspaceRoute.swift | 13 ++++++--- .../Tests/WorkspaceRouteTests.swift | 6 ++++ 3 files changed, 34 insertions(+), 14 deletions(-) diff --git a/ShadowsocksX-NG2/App/MainWindowView.swift b/ShadowsocksX-NG2/App/MainWindowView.swift index 732da09c..bc5bc6e8 100644 --- a/ShadowsocksX-NG2/App/MainWindowView.swift +++ b/ShadowsocksX-NG2/App/MainWindowView.swift @@ -44,6 +44,13 @@ struct MainWindowView: View { let configurationGroupFileExporter: any ConfigurationGroupFileExporter @StateObject private var homeServerList = HomeServerListState() + /// 侧栏选中项中转:macOS 的 List/NavigationSplitView 会在视图更新期间回写 + /// selection(选择再同步,且可能携带缓存的旧值),回写必须落在 SwiftUI 自管 + /// 的 @State 上,再经 onChange 单向驱动 route;直接 publish 到 route 会触发 + /// view-update 期间发布警告,且缓存的旧值会把外部导航弹回。route 的外部 + /// 导航(添加菜单、规则编辑器返回等)经 sidebar 上的 onChange 回填选中项。 + /// 初值须与 WorkspaceRoute 的初始 destination 对齐,否则首帧侧栏无高亮行。 + @State private var sidebarSelection: WorkspaceDestination? = WorkspaceRoute.initialDestination @State private var selection: NodeID? @State private var ruleDraft: CustomRuleDraft? /// 全局添加菜单打开的表单,以及诊断导出由窗口壳持有。 @@ -271,7 +278,7 @@ extension MainWindowView { // MARK: - 侧栏 private var sidebar: some View { - List(selection: navigationBinding) { + List(selection: $sidebarSelection) { Section { ForEach(WorkspaceDestination.allCases) { destination in sidebarRow(destination) @@ -280,6 +287,17 @@ extension MainWindowView { } } .listStyle(.sidebar) + // 点击行 → @State 选中项 → route;nil 回写无对应 destination,忽略。 + .onChange(of: sidebarSelection) { _, destination in + guard let destination else { return } + route.navigate(to: destination) + } + // route 的外部导航 → 回填选中项;等值不写,避免导航回声。 + .onChange(of: route.destination) { _, destination in + if sidebarSelection != destination { + sidebarSelection = destination + } + } .safeAreaInset(edge: .top, spacing: 4) { identityHeader } .safeAreaInset(edge: .bottom, spacing: 4) { statusCard } } @@ -319,15 +337,6 @@ extension MainWindowView { } } - private var navigationBinding: Binding { - Binding( - get: { route.destination }, - set: { destination in - guard let destination else { return } - route.navigate(to: destination) - }) - } - /// 行尾数量角标:服务器 = 目录树内服务器叶子数;订阅 = 订阅数。 private func badgeCount(for destination: WorkspaceDestination) -> Int? { switch destination { diff --git a/ShadowsocksX-NG2/App/WorkspaceRoute.swift b/ShadowsocksX-NG2/App/WorkspaceRoute.swift index bd172517..4de94ee8 100644 --- a/ShadowsocksX-NG2/App/WorkspaceRoute.swift +++ b/ShadowsocksX-NG2/App/WorkspaceRoute.swift @@ -25,12 +25,17 @@ final class WorkspaceRoute: ObservableObject { /// scene id 散落(架构测试锚定)。 static let workspaceSceneID = "workspace" - @Published private(set) var destination: WorkspaceDestination = .home + /// 实例初始 destination。MainWindowView 侧栏 @State 选中项的初值须与此 + /// 对齐,否则首帧侧栏无高亮行。 + static let initialDestination: WorkspaceDestination = .home + + @Published private(set) var destination: WorkspaceDestination = initialDestination /// workspace 内部导航只改变 route destination;开窗不由 route 驱动。 - /// 同值导航必须是无操作:macOS 的 List/NavigationSplitView 会在视图更新 - /// 期间回写 selection Binding(选择再同步),此时 publish 会触发 - /// "Publishing changes from within view updates" 运行时警告。 + /// 同值导航必须是无操作:外部导航经 onChange 回填侧栏选中项后会再触发 + /// 一次 selection onChange(导航回声),由同值短路变成无操作;publish + /// 一旦发生在视图更新期间(List/NavigationSplitView 的选择再同步回写) + /// 会触发 "Publishing changes from within view updates" 运行时警告。 func navigate(to destination: WorkspaceDestination) { guard destination != self.destination else { return } self.destination = destination diff --git a/ShadowsocksX-NG2/Tests/WorkspaceRouteTests.swift b/ShadowsocksX-NG2/Tests/WorkspaceRouteTests.swift index 1e14547c..e8ae736a 100644 --- a/ShadowsocksX-NG2/Tests/WorkspaceRouteTests.swift +++ b/ShadowsocksX-NG2/Tests/WorkspaceRouteTests.swift @@ -10,6 +10,12 @@ final class WorkspaceRouteTests: XCTestCase { XCTAssertEqual(WorkspaceRoute().destination, .home) } + /// 侧栏 @State 选中项的初值锚定在 initialDestination 上(见 MainWindowView); + /// 两者脱钩会导致首帧侧栏无高亮行。 + func testInitialDestinationConstantMatchesFreshRoute() { + XCTAssertEqual(WorkspaceRoute.initialDestination, WorkspaceRoute().destination) + } + /// 侧栏 List/NavigationSplitView 会在视图更新期间回写 selection Binding; /// 同值导航不得 publish(否则触发 view-update 期间发布警告)。 func testNavigateToCurrentDestinationDoesNotPublish() {