Post

Replies

Boosts

Views

Activity

Issue with Property Wrapper Publisher Being Deallocated Prematurely When Not Stored as a Property
Hello everyone, I've built a @CurrentValue property wrapper that mimics the behavior of @Published, allowing a property to publish values on "did set". I've also created my own assign(to:) implementation that works with @CurrentValue properties, allowing values to be assigned from a publisher to a @CurrentValue property. However, I'm running into an issue. When I use this property wrapper with two classes and the source class (providing the publisher) is not stored as a property, the subscription is deallocated, and values are no longer forwarded. Here's the property wrapper code: @propertyWrapper public struct CurrentValue<Value> { /// A publisher for properties marked with the `@CurrentValue` attribute. public struct Publisher: Combine.Publisher { public typealias Output = Value public typealias Failure = Never /// A subscription that forwards the values from the CurrentValueSubject to the downstream subscriber private class CurrentValueSubscription<S>: Subscription where S: Subscriber, S.Input == Output, S.Failure == Failure { private var subscriber: S? private var currentValueSubject: CurrentValueSubject<S.Input, S.Failure>? private var cancellable: AnyCancellable? init(subscriber: S, publisher: CurrentValue<Value>.Publisher) { self.subscriber = subscriber self.currentValueSubject = publisher.subject } func request(_ demand: Subscribers.Demand) { var demand = demand cancellable = currentValueSubject?.sink { [weak self] value in // We'll continue to emit new values as long as there's demand if let subscriber = self?.subscriber, demand > 0 { demand -= 1 demand += subscriber.receive(value) } else { // If we have no demand, we'll cancel our subscription: self?.subscriber?.receive(completion: .finished) self?.cancel() } } } func cancel() { cancellable = nil subscriber = nil currentValueSubject = nil } } /// A subscription store that holds a reference to all the assign subscribers so we can cancel them when self is deallocated fileprivate final class AssignSubscriptionStore { fileprivate var cancellables: Set<AnyCancellable> = [] } fileprivate let subject: CurrentValueSubject<Value, Never> fileprivate let assignSubscriptionStore: AssignSubscriptionStore = .init() fileprivate var value: Value { get { subject.value } nonmutating set { subject.value = newValue } } init(_ initialValue: Output) { self.subject = .init(initialValue) } public func receive<S>(subscriber: S) where S: Subscriber, Failure == S.Failure, Output == S.Input { let subscription = CurrentValueSubscription(subscriber: subscriber, publisher: self) subscriber.receive(subscription: subscription) } } public var wrappedValue: Value { get { publisher.value } nonmutating set { publisher.value = newValue } } public var projectedValue: Publisher { get { publisher } mutating set { publisher = newValue } } private var publisher: Publisher public init(wrappedValue: Value) { publisher = .init(wrappedValue) } } /// A subscriber that receives values from an upstream publisher and assigns them to a downstream CurrentValue property. private final class AssignSubscriber<Input>: Subscriber, Cancellable { typealias Failure = Never private var receivingSubject: CurrentValueSubject<Input, Never>? private weak var assignSubscriberStore: CurrentValue<Input>.Publisher.AssignSubscriptionStore? init(currentValue: CurrentValue<Input>.Publisher) { self.receivingSubject = currentValue.subject self.assignSubscriberStore = currentValue.assignSubscriptionStore } func receive(subscription: Subscription) { // Hold a reference to the subscription in the downstream publisher // so when it deallocates, the susbcription is automatically cancelled assignSubscriberStore?.cancellables.insert(AnyCancellable(subscription)) subscription.request(.unlimited) } func receive(_ input: Input) -> Subscribers.Demand { receivingSubject?.value = input return .none } func receive(completion: Subscribers.Completion<Never>) { // Nothing to do here } public func cancel() { receivingSubject = nil assignSubscriberStore = nil } } public extension Publisher where Self.Failure == Never { /// Assigns the output of the upstream publisher to a downstream CurrentValue property /// - Parameter currentValue: The CurrentValue property to assign the values to func assign(to currentValue: inout CurrentValue<Self.Output>.Publisher) { let subscriber = AssignSubscriber(currentValue: currentValue) self.subscribe(subscriber) } } Here’s an example demonstrating the issue, where two classes are used: Source, which owns the @CurrentValue property, and Forwarder1, which subscribes to updates from Source: final class Source { @CurrentValue public private(set) var value: Int = 1 func update(value: Int) { self.value = value } } final class Forwarder1 { @CurrentValue public private(set) var value: Int init(source: Source) { self.value = source.value source.$value.dropFirst().assign(to: &$value) // The source is not stored as a property, so the subscription deallocates } func update(value: Int) { self.value = value } } With this setup, if source isn’t retained as a property in Forwarder1, the subscription is deallocated prematurely, and value in Forwarder1 stops receiving updates from Source. However, this doesn’t happen with @Published properties in Combine. Even if source isn’t retained, @Published subscriptions seem to stay active, propagating values as expected. My Questions: What does Combine do internally with @Published properties that prevents the subscription from being deallocated prematurely, even if the publisher source isn’t retained as a property? Is there a recommended approach to address this in my custom property wrapper to achieve similar behavior, ensuring the subscription isn’t lost? Any insights into Combine’s internals or suggestions on how to resolve this would be greatly appreciated. Thank you!
0
4
166
Nov ’24