Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
// Audited for 6.5.4
// Status: Complete

import Foundation
import class Foundation.Thread
import OpenAttributeGraphShims
#if OPENSWIFTUI_OPENCOMBINE
import OpenCombine
Expand Down
44 changes: 39 additions & 5 deletions Sources/OpenSwiftUICore/Data/Combine/ObservedObject.swift
Original file line number Diff line number Diff line change
Expand Up @@ -209,10 +209,42 @@ public struct ObservedObject<ObjectType>: DynamicProperty where ObjectType: Obse
}
}

@available(OpenSwiftUI_v1_0, *)
extension ObservedObject {
public static func _makeProperty<Value>(
#if OPENSWIFTUI_RELEASE_2025 // Audited for 7.0.67
nonisolated static func makeBoxAndSignal<V>(
in buffer: inout _DynamicPropertyBuffer,
container: _GraphValue<V>,
fieldOffset: Int
) -> Attribute<()> {
let attribute = Attribute(value: ())
let box = ObservedObjectPropertyBox<ObjectType>(
host: .currentHost,
invalidation: WeakAttribute(attribute)
)
buffer.append(box, fieldOffset: fieldOffset)
return attribute
}

nonisolated public static func _makeProperty<V>(
in buffer: inout _DynamicPropertyBuffer,
container: _GraphValue<V>,
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<V>(
in buffer: inout _DynamicPropertyBuffer,
container: _GraphValue<Value>,
container: _GraphValue<V>,
fieldOffset: Int,
inputs: inout _GraphInputs
) {
Expand All @@ -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
}
}
Expand Down
107 changes: 88 additions & 19 deletions Sources/OpenSwiftUICore/Data/Combine/StateObject.swift
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<ObjectType> where ObjectType: ObservableObject {
@preconcurrency
@MainActor
public struct StateObject<ObjectType>: DynamicProperty where ObjectType: ObservableObject {
@usableFromInline
@preconcurrency
@MainActor
@frozen
enum Storage {
case initially(() -> ObjectType)
Expand Down Expand Up @@ -224,10 +232,20 @@ public struct StateObject<ObjectType> 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<ObjectType> {
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.
Expand Down Expand Up @@ -269,26 +287,77 @@ public struct StateObject<ObjectType> where ObjectType: ObservableObject {
public var projectedValue: ObservedObject<ObjectType>.Wrapper {
objectValue.projectedValue
}
}

extension StateObject: DynamicProperty {
public static func _makeProperty(in _: inout _DynamicPropertyBuffer, container _: _GraphValue<some Any>, fieldOffset _: Int, inputs _: inout _GraphInputs) {
// TODO:
private struct Box: DynamicPropertyBox {
var links: _DynamicPropertyBuffer
var object: ObservedObject<ObjectType>?

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<ObservedObject<ObjectType>?> = 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<V>(
in buffer: inout _DynamicPropertyBuffer,
container: _GraphValue<V>,
fieldOffset: Int,
inputs: inout _GraphInputs
) {
var buf = _DynamicPropertyBuffer()
#if OPENSWIFTUI_RELEASE_2025
let attribute = ObservedObject<ObjectType>.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<ObjectType>._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<ObjectType> {
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
}
}
4 changes: 4 additions & 0 deletions Sources/OpenSwiftUICore/Util/AttributeGraphAdditions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading