Search code examples
swiftconcurrencyuikitswift6mainactor

How to Migrate a UIControl Publisher to Swift 6 Without Concurrency Warnings


I created a Publisher for UIControl based on the following code:

Original Code gist

Source: Medium article

However, when I tried migrating to Swift 6 by enabling strict concurrency checking, I encountered the following context-related warning:

Call to main actor-isolated instance method 'addTarget(_:action:for:)' in a synchronous nonisolated context

To fix this, I made several changes to use MainActor in some parts of the code (marked as ✅ changed). Here's the updated code:

protocol UIControlPublishable: UIControl {}

extension UIControlPublishable {
    
    func publisher(for event: UIControl.Event) -> UIControl.InteractionPublisher<Self> {
        return InteractionPublisher(control: self, event: event)
    }
}

extension UIControl: UIControlPublishable {
    
    @MainActor // ✅ Changed
    class InteractionSubscription<S: Subscriber, C: UIControl>: Subscription where S.Input == C {
        
        private let subscriber: S?
        private weak var control: C?
        private let event: UIControl.Event
        
        init(subscriber: S,
             control: C?,
             event: UIControl.Event) {
            self.subscriber = subscriber
            self.control = control
            self.event = event
            
            self.control?.addTarget(self, action: #selector(handleEvent), for: event)
        }
        
        @objc func handleEvent(_ sender: UIControl) {
            guard let control = self.control else {
                return
            }
            _ = self.subscriber?.receive(control)
        }
        
        nonisolated func request(_ demand: Subscribers.Demand) {}
        
        nonisolated func cancel() {
            Task { @MainActor in  // ✅ Changed
                self.control?.removeTarget(self, action: #selector(handleEvent), for: self.event)
                self.control = nil
            }
        }
    }
    
    enum InteractionPublisherError: Error {
        case objectFoundNil
    }
    
    struct InteractionPublisher<C: UIControl>: Publisher {
        
        typealias Output = C
        typealias Failure = InteractionPublisherError
        
        private weak var control: C?
        private let event: UIControl.Event
        
        init(control: C, event: UIControl.Event) {
            self.control = control
            self.event = event
        }
        
        func receive<S>(subscriber: S) where S : Subscriber, InteractionPublisherError == S.Failure, C == S.Input {
            Task { @MainActor in  // ✅ Changed
                // ❌ Task or actor isolated value cannot be sent; this is an error in the Swift 6 language mode
                guard let control = control else {
                    subscriber.receive(completion: .failure(.objectFoundNil))
                    return
                }
                
                let subscription = InteractionSubscription(
                    subscriber: subscriber,
                    control: control,
                    event: event
                )
                
                subscriber.receive(subscription: subscription)
            }
        }
    }
}

After this update, when trying to access subscriber inside the receive function in the MainActor context, I encountered the following warning:

func receive<S>(subscriber: S) where S : Subscriber, InteractionPublisherError == S.Failure, C == S.Input {
    Task { @MainActor in
          // ❌ Task or actor isolated value cannot be sent; this is an error in the Swift 6 language mode
        guard let control = control else {
            subscriber.receive(completion: .failure(.objectFoundNil))
            return
        }
        
        let subscription = InteractionSubscription(
            subscriber: subscriber,
            control: control,
            event: event
        )
        
        subscriber.receive(subscription: subscription)
    }
}

Even simplifying the code inside the Task to only use the subscriber results in the same issue:

        func receive<S>(subscriber: S) where S : Subscriber, InteractionPublisherError == S.Failure, C == S.Input {
            Task { @MainActor in
                let temp = subscriber
            }
        }

I tried capturing subscriber with Task { @MainActor [subscriber] in }, but the issue persists.

How can I migrate this code to Swift 6 without encountering this issue?


Solution

  • Combine itself hasn't really been migrated to Swift 6 yet, and I can't imagine that it ever will be, due to how it handles concurrency. Combine subscribers can request and receive values on any queue they want using subscribe(on:) and receive(on:), so the closure that things like sink accepts should be @Sendable, but isn't. Expectedly, you would get a crash if you receive(on: DispatchQueue.global()) and then sink on the main actor.

    The error is basically saying that you should not be sending the subscriber to the main actor - it should stay where it is, because the subscriber can decide which queue it is subscribing from with subscribe(on:).

    Since this publisher only makes sense to be used on the main actor, you might as well just use @preconcurrency conformances:

    extension UIControl: UIControlPublishable {
        
        @MainActor
        class InteractionSubscription<S: Subscriber, C: UIControl>: @preconcurrency Subscription, CustomCombineIdentifierConvertible where S.Input == C {
            
            private let subscriber: S?
            private weak var control: C?
            private let event: UIControl.Event
            
            init(subscriber: S,
                 control: C?,
                 event: UIControl.Event) {
                self.subscriber = subscriber
                self.control = control
                self.event = event
                
                self.control?.addTarget(self, action: #selector(handleEvent), for: event)
            }
            
            @objc func handleEvent(_ sender: UIControl) {
                guard let control = self.control else {
                    return
                }
                _ = self.subscriber?.receive(control)
            }
            
            func request(_ demand: Subscribers.Demand) {}
            
            func cancel() {
                self.control?.removeTarget(self, action: #selector(handleEvent), for: self.event)
                self.control = nil
            }
        }
        
        enum InteractionPublisherError: Error {
            case objectFoundNil
        }
        
        @MainActor
        struct InteractionPublisher<C: UIControl>: @preconcurrency Publisher {
            
            typealias Output = C
            typealias Failure = InteractionPublisherError
            
            private weak var control: C?
            private let event: UIControl.Event
            
            init(control: C, event: UIControl.Event) {
                self.control = control
                self.event = event
            }
            
            func receive<S>(subscriber: S) where S : Subscriber, InteractionPublisherError == S.Failure, C == S.Input {
                guard let control = control else {
                    subscriber.receive(completion: .failure(.objectFoundNil))
                    return
                }
                
                let subscription = InteractionSubscription(
                    subscriber: subscriber,
                    control: control,
                    event: event
                )
                
                subscriber.receive(subscription: subscription)
            }
        }
    }