mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-31 06:33:44 +00:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user