How to use non-Sendable type in async reducer code?

I am working on an app that uses modern TCA (ReducerProtocol and async code). In one of my action handlers in reducer, I have this:

return .run { send in
  let result = await environment.mediaManager.storePhotoOrVideo(
    fromItemProvider: itemProvider,
    mediaSessionId: mediaSessionId,
    configuration: .default
  )
  await send(.mediaManagerStoredPhoto(result))
}

This is nice and expressive and shows exactly what I want to do. Return type of the environment object function call is Result<URL, MediaManagerError>. I receive itemProvider and process it, receiving the result, and pass it on to another action.

Here is the problem. NSItemProvider is an old type that is not Sendable, and most likely will never be. So I get this warning. Which will be an error further down the road, as Swift keeps strengthening Sendable conformance checking.

Is there a way to use some other API or approach, so that I could keep using non-sendable NSItemProvider here, without any warnings (future errors)?

2 Likes

Since NSItemProvider is part of foundation, does adding @preconcurrency to the Foundation import fix the issue?

@preconcurrency import Foundation

Using @preconcurrency import Foundation doesn’t change anything. The warning remains there.

Just in case, mentioning the versions – I’m using Xcode 14.2 (14C18) which has Swift 5.7.2.

Can you wrap the item provider in an ActorIsolated?

1 Like

Indeed! Thank you :heart_eyes_cat: ActorIsolated is the answer.

Is it the answer though? NSItemProvider is a class, so all ActorIsolated does is just protects access to the NSItemProvider instance itself. It doesn't protect from any potential asynchronous internal changes inside of NSItemProvider. In other words, it doesn't actually make it Sendable.
It silences the warning, but is it not the same as

extension NSItemProvider: @unchecked Sendable {}

?
Or am I wrong?

I think you're correct, but in some cases there may not be much of an alternative, as the internals of NSItemProvider may not be able to be affected by the end user, and some APIs require its usage. That said, if there's a way to not use NSItemProvider and instead use simpler, natively-Sendable data types, that would probably be safer.

Now that I look at ActorIsolated I'm realizing that value should not be public. You should be forced to go through withValue to access the underlying value. That's how LockIsolated was designed, so I think it was just an oversight for ActorIsolated.

1 Like

A few Xcode versions forward, and indeed, the warnings are back.

Xcode 14.2 says:

Xcode 14.3 beta 3 says:

It really does look like there is no good solution to use non-Sendable types in TCA reducers, and what I should do is isolate NSItemProvider handling to an outside service, and interact with it through dependencies (formely known as environment). I can still manage the control flow with the reducer, but I cannot cleanly move the actual NSItemProvider objects (or any other non-Sendable types) through it.

I'm dealing with this same issue regarding NSItemProvider. In my case, its for drag-and-drop on macOS.

Could you share how you achieved it?

I never worked out a solution. We abandoned that entire area of code (not because of Sendability concerns but other reasons).

Fair enough. I was able to create a workaround (basically creating a Sendable wrapper which clones the original). I'll see if I can dig up the code for posterity.

1 Like

Hi David, I am currently dealing with the whole NSItemProvider / Sendable issue. Were you able to find your workaround code?. Very interested to see how you implemented this.

Sure thing. This is the implementation in using, heavily based on a solution given to me in the Slack group.

import Foundation
import FCPXMLDocument
import UniformTypeIdentifiers

/// NSItemProvider is not Sendable, so we need to wrap it in a Sendable type.
public struct ItemProvider: @unchecked Sendable, Equatable {
    public var value: NSItemProvider { _value.clone() }

    private var _value: NSItemProvider

    /// Initialises an ``ItemProvider`` based on a provided `NSItemProvider`.
    ///
    /// - Parameter value: the original provider.
    public init(value: NSItemProvider) {
        self._value = value.clone()
    }
}

extension NSItemProvider {
    /// Clones itself and returns the result.
    ///
    /// - Returns the clone.
    fileprivate func clone() -> NSItemProvider {
        // swiftlint:disable:next force_cast
        copy() as! NSItemProvider
    }
}

extension ItemProvider {
    
    /// Checks if there is an item confirming to
    /// - Parameter type: The type to search for.
    /// - Returns: `true` if one is available.
    func hasCompatibleContentType(to type: UTType) -> Bool {
        findCompatibleContentType(to: type) != nil
    }
    
    /// Finds the first matching compatible content type, if available. If it's a perfect match, it is returned.
    /// Othewise, if it's a dynamic type, it will match if the `preferredFilenameExtension` is set and matches.
    ///
    /// - Parameter type: The type to search for.
    /// - Returns: The matching type, if found, or `nil`.
    public func findCompatibleContentType(to type: UTType) -> UTType? {
        return value.registeredContentTypes.first(where: type.isCompatible(with:))
    }

    /// Asynchronously loads and returns the URL for an item conforming to the specified `UTType`.
    /// - Parameter type: The `UTType` to which the item is expected to conform.
    /// - Returns: The `URL` of the item if it exists and conforms to the specified `UTType`, or `nil` if not.
    public func loadFileURL(forType type: UTType) async throws -> URL? {
        guard let registeredType = findCompatibleContentType(to: type) else { return nil }

        return try await withCheckedThrowingContinuation { continuation in
            value.loadItem(forTypeIdentifier: registeredType.identifier, options: nil) { (item, error) in
                if let error = error {
                    continuation.resume(throwing: error)
                    return
                }

                guard let url = item as? URL else {
                    continuation.resume(returning: nil)
                    return
                }

                // If the URL is a security-scoped resource, make sure to handle it appropriately outside of this function.
                continuation.resume(returning: url)
            }
        }
    }
}
2 Likes

Thank you so much for this comment. It definitely sent me in the right direction for a personal project.

1 Like

That's really helpful, thanks for posting it.