Skip to content

Commit 3cefc10

Browse files
authored
Add StateObject implementation (#525)
1 parent ff01ac1 commit 3cefc10

File tree

5 files changed

+290
-25
lines changed

5 files changed

+290
-25
lines changed

Sources/OpenSwiftUICore/Data/Combine/AttributeInvalidatingSubscriber.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
// Audited for 6.5.4
66
// Status: Complete
77

8-
import Foundation
8+
import class Foundation.Thread
99
import OpenAttributeGraphShims
1010
#if OPENSWIFTUI_OPENCOMBINE
1111
import OpenCombine

Sources/OpenSwiftUICore/Data/Combine/ObservedObject.swift

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -209,10 +209,42 @@ public struct ObservedObject<ObjectType>: DynamicProperty where ObjectType: Obse
209209
}
210210
}
211211

212+
@available(OpenSwiftUI_v1_0, *)
212213
extension ObservedObject {
213-
public static func _makeProperty<Value>(
214+
#if OPENSWIFTUI_RELEASE_2025 // Audited for 7.0.67
215+
nonisolated static func makeBoxAndSignal<V>(
216+
in buffer: inout _DynamicPropertyBuffer,
217+
container: _GraphValue<V>,
218+
fieldOffset: Int
219+
) -> Attribute<()> {
220+
let attribute = Attribute(value: ())
221+
let box = ObservedObjectPropertyBox<ObjectType>(
222+
host: .currentHost,
223+
invalidation: WeakAttribute(attribute)
224+
)
225+
buffer.append(box, fieldOffset: fieldOffset)
226+
return attribute
227+
}
228+
229+
nonisolated public static func _makeProperty<V>(
230+
in buffer: inout _DynamicPropertyBuffer,
231+
container: _GraphValue<V>,
232+
fieldOffset: Int,
233+
inputs: inout _GraphInputs
234+
) {
235+
let attribute = makeBoxAndSignal(in: &buffer, container: container, fieldOffset: fieldOffset)
236+
addTreeValue(
237+
attribute,
238+
as: ObjectType.self,
239+
at: fieldOffset,
240+
in: V.self,
241+
flags: .observedObjectSignal
242+
)
243+
}
244+
#else
245+
nonisolated public static func _makeProperty<V>(
214246
in buffer: inout _DynamicPropertyBuffer,
215-
container: _GraphValue<Value>,
247+
container: _GraphValue<V>,
216248
fieldOffset: Int,
217249
inputs: inout _GraphInputs
218250
) {
@@ -226,14 +258,16 @@ extension ObservedObject {
226258
attribute,
227259
as: ObjectType.self,
228260
at: fieldOffset,
229-
in: Value.self,
261+
in: V.self,
230262
flags: .observedObjectSignal
231263
)
232264
}
265+
#endif
233266
}
234267

235-
extension ObservableObject {
236-
public static var _propertyBehaviors: UInt32 {
268+
@available(OpenSwiftUI_v3_0, *)
269+
extension ObservedObject {
270+
nonisolated public static var _propertyBehaviors: UInt32 {
237271
DynamicPropertyBehaviors.requiresMainThread.rawValue
238272
}
239273
}

Sources/OpenSwiftUICore/Data/Combine/StateObject.swift

Lines changed: 88 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
//
22
// StateObject.swift
3-
// OpenSwiftUI
3+
// OpenSwiftUICore
44
//
5-
// Audited for 3.5.2
6-
// Status: Blocked by DynamicProperty
5+
// Audited for 6.5.4
6+
// Status: Complete
7+
// ID: BDD24532CFCFEBA7264ABA5DE20A4002 (SwiftUICore)
78

9+
import class Foundation.Thread
10+
import OpenAttributeGraphShims
811
#if OPENSWIFTUI_OPENCOMBINE
912
public import OpenCombine
1013
#else
@@ -173,10 +176,15 @@ public import Combine
173176
/// Also, changing the identity resets _all_ state held by the view, including
174177
/// values that you manage as ``State``, ``FocusState``, ``GestureState``,
175178
/// and so on.
179+
@available(OpenSwiftUI_v2_0, *)
176180
@frozen
177181
@propertyWrapper
178-
public struct StateObject<ObjectType> where ObjectType: ObservableObject {
182+
@preconcurrency
183+
@MainActor
184+
public struct StateObject<ObjectType>: DynamicProperty where ObjectType: ObservableObject {
179185
@usableFromInline
186+
@preconcurrency
187+
@MainActor
180188
@frozen
181189
enum Storage {
182190
case initially(() -> ObjectType)
@@ -224,10 +232,20 @@ public struct StateObject<ObjectType> where ObjectType: ObservableObject {
224232
///
225233
/// - Parameter thunk: An initial value for the state object.
226234
@inlinable
227-
public init(wrappedValue thunk: @autoclosure @escaping () -> ObjectType) {
235+
nonisolated public init(wrappedValue thunk: @autoclosure @escaping () -> ObjectType) {
228236
storage = .initially(thunk)
229237
}
230238

239+
var objectValue: ObservedObject<ObjectType> {
240+
switch storage {
241+
case let .initially(thunk):
242+
Log.runtimeIssues("Accessing StateObject's object without being installed on a View. This will create a new instance each time.")
243+
return ObservedObject(wrappedValue: thunk())
244+
case let .object(value):
245+
return value
246+
}
247+
}
248+
231249
/// The underlying value referenced by the state object.
232250
///
233251
/// The wrapped value property provides primary access to the value's data.
@@ -269,26 +287,77 @@ public struct StateObject<ObjectType> where ObjectType: ObservableObject {
269287
public var projectedValue: ObservedObject<ObjectType>.Wrapper {
270288
objectValue.projectedValue
271289
}
272-
}
273290

274-
extension StateObject: DynamicProperty {
275-
public static func _makeProperty(in _: inout _DynamicPropertyBuffer, container _: _GraphValue<some Any>, fieldOffset _: Int, inputs _: inout _GraphInputs) {
276-
// TODO:
291+
private struct Box: DynamicPropertyBox {
292+
var links: _DynamicPropertyBuffer
293+
var object: ObservedObject<ObjectType>?
294+
295+
typealias Property = StateObject
296+
297+
func destroy() {
298+
links.destroy()
299+
}
300+
301+
mutating func reset() {
302+
links.reset()
303+
object = nil
304+
}
305+
306+
mutating func update(property: inout Property, phase: ViewPhase) -> Bool {
307+
var changed = false
308+
if object == nil {
309+
switch property.storage {
310+
case let .initially(thunk):
311+
if !Thread.isMainThread {
312+
Log.runtimeIssues(
313+
"Updating StateObject<%s> from background\nthreads will cause undefined behavior; make sure to update it from the main thread.",
314+
["\(ObjectType.self)"]
315+
)
316+
}
317+
var value: UncheckedSendable<ObservedObject<ObjectType>?> = UncheckedSendable(nil)
318+
value.value = ObservedObject(wrappedValue: thunk())
319+
self.object = value.value
320+
case let .object(object):
321+
self.object = object
322+
}
323+
changed = true
324+
}
325+
var object = object!
326+
defer { property.storage = .object(object) }
327+
// NOTE: This should be withUnsafeMutablePointer IMO. Currently align with SwiftUI implementation.
328+
return withUnsafePointer(to: object) { pointer in
329+
links.update(container: UnsafeMutableRawPointer(mutating: pointer), phase: phase) || changed
330+
}
331+
}
277332
}
278333

279-
public static var _propertyBehaviors: UInt32 {
280-
DynamicPropertyBehaviors.requiresMainThread.rawValue
334+
nonisolated public static func _makeProperty<V>(
335+
in buffer: inout _DynamicPropertyBuffer,
336+
container: _GraphValue<V>,
337+
fieldOffset: Int,
338+
inputs: inout _GraphInputs
339+
) {
340+
var buf = _DynamicPropertyBuffer()
341+
#if OPENSWIFTUI_RELEASE_2025
342+
let attribute = ObservedObject<ObjectType>.makeBoxAndSignal(in: &buf, container: container, fieldOffset: 0)
343+
buffer.append(Box(links: buf, object: nil), fieldOffset: fieldOffset)
344+
addTreeValue(
345+
attribute,
346+
as: ObjectType.self,
347+
at: fieldOffset,
348+
in: V.self,
349+
flags: .stateObjectSignal
350+
)
351+
#else
352+
ObservedObject<ObjectType>._makeProperty(in: &buf, container: container, fieldOffset: 0, inputs: &inputs)
353+
buffer.append(Box(links: buf, object: nil), fieldOffset: fieldOffset)
354+
#endif
281355
}
282356
}
283357

358+
@available(OpenSwiftUI_v3_0, *)
284359
extension StateObject {
285-
var objectValue: ObservedObject<ObjectType> {
286-
switch storage {
287-
case let .initially(thunk):
288-
Log.runtimeIssues("Accessing StateObject's object without being installed on a View. This will create a new instance each time.")
289-
return ObservedObject(wrappedValue: thunk())
290-
case let .object(value):
291-
return value
292-
}
360+
public static var _propertyBehaviors: UInt32 {
361+
DynamicPropertyBehaviors.requiresMainThread.rawValue
293362
}
294363
}

Sources/OpenSwiftUICore/Util/AttributeGraphAdditions.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,10 @@ package struct TreeValueFlags: OptionSet {
195195
package static let appStorageSignal: TreeValueFlags = .init(rawValue: 4)
196196

197197
package static let sceneStorageSignal: TreeValueFlags = .init(rawValue: 5)
198+
199+
#if OPENSWIFTUI_SUPPORT_2025_API // Audited for 7.0.67
200+
package static let stateObjectSignal: TreeValueFlags = .init(rawValue: 6)
201+
#endif
198202
}
199203

200204
// MARK: - Metadata Additions [6.5.4]
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
//
2+
// StateObjectCompatibilityTests.swift
3+
// OpenSwiftUICompatibilityTests
4+
5+
import Testing
6+
import OpenSwiftUITestsSupport
7+
#if OPENSWIFTUI_OPENCOMBINE
8+
import OpenCombine
9+
#else
10+
import Combine
11+
#endif
12+
13+
@MainActor
14+
struct StateObjectCompatibilityTests {
15+
@MainActor
16+
enum Count {
17+
static var contentBody: Int = 0
18+
static var subviewBody: Int = 0
19+
}
20+
21+
class Model: ObservableObject {
22+
@Published var t1 = false
23+
var t2 = false
24+
}
25+
26+
@Observable
27+
class Model2 {
28+
var t1 = false
29+
@ObservationIgnored var t2 = false
30+
}
31+
32+
struct Subview: View {
33+
var condition: Bool
34+
35+
var body: some View {
36+
let _ = {
37+
Count.subviewBody += 1
38+
}()
39+
Color(condition ? .red : .blue)
40+
}
41+
}
42+
43+
@Test
44+
func stateObjectChangePublishValue() async throws {
45+
defer {
46+
Count.contentBody = 0
47+
Count.subviewBody = 0
48+
}
49+
struct ContentView: View {
50+
@StateObject private var model = Model()
51+
var body: some View {
52+
let _ = {
53+
Count.contentBody += 1
54+
}()
55+
VStack {
56+
Subview(condition: model.t1)
57+
Subview(condition: model.t2)
58+
}.onAppear {
59+
model.t1.toggle()
60+
}
61+
}
62+
}
63+
try await triggerLayoutWithWindow(expectedCount: 0) { _ in
64+
PlatformHostingController(
65+
rootView: ContentView()
66+
)
67+
}
68+
#expect(Count.contentBody == 2)
69+
#expect(Count.subviewBody == 3)
70+
}
71+
72+
@Test
73+
func stateObjectChangeNonPublishValue() async throws {
74+
defer {
75+
Count.contentBody = 0
76+
Count.subviewBody = 0
77+
}
78+
struct ContentView: View {
79+
@StateObject private var model = Model()
80+
var body: some View {
81+
let _ = {
82+
Count.contentBody += 1
83+
}()
84+
VStack {
85+
Subview(condition: model.t1)
86+
Subview(condition: model.t2)
87+
}.onAppear {
88+
model.t2.toggle()
89+
}
90+
}
91+
}
92+
try await triggerLayoutWithWindow(expectedCount: 0) { _ in
93+
PlatformHostingController(
94+
rootView: ContentView()
95+
)
96+
}
97+
#expect(Count.contentBody == 1)
98+
#expect(Count.subviewBody == 2)
99+
}
100+
101+
@Test
102+
func observableMacroTrackedValue() async throws {
103+
defer {
104+
Count.contentBody = 0
105+
Count.subviewBody = 0
106+
}
107+
struct ContentView: View {
108+
@State private var model = Model2()
109+
var body: some View {
110+
let _ = {
111+
Count.contentBody += 1
112+
}()
113+
VStack {
114+
Subview(condition: model.t1)
115+
Subview(condition: model.t2)
116+
}.onAppear {
117+
model.t1.toggle()
118+
}
119+
}
120+
}
121+
try await triggerLayoutWithWindow(expectedCount: 0) { _ in
122+
PlatformHostingController(
123+
rootView: ContentView()
124+
)
125+
}
126+
#expect(Count.contentBody == 2)
127+
#expect(Count.subviewBody == 3)
128+
}
129+
130+
@Test
131+
func observableMacroIgnoredValue() async throws {
132+
defer {
133+
Count.contentBody = 0
134+
Count.subviewBody = 0
135+
}
136+
struct ContentView: View {
137+
@State private var model = Model2()
138+
var body: some View {
139+
let _ = {
140+
Count.contentBody += 1
141+
}()
142+
VStack {
143+
Subview(condition: model.t1)
144+
Subview(condition: model.t2)
145+
}.onAppear {
146+
model.t2.toggle()
147+
}
148+
}
149+
}
150+
try await triggerLayoutWithWindow(expectedCount: 0) { _ in
151+
PlatformHostingController(
152+
rootView: ContentView()
153+
)
154+
}
155+
#expect(Count.contentBody == 1)
156+
#expect(Count.subviewBody == 2)
157+
}
158+
}

0 commit comments

Comments
 (0)