From fd8efcb51d90a97bca0abe282f83d686fe946d60 Mon Sep 17 00:00:00 2001 From: Kyle Date: Thu, 2 Oct 2025 01:02:36 +0800 Subject: [PATCH 1/2] Add StateObject implementation --- .../AttributeInvalidatingSubscriber.swift | 2 +- .../Data/Combine/ObservedObject.swift | 44 ++++++- .../Data/Combine/StateObject.swift | 107 ++++++++++++++---- .../Util/AttributeGraphAdditions.swift | 4 + 4 files changed, 132 insertions(+), 25 deletions(-) diff --git a/Sources/OpenSwiftUICore/Data/Combine/AttributeInvalidatingSubscriber.swift b/Sources/OpenSwiftUICore/Data/Combine/AttributeInvalidatingSubscriber.swift index ac416bd90..bfcbd642c 100644 --- a/Sources/OpenSwiftUICore/Data/Combine/AttributeInvalidatingSubscriber.swift +++ b/Sources/OpenSwiftUICore/Data/Combine/AttributeInvalidatingSubscriber.swift @@ -5,7 +5,7 @@ // Audited for 6.5.4 // Status: Complete -import Foundation +import class Foundation.Thread import OpenAttributeGraphShims #if OPENSWIFTUI_OPENCOMBINE import OpenCombine diff --git a/Sources/OpenSwiftUICore/Data/Combine/ObservedObject.swift b/Sources/OpenSwiftUICore/Data/Combine/ObservedObject.swift index 5063e1c57..3d9580c70 100644 --- a/Sources/OpenSwiftUICore/Data/Combine/ObservedObject.swift +++ b/Sources/OpenSwiftUICore/Data/Combine/ObservedObject.swift @@ -209,10 +209,42 @@ public struct ObservedObject: DynamicProperty where ObjectType: Obse } } +@available(OpenSwiftUI_v1_0, *) extension ObservedObject { - public static func _makeProperty( + #if OPENSWIFTUI_RELEASE_2025 // Audited for 7.0.67 + nonisolated static func makeBoxAndSignal( + in buffer: inout _DynamicPropertyBuffer, + container: _GraphValue, + fieldOffset: Int + ) -> Attribute<()> { + let attribute = Attribute(value: ()) + let box = ObservedObjectPropertyBox( + host: .currentHost, + invalidation: WeakAttribute(attribute) + ) + buffer.append(box, fieldOffset: fieldOffset) + return attribute + } + + nonisolated public static func _makeProperty( + in buffer: inout _DynamicPropertyBuffer, + container: _GraphValue, + fieldOffset: Int, + inputs: inout _GraphInputs + ) { + let attribute = makeBoxAndSignal(in: &buffer, container: container, fieldOffset: fieldOffset) + addTreeValue( + attribute, + as: ObjectType.self, + at: fieldOffset, + in: V.self, + flags: .observedObjectSignal + ) + } + #else + nonisolated public static func _makeProperty( in buffer: inout _DynamicPropertyBuffer, - container: _GraphValue, + container: _GraphValue, fieldOffset: Int, inputs: inout _GraphInputs ) { @@ -226,14 +258,16 @@ extension ObservedObject { attribute, as: ObjectType.self, at: fieldOffset, - in: Value.self, + in: V.self, flags: .observedObjectSignal ) } + #endif } -extension ObservableObject { - public static var _propertyBehaviors: UInt32 { +@available(OpenSwiftUI_v3_0, *) +extension ObservedObject { + nonisolated public static var _propertyBehaviors: UInt32 { DynamicPropertyBehaviors.requiresMainThread.rawValue } } diff --git a/Sources/OpenSwiftUICore/Data/Combine/StateObject.swift b/Sources/OpenSwiftUICore/Data/Combine/StateObject.swift index 52330a294..031e63dc1 100644 --- a/Sources/OpenSwiftUICore/Data/Combine/StateObject.swift +++ b/Sources/OpenSwiftUICore/Data/Combine/StateObject.swift @@ -1,10 +1,13 @@ // // StateObject.swift -// OpenSwiftUI +// OpenSwiftUICore // -// Audited for 3.5.2 -// Status: Blocked by DynamicProperty +// Audited for 6.5.4 +// Status: Complete +// ID: BDD24532CFCFEBA7264ABA5DE20A4002 (SwiftUICore) +import class Foundation.Thread +import OpenAttributeGraphShims #if OPENSWIFTUI_OPENCOMBINE public import OpenCombine #else @@ -173,10 +176,15 @@ public import Combine /// Also, changing the identity resets _all_ state held by the view, including /// values that you manage as ``State``, ``FocusState``, ``GestureState``, /// and so on. +@available(OpenSwiftUI_v2_0, *) @frozen @propertyWrapper -public struct StateObject where ObjectType: ObservableObject { +@preconcurrency +@MainActor +public struct StateObject: DynamicProperty where ObjectType: ObservableObject { @usableFromInline + @preconcurrency + @MainActor @frozen enum Storage { case initially(() -> ObjectType) @@ -224,10 +232,20 @@ public struct StateObject where ObjectType: ObservableObject { /// /// - Parameter thunk: An initial value for the state object. @inlinable - public init(wrappedValue thunk: @autoclosure @escaping () -> ObjectType) { + nonisolated public init(wrappedValue thunk: @autoclosure @escaping () -> ObjectType) { storage = .initially(thunk) } + var objectValue: ObservedObject { + switch storage { + case let .initially(thunk): + Log.runtimeIssues("Accessing StateObject's object without being installed on a View. This will create a new instance each time.") + return ObservedObject(wrappedValue: thunk()) + case let .object(value): + return value + } + } + /// The underlying value referenced by the state object. /// /// The wrapped value property provides primary access to the value's data. @@ -269,26 +287,77 @@ public struct StateObject where ObjectType: ObservableObject { public var projectedValue: ObservedObject.Wrapper { objectValue.projectedValue } -} -extension StateObject: DynamicProperty { - public static func _makeProperty(in _: inout _DynamicPropertyBuffer, container _: _GraphValue, fieldOffset _: Int, inputs _: inout _GraphInputs) { - // TODO: + private struct Box: DynamicPropertyBox { + var links: _DynamicPropertyBuffer + var object: ObservedObject? + + typealias Property = StateObject + + func destroy() { + links.destroy() + } + + mutating func reset() { + links.reset() + object = nil + } + + mutating func update(property: inout Property, phase: ViewPhase) -> Bool { + var changed = false + if object == nil { + switch property.storage { + case let .initially(thunk): + if !Thread.isMainThread { + Log.runtimeIssues( + "Updating StateObject<%s> from background\nthreads will cause undefined behavior; make sure to update it from the main thread.", + ["\(ObjectType.self)"] + ) + } + var value: UncheckedSendable?> = UncheckedSendable(nil) + value.value = ObservedObject(wrappedValue: thunk()) + self.object = value.value + case let .object(object): + self.object = object + } + changed = true + } + var object = object! + defer { property.storage = .object(object) } + // NOTE: This should be withUnsafeMutablePointer IMO. Currently align with SwiftUI implementation. + return withUnsafePointer(to: object) { pointer in + links.update(container: UnsafeMutableRawPointer(mutating: pointer), phase: phase) || changed + } + } } - public static var _propertyBehaviors: UInt32 { - DynamicPropertyBehaviors.requiresMainThread.rawValue + nonisolated public static func _makeProperty( + in buffer: inout _DynamicPropertyBuffer, + container: _GraphValue, + fieldOffset: Int, + inputs: inout _GraphInputs + ) { + var buf = _DynamicPropertyBuffer() + #if OPENSWIFTUI_RELEASE_2025 + let attribute = ObservedObject.makeBoxAndSignal(in: &buf, container: container, fieldOffset: 0) + buffer.append(Box(links: buf, object: nil), fieldOffset: fieldOffset) + addTreeValue( + attribute, + as: ObjectType.self, + at: fieldOffset, + in: V.self, + flags: .stateObjectSignal + ) + #else + ObservedObject._makeProperty(in: &buf, container: container, fieldOffset: 0, inputs: &inputs) + buffer.append(Box(links: buf, object: nil), fieldOffset: fieldOffset) + #endif } } +@available(OpenSwiftUI_v3_0, *) extension StateObject { - var objectValue: ObservedObject { - switch storage { - case let .initially(thunk): - Log.runtimeIssues("Accessing StateObject's object without being installed on a View. This will create a new instance each time.") - return ObservedObject(wrappedValue: thunk()) - case let .object(value): - return value - } + public static var _propertyBehaviors: UInt32 { + DynamicPropertyBehaviors.requiresMainThread.rawValue } } diff --git a/Sources/OpenSwiftUICore/Util/AttributeGraphAdditions.swift b/Sources/OpenSwiftUICore/Util/AttributeGraphAdditions.swift index f83225579..0b5467617 100644 --- a/Sources/OpenSwiftUICore/Util/AttributeGraphAdditions.swift +++ b/Sources/OpenSwiftUICore/Util/AttributeGraphAdditions.swift @@ -195,6 +195,10 @@ package struct TreeValueFlags: OptionSet { package static let appStorageSignal: TreeValueFlags = .init(rawValue: 4) package static let sceneStorageSignal: TreeValueFlags = .init(rawValue: 5) + + #if OPENSWIFTUI_SUPPORT_2025_API // Audited for 7.0.67 + package static let stateObjectSignal: TreeValueFlags = .init(rawValue: 6) + #endif } // MARK: - Metadata Additions [6.5.4] From 4c013d62404aab2757eb97d8d8eaaffaddeb0869 Mon Sep 17 00:00:00 2001 From: Kyle Date: Thu, 2 Oct 2025 01:13:30 +0800 Subject: [PATCH 2/2] Add StateObjectCompatibilityTests --- .../StateObjectCompatibilityTests.swift | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 Tests/OpenSwiftUICompatibilityTests/Data/Combine/StateObjectCompatibilityTests.swift diff --git a/Tests/OpenSwiftUICompatibilityTests/Data/Combine/StateObjectCompatibilityTests.swift b/Tests/OpenSwiftUICompatibilityTests/Data/Combine/StateObjectCompatibilityTests.swift new file mode 100644 index 000000000..29fb8acf6 --- /dev/null +++ b/Tests/OpenSwiftUICompatibilityTests/Data/Combine/StateObjectCompatibilityTests.swift @@ -0,0 +1,158 @@ +// +// StateObjectCompatibilityTests.swift +// OpenSwiftUICompatibilityTests + +import Testing +import OpenSwiftUITestsSupport +#if OPENSWIFTUI_OPENCOMBINE +import OpenCombine +#else +import Combine +#endif + +@MainActor +struct StateObjectCompatibilityTests { + @MainActor + enum Count { + static var contentBody: Int = 0 + static var subviewBody: Int = 0 + } + + class Model: ObservableObject { + @Published var t1 = false + var t2 = false + } + + @Observable + class Model2 { + var t1 = false + @ObservationIgnored var t2 = false + } + + struct Subview: View { + var condition: Bool + + var body: some View { + let _ = { + Count.subviewBody += 1 + }() + Color(condition ? .red : .blue) + } + } + + @Test + func stateObjectChangePublishValue() async throws { + defer { + Count.contentBody = 0 + Count.subviewBody = 0 + } + struct ContentView: View { + @StateObject private var model = Model() + var body: some View { + let _ = { + Count.contentBody += 1 + }() + VStack { + Subview(condition: model.t1) + Subview(condition: model.t2) + }.onAppear { + model.t1.toggle() + } + } + } + try await triggerLayoutWithWindow(expectedCount: 0) { _ in + PlatformHostingController( + rootView: ContentView() + ) + } + #expect(Count.contentBody == 2) + #expect(Count.subviewBody == 3) + } + + @Test + func stateObjectChangeNonPublishValue() async throws { + defer { + Count.contentBody = 0 + Count.subviewBody = 0 + } + struct ContentView: View { + @StateObject private var model = Model() + var body: some View { + let _ = { + Count.contentBody += 1 + }() + VStack { + Subview(condition: model.t1) + Subview(condition: model.t2) + }.onAppear { + model.t2.toggle() + } + } + } + try await triggerLayoutWithWindow(expectedCount: 0) { _ in + PlatformHostingController( + rootView: ContentView() + ) + } + #expect(Count.contentBody == 1) + #expect(Count.subviewBody == 2) + } + + @Test + func observableMacroTrackedValue() async throws { + defer { + Count.contentBody = 0 + Count.subviewBody = 0 + } + struct ContentView: View { + @State private var model = Model2() + var body: some View { + let _ = { + Count.contentBody += 1 + }() + VStack { + Subview(condition: model.t1) + Subview(condition: model.t2) + }.onAppear { + model.t1.toggle() + } + } + } + try await triggerLayoutWithWindow(expectedCount: 0) { _ in + PlatformHostingController( + rootView: ContentView() + ) + } + #expect(Count.contentBody == 2) + #expect(Count.subviewBody == 3) + } + + @Test + func observableMacroIgnoredValue() async throws { + defer { + Count.contentBody = 0 + Count.subviewBody = 0 + } + struct ContentView: View { + @State private var model = Model2() + var body: some View { + let _ = { + Count.contentBody += 1 + }() + VStack { + Subview(condition: model.t1) + Subview(condition: model.t2) + }.onAppear { + model.t2.toggle() + } + } + } + try await triggerLayoutWithWindow(expectedCount: 0) { _ in + PlatformHostingController( + rootView: ContentView() + ) + } + #expect(Count.contentBody == 1) + #expect(Count.subviewBody == 2) + } +}