What's the best way to resolve the concurrency warning: "Capture of 'self' with non-sendable type 'LoadingView' in a `@Sendable` closure"?

I'm trying to learn the future of Swift Concurrency and understand the concurrency checking that will eventually be enabled by default in Swift 6.

I turned on concurrency checks by adding the flags -Xfrontend -warn-concurrency -Xfrontend -enable-actor-data-race-checks to see what developing under these checks will be like. I came across a warning about capturing a non-sendable value in a sendable closure, but the best way to resolve the warning was unclear.

struct LoadingView: View {

    @State private var dotCount = 0

    var body: some View {
        Text(String(repeating: ".", count: dotCount + 1))
            .onAppear(perform: animateDots)
    }

    func animateDots() {
        Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in
            dotCount = (dotCount + 1) % 3 // <-- Capture of 'self' with non-sendable type 'LoadingView' in a `@Sendable` closure
        }.fire()
    }
}

I tried a few different things to get this warning to go away:

  1. The most obvious solution given the warning was to make LoadingView conform to Sendable, but that leads to the warning Stored property '_dotCount' of 'Sendable'-conforming struct 'LoadingView' has non-sendable type 'State<Int>'. You can solve that by making State or LoadingView conform to @unchecked Sendable. I suppose that might be okay since the documentation for State says, "You can safely mutate state properties from any thread." But I'm don't know why State doesn't conform to Sendable in the first place.
  2. Another idea I had was to mark LoadingView with MainActor and then wrap the body of my timer closure in Task { @MainActor in ... } This works, but it doesn't seem ideal to have to wrap everything in an extra Task or first LoadingView to be on the MainActor when maybe it doesn't need to be.
  3. My final ideas was to extract the body of the time closure into a method on LoadingView and mark it with @Sendable. Then I can add the method to the capture list of the timer closure and call it without any warnings appearing.

My final code is:

struct LoadingView: View {

    @State private var dotCount = 0

    var body: some View {
        Text(String(repeating: ".", count: dotCount + 1))
            .onAppear {
                animateDots()
            }
    }

    func animateDots() {
        Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [incrementDotCount] _ in
            incrementDotCount()
        }.fire()
    }

    @Sendable
    func incrementDotCount() {
        dotCount = (dotCount + 1) % 3
    }
}

This seems like a good solutions, except I'm not sure how it actually works. Why am I allowed to mark a method of a non-sendable type with @Sendable? It seems like I'm still implicitly capturing self in order to call the @Sendable method. Am I missing something, or is this possibly a bug with how Swift is checking @Sendable closures?

In this case, the best resolution might be to eliminate the use of Timer and onAppear entirely in favor of .task:

struct ContentView: View {
    @State private var dotCount = 0

    var body: some View {
        Text(String(repeating: ".", count: dotCount + 1))
            .task {
                await animateDots()
            }
    }

    private func animateDots() async {
        while !Task.isCancelled {
            dotCount = (dotCount + 1) % 3
            try? await Task.sleep(for: .milliseconds(500))
        }
    }
}
1 Like

Now I'm confused, why is safe to mutate dotCount like this? I tried and there is no warning which I find weird right now (could be because is late night over here :joy:). I don't see what is providing isolation guarantees to that variable, you could start multiple tasks that call this method, which runs in the concurrent pool, and they could be concurrently mutating it.

The task modifier is declared like this:

  @inlinable public func task(
    priority: _Concurrency.TaskPriority = .userInitiated,
    @_inheritActorContext _ action: @escaping @Sendable () async -> Swift.Void
  ) -> some SwiftUI.View {
        modifier(_TaskModifier(priority: priority, action: action))
    }

That @_inheritActorContext attribute means the closure inherits the MainActor context of the enclosing View.

1 Like

Generally with a struct that won't be a problem, because you're only mutating a local copy. I'm not sure how this is suppose to work broadly, but in the case of the @State property wrapper, you're not actually mutating anything directly, you're actually calling the setter on State.wrappedValue. If you command click into the State interface file you can even see that the setter is prefixed with nonmutating. So SwiftUI must saving this value you set somewhere else and applying it before the next render pass. I guess some isolation guarantees are provided by @State.

That's will ensure that the code inside the .task closure will be run on the main thread, but as soon as you call an async function it will yield and the function you called will run on a different thread. You can see this by breakpointing inside the animateDots method.

1 Like

that’s my understanding too.

that’s what I would like to know :slight_smile:

The documentation for @State says:

You can safely mutate state properties from any thread.

2 Likes

@1-877-547-7272 Then why aren't @State properties @Sendable?

State (and SwiftUI) predate Swift concurrency by a couple years, and it hasn't been updated to be fully compliant with the requirements of that system. Hopefully we'll see such an update this summer as Swift 6 releases and all of these warnings become errors.

I'm repeating this clarification everywhere because a lot of people (not necessarily you) are confused by this: the data-race safety warnings will only become errors when you explicitly adopt -swift-version 6. Simply using the Swift 6.0 compiler without any changes to your project settings will not make these warnings become errors in your project.

5 Likes

You're right of course, and I remind others of the same regularly as well. Perhaps the various "this is an error in Swift 6" diagnostics could be updated to something like "this is an error in Swift 6 language mode", so users know there's a difference. Otherwise it certainly looks like all this code will be broken with the Swift 6 compiler this summer.

4 Likes

I was just thinking the same when I came across this error today. I agree this would be a helpful change.

1 Like

Good idea, thanks!

2 Likes

Nice turnaround, thanks! Now if only there was an educational note about language modes (and Xcode exposed them). :thinking:

2 Likes

Thanks! That’ll definitely help clear things up.

Im trying to enable complete concurrency checks in our codebase, and have several warnings that come up that are similar to this:

struct ReportTypeButtonStyle: ButtonStyle {
    @MainActor static let decompressHaptic = UIImpactFeedbackGenerator(style: .light)
    @MainActor static let compressHaptic = UIImpactFeedbackGenerator(style: .heavy)
    
    @State private var isAnimating = false
    
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .background(
                RoundedRectangle(cornerRadius: 28)
                    .fill(Color.andiumPrimaryBackground)
                    .shadow(radius: isAnimating ?  0 : 1, y: isAnimating ? 0 : 2)
            )
            .scaleEffect(isAnimating ? 0.90 : 1)
            .onChange(of: configuration.isPressed) { newValue in
                Task {
                    await configurationChangeHandler(isPressed: newValue) // WARNING: Capture of 'self' with non-sendable type 'ReportTypeButtonStyle' in a `@Sendable` closure
                }
            }
            .animation(.bouncy(duration: 0.3, extraBounce: 0.3), value: isAnimating)
            .onChange(of: isAnimating) { newValue in
                Task { @MainActor in
                    if newValue {
                        Self.compressHaptic.impactOccurred()
                    } else {
                        Self.decompressHaptic.impactOccurred()
                    }
                }
            }
    }
    
    private func configurationChangeHandler(isPressed: Bool) async {
        if isPressed {
            self.isAnimating = true
        } else {
            try? await Task.sleep(for: .seconds(0.1))
            self.isAnimating = false
        }
    }
}

So when I try to pass the @State property "isAnimating" into any async context, the warning appears. Is the best solution just to wait until @State conforms to Sendable? Or am I missing something else I could do in these situations?