fix: defer plugin mode changes past view updates

macOS segmented Picker writes its binding within a SwiftUI update.
Defer the draft mutation to a main actor task to prevent synchronous
Published notifications during that update.
This commit is contained in:
Qiu Yuzhou
2026-10-04 18:46:25 +08:00
parent 1a22af7e62
commit 0164ff75ec
2 changed files with 75 additions and 4 deletions
@@ -103,7 +103,7 @@ struct ServerPluginSection: View {
}
/// 查看模式切换(参数列表 ⇄ 原始文本);被未完成行拒绝时回弹到当前模式。
private struct PluginOptionsModePicker: View {
struct PluginOptionsModePicker: View {
@ObservedObject var draft: PluginOptionsDraft
@State private var displayedMode = PluginOptionsDraft.Mode.table
@@ -123,9 +123,14 @@ private struct PluginOptionsModePicker: View {
Binding(
get: { displayedMode },
set: { requested in
let succeeded =
requested == .rawText ? draft.switchToRawText() : draft.switchToTable()
displayedMode = succeeded ? requested : draft.mode
displayedMode = requested
// macOS 分段 Picker 在 SwiftUI 更新事务内调用绑定 setter;草稿的
// @Published 更新须离开该事务,避免向观察视图同步发布变更。
Task { @MainActor in
let succeeded =
requested == .rawText ? draft.switchToRawText() : draft.switchToTable()
displayedMode = succeeded ? requested : draft.mode
}
})
}
}
@@ -0,0 +1,66 @@
import AppKit
import Combine
import SwiftUI
import Testing
@testable import ShadowsocksX_NG2
@MainActor
struct PluginOptionsModePickerTests {
@Test
func nativePickerDefersDraftPublicationUntilAfterItsAction() async throws {
let draft = PluginOptionsDraft()
draft.load("tls;host=example.com;path=/ws;")
let host = NSHostingView(rootView: PluginOptionsModePicker(draft: draft))
host.frame = NSRect(x: 0, y: 0, width: 320, height: 60)
host.layoutSubtreeIfNeeded()
let picker = try #require(Self.segmentedControl(in: host))
var publications = 0
let observation = draft.objectWillChange.sink { publications += 1 }
defer { observation.cancel() }
// 原生 macOS Picker 的 action 在 SwiftUI 更新事务内写入绑定。
// 此时同步发布 ObservableObject 变更会触发运行时警告。
picker.selectedSegment = 1
picker.sendAction(picker.action, to: picker.target)
#expect(publications == 0)
#expect(draft.mode == .table)
await Task { @MainActor in }.value
#expect(draft.mode == .rawText)
#expect(draft.rawText == "tls;host=example.com;path=/ws;")
#expect(publications > 0)
publications = 0
picker.selectedSegment = 0
picker.sendAction(picker.action, to: picker.target)
#expect(publications == 0)
await Task { @MainActor in }.value
#expect(draft.mode == .table)
#expect(draft.composedString == "tls;host=example.com;path=/ws;")
}
@Test
func nativePickerKeepsTableModeWhenARowIsUnfinished() async throws {
let draft = PluginOptionsDraft()
draft.load("host=example.com")
draft.updateValue("unfinished", of: draft.quickAddRowID)
let host = NSHostingView(rootView: PluginOptionsModePicker(draft: draft))
host.frame = NSRect(x: 0, y: 0, width: 320, height: 60)
host.layoutSubtreeIfNeeded()
let picker = try #require(Self.segmentedControl(in: host))
picker.selectedSegment = 1
picker.sendAction(picker.action, to: picker.target)
await Task { @MainActor in }.value
host.layoutSubtreeIfNeeded()
#expect(draft.mode == .table)
#expect(draft.hasUnfinishedRows)
#expect(picker.selectedSegment == 0)
}
private static func segmentedControl(in view: NSView) -> NSSegmentedControl? {
if let control = view as? NSSegmentedControl { return control }
return view.subviews.lazy.compactMap { segmentedControl(in: $0) }.first
}
}