diff --git a/Package.resolved b/Package.resolved index 05a573cb3..0b9bf16bb 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "8d360f40fb5597175d979d470392550ddec9eab480e1079984d3ca5d305dfac7", + "originHash" : "dba5f45b3509800de52297e240d3ba12fa4ee6ff3696a740d87e49bfa8b63b81", "pins" : [ { "identity" : "darwinprivateframeworks", @@ -7,7 +7,7 @@ "location" : "https://github.com/OpenSwiftUIProject/DarwinPrivateFrameworks.git", "state" : { "branch" : "main", - "revision" : "8a2d63286731ff9ba4d30776bb54aa64b0939167" + "revision" : "687fe390c84dac918fe8f5f5823ad2a1edfa2966" } }, { @@ -16,7 +16,7 @@ "location" : "https://github.com/OpenSwiftUIProject/OpenAttributeGraph", "state" : { "branch" : "main", - "revision" : "80d85ebb7cc2195f4115c830c190776a7b141c84" + "revision" : "6f398f0238e498ce60f98b90e99efdc27b0f0c78" } }, { diff --git a/Sources/OpenSwiftUICore/Data/Binding/Binding+ObjectLocation.swift b/Sources/OpenSwiftUICore/Data/Binding/Binding+ObjectLocation.swift new file mode 100644 index 000000000..376e53785 --- /dev/null +++ b/Sources/OpenSwiftUICore/Data/Binding/Binding+ObjectLocation.swift @@ -0,0 +1,61 @@ +// +// Binding+ObjectLocation.swift +// OpenSwiftUICore +// +// Audited for 6.5.4 +// Status: Complete +// ID: 7719FABF28E05207C06C2817640AD611 (SwiftUICore) + +import Foundation + +extension Binding { + init( + _ root: ObjectType, + keyPath: ReferenceWritableKeyPath, + isolation: (any Actor)? = #isolation + ) { + let location = ObjectLocation(base: root, keyPath: keyPath, isolation: isolation) + let box = LocationBox(location) + self.init(value: location.get(), location: box) + } +} + +private struct ObjectLocation: Location where Root: AnyObject { + var base: Root + + var keyPath: ReferenceWritableKeyPath + + var isolation: (any Actor)? + + var wasRead: Bool { + get { true } + nonmutating set {} + } + + func get() -> Value { + checkIsolation() + return base[keyPath: keyPath] + } + + func set(_ value: Value, transaction: Transaction) { + withTransaction(transaction) { + checkIsolation() + base[keyPath: keyPath] = value + } + } + + static func == (_ lhs: ObjectLocation, _ rhs: ObjectLocation) -> Bool { + lhs.base === rhs.base && lhs.keyPath == rhs.keyPath + } + + func checkIsolation() { + guard let isolation, isolation === MainActor.shared, !Thread.isMainThread else { + return + } + let description = String(describing: keyPath) + Log.runtimeIssues( + "%s is isolated to the main actor. Accessing it via Binding from a different actor will cause undefined behaviors, and potential data races; This warning will become a runtime crash in a future version of OpenSwiftUI.", + [description] + ) + } +} diff --git a/Sources/OpenSwiftUICore/Data/Combine/AttributeInvalidatingSubscriber.swift b/Sources/OpenSwiftUICore/Data/Combine/AttributeInvalidatingSubscriber.swift new file mode 100644 index 000000000..ac416bd90 --- /dev/null +++ b/Sources/OpenSwiftUICore/Data/Combine/AttributeInvalidatingSubscriber.swift @@ -0,0 +1,106 @@ +// +// AttributeInvalidatingSubscriber.swift +// OpenSwiftUICore +// +// Audited for 6.5.4 +// Status: Complete + +import Foundation +import OpenAttributeGraphShims +#if OPENSWIFTUI_OPENCOMBINE +import OpenCombine +#else +import Combine +#endif + +// MARK: - AttributeInvalidatingSubscriber + +class AttributeInvalidatingSubscriber where Upstream: Publisher { + typealias Input = Upstream.Output + + typealias Failure = Upstream.Failure + + // MARK: - StateType + + enum StateType { + case subscribed(any Subscription) + case unsubscribed + case complete + } + + weak var host: GraphHost? + + let attribute: WeakAttribute<()> + + var state: StateType + + init(host: GraphHost, attribute: WeakAttribute<()>) { + self.host = host + self.attribute = attribute + self.state = .unsubscribed + } + + private func invalidateAttribute() { + let style: GraphMutation.Style + if !Thread.isMainThread { + Log.runtimeIssues("Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.") + style = .immediate + } else if Update.threadIsUpdating, isLinkedOnOrAfter(.v4) { + Log.runtimeIssues("Publishing changes from within view updates is not allowed, this will cause undefined behavior.") + style = .deferred + } else { + style = .immediate + } + Update.perform { + guard let host else { return } + host.asyncTransaction( + .current, + invalidating: attribute, + style: style + ) + } + } +} + +// MARK: - AttributeInvalidatingSubscriber + Subscriber + +extension AttributeInvalidatingSubscriber: Subscriber { + func receive(subscription: any Subscription) { + guard case .unsubscribed = state else { + subscription.cancel() + return + } + state = .subscribed(subscription) + subscription.request(.unlimited) + } + + func receive(_ input: Input) -> Subscribers.Demand { + if case .subscribed = state { + invalidateAttribute() + } + return .none + } + + func receive(completion: Subscribers.Completion) { + guard case .subscribed = state else { + return + } + state = .complete + invalidateAttribute() + } +} + +// MARK: - AttributeInvalidatingSubscriber + Cancellable + +extension AttributeInvalidatingSubscriber: Cancellable { + func cancel() { + if case let .subscribed(subscription) = state { + subscription.cancel() + } + state = .unsubscribed + } +} + +// MARK: - AttributeInvalidatingSubscriber + CustomCombineIdentifierConvertible + +extension AttributeInvalidatingSubscriber: CustomCombineIdentifierConvertible {} diff --git a/Sources/OpenSwiftUICore/Data/Combine/SubscriptionLifetime.swift b/Sources/OpenSwiftUICore/Data/Combine/SubscriptionLifetime.swift new file mode 100644 index 000000000..97162149e --- /dev/null +++ b/Sources/OpenSwiftUICore/Data/Combine/SubscriptionLifetime.swift @@ -0,0 +1,163 @@ +// +// SubscriptionLifetime.swift +// OpenSwiftUICore +// +// Audited for 6.5.4 +// Status: Complete +// ID: 6C59EBF8CD01332EB851D19EA2F31D6B (SwiftUICore) + +import OpenAttributeGraphShims +#if OPENSWIFTUI_OPENCOMBINE +import OpenCombine +#else +import Combine +#endif + +// MARK: - SubscriptionLifetime + +class SubscriptionLifetime: Cancellable where Upstream: Publisher { + + // MARK: - Connection + + private struct Connection: Subscriber, CustomCombineIdentifierConvertible + where Downstream: Subscriber, Upstream.Failure == Downstream.Failure, Upstream.Output == Downstream.Input { + typealias Input = Downstream.Input + + typealias Failure = Downstream.Failure + + var combineIdentifier: CombineIdentifier = .init() + + weak var parent: SubscriptionLifetime? + + let downstream: Downstream + + let subscriptionID: Int + + init( + parent: SubscriptionLifetime, + downstream: Downstream, + subscriptionID: Int + ) { + self.parent = parent + self.downstream = downstream + self.subscriptionID = subscriptionID + } + + func receive(subscription: any Subscription) { + guard let parent, + parent.shouldAcceptSubscription(subscription, for: subscriptionID) else { + return + } + downstream.receive(subscription: subscription) + subscription.request(.unlimited) + } + + func receive(_ input: Input) -> Subscribers.Demand { + guard let parent, + parent.shouldAcceptValue(for: subscriptionID) else { + return .none + } + _ = downstream.receive(input) + return .none + } + + func receive(completion: Subscribers.Completion) { + guard let parent, + parent.shouldAcceptCompletion(for: subscriptionID) else { + return + } + downstream.receive(completion: completion) + } + } + + // MARK: - StateType + + enum StateType { + case requestedSubscription(to: Upstream, subscriber: AnyCancellable, subscriptionID: Int) + case subscribed(to: Upstream, subscriber: AnyCancellable, subscription: Subscription, subscriptionID: Int) + case uninitialized + } + + var subscriptionID: UniqueSeedGenerator = .init() + + var state: StateType = .uninitialized + + init() { + _openSwiftUIEmptyStub() + } + + deinit { + cancel() + } + + var isUninitialized: Bool { + guard case .uninitialized = state else { + return false + } + return true + } + + private func shouldAcceptSubscription(_ subscription: any Subscription, for subscriptionID: Int) -> Bool { + guard case let .requestedSubscription(oldPublisher, oldSubscriber, oldSubscriptionID) = state, + oldSubscriptionID == subscriptionID else { + subscription.cancel() + return false + } + state = .subscribed( + to: oldPublisher, + subscriber: oldSubscriber, + subscription: subscription, + subscriptionID: subscriptionID + ) + return true + } + + private func shouldAcceptValue(for subscriptionID: Int) -> Bool { + guard case .subscribed = state else { + return false + } + return true + } + + private func shouldAcceptCompletion(for subscriptionID: Int) -> Bool { + guard case let .subscribed(_, _, _, oldSubscriptionID) = state, + subscriptionID == oldSubscriptionID else { + return false + } + state = .uninitialized + return true + } + + func cancel() { + guard case let .subscribed(_, subscriber, subscription, _) = state else { + return + } + subscriber.cancel() + subscription.cancel() + } + + func subscribe( + subscriber: S, + to upstream: Upstream + ) where S: Cancellable, S: Subscriber, Upstream.Failure == S.Failure, Upstream.Output == S.Input { + let shouldRequest: Bool + if case let .subscribed(oldUpstream, oldSubscriber, oldSubscription, _) = state { + if compareValues(oldUpstream, upstream) { + shouldRequest = false + } else { + oldSubscriber.cancel() + oldSubscription.cancel() + shouldRequest = true + } + } else { + shouldRequest = true + } + guard shouldRequest else { + return + } + let id = subscriptionID.generate() + let connection = Connection(parent: self, downstream: subscriber, subscriptionID: id) + state = .requestedSubscription(to: upstream, subscriber: .init(subscriber), subscriptionID: id) + upstream.subscribe(connection) + } +} diff --git a/Sources/OpenSwiftUICore/Data/DynamicProperty/DynamicProperty.swift b/Sources/OpenSwiftUICore/Data/DynamicProperty/DynamicProperty.swift index 445734e90..d26aa1196 100644 --- a/Sources/OpenSwiftUICore/Data/DynamicProperty/DynamicProperty.swift +++ b/Sources/OpenSwiftUICore/Data/DynamicProperty/DynamicProperty.swift @@ -42,9 +42,11 @@ public protocol DynamicProperty { package struct DynamicPropertyBehaviors: OptionSet { package let rawValue: UInt32 + package static let allowsAsync = DynamicPropertyBehaviors(rawValue: 1 << 0) + package static let requiresMainThread = DynamicPropertyBehaviors(rawValue: 1 << 1) - + package init(rawValue: UInt32) { self.rawValue = rawValue } diff --git a/Sources/OpenSwiftUICore/Data/Environment/Environment.swift b/Sources/OpenSwiftUICore/Data/Environment/Environment.swift index ad36f5cac..df4b66211 100644 --- a/Sources/OpenSwiftUICore/Data/Environment/Environment.swift +++ b/Sources/OpenSwiftUICore/Data/Environment/Environment.swift @@ -270,7 +270,7 @@ private struct EnvironmentBox: DynamicPropertyBox { guard case let .keyPath(propertyKeyPath) = property.content else { return false } - let (environment, environmentChanged) = _environment.changedValue(options: []) + let (environment, environmentChanged) = _environment.changedValue() let keyPathChanged = (propertyKeyPath != keyPath) if keyPathChanged { keyPath = propertyKeyPath } let valueChanged: Bool diff --git a/Sources/OpenSwiftUICore/Data/State/ObservableObjectLocation.swift b/Sources/OpenSwiftUICore/Data/State/ObservableObjectLocation.swift deleted file mode 100644 index b85fb33a6..000000000 --- a/Sources/OpenSwiftUICore/Data/State/ObservableObjectLocation.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// ObservableObjectLocation.swift -// OpenSwiftUI -// -// Audited for iOS 15.5 -// Status: Blocked by PropertyList.Element - -#if OPENSWIFTUI_OPENCOMBINE -import OpenCombine -#else -import Combine -#endif -import OpenSwiftUI_SPI - -struct ObservableObjectLocation: Location where Root: ObservableObject { - let base: Root - let keyPath: ReferenceWritableKeyPath - - var wasRead: Bool { - get { true } - set {} - } - func get() -> Value { base[keyPath: keyPath] } - // FIXME - func set(_ value: Value, transaction: Transaction) { - let element = _threadTransactionData().map { Unmanaged.fromOpaque($0).takeRetainedValue() } - let newElement: PropertyList.Element? - if let element = transaction.plist.elements { - // newElement = element.byPrepending(element) - newElement = nil - } else { - newElement = element - } - - let data = _threadTransactionData() - defer { _setThreadTransactionData(data) } - _setThreadTransactionData(newElement.map { Unmanaged.passUnretained($0).toOpaque() }) - base[keyPath: keyPath] = value - } - - // FIXME - static func == (lhs: ObservableObjectLocation, rhs: ObservableObjectLocation) -> Bool { - lhs.base === rhs.base && lhs.keyPath == rhs.keyPath - } -} - -//extension ObservableObjectLocation: TransactionHostProvider { -// // TODO -// var mutationHost: GraphHost? { nil } -//} diff --git a/Sources/OpenSwiftUICore/Data/State/ObservedObject.swift b/Sources/OpenSwiftUICore/Data/State/ObservedObject.swift index 8af2f2bfc..5d01fee11 100644 --- a/Sources/OpenSwiftUICore/Data/State/ObservedObject.swift +++ b/Sources/OpenSwiftUICore/Data/State/ObservedObject.swift @@ -1,16 +1,20 @@ // // ObservedObject.swift -// OpenSwiftUI +// OpenSwiftUICore // -// Audited for iOS 15.5 -// Status: Blocked by DynamicProperty +// Audited for 6.5.4 +// Status: Blocked by addTreeValueSlow +// ID: C212C242BFEB175E53A59438AB276A7C (SwiftUICore) +import OpenAttributeGraphShims #if OPENSWIFTUI_OPENCOMBINE public import OpenCombine #else public import Combine #endif +// MARK: - ObservedObject + /// A property wrapper type that subscribes to an observable object and /// invalidates a view whenever the observable object changes. /// @@ -70,26 +74,42 @@ public import Combine /// its body, wrap the object with the ``Bindable`` property wrapper instead; /// for example, `@Bindable var model: DataModel`. For more information, see /// . +@available(OpenSwiftUI_v1_0, *) +@preconcurrency +@MainActor @propertyWrapper @frozen -public struct ObservedObject where ObjectType: ObservableObject { +public struct ObservedObject: DynamicProperty where ObjectType: ObservableObject { + /// A wrapper of the underlying observable object that can create bindings /// to its properties. + @preconcurrency + @MainActor @dynamicMemberLookup @frozen public struct Wrapper { + let root: ObjectType + init(root: ObjectType) { + self.root = root + } + /// Gets a binding to the value of a specified key path. /// /// - Parameter keyPath: A key path to a specific value. /// /// - Returns: A new binding. - public subscript(dynamicMember keyPath: ReferenceWritableKeyPath) -> Binding { + public subscript( + dynamicMember keyPath: ReferenceWritableKeyPath + ) -> Binding { Binding(root, keyPath: keyPath) } } + @usableFromInline + var _seed = 0 + /// Creates an observed object with an initial value. /// /// This initializer has the same behavior as the ``init(wrappedValue:)`` @@ -142,9 +162,6 @@ public struct ObservedObject where ObjectType: ObservableObject { self.wrappedValue = wrappedValue } - @usableFromInline - var _seed = 0 - /// The underlying value that the observed object references. /// /// The wrapped value property provides primary access to the observed @@ -163,8 +180,6 @@ public struct ObservedObject where ObjectType: ObservableObject { /// When you change a wrapped value, you can access the new value /// immediately. However, OpenSwiftUI updates views that display the value /// asynchronously, so the interface might not update immediately. - @MainActor - @preconcurrency public var wrappedValue: ObjectType /// A projection of the observed object that creates bindings to its @@ -184,25 +199,70 @@ public struct ObservedObject where ObjectType: ObservableObject { /// } /// } /// - @MainActor - @preconcurrency + /// > Important: A `Binding` created by the projected value must only be + /// read from, or written to by the main actor. Failing to do so may result + /// in undefined behavior, or data loss. When this occurs, OpenSwiftUI will + /// issue a runtime warning. In a future release, a crash will occur + /// instead. public var projectedValue: ObservedObject.Wrapper { .init(root: wrappedValue) } } -extension ObservedObject: DynamicProperty { - public static func _makeProperty(in _: inout _DynamicPropertyBuffer, container _: _GraphValue, fieldOffset _: Int, inputs _: inout _GraphInputs) { - // TODO +extension ObservedObject { + public static func _makeProperty( + in buffer: inout _DynamicPropertyBuffer, + container: _GraphValue, + fieldOffset: Int, + inputs: inout _GraphInputs + ) { + let attribute = Attribute(value: ()) + let box = ObservedObjectPropertyBox( + host: .currentHost, + invalidation: WeakAttribute(attribute) + ) + buffer.append(box, fieldOffset: fieldOffset) + // TODO: addTreeValueSlow } +} - public static var _propertyBehaviors: UInt32 { 2 } +extension ObservableObject { + public static var _propertyBehaviors: UInt32 { + DynamicPropertyBehaviors.requiresMainThread.rawValue + } } -extension Binding { - init(_ root: ObjectType, keyPath: ReferenceWritableKeyPath) { - let location = ObservableObjectLocation(base: root, keyPath: keyPath) - let box = LocationBox(location) - self.init(value: location.get(), location: box) +// MARK: - ObservedObjectPropertyBox + +private struct ObservedObjectPropertyBox: DynamicPropertyBox where ObjectType: ObservableObject { + typealias Upstream = ObjectType.ObjectWillChangePublisher + + let subscriber: AttributeInvalidatingSubscriber + + let lifetime: SubscriptionLifetime = .init() + + var seed: Int = .zero + + var lastObject: ObjectType? + + init(host: GraphHost, invalidation: WeakAttribute<()>) { + subscriber = .init(host: host, attribute: invalidation) + } + + typealias Property = ObservedObject + + mutating func update(property: inout Property, phase: ViewPhase) -> Bool { + let object = property.wrappedValue + let shouldForceSubscription = isLinkedOnOrAfter(.v6) ? false : Upstream.self != ObservableObjectPublisher.self + if object !== lastObject || lifetime.isUninitialized || shouldForceSubscription { + lifetime.subscribe(subscriber: subscriber, to: object.objectWillChange) + } + lastObject = object + let changed = subscriber.attribute.changedValue()?.changed ?? false + if changed { + seed &+= 1 + } + property._seed = seed + return changed } } diff --git a/Sources/OpenSwiftUICore/Data/State/State.swift b/Sources/OpenSwiftUICore/Data/State/State.swift index 9a7eae7b8..5b5a0e4aa 100644 --- a/Sources/OpenSwiftUICore/Data/State/State.swift +++ b/Sources/OpenSwiftUICore/Data/State/State.swift @@ -266,7 +266,7 @@ private struct StatePropertyBox: DynamicPropertyBox { signal: signal ) } - let signalChanged = signal.changedValue(options: [])?.changed ?? false + let signalChanged = signal.changedValue()?.changed ?? false property._value = location!.updateValue property._location = location! return (signalChanged ? location!.wasRead : false) || locationChanged diff --git a/Sources/OpenSwiftUICore/Data/State/StateObject.swift b/Sources/OpenSwiftUICore/Data/State/StateObject.swift index f9d53af95..36744ea0a 100644 --- a/Sources/OpenSwiftUICore/Data/State/StateObject.swift +++ b/Sources/OpenSwiftUICore/Data/State/StateObject.swift @@ -276,7 +276,9 @@ extension StateObject: DynamicProperty { // TODO: } - public static var _propertyBehaviors: UInt32 { 2 } + public static var _propertyBehaviors: UInt32 { + DynamicPropertyBehaviors.requiresMainThread.rawValue + } } extension StateObject { diff --git a/Sources/OpenSwiftUICore/Data/Update.swift b/Sources/OpenSwiftUICore/Data/Update.swift index 5e8f72d98..ddd67322e 100644 --- a/Sources/OpenSwiftUICore/Data/Update.swift +++ b/Sources/OpenSwiftUICore/Data/Update.swift @@ -25,10 +25,11 @@ package enum Update { package static var isActive: Bool { depth != 0 } - + + // Audited [6.5.4] @inlinable package static var threadIsUpdating: Bool { - depth < dispatchDepth ? isOwner : false + depth > dispatchDepth ? isOwner : false } @inlinable diff --git a/Sources/OpenSwiftUICore/Util/Data/UniqueSeedGenerator.swift b/Sources/OpenSwiftUICore/Util/Data/UniqueSeedGenerator.swift new file mode 100644 index 000000000..196cc7cec --- /dev/null +++ b/Sources/OpenSwiftUICore/Util/Data/UniqueSeedGenerator.swift @@ -0,0 +1,19 @@ +// +// UniqueSeedGenerator.swift +// OpenSwiftUICore +// +// Audited for 6.5.4 +// Status: Complete + +package struct UniqueSeedGenerator { + var nextID: Int + + package init() { + nextID = .zero + } + + package mutating func generate() -> Int { + defer { nextID += 1 } + return nextID + } +}