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!